@remit/drizzle-service 0.0.82 → 0.0.84

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.
@@ -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
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The guard that keeps an instance installed by an earlier build from running a
3
+ * predicate its index cannot answer.
4
+ *
5
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table, so
6
+ * adding an indexed column changes nothing on a database that already has the
7
+ * old one — and a MATCH naming that column raises rather than missing. The
8
+ * migrator compares the shipped shape with the installed one and rebuilds.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { describe, test } from "node:test";
13
+ import {
14
+ searchIndexColumns,
15
+ searchIndexShapeIsCurrent,
16
+ } from "./search-index-shape.js";
17
+
18
+ const shipped = readFileSync(
19
+ new URL("../../../../npm-scripts/sqlite-search-index.sql", import.meta.url),
20
+ "utf8",
21
+ );
22
+
23
+ const installed = (columns: string[]): string =>
24
+ `CREATE VIRTUAL TABLE thread_message_fts USING fts5(${columns.join(", ")}, content='thread_message', content_rowid='rowid', tokenize='trigram remove_diacritics 1')`;
25
+
26
+ describe("searchIndexColumns", () => {
27
+ test("reads the indexed columns and leaves the options out", () => {
28
+ assert.deepEqual(searchIndexColumns(installed(["subject", "sender"])), [
29
+ "subject",
30
+ "sender",
31
+ ]);
32
+ });
33
+
34
+ test("reads the shipped index, triggers and all", () => {
35
+ assert.deepEqual(searchIndexColumns(shipped), [
36
+ "subject",
37
+ "sender",
38
+ "body",
39
+ ]);
40
+ });
41
+
42
+ test("a table that is not an fts5 index has no columns", () => {
43
+ assert.deepEqual(searchIndexColumns("CREATE TABLE t (a, b)"), []);
44
+ });
45
+ });
46
+
47
+ describe("searchIndexShapeIsCurrent", () => {
48
+ test("the shipped index matches itself", () => {
49
+ assert.equal(searchIndexShapeIsCurrent(shipped, shipped), true);
50
+ });
51
+
52
+ // The upgrade this exists for: an instance carrying the two-column index the
53
+ // previous build installed must be rebuilt, or every search raises.
54
+ test("an index missing an indexed column is stale", () => {
55
+ assert.equal(
56
+ searchIndexShapeIsCurrent(installed(["subject", "sender"]), shipped),
57
+ false,
58
+ );
59
+ });
60
+
61
+ test("an index carrying every shipped column is current", () => {
62
+ assert.equal(
63
+ searchIndexShapeIsCurrent(
64
+ installed(["subject", "sender", "body"]),
65
+ shipped,
66
+ ),
67
+ true,
68
+ );
69
+ });
70
+
71
+ // An extra column indexes text nothing reads. It costs space and matches
72
+ // nothing wrong, so it is not a reason to drop and re-tokenize the table.
73
+ test("an index carrying more than the shipped columns is current", () => {
74
+ assert.equal(
75
+ searchIndexShapeIsCurrent(
76
+ installed(["subject", "sender", "body", "labels"]),
77
+ shipped,
78
+ ),
79
+ true,
80
+ );
81
+ });
82
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Whether an installed FTS5 search index carries the columns the shipped one
3
+ * declares.
4
+ *
5
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against a table that already
6
+ * exists, so a database installed by an earlier build keeps that build's
7
+ * columns for good. A predicate naming a column that build never indexed does
8
+ * not degrade — SQLite raises, and every search on that instance fails — so the
9
+ * migrator compares the two shapes and rebuilds where they differ.
10
+ *
11
+ * Both sides are read from DDL rather than from a hand-kept list: the shipped
12
+ * side is the committed `.sql`, the installed side is what `sqlite_master`
13
+ * holds, and neither can drift from what is actually there.
14
+ */
15
+
16
+ /** The text between the parentheses of a `USING fts5(...)` clause. */
17
+ const fts5Arguments = (ddl: string): string => {
18
+ const clause = ddl.search(/using\s+fts5\s*\(/i);
19
+ if (clause === -1) return "";
20
+ const open = ddl.indexOf("(", clause);
21
+ let depth = 0;
22
+ for (let index = open; index < ddl.length; index++) {
23
+ const char = ddl[index];
24
+ if (char === "(") depth++;
25
+ if (char !== ")") continue;
26
+ depth--;
27
+ if (depth === 0) return ddl.slice(open + 1, index);
28
+ }
29
+ return "";
30
+ };
31
+
32
+ /**
33
+ * The indexed column names an FTS5 table declares. An argument carrying an `=`
34
+ * is an option (`content=`, `tokenize=`), not a column, and anything quoted or
35
+ * otherwise unusual is left out rather than guessed at — a name this cannot
36
+ * read is a name the comparison must not claim is missing.
37
+ */
38
+ export const searchIndexColumns = (ddl: string): string[] =>
39
+ fts5Arguments(ddl)
40
+ .split(",")
41
+ .map((argument) => argument.trim())
42
+ .filter((argument) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(argument));
43
+
44
+ /**
45
+ * Whether `installed` indexes every column `shipped` does. A superset passes:
46
+ * an extra column indexes text no predicate reads, which costs space and
47
+ * matches nothing wrong.
48
+ */
49
+ export const searchIndexShapeIsCurrent = (
50
+ installed: string,
51
+ shipped: string,
52
+ ): boolean => {
53
+ const columns = new Set(searchIndexColumns(installed));
54
+ const expected = searchIndexColumns(shipped);
55
+ return expected.length > 0 && expected.every((column) => columns.has(column));
56
+ };