@remit/drizzle-service 0.0.83 → 0.0.85

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.83",
3
+ "version": "0.0.85",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import Database from "better-sqlite3";
4
+ import {
5
+ applyMigration,
6
+ migrationJournal,
7
+ } from "./test-shipped-sqlite-schema.js";
8
+
9
+ /**
10
+ * `sync_status` becomes NOT NULL, and on a real install almost every row is
11
+ * NULL: the sweep's insert is the only create path that omitted it, and the
12
+ * sweep is what discovers a folder in the first place.
13
+ *
14
+ * drizzle-kit does not emit data migrations, and SQLite cannot alter
15
+ * nullability, so the schema change is a copy-and-swap whose `INSERT … SELECT`
16
+ * would violate the new constraint on every one of those rows. Because drizzle
17
+ * runs a folder's whole pending set inside one `BEGIN`/`COMMIT`, that failure
18
+ * rolls the entities set back and the migrate one-shot that gates all six
19
+ * services never completes.
20
+ *
21
+ * So the pair is proven here in the order the migrator runs it, against the
22
+ * data it will actually meet.
23
+ */
24
+ const BACKFILL = "0026_mailbox_sync_status_backfill";
25
+ const SCHEMA_CHANGE = "0027_mailbox_sync_status_total";
26
+
27
+ const atPredecessor = (): Database.Database => {
28
+ const sqlite = new Database(":memory:");
29
+ for (const entry of migrationJournal()) {
30
+ if (entry.tag === BACKFILL) return sqlite;
31
+ applyMigration(sqlite, entry.tag);
32
+ }
33
+ throw new Error(`${BACKFILL} is not in the journal`);
34
+ };
35
+
36
+ const seed = (
37
+ sqlite: Database.Database,
38
+ rows: Array<{ mailboxId: string; syncStatus: string | null }>,
39
+ ): void => {
40
+ const insert = sqlite.prepare(
41
+ `INSERT INTO mailbox (
42
+ mailbox_id, account_id, namespace_type, namespace_prefix,
43
+ hierarchy_delimiter, full_path, uid_validity, uid_next, highest_modseq,
44
+ message_count, unseen_count, deleted_count, total_size, last_sync_uid,
45
+ high_water_mark_uid, last_message_sync_at, parent_mailbox_id,
46
+ sync_status, cursor_state, created_at, updated_at
47
+ ) VALUES (?, 'acct', 'personal', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0,
48
+ 'None', ?, 'normal', 0, 0)`,
49
+ );
50
+ for (const row of rows) {
51
+ insert.run(row.mailboxId, `Folder/${row.mailboxId}`, row.syncStatus);
52
+ }
53
+ };
54
+
55
+ describe("mailbox sync_status becomes total", () => {
56
+ test("the backfill runs first, so the rebuild sees no NULLs", () => {
57
+ const sqlite = atPredecessor();
58
+ seed(sqlite, [
59
+ { mailboxId: "swept-1", syncStatus: null },
60
+ { mailboxId: "swept-2", syncStatus: null },
61
+ { mailboxId: "created", syncStatus: "pending" },
62
+ { mailboxId: "failed", syncStatus: "failed" },
63
+ ]);
64
+
65
+ applyMigration(sqlite, BACKFILL);
66
+ applyMigration(sqlite, SCHEMA_CHANGE);
67
+
68
+ const rows = sqlite
69
+ .prepare(
70
+ "SELECT mailbox_id, sync_status FROM mailbox ORDER BY mailbox_id",
71
+ )
72
+ .all() as Array<{ mailbox_id: string; sync_status: string }>;
73
+
74
+ assert.deepEqual(rows, [
75
+ { mailbox_id: "created", sync_status: "pending" },
76
+ { mailbox_id: "failed", sync_status: "failed" },
77
+ { mailbox_id: "swept-1", sync_status: "synced" },
78
+ { mailbox_id: "swept-2", sync_status: "synced" },
79
+ ]);
80
+ sqlite.close();
81
+ });
82
+
83
+ test("the rebuild is refused without the backfill ahead of it", () => {
84
+ // The failure this ordering exists to prevent, made visible: without the
85
+ // backfill the copy-and-swap cannot insert a NULL into the new column.
86
+ const sqlite = atPredecessor();
87
+ seed(sqlite, [{ mailboxId: "swept-1", syncStatus: null }]);
88
+
89
+ assert.throws(
90
+ () => applyMigration(sqlite, SCHEMA_CHANGE),
91
+ /NOT NULL constraint failed/,
92
+ );
93
+ sqlite.close();
94
+ });
95
+
96
+ test("the rebuild keeps the account index and the new column", () => {
97
+ const sqlite = atPredecessor();
98
+ applyMigration(sqlite, BACKFILL);
99
+ applyMigration(sqlite, SCHEMA_CHANGE);
100
+ applyMigration(sqlite, "0028_mailbox_pending_path");
101
+
102
+ const columns = (
103
+ sqlite.prepare("PRAGMA table_info(mailbox)").all() as Array<{
104
+ name: string;
105
+ }>
106
+ ).map((column) => column.name);
107
+ assert.equal(columns.includes("pending_path"), true);
108
+ assert.equal(columns.includes("old_path"), false);
109
+
110
+ const indexes = (
111
+ sqlite.prepare("PRAGMA index_list(mailbox)").all() as Array<{
112
+ name: string;
113
+ }>
114
+ ).map((index) => index.name);
115
+ assert.equal(indexes.includes("mailbox_by_account_id"), true);
116
+ sqlite.close();
117
+ });
118
+ });
@@ -7,7 +7,10 @@ import Database from "better-sqlite3";
7
7
  import { drizzle } from "drizzle-orm/better-sqlite3";
8
8
  import { AccountSettingRepo } from "../repos/i4-account-setting.js";
9
9
  import { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
10
- import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
10
+ import {
11
+ applyMigration,
12
+ shippedTableDdl,
13
+ } from "../test-shipped-sqlite-schema.js";
11
14
  import {
12
15
  type JunkOnlyRepairClient,
13
16
  type JunkOnlyRepairMode,
@@ -197,6 +200,9 @@ describe("addresses standing only on mail in Junk", () => {
197
200
  ]) {
198
201
  sqlite.exec(shippedTableDdl(DDL_TAG, table));
199
202
  }
203
+ // `mailbox` is read through the repo, which selects every column the
204
+ // entity declares — including the rename target added after this DDL.
205
+ applyMigration(sqlite, "0028_mailbox_pending_path");
200
206
  sqlite.exec(
201
207
  readFileSync(
202
208
  new URL(
@@ -75,6 +75,137 @@ describe("concurrent flag merges on one address", () => {
75
75
  await repo.deleteAddress(addr.accountConfigId, addr.addressId);
76
76
  });
77
77
 
78
+ test("marking a blocked sender never-spam drops the block", async () => {
79
+ const addr = await address();
80
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
81
+ blocked: { value: true, setAt: 1 },
82
+ muted: { value: true, setAt: 1 },
83
+ });
84
+
85
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
86
+ neverSpam: { value: true, setAt: 2 },
87
+ });
88
+
89
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
90
+ assert.equal(merged.flags?.neverSpam?.value, true);
91
+ assert.equal(merged.flags?.blocked, undefined);
92
+ assert.equal(
93
+ merged.flags?.muted?.value,
94
+ true,
95
+ "a flag on another axis is untouched",
96
+ );
97
+
98
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
99
+ });
100
+
101
+ test("blocking a never-spam sender drops the grant", async () => {
102
+ const addr = await address();
103
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
104
+ neverSpam: { value: true, setAt: 1 },
105
+ });
106
+
107
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
108
+ blocked: { value: true, setAt: 2 },
109
+ });
110
+
111
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
112
+ assert.equal(merged.flags?.blocked?.value, true);
113
+ assert.equal(merged.flags?.neverSpam, undefined);
114
+
115
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
116
+ });
117
+
118
+ test("a never-spam grant turned off leaves a later block alone", async () => {
119
+ const addr = await address();
120
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
121
+ neverSpam: { value: false, setAt: 1 },
122
+ });
123
+
124
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
125
+ blocked: { value: true, setAt: 2 },
126
+ });
127
+
128
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
129
+ assert.equal(merged.flags?.blocked?.value, true);
130
+ assert.equal(
131
+ merged.flags?.neverSpam?.value,
132
+ false,
133
+ "an explicit false is not a contradiction and keeps its audit trail",
134
+ );
135
+
136
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
137
+ });
138
+
139
+ // A row written before the invariant existed still carries both. Healing it
140
+ // while patching an unrelated key would revoke a placement instruction the
141
+ // user never touched, so the write leaves it and the read arbitrates.
142
+ test("a patch about another flag leaves a legacy both-set row alone", async () => {
143
+ const accountConfigId = randomId();
144
+ const addr = await repo.createAddress({
145
+ addressId: randomId(),
146
+ accountConfigId,
147
+ localPart: "legacy",
148
+ domain: "example.com",
149
+ normalizedEmail: "legacy@example.com",
150
+ normalizedCompound: "legacy@example.com:legacy",
151
+ flags: {
152
+ blocked: { value: true, setAt: 1 },
153
+ neverSpam: { value: true, setAt: 2 },
154
+ },
155
+ });
156
+
157
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
158
+ muted: { value: true, setAt: 3 },
159
+ });
160
+
161
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
162
+ assert.equal(merged.flags?.blocked?.value, true);
163
+ assert.equal(merged.flags?.neverSpam?.value, true);
164
+ assert.equal(merged.flags?.muted?.value, true);
165
+
166
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
167
+ });
168
+
169
+ test("writing either placement flag heals a legacy both-set row", async () => {
170
+ const accountConfigId = randomId();
171
+ const addr = await repo.createAddress({
172
+ addressId: randomId(),
173
+ accountConfigId,
174
+ localPart: "legacy",
175
+ domain: "example.com",
176
+ normalizedEmail: "legacy2@example.com",
177
+ normalizedCompound: "legacy2@example.com:legacy",
178
+ flags: {
179
+ blocked: { value: true, setAt: 1 },
180
+ neverSpam: { value: true, setAt: 2 },
181
+ },
182
+ });
183
+
184
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
185
+ neverSpam: { value: true, setAt: 3 },
186
+ });
187
+
188
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
189
+ assert.equal(merged.flags?.neverSpam?.value, true);
190
+ assert.equal(merged.flags?.blocked, undefined);
191
+
192
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
193
+ });
194
+
195
+ test("one patch naming both contradictory flags settles on the block", async () => {
196
+ const addr = await address();
197
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
198
+ blocked: { value: true, setAt: 1 },
199
+ neverSpam: { value: true, setAt: 1 },
200
+ });
201
+
202
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
203
+ assert.equal(merged.flags?.blocked?.value, true);
204
+ assert.equal(merged.flags?.neverSpam, undefined);
205
+
206
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
207
+ });
208
+
78
209
  test("a concurrent merge does not resurrect a deleted flag", async () => {
79
210
  const addr = await address();
80
211
  await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
@@ -70,6 +70,41 @@ type MergeAttempt =
70
70
  | { outcome: "missing" }
71
71
  | { outcome: "contended" };
72
72
 
73
+ /**
74
+ * `blocked` and `neverSpam` are two directly contradictory statements about
75
+ * where a sender's mail belongs (issue #605), so a row asserting both is not a
76
+ * tie to break — it is a row that should not exist. This is the only place that
77
+ * sees both keys at once: `buildFlagsPatch` stays a pure per-key translation,
78
+ * and the merge fold is the read-modify-write of the whole map.
79
+ *
80
+ * The flag the patch just raised wins, so the instruction the user gave last is
81
+ * the one that stands. A patch raising both resolves to `blocked` — the same
82
+ * direction the `blocked`/`vip` same-second tie already breaks in.
83
+ *
84
+ * Only a patch that raises one of the two is allowed to drop the other. A row
85
+ * that somehow already carries both survives a patch about anything else
86
+ * untouched: dropping a placement instruction as a side effect of writing
87
+ * `muted` would be a silent revocation the user never asked for. Such a row is
88
+ * resolved on read — `resolveSenderPlacement` reads it as `Blocked` — and
89
+ * healed the next time either key is written.
90
+ */
91
+ const dropContradictedPlacementFlag = (
92
+ next: AddressFlags,
93
+ patch: FlagsMergePatch,
94
+ ): AddressFlags => {
95
+ if (next.blocked?.value !== true || next.neverSpam?.value !== true)
96
+ return next;
97
+ if (patch.blocked?.value === true) {
98
+ const { neverSpam: _neverSpam, ...rest } = next;
99
+ return rest;
100
+ }
101
+ if (patch.neverSpam?.value === true) {
102
+ const { blocked: _blocked, ...rest } = next;
103
+ return rest;
104
+ }
105
+ return next;
106
+ };
107
+
73
108
  /**
74
109
  * The stored `"<display name> <email>"` compound, folded in JavaScript exactly
75
110
  * as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
@@ -496,7 +531,7 @@ export class AddressRepo implements IAddressRepository {
496
531
  }
497
532
  (next[key] as AddressFlags[keyof AddressFlags]) = value;
498
533
  }
499
- return next;
534
+ return dropContradictedPlacementFlag(next, patch);
500
535
  });
501
536
  }
502
537
 
@@ -68,31 +68,39 @@ describe("MailboxRepo (sqlite)", () => {
68
68
  assert.equal(reread.highestModseq, "9007199254740993");
69
69
  });
70
70
 
71
- test("renameChildPaths marks each child pending along with its new path (#290)", async () => {
72
- // A renamed parent is set pending by the caller; its children's new paths
73
- // are equally absent from the server until MAILBOX_RENAME lands, so they
74
- // must be pending too — otherwise a reconcile in that window reaps the
75
- // child as server-deleted.
71
+ test("a row written without a state reads back synced (D1)", async () => {
76
72
  const accountId = randomUUID();
77
- const parent = await repo.create({
78
- ...makeMailboxInput(accountId, "Work"),
79
- syncStatus: MailboxSyncStatus.pending,
80
- });
81
- const child = await repo.create({
82
- ...makeMailboxInput(accountId, "Work/sub"),
83
- syncStatus: MailboxSyncStatus.synced,
84
- });
73
+ const created = await repo.create(
74
+ makeMailboxInput(accountId, "Discovered"),
75
+ );
76
+ assert.equal(created.syncStatus, MailboxSyncStatus.synced);
77
+ const fetched = await repo.get(accountId, created.mailboxId);
78
+ assert.equal(fetched.syncStatus, MailboxSyncStatus.synced);
79
+ });
85
80
 
86
- await repo.renameChildPaths(accountId, "Work", "Projects", "/");
81
+ test("pendingPath round-trips and clears", async () => {
82
+ const accountId = randomUUID();
83
+ const created = await repo.create(makeMailboxInput(accountId, "Archive"));
84
+ assert.equal(created.pendingPath, undefined);
87
85
 
88
- const renamedChild = await repo.get(accountId, child.mailboxId);
89
- assert.equal(renamedChild.fullPath, "Projects/sub");
90
- assert.equal(renamedChild.syncStatus, MailboxSyncStatus.pending);
86
+ const claimed = await repo.transition(accountId, created.mailboxId, {
87
+ from: [MailboxSyncStatus.synced],
88
+ to: MailboxSyncStatus.pending,
89
+ set: { pendingPath: "Records" },
90
+ });
91
+ assert.equal(claimed?.pendingPath, "Records");
92
+ assert.equal(
93
+ (await repo.get(accountId, created.mailboxId)).pendingPath,
94
+ "Records",
95
+ );
91
96
 
92
- // The parent row is untouched by this call (its own path/status is the
93
- // caller's job).
94
- const parentRow = await repo.get(accountId, parent.mailboxId);
95
- assert.equal(parentRow.fullPath, "Work");
97
+ const settled = await repo.transition(accountId, created.mailboxId, {
98
+ from: [MailboxSyncStatus.pending],
99
+ to: MailboxSyncStatus.synced,
100
+ set: { fullPath: "Records", pendingPath: null },
101
+ });
102
+ assert.equal(settled?.pendingPath, undefined);
103
+ assert.equal(settled?.fullPath, "Records");
96
104
  });
97
105
  });
98
106
 
@@ -115,6 +123,9 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
115
123
  const sqlite = new Database(":memory:");
116
124
  sqlite.exec(shippedTableDdl("0000_happy_roland_deschain", "mailbox"));
117
125
  applyMigration(sqlite, "0002_highest_modseq_text");
126
+ applyMigration(sqlite, "0026_mailbox_sync_status_backfill");
127
+ applyMigration(sqlite, "0027_mailbox_sync_status_total");
128
+ applyMigration(sqlite, "0028_mailbox_pending_path");
118
129
  const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
119
130
  repo = new MailboxRepo(db as never);
120
131
  close = async () => {
@@ -157,6 +168,31 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
157
168
  assert.strictEqual(fetched.highestModseq, "900:149");
158
169
  });
159
170
 
171
+ test("a row inserted without a state reads back synced", async () => {
172
+ const accountId = randomUUID();
173
+ const created = await repo.create(makeMailboxInput(accountId, "Notes"));
174
+ assert.equal(created.syncStatus, MailboxSyncStatus.synced);
175
+ });
176
+
177
+ test("pendingPath round-trips and clears on the shipped shape", async () => {
178
+ const accountId = randomUUID();
179
+ const created = await repo.create(makeMailboxInput(accountId, "Receipts"));
180
+
181
+ const claimed = await repo.transition(accountId, created.mailboxId, {
182
+ from: [MailboxSyncStatus.synced],
183
+ to: MailboxSyncStatus.pending,
184
+ set: { pendingPath: "Invoices" },
185
+ });
186
+ assert.equal(claimed?.pendingPath, "Invoices");
187
+
188
+ const cleared = await repo.transition(accountId, created.mailboxId, {
189
+ from: [MailboxSyncStatus.pending],
190
+ to: MailboxSyncStatus.synced,
191
+ set: { pendingPath: null },
192
+ });
193
+ assert.equal(cleared?.pendingPath, undefined);
194
+ });
195
+
160
196
  test("round-trips a cursor above 2^53 with its exact digits", async () => {
161
197
  const accountId = randomUUID();
162
198
  const modseq = "18446744073709551615";
@@ -1,7 +1,31 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { MailboxSyncStatus } from "@remit/domain-enums";
4
+ import { eq } from "drizzle-orm";
5
+ import {
6
+ envelopeId as deriveEnvelopeId,
7
+ rootBodyPartId as deriveRootBodyPartId,
8
+ } from "../id.js";
9
+ import {
10
+ filterTable,
11
+ mailboxSpecialUseTable,
12
+ mailboxTable,
13
+ messageTable,
14
+ outboxTable,
15
+ threadMessageTable,
16
+ } from "../schema.js";
3
17
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
18
+ import { runInTransaction } from "../tx.js";
4
19
  import { MailboxRepo } from "./i4-mailbox.js";
20
+ import { DrizzleMessageRepository, deleteMessageSubtree } from "./message.js";
21
+
22
+ /** Every state a row can carry, for a transition that decides against none. */
23
+ const EVERY_STATE = [
24
+ MailboxSyncStatus.synced,
25
+ MailboxSyncStatus.pending,
26
+ MailboxSyncStatus.failed,
27
+ MailboxSyncStatus.deleting,
28
+ ] as const;
5
29
 
6
30
  function makeMailboxInput(accountId: string, fullPath = "INBOX") {
7
31
  return {
@@ -143,19 +167,6 @@ describe("MailboxRepo", () => {
143
167
  assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
144
168
  });
145
169
 
146
- test("renameChildPaths updates all children", async () => {
147
- const accountId = randomId();
148
- await repo.create(makeMailboxInput(accountId, "OldName/Sub1"));
149
- await repo.create(makeMailboxInput(accountId, "OldName/Sub2"));
150
-
151
- await repo.renameChildPaths(accountId, "OldName", "NewName");
152
-
153
- const sub1 = await repo.findByPath(accountId, "NewName/Sub1");
154
- const sub2 = await repo.findByPath(accountId, "NewName/Sub2");
155
- assert.ok(sub1, "Sub1 renamed");
156
- assert.ok(sub2, "Sub2 renamed");
157
- });
158
-
159
170
  test("cross-tenant: get refuses a foreign account", async () => {
160
171
  const accountId = randomId();
161
172
  const other = randomId();
@@ -217,6 +228,490 @@ describe("MailboxRepo", () => {
217
228
  await repo.delete(accountB, b.mailboxId);
218
229
  });
219
230
 
231
+ describe("transition — the conditional write (D3)", () => {
232
+ test("an accepted from-state applies the new state and the set fields", async () => {
233
+ const accountId = randomId();
234
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Archive"));
235
+
236
+ const written = await repo.transition(accountId, mailbox.mailboxId, {
237
+ from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
238
+ to: MailboxSyncStatus.pending,
239
+ set: { pendingPath: "Records" },
240
+ });
241
+
242
+ assert.equal(written?.syncStatus, MailboxSyncStatus.pending);
243
+ assert.equal(written?.pendingPath, "Records");
244
+ assert.equal(written?.fullPath, "Archive", "fullPath stays confirmed");
245
+ });
246
+
247
+ test("a state outside `from` writes nothing and leaves the row byte-identical", async () => {
248
+ const accountId = randomId();
249
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Work"));
250
+ const before = await repo.get(accountId, mailbox.mailboxId);
251
+
252
+ const lost = await repo.transition(accountId, mailbox.mailboxId, {
253
+ from: [MailboxSyncStatus.deleting],
254
+ to: MailboxSyncStatus.synced,
255
+ set: { fullPath: "Nope" },
256
+ });
257
+
258
+ assert.equal(lost, null);
259
+ assert.deepEqual(await repo.get(accountId, mailbox.mailboxId), before);
260
+ });
261
+
262
+ test("an absent id is a null, not a throw", async () => {
263
+ assert.equal(
264
+ await repo.transition(randomId(), "no-such-mailbox", {
265
+ from: EVERY_STATE,
266
+ to: MailboxSyncStatus.synced,
267
+ }),
268
+ null,
269
+ );
270
+ });
271
+
272
+ test("wherePendingPath as a string matches only that target", async () => {
273
+ const accountId = randomId();
274
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Bills"));
275
+ await repo.transition(accountId, mailbox.mailboxId, {
276
+ from: [MailboxSyncStatus.synced],
277
+ to: MailboxSyncStatus.pending,
278
+ set: { pendingPath: "Invoices" },
279
+ });
280
+
281
+ assert.equal(
282
+ await repo.transition(accountId, mailbox.mailboxId, {
283
+ from: [MailboxSyncStatus.pending],
284
+ wherePendingPath: "Somewhere else",
285
+ to: MailboxSyncStatus.synced,
286
+ }),
287
+ null,
288
+ );
289
+
290
+ const settled = await repo.transition(accountId, mailbox.mailboxId, {
291
+ from: [MailboxSyncStatus.pending],
292
+ wherePendingPath: "Invoices",
293
+ to: MailboxSyncStatus.synced,
294
+ set: { fullPath: "Invoices", pendingPath: null },
295
+ });
296
+ assert.equal(settled?.fullPath, "Invoices");
297
+ });
298
+
299
+ test("wherePendingPath: null refuses a row a rename has claimed", async () => {
300
+ // The seventh-state hole (D3): a create settle that predicates on
301
+ // `pending` alone matches a row a rename has since claimed, writes
302
+ // `synced` with the rename target still on it, and the rename then
303
+ // never runs and is never marked failed.
304
+ const accountId = randomId();
305
+ const claimed = await repo.create(makeMailboxInput(accountId, "Notes"));
306
+ await repo.transition(accountId, claimed.mailboxId, {
307
+ from: [MailboxSyncStatus.synced],
308
+ to: MailboxSyncStatus.pending,
309
+ set: { pendingPath: "Journal" },
310
+ });
311
+
312
+ const createSettle = await repo.transition(accountId, claimed.mailboxId, {
313
+ from: [MailboxSyncStatus.pending],
314
+ wherePendingPath: null,
315
+ to: MailboxSyncStatus.synced,
316
+ });
317
+ assert.equal(createSettle, null);
318
+
319
+ const still = await repo.get(accountId, claimed.mailboxId);
320
+ assert.equal(still.syncStatus, MailboxSyncStatus.pending);
321
+ assert.equal(still.pendingPath, "Journal");
322
+
323
+ const creating = await repo.create({
324
+ ...makeMailboxInput(accountId, "Fresh"),
325
+ syncStatus: MailboxSyncStatus.pending,
326
+ });
327
+ const settled = await repo.transition(accountId, creating.mailboxId, {
328
+ from: [MailboxSyncStatus.pending],
329
+ wherePendingPath: null,
330
+ to: MailboxSyncStatus.synced,
331
+ });
332
+ assert.equal(settled?.syncStatus, MailboxSyncStatus.synced);
333
+ });
334
+
335
+ test("omitting wherePendingPath predicates on syncStatus alone", async () => {
336
+ const accountId = randomId();
337
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Old"));
338
+ await repo.transition(accountId, mailbox.mailboxId, {
339
+ from: [MailboxSyncStatus.synced],
340
+ to: MailboxSyncStatus.pending,
341
+ set: { pendingPath: "New" },
342
+ });
343
+
344
+ const written = await repo.transition(accountId, mailbox.mailboxId, {
345
+ from: [MailboxSyncStatus.pending],
346
+ to: MailboxSyncStatus.failed,
347
+ });
348
+ assert.equal(written?.syncStatus, MailboxSyncStatus.failed);
349
+ assert.equal(written?.pendingPath, "New");
350
+ });
351
+
352
+ test("a rename target survives only under pending and failed", async () => {
353
+ // The invariant, over the only writer of either field: a caller that
354
+ // asks for the seventh combination does not get it. `synced` with a
355
+ // rename target on it is what strands a folder whose rename then never
356
+ // runs and is never marked failed.
357
+ const accountId = randomId();
358
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Trips"));
359
+
360
+ for (const to of EVERY_STATE) {
361
+ const written = await repo.transition(accountId, mailbox.mailboxId, {
362
+ from: EVERY_STATE,
363
+ to,
364
+ set: { pendingPath: "Holidays" },
365
+ });
366
+ assert.equal(
367
+ written?.pendingPath,
368
+ to === MailboxSyncStatus.pending || to === MailboxSyncStatus.failed
369
+ ? "Holidays"
370
+ : undefined,
371
+ `transition to ${to}`,
372
+ );
373
+ assert.deepEqual(
374
+ await repo.get(accountId, mailbox.mailboxId),
375
+ written,
376
+ "what the transition returned is what the row now says",
377
+ );
378
+ }
379
+ });
380
+
381
+ test("two overlapping transitions with the same from: exactly one wins", async () => {
382
+ const accountId = randomId();
383
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Races"));
384
+
385
+ const outcomes = await Promise.all([
386
+ repo.transition(accountId, mailbox.mailboxId, {
387
+ from: [MailboxSyncStatus.synced],
388
+ to: MailboxSyncStatus.deleting,
389
+ }),
390
+ repo.transition(accountId, mailbox.mailboxId, {
391
+ from: [MailboxSyncStatus.synced],
392
+ to: MailboxSyncStatus.pending,
393
+ }),
394
+ ]);
395
+
396
+ assert.equal(outcomes.filter((outcome) => outcome !== null).length, 1);
397
+ });
398
+
399
+ test("cross-tenant: a foreign account transitions nothing", async () => {
400
+ const accountId = randomId();
401
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Private"));
402
+
403
+ assert.equal(
404
+ await repo.transition(randomId(), mailbox.mailboxId, {
405
+ from: EVERY_STATE,
406
+ to: MailboxSyncStatus.deleting,
407
+ }),
408
+ null,
409
+ );
410
+ assert.equal(
411
+ (await repo.get(accountId, mailbox.mailboxId)).syncStatus,
412
+ MailboxSyncStatus.synced,
413
+ );
414
+ });
415
+ });
416
+
417
+ describe("transitionSubtree — the intent, all-or-nothing (D6)", () => {
418
+ const seedSubtree = async (accountId: string) => ({
419
+ parent: await repo.create(makeMailboxInput(accountId, "Work")),
420
+ child: await repo.create(makeMailboxInput(accountId, "Work/Projects")),
421
+ grandchild: await repo.create(
422
+ makeMailboxInput(accountId, "Work/Projects/Alpha"),
423
+ ),
424
+ });
425
+
426
+ test("transitions the folder and every descendant, each from its own path", async () => {
427
+ const accountId = randomId();
428
+ const { parent, child, grandchild } = await seedSubtree(accountId);
429
+
430
+ const written = await repo.transitionSubtree(
431
+ accountId,
432
+ parent.mailboxId,
433
+ {
434
+ from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
435
+ to: MailboxSyncStatus.pending,
436
+ rowSet: (row) => ({
437
+ pendingPath: row.fullPath.replace("Work", "Archive"),
438
+ }),
439
+ },
440
+ );
441
+
442
+ assert.equal(written?.length, 3);
443
+ assert.equal(
444
+ (await repo.get(accountId, parent.mailboxId)).pendingPath,
445
+ "Archive",
446
+ );
447
+ assert.equal(
448
+ (await repo.get(accountId, child.mailboxId)).pendingPath,
449
+ "Archive/Projects",
450
+ );
451
+ assert.equal(
452
+ (await repo.get(accountId, grandchild.mailboxId)).pendingPath,
453
+ "Archive/Projects/Alpha",
454
+ );
455
+ });
456
+
457
+ test("one descendant outside `from` refuses the intent and writes nothing", async () => {
458
+ const accountId = randomId();
459
+ const { parent, child, grandchild } = await seedSubtree(accountId);
460
+ await repo.transition(accountId, grandchild.mailboxId, {
461
+ from: [MailboxSyncStatus.synced],
462
+ to: MailboxSyncStatus.deleting,
463
+ });
464
+
465
+ const refused = await repo.transitionSubtree(
466
+ accountId,
467
+ parent.mailboxId,
468
+ {
469
+ from: [MailboxSyncStatus.synced],
470
+ to: MailboxSyncStatus.pending,
471
+ rowSet: (row) => ({ pendingPath: `Moved/${row.fullPath}` }),
472
+ },
473
+ );
474
+
475
+ assert.equal(refused, null);
476
+ for (const row of [parent, child]) {
477
+ const after = await repo.get(accountId, row.mailboxId);
478
+ assert.equal(after.syncStatus, MailboxSyncStatus.synced);
479
+ assert.equal(after.pendingPath, undefined);
480
+ }
481
+ assert.equal(
482
+ (await repo.get(accountId, grandchild.mailboxId)).syncStatus,
483
+ MailboxSyncStatus.deleting,
484
+ );
485
+ });
486
+
487
+ test("the from-state predicate rides each UPDATE, not a prior read", async () => {
488
+ // A read-then-check-then-write version passes the case above and still
489
+ // misses a row that leaves an accepted state after the read. The
490
+ // predicate is on the UPDATE, so the affected-row count catches it.
491
+ const accountId = randomId();
492
+ const { parent, child } = await seedSubtree(accountId);
493
+
494
+ const refused = await repo.transitionSubtree(
495
+ accountId,
496
+ parent.mailboxId,
497
+ {
498
+ from: [MailboxSyncStatus.synced],
499
+ to: MailboxSyncStatus.pending,
500
+ rowSet: (row) => {
501
+ if (row.mailboxId === parent.mailboxId) {
502
+ // Not a real concurrent writer — the shape of one. The row
503
+ // leaves `synced` after the subtree was resolved.
504
+ db.update(mailboxTable)
505
+ .set({ syncStatus: MailboxSyncStatus.deleting })
506
+ .where(eq(mailboxTable.mailboxId, child.mailboxId))
507
+ .run();
508
+ }
509
+ return { pendingPath: row.fullPath };
510
+ },
511
+ },
512
+ );
513
+
514
+ assert.equal(refused, null);
515
+ assert.equal(
516
+ (await repo.get(accountId, parent.mailboxId)).syncStatus,
517
+ MailboxSyncStatus.synced,
518
+ );
519
+ });
520
+
521
+ test("an absent folder is a null", async () => {
522
+ assert.equal(
523
+ await repo.transitionSubtree(randomId(), "no-such-mailbox", {
524
+ from: EVERY_STATE,
525
+ to: MailboxSyncStatus.pending,
526
+ rowSet: () => ({}),
527
+ }),
528
+ null,
529
+ );
530
+ });
531
+ });
532
+
533
+ describe("deleteMailboxWithMail — the folder's mail goes with it (D8)", () => {
534
+ const seedMessage = async (mailboxId: string, uid: number) => {
535
+ const messageId = randomId();
536
+ await new DrizzleMessageRepository(db as never).create({
537
+ messageId,
538
+ mailboxId,
539
+ uid,
540
+ sequenceNumber: uid,
541
+ rfc822Size: 10,
542
+ internalDate: 1700000000000,
543
+ envelopeId: deriveEnvelopeId(messageId),
544
+ rootBodyPartId: deriveRootBodyPartId(messageId),
545
+ });
546
+ await db.insert(threadMessageTable).values({
547
+ threadMessageId: randomId(),
548
+ accountConfigId: "acct",
549
+ threadId: randomId(),
550
+ messageId,
551
+ mailboxId,
552
+ uid,
553
+ referenceOrder: 0,
554
+ internalDate: 1700000000000,
555
+ sentDate: 1700000000000,
556
+ isRead: false,
557
+ hasAttachment: false,
558
+ hasStars: false,
559
+ isDeleted: false,
560
+ createdAt: 1700000000000,
561
+ updatedAt: 1700000000000,
562
+ });
563
+ return messageId;
564
+ };
565
+
566
+ const outboxEventsFor = async (messageId: string) =>
567
+ db.select().from(outboxTable).where(eq(outboxTable.messageId, messageId));
568
+
569
+ test("removes the folder, its mail and its own child rows, and nothing else's", async () => {
570
+ const accountId = randomId();
571
+ const doomed = await repo.create(makeMailboxInput(accountId, "Receipts"));
572
+ const spared = await repo.create(makeMailboxInput(accountId, "Keep"));
573
+ const doomedMessage = await seedMessage(doomed.mailboxId, 1);
574
+ const sparedMessage = await seedMessage(spared.mailboxId, 1);
575
+ await db.insert(mailboxSpecialUseTable).values({
576
+ mailboxSpecialUseId: randomId(),
577
+ mailboxId: doomed.mailboxId,
578
+ specialUse: "Archive",
579
+ });
580
+
581
+ await repo.deleteMailboxWithMail(accountId, doomed.mailboxId);
582
+
583
+ await assert.rejects(
584
+ () => repo.get(accountId, doomed.mailboxId),
585
+ /Mailbox not found/,
586
+ );
587
+ assert.deepEqual(
588
+ await db
589
+ .select()
590
+ .from(messageTable)
591
+ .where(eq(messageTable.mailboxId, doomed.mailboxId)),
592
+ [],
593
+ );
594
+ assert.deepEqual(
595
+ await db
596
+ .select()
597
+ .from(threadMessageTable)
598
+ .where(eq(threadMessageTable.mailboxId, doomed.mailboxId)),
599
+ [],
600
+ );
601
+ assert.deepEqual(
602
+ await db
603
+ .select()
604
+ .from(mailboxSpecialUseTable)
605
+ .where(eq(mailboxSpecialUseTable.mailboxId, doomed.mailboxId)),
606
+ [],
607
+ );
608
+
609
+ // The search index is cleared by the outbox row, not by the delete.
610
+ const removals = await outboxEventsFor(doomedMessage);
611
+ assert.deepEqual(
612
+ removals.map((row) => row.event),
613
+ ["message.removed"],
614
+ );
615
+
616
+ const survivors = await db
617
+ .select()
618
+ .from(messageTable)
619
+ .where(eq(messageTable.mailboxId, spared.mailboxId));
620
+ assert.equal(survivors.length, 1);
621
+ assert.equal(
622
+ (await outboxEventsFor(sparedMessage)).some(
623
+ (row) => row.event === "message.removed",
624
+ ),
625
+ false,
626
+ );
627
+ assert.equal(
628
+ (await repo.get(accountId, spared.mailboxId)).mailboxId,
629
+ spared.mailboxId,
630
+ );
631
+ });
632
+
633
+ test("leaves a filter bound to the folder alone", async () => {
634
+ // D16 refuses the delete while a binding stands, so there is nothing to
635
+ // unbind — and deleting a user's filters as a side effect of a folder
636
+ // delete is the outcome the design rules out. This is the test that
637
+ // stops a future refactor doing it.
638
+ const accountId = randomId();
639
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Bound"));
640
+ const filterId = randomId();
641
+ await db.insert(filterTable).values({
642
+ filterId,
643
+ accountConfigId: accountId,
644
+ name: "Invoices → Bound",
645
+ scope: "Standing",
646
+ ruleChangedAt: 0,
647
+ actionChangedAt: 0,
648
+ actionMailboxId: mailbox.mailboxId,
649
+ createdAt: 0,
650
+ updatedAt: 0,
651
+ });
652
+
653
+ await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
654
+
655
+ const rows = await db
656
+ .select()
657
+ .from(filterTable)
658
+ .where(eq(filterTable.filterId, filterId));
659
+ assert.equal(rows.length, 1);
660
+ assert.equal(rows[0].actionMailboxId, mailbox.mailboxId);
661
+ });
662
+
663
+ test("re-running after a partial removal completes", async () => {
664
+ const accountId = randomId();
665
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Resume"));
666
+ const first = await seedMessage(mailbox.mailboxId, 1);
667
+ await seedMessage(mailbox.mailboxId, 2);
668
+
669
+ // The shape an interruption leaves: one message's rows already gone,
670
+ // the mailbox row still there and still `deleting`.
671
+ await runInTransaction(db, (tx) => deleteMessageSubtree(tx, [first]));
672
+ await repo.transition(accountId, mailbox.mailboxId, {
673
+ from: EVERY_STATE,
674
+ to: MailboxSyncStatus.deleting,
675
+ });
676
+
677
+ await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
678
+
679
+ await assert.rejects(
680
+ () => repo.get(accountId, mailbox.mailboxId),
681
+ /Mailbox not found/,
682
+ );
683
+ assert.deepEqual(
684
+ await db
685
+ .select()
686
+ .from(messageTable)
687
+ .where(eq(messageTable.mailboxId, mailbox.mailboxId)),
688
+ [],
689
+ );
690
+ });
691
+
692
+ test("cross-tenant: a foreign account removes neither the row nor its mail", async () => {
693
+ const accountId = randomId();
694
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Mine"));
695
+ await seedMessage(mailbox.mailboxId, 1);
696
+
697
+ await repo.deleteMailboxWithMail(randomId(), mailbox.mailboxId);
698
+
699
+ assert.equal(
700
+ (await repo.get(accountId, mailbox.mailboxId)).mailboxId,
701
+ mailbox.mailboxId,
702
+ );
703
+ assert.equal(
704
+ (
705
+ await db
706
+ .select()
707
+ .from(messageTable)
708
+ .where(eq(messageTable.mailboxId, mailbox.mailboxId))
709
+ ).length,
710
+ 1,
711
+ );
712
+ });
713
+ });
714
+
220
715
  describe("continuation token rejection (#172)", () => {
221
716
  for (const [label, token] of [
222
717
  ["an unparseable", "not-a-cursor"],
@@ -3,16 +3,32 @@ import type {
3
3
  CreateMailboxInput,
4
4
  IMailboxRepository,
5
5
  MailboxItem,
6
+ MailboxStatePredicate,
7
+ MailboxSubtreeTransitionIntent,
8
+ MailboxTransitionIntent,
9
+ MailboxTransitionWrite,
6
10
  ResultList,
7
11
  UpdateMailboxInput,
8
12
  } from "@remit/data-ports";
9
- import { MailboxCursorState, MailboxSyncStatus } from "@remit/domain-enums";
10
- import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
13
+ import { MailboxCursorState } from "@remit/domain-enums";
14
+ import { and, asc, eq, gt, inArray, isNull, or, type SQL } from "drizzle-orm";
11
15
  import shortUuid from "short-uuid";
12
16
  import type { Db } from "../db.js";
13
17
  import { NotFoundError } from "../error.js";
14
18
  import { decodeToken, resultList } from "../pagination.js";
15
- import { mailboxTable } from "../schema/i4-mailbox.js";
19
+ import {
20
+ mailboxAttributeTable,
21
+ mailboxFlagTable,
22
+ mailboxSpecialUseTable,
23
+ mailboxTable,
24
+ } from "../schema/i4-mailbox.js";
25
+ import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
26
+ import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
27
+ import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
28
+ import { messageTable } from "../schema/message-data.js";
29
+ import { threadMessageTable } from "../schema/thread-message.js";
30
+ import { runInTransaction } from "../tx.js";
31
+ import { deleteMessageSubtree } from "./message.js";
16
32
 
17
33
  const base36Translator = shortUuid.createTranslator(
18
34
  shortUuid.constants.uuid25Base36,
@@ -21,6 +37,63 @@ const generateMailboxId = () => base36Translator.fromUUID(randomUUID());
21
37
 
22
38
  type DB = Db<Record<string, unknown>>;
23
39
 
40
+ /**
41
+ * Message subtrees removed per transaction by {@link MailboxRepo.deleteMailboxWithMail},
42
+ * matching `SUBTREE_BATCH_SIZE` in the account purge. On SQLite each batch holds
43
+ * the process's only write slot, so the bound is what keeps a large folder's
44
+ * delete from parking every other writer behind it (D8).
45
+ */
46
+ const MAIL_DELETE_BATCH_SIZE = 100;
47
+
48
+ /** Refuses a subtree intent from inside the transaction, so the throw is the rollback. */
49
+ class SubtreeContested extends Error {
50
+ constructor() {
51
+ super("mailbox subtree transition contested");
52
+ this.name = "SubtreeContested";
53
+ }
54
+ }
55
+
56
+ /** The WHERE terms of a folder-state transition (folder-rename-and-delete.md D3). */
57
+ const stateTerms = (expected: MailboxStatePredicate): SQL[] => {
58
+ const terms: SQL[] = [
59
+ inArray(mailboxTable.syncStatus, [...expected.from]),
60
+ ] as SQL[];
61
+ if (expected.wherePendingPath === undefined) return terms;
62
+ terms.push(
63
+ expected.wherePendingPath === null
64
+ ? isNull(mailboxTable.pendingPath)
65
+ : eq(mailboxTable.pendingPath, expected.wherePendingPath),
66
+ );
67
+ return terms;
68
+ };
69
+
70
+ /**
71
+ * A rename target only means something while a rename is outstanding or has
72
+ * just failed, so the two states that cannot carry one drop it here rather than
73
+ * relying on every caller to remember. That is what makes the invariant — a
74
+ * non-null `pendingPath` only under `pending` or `failed` — hold by
75
+ * construction: this is the only writer of either field, and `synced` with a
76
+ * target on it is the seventh combination the design calls unreachable.
77
+ */
78
+ const KEEPS_A_RENAME_TARGET: readonly MailboxItem["syncStatus"][] = [
79
+ "pending",
80
+ "failed",
81
+ ];
82
+
83
+ const transitionSet = (
84
+ to: MailboxItem["syncStatus"],
85
+ write: MailboxTransitionWrite | undefined,
86
+ ): Partial<typeof mailboxTable.$inferInsert> => ({
87
+ syncStatus: to,
88
+ ...(write?.fullPath !== undefined ? { fullPath: write.fullPath } : {}),
89
+ ...(KEEPS_A_RENAME_TARGET.includes(to)
90
+ ? write?.pendingPath !== undefined
91
+ ? { pendingPath: write.pendingPath }
92
+ : {}
93
+ : { pendingPath: null }),
94
+ updatedAt: Date.now(),
95
+ });
96
+
24
97
  export function rowToMailbox(
25
98
  row: typeof mailboxTable.$inferSelect,
26
99
  ): MailboxItem {
@@ -43,9 +116,9 @@ export function rowToMailbox(
43
116
  lastMessageSyncAt: row.lastMessageSyncAt,
44
117
  initialSyncCompletedAt: row.initialSyncCompletedAt ?? undefined,
45
118
  parentMailboxId: row.parentMailboxId,
46
- syncStatus: (row.syncStatus as MailboxItem["syncStatus"]) ?? undefined,
119
+ syncStatus: row.syncStatus as MailboxItem["syncStatus"],
120
+ ...(row.pendingPath !== null ? { pendingPath: row.pendingPath } : {}),
47
121
  cursorState: (row.cursorState as MailboxItem["cursorState"]) ?? undefined,
48
- oldPath: row.oldPath ?? undefined,
49
122
  specialUse: (row.specialUse as MailboxItem["specialUse"]) ?? undefined,
50
123
  createdAt: row.createdAt,
51
124
  updatedAt: row.updatedAt,
@@ -78,9 +151,11 @@ export class MailboxRepo implements IMailboxRepository {
78
151
  lastMessageSyncAt: input.lastMessageSyncAt,
79
152
  initialSyncCompletedAt: input.initialSyncCompletedAt,
80
153
  parentMailboxId: input.parentMailboxId ?? "",
81
- syncStatus: input.syncStatus,
154
+ // Total per D1: an insert that names no state is a folder the
155
+ // server just told us about, and a folder the server told us
156
+ // about is confirmed.
157
+ syncStatus: input.syncStatus ?? "synced",
82
158
  cursorState: input.cursorState ?? MailboxCursorState.normal,
83
- oldPath: input.oldPath,
84
159
  specialUse: input.specialUse ?? null,
85
160
  createdAt: now,
86
161
  updatedAt: now,
@@ -161,16 +236,12 @@ export class MailboxRepo implements IMailboxRepository {
161
236
  updates.initialSyncCompletedAt = input.initialSyncCompletedAt;
162
237
  if (input.parentMailboxId !== undefined)
163
238
  updates.parentMailboxId = input.parentMailboxId;
164
- if (input.syncStatus !== undefined) updates.syncStatus = input.syncStatus;
165
239
  if (input.cursorState !== undefined)
166
240
  updates.cursorState = input.cursorState;
167
- if (input.oldPath !== undefined) updates.oldPath = input.oldPath;
168
241
  if (input.specialUse !== undefined) updates.specialUse = input.specialUse;
169
242
 
170
243
  if (remove) {
171
244
  for (const field of remove) {
172
- if (field === "syncStatus") updates.syncStatus = null;
173
- if (field === "oldPath") updates.oldPath = null;
174
245
  if (field === "specialUse") updates.specialUse = null;
175
246
  }
176
247
  }
@@ -189,6 +260,81 @@ export class MailboxRepo implements IMailboxRepository {
189
260
  return rowToMailbox(row);
190
261
  }
191
262
 
263
+ async transition(
264
+ accountId: string,
265
+ mailboxId: string,
266
+ intent: MailboxTransitionIntent,
267
+ ): Promise<MailboxItem | null> {
268
+ const [row] = await this.db
269
+ .update(mailboxTable)
270
+ .set(transitionSet(intent.to, intent.set))
271
+ .where(
272
+ and(
273
+ eq(mailboxTable.accountId, accountId),
274
+ eq(mailboxTable.mailboxId, mailboxId),
275
+ ...stateTerms(intent),
276
+ ),
277
+ )
278
+ .returning();
279
+ return row ? rowToMailbox(row) : null;
280
+ }
281
+
282
+ async transitionSubtree(
283
+ accountId: string,
284
+ mailboxId: string,
285
+ intent: MailboxSubtreeTransitionIntent,
286
+ ): Promise<MailboxItem[] | null> {
287
+ return runInTransaction(this.db, async (tx) => {
288
+ const repo = new MailboxRepo(tx);
289
+ const root = await repo
290
+ .get(accountId, mailboxId)
291
+ .catch((error: unknown) => {
292
+ if (error instanceof NotFoundError) return null;
293
+ throw error;
294
+ });
295
+ if (!root) return null;
296
+
297
+ const subtree = [
298
+ root,
299
+ ...(await repo.findByPathPrefix(
300
+ accountId,
301
+ root.fullPath,
302
+ root.hierarchyDelimiter,
303
+ )),
304
+ ];
305
+
306
+ const written: MailboxItem[] = [];
307
+ for (const row of subtree) {
308
+ // The from-state predicate rides each UPDATE rather than a read
309
+ // taken before them (D3). Read-then-check-then-write is safe on
310
+ // SQLite only because `runInTransaction` serializes top-level
311
+ // writes; under Postgres READ COMMITTED a single-row transition
312
+ // committing in between is missed entirely.
313
+ const [updated] = await tx
314
+ .update(mailboxTable)
315
+ .set(transitionSet(intent.to, intent.rowSet(row)))
316
+ .where(
317
+ and(
318
+ eq(mailboxTable.accountId, accountId),
319
+ eq(mailboxTable.mailboxId, row.mailboxId),
320
+ inArray(mailboxTable.syncStatus, [...intent.from]),
321
+ ),
322
+ )
323
+ .returning();
324
+ if (updated) written.push(rowToMailbox(updated));
325
+ }
326
+
327
+ // A subtree cannot be half-renamed: one row that moved out from under
328
+ // this call refuses the whole intent, and the throw is what rolls the
329
+ // rest back.
330
+ if (written.length !== subtree.length) throw new SubtreeContested();
331
+ return written;
332
+ }).catch((error: unknown) => {
333
+ if (error instanceof SubtreeContested) return null;
334
+ throw error;
335
+ });
336
+ }
337
+
192
338
  async resolveAccountId(mailboxId: string): Promise<string | null> {
193
339
  const [row] = await this.db
194
340
  .select({ accountId: mailboxTable.accountId })
@@ -335,22 +481,78 @@ export class MailboxRepo implements IMailboxRepository {
335
481
  return rows.map(rowToMailbox);
336
482
  }
337
483
 
338
- async renameChildPaths(
484
+ async deleteMailboxWithMail(
339
485
  accountId: string,
340
- oldPath: string,
341
- newPath: string,
342
- delimiter = "/",
486
+ mailboxId: string,
343
487
  ): Promise<void> {
344
- const children = await this.findByPathPrefix(accountId, oldPath, delimiter);
345
- for (const child of children) {
346
- const newChildPath = child.fullPath.replace(oldPath, newPath);
347
- // Mark the child pending, like the renamed parent: its new path is not
348
- // on the server until MAILBOX_RENAME lands, so a reconcile running in
349
- // that window must not reap it as server-deleted (#290).
350
- await this.update(accountId, child.mailboxId, {
351
- fullPath: newChildPath,
352
- syncStatus: MailboxSyncStatus.pending,
488
+ // Tenant scope, and the re-entry guard in the same read: a redelivery that
489
+ // arrives after the final commit finds no row and has nothing left to do,
490
+ // exactly as `delete` no-ops. Every removal below keys on `mailboxId`
491
+ // alone, so this is what stops a foreign accountId reaching them.
492
+ const [owned] = await this.db
493
+ .select({ mailboxId: mailboxTable.mailboxId })
494
+ .from(mailboxTable)
495
+ .where(
496
+ and(
497
+ eq(mailboxTable.accountId, accountId),
498
+ eq(mailboxTable.mailboxId, mailboxId),
499
+ ),
500
+ );
501
+ if (!owned) return;
502
+
503
+ // Ordered, batched and resumable rather than one transaction (D8). The
504
+ // caller keeps the row `deleting` until the last commit, so an interrupted
505
+ // run re-enters here and continues against whatever is left.
506
+ for (;;) {
507
+ const rows = await this.db
508
+ .select({ messageId: messageTable.messageId })
509
+ .from(messageTable)
510
+ .where(eq(messageTable.mailboxId, mailboxId))
511
+ .limit(MAIL_DELETE_BATCH_SIZE);
512
+ if (rows.length === 0) break;
513
+ const messageIds = rows.map((row) => row.messageId);
514
+
515
+ await runInTransaction(this.db, async (tx) => {
516
+ // The primitive the rest of the codebase deletes mail with: nine
517
+ // per-message child tables plus one `message.removed` outbox row
518
+ // each, which is what clears the search index. A bespoke table
519
+ // list would orphan those nine and leave deleted mail searchable.
520
+ await deleteMessageSubtree(tx, messageIds);
521
+ await tx
522
+ .delete(threadMessageTable)
523
+ .where(inArray(threadMessageTable.messageId, messageIds));
353
524
  });
354
525
  }
526
+
527
+ await this.db
528
+ .delete(mailboxSpecialUseTable)
529
+ .where(eq(mailboxSpecialUseTable.mailboxId, mailboxId));
530
+ await this.db
531
+ .delete(mailboxAttributeTable)
532
+ .where(eq(mailboxAttributeTable.mailboxId, mailboxId));
533
+ await this.db
534
+ .delete(mailboxFlagTable)
535
+ .where(eq(mailboxFlagTable.mailboxId, mailboxId));
536
+ await this.db
537
+ .delete(mailboxLockTable)
538
+ .where(eq(mailboxLockTable.mailboxId, mailboxId));
539
+ await this.db
540
+ .delete(messageFlagPushTable)
541
+ .where(eq(messageFlagPushTable.mailboxId, mailboxId));
542
+ await this.db
543
+ .delete(messagePlacementMoveTable)
544
+ .where(
545
+ or(
546
+ eq(messagePlacementMoveTable.sourceMailboxId, mailboxId),
547
+ eq(messagePlacementMoveTable.destinationMailboxId, mailboxId),
548
+ ),
549
+ );
550
+
551
+ // `filter` also carries a mailboxId and is deliberately not in that list:
552
+ // D16 refuses the delete while any filter or role appointment is bound, so
553
+ // there is nothing to unbind, and deleting a user's filters as a side
554
+ // effect of a folder delete is the outcome the design rules out.
555
+
556
+ await this.delete(accountId, mailboxId);
355
557
  }
356
558
  }
@@ -2,3 +2,5 @@ import * as entities from "@remit/drizzle-sqlite-schema";
2
2
 
3
3
  export const mailboxTable = entities.mailboxes;
4
4
  export const mailboxSpecialUseTable = entities.mailboxSpecialUseEntries;
5
+ export const mailboxAttributeTable = entities.mailboxAttributeEntries;
6
+ export const mailboxFlagTable = entities.mailboxFlags;