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
73export const 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 // The drizzle handle is alchemy's lazy proxy chain: it only becomes a
85 // real Effect when consumed via `yield*`. Handing it to Effect.all sends
86 // the raw Proxy into the fiber runtime, which spins the isolate at 100%
87 // CPU (registrations then hang until the client aborts) — keep every db
88 // chain directly yielded.
89 if (registration.pushToken) {
90 yield* db
91 .update(relayMobileDevices)
92 .set({ pushToken: null, updatedAt })
93 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
94 .pipe(
95 Effect.mapError(
96 (cause) =>
97 new DeviceRegistrationPersistenceError({
98 userId: input.userId,
99 deviceId: registration.deviceId,
100 stage: "claim-push-token",
101 cause,
102 }),
103 ),
104 );
105 }
106 if (registration.pushToStartToken) {
107 yield* db
108 .update(relayMobileDevices)
109 .set({ pushToStartToken: null, updatedAt })
110 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))
111 .pipe(
112 Effect.mapError(
113 (cause) =>
114 new DeviceRegistrationPersistenceError({
115 userId: input.userId,
116 deviceId: registration.deviceId,
117 stage: "claim-push-to-start-token",
118 cause,
119 }),
120 ),
121 );
122 }
123
124 yield* db
125 .insert(relayMobileDevices)
126 .values({
127 userId: input.userId,
128 deviceId: registration.deviceId,
129 label: registration.label,
130 platform: registration.platform,
131 iosMajorVersion: registration.iosMajorVersion ?? null,
132 androidApiLevel: registration.androidApiLevel ?? null,
133 appVersion: registration.appVersion ?? null,
134 bundleId: registration.bundleId ?? null,
135 apsEnvironment: registration.apsEnvironment ?? null,
136 pushToken: registration.pushToken ?? null,
137 pushToStartToken: registration.pushToStartToken ?? null,
138 preferencesJson: registration.preferences,
139 createdAt: updatedAt,
140 updatedAt,
141 })
142 .onConflictDoUpdate({
143 target: [relayMobileDevices.userId, relayMobileDevices.deviceId],
144 set: {
145 platform: registration.platform,
146 label: registration.label,
147 iosMajorVersion: registration.iosMajorVersion ?? null,
148 androidApiLevel: registration.androidApiLevel ?? null,
149 appVersion: registration.appVersion ?? null,
150 // Preserve routing from newer app builds when an older build
151 // re-registers without these fields.
152 bundleId: sql`coalesce(excluded.bundle_id, ${relayMobileDevices.bundleId})`,
153 apsEnvironment: sql`coalesce(
154 excluded.aps_environment,
155 ${relayMobileDevices.apsEnvironment}
156 )`,
157 pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
158 pushToStartToken: sql`coalesce(
159 excluded.push_to_start_token,
160 ${relayMobileDevices.pushToStartToken}
161 )`,
162 preferencesJson: registration.preferences,
163 updatedAt,
164 },
165 })
166 .pipe(
167 Effect.mapError(
168 (cause) =>
169 new DeviceRegistrationPersistenceError({
170 userId: input.userId,
171 deviceId: registration.deviceId,
172 stage: "upsert-device",
173 cause,
174 }),
175 ),
176 );
177 }),
178 unregister: Effect.fn("relay.devices.unregister")(function* (input) {
179 yield* Effect.annotateCurrentSpan({
180 "relay.mobile.device_id": input.deviceId,
181 });
182 // Same proxy-chain constraint as register above: db chains must be
183 // consumed via `yield*`, never passed to Effect.all.
184 yield* db
185 .delete(relayLiveActivities)
186 .where(
187 and(
188 eq(relayLiveActivities.userId, input.userId),
189 eq(relayLiveActivities.deviceId, input.deviceId),
190 ),
191 )
192 .pipe(
193 Effect.mapError(
194 (cause) =>
195 new DeviceUnregistrationPersistenceError({
196 userId: input.userId,
197 deviceId: input.deviceId,
198 stage: "delete-live-activity",
199 cause,
200 }),
201 ),
202 );
203 yield* db
204 .delete(relayMobileDevices)
205 .where(
206 and(
207 eq(relayMobileDevices.userId, input.userId),
208 eq(relayMobileDevices.deviceId, input.deviceId),
209 ),
210 )
211 .pipe(
212 Effect.mapError(
213 (cause) =>
214 new DeviceUnregistrationPersistenceError({
215 userId: input.userId,
216 deviceId: input.deviceId,
217 stage: "delete-device",
218 cause,
219 }),
220 ),
221 );
222 }),
223 listForUser: Effect.fn("relay.devices.listForUser")(function* (input) {
224 const rows = yield* db
225 .select({
226 deviceId: relayMobileDevices.deviceId,
227 label: relayMobileDevices.label,
228 platform: relayMobileDevices.platform,
229 iosMajorVersion: relayMobileDevices.iosMajorVersion,
230 androidApiLevel: relayMobileDevices.androidApiLevel,
231 appVersion: relayMobileDevices.appVersion,
232 preferences: relayMobileDevices.preferencesJson,
233 updatedAt: relayMobileDevices.updatedAt,
234 })
235 .from(relayMobileDevices)
236 .where(eq(relayMobileDevices.userId, input.userId))
237 .pipe(
238 Effect.mapError(
239 (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }),
240 ),
241 );
242 return rows.map((row) => ({
243 deviceId: row.deviceId,
244 label: row.label,
245 platform: row.platform,
246 iosMajorVersion: row.iosMajorVersion,
247 androidApiLevel: row.androidApiLevel,
248 appVersion: row.appVersion,
249 notifications: {
250 enabled: row.preferences.notificationsEnabled,
251 notifyOnApproval: row.preferences.notifyOnApproval,
252 notifyOnInput: row.preferences.notifyOnInput,
253 notifyOnCompletion: row.preferences.notifyOnCompletion,
254 notifyOnFailure: row.preferences.notifyOnFailure,
255 },
256 liveActivities: {
257 enabled: row.preferences.liveActivitiesEnabled,
258 },
259 updatedAt: row.updatedAt,
260 }));
261 }),
262 });
263});
264
265export const layer = Layer.effect(Devices, make);