@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,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import Database from "better-sqlite3";
3
+ import { pushSQLiteSchema } from "drizzle-kit/api";
4
+ import { drizzle } from "drizzle-orm/better-sqlite3";
5
+ import type { Db } from "./db.js";
6
+
7
+ // The exact FTS5 objects the migrator installs (RFC 036 D4), read from the one
8
+ // committed source so a test runs the real search path, not a hand-copied twin.
9
+ const searchIndexDdl = (): string =>
10
+ readFileSync(
11
+ new URL("../../../npm-scripts/sqlite-search-index.sql", import.meta.url),
12
+ "utf8",
13
+ );
14
+
15
+ // SQLite counterpart of repos/test-helpers.ts's embedded-Postgres harness
16
+ // (RFC 036 D1). A real better-sqlite3 database (in-memory by default) with the
17
+ // schema pushed from the drizzle table objects — the sqlite `pushSchema` — so a
18
+ // repo runs against the exact dialect it ships on, no hand-maintained DDL.
19
+ //
20
+ // The tests that use it run in a `DATA_BACKEND=sqlite` process (see
21
+ // test:run:sqlite), so the schema facades resolve to the sqlite tables and the
22
+ // repos take the sqlite transaction / predicate paths.
23
+
24
+ export type SqliteTestDb<TSchema extends Record<string, unknown>> = Db<TSchema>;
25
+
26
+ export async function createSqliteTestDb<
27
+ TSchema extends Record<string, unknown>,
28
+ >(
29
+ schema: TSchema,
30
+ options?: { filename?: string; searchIndex?: boolean },
31
+ ): Promise<{
32
+ db: SqliteTestDb<TSchema>;
33
+ sqlite: Database.Database;
34
+ close: () => Promise<void>;
35
+ }> {
36
+ const sqlite = new Database(options?.filename ?? ":memory:");
37
+ sqlite.pragma("foreign_keys = ON");
38
+
39
+ const db = drizzle(sqlite, { schema }) as unknown as SqliteTestDb<TSchema>;
40
+
41
+ // pushSQLiteSchema derives the CREATE statements from the table objects;
42
+ // better-sqlite3 rejects its own `apply()` (it issues the DDL through a
43
+ // data-returning path), so run the statements directly. The pull-schema
44
+ // progress spinner writes to stdout — silence it so the test reporter output
45
+ // stays clean.
46
+ const write = process.stdout.write.bind(process.stdout);
47
+ process.stdout.write = (() => true) as typeof process.stdout.write;
48
+ let statementsToExecute: string[];
49
+ try {
50
+ ({ statementsToExecute } = await pushSQLiteSchema(
51
+ schema,
52
+ db as unknown as Parameters<typeof pushSQLiteSchema>[1],
53
+ ));
54
+ } finally {
55
+ process.stdout.write = write;
56
+ }
57
+ for (const statement of statementsToExecute) {
58
+ sqlite.exec(statement);
59
+ }
60
+
61
+ // Install the FTS5 search objects on top of the pushed schema so the
62
+ // thread-message search predicates run their real trigram path (RFC 036 D4).
63
+ // Opt-in: only schemas that include the `thread_message` table can carry it.
64
+ if (options?.searchIndex) {
65
+ sqlite.exec(searchIndexDdl());
66
+ }
67
+
68
+ return {
69
+ db,
70
+ sqlite,
71
+ close: async () => {
72
+ sqlite.close();
73
+ },
74
+ };
75
+ }
package/src/test-db.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { pushSchema } from "drizzle-kit/api";
2
+ import { drizzle } from "drizzle-orm/node-postgres";
3
+ import EmbeddedPostgres from "embedded-postgres";
4
+ import pg from "pg";
5
+ import * as schema from "./schema.js";
6
+
7
+ const { Pool } = pg;
8
+
9
+ export type TestDb = ReturnType<typeof drizzle<typeof schema>>;
10
+
11
+ let _instance: EmbeddedPostgres | null = null;
12
+ let _port = 0;
13
+
14
+ async function ensureStarted(): Promise<{ port: number }> {
15
+ if (_instance) return { port: _port };
16
+
17
+ for (let attempt = 0; attempt < 10; attempt++) {
18
+ const port = 15000 + Math.floor(Math.random() * 5000);
19
+ const instance = new EmbeddedPostgres({
20
+ databaseDir: `/tmp/remit-test-pg-${process.pid}-${port}-${attempt}`,
21
+ port,
22
+ persistent: false,
23
+ });
24
+ try {
25
+ await instance.initialise();
26
+ await instance.start();
27
+ } catch (err) {
28
+ await instance.stop().catch(() => undefined);
29
+ if (attempt === 9) throw err;
30
+ continue;
31
+ }
32
+ _instance = instance;
33
+ _port = port;
34
+ process.on("exit", () => {
35
+ instance.stop().catch(() => undefined);
36
+ });
37
+ return { port };
38
+ }
39
+ throw new Error("Failed to start embedded postgres");
40
+ }
41
+
42
+ export async function createTestDb(): Promise<{
43
+ db: TestDb;
44
+ pool: pg.Pool;
45
+ close: () => Promise<void>;
46
+ }> {
47
+ const { port } = await ensureStarted();
48
+
49
+ const pool = new Pool({
50
+ host: "localhost",
51
+ port,
52
+ user: "postgres",
53
+ password: "password",
54
+ database: "postgres",
55
+ });
56
+
57
+ const db = drizzle(pool, { schema }) as TestDb;
58
+
59
+ const { apply } = await pushSchema(schema, drizzle(pool));
60
+ await apply();
61
+
62
+ return {
63
+ db,
64
+ pool,
65
+ close: async () => {
66
+ await pool.end();
67
+ if (_instance) {
68
+ const inst = _instance;
69
+ _instance = null;
70
+ await inst.stop();
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ export { randomId } from "./id.js";
package/src/tx.ts ADDED
@@ -0,0 +1,208 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { type SQL, sql } from "drizzle-orm";
4
+ import type { Db } from "./db.js";
5
+ import { isSqlite } from "./dialect.js";
6
+
7
+ // Runs a write set in one transaction, on either dialect (RFC 036 D1).
8
+ //
9
+ // Postgres uses drizzle's own `db.transaction()`. better-sqlite3 cannot: its
10
+ // native transaction runner rejects a callback that returns a promise, and the
11
+ // repos' write sets are async. SQLite instead brackets the callback with a
12
+ // SAVEPOINT — a savepoint opens a transaction when none is active and commits
13
+ // when the outermost one is released.
14
+ //
15
+ // All writers on this backend share one better-sqlite3 connection (RFC 036 D3),
16
+ // and every query runs synchronously, but an async callback still yields the
17
+ // event loop at each `await`. Two concurrent top-level transactions would
18
+ // therefore interleave their SAVEPOINT/RELEASE on the shared connection and
19
+ // corrupt each other's boundaries — and the existing callers do run concurrently
20
+ // (message-sync's `unitOfWork.transaction` under pMap). So top-level sqlite
21
+ // transactions are serialized behind an async queue: one runs start-to-finish
22
+ // before the next begins, which is exactly the global write serialization D3
23
+ // accepts at single-box scale. Nested calls (a repo opening its own transaction
24
+ // while a unit-of-work already holds one) are detected via AsyncLocalStorage and
25
+ // take a nested savepoint on the same open transaction instead of re-queuing,
26
+ // which would deadlock.
27
+
28
+ const inSqliteTx = new AsyncLocalStorage<true>();
29
+
30
+ let sqliteQueue: Promise<unknown> = Promise.resolve();
31
+
32
+ function serializeSqlite<T>(task: () => Promise<T>): Promise<T> {
33
+ const result = sqliteQueue.then(task, task);
34
+ // Keep the queue alive regardless of this task's outcome.
35
+ sqliteQueue = result.then(
36
+ () => undefined,
37
+ () => undefined,
38
+ );
39
+ return result;
40
+ }
41
+
42
+ async function runSqliteSavepoint<TSchema extends Record<string, unknown>, T>(
43
+ db: Db<TSchema>,
44
+ fn: (tx: Db<TSchema>) => Promise<T>,
45
+ ): Promise<T> {
46
+ const runner = db as unknown as { run: (query: SQL) => unknown };
47
+ const name = `sp_${randomUUID().replace(/-/g, "")}`;
48
+ runner.run(sql.raw(`SAVEPOINT ${name}`));
49
+ try {
50
+ const result = await fn(db);
51
+ runner.run(sql.raw(`RELEASE SAVEPOINT ${name}`));
52
+ return result;
53
+ } catch (error) {
54
+ try {
55
+ runner.run(sql.raw(`ROLLBACK TO SAVEPOINT ${name}`));
56
+ runner.run(sql.raw(`RELEASE SAVEPOINT ${name}`));
57
+ } catch {
58
+ // A failed rollback must not mask the error that caused it; surface
59
+ // the original below.
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export async function runInTransaction<
66
+ TSchema extends Record<string, unknown>,
67
+ T,
68
+ >(db: Db<TSchema>, fn: (tx: Db<TSchema>) => Promise<T>): Promise<T> {
69
+ if (!isSqlite()) {
70
+ return db.transaction(fn);
71
+ }
72
+
73
+ if (inSqliteTx.getStore()) {
74
+ // Already inside a top-level transaction on this connection — nest with a
75
+ // savepoint, do not re-queue.
76
+ return runSqliteSavepoint(db, fn);
77
+ }
78
+
79
+ return serializeSqlite(() =>
80
+ inSqliteTx.run(true, () => runSqliteSavepoint(db, fn)),
81
+ );
82
+ }
83
+
84
+ // Every write on the shared better-sqlite3 connection must run start-to-finish
85
+ // with the connection in autocommit — never during the `await` gap of a
86
+ // top-level `runInTransaction` unit, where a plain single statement would join
87
+ // the open SAVEPOINT and be lost on its rollback (the uncommitted-read /
88
+ // rollback hazard from RFC 036 D3, named on PR #1310). A single statement runs
89
+ // synchronously, so the hazard only exists when it lands inside another unit's
90
+ // open savepoint; routing it through the same async queue that serializes
91
+ // transactions guarantees no savepoint is open when it executes.
92
+ //
93
+ // Inside a top-level unit (ALS flag set), the savepoint already holds the queue
94
+ // slot — running directly is correct and re-queuing would deadlock.
95
+ function runSqliteWrite<T>(execute: () => T | Promise<T>): Promise<T> {
96
+ if (inSqliteTx.getStore()) return Promise.resolve(execute());
97
+ return serializeSqlite(async () => execute());
98
+ }
99
+
100
+ const SQLITE_WRITE_METHODS = new Set(["insert", "update", "delete"]);
101
+
102
+ // The methods that actually run a drizzle write builder's query. Every one of
103
+ // them must run under the write queue, not just the `await` (`then`) path — a
104
+ // repo (now or later) could execute a write via any of these and must not
105
+ // bypass serialization. `values` is an executor only on a runnable (a builder
106
+ // with `execute` — including a `.prepare()`d query); on the pre-`.values(rows)`
107
+ // insert builder it is the chain method, which the runnable gate below leaves
108
+ // to the pass-through path.
109
+ const SQLITE_EXECUTORS = new Set(["execute", "run", "all", "get", "values"]);
110
+
111
+ // A drizzle write builder is identified by its `execute` method. A Promise (what
112
+ // the executors above return) has `then` but no `execute`, so it is never
113
+ // mistaken for a builder and re-wrapped — re-wrapping a Promise would make its
114
+ // intercepted `then` call the non-existent `promise.execute()`.
115
+ const isWriteBuilder = (value: unknown): value is { execute: () => unknown } =>
116
+ typeof value === "object" &&
117
+ value !== null &&
118
+ typeof (value as { execute?: unknown }).execute === "function";
119
+
120
+ const runBuilder = (builder: object): Promise<unknown> =>
121
+ runSqliteWrite(() => (builder as { execute: () => unknown }).execute());
122
+
123
+ // Wrap a drizzle insert/update/delete builder so every terminal — the `await`
124
+ // (`then`/`catch`/`finally`) and the explicit executors (`execute`/`run`/`all`/
125
+ // `get`/`values`) — runs the query through `runSqliteWrite` instead of inline.
126
+ // Terminals exist only on a runnable (`execute` present), so interception is
127
+ // gated on that: on a pre-`.values(rows)` insert builder nothing is
128
+ // intercepted and the chain passes through untouched. The chain
129
+ // methods (`.values().returning().onConflictDoUpdate()`, `.set()`, `.where()`)
130
+ // return builders, so each chained result is re-wrapped until a terminal fires.
131
+ // Reads never reach here — only the write entry points below.
132
+ function wrapWriteBuilder<B extends object>(builder: B): B {
133
+ return new Proxy(builder, {
134
+ get(target, prop, receiver) {
135
+ if (isWriteBuilder(target)) {
136
+ if (prop === "then") {
137
+ return (
138
+ onFulfilled?: ((value: unknown) => unknown) | null,
139
+ onRejected?: ((reason: unknown) => unknown) | null,
140
+ ) => runBuilder(target).then(onFulfilled, onRejected);
141
+ }
142
+ if (prop === "catch") {
143
+ return (onRejected?: ((reason: unknown) => unknown) | null) =>
144
+ runBuilder(target).catch(onRejected);
145
+ }
146
+ if (prop === "finally") {
147
+ return (onFinally?: (() => void) | null) =>
148
+ runBuilder(target).finally(onFinally);
149
+ }
150
+ }
151
+ if (
152
+ typeof prop === "string" &&
153
+ SQLITE_EXECUTORS.has(prop) &&
154
+ isWriteBuilder(target)
155
+ ) {
156
+ return (...args: unknown[]) =>
157
+ runSqliteWrite(() =>
158
+ (target as unknown as Record<string, (...a: unknown[]) => unknown>)[
159
+ prop
160
+ ](...args),
161
+ );
162
+ }
163
+ const value = Reflect.get(target, prop, receiver);
164
+ if (typeof value === "function") {
165
+ return (...args: unknown[]) => {
166
+ const result = (value as (...a: unknown[]) => unknown).apply(
167
+ target,
168
+ args,
169
+ );
170
+ return result === target || isWriteBuilder(result)
171
+ ? wrapWriteBuilder(result as object)
172
+ : result;
173
+ };
174
+ }
175
+ return value;
176
+ },
177
+ });
178
+ }
179
+
180
+ // Route every repo write through the write queue (RFC 036 D3). The handle a repo
181
+ // holds intercepts `insert`/`update`/`delete` and serializes the resulting
182
+ // query, so no repo insert/update/delete can land inside another unit's open
183
+ // SAVEPOINT. Deliberate escape hatches pass straight through: `run` (the
184
+ // savepoint DDL, already inside a serialized unit), `transaction`, and reads —
185
+ // reads are not serialized, so a read issued during another unit's open
186
+ // transaction can still observe uncommitted rows; the wrapper closes the
187
+ // write-side rollback hazard, not read isolation. On Postgres this is never
188
+ // applied.
189
+ export function serializeSqliteWrites<TDb extends Db<Record<string, unknown>>>(
190
+ db: TDb,
191
+ ): TDb {
192
+ return new Proxy(db, {
193
+ get(target, prop, receiver) {
194
+ const value = Reflect.get(target, prop, receiver);
195
+ if (
196
+ typeof prop === "string" &&
197
+ SQLITE_WRITE_METHODS.has(prop) &&
198
+ typeof value === "function"
199
+ ) {
200
+ return (...args: unknown[]) =>
201
+ wrapWriteBuilder(
202
+ (value as (...a: unknown[]) => object).apply(target, args),
203
+ );
204
+ }
205
+ return typeof value === "function" ? value.bind(target) : value;
206
+ },
207
+ }) as TDb;
208
+ }
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { test } from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
9
+
10
+ // The migration-check script is stripped from the open-core tree; skip there and
11
+ // run where it ships.
12
+ const hasCheckScript = existsSync(
13
+ resolve(repoRoot, "npm-scripts/check-vps-migrations.mjs"),
14
+ );
15
+
16
+ // Fails when the committed VPS migrations (deploy/vps/migrations/*) no longer
17
+ // produce the schema drizzle would generate from the entity + auth schemas.
18
+ // See npm-scripts/check-vps-migrations.mjs for the mechanism.
19
+ test(
20
+ "committed VPS migrations match the drizzle schema",
21
+ { skip: !hasCheckScript },
22
+ () => {
23
+ assert.doesNotThrow(() => {
24
+ execFileSync(
25
+ "node",
26
+ ["npm-scripts/check-vps-migrations.mjs", "--check"],
27
+ {
28
+ cwd: repoRoot,
29
+ stdio: "inherit",
30
+ },
31
+ );
32
+ }, "committed VPS migrations are stale — run `npm run migrations:generate`");
33
+ },
34
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts", "drizzle.config.ts"]
8
+ }