Open History to see the last commit
1import type {
2 RelayClientDeviceRecord,
3 RelayDeviceRegistrationRequest,
4} from "@t3tools/contracts/relay";
5import * as Context from "effect/Context";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import { and, eq } from "drizzle-orm";
11import { sql } from "drizzle-orm";
12
13import * as RelayDb from "../db.ts";
14import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
15
16export class DeviceRegistrationPersistenceError extends Schema.TaggedError<DeviceRegistrationPersistenceError>()(
17 "DeviceRegistrationPersistenceError",
18 {
19 userId: Schema.String,
20 deviceId: Schema.String,
21 stage: Schema.Literals(["claim-push-token", "claim-push-to-start-token", "upsert-device"]),
22 cause: Schema.Defect(),
23 },
24) {
25 override get message(): string {
26 return `Failed to persist mobile device registration for ${this.userId}/${this.deviceId} during ${this.stage}.`;
27 }
28}
29
30export class DeviceUnregistrationPersistenceError extends Schema.TaggedError<DeviceUnregistrationPersistenceError>()(
31 "DeviceUnregistrationPersistenceError",
32 {
33 userId: Schema.String,
34 deviceId: Schema.String,
35 stage: Schema.Literals(["delete-live-activity", "delete-device"]),
36 cause: Schema.Defect(),
37 },
38) {
39 override get message(): string {
40 return `Failed to unregister mobile device ${this.userId}/${this.deviceId} during ${this.stage}.`;
41 }
42}
43
44export class DeviceListPersistenceError extends Schema.TaggedError<DeviceListPersistenceError>()(
45 "DeviceListPersistenceError",
46 {
47 userId: Schema.String,
48 cause: Schema.Defect(),
49 },
50) {
51 override get message(): string {
52 return `Failed to list mobile devices for ${this.userId}.`;
53 }
54}
55
56export class Devices extends Context.Service<
57 Devices,
58 {
59 readonly register: (input: {
60 readonly userId: string;
61 readonly registration: RelayDeviceRegistrationRequest;
62 }) => Effect.Effect<void, DeviceRegistrationPersistenceError>;
63 readonly unregister: (input: {
64 readonly userId: string;
65 readonly deviceId: string;
66 }) => Effect.Effect<void, DeviceUnregistrationPersistenceError>;
67 readonly listForUser: (input: {
68 readonly userId: string;
69 }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, DeviceListPersistenceError>;
70 }
71>()("t3code-relay/agentActivity/Devices") {}
72
73const make = Effect.gen(function* () {
74 const db = yield* RelayDb.RelayDb;
75
76 return Devices.of({
77 register: Effect.fn("relay.devices.register")(function* (input) {
78 yield* Effect.annotateCurrentSpan({
79 "relay.mobile.device_id": input.registration.deviceId,
80 });
81 const updatedAt = DateTime.formatIso(yield* DateTime.now);
82 const registration = input.registration;
83
84 // Alchemy's lazy drizzle proxies become Effects only at yield*; Effect.all sends raw proxies into a spinning fiber.
85 if (registration.pushToken) {
86 yield* db
87 .update(relayMobileDevices)
88 .set({ pushToken: null, updatedAt })
89 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
90 .pipe(
91 Effect.mapError(
92 (cause) =>
93 new DeviceRegistrationPersistenceError({
94 userId: input.userId,
95 deviceId: registration.deviceId,
96 stage: "claim-push-token",
97 cause,
98 }),
99 ),
100 );
101 }
102 if (registration.pushToStartToken) {
103 yield* db
104 .update(relayMobileDevices)
105 .set({ pushToStartToken: null, updatedAt })
106 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))
107 .pipe(
108 Effect.mapError(
109 (cause) =>
110 new DeviceRegistrationPersistenceError({
111 userId: input.userId,
112 deviceId: registration.deviceId,
113 stage: "claim-push-to-start-token",
114 cause,
115 }),
116 ),
117 );
118 }
119
120 yield* db
121 .insert(relayMobileDevices)
122 .values({
123 userId: input.userId,
124 deviceId: registration.deviceId,
125 label: registration.label,
126 platform: registration.platform,
127 iosMajorVersion: registration.iosMajorVersion ?? null,
128 androidApiLevel: registration.androidApiLevel ?? null,
129 appVersion: registration.appVersion ?? null,
130 bundleId: registration.bundleId ?? null,
131 apsEnvironment: registration.apsEnvironment ?? null,
132 pushToken: registration.pushToken ?? null,
133 pushToStartToken: registration.pushToStartToken ?? null,
134 preferencesJson: registration.preferences,
135 createdAt: updatedAt,
136 updatedAt,
137 })
138 .onConflictDoUpdate({
139 target: [relayMobileDevices.userId, relayMobileDevices.deviceId],
140 set: {
141 platform: registration.platform,
142 label: registration.label,
143 iosMajorVersion: registration.iosMajorVersion ?? null,
144 androidApiLevel: registration.androidApiLevel ?? null,
145 appVersion: registration.appVersion ?? null,
146 // Older builds must preserve newer routing fields on re-registration.
147 bundleId: sql`coalesce(excluded.bundle_id, ${relayMobileDevices.bundleId})`,
148 apsEnvironment: sql`coalesce(
149 excluded.aps_environment,
150 ${relayMobileDevices.apsEnvironment}
151 )`,
152 pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
153 pushToStartToken: sql`coalesce(
154 excluded.push_to_start_token,
155 ${relayMobileDevices.pushToStartToken}
156 )`,
157 preferencesJson: registration.preferences,
158 updatedAt,
159 },
160 })
161 .pipe(
162 Effect.mapError(
163 (cause) =>
164 new DeviceRegistrationPersistenceError({
165 userId: input.userId,
166 deviceId: registration.deviceId,
167 stage: "upsert-device",
168 cause,
169 }),
170 ),
171 );
172 }),
173 unregister: Effect.fn("relay.devices.unregister")(function* (input) {
174 yield* Effect.annotateCurrentSpan({
175 "relay.mobile.device_id": input.deviceId,
176 });
177 yield* db
178 .delete(relayLiveActivities)
179 .where(
180 and(
181 eq(relayLiveActivities.userId, input.userId),
182 eq(relayLiveActivities.deviceId, input.deviceId),
183 ),
184 )
185 .pipe(
186 Effect.mapError(
187 (cause) =>
188 new DeviceUnregistrationPersistenceError({
189 userId: input.userId,
190 deviceId: input.deviceId,
191 stage: "delete-live-activity",
192 cause,
193 }),
194 ),
195 );
196 yield* db
197 .delete(relayMobileDevices)
198 .where(
199 and(
200 eq(relayMobileDevices.userId, input.userId),
201 eq(relayMobileDevices.deviceId, input.deviceId),
202 ),
203 )
204 .pipe(
205 Effect.mapError(
206 (cause) =>
207 new DeviceUnregistrationPersistenceError({
208 userId: input.userId,
209 deviceId: input.deviceId,
210 stage: "delete-device",
211 cause,
212 }),
213 ),
214 );
215 }),
216 listForUser: Effect.fn("relay.devices.listForUser")(function* (input) {
217 const rows = yield* db
218 .select({
219 deviceId: relayMobileDevices.deviceId,
220 label: relayMobileDevices.label,
221 platform: relayMobileDevices.platform,
222 iosMajorVersion: relayMobileDevices.iosMajorVersion,
223 androidApiLevel: relayMobileDevices.androidApiLevel,
224 appVersion: relayMobileDevices.appVersion,
225 preferences: relayMobileDevices.preferencesJson,
226 updatedAt: relayMobileDevices.updatedAt,
227 })
228 .from(relayMobileDevices)
229 .where(eq(relayMobileDevices.userId, input.userId))
230 .pipe(
231 Effect.mapError(
232 (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }),
233 ),
234 );
235 return rows.map((row) => ({
236 deviceId: row.deviceId,
237 label: row.label,
238 platform: row.platform,
239 iosMajorVersion: row.iosMajorVersion,
240 androidApiLevel: row.androidApiLevel,
241 appVersion: row.appVersion,
242 notifications: {
243 enabled: row.preferences.notificationsEnabled,
244 notifyOnApproval: row.preferences.notifyOnApproval,
245 notifyOnInput: row.preferences.notifyOnInput,
246 notifyOnCompletion: row.preferences.notifyOnCompletion,
247 notifyOnFailure: row.preferences.notifyOnFailure,
248 },
249 liveActivities: {
250 enabled: row.preferences.liveActivitiesEnabled,
251 },
252 updatedAt: row.updatedAt,
253 }));
254 }),
255 });
256});
257
258export const layer = Layer.effect(Devices, make);