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", () => {...@@ -223,4 +223,82 @@ describe("Devices", () => {
223 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),223 Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
224 );224 );
225 });225 });
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 });
226});304});
infra/relay/src/agentActivity/Devices.ts+183-119
...@@ -15,28 +15,41 @@ import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.t...@@ -15,28 +15,41 @@ import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.t
1515
16export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass<DeviceRegistrationPersistenceError>()(16export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass<DeviceRegistrationPersistenceError>()(
17 "DeviceRegistrationPersistenceError",17 "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 },
19) {24) {
20 override get message(): string {25 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}.`;
22 }27 }
23}28}
2429
25export class DeviceUnregistrationPersistenceError extends Schema.TaggedErrorClass<DeviceUnregistrationPersistenceError>()(30export class DeviceUnregistrationPersistenceError extends Schema.TaggedErrorClass<DeviceUnregistrationPersistenceError>()(
26 "DeviceUnregistrationPersistenceError",31 "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 },
28) {38) {
29 override get message(): string {39 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}.`;
31 }41 }
32}42}
3343
34export class DeviceListPersistenceError extends Schema.TaggedErrorClass<DeviceListPersistenceError>()(44export class DeviceListPersistenceError extends Schema.TaggedErrorClass<DeviceListPersistenceError>()(
35 "DeviceListPersistenceError",45 "DeviceListPersistenceError",
36 { cause: Schema.Defect() },46 {
47 userId: Schema.String,
48 cause: Schema.Defect(),
49 },
37) {50) {
38 override get message(): string {51 override get message(): string {
39 return "Failed to list mobile devices";52 return `Failed to list mobile devices for ${this.userId}.`;
40 }53 }
41}54}
4255
...@@ -61,130 +74,181 @@ export const make = Effect.gen(function* () {...@@ -61,130 +74,181 @@ export const make = Effect.gen(function* () {
61 const db = yield* RelayDb.RelayDb;74 const db = yield* RelayDb.RelayDb;
6275
63 return Devices.of({76 return Devices.of({
64 register: Effect.fn("relay.devices.register")(77 register: Effect.fn("relay.devices.register")(function* (input) {
65 function* (input) {78 yield* Effect.annotateCurrentSpan({
66 yield* Effect.annotateCurrentSpan({79 "relay.mobile.device_id": input.registration.deviceId,
67 "relay.mobile.device_id": input.registration.deviceId,80 });
68 });81 const updatedAt = DateTime.formatIso(yield* DateTime.now);
69 const updatedAt = DateTime.formatIso(yield* DateTime.now);82 const registration = input.registration;
70 const registration = input.registration;
7183
72 yield* Effect.all(84 yield* Effect.all(
73 [85 [
74 registration.pushToken86 registration.pushToken
75 ? db87 ? db
76 .update(relayMobileDevices)88 .update(relayMobileDevices)
77 .set({ pushToken: null, updatedAt })89 .set({ pushToken: null, updatedAt })
78 .where(eq(relayMobileDevices.pushToken, registration.pushToken))90 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
79 : Effect.void,91 .pipe(
80 registration.pushToStartToken92 Effect.mapError(
81 ? db93 (cause) =>
82 .update(relayMobileDevices)94 new DeviceRegistrationPersistenceError({
83 .set({ pushToStartToken: null, updatedAt })95 userId: input.userId,
84 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))96 deviceId: registration.deviceId,
85 : Effect.void,97 stage: "claim-push-token",
86 ],98 cause,
87 { concurrency: 2, discard: true },99 }),
88 );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* db124 yield* db
91 .insert(relayMobileDevices)125 .insert(relayMobileDevices)
92 .values({126 .values({
93 userId: input.userId,127 userId: input.userId,
94 deviceId: registration.deviceId,128 deviceId: registration.deviceId,
95 label: registration.label,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: {
96 platform: registration.platform,142 platform: registration.platform,
143 label: registration.label,
97 iosMajorVersion: registration.iosMajorVersion,144 iosMajorVersion: registration.iosMajorVersion,
98 appVersion: registration.appVersion ?? null,145 appVersion: registration.appVersion ?? null,
99 pushToken: registration.pushToken ?? null,146 pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
100 pushToStartToken: registration.pushToStartToken ?? null,147 pushToStartToken: sql`coalesce(
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(
114 excluded.push_to_start_token,148 excluded.push_to_start_token,
115 ${relayMobileDevices.pushToStartToken}149 ${relayMobileDevices.pushToStartToken}
116 )`,150 )`,
117 preferencesJson: registration.preferences,151 preferencesJson: registration.preferences,
118 updatedAt,152 updatedAt,
119 },153 },
120 });154 })
121 },155 .pipe(
122 Effect.mapError((cause) => new DeviceRegistrationPersistenceError({ cause })),156 Effect.mapError(
123 ),157 (cause) =>
124 unregister: Effect.fn("relay.devices.unregister")(158 new DeviceRegistrationPersistenceError({
125 function* (input) {159 userId: input.userId,
126 yield* Effect.annotateCurrentSpan({160 deviceId: registration.deviceId,
127 "relay.mobile.device_id": input.deviceId,161 stage: "upsert-device",
128 });162 cause,
129 yield* Effect.all(163 }),
130 [164 ),
131 db165 );
132 .delete(relayLiveActivities)166 }),
133 .where(167 unregister: Effect.fn("relay.devices.unregister")(function* (input) {
134 and(168 yield* Effect.annotateCurrentSpan({
135 eq(relayLiveActivities.userId, input.userId),169 "relay.mobile.device_id": input.deviceId,
136 eq(relayLiveActivities.deviceId, input.deviceId),170 });
137 ),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),
138 ),179 ),
139 db180 )
140 .delete(relayMobileDevices)181 .pipe(
141 .where(182 Effect.mapError(
142 and(183 (cause) =>
143 eq(relayMobileDevices.userId, input.userId),184 new DeviceUnregistrationPersistenceError({
144 eq(relayMobileDevices.deviceId, input.deviceId),185 userId: input.userId,
145 ),186 deviceId: input.deviceId,
187 stage: "delete-live-activity",
188 cause,
189 }),
146 ),190 ),
147 ],191 ),
148 { concurrency: 2, discard: true },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 ),
149 );232 );
150 },233 return rows.map((row) => ({
151 Effect.mapError((cause) => new DeviceUnregistrationPersistenceError({ cause })),234 deviceId: row.deviceId,
152 ),235 label: row.label,
153 listForUser: Effect.fn("relay.devices.listForUser")(236 platform: row.platform,
154 function* (input) {237 iosMajorVersion: row.iosMajorVersion,
155 const rows = yield* db238 appVersion: row.appVersion,
156 .select({239 notifications: {
157 deviceId: relayMobileDevices.deviceId,240 enabled: row.preferences.notificationsEnabled,
158 label: relayMobileDevices.label,241 notifyOnApproval: row.preferences.notifyOnApproval,
159 platform: relayMobileDevices.platform,242 notifyOnInput: row.preferences.notifyOnInput,
160 iosMajorVersion: relayMobileDevices.iosMajorVersion,243 notifyOnCompletion: row.preferences.notifyOnCompletion,
161 appVersion: relayMobileDevices.appVersion,244 notifyOnFailure: row.preferences.notifyOnFailure,
162 preferences: relayMobileDevices.preferencesJson,245 },
163 updatedAt: relayMobileDevices.updatedAt,246 liveActivities: {
164 })247 enabled: row.preferences.liveActivitiesEnabled,
165 .from(relayMobileDevices)248 },
166 .where(eq(relayMobileDevices.userId, input.userId));249 updatedAt: row.updatedAt,
167 return rows.map((row) => ({250 }));
168 deviceId: row.deviceId,251 }),
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 ),
188 });252 });
189});253});
190254