@remit/drizzle-service 0.0.35 → 0.0.37

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 (61) hide show
  1. package/package.json +3 -10
  2. package/src/db.ts +6 -6
  3. package/src/error.ts +2 -5
  4. package/src/index.ts +0 -1
  5. package/src/repair/thread-message-category-contract.ts +2 -4
  6. package/src/repair/thread-message-category.sqlite.test.ts +1 -2
  7. package/src/repair/thread-message-category.ts +15 -25
  8. package/src/repos/cascade-delete.ts +8 -22
  9. package/src/repos/category-fixture.ts +2 -19
  10. package/src/repos/filter-anchor.ts +4 -4
  11. package/src/repos/filter.ts +3 -3
  12. package/src/repos/i4-account-config.ts +2 -2
  13. package/src/repos/i4-account-export-request.ts +2 -2
  14. package/src/repos/i4-account-setting.ts +2 -2
  15. package/src/repos/i4-account.ts +2 -2
  16. package/src/repos/i4-mailbox-lock.ts +2 -2
  17. package/src/repos/i4-mailbox-special-use.ts +2 -2
  18. package/src/repos/i4-mailbox.ts +2 -2
  19. package/src/repos/i4-message-flag-push.test.ts +1 -1
  20. package/src/repos/i4-message-flag-push.ts +3 -3
  21. package/src/repos/i4-message-placement-move.test.ts +1 -1
  22. package/src/repos/i4-message-placement-move.ts +4 -3
  23. package/src/repos/i4-organize-job-request.ts +2 -2
  24. package/src/repos/i4-outbox-message.ts +2 -2
  25. package/src/repos/label.ts +2 -2
  26. package/src/repos/message-flag.ts +2 -2
  27. package/src/repos/message-label.ts +2 -2
  28. package/src/repos/message.ts +8 -10
  29. package/src/repos/quarantine.ts +2 -2
  30. package/src/repos/test-helpers.ts +11 -213
  31. package/src/repos/thread-message.sqlite.test.ts +35 -10
  32. package/src/repos/thread-message.test.ts +45 -105
  33. package/src/repos/thread-message.ts +11 -25
  34. package/src/repos/thread-search-predicates.ts +13 -41
  35. package/src/repos/unit-of-work.ts +1 -1
  36. package/src/schema/i4-account-config.ts +1 -1
  37. package/src/schema/i4-account-export-request.ts +1 -1
  38. package/src/schema/i4-account-setting.ts +1 -1
  39. package/src/schema/i4-address.ts +1 -1
  40. package/src/schema/i4-mailbox-lock.ts +1 -1
  41. package/src/schema/i4-mailbox.ts +1 -1
  42. package/src/schema/i4-message-flag-push.ts +1 -1
  43. package/src/schema/i4-message-placement-move.ts +1 -1
  44. package/src/schema/i4-organize-job-request.ts +1 -1
  45. package/src/schema/i4-outbox-message.ts +1 -1
  46. package/src/schema/message-data.ts +1 -1
  47. package/src/schema/outbox.ts +13 -56
  48. package/src/schema/quarantine.ts +1 -1
  49. package/src/schema/thread-message.ts +1 -1
  50. package/src/schema-full-sqlite.ts +8 -6
  51. package/src/schema.ts +4 -4
  52. package/src/sqlite-client.ts +4 -5
  53. package/src/test-db-sqlite.ts +4 -9
  54. package/src/test-db.ts +11 -66
  55. package/src/tx.ts +6 -12
  56. package/drizzle.config.ts +0 -15
  57. package/src/dialect.ts +0 -13
  58. package/src/repair/thread-message-category.test.ts +0 -229
  59. package/src/repos/thread-message-category.test.ts +0 -156
  60. package/src/schema/active-entities.ts +0 -19
  61. package/src/schema-full.ts +0 -16
@@ -1,229 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { createTestDb } from "../test-db.js";
4
- import {
5
- type RepairSqlClient,
6
- repairStatement,
7
- repairThreadMessageCategory,
8
- } from "./thread-message-category.js";
9
- import { describeRepairContract } from "./thread-message-category-contract.js";
10
-
11
- const { pool, close } = await createTestDb();
12
-
13
- after(async () => {
14
- await close();
15
- });
16
-
17
- const client: RepairSqlClient = {
18
- dialect: "postgres",
19
- all: async (sql) => (await pool.query(sql)).rows,
20
- run: async (sql) => (await pool.query(sql)).rowCount ?? 0,
21
- };
22
-
23
- const truncate = async (): Promise<void> => {
24
- await pool.query("DELETE FROM thread_message");
25
- await pool.query("DELETE FROM message");
26
- };
27
-
28
- describeRepairContract("postgres", async () => ({
29
- client,
30
- reset: truncate,
31
- close: async () => undefined,
32
- }));
33
-
34
- // Postgres is where a mid-sync run can go wrong, and the two interleavings have
35
- // opposite outcomes — so each is driven with two real connections rather than
36
- // argued about. Under READ COMMITTED an UPDATE that meets a row a concurrent
37
- // transaction just changed re-evaluates its WHERE clause against the new version
38
- // of that row, but keeps reading other tables at its original snapshot.
39
- //
40
- // Both cases need a *re*-classification to be observable at all: `message` moving
41
- // from one decided category to another. `backfillClassification` cannot do that
42
- // (it returns early once the category is decided), but the primary path is
43
- // re-enterable — `if (message.bodyStorageKey && !force)`, where `force` is the
44
- // read-miss re-arm cue — and a forced re-fetch decides differently across a
45
- // release that changed the classifier. So the uncovered case below is
46
- // effectively unreachable rather than impossible. The seeds are synthetic
47
- // because reproducing it otherwise would mean changing the classifier.
48
- describe("thread_message.category repair — a writer racing the statement", () => {
49
- const insertMessage = async (
50
- messageId: string,
51
- category: string,
52
- ): Promise<void> => {
53
- await pool.query(
54
- `INSERT INTO message (
55
- message_id, mailbox_id, uid, sequence_number, rfc822_size, internal_date,
56
- envelope_id, root_body_part_id, category, created_at, updated_at
57
- ) VALUES ($1, 'mbx-inbox', 1, 1, 100, 0, 'env-1', 'part-1', $2, 0, 0)`,
58
- [messageId, category],
59
- );
60
- };
61
-
62
- const insertRow = async (
63
- messageId: string,
64
- category: string,
65
- ): Promise<void> => {
66
- await pool.query(
67
- `INSERT INTO thread_message (
68
- thread_message_id, thread_id, message_id, account_config_id, mailbox_id,
69
- uid, reference_order, internal_date, sent_date, is_read, has_attachment,
70
- has_stars, is_deleted, category, created_at, updated_at
71
- ) VALUES ($1, 'thr-1', $1, 'acct-1', 'mbx-inbox', 1, 0, 0, 0,
72
- false, false, false, false, $2, 0, 1000)`,
73
- [messageId, category],
74
- );
75
- };
76
-
77
- // A body-sync denormalize, faithful to the shipped order (#326): the row
78
- // first, the message second, both stamped with the wall clock at write time.
79
- const reclassify = async (
80
- connection: {
81
- query: (sql: string, values?: unknown[]) => Promise<unknown>;
82
- },
83
- messageId: string,
84
- category: string,
85
- ): Promise<void> => {
86
- await connection.query(
87
- "UPDATE thread_message SET category = $2, updated_at = $3 WHERE thread_message_id = $1",
88
- [messageId, category, Date.now()],
89
- );
90
- await connection.query(
91
- "UPDATE message SET category = $2, updated_at = $3 WHERE message_id = $1",
92
- [messageId, category, Date.now()],
93
- );
94
- };
95
-
96
- const categories = async (): Promise<Record<string, string>> => {
97
- const { rows } = await pool.query<{
98
- thread_message_id: string;
99
- category: string;
100
- }>("SELECT thread_message_id, category FROM thread_message");
101
- return Object.fromEntries(
102
- rows.map((row) => [row.thread_message_id, row.category]),
103
- );
104
- };
105
-
106
- before(truncate);
107
-
108
- // The case the guard covers: the writer's transaction begins after the
109
- // statement, so its stamp is beyond the statement's clock and the re-check
110
- // against the committed row excludes it. The repair is held on an unrelated
111
- // row lock for the duration, which is what puts the writer inside the
112
- // statement's window without touching its stamp.
113
- test("a writer that begins after the statement keeps its value", async () => {
114
- await truncate();
115
- await insertMessage("aaa-blocker", "newsletter");
116
- await insertRow("aaa-blocker", "uncategorized");
117
- await insertMessage("bbb-raced", "newsletter");
118
- await insertRow("bbb-raced", "uncategorized");
119
-
120
- const holder = await pool.connect();
121
- const writer = await pool.connect();
122
- try {
123
- await holder.query("BEGIN");
124
- await holder.query(
125
- "UPDATE thread_message SET updated_at = 1001 WHERE thread_message_id = 'aaa-blocker'",
126
- );
127
-
128
- const repairing = repairThreadMessageCategory(client);
129
- // The repair reaches the blocker row — inserted first, so first in the
130
- // sequential scan — and waits there. The sleep is on the holder's own
131
- // connection, so nothing polls.
132
- await holder.query("SELECT pg_sleep(0.5)");
133
-
134
- await writer.query("BEGIN");
135
- await reclassify(writer, "bbb-raced", "marketing");
136
- await writer.query("COMMIT");
137
-
138
- await holder.query("COMMIT");
139
-
140
- // Only the blocker. A 2 here means the repair finished before the writer
141
- // began, so the interleaving this test is named for did not happen.
142
- assert.equal((await repairing).rowsWritten, 1);
143
- } finally {
144
- holder.release();
145
- writer.release();
146
- }
147
-
148
- assert.deepEqual(await categories(), {
149
- "aaa-blocker": "newsletter",
150
- "bbb-raced": "marketing",
151
- });
152
- });
153
-
154
- // The live mid-sync case, and it is safe: a writer whose transaction began
155
- // before the statement, doing what body-sync actually does — filling in the
156
- // copy of a category the message already holds. The repair blocks on the row,
157
- // re-checks against the committed version, finds it now agrees, and writes
158
- // nothing. This is the interleaving D16's safety argument rests on.
159
- test("a concurrent denormalize of the same row leaves it correct", async () => {
160
- await truncate();
161
- await insertMessage("ddd-catchup", "newsletter");
162
- await insertRow("ddd-catchup", "uncategorized");
163
-
164
- const writer = await pool.connect();
165
- try {
166
- await writer.query("BEGIN");
167
- await writer.query(
168
- "UPDATE thread_message SET category = 'newsletter', updated_at = $1 WHERE thread_message_id = 'ddd-catchup'",
169
- [Date.now()],
170
- );
171
-
172
- const repairing = repairThreadMessageCategory(client);
173
- await writer.query("SELECT pg_sleep(0.5)");
174
- await writer.query("COMMIT");
175
-
176
- assert.equal((await repairing).rowsWritten, 0);
177
- } finally {
178
- writer.release();
179
- }
180
-
181
- assert.deepEqual(await categories(), { "ddd-catchup": "newsletter" });
182
- });
183
-
184
- // The known limit, pinned so it cannot silently widen. Reaching it needs four
185
- // things at once: an unrepaired `behind` row, a writer whose transaction began
186
- // before the statement, that writer's row write committing before the statement
187
- // while its message write commits after, and that writer moving
188
- // message.category from one decided value to a different one. Only a forced
189
- // re-fetch across a classifier change does the fourth, which makes this
190
- // effectively unreachable rather than impossible; the seed is synthetic and the
191
- // assertion records the loss rather than endorsing it. If this test ever fails,
192
- // the guard got stronger and D16 and the module header should say so.
193
- test("a writer that began before the statement is outside the guard", async () => {
194
- await truncate();
195
- await insertMessage("ccc-reclass", "newsletter");
196
- await insertRow("ccc-reclass", "uncategorized");
197
-
198
- const writer = await pool.connect();
199
- try {
200
- await writer.query("BEGIN");
201
- await reclassify(writer, "ccc-reclass", "marketing");
202
-
203
- const repairing = repairThreadMessageCategory(client);
204
- await writer.query("SELECT pg_sleep(0.5)");
205
- await writer.query("COMMIT");
206
-
207
- assert.equal((await repairing).rowsWritten, 1);
208
- } finally {
209
- writer.release();
210
- }
211
-
212
- assert.deepEqual(await categories(), { "ccc-reclass": "newsletter" });
213
- const { rows } = await pool.query<{ category: string }>(
214
- "SELECT category FROM message WHERE message_id = 'ccc-reclass'",
215
- );
216
- assert.deepEqual(rows, [{ category: "marketing" }]);
217
- });
218
-
219
- test("only the clock expression differs between the dialects", () => {
220
- const postgres = repairStatement("postgres");
221
- const sqlite = repairStatement("sqlite");
222
- assert.ok(postgres.includes("EXTRACT(EPOCH FROM now())"));
223
- assert.ok(sqlite.includes("unixepoch('subsec')"));
224
- assert.equal(
225
- postgres.replace(/CAST\(EXTRACT.*$/, ""),
226
- sqlite.replace(/CAST\(unixepoch.*$/, ""),
227
- );
228
- });
229
- });
@@ -1,156 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { drizzle } from "drizzle-orm/node-postgres";
4
- import type pg from "pg";
5
- import type { Db } from "../db.js";
6
- import { threadMessageTable } from "../schema/thread-message.js";
7
- import * as schema from "../schema.js";
8
- import { createTestDb } from "../test-db.js";
9
- import { categoryFixtureRows, TRIMMED_TOTALS } from "./category-fixture.js";
10
- import { DrizzleThreadMessageRepository } from "./thread-message.js";
11
-
12
- /**
13
- * The Postgres half of the category-predicate guard (#304). The behavioural
14
- * coverage is in thread-message.sqlite.test.ts against the full measured shape;
15
- * what this file exists for is the query plan, which is dialect-specific:
16
- * `EXPLAIN QUERY PLAN` is SQLite syntax and Postgres needs its own `EXPLAIN`.
17
- *
18
- * It runs against the embedded Postgres harness, which pushes the generated
19
- * drizzle schema, so `tm_by_mailbox_category_date` is present here for the same
20
- * reason it is present in the sqlite harness: the index is declared in TypeSpec
21
- * and emitted into the schema both dialects derive from. That is what makes the
22
- * assertion a real guard rather than a restatement of the predicate — remove the
23
- * index from the schema and it fails.
24
- *
25
- * The fixture is the measured shape with its common categories trimmed
26
- * (TRIMMED_TOTALS): the rare tail the assertions use is untouched, and the
27
- * planner needs only enough rows to prefer an index over a sequential scan. It
28
- * also needs statistics before it will, so the fixture is ANALYZEd; a production
29
- * instance gets that from autovacuum.
30
- */
31
-
32
- const ACCOUNT = "acct-category-pg";
33
- const MAILBOX = "mbx-category-pg";
34
-
35
- describe("thread-message category predicate (postgres)", () => {
36
- let pool: pg.Pool;
37
- let close: () => Promise<void>;
38
- let repo: DrizzleThreadMessageRepository;
39
- const logged: Array<{ text: string; params: unknown[] }> = [];
40
-
41
- before(async () => {
42
- ({ pool, close } = await createTestDb());
43
-
44
- const db = drizzle(pool, {
45
- schema,
46
- logger: {
47
- logQuery(text: string, params: unknown[]) {
48
- logged.push({ text, params });
49
- },
50
- },
51
- }) as unknown as Db<Record<string, unknown>>;
52
- repo = new DrizzleThreadMessageRepository(db);
53
-
54
- const rows = categoryFixtureRows({
55
- totals: TRIMMED_TOTALS,
56
- accountConfigId: ACCOUNT,
57
- mailboxId: MAILBOX,
58
- });
59
- const insertDb = drizzle(pool, { schema });
60
- for (let i = 0; i < rows.length; i += 500) {
61
- await insertDb.insert(threadMessageTable).values(rows.slice(i, i + 500));
62
- }
63
- await pool.query("ANALYZE thread_message");
64
- });
65
-
66
- after(async () => {
67
- await close();
68
- });
69
-
70
- test("a filtered page is a full page of matches, however far back they sit", async () => {
71
- const social = await repo.searchByMailboxWindow(
72
- ACCOUNT,
73
- MAILBOX,
74
- { category: ["social"] },
75
- { limit: 50, order: "desc", excludeDeleted: true },
76
- );
77
- assert.equal(social.items.length, 50);
78
- assert.ok(social.items.every((item) => item.category === "social"));
79
- });
80
-
81
- test("a continuation token walks the whole match set without repeats or gaps", async () => {
82
- const seen: string[] = [];
83
- let continuationToken: string | undefined;
84
- do {
85
- const page = await repo.searchByMailboxWindow(
86
- ACCOUNT,
87
- MAILBOX,
88
- { category: ["social"] },
89
- {
90
- limit: 25,
91
- order: "desc",
92
- excludeDeleted: true,
93
- continuationToken,
94
- },
95
- );
96
- seen.push(...page.items.map((item) => item.threadMessageId));
97
- continuationToken = page.continuationToken;
98
- } while (continuationToken);
99
- assert.equal(seen.length, TRIMMED_TOTALS.social);
100
- assert.equal(new Set(seen).size, TRIMMED_TOTALS.social);
101
- });
102
-
103
- describe("query plan", () => {
104
- // Each assertion names the category column as well as the index: both
105
- // engines can reach this index on its (account_config_id, mailbox_id)
106
- // prefix alone, so an index-only assertion would still pass with the
107
- // predicate removed.
108
- const assertServedByIndex = async (
109
- run: () => Promise<unknown>,
110
- ): Promise<void> => {
111
- logged.length = 0;
112
- await run();
113
- const selects = logged.filter((entry) => /^\s*select/i.test(entry.text));
114
- assert.ok(selects.length > 0, "the repo issued a select");
115
- const plan: string[] = [];
116
- for (const entry of selects) {
117
- const explained = await pool.query<{ "QUERY PLAN": string }>({
118
- text: `EXPLAIN ${entry.text}`,
119
- values: entry.params,
120
- });
121
- plan.push(...explained.rows.map((row) => row["QUERY PLAN"]));
122
- }
123
- const text = plan.join("\n");
124
- assert.ok(
125
- text.includes("tm_by_mailbox_category_date"),
126
- `no plan node used the index:\n${text}`,
127
- );
128
- assert.ok(
129
- /Index Cond:[^\n]*category/.test(text),
130
- `the index was used but not on category:\n${text}`,
131
- );
132
- };
133
-
134
- test("the filtered window is served by tm_by_mailbox_category_date", async () => {
135
- await assertServedByIndex(() =>
136
- repo.searchByMailboxWindow(
137
- ACCOUNT,
138
- MAILBOX,
139
- { category: ["social"] },
140
- { limit: 50, order: "desc", excludeDeleted: true },
141
- ),
142
- );
143
- });
144
-
145
- test("the filtered count is served by tm_by_mailbox_category_date", async () => {
146
- await assertServedByIndex(() =>
147
- repo.countByMailbox(
148
- ACCOUNT,
149
- MAILBOX,
150
- { category: ["social"] },
151
- { excludeDeleted: true },
152
- ),
153
- );
154
- });
155
- });
156
- });
@@ -1,19 +0,0 @@
1
- // The dialect-selected entity table set (RFC 036 D1). Both generated packages
2
- // export the same table symbols under the same names — they differ only in the
3
- // column builders (`pgTable`/`jsonb`/`timestamp` vs `sqliteTable`/`text(json)`/
4
- // `integer`). A process runs one dialect (see ../dialect.ts), so the active set
5
- // is chosen once here and re-exported through the schema facades the repos
6
- // import from.
7
- //
8
- // The cast to the Postgres module type is the single "typing loosens at the
9
- // injection boundary" point RFC 036 D1 names: the repos are written once
10
- // against the Postgres-typed shape, and at runtime the SQLite tables carry
11
- // their own dialect so the queries generate the correct SQL. The backend
12
- // already crosses this boundary with a cast on the db handle.
13
- import * as pgEntities from "@remit/drizzle-pg-schema";
14
- import * as sqliteEntities from "@remit/drizzle-sqlite-schema";
15
- import { isSqlite } from "../dialect.js";
16
-
17
- export const entities: typeof pgEntities = isSqlite()
18
- ? (sqliteEntities as unknown as typeof pgEntities)
19
- : pgEntities;
@@ -1,16 +0,0 @@
1
- // Complete Postgres drizzle schema for committed-migration GENERATION only —
2
- // consumed by packages/migrate/drizzle.entities.config.ts and the drift guard
3
- // (npm-scripts/check-vps-migrations.mjs), never by `pushSchema`.
4
- //
5
- // It pulls the generated entity package in wholesale, so a new TypeSpec entity
6
- // flows into the committed migration with nothing to hand-maintain — the
7
- // omission that let eight tables drift out of the deployed schema. The only
8
- // addition is the `outbox` infra table, which has no entity. This file imports
9
- // the raw pg outbox directly (not the dialect-selected `outboxTable`), so
10
- // migration generation never depends on the runtime `DATA_BACKEND`.
11
- //
12
- // schema.ts stays the app/dev surface (single `*Table` alias per table, what
13
- // the repos and `pushSchema` need); this file exposes canonical names, so it
14
- // must not be fed to `pushSchema` alongside schema.ts (duplicate index names).
15
- export * from "@remit/drizzle-pg-schema";
16
- export { pgOutboxTable as outboxTable } from "./schema/outbox.js";