@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
4
+ import { chunkRows, rowsPerStatement } from "./bound-parameters.js";
5
+
6
+ const tenColumns = sqliteTable("ten_columns", {
7
+ c0: text("c0"),
8
+ c1: text("c1"),
9
+ c2: text("c2"),
10
+ c3: text("c3"),
11
+ c4: text("c4"),
12
+ c5: text("c5"),
13
+ c6: text("c6"),
14
+ c7: text("c7"),
15
+ c8: text("c8"),
16
+ c9: integer("c9"),
17
+ });
18
+
19
+ const rows = (count: number): number[] =>
20
+ Array.from({ length: count }, (_, index) => index);
21
+
22
+ describe("chunkRows", () => {
23
+ test("no rows make no statements", () => {
24
+ assert.deepEqual(chunkRows(tenColumns, [], 100), []);
25
+ });
26
+
27
+ test("rows that fill the limit exactly make one statement", () => {
28
+ const chunks = chunkRows(tenColumns, rows(10), 100);
29
+ assert.deepEqual(
30
+ chunks.map((slice) => slice.length),
31
+ [10],
32
+ );
33
+ });
34
+
35
+ test("one row over the limit makes a second statement", () => {
36
+ const chunks = chunkRows(tenColumns, rows(11), 100);
37
+ assert.deepEqual(
38
+ chunks.map((slice) => slice.length),
39
+ [10, 1],
40
+ );
41
+ assert.deepEqual(chunks.flat(), rows(11));
42
+ });
43
+
44
+ test("reserved parameters shrink every statement", () => {
45
+ const chunks = chunkRows(tenColumns, rows(10), 100, 1);
46
+ assert.deepEqual(
47
+ chunks.map((slice) => slice.length),
48
+ [9, 1],
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("rowsPerStatement", () => {
54
+ test("a row wider than the limit is refused", () => {
55
+ assert.throws(() => rowsPerStatement(100, 101), RangeError);
56
+ });
57
+ });
@@ -0,0 +1,49 @@
1
+ import { getTableColumns, type Table } from "drizzle-orm";
2
+
3
+ export interface SqliteLimits {
4
+ /** The most parameters the host's SQLite binds in one statement: 32766 on better-sqlite3, 100 on D1 and Durable Object storage. */
5
+ maxBoundParameters: number;
6
+ }
7
+
8
+ export const BETTER_SQLITE3_LIMITS: SqliteLimits = {
9
+ maxBoundParameters: 32766,
10
+ };
11
+
12
+ export const chunk = <T>(items: readonly T[], size: number): T[][] => {
13
+ const chunks: T[][] = [];
14
+ for (let start = 0; start < items.length; start += size) {
15
+ chunks.push(items.slice(start, start + size));
16
+ }
17
+ return chunks;
18
+ };
19
+
20
+ export const rowsPerStatement = (
21
+ maxBoundParameters: number,
22
+ parametersPerRow: number,
23
+ reservedParameters = 0,
24
+ ): number => {
25
+ const rows = Math.floor(
26
+ (maxBoundParameters - reservedParameters) / parametersPerRow,
27
+ );
28
+ if (rows < 1) {
29
+ throw new RangeError(
30
+ `a row of ${parametersPerRow} bound parameters does not fit a statement limit of ${maxBoundParameters}`,
31
+ );
32
+ }
33
+ return rows;
34
+ };
35
+
36
+ export const chunkRows = <T>(
37
+ table: Table,
38
+ rows: readonly T[],
39
+ maxBoundParameters: number,
40
+ reservedParameters = 0,
41
+ ): T[][] =>
42
+ chunk(
43
+ rows,
44
+ rowsPerStatement(
45
+ maxBoundParameters,
46
+ Object.keys(getTableColumns(table)).length,
47
+ reservedParameters,
48
+ ),
49
+ );
package/src/databases.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { syncBatch } from "./batch.js";
2
+ import type { SqliteLimits } from "./bound-parameters.js";
2
3
  import type { Db, SqliteStore } from "./db.js";
3
4
  import { messageDataSchema } from "./schema/message-data.js";
4
5
  import { createSqliteDatabase } from "./sqlite-client.js";
@@ -22,16 +23,16 @@ export interface SqliteStores {
22
23
 
23
24
  export const syncStore = <TSchema extends Record<string, unknown>>(
24
25
  db: Db<TSchema>,
25
- ): SqliteStore<TSchema> => ({ db, batch: syncBatch(db) });
26
+ { maxBoundParameters }: SqliteLimits,
27
+ ): SqliteStore<TSchema> => ({ db, batch: syncBatch(db), maxBoundParameters });
26
28
 
27
- export const syncStores = ({
28
- app,
29
- mail,
30
- search,
31
- }: SqliteDatabases): SqliteStores => ({
32
- app: syncStore(app),
33
- mail: syncStore(mail),
34
- search: syncStore(search),
29
+ export const syncStores = (
30
+ { app, mail, search }: SqliteDatabases,
31
+ limits: SqliteLimits,
32
+ ): SqliteStores => ({
33
+ app: syncStore(app, limits),
34
+ mail: syncStore(mail, limits),
35
+ search: syncStore(search, limits),
35
36
  });
36
37
 
37
38
  export interface SqliteDatabasePaths {
package/src/db.ts CHANGED
@@ -26,4 +26,5 @@ export type RepositoryDb<TSchema extends Record<string, unknown>> = Omit<
26
26
  export interface SqliteStore<TSchema extends Record<string, unknown>> {
27
27
  db: RepositoryDb<TSchema>;
28
28
  batch: WriteBatch;
29
+ maxBoundParameters: number;
29
30
  }
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ export {
5
5
  syncBatch,
6
6
  type WriteBatch,
7
7
  } from "./batch.js";
8
+ export {
9
+ BETTER_SQLITE3_LIMITS,
10
+ type SqliteLimits,
11
+ } from "./bound-parameters.js";
8
12
  export {
9
13
  hasSqliteDatabaseEnv,
10
14
  openSqliteDatabases,
@@ -18,6 +22,11 @@ export {
18
22
  } from "./databases.js";
19
23
  export type { RepositoryDb, SqliteStore } from "./db.js";
20
24
  export { CreateFailedConflictError, NotFoundError } from "./error.js";
25
+ export {
26
+ accountIdsOf,
27
+ eraseAccountConfigAppRows,
28
+ runDrizzleAccountConfigErase,
29
+ } from "./repos/account-config-erase.js";
21
30
  export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
22
31
  export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
23
32
  export { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
@@ -4,6 +4,7 @@ import { composeFolderRoleAppointmentName } from "@remit/data-ports/folder-role"
4
4
  import { CanonicalMailboxRole } from "@remit/domain-enums";
5
5
  import Database from "better-sqlite3";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
7
8
  import { AccountSettingRepo } from "../repos/i4-account-setting.js";
8
9
  import type { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
9
10
  import { createSqliteRepositories } from "../sqlite-repositories.js";
@@ -182,11 +183,14 @@ describe("addresses standing only on mail in Junk", () => {
182
183
  before(() => {
183
184
  sqlite = new Database(":memory:");
184
185
  const db = drizzle(sqlite);
185
- specialUse = createSqliteRepositories({
186
- app: db,
187
- mail: db,
188
- search: db,
189
- }).mailboxSpecialUse;
186
+ specialUse = createSqliteRepositories(
187
+ {
188
+ app: db,
189
+ mail: db,
190
+ search: db,
191
+ },
192
+ BETTER_SQLITE3_LIMITS,
193
+ ).mailboxSpecialUse;
190
194
  accountSetting = new AccountSettingRepo(db as never);
191
195
  applyMigrationSet(sqlite, "app");
192
196
  applyMigrationSet(sqlite, "mail");
@@ -0,0 +1,127 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { eq, getTableColumns } from "drizzle-orm";
4
+ import {
5
+ getTableConfig,
6
+ type SQLiteColumn,
7
+ SQLiteTable,
8
+ } from "drizzle-orm/sqlite-core";
9
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
10
+ import { syncStores } from "../databases.js";
11
+ import * as appTables from "../schema-sets/app.js";
12
+ import * as mailTables from "../schema-sets/mail.js";
13
+ import { createTestDb, type TestDb } from "../test-db.js";
14
+ import { runDrizzleAccountConfigErase } from "./account-config-erase.js";
15
+
16
+ const log = { info: () => {} };
17
+
18
+ const ERASED = "cfg-erased";
19
+ const KEPT = "cfg-kept";
20
+
21
+ const ownerIdsOf = (tenant: string): Record<string, string> => ({
22
+ account_config_id: tenant,
23
+ account_id: `${tenant}-account`,
24
+ mailbox_id: `${tenant}-mailbox`,
25
+ message_id: `${tenant}-message`,
26
+ calendar_id: `${tenant}-calendar`,
27
+ });
28
+
29
+ interface Owned {
30
+ readonly name: string;
31
+ readonly table: SQLiteTable;
32
+ readonly ownerColumn: SQLiteColumn;
33
+ }
34
+
35
+ const owned: Owned[] = Object.entries({ ...appTables, ...mailTables }).flatMap(
36
+ ([name, table]) => {
37
+ if (!(table instanceof SQLiteTable)) return [];
38
+ const ownerKeys = Object.keys(ownerIdsOf(ERASED));
39
+ const ownerColumn = ownerKeys
40
+ .map((key) =>
41
+ getTableConfig(table).columns.find((column) => column.name === key),
42
+ )
43
+ .find((column) => column !== undefined);
44
+ return ownerColumn === undefined ? [] : [{ name, table, ownerColumn }];
45
+ },
46
+ );
47
+
48
+ const fillerOf = (column: SQLiteColumn, tenant: string): unknown => {
49
+ switch (column.dataType) {
50
+ case "number":
51
+ return 1;
52
+ case "boolean":
53
+ return false;
54
+ case "json":
55
+ return {};
56
+ case "buffer":
57
+ return Buffer.from(tenant);
58
+ default:
59
+ return `${tenant}-${column.name}`;
60
+ }
61
+ };
62
+
63
+ const rowOf = (table: SQLiteTable, tenant: string): Record<string, unknown> => {
64
+ const ownerIds = ownerIdsOf(tenant);
65
+ return Object.fromEntries(
66
+ Object.entries(getTableColumns(table)).flatMap(([key, column]) => {
67
+ const ownerId = ownerIds[column.name];
68
+ if (ownerId !== undefined) return [[key, ownerId]];
69
+ if (column.notNull && !column.hasDefault)
70
+ return [[key, fillerOf(column, tenant)]];
71
+ return [];
72
+ }),
73
+ );
74
+ };
75
+
76
+ const countOf = async (
77
+ db: TestDb,
78
+ { table, ownerColumn }: Owned,
79
+ tenant: string,
80
+ ): Promise<number> => {
81
+ const ownerId = ownerIdsOf(tenant)[ownerColumn.name] ?? "";
82
+ return (await db.select().from(table).where(eq(ownerColumn, ownerId))).length;
83
+ };
84
+
85
+ describe("runDrizzleAccountConfigErase", () => {
86
+ let db: TestDb;
87
+ let close: () => Promise<void>;
88
+
89
+ before(async () => {
90
+ ({ db, close } = await createTestDb());
91
+ for (const tenant of [ERASED, KEPT]) {
92
+ for (const { table } of owned) {
93
+ await db.insert(table).values(rowOf(table, tenant) as never);
94
+ }
95
+ }
96
+ const stores = syncStores(
97
+ { app: db, mail: db, search: db } as never,
98
+ BETTER_SQLITE3_LIMITS,
99
+ );
100
+ await runDrizzleAccountConfigErase(stores, ERASED, log);
101
+ });
102
+
103
+ after(async () => {
104
+ await close();
105
+ });
106
+
107
+ test("leaves no row of the erased account config in any table it owns", async () => {
108
+ for (const entry of owned) {
109
+ const expected = entry.table === appTables.accountConfigs ? 1 : 0;
110
+ assert.equal(
111
+ await countOf(db, entry, ERASED),
112
+ expected,
113
+ `${entry.name} keeps rows of the erased account config`,
114
+ );
115
+ }
116
+ });
117
+
118
+ test("keeps every row of another account config", async () => {
119
+ for (const entry of owned) {
120
+ assert.equal(
121
+ await countOf(db, entry, KEPT),
122
+ 1,
123
+ `${entry.name} lost rows of another account config`,
124
+ );
125
+ }
126
+ });
127
+ });
@@ -0,0 +1,170 @@
1
+ import { eq } from "drizzle-orm";
2
+ import {
3
+ getTableConfig,
4
+ type SQLiteColumn,
5
+ SQLiteTable,
6
+ } from "drizzle-orm/sqlite-core";
7
+ import type { SqliteBatchItem } from "../batch.js";
8
+ import { chunk } from "../bound-parameters.js";
9
+ import type { SqliteStores } from "../databases.js";
10
+ import type { SqliteStore } from "../db.js";
11
+ import {
12
+ accountConfigTable,
13
+ accountTable,
14
+ } from "../schema/i4-account-config.js";
15
+ import { mailboxTable } from "../schema/i4-mailbox.js";
16
+ import { calendarTable, messageTable } from "../schema.js";
17
+ import * as appTables from "../schema-sets/app.js";
18
+ import * as mailTables from "../schema-sets/mail.js";
19
+ import { SearchIndex } from "../search/thread-message-search.js";
20
+ import type { CascadeDeleteLogger } from "./cascade-delete.js";
21
+ import { inList } from "./in-list.js";
22
+
23
+ /**
24
+ * The column an account config owns a table's rows by: its own id where the
25
+ * table carries it, else the first id of the parent it hangs off.
26
+ */
27
+ export const OWNER_KEYS = [
28
+ "account_config_id",
29
+ "account_id",
30
+ "mailbox_id",
31
+ "message_id",
32
+ "calendar_id",
33
+ ] as const;
34
+
35
+ export type OwnerKey = (typeof OWNER_KEYS)[number];
36
+
37
+ export interface OwnedTable {
38
+ readonly table: SQLiteTable;
39
+ readonly ownerKey: OwnerKey;
40
+ readonly ownerColumn: SQLiteColumn;
41
+ }
42
+
43
+ export const ownedTablesOf = (tables: Record<string, unknown>): OwnedTable[] =>
44
+ Object.values(tables).flatMap((table) => {
45
+ if (!(table instanceof SQLiteTable)) return [];
46
+ const { columns } = getTableConfig(table);
47
+ for (const ownerKey of OWNER_KEYS) {
48
+ const ownerColumn = columns.find((column) => column.name === ownerKey);
49
+ if (ownerColumn !== undefined) return [{ table, ownerKey, ownerColumn }];
50
+ }
51
+ return [];
52
+ });
53
+
54
+ // Durable Object SQLite caps a bound value at 2 MB; a chunk of ids stays far
55
+ // below it.
56
+ const IDS_PER_STATEMENT = 5_000;
57
+
58
+ type OwnerIds = Readonly<Record<OwnerKey, readonly string[]>>;
59
+
60
+ const keyedDeletes = (
61
+ store: SqliteStore<Record<string, unknown>>,
62
+ tables: readonly OwnedTable[],
63
+ ids: OwnerIds,
64
+ ): SqliteBatchItem[] =>
65
+ tables
66
+ .filter(({ table }) => table !== accountConfigTable)
67
+ .flatMap(({ table, ownerKey, ownerColumn }) =>
68
+ ownerKey === "account_config_id"
69
+ ? ids.account_config_id.map((id) =>
70
+ store.db.delete(table).where(eq(ownerColumn, id)),
71
+ )
72
+ : chunk(ids[ownerKey], IDS_PER_STATEMENT).map((slice) =>
73
+ store.db.delete(table).where(inList(ownerColumn, slice)),
74
+ ),
75
+ );
76
+
77
+ const APP_TABLES = ownedTablesOf(appTables);
78
+ const MAIL_TABLES = ownedTablesOf(mailTables);
79
+
80
+ const idsOf = async <Row>(
81
+ rows: PromiseLike<Row[]>,
82
+ pick: (row: Row) => string,
83
+ ): Promise<string[]> => (await rows).map(pick);
84
+
85
+ /**
86
+ * Delete the app database rows an account config owns, except the
87
+ * AccountConfig row itself, which the caller deletes last.
88
+ */
89
+ export const eraseAccountConfigAppRows = async (
90
+ app: SqliteStore<Record<string, unknown>>,
91
+ accountConfigId: string,
92
+ accountIds: readonly string[],
93
+ ): Promise<void> => {
94
+ const calendarIds = await idsOf(
95
+ app.db
96
+ .select({ calendarId: calendarTable.calendarId })
97
+ .from(calendarTable)
98
+ .where(eq(calendarTable.accountConfigId, accountConfigId)),
99
+ (row) => row.calendarId,
100
+ );
101
+ await app.batch(
102
+ keyedDeletes(app, APP_TABLES, {
103
+ account_config_id: [accountConfigId],
104
+ account_id: accountIds,
105
+ mailbox_id: [],
106
+ message_id: [],
107
+ calendar_id: calendarIds,
108
+ }),
109
+ );
110
+ };
111
+
112
+ export const accountIdsOf = (
113
+ app: SqliteStore<Record<string, unknown>>,
114
+ accountConfigId: string,
115
+ ): Promise<string[]> =>
116
+ idsOf(
117
+ app.db
118
+ .select({ accountId: accountTable.accountId })
119
+ .from(accountTable)
120
+ .where(eq(accountTable.accountConfigId, accountConfigId)),
121
+ (row) => row.accountId,
122
+ );
123
+
124
+ /**
125
+ * Delete every row an account config owns across the app, mail and search
126
+ * databases, one keyed statement per table. A rerun deletes what is left.
127
+ */
128
+ export const runDrizzleAccountConfigErase = async (
129
+ stores: SqliteStores,
130
+ accountConfigId: string,
131
+ log: CascadeDeleteLogger,
132
+ ): Promise<void> => {
133
+ const { app, mail, search } = stores;
134
+ const accountIds = await accountIdsOf(app, accountConfigId);
135
+ const mailboxIds = await idsOf(
136
+ mail.db
137
+ .select({ mailboxId: mailboxTable.mailboxId })
138
+ .from(mailboxTable)
139
+ .where(inList(mailboxTable.accountId, accountIds)),
140
+ (row) => row.mailboxId,
141
+ );
142
+ const messageIds = await idsOf(
143
+ mail.db
144
+ .select({ messageId: messageTable.messageId })
145
+ .from(messageTable)
146
+ .where(inList(messageTable.mailboxId, mailboxIds)),
147
+ (row) => row.messageId,
148
+ );
149
+
150
+ await new SearchIndex(mail.db, search).removeAccount(accountConfigId);
151
+ await mail.batch(
152
+ keyedDeletes(mail, MAIL_TABLES, {
153
+ account_config_id: [accountConfigId],
154
+ account_id: accountIds,
155
+ mailbox_id: mailboxIds,
156
+ message_id: messageIds,
157
+ calendar_id: [],
158
+ }),
159
+ );
160
+ await eraseAccountConfigAppRows(app, accountConfigId, accountIds);
161
+
162
+ log.info(
163
+ {
164
+ accountConfigId,
165
+ accountCount: accountIds.length,
166
+ messageCount: messageIds.length,
167
+ },
168
+ "Drizzle account config erase complete",
169
+ );
170
+ };
@@ -1,4 +1,5 @@
1
1
  import { accountRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
2
3
  import { NotFoundError } from "../error.js";
3
4
  import { randomId } from "../id.js";
4
5
  import { accountTable } from "../schema.js";
@@ -13,11 +14,14 @@ accountRepositoryConformance({
13
14
  accounts: accountTable,
14
15
  });
15
16
  close = closeDb;
16
- return createSqliteRepositories({
17
- app: db,
18
- mail: db,
19
- search: db,
20
- }).account;
17
+ return createSqliteRepositories(
18
+ {
19
+ app: db,
20
+ mail: db,
21
+ search: db,
22
+ },
23
+ BETTER_SQLITE3_LIMITS,
24
+ ).account;
21
25
  },
22
26
  teardown: async () => {
23
27
  await close?.();
@@ -10,7 +10,7 @@ calendarEventIndexRepositoryConformance({
10
10
  async createRepository() {
11
11
  const opened = await openD1TestStores();
12
12
  close = opened.close;
13
- return new CalendarEventIndexRepo(opened.stores.app.db);
13
+ return new CalendarEventIndexRepo(opened.stores.app);
14
14
  },
15
15
  teardown: async () => {
16
16
  await close?.();
@@ -1,4 +1,6 @@
1
1
  import { calendarEventIndexRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
3
+ import { syncStore } from "../databases.js";
2
4
  import { NotFoundError } from "../error.js";
3
5
  import { randomId } from "../id.js";
4
6
  import { calendarEventIndexTable } from "../schema.js";
@@ -13,7 +15,7 @@ calendarEventIndexRepositoryConformance({
13
15
  calendarEventIndexes: calendarEventIndexTable,
14
16
  });
15
17
  close = closeDb;
16
- return new CalendarEventIndexRepo(db as never);
18
+ return new CalendarEventIndexRepo(syncStore(db, BETTER_SQLITE3_LIMITS));
17
19
  },
18
20
  teardown: async () => {
19
21
  await close?.();
@@ -4,7 +4,9 @@ import type {
4
4
  ICalendarEventIndexRepository,
5
5
  } from "@remit/data-ports";
6
6
  import { and, asc, eq, gte, lt } from "drizzle-orm";
7
- import type { RepositoryDb } from "../db.js";
7
+ import type { SqliteBatchItem, WriteBatch } from "../batch.js";
8
+ import { chunkRows } from "../bound-parameters.js";
9
+ import type { RepositoryDb, SqliteStore } from "../db.js";
8
10
  import { calendarEventIndexTable } from "../schema.js";
9
11
 
10
12
  type DB = RepositoryDb<Record<string, unknown>>;
@@ -28,39 +30,52 @@ function rowToOccurrence(
28
30
  }
29
31
 
30
32
  export class CalendarEventIndexRepo implements ICalendarEventIndexRepository {
31
- constructor(private db: DB) {}
33
+ private readonly db: DB;
34
+ private readonly batch: WriteBatch;
35
+ private readonly maxBoundParameters: number;
36
+
37
+ constructor(store: SqliteStore<Record<string, unknown>>) {
38
+ this.db = store.db;
39
+ this.batch = store.batch;
40
+ this.maxBoundParameters = store.maxBoundParameters;
41
+ }
32
42
 
33
43
  async replaceForObject(
34
44
  calendarId: string,
35
45
  calendarObjectId: string,
36
46
  occurrences: CalendarOccurrenceInput[],
37
47
  ): Promise<void> {
38
- await this.deleteForObject(calendarId, calendarObjectId);
39
- if (occurrences.length === 0) return;
40
-
41
48
  const now = Date.now();
42
- await this.db.insert(calendarEventIndexTable).values(
43
- occurrences.map((occurrence) => ({
44
- calendarId,
45
- calendarObjectId,
46
- recurrenceId: occurrence.recurrenceId,
47
- startAt: occurrence.startAt,
48
- endAt: occurrence.endAt,
49
- allDay: occurrence.allDay,
50
- summary: occurrence.summary,
51
- status: occurrence.status,
52
- transparency: occurrence.transparency,
53
- createdAt: now,
54
- updatedAt: now,
55
- })),
56
- );
49
+ const rows = occurrences.map((occurrence) => ({
50
+ calendarId,
51
+ calendarObjectId,
52
+ recurrenceId: occurrence.recurrenceId,
53
+ startAt: occurrence.startAt,
54
+ endAt: occurrence.endAt,
55
+ allDay: occurrence.allDay,
56
+ summary: occurrence.summary,
57
+ status: occurrence.status,
58
+ transparency: occurrence.transparency,
59
+ createdAt: now,
60
+ updatedAt: now,
61
+ }));
62
+ const inserts: SqliteBatchItem[] = chunkRows(
63
+ calendarEventIndexTable,
64
+ rows,
65
+ this.maxBoundParameters,
66
+ ).map((slice) => this.db.insert(calendarEventIndexTable).values(slice));
67
+ await this.batch([this.clear(calendarId, calendarObjectId), ...inserts]);
57
68
  }
58
69
 
59
70
  async deleteForObject(
60
71
  calendarId: string,
61
72
  calendarObjectId: string,
62
73
  ): Promise<void> {
63
- await this.db
74
+ await this.clear(calendarId, calendarObjectId);
75
+ }
76
+
77
+ private clear(calendarId: string, calendarObjectId: string) {
78
+ return this.db
64
79
  .delete(calendarEventIndexTable)
65
80
  .where(
66
81
  and(