@remit/drizzle-service 0.0.104 → 0.0.106

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/bound-parameters.test.ts +57 -0
  3. package/src/bound-parameters.ts +49 -0
  4. package/src/databases.ts +10 -9
  5. package/src/db.ts +1 -0
  6. package/src/index.ts +9 -0
  7. package/src/repair/junk-only-address.sqlite.test.ts +9 -5
  8. package/src/repos/account-config-erase.test.ts +127 -0
  9. package/src/repos/account-config-erase.ts +170 -0
  10. package/src/repos/account.conformance.sqlite.test.ts +9 -5
  11. package/src/repos/calendar-event-index.conformance.d1.test.ts +1 -1
  12. package/src/repos/calendar-event-index.conformance.sqlite.test.ts +3 -1
  13. package/src/repos/calendar-event-index.ts +36 -21
  14. package/src/repos/calendar-store.ts +17 -10
  15. package/src/repos/cascade-delete.test.ts +9 -2
  16. package/src/repos/cascade-delete.ts +2 -1
  17. package/src/repos/envelope.d1.test.ts +66 -0
  18. package/src/repos/envelope.sqlite.test.ts +5 -1
  19. package/src/repos/envelope.test.ts +5 -1
  20. package/src/repos/envelope.ts +40 -31
  21. package/src/repos/i4-account.test.ts +17 -10
  22. package/src/repos/i4-address-junk-move.sqlite.test.ts +9 -5
  23. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +9 -5
  24. package/src/repos/i4-mailbox.sqlite.test.ts +17 -10
  25. package/src/repos/i4-mailbox.test.ts +17 -10
  26. package/src/repos/i4-outbox-attachment.test.ts +9 -5
  27. package/src/repos/message.sqlite.test.ts +5 -1
  28. package/src/repos/message.test.ts +5 -1
  29. package/src/repos/sync-warning.ts +35 -22
  30. package/src/repos/thread-message-body-muted.sqlite.test.ts +9 -5
  31. package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
  32. package/src/repos/thread-message.sqlite.test.ts +9 -5
  33. package/src/repos/thread-message.test.ts +33 -20
  34. package/src/search/thread-message-search.sqlite.test.ts +13 -6
  35. package/src/search/thread-message-search.ts +14 -17
  36. package/src/sqlite-repositories.ts +6 -3
  37. package/src/test-d1.ts +36 -19
  38. package/src/test-stores.ts +15 -2
@@ -10,6 +10,7 @@
10
10
  import assert from "node:assert/strict";
11
11
  import { after, before, describe, test } from "node:test";
12
12
  import type { CreateThreadMessageInput } from "@remit/data-ports";
13
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
13
14
  import { addressTable } from "../schema/i4-address.js";
14
15
  import { messageSearchTextTable } from "../schema/message-data.js";
15
16
  import { threadMessageTable } from "../schema/thread-message.js";
@@ -60,11 +61,14 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
60
61
  },
61
62
  { searchIndex: true },
62
63
  ));
63
- repo = createSqliteRepositories({
64
- app: db,
65
- mail: db,
66
- search: db,
67
- }).threadMessage;
64
+ repo = createSqliteRepositories(
65
+ {
66
+ app: db,
67
+ mail: db,
68
+ search: db,
69
+ },
70
+ BETTER_SQLITE3_LIMITS,
71
+ ).threadMessage;
68
72
  });
69
73
 
70
74
  after(async () => {
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
3
  import type { CreateThreadMessageInput } from "@remit/data-ports";
4
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
4
5
  import { messageSearchTextTable } from "../schema/message-data.js";
5
6
  import { threadMessageTable } from "../schema/thread-message.js";
6
7
  import { createSqliteRepositories } from "../sqlite-repositories.js";
@@ -59,11 +60,14 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
59
60
  { searchIndex: true },
60
61
  );
61
62
  close = created.close;
62
- repo = createSqliteRepositories({
63
- app: created.db,
64
- mail: created.db,
65
- search: created.db,
66
- }).threadMessage;
63
+ repo = createSqliteRepositories(
64
+ {
65
+ app: created.db,
66
+ mail: created.db,
67
+ search: created.db,
68
+ },
69
+ BETTER_SQLITE3_LIMITS,
70
+ ).threadMessage;
67
71
 
68
72
  await repo.create(
69
73
  makeInput({
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
3
  import type { CreateThreadMessageInput } from "@remit/data-ports";
4
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
4
5
  import { messageSearchTextTable } from "../schema/message-data.js";
5
6
  import { threadMessageTable } from "../schema/thread-message.js";
6
7
  import { createSqliteRepositories } from "../sqlite-repositories.js";
@@ -60,11 +61,14 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
60
61
  },
61
62
  { searchIndex: true },
62
63
  ));
63
- repo = createSqliteRepositories({
64
- app: db,
65
- mail: db,
66
- search: db,
67
- }).threadMessage;
64
+ repo = createSqliteRepositories(
65
+ {
66
+ app: db,
67
+ mail: db,
68
+ search: db,
69
+ },
70
+ BETTER_SQLITE3_LIMITS,
71
+ ).threadMessage;
68
72
  });
69
73
 
70
74
  after(async () => {
@@ -5,6 +5,7 @@ import type {
5
5
  IMessageRepository,
6
6
  } from "@remit/data-ports";
7
7
  import shortUuid from "short-uuid";
8
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
8
9
  import { messageSearchTextTable } from "../schema/message-data.js";
9
10
  import { threadMessageTable } from "../schema/thread-message.js";
10
11
  import { createSqliteRepositories } from "../sqlite-repositories.js";
@@ -94,11 +95,14 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
94
95
  before(async () => {
95
96
  const harness = await setupDb();
96
97
  close = harness.close;
97
- repo = createSqliteRepositories({
98
- app: harness.db,
99
- mail: harness.db,
100
- search: harness.db,
101
- }).threadMessage;
98
+ repo = createSqliteRepositories(
99
+ {
100
+ app: harness.db,
101
+ mail: harness.db,
102
+ search: harness.db,
103
+ },
104
+ BETTER_SQLITE3_LIMITS,
105
+ ).threadMessage;
102
106
  });
103
107
 
104
108
  after(async () => {
@@ -400,11 +404,14 @@ describe("DrizzleThreadMessageRepository.searchByDate", () => {
400
404
  before(async () => {
401
405
  const harness = await setupDb();
402
406
  close = harness.close;
403
- const repositories = createSqliteRepositories({
404
- app: harness.db,
405
- mail: harness.db,
406
- search: harness.db,
407
- });
407
+ const repositories = createSqliteRepositories(
408
+ {
409
+ app: harness.db,
410
+ mail: harness.db,
411
+ search: harness.db,
412
+ },
413
+ BETTER_SQLITE3_LIMITS,
414
+ );
408
415
  repo = repositories.threadMessage;
409
416
  messages = repositories.message;
410
417
  });
@@ -935,11 +942,14 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
935
942
  before(async () => {
936
943
  const harness = await setupDb();
937
944
  close = harness.close;
938
- repo = createSqliteRepositories({
939
- app: harness.db,
940
- mail: harness.db,
941
- search: harness.db,
942
- }).threadMessage;
945
+ repo = createSqliteRepositories(
946
+ {
947
+ app: harness.db,
948
+ mail: harness.db,
949
+ search: harness.db,
950
+ },
951
+ BETTER_SQLITE3_LIMITS,
952
+ ).threadMessage;
943
953
  });
944
954
 
945
955
  after(async () => {
@@ -1111,11 +1121,14 @@ describe("DrizzleThreadMessageRepository — core CRUD", () => {
1111
1121
  before(async () => {
1112
1122
  const harness = await setupDb();
1113
1123
  close = harness.close;
1114
- repo = createSqliteRepositories({
1115
- app: harness.db,
1116
- mail: harness.db,
1117
- search: harness.db,
1118
- }).threadMessage;
1124
+ repo = createSqliteRepositories(
1125
+ {
1126
+ app: harness.db,
1127
+ mail: harness.db,
1128
+ search: harness.db,
1129
+ },
1130
+ BETTER_SQLITE3_LIMITS,
1131
+ ).threadMessage;
1119
1132
  });
1120
1133
 
1121
1134
  after(async () => {
@@ -3,6 +3,7 @@ import { after, before, describe, test } from "node:test";
3
3
  import type { CreateThreadMessageInput } from "@remit/data-ports";
4
4
  import Database from "better-sqlite3";
5
5
  import { drizzle } from "drizzle-orm/better-sqlite3";
6
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
6
7
  import { syncStore } from "../databases.js";
7
8
  import type { Db } from "../db.js";
8
9
  import { messageSearchTextTable } from "../schema/message-data.js";
@@ -50,12 +51,18 @@ const open = (set: "mail" | "search") => {
50
51
  describe("the thread-message search index in its own database", () => {
51
52
  const mail = open("mail");
52
53
  const search = open("search");
53
- const index = new SearchIndex(mail.db, syncStore(search.db));
54
- const repo = createSqliteRepositories({
55
- app: mail.db,
56
- mail: mail.db,
57
- search: search.db,
58
- }).threadMessage;
54
+ const index = new SearchIndex(
55
+ mail.db,
56
+ syncStore(search.db, BETTER_SQLITE3_LIMITS),
57
+ );
58
+ const repo = createSqliteRepositories(
59
+ {
60
+ app: mail.db,
61
+ mail: mail.db,
62
+ search: search.db,
63
+ },
64
+ BETTER_SQLITE3_LIMITS,
65
+ ).threadMessage;
59
66
 
60
67
  const found = async (query: string): Promise<string[]> =>
61
68
  (await repo.searchByMailboxWindow(ACCOUNT, MAILBOX, { query })).items.map(
@@ -13,6 +13,7 @@ import {
13
13
  } from "drizzle-orm";
14
14
  import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
15
15
  import type { SqliteBatchItem, WriteBatch } from "../batch.js";
16
+ import { chunkRows } from "../bound-parameters.js";
16
17
  import type { RepositoryDb, SqliteStore } from "../db.js";
17
18
  import { messageSearchTextTable } from "../schema/message-data.js";
18
19
  import { threadMessageTable } from "../schema/thread-message.js";
@@ -37,10 +38,7 @@ type AnyDb = RepositoryDb<Record<string, unknown>>;
37
38
 
38
39
  type SearchDocument = typeof threadMessageSearchTable.$inferInsert;
39
40
 
40
- // D1 refuses a statement with more than 100 bound parameters. A document is
41
- // nine parameters, so ten documents per insert stay under it.
42
- const DOCUMENTS_PER_STATEMENT = 10;
43
- const DOCUMENTS_PER_REBUILD_PAGE = DOCUMENTS_PER_STATEMENT * 30;
41
+ const DOCUMENTS_PER_REBUILD_PAGE = 300;
44
42
 
45
43
  // The document's rowid, derived from its thread message id so a write or a
46
44
  // removal finds the document without a lookup. 53 bits, the integers a number
@@ -96,14 +94,6 @@ const toDocument = (row: IndexedRow): SearchDocument => ({
96
94
  body: row.searchText ?? "",
97
95
  });
98
96
 
99
- const chunk = <T>(items: readonly T[], size: number): T[][] => {
100
- const chunks: T[][] = [];
101
- for (let start = 0; start < items.length; start += size) {
102
- chunks.push(items.slice(start, start + size));
103
- }
104
- return chunks;
105
- };
106
-
107
97
  /** Where a page of candidates resumes: the last one the previous page held. */
108
98
  export interface SearchPosition {
109
99
  rank: number;
@@ -141,6 +131,7 @@ export interface CandidateQuery {
141
131
  export class SearchIndex {
142
132
  private readonly search: AnyDb;
143
133
  private readonly batch: WriteBatch;
134
+ private readonly maxBoundParameters: number;
144
135
 
145
136
  constructor(
146
137
  private readonly mail: AnyDb,
@@ -148,12 +139,17 @@ export class SearchIndex {
148
139
  ) {
149
140
  this.search = store.db;
150
141
  this.batch = store.batch;
142
+ this.maxBoundParameters = store.maxBoundParameters;
151
143
  }
152
144
 
153
145
  /** Make the index match the mail database for these thread messages. */
154
146
  async sync(threadMessageIds: readonly string[]): Promise<void> {
155
147
  const ids = [...new Set(threadMessageIds)];
156
- for (const slice of chunk(ids, DOCUMENTS_PER_STATEMENT)) {
148
+ for (const slice of chunkRows(
149
+ threadMessageSearchTable,
150
+ ids,
151
+ this.maxBoundParameters,
152
+ )) {
157
153
  const rows = await this.mail
158
154
  .select(indexedColumns)
159
155
  .from(threadMessageTable)
@@ -272,10 +268,11 @@ export class SearchIndex {
272
268
  const last = rows.at(-1);
273
269
  if (!last) return indexed;
274
270
  await this.batch(
275
- chunk(rows, DOCUMENTS_PER_STATEMENT).map((slice) =>
276
- this.search
277
- .insert(threadMessageSearchTable)
278
- .values(slice.map(toDocument)),
271
+ chunkRows(threadMessageSearchTable, rows, this.maxBoundParameters).map(
272
+ (slice) =>
273
+ this.search
274
+ .insert(threadMessageSearchTable)
275
+ .values(slice.map(toDocument)),
279
276
  ),
280
277
  );
281
278
  indexed += rows.length;
@@ -1,3 +1,4 @@
1
+ import type { SqliteLimits } from "./bound-parameters.js";
1
2
  import {
2
3
  type SqliteDatabases,
3
4
  type SqliteStores,
@@ -67,7 +68,7 @@ export const composeSqliteRepositories = ({
67
68
  accountExportRequest: new AccountExportRequestRepo(app.db),
68
69
  configImport: new ConfigImportRepo(app.db),
69
70
  quarantine: new QuarantineRepo(mail.db),
70
- syncWarning: new SyncWarningRepo(mail.db),
71
+ syncWarning: new SyncWarningRepo(mail),
71
72
  organizeJobRequest: new OrganizeJobRequestRepo(mail.db),
72
73
  placementMove: new MessagePlacementMoveRepo(mail.db),
73
74
  flagPush: new MessageFlagPushRepo(mail.db),
@@ -77,7 +78,7 @@ export const composeSqliteRepositories = ({
77
78
  senderSignerStanding: new SenderSignerStandingRepo(mail.db),
78
79
  calendarCollection: new CalendarCollectionRepo(app.db),
79
80
  calendarObject: new CalendarObjectRepo(app.db),
80
- calendarEventIndex: new CalendarEventIndexRepo(app.db),
81
+ calendarEventIndex: new CalendarEventIndexRepo(app),
81
82
  calendarFeedToken: new CalendarFeedTokenRepo(app.db),
82
83
  calendarSuggestion,
83
84
  calendarStore: new DrizzleCalendarStore(app, calendarSuggestion),
@@ -88,4 +89,6 @@ export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
88
89
 
89
90
  export const createSqliteRepositories = (
90
91
  databases: SqliteDatabases,
91
- ): SqliteRepositories => composeSqliteRepositories(syncStores(databases));
92
+ limits: SqliteLimits,
93
+ ): SqliteRepositories =>
94
+ composeSqliteRepositories(syncStores(databases, limits));
package/src/test-d1.ts CHANGED
@@ -1,40 +1,57 @@
1
1
  import { drizzle } from "drizzle-orm/d1";
2
2
  import { Miniflare } from "miniflare";
3
3
  import { asyncBatch } from "./batch.js";
4
+ import type { SqliteLimits } from "./bound-parameters.js";
4
5
  import { syncStore } from "./databases.js";
5
- import { migrationStatements } from "./test-shipped-sqlite-schema.js";
6
+ import type { SqliteStore } from "./db.js";
7
+ import {
8
+ type MigrationSet,
9
+ migrationStatements,
10
+ } from "./test-shipped-sqlite-schema.js";
6
11
  import { createShippedSqliteDb } from "./test-sqlite.js";
7
12
  import type { OpenTestStores } from "./test-stores.js";
8
13
 
9
- /**
10
- * The stores a Cloudflare deployment runs the app repositories on: the app
11
- * database on an in-process D1, migrated with the committed app set, written
12
- * through `db.batch()`. The mail and search sets stay on a synchronous handle,
13
- * as they do in the tenant mail Durable Object.
14
- */
15
- export const openD1TestStores: OpenTestStores = async () => {
14
+ const CLOUDFLARE_LIMITS: SqliteLimits = { maxBoundParameters: 100 };
15
+
16
+ /** One in-process D1 database migrated with a committed set, written through `db.batch()`. */
17
+ export const openD1TestStore = async (
18
+ set: MigrationSet,
19
+ ): Promise<{
20
+ store: SqliteStore<Record<string, unknown>>;
21
+ close: () => Promise<void>;
22
+ }> => {
16
23
  const miniflare = new Miniflare({
17
24
  modules: true,
18
25
  script: "export default {};",
19
- d1Databases: { APP: "app" },
26
+ d1Databases: { DB: set },
20
27
  });
21
- const d1 = await miniflare.getD1Database("APP");
22
- const statements = migrationStatements("app").filter(
28
+ const d1 = await miniflare.getD1Database("DB");
29
+ const statements = migrationStatements(set).filter(
23
30
  (statement) => statement.trim() !== "",
24
31
  );
25
32
  await d1.batch(statements.map((statement) => d1.prepare(statement)));
26
- const app = drizzle(d1);
33
+ const db = drizzle(d1);
34
+ return {
35
+ store: { db, batch: asyncBatch(db), ...CLOUDFLARE_LIMITS },
36
+ close: () => miniflare.dispose(),
37
+ };
38
+ };
39
+
40
+ /**
41
+ * The stores a Cloudflare deployment runs the app repositories on: the app
42
+ * database on an in-process D1, migrated with the committed app set, written
43
+ * through `db.batch()`. The mail and search sets stay on a synchronous handle,
44
+ * as they do in the tenant mail Durable Object.
45
+ */
46
+ export const openD1TestStores: OpenTestStores = async () => {
47
+ const app = await openD1TestStore("app");
27
48
  const shipped = createShippedSqliteDb();
28
- const local = syncStore(shipped.db);
49
+ const local = syncStore(shipped.db, CLOUDFLARE_LIMITS);
29
50
  return {
30
- stores: {
31
- app: { db: app, batch: asyncBatch(app) },
32
- mail: local,
33
- search: local,
34
- },
51
+ stores: { app: app.store, mail: local, search: local },
35
52
  close: async () => {
36
53
  shipped.close();
37
- await miniflare.dispose();
54
+ await app.close();
38
55
  },
39
56
  };
40
57
  };
@@ -1,3 +1,4 @@
1
+ import { BETTER_SQLITE3_LIMITS } from "./bound-parameters.js";
1
2
  import { type SqliteStores, syncStores } from "./databases.js";
2
3
  import {
3
4
  calendarEventIndexTable,
@@ -16,7 +17,13 @@ export type OpenTestStores = () => Promise<{
16
17
  /** The whole schema on one in-memory better-sqlite3 handle that plays all three databases. */
17
18
  export const openSqliteTestStores: OpenTestStores = async () => {
18
19
  const { db, close } = await createTestDb();
19
- return { stores: syncStores({ app: db, mail: db, search: db }), close };
20
+ return {
21
+ stores: syncStores(
22
+ { app: db, mail: db, search: db },
23
+ BETTER_SQLITE3_LIMITS,
24
+ ),
25
+ close,
26
+ };
20
27
  };
21
28
 
22
29
  /** The three calendar tables alone on one in-memory better-sqlite3 handle. */
@@ -26,5 +33,11 @@ export const openCalendarSqliteTestStores: OpenTestStores = async () => {
26
33
  calendarObjects: calendarObjectTable,
27
34
  calendarEventIndexes: calendarEventIndexTable,
28
35
  });
29
- return { stores: syncStores({ app: db, mail: db, search: db }), close };
36
+ return {
37
+ stores: syncStores(
38
+ { app: db, mail: db, search: db },
39
+ BETTER_SQLITE3_LIMITS,
40
+ ),
41
+ close,
42
+ };
30
43
  };