@remit/drizzle-service 0.0.79 → 0.0.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.79",
3
+ "version": "0.0.81",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The two data migrations this release carries, against a database written by
3
+ * the build before it.
4
+ *
5
+ * Both exist because a predicate changed meaning under rows already on disk.
6
+ * `carriesForeignUid` reads `originalUid` without consulting `status`, so a
7
+ * settled row that a pre-#1217 build left one on answers "this uid belongs to
8
+ * another folder" forever — and Empty Trash refuses to remove it after the
9
+ * server copy is gone (reader#1230). And the give-up signal moved from
10
+ * `active` + `failed` to its own value, so an abandoned delete written before
11
+ * the upgrade would go quiet: the row stays where it was handed back and the
12
+ * user is told nothing.
13
+ *
14
+ * Applied through the shipped journal rather than a schema pushed from the
15
+ * table objects, because a data migration that never runs is exactly the
16
+ * failure being pinned.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import { after, before, describe, test } from "node:test";
21
+ import Database from "better-sqlite3";
22
+ import {
23
+ applyMigration,
24
+ migrationJournal,
25
+ } from "../test-shipped-sqlite-schema.js";
26
+
27
+ const STALE_ORIGINAL_UID = "msg-stale-original-uid";
28
+ const IN_FLIGHT = "msg-in-flight";
29
+ const GAVE_UP = "msg-gave-up";
30
+ const RETRYING_DELETE = "msg-retrying-delete";
31
+
32
+ /** The migration that clears a settled row's stale `original_uid`. */
33
+ const ORIGINAL_UID_BACKFILL = 24;
34
+
35
+ /** The migration that carries an abandoned delete onto the give-up value. */
36
+ const GIVE_UP_BACKFILL = 25;
37
+
38
+ interface MessageRow {
39
+ message_id: string;
40
+ status: string;
41
+ sync_status: string;
42
+ abandoned_mutation: string;
43
+ original_uid: number | null;
44
+ original_mailbox_id: string | null;
45
+ }
46
+
47
+ describe("the placement backfills carry a pre-upgrade database forward", () => {
48
+ let sqlite: Database.Database;
49
+
50
+ const rowFor = (messageId: string): MessageRow =>
51
+ sqlite
52
+ .prepare("SELECT * FROM message WHERE message_id = ?")
53
+ .get(messageId) as MessageRow;
54
+
55
+ before(() => {
56
+ sqlite = new Database(":memory:");
57
+ const entries = migrationJournal();
58
+
59
+ // Everything the previous release shipped, and nothing this one adds.
60
+ for (const entry of entries.filter(
61
+ (candidate) => candidate.idx < ORIGINAL_UID_BACKFILL,
62
+ )) {
63
+ applyMigration(sqlite, entry.tag);
64
+ }
65
+
66
+ const insert = sqlite.prepare(
67
+ `INSERT INTO message (
68
+ message_id, mailbox_id, uid, sequence_number, rfc822_size,
69
+ internal_date, envelope_id, root_body_part_id,
70
+ status, sync_status, original_mailbox_id, original_uid,
71
+ created_at, updated_at
72
+ ) VALUES (?, ?, ?, 1, 100, 0, ?, ?, ?, ?, ?, ?, 0, 0)`,
73
+ );
74
+
75
+ // Settled, with the `original_uid` a pre-#1217 `updateUid` left behind —
76
+ // and Trash handing back the source's own number, which on a young
77
+ // account is the common case rather than the exotic one.
78
+ insert.run(
79
+ STALE_ORIGINAL_UID,
80
+ "mbx-trash",
81
+ 10,
82
+ `env-${STALE_ORIGINAL_UID}`,
83
+ `bp-${STALE_ORIGINAL_UID}`,
84
+ "active",
85
+ "synced",
86
+ "mbx-inbox",
87
+ 10,
88
+ );
89
+
90
+ // Genuinely mid-move: this row's `original_uid` is not stale and must
91
+ // survive, or the guard that stops a move binding somebody else's uid
92
+ // stops working on every row the upgrade touched.
93
+ insert.run(
94
+ IN_FLIGHT,
95
+ "mbx-archive",
96
+ 42,
97
+ `env-${IN_FLIGHT}`,
98
+ `bp-${IN_FLIGHT}`,
99
+ "moving",
100
+ "pending",
101
+ "mbx-inbox",
102
+ 42,
103
+ );
104
+
105
+ // The pair a give-up wrote before this release. Two writers produced it —
106
+ // `abandonDelete` and the paused-cursor hand-back for an unproven MOVE —
107
+ // through the identical `restoreSourcePlacement` call, so the row cannot
108
+ // say which.
109
+ insert.run(
110
+ GAVE_UP,
111
+ "mbx-inbox",
112
+ 7,
113
+ `env-${GAVE_UP}`,
114
+ `bp-${GAVE_UP}`,
115
+ "active",
116
+ "failed",
117
+ "mbx-inbox",
118
+ null,
119
+ );
120
+
121
+ // A transient attempt with a redelivery behind it. It writes the same
122
+ // `failed`, and keeping `deleting` beside it is the only thing that tells
123
+ // the two apart — so the backfill must not touch it.
124
+ insert.run(
125
+ RETRYING_DELETE,
126
+ "mbx-trash",
127
+ 8,
128
+ `env-${RETRYING_DELETE}`,
129
+ `bp-${RETRYING_DELETE}`,
130
+ "deleting",
131
+ "failed",
132
+ "mbx-inbox",
133
+ 8,
134
+ );
135
+
136
+ for (const entry of entries.filter(
137
+ (candidate) => candidate.idx >= ORIGINAL_UID_BACKFILL,
138
+ )) {
139
+ applyMigration(sqlite, entry.tag);
140
+ }
141
+ });
142
+
143
+ after(() => {
144
+ sqlite.close();
145
+ });
146
+
147
+ test("the journal ships both backfills", () => {
148
+ const idxs = migrationJournal().map((entry) => entry.idx);
149
+ assert.ok(idxs.includes(ORIGINAL_UID_BACKFILL));
150
+ assert.ok(idxs.includes(GIVE_UP_BACKFILL));
151
+ });
152
+
153
+ test("a settled row loses the stale uid that made it look foreign", () => {
154
+ const row = rowFor(STALE_ORIGINAL_UID);
155
+ assert.equal(row.original_uid, null);
156
+ assert.equal(
157
+ row.original_mailbox_id,
158
+ "mbx-inbox",
159
+ "Undo still restores to the folder the message came from",
160
+ );
161
+ });
162
+
163
+ test("a row still mid-move keeps the uid its guard depends on", () => {
164
+ assert.equal(rowFor(IN_FLIGHT).original_uid, 42);
165
+ });
166
+
167
+ test("a give-up keeps its chip, on the value the client now reads", () => {
168
+ const row = rowFor(GAVE_UP);
169
+ assert.equal(row.sync_status, "abandoned");
170
+ assert.equal(row.status, "active");
171
+ });
172
+
173
+ /**
174
+ * The two pre-upgrade writers are indistinguishable in the row, so the
175
+ * backfill has to pick one label for both. It picks the survivable mistake:
176
+ * a wrongly-labelled move offers a folder picker the user can dismiss, while
177
+ * a wrongly-labelled delete offers a button that destroys the message — the
178
+ * #1229 defect, on a press the user believed was a repair.
179
+ */
180
+ test("and is never labelled a delete, which would offer to destroy it", () => {
181
+ assert.equal(rowFor(GAVE_UP).abandoned_mutation, "move");
182
+ });
183
+
184
+ test("a delete mid-retry is left exactly as it was", () => {
185
+ const row = rowFor(RETRYING_DELETE);
186
+ assert.equal(row.sync_status, "failed");
187
+ assert.equal(row.abandoned_mutation, "none");
188
+ assert.equal(row.status, "deleting");
189
+ });
190
+ });
@@ -171,6 +171,84 @@ describe("DrizzleMessageRepository (sqlite)", () => {
171
171
  assert.equal(item.status, "active");
172
172
  });
173
173
 
174
+ // The lock of imap-mutations R3. There is no version column: the predicate
175
+ // is over the placement fields themselves, so an unrelated write to any
176
+ // other column cannot make a transition lose, and a placement the caller
177
+ // did not read cannot make it win.
178
+ describe("transitionPlacement", () => {
179
+ const LOCK_ID = "00000000-0000-0000-2222-00000000000a";
180
+ const OTHER_MAILBOX = "00000000-0000-0000-2222-00000000000b";
181
+
182
+ before(async () => {
183
+ await repo.create({
184
+ ...BASE_INPUT,
185
+ messageId: LOCK_ID,
186
+ uid: 7,
187
+ envelopeId: deriveEnvelopeId(LOCK_ID),
188
+ rootBodyPartId: deriveRootBodyPartId(LOCK_ID),
189
+ status: "moving" as const,
190
+ syncStatus: "pending" as const,
191
+ });
192
+ });
193
+
194
+ test("writes the row when every named field still matches", async () => {
195
+ const written = await repo.transitionPlacement(
196
+ LOCK_ID,
197
+ { status: "moving", mailboxId: MAILBOX_ID, uid: 7 },
198
+ { status: "active", syncStatus: "synced", mailboxId: OTHER_MAILBOX },
199
+ );
200
+
201
+ assert.equal(written?.status, "active");
202
+ assert.equal(written?.syncStatus, "synced");
203
+ assert.equal(written?.mailboxId, OTHER_MAILBOX);
204
+ });
205
+
206
+ test("answers undefined and writes nothing when the row has moved on", async () => {
207
+ const lost = await repo.transitionPlacement(
208
+ LOCK_ID,
209
+ { status: "moving" },
210
+ { status: "deleting" },
211
+ );
212
+
213
+ assert.equal(lost, undefined);
214
+ assert.equal(
215
+ (await repo.get(LOCK_ID)).status,
216
+ "active",
217
+ "the loser leaves the winner's placement exactly as it found it",
218
+ );
219
+ });
220
+
221
+ test("a set of from-states matches any of them", async () => {
222
+ const written = await repo.transitionPlacement(
223
+ LOCK_ID,
224
+ { status: ["moving", "active"] },
225
+ { syncStatus: "abandoned" },
226
+ );
227
+
228
+ assert.equal(written?.syncStatus, "abandoned");
229
+ });
230
+
231
+ test("a field the caller did not read constrains nothing", async () => {
232
+ const written = await repo.transitionPlacement(
233
+ LOCK_ID,
234
+ { uid: 7 },
235
+ { syncStatus: "synced" },
236
+ );
237
+
238
+ assert.equal(written?.syncStatus, "synced");
239
+ });
240
+
241
+ test("a row that no longer exists loses like any other predicate", async () => {
242
+ const lost = await repo.transitionPlacement(
243
+ "00000000-0000-0000-2222-0000000000ff",
244
+ { status: "active" },
245
+ { status: "deleting" },
246
+ );
247
+
248
+ assert.equal(lost, undefined);
249
+ });
250
+ });
251
+
174
252
  test("delete removes the message and appends a removal outbox row", async () => {
175
253
  await repo.delete(MESSAGE_ID);
176
254
  await assert.rejects(() => repo.get(MESSAGE_ID));
@@ -195,13 +195,18 @@ describe("DrizzleMessageRepository", () => {
195
195
  });
196
196
 
197
197
  test("returns the moved message with new mailbox and pending status", async () => {
198
- const moved = await messageRepo.updateForMove(MOVE_MESSAGE_ID, {
199
- mailboxId: DEST_MAILBOX_ID,
200
- status: "moving",
201
- syncStatus: "pending",
202
- originalMailboxId: SOURCE_MAILBOX_ID,
203
- originalUid: 7,
204
- });
198
+ const moved = await messageRepo.transitionPlacement(
199
+ MOVE_MESSAGE_ID,
200
+ { status: "active", mailboxId: SOURCE_MAILBOX_ID, uid: 7 },
201
+ {
202
+ mailboxId: DEST_MAILBOX_ID,
203
+ status: "moving",
204
+ syncStatus: "pending",
205
+ originalMailboxId: SOURCE_MAILBOX_ID,
206
+ originalUid: 7,
207
+ },
208
+ );
209
+ assert.ok(moved);
205
210
  assert.equal(moved.mailboxId, DEST_MAILBOX_ID);
206
211
  assert.equal(moved.status, "moving");
207
212
  assert.equal(moved.syncStatus, "pending");
@@ -277,16 +282,25 @@ describe("DrizzleMessageRepository", () => {
277
282
  assert.equal(rows[0].processedAt, null, "event starts undrained");
278
283
  });
279
284
 
280
- test("updateForMove throws NotFoundError for unknown messageId", async () => {
281
- await assert.rejects(
282
- () =>
283
- messageRepo.updateForMove("00000000-0000-0000-9999-000000000001", {
284
- mailboxId: DEST_MAILBOX_ID,
285
- }),
286
- (err: Error) => {
287
- assert.equal(err.name, "NotFoundError");
288
- return true;
289
- },
285
+ test("updateUid drops the source uid and keeps the folder Undo restores to", async () => {
286
+ // `originalUid` says the row's `uid` was recorded under
287
+ // `originalMailboxId`. The confirmation above makes that untrue, and a
288
+ // leftover that happens to equal the destination's own uid — two
289
+ // independent per-folder counters, so a routine collision — reads as an
290
+ // unsettled placement for the rest of the row's life (#1217).
291
+ const read = await messageRepo.get(MOVE_MESSAGE_ID);
292
+ assert.equal(read.originalUid, undefined);
293
+ assert.equal(read.originalMailboxId, SOURCE_MAILBOX_ID);
294
+ });
295
+
296
+ test("the optimistic move answers undefined for an unknown messageId", async () => {
297
+ assert.equal(
298
+ await messageRepo.transitionPlacement(
299
+ "00000000-0000-0000-9999-000000000001",
300
+ { status: "active" },
301
+ { mailboxId: DEST_MAILBOX_ID },
302
+ ),
303
+ undefined,
290
304
  );
291
305
  });
292
306
 
@@ -4,8 +4,9 @@ import type {
4
4
  IMessageRepository,
5
5
  MessageDescription,
6
6
  MessageItem,
7
+ PlacementPredicate,
7
8
  } from "@remit/data-ports";
8
- import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
9
+ import { and, asc, eq, gt, inArray, or, type SQL } from "drizzle-orm";
9
10
 
10
11
  import type { Db } from "../db.js";
11
12
  import {
@@ -57,6 +58,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
57
58
  envelopeId: row.envelopeId,
58
59
  rootBodyPartId: row.rootBodyPartId,
59
60
  status: row.status,
61
+ abandonedMutation: row.abandonedMutation,
60
62
  syncStatus: row.syncStatus,
61
63
  category: row.category,
62
64
  classificationState: row.classificationState,
@@ -169,6 +171,34 @@ export async function deleteMessageSubtree(
169
171
  );
170
172
  }
171
173
 
174
+ /**
175
+ * The WHERE terms of a placement transition (imap-mutations R3). A field the
176
+ * caller left out is a field it did not read, so it constrains nothing; a field
177
+ * given as a list matches any of the states that share one column value.
178
+ */
179
+ const isList = <T>(value: T | readonly T[]): value is readonly T[] =>
180
+ Array.isArray(value);
181
+
182
+ const oneOf = <T>(value: T | readonly T[]): T[] =>
183
+ isList(value) ? [...value] : [value];
184
+
185
+ const placementTerms = (expected: PlacementPredicate): SQL[] => {
186
+ const terms: SQL[] = [];
187
+ if (expected.status !== undefined) {
188
+ terms.push(inArray(messageTable.status, oneOf(expected.status)));
189
+ }
190
+ if (expected.syncStatus !== undefined) {
191
+ terms.push(inArray(messageTable.syncStatus, oneOf(expected.syncStatus)));
192
+ }
193
+ if (expected.mailboxId !== undefined) {
194
+ terms.push(eq(messageTable.mailboxId, expected.mailboxId));
195
+ }
196
+ if (expected.uid !== undefined) {
197
+ terms.push(eq(messageTable.uid, expected.uid));
198
+ }
199
+ return terms;
200
+ };
201
+
172
202
  export class DrizzleMessageRepository implements IMessageRepository {
173
203
  constructor(private db: DB) {}
174
204
 
@@ -359,10 +389,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
359
389
  ...(input.bodyStorageKey !== undefined
360
390
  ? { bodyStorageKey: input.bodyStorageKey }
361
391
  : {}),
362
- ...(input.status !== undefined ? { status: input.status } : {}),
363
- ...(input.syncStatus !== undefined
364
- ? { syncStatus: input.syncStatus }
365
- : {}),
366
392
  ...(input.category !== undefined ? { category: input.category } : {}),
367
393
  ...(input.classificationState !== undefined
368
394
  ? { classificationState: input.classificationState }
@@ -455,17 +481,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
455
481
  return this.get(messageId);
456
482
  }
457
483
 
458
- async clearOriginalMailboxId(
459
- messageId: string,
460
- ): ReturnType<IMessageRepository["clearOriginalMailboxId"]> {
461
- const now = Date.now();
462
- await this.db
463
- .update(messageTable)
464
- .set({ originalMailboxId: null, originalUid: null, updatedAt: now })
465
- .where(eq(messageTable.messageId, messageId));
466
- return this.get(messageId);
467
- }
468
-
469
484
  async delete(messageId: string): Promise<void> {
470
485
  await this.deleteMany([messageId]);
471
486
  }
@@ -532,22 +547,24 @@ export class DrizzleMessageRepository implements IMessageRepository {
532
547
  return rows.map(toMessageItem);
533
548
  }
534
549
 
535
- async updateForMove(
550
+ async transitionPlacement(
536
551
  messageId: string,
537
- input: Parameters<IMessageRepository["updateForMove"]>[1],
538
- ): ReturnType<IMessageRepository["updateForMove"]> {
552
+ expected: PlacementPredicate,
553
+ next: Parameters<IMessageRepository["transitionPlacement"]>[2],
554
+ ): ReturnType<IMessageRepository["transitionPlacement"]> {
539
555
  const setValues = {
540
- ...(input.mailboxId !== undefined ? { mailboxId: input.mailboxId } : {}),
541
- ...(input.uid !== undefined ? { uid: input.uid } : {}),
542
- ...(input.status !== undefined ? { status: input.status } : {}),
543
- ...(input.syncStatus !== undefined
544
- ? { syncStatus: input.syncStatus }
556
+ ...(next.mailboxId !== undefined ? { mailboxId: next.mailboxId } : {}),
557
+ ...(next.uid !== undefined ? { uid: next.uid } : {}),
558
+ ...(next.status !== undefined ? { status: next.status } : {}),
559
+ ...(next.syncStatus !== undefined ? { syncStatus: next.syncStatus } : {}),
560
+ ...(next.abandonedMutation !== undefined
561
+ ? { abandonedMutation: next.abandonedMutation }
545
562
  : {}),
546
- ...(input.originalMailboxId !== undefined
547
- ? { originalMailboxId: input.originalMailboxId }
563
+ ...(next.originalMailboxId !== undefined
564
+ ? { originalMailboxId: next.originalMailboxId }
548
565
  : {}),
549
- ...(input.originalUid !== undefined
550
- ? { originalUid: input.originalUid }
566
+ ...(next.originalUid !== undefined
567
+ ? { originalUid: next.originalUid }
551
568
  : {}),
552
569
  updatedAt: Date.now(),
553
570
  };
@@ -555,11 +572,11 @@ export class DrizzleMessageRepository implements IMessageRepository {
555
572
  const rows = await this.db
556
573
  .update(messageTable)
557
574
  .set(setValues)
558
- .where(eq(messageTable.messageId, messageId))
575
+ .where(
576
+ and(eq(messageTable.messageId, messageId), ...placementTerms(expected)),
577
+ )
559
578
  .returning();
560
- if (rows.length === 0) {
561
- throw new NotFoundError(`Message not found: ${messageId}`);
562
- }
579
+ if (rows.length === 0) return undefined;
563
580
  return toMessageItem(rows[0]);
564
581
  }
565
582
 
@@ -574,14 +591,28 @@ export class DrizzleMessageRepository implements IMessageRepository {
574
591
  // re-index would skip them on content hash. Enqueue a move re-index event
575
592
  // in the same transaction as the update; the search-index worker drains it
576
593
  // with force, refreshing the stored mailbox metadata.
594
+ //
595
+ // `originalUid` goes with the settle. It exists to say the row's `uid`
596
+ // was recorded under `originalMailboxId`, and that stops being true here;
597
+ // left behind it makes the settled row indistinguishable from an
598
+ // in-flight one whenever the destination's counter happens to hand back
599
+ // the source's number, which on a young account is most of them (#1217).
600
+ // `originalMailboxId` stays: Undo restores the message to it.
577
601
  const rows = await runInTransaction(this.db, async (tx) => {
578
602
  const updated = await tx
579
603
  .update(messageTable)
580
604
  .set({
581
605
  uid: newUid,
582
606
  mailboxId: newMailboxId,
607
+ originalUid: null,
583
608
  status: "active",
584
609
  syncStatus: "synced",
610
+ // The settle clears the epitaph as well as the pair. `syncStatus`
611
+ // alone already gates every reader of it, so this is tidiness
612
+ // rather than correctness — but a row that has just settled has
613
+ // nothing it gave up on, and leaving a value there invites a
614
+ // reader that forgets the gate.
615
+ abandonedMutation: "none",
585
616
  updatedAt: Date.now(),
586
617
  })
587
618
  .where(eq(messageTable.messageId, messageId))