@remit/drizzle-service 0.0.1

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.
Files changed (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. package/tsconfig.json +8 -0
@@ -0,0 +1,15 @@
1
+ const url =
2
+ process.env.DATABASE_URL ??
3
+ process.env.PG_CONNECTION_URL ??
4
+ "postgresql://remit:remit@localhost:5432/remit_dev";
5
+
6
+ export default {
7
+ dialect: "postgresql",
8
+ schema: "./src/schema.ts",
9
+ out: "./.drizzle",
10
+ dbCredentials: { url },
11
+ // The better-auth identity tables (auth_*) share this database but are owned
12
+ // by remit-auth-service's own drizzle config. Exclude them here so an entity
13
+ // push never proposes dropping tables it does not manage.
14
+ tablesFilter: ["!auth_*"],
15
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@remit/drizzle-service",
3
+ "version": "0.0.1",
4
+ "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test:typecheck": "tsgo --noEmit -p tsconfig.json",
16
+ "test:run:pg": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/!(*.sqlite).test.ts'",
17
+ "test:run:sqlite": "node --env-file=../../localhost-test-unit-sqlite.env --import tsx --test 'src/**/*.sqlite.test.ts'",
18
+ "test:run": "npm run test:run:pg && npm run test:run:sqlite",
19
+ "test": "npm run test:typecheck && npm run test:run",
20
+ "fix": "biome check --write src"
21
+ },
22
+ "devDependencies": {
23
+ "@remit/api-zod-schemas": "*",
24
+ "@types/better-sqlite3": "^7.6.13",
25
+ "@types/pg": "^8.0.0",
26
+ "better-sqlite3": "^12.11.1",
27
+ "drizzle-kit": "^0.31.1",
28
+ "embedded-postgres": "^18.4.0-beta.17",
29
+ "tsx": "*",
30
+ "zod": "*"
31
+ },
32
+ "dependencies": {
33
+ "@remit/data-ports": "*",
34
+ "@remit/domain-enums": "*",
35
+ "drizzle-orm": "^0.45.2",
36
+ "pg": "^8.0.0",
37
+ "postgres": "^3.4.5",
38
+ "short-uuid": "*",
39
+ "uuid": "*",
40
+ "@remit/drizzle-pg-schema": "*",
41
+ "@remit/drizzle-sqlite-schema": "*"
42
+ },
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/remit-mail/remit.git",
50
+ "directory": "packages/drizzle-service"
51
+ }
52
+ }
package/src/db.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
2
+ import type { PgDatabase } from "drizzle-orm/pg-core";
3
+
4
+ /**
5
+ * A drizzle handle a repository can run on: the top-level database or a
6
+ * transaction (or savepoint) bound to one. Both share the query-builder API, so
7
+ * a repo constructed with either behaves the same — standalone, or enlisted in a
8
+ * unit-of-work transaction.
9
+ */
10
+ export type Db<TSchema extends Record<string, unknown>> = PgDatabase<
11
+ NodePgQueryResultHKT,
12
+ TSchema
13
+ >;
package/src/dialect.ts ADDED
@@ -0,0 +1,13 @@
1
+ // The SQL dialect this process runs against (RFC 036 D1). One deployment is
2
+ // one backend, chosen once at startup by `DATA_BACKEND`: `sqlite` selects the
3
+ // SQLite entity tables, outbox table, transaction strategy, and search
4
+ // predicates; anything else keeps the Postgres behavior that predates this
5
+ // switch. Read once at module load — the repos and schema facade branch on it,
6
+ // and a single process never mixes dialects.
7
+
8
+ export type SqlDialect = "postgres" | "sqlite";
9
+
10
+ export const SQL_DIALECT: SqlDialect =
11
+ process.env.DATA_BACKEND === "sqlite" ? "sqlite" : "postgres";
12
+
13
+ export const isSqlite = (): boolean => SQL_DIALECT === "sqlite";
package/src/error.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Re-export the shared error contract so an error thrown by a pg repo is the
2
+ // same class the backend handlers and workers already catch (they import these
3
+ // from @remit/data-ports/errors). Defining parallel classes here made
4
+ // `instanceof NotFoundError` false across the adapter boundary — a fresh user's
5
+ // GET /config surfaced a 404 fatal overlay instead of the empty-config path.
6
+ export {
7
+ CreateFailedConflictError,
8
+ ForbiddenError,
9
+ NotFoundError,
10
+ } from "@remit/data-ports/errors";
11
+
12
+ export class NotImplementedError extends Error {
13
+ name = "NotImplementedError";
14
+ statusCode = 501;
15
+ }
16
+
17
+ const PG_UNIQUE_VIOLATION = "23505";
18
+ // better-sqlite3 raises a `SqliteError` whose `.code` is one of these for a
19
+ // primary-key or unique-index collision (RFC 036 D1).
20
+ const SQLITE_UNIQUE_VIOLATIONS = new Set([
21
+ "SQLITE_CONSTRAINT_UNIQUE",
22
+ "SQLITE_CONSTRAINT_PRIMARYKEY",
23
+ ]);
24
+
25
+ // Drizzle wraps the underlying driver error and carries the original (with its
26
+ // SQLSTATE / SQLite `code`) on `.cause`, so walk the cause chain — covers both
27
+ // the pg and better-sqlite3 drivers.
28
+ export const isUniqueViolation = (error: unknown): boolean => {
29
+ let current: unknown = error;
30
+ while (current) {
31
+ const code = (current as { code?: string }).code;
32
+ if (code === PG_UNIQUE_VIOLATION) return true;
33
+ if (code !== undefined && SQLITE_UNIQUE_VIOLATIONS.has(code)) return true;
34
+ current = (current as { cause?: unknown }).cause;
35
+ }
36
+ return false;
37
+ };
package/src/id.ts ADDED
@@ -0,0 +1,50 @@
1
+ import shortUuid from "short-uuid";
2
+ import { v5 as uuidv5 } from "uuid";
3
+
4
+ const REMIT_NAMESPACE = "9e89694d-214b-4d9b-99f5-214b4d9b99f5";
5
+
6
+ const base36 = shortUuid.createTranslator(shortUuid.constants.uuid25Base36);
7
+
8
+ // A 25-char base36-encoded UUID, matching the id format the API contract
9
+ // enforces (path params carry minLength 25) and the electrodb adapter's
10
+ // base36uuid. The prior Math.random-based generator produced variable-length
11
+ // ids (~19 chars) that failed `{accountId}`/`{messageId}` path validation.
12
+ export const randomId = (): string => base36.generate();
13
+
14
+ export const generateDeterministicId = (name: string): string =>
15
+ uuidv5(name, REMIT_NAMESPACE);
16
+
17
+ // Deterministic base36-encoded UUIDv5, byte-identical to the electrodb
18
+ // adapter's `base36uuidv5` so a `MessageLabel` derived from the same
19
+ // (messageId, labelId) keys the same row on either backend.
20
+ export const deterministicBase36Id = (name: string): string =>
21
+ base36.fromUUID(uuidv5(name, REMIT_NAMESPACE));
22
+
23
+ export const bodyPartId = (messageId: string, partPath: string): string =>
24
+ generateDeterministicId(`${messageId}:${partPath}`);
25
+
26
+ export const rootBodyPartId = (messageId: string): string =>
27
+ bodyPartId(messageId, "0");
28
+
29
+ export const bodyPartParameterId = (
30
+ messageId: string,
31
+ partPath: string,
32
+ parameterName: string,
33
+ ): string =>
34
+ generateDeterministicId(`${messageId}:${partPath}:${parameterName}`);
35
+
36
+ export const envelopeId = (messageId: string): string =>
37
+ generateDeterministicId(`envelope:${messageId}`);
38
+
39
+ export const envelopeAddressId = (
40
+ messageId: string,
41
+ role: string,
42
+ order: number,
43
+ ): string =>
44
+ generateDeterministicId(`envelopeaddress:${messageId}:${role}:${order}`);
45
+
46
+ export const bodyPartContentId = (
47
+ messageId: string,
48
+ bodyPartId: string,
49
+ ): string =>
50
+ generateDeterministicId(`bodypartcontent:${messageId}:${bodyPartId}`);
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ export { CreateFailedConflictError, NotFoundError } from "./error.js";
2
+ export {
3
+ type CascadeDeleteLogger,
4
+ type CascadeDeleter,
5
+ type CascadeEntity,
6
+ createCascadeDeleter,
7
+ createSqliteCascadeDeleter,
8
+ runDrizzleCascadeDelete,
9
+ } from "./repos/cascade-delete.js";
10
+ export { DrizzleEnvelopeRepository } from "./repos/envelope.js";
11
+ export { FilterRepo } from "./repos/filter.js";
12
+ export { FilterAnchorRepo } from "./repos/filter-anchor.js";
13
+ export * from "./repos/i4-account.js";
14
+ export * from "./repos/i4-account-config.js";
15
+ export * from "./repos/i4-account-export-request.js";
16
+ export * from "./repos/i4-account-setting.js";
17
+ export * from "./repos/i4-address.js";
18
+ export * from "./repos/i4-mailbox.js";
19
+ export * from "./repos/i4-mailbox-lock.js";
20
+ export * from "./repos/i4-mailbox-special-use.js";
21
+ export {
22
+ type FlagPushOperation,
23
+ type MessageFlagPushItem,
24
+ MessageFlagPushRepo,
25
+ type MessageFlagPushState,
26
+ type PutMessageFlagPushInput,
27
+ } from "./repos/i4-message-flag-push.js";
28
+ export {
29
+ type MessagePlacementMoveItem,
30
+ MessagePlacementMoveRepo,
31
+ type PutMessagePlacementMoveInput,
32
+ } from "./repos/i4-message-placement-move.js";
33
+ export * from "./repos/i4-organize-job-request.js";
34
+ export * from "./repos/i4-outbox-message.js";
35
+ export { LabelRepo } from "./repos/label.js";
36
+ export {
37
+ DrizzleMessageRepository,
38
+ deleteMessageSubtree,
39
+ MESSAGE_REMOVED_EVENT,
40
+ } from "./repos/message.js";
41
+ export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
42
+ export { MessageLabelRepo } from "./repos/message-label.js";
43
+ export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
44
+ export { DrizzleUnitOfWork } from "./repos/unit-of-work.js";
45
+ export * from "./schema/i4-account-config.js";
46
+ export * from "./schema/i4-account-export-request.js";
47
+ export * from "./schema/i4-account-setting.js";
48
+ export * from "./schema/i4-address.js";
49
+ export * from "./schema/i4-mailbox.js";
50
+ export * from "./schema/i4-mailbox-lock.js";
51
+ export * from "./schema/i4-message-flag-push.js";
52
+ export * from "./schema/i4-message-placement-move.js";
53
+ export * from "./schema/i4-organize-job-request.js";
54
+ export * from "./schema/i4-outbox-message.js";
55
+ export * from "./schema/message-data.js";
56
+ export { messageDataSchema } from "./schema/message-data.js";
57
+ export {
58
+ createSqliteDatabase,
59
+ type SqliteClient,
60
+ type SqliteClientOptions,
61
+ } from "./sqlite-client.js";
62
+ export { runInTransaction, serializeSqliteWrites } from "./tx.js";
@@ -0,0 +1,29 @@
1
+ import type { ResultList } from "@remit/data-ports";
2
+
3
+ export function encodeToken(data: Record<string, unknown>): string {
4
+ return Buffer.from(JSON.stringify(data)).toString("base64url");
5
+ }
6
+
7
+ export function decodeToken(
8
+ token: string,
9
+ ): Record<string, unknown> | undefined {
10
+ try {
11
+ return JSON.parse(
12
+ Buffer.from(token, "base64url").toString("utf8"),
13
+ ) as Record<string, unknown>;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ export function resultList<T>(
20
+ items: T[],
21
+ limit: number | undefined,
22
+ lastKey?: Record<string, unknown>,
23
+ ): ResultList<T> {
24
+ const continuationToken =
25
+ lastKey && limit !== undefined && items.length === limit
26
+ ? encodeToken(lastKey)
27
+ : undefined;
28
+ return { items, continuationToken };
29
+ }
@@ -0,0 +1,159 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, before, describe, test } from "node:test";
6
+ import { MailboxCursorState } from "@remit/domain-enums";
7
+ import Database from "better-sqlite3";
8
+ import { messageDataSchema } from "../schema/message-data.js";
9
+ import {
10
+ accountTable,
11
+ envelopeTable,
12
+ mailboxTable,
13
+ messageTable,
14
+ } from "../schema.js";
15
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
16
+ import {
17
+ type CascadeEntity,
18
+ createSqliteCascadeDeleter,
19
+ } from "./cascade-delete.js";
20
+
21
+ // createSqliteCascadeDeleter opens the shared SQLite file itself, so the schema
22
+ // this harness pushes must live on a real file (not the in-memory default) for
23
+ // the deleter's own connection to see it. `deleteMessageSubtree` touches every
24
+ // message-data child table, so push the full message-data schema plus the
25
+ // account and mailbox containers.
26
+ const CASCADE_SCHEMA = {
27
+ ...messageDataSchema,
28
+ account: accountTable,
29
+ mailbox: mailboxTable,
30
+ };
31
+
32
+ const log = { info: () => {} };
33
+ const NOW = 1700000000000;
34
+ const ACCOUNT_CONFIG_ID = "cfg-sqlite-1";
35
+ const ACCOUNT_ID = "acc-sqlite-1";
36
+ const MAILBOX_ID = "mbx-sqlite-1";
37
+ const MESSAGE_ID = "msg-sqlite-1";
38
+ const ENVELOPE_ID = "00000000-0000-4000-8000-0000000000a1";
39
+ const ROOT_BODY_PART_ID = "00000000-0000-4000-8000-0000000000a2";
40
+
41
+ describe("createSqliteCascadeDeleter", () => {
42
+ let filename: string;
43
+ let close: () => Promise<void>;
44
+
45
+ before(async () => {
46
+ const dir = await mkdtemp(join(tmpdir(), "remit-sqlite-cascade-"));
47
+ filename = join(dir, "cascade.db");
48
+ const created = await createSqliteTestDb(CASCADE_SCHEMA, { filename });
49
+ close = created.close;
50
+ const { db } = created;
51
+
52
+ await db.insert(accountTable).values({
53
+ accountId: ACCOUNT_ID,
54
+ accountConfigId: ACCOUNT_CONFIG_ID,
55
+ username: "u",
56
+ email: "u@example.com",
57
+ authType: "password",
58
+ imapHost: "imap.example.com",
59
+ imapPort: 993,
60
+ imapTls: true,
61
+ imapStartTls: false,
62
+ smtpPort: 587,
63
+ isActive: true,
64
+ connectionState: "authenticated",
65
+ createdAt: NOW,
66
+ updatedAt: NOW,
67
+ });
68
+ await db.insert(mailboxTable).values({
69
+ mailboxId: MAILBOX_ID,
70
+ accountId: ACCOUNT_ID,
71
+ namespaceType: "personal",
72
+ namespacePrefix: "",
73
+ hierarchyDelimiter: "/",
74
+ fullPath: "INBOX",
75
+ uidValidity: 1,
76
+ uidNext: 1,
77
+ highestModseq: 0,
78
+ messageCount: 0,
79
+ unseenCount: 0,
80
+ deletedCount: 0,
81
+ totalSize: 0,
82
+ lastSyncUid: 0,
83
+ highWaterMarkUid: 0,
84
+ lastMessageSyncAt: NOW,
85
+ cursorState: MailboxCursorState.normal,
86
+ createdAt: NOW,
87
+ updatedAt: NOW,
88
+ });
89
+ await db.insert(messageTable).values({
90
+ messageId: MESSAGE_ID,
91
+ mailboxId: MAILBOX_ID,
92
+ uid: 1,
93
+ sequenceNumber: 1,
94
+ rfc822Size: 10,
95
+ internalDate: NOW,
96
+ envelopeId: ENVELOPE_ID,
97
+ rootBodyPartId: ROOT_BODY_PART_ID,
98
+ status: "active",
99
+ syncStatus: "synced",
100
+ category: "uncategorized",
101
+ createdAt: NOW,
102
+ updatedAt: NOW,
103
+ });
104
+ await db.insert(envelopeTable).values({
105
+ envelopeId: ENVELOPE_ID,
106
+ messageId: MESSAGE_ID,
107
+ dateValue: NOW,
108
+ dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
109
+ createdAt: NOW,
110
+ updatedAt: NOW,
111
+ });
112
+
113
+ // Close the seeding connection so the deleter's own WAL connection owns
114
+ // the file without a cross-connection lock.
115
+ await close();
116
+ });
117
+
118
+ after(async () => {
119
+ // The seeding connection is already closed; nothing else to release.
120
+ });
121
+
122
+ test("deletes the message subtree, mailbox, and account over SQLite, emitting a removal event", async () => {
123
+ const entities: CascadeEntity[] = [
124
+ { entityType: "Message", key: { messageId: MESSAGE_ID } },
125
+ { entityType: "Envelope", key: { envelopeId: ENVELOPE_ID } },
126
+ { entityType: "Mailbox", key: { mailboxId: MAILBOX_ID } },
127
+ { entityType: "Account", key: { accountId: ACCOUNT_ID } },
128
+ ];
129
+
130
+ const deleter = await createSqliteCascadeDeleter(filename);
131
+ await deleter(entities, log);
132
+
133
+ const sqlite = new Database(filename);
134
+ const count = (table: string, column: string, value: string): number =>
135
+ (
136
+ sqlite
137
+ .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`)
138
+ .get(value) as { n: number }
139
+ ).n;
140
+
141
+ assert.equal(count("message", "message_id", MESSAGE_ID), 0, "message gone");
142
+ assert.equal(
143
+ count("envelope", "message_id", MESSAGE_ID),
144
+ 0,
145
+ "envelope gone",
146
+ );
147
+ assert.equal(count("mailbox", "mailbox_id", MAILBOX_ID), 0, "mailbox gone");
148
+ assert.equal(count("account", "account_id", ACCOUNT_ID), 0, "account gone");
149
+
150
+ const outboxRows = (
151
+ sqlite.prepare("SELECT COUNT(*) AS n FROM outbox").get() as { n: number }
152
+ ).n;
153
+ assert.ok(
154
+ outboxRows >= 1,
155
+ "a message-removed outbox row must be emitted for search-index cleanup",
156
+ );
157
+ sqlite.close();
158
+ });
159
+ });