authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:02:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:02:59-07:00
loga1b727f62d56edd0e43c1d0a74142626fc7673c5
tree6694c4e373c73ab263d96de4ea8fa8387e4de8b6
parentfdc1e8b3a26d9a2b09e9b814e1a02be77ebb68a1
signature Signed by PGP key B5690EEEBB952194

[codex] Structure relay device persistence errors (#3344)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 261 insertions(+), 119 deletions(-)

infra/relay/src/agentActivity/Devices.test.ts+78
......@@ -223,4 +223,82 @@ describe("Devices", () => {
223223 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
224224 );
225225 });
226
227 it.effect("identifies the failed device registration stage", () => {
228 const cause = new Error("push-token claim failed");
229 const fakeDb = {
230 update: () => ({
231 set: (values: Record<string, unknown>) => ({
232 where: () => ("pushToken" in values ? Effect.fail(cause) : Effect.void),
233 }),
234 }),
235 } as unknown as RelayDb.RelayDb["Service"];
236
237 return Effect.gen(function* () {
238 const devices = yield* Devices.Devices;
239 const error = yield* devices.register({ userId: "user-2", registration }).pipe(Effect.flip);
240
241 expect(error).toMatchObject({
242 userId: "user-2",
243 deviceId: "device-1",
244 stage: "claim-push-token",
245 });
246 expect(error.cause).toBe(cause);
247 expect(error.message).toBe(
248 "Failed to persist mobile device registration for user-2/device-1 during claim-push-token.",
249 );
250 }).pipe(
251 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
252 );
253 });
254
255 it.effect("identifies the failed device unregistration stage", () => {
256 const cause = new Error("live activity delete failed");
257 const fakeDb = {
258 delete: (table: unknown) => ({
259 where: () => (table === relayLiveActivities ? Effect.fail(cause) : Effect.void),
260 }),
261 } as unknown as RelayDb.RelayDb["Service"];
262
263 return Effect.gen(function* () {
264 const devices = yield* Devices.Devices;
265 const error = yield* devices
266 .unregister({ userId: "user-2", deviceId: "device-1" })
267 .pipe(Effect.flip);
268
269 expect(error).toMatchObject({
270 userId: "user-2",
271 deviceId: "device-1",
272 stage: "delete-live-activity",
273 });
274 expect(error.cause).toBe(cause);
275 expect(error.message).toBe(
276 "Failed to unregister mobile device user-2/device-1 during delete-live-activity.",
277 );
278 }).pipe(
279 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
280 );
281 });
282
283 it.effect("attaches the user to device list failures", () => {
284 const cause = new Error("device list failed");
285 const fakeDb = {
286 select: () => ({
287 from: () => ({
288 where: () => Effect.fail(cause),
289 }),
290 }),
291 } as unknown as RelayDb.RelayDb["Service"];
292
293 return Effect.gen(function* () {
294 const devices = yield* Devices.Devices;
295 const error = yield* devices.listForUser({ userId: "user-2" }).pipe(Effect.flip);
296
297 expect(error).toMatchObject({ userId: "user-2" });
298 expect(error.cause).toBe(cause);
299 expect(error.message).toBe("Failed to list mobile devices for user-2.");
300 }).pipe(
301 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
302 );
303 });
226304});
infra/relay/src/agentActivity/Devices.ts+183-119
......@@ -15,28 +15,41 @@ import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.t
1515
1616export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass<DeviceRegistrationPersistenceError>()(
1717 "DeviceRegistrationPersistenceError",
18 { cause: Schema.Defect() },
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 },
1924) {
2025 override get message(): string {
21 return "Failed to persist mobile device registration";
26 return `Failed to persist mobile device registration for ${this.userId}/${this.deviceId} during ${this.stage}.`;
2227 }
2328}
2429
2530export class DeviceUnregistrationPersistenceError extends Schema.TaggedErrorClass<DeviceUnregistrationPersistenceError>()(
2631 "DeviceUnregistrationPersistenceError",
27 { cause: Schema.Defect() },
32 {
33 userId: Schema.String,
34 deviceId: Schema.String,
35 stage: Schema.Literals(["delete-live-activity", "delete-device"]),
36 cause: Schema.Defect(),
37 },
2838) {
2939 override get message(): string {
30 return "Failed to unregister mobile device";
40 return `Failed to unregister mobile device ${this.userId}/${this.deviceId} during ${this.stage}.`;
3141 }
3242}
3343
3444export class DeviceListPersistenceError extends Schema.TaggedErrorClass<DeviceListPersistenceError>()(
3545 "DeviceListPersistenceError",
36 { cause: Schema.Defect() },
46 {
47 userId: Schema.String,
48 cause: Schema.Defect(),
49 },
3750) {
3851 override get message(): string {
39 return "Failed to list mobile devices";
52 return `Failed to list mobile devices for ${this.userId}.`;
4053 }
4154}
4255
......@@ -61,130 +74,181 @@ export const make = Effect.gen(function* () {
6174 const db = yield* RelayDb.RelayDb;
6275
6376 return Devices.of({
64 register: Effect.fn("relay.devices.register")(
65 function* (input) {
66 yield* Effect.annotateCurrentSpan({
67 "relay.mobile.device_id": input.registration.deviceId,
68 });
69 const updatedAt = DateTime.formatIso(yield* DateTime.now);
70 const registration = input.registration;
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;
7183
72 yield* Effect.all(
73 [
74 registration.pushToken
75 ? db
76 .update(relayMobileDevices)
77 .set({ pushToken: null, updatedAt })
78 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
79 : Effect.void,
80 registration.pushToStartToken
81 ? db
82 .update(relayMobileDevices)
83 .set({ pushToStartToken: null, updatedAt })
84 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))
85 : Effect.void,
86 ],
87 { concurrency: 2, discard: true },
88 );
84 yield* Effect.all(
85 [
86 registration.pushToken
87 ? db
88 .update(relayMobileDevices)
89 .set({ pushToken: null, updatedAt })
90 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
91 .pipe(
92 Effect.mapError(
93 (cause) =>
94 new DeviceRegistrationPersistenceError({
95 userId: input.userId,
96 deviceId: registration.deviceId,
97 stage: "claim-push-token",
98 cause,
99 }),
100 ),
101 )
102 : Effect.void,
103 registration.pushToStartToken
104 ? db
105 .update(relayMobileDevices)
106 .set({ pushToStartToken: null, updatedAt })
107 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))
108 .pipe(
109 Effect.mapError(
110 (cause) =>
111 new DeviceRegistrationPersistenceError({
112 userId: input.userId,
113 deviceId: registration.deviceId,
114 stage: "claim-push-to-start-token",
115 cause,
116 }),
117 ),
118 )
119 : Effect.void,
120 ],
121 { discard: true },
122 );
89123
90 yield* db
91 .insert(relayMobileDevices)
92 .values({
93 userId: input.userId,
94 deviceId: registration.deviceId,
95 label: registration.label,
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,
132 appVersion: registration.appVersion ?? null,
133 pushToken: registration.pushToken ?? null,
134 pushToStartToken: registration.pushToStartToken ?? null,
135 preferencesJson: registration.preferences,
136 createdAt: updatedAt,
137 updatedAt,
138 })
139 .onConflictDoUpdate({
140 target: [relayMobileDevices.userId, relayMobileDevices.deviceId],
141 set: {
96142 platform: registration.platform,
143 label: registration.label,
97144 iosMajorVersion: registration.iosMajorVersion,
98145 appVersion: registration.appVersion ?? null,
99 pushToken: registration.pushToken ?? null,
100 pushToStartToken: registration.pushToStartToken ?? null,
101 preferencesJson: registration.preferences,
102 createdAt: updatedAt,
103 updatedAt,
104 })
105 .onConflictDoUpdate({
106 target: [relayMobileDevices.userId, relayMobileDevices.deviceId],
107 set: {
108 platform: registration.platform,
109 label: registration.label,
110 iosMajorVersion: registration.iosMajorVersion,
111 appVersion: registration.appVersion ?? null,
112 pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
113 pushToStartToken: sql`coalesce(
146 pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
147 pushToStartToken: sql`coalesce(
114148 excluded.push_to_start_token,
115149 ${relayMobileDevices.pushToStartToken}
116150 )`,
117 preferencesJson: registration.preferences,
118 updatedAt,
119 },
120 });
121 },
122 Effect.mapError((cause) => new DeviceRegistrationPersistenceError({ cause })),
123 ),
124 unregister: Effect.fn("relay.devices.unregister")(
125 function* (input) {
126 yield* Effect.annotateCurrentSpan({
127 "relay.mobile.device_id": input.deviceId,
128 });
129 yield* Effect.all(
130 [
131 db
132 .delete(relayLiveActivities)
133 .where(
134 and(
135 eq(relayLiveActivities.userId, input.userId),
136 eq(relayLiveActivities.deviceId, input.deviceId),
137 ),
151 preferencesJson: registration.preferences,
152 updatedAt,
153 },
154 })
155 .pipe(
156 Effect.mapError(
157 (cause) =>
158 new DeviceRegistrationPersistenceError({
159 userId: input.userId,
160 deviceId: registration.deviceId,
161 stage: "upsert-device",
162 cause,
163 }),
164 ),
165 );
166 }),
167 unregister: Effect.fn("relay.devices.unregister")(function* (input) {
168 yield* Effect.annotateCurrentSpan({
169 "relay.mobile.device_id": input.deviceId,
170 });
171 yield* Effect.all(
172 [
173 db
174 .delete(relayLiveActivities)
175 .where(
176 and(
177 eq(relayLiveActivities.userId, input.userId),
178 eq(relayLiveActivities.deviceId, input.deviceId),
138179 ),
139 db
140 .delete(relayMobileDevices)
141 .where(
142 and(
143 eq(relayMobileDevices.userId, input.userId),
144 eq(relayMobileDevices.deviceId, input.deviceId),
145 ),
180 )
181 .pipe(
182 Effect.mapError(
183 (cause) =>
184 new DeviceUnregistrationPersistenceError({
185 userId: input.userId,
186 deviceId: input.deviceId,
187 stage: "delete-live-activity",
188 cause,
189 }),
146190 ),
147 ],
148 { concurrency: 2, discard: true },
191 ),
192 db
193 .delete(relayMobileDevices)
194 .where(
195 and(
196 eq(relayMobileDevices.userId, input.userId),
197 eq(relayMobileDevices.deviceId, input.deviceId),
198 ),
199 )
200 .pipe(
201 Effect.mapError(
202 (cause) =>
203 new DeviceUnregistrationPersistenceError({
204 userId: input.userId,
205 deviceId: input.deviceId,
206 stage: "delete-device",
207 cause,
208 }),
209 ),
210 ),
211 ],
212 { discard: true },
213 );
214 }),
215 listForUser: Effect.fn("relay.devices.listForUser")(function* (input) {
216 const rows = yield* db
217 .select({
218 deviceId: relayMobileDevices.deviceId,
219 label: relayMobileDevices.label,
220 platform: relayMobileDevices.platform,
221 iosMajorVersion: relayMobileDevices.iosMajorVersion,
222 appVersion: relayMobileDevices.appVersion,
223 preferences: relayMobileDevices.preferencesJson,
224 updatedAt: relayMobileDevices.updatedAt,
225 })
226 .from(relayMobileDevices)
227 .where(eq(relayMobileDevices.userId, input.userId))
228 .pipe(
229 Effect.mapError(
230 (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }),
231 ),
149232 );
150 },
151 Effect.mapError((cause) => new DeviceUnregistrationPersistenceError({ cause })),
152 ),
153 listForUser: Effect.fn("relay.devices.listForUser")(
154 function* (input) {
155 const rows = yield* db
156 .select({
157 deviceId: relayMobileDevices.deviceId,
158 label: relayMobileDevices.label,
159 platform: relayMobileDevices.platform,
160 iosMajorVersion: relayMobileDevices.iosMajorVersion,
161 appVersion: relayMobileDevices.appVersion,
162 preferences: relayMobileDevices.preferencesJson,
163 updatedAt: relayMobileDevices.updatedAt,
164 })
165 .from(relayMobileDevices)
166 .where(eq(relayMobileDevices.userId, input.userId));
167 return rows.map((row) => ({
168 deviceId: row.deviceId,
169 label: row.label,
170 platform: row.platform,
171 iosMajorVersion: row.iosMajorVersion,
172 appVersion: row.appVersion,
173 notifications: {
174 enabled: row.preferences.notificationsEnabled,
175 notifyOnApproval: row.preferences.notifyOnApproval,
176 notifyOnInput: row.preferences.notifyOnInput,
177 notifyOnCompletion: row.preferences.notifyOnCompletion,
178 notifyOnFailure: row.preferences.notifyOnFailure,
179 },
180 liveActivities: {
181 enabled: row.preferences.liveActivitiesEnabled,
182 },
183 updatedAt: row.updatedAt,
184 }));
185 },
186 Effect.mapError((cause) => new DeviceListPersistenceError({ cause })),
187 ),
233 return rows.map((row) => ({
234 deviceId: row.deviceId,
235 label: row.label,
236 platform: row.platform,
237 iosMajorVersion: row.iosMajorVersion,
238 appVersion: row.appVersion,
239 notifications: {
240 enabled: row.preferences.notificationsEnabled,
241 notifyOnApproval: row.preferences.notifyOnApproval,
242 notifyOnInput: row.preferences.notifyOnInput,
243 notifyOnCompletion: row.preferences.notifyOnCompletion,
244 notifyOnFailure: row.preferences.notifyOnFailure,
245 },
246 liveActivities: {
247 enabled: row.preferences.liveActivitiesEnabled,
248 },
249 updatedAt: row.updatedAt,
250 }));
251 }),
188252 });
189253});
190254