@remit/drizzle-service 0.0.103 → 0.0.105

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 (66) hide show
  1. package/package.json +4 -3
  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 +4 -0
  7. package/src/repair/junk-only-address.sqlite.test.ts +9 -5
  8. package/src/repos/account.conformance.d1.test.ts +20 -0
  9. package/src/repos/account.conformance.sqlite.test.ts +9 -5
  10. package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
  11. package/src/repos/calendar-default.d1.test.ts +4 -0
  12. package/src/repos/calendar-default.sqlite.test.ts +3 -76
  13. package/src/repos/calendar-default.suite.ts +68 -0
  14. package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
  15. package/src/repos/calendar-event-index.conformance.sqlite.test.ts +3 -1
  16. package/src/repos/calendar-event-index.ts +36 -21
  17. package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
  18. package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
  19. package/src/repos/calendar-put.d1.test.ts +4 -0
  20. package/src/repos/calendar-put.sqlite.test.ts +3 -209
  21. package/src/repos/calendar-put.suite.ts +210 -0
  22. package/src/repos/calendar-store.ts +17 -10
  23. package/src/repos/cascade-delete.test.ts +9 -2
  24. package/src/repos/cascade-delete.ts +3 -2
  25. package/src/repos/envelope.d1.test.ts +66 -0
  26. package/src/repos/envelope.sqlite.test.ts +5 -1
  27. package/src/repos/envelope.test.ts +5 -1
  28. package/src/repos/envelope.ts +40 -31
  29. package/src/repos/filter.d1.test.ts +4 -0
  30. package/src/repos/filter.suite.ts +476 -0
  31. package/src/repos/filter.test.ts +3 -472
  32. package/src/repos/i4-account-config.d1.test.ts +4 -0
  33. package/src/repos/i4-account-config.suite.ts +131 -0
  34. package/src/repos/i4-account-config.test.ts +3 -127
  35. package/src/repos/i4-account-export-request.d1.test.ts +4 -0
  36. package/src/repos/i4-account-export-request.suite.ts +44 -0
  37. package/src/repos/i4-account-export-request.test.ts +3 -40
  38. package/src/repos/i4-account-setting.d1.test.ts +4 -0
  39. package/src/repos/i4-account-setting.suite.ts +100 -0
  40. package/src/repos/i4-account-setting.test.ts +3 -93
  41. package/src/repos/i4-account.test.ts +17 -10
  42. package/src/repos/i4-address-junk-move.sqlite.test.ts +9 -5
  43. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +9 -5
  44. package/src/repos/i4-mailbox.sqlite.test.ts +17 -10
  45. package/src/repos/i4-mailbox.test.ts +17 -10
  46. package/src/repos/i4-outbox-attachment.test.ts +9 -5
  47. package/src/repos/label.conformance.d1.test.ts +20 -0
  48. package/src/repos/message.sqlite.test.ts +59 -1
  49. package/src/repos/message.test.ts +5 -1
  50. package/src/repos/message.ts +66 -0
  51. package/src/repos/sync-warning.ts +35 -22
  52. package/src/repos/thread-message-body-muted.sqlite.test.ts +32 -24
  53. package/src/repos/thread-message-field-terms.sqlite.test.ts +18 -10
  54. package/src/repos/thread-message.sqlite.test.ts +15 -9
  55. package/src/repos/thread-message.test.ts +54 -29
  56. package/src/repos/thread-message.ts +13 -9
  57. package/src/repos/thread-search-predicates.ts +36 -63
  58. package/src/schema/message-data.ts +3 -0
  59. package/src/schema-sets/mail.ts +1 -0
  60. package/src/search/thread-message-search.sqlite.test.ts +62 -14
  61. package/src/search/thread-message-search.ts +27 -21
  62. package/src/sqlite-repositories.ts +6 -3
  63. package/src/test-d1.ts +57 -0
  64. package/src/test-db-sqlite.ts +1 -1
  65. package/src/test-shipped-sqlite-schema.ts +8 -6
  66. package/src/test-stores.ts +43 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -25,14 +25,15 @@
25
25
  "@remit/calendar-service": "*",
26
26
  "@remit/config-transfer": "*",
27
27
  "drizzle-kit": "^0.31.1",
28
+ "miniflare": "^4.20260730.0",
28
29
  "tsx": "*"
29
30
  },
30
31
  "dependencies": {
31
32
  "@remit/data-ports": "*",
32
33
  "@remit/domain-enums": "*",
33
34
  "drizzle-orm": "^0.45.2",
34
- "short-uuid": "*",
35
- "uuid": "*",
35
+ "short-uuid": "^6.0.3",
36
+ "uuid": "^13.0.2",
36
37
  "@remit/drizzle-sqlite-schema": "*",
37
38
  "better-sqlite3": "^12.11.1",
38
39
  "@types/better-sqlite3": "^7.6.13"
@@ -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,
@@ -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,20 @@
1
+ import { accountRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { composeSqliteRepositories } from "../sqlite-repositories.js";
5
+ import { openD1TestStores } from "../test-d1.js";
6
+
7
+ let close: (() => Promise<void>) | undefined;
8
+
9
+ accountRepositoryConformance({
10
+ async createRepository() {
11
+ const opened = await openD1TestStores();
12
+ close = opened.close;
13
+ return composeSqliteRepositories(opened.stores).account;
14
+ },
15
+ teardown: async () => {
16
+ await close?.();
17
+ },
18
+ makeId: () => randomId(),
19
+ isNotFoundError: (error) => error instanceof NotFoundError,
20
+ });
@@ -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?.();
@@ -0,0 +1,20 @@
1
+ import { calendarCollectionRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { openD1TestStores } from "../test-d1.js";
5
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
6
+
7
+ let close: (() => Promise<void>) | undefined;
8
+
9
+ calendarCollectionRepositoryConformance({
10
+ async createRepository() {
11
+ const opened = await openD1TestStores();
12
+ close = opened.close;
13
+ return new CalendarCollectionRepo(opened.stores.app.db);
14
+ },
15
+ teardown: async () => {
16
+ await close?.();
17
+ },
18
+ makeId: () => randomId(),
19
+ isNotFoundError: (error) => error instanceof NotFoundError,
20
+ });
@@ -0,0 +1,4 @@
1
+ import { openD1TestStores } from "../test-d1.js";
2
+ import { calendarDefaultSuite } from "./calendar-default.suite.js";
3
+
4
+ calendarDefaultSuite(openD1TestStores);
@@ -1,77 +1,4 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { provisionDefaultCalendar } from "@remit/calendar-service";
4
- import type { ICalendarStore } from "@remit/data-ports";
5
- import type { Db } from "../db.js";
6
- import {
7
- calendarEventIndexTable,
8
- calendarObjectTable,
9
- calendarTable,
10
- } from "../schema.js";
11
- import { createSqliteRepositories } from "../sqlite-repositories.js";
12
- import { createSqliteTestDb } from "../test-db-sqlite.js";
13
- import { CalendarCollectionRepo } from "./calendar-collection.js";
1
+ import { openCalendarSqliteTestStores } from "../test-stores.js";
2
+ import { calendarDefaultSuite } from "./calendar-default.suite.js";
14
3
 
15
- const ACCOUNT_CONFIG_ID = "account-config-default";
16
-
17
- describe("provisioning the default calendar", () => {
18
- let db: Db<Record<string, unknown>>;
19
- let close: () => Promise<void>;
20
- let store: ICalendarStore;
21
-
22
- before(async () => {
23
- const created = await createSqliteTestDb({
24
- calendars: calendarTable,
25
- calendarObjects: calendarObjectTable,
26
- calendarEventIndexes: calendarEventIndexTable,
27
- });
28
- db = created.db as unknown as Db<Record<string, unknown>>;
29
- close = created.close;
30
- store = createSqliteRepositories({
31
- app: db,
32
- mail: db,
33
- search: db,
34
- }).calendarStore;
35
- });
36
-
37
- after(() => close());
38
-
39
- test("leaves one calendar behind when several first reads arrive together", async () => {
40
- const provisioned = await Promise.all(
41
- Array.from({ length: 8 }, () =>
42
- provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID),
43
- ),
44
- );
45
-
46
- const ids = new Set(provisioned.map((collection) => collection.calendarId));
47
- assert.equal(ids.size, 1, "every caller was handed the same calendar");
48
-
49
- const rows = await new CalendarCollectionRepo(db).listByAccountConfig(
50
- ACCOUNT_CONFIG_ID,
51
- );
52
- assert.equal(rows.length, 1);
53
- assert.equal(rows[0]?.urlSegment, "default");
54
- assert.equal(
55
- rows[0]?.syncSequence,
56
- 0,
57
- "a losing provision did not reset a collection the winner may already have written to",
58
- );
59
- });
60
-
61
- test("a later provision returns the stored collection rather than resetting it", async () => {
62
- const repo = new CalendarCollectionRepo(db);
63
- const [before] = await repo.listByAccountConfig(ACCOUNT_CONFIG_ID);
64
- assert.ok(before);
65
- await repo.bumpSyncSequence(ACCOUNT_CONFIG_ID, before.calendarId);
66
-
67
- const again = await provisionDefaultCalendar(
68
- store,
69
- ACCOUNT_CONFIG_ID,
70
- "A different name",
71
- );
72
-
73
- assert.equal(again.calendarId, before.calendarId);
74
- assert.equal(again.displayName, before.displayName);
75
- assert.equal(again.syncSequence, 1);
76
- });
77
- });
4
+ calendarDefaultSuite(openCalendarSqliteTestStores);
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { provisionDefaultCalendar } from "@remit/calendar-service";
4
+ import type { ICalendarStore } from "@remit/data-ports";
5
+ import type { RepositoryDb } from "../db.js";
6
+ import { composeSqliteRepositories } from "../sqlite-repositories.js";
7
+ import type { OpenTestStores } from "../test-stores.js";
8
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
9
+
10
+ const ACCOUNT_CONFIG_ID = "account-config-default";
11
+
12
+ export const calendarDefaultSuite = (open: OpenTestStores): void => {
13
+ describe("provisioning the default calendar", () => {
14
+ let db: RepositoryDb<Record<string, unknown>>;
15
+ let close: () => Promise<void>;
16
+ let store: ICalendarStore;
17
+
18
+ before(async () => {
19
+ const opened = await open();
20
+ db = opened.stores.app.db;
21
+ close = opened.close;
22
+ store = composeSqliteRepositories(opened.stores).calendarStore;
23
+ });
24
+
25
+ after(() => close());
26
+
27
+ test("leaves one calendar behind when several first reads arrive together", async () => {
28
+ const provisioned = await Promise.all(
29
+ Array.from({ length: 8 }, () =>
30
+ provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID),
31
+ ),
32
+ );
33
+
34
+ const ids = new Set(
35
+ provisioned.map((collection) => collection.calendarId),
36
+ );
37
+ assert.equal(ids.size, 1, "every caller was handed the same calendar");
38
+
39
+ const rows = await new CalendarCollectionRepo(db).listByAccountConfig(
40
+ ACCOUNT_CONFIG_ID,
41
+ );
42
+ assert.equal(rows.length, 1);
43
+ assert.equal(rows[0]?.urlSegment, "default");
44
+ assert.equal(
45
+ rows[0]?.syncSequence,
46
+ 0,
47
+ "a losing provision did not reset a collection the winner may already have written to",
48
+ );
49
+ });
50
+
51
+ test("a later provision returns the stored collection rather than resetting it", async () => {
52
+ const repo = new CalendarCollectionRepo(db);
53
+ const [before] = await repo.listByAccountConfig(ACCOUNT_CONFIG_ID);
54
+ assert.ok(before);
55
+ await repo.bumpSyncSequence(ACCOUNT_CONFIG_ID, before.calendarId);
56
+
57
+ const again = await provisionDefaultCalendar(
58
+ store,
59
+ ACCOUNT_CONFIG_ID,
60
+ "A different name",
61
+ );
62
+
63
+ assert.equal(again.calendarId, before.calendarId);
64
+ assert.equal(again.displayName, before.displayName);
65
+ assert.equal(again.syncSequence, 1);
66
+ });
67
+ });
68
+ };
@@ -0,0 +1,20 @@
1
+ import { calendarEventIndexRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { openD1TestStores } from "../test-d1.js";
5
+ import { CalendarEventIndexRepo } from "./calendar-event-index.js";
6
+
7
+ let close: (() => Promise<void>) | undefined;
8
+
9
+ calendarEventIndexRepositoryConformance({
10
+ async createRepository() {
11
+ const opened = await openD1TestStores();
12
+ close = opened.close;
13
+ return new CalendarEventIndexRepo(opened.stores.app);
14
+ },
15
+ teardown: async () => {
16
+ await close?.();
17
+ },
18
+ makeId: () => randomId(),
19
+ isNotFoundError: (error) => error instanceof NotFoundError,
20
+ });
@@ -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(
@@ -0,0 +1,20 @@
1
+ import { calendarFeedTokenRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { openD1TestStores } from "../test-d1.js";
5
+ import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
6
+
7
+ let close: (() => Promise<void>) | undefined;
8
+
9
+ calendarFeedTokenRepositoryConformance({
10
+ async createRepository() {
11
+ const opened = await openD1TestStores();
12
+ close = opened.close;
13
+ return new CalendarFeedTokenRepo(opened.stores.app.db);
14
+ },
15
+ teardown: async () => {
16
+ await close?.();
17
+ },
18
+ makeId: () => randomId(),
19
+ isNotFoundError: (error) => error instanceof NotFoundError,
20
+ });
@@ -0,0 +1,20 @@
1
+ import { calendarObjectRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { openD1TestStores } from "../test-d1.js";
5
+ import { CalendarObjectRepo } from "./calendar-object.js";
6
+
7
+ let close: (() => Promise<void>) | undefined;
8
+
9
+ calendarObjectRepositoryConformance({
10
+ async createRepository() {
11
+ const opened = await openD1TestStores();
12
+ close = opened.close;
13
+ return new CalendarObjectRepo(opened.stores.app.db);
14
+ },
15
+ teardown: async () => {
16
+ await close?.();
17
+ },
18
+ makeId: () => randomId(),
19
+ isNotFoundError: (error) => error instanceof NotFoundError,
20
+ });
@@ -0,0 +1,4 @@
1
+ import { openD1TestStores } from "../test-d1.js";
2
+ import { calendarPutSuite } from "./calendar-put.suite.js";
3
+
4
+ calendarPutSuite(openD1TestStores);