@remit/drizzle-service 0.0.109 → 1.0.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.109",
3
+ "version": "1.0.0",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
33
33
  export { CalendarObjectRepo } from "./repos/calendar-object.js";
34
34
  export { DrizzleCalendarStore } from "./repos/calendar-store.js";
35
35
  export { CalendarSuggestionRepo } from "./repos/calendar-suggestion.js";
36
+ export { CalendarTaskRepo } from "./repos/calendar-task.js";
36
37
  export {
37
38
  type CascadeDeleteLogger,
38
39
  type CascadeDeleter,
@@ -78,6 +79,7 @@ export {
78
79
  type MessageObjectStore,
79
80
  messageObjectsInMemory,
80
81
  } from "./repos/message-objects.js";
82
+ export { OutboxSchedulingPartRepo } from "./repos/outbox-scheduling-part.js";
81
83
  export { QuarantineRepo } from "./repos/quarantine.js";
82
84
  export { SyncWarningRepo } from "./repos/sync-warning.js";
83
85
  export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
@@ -6,6 +6,7 @@ import {
6
6
  putCalendarObject,
7
7
  } from "@remit/calendar-service";
8
8
  import type {
9
+ CalendarCommitReport,
9
10
  CalendarRepositories,
10
11
  CalendarWrite,
11
12
  ICalendarStore,
@@ -49,7 +50,9 @@ class FailingLastWriteStore implements ICalendarStore {
49
50
  return this.inner.repos;
50
51
  }
51
52
 
52
- async commit(writes: readonly CalendarWrite[]): Promise<void> {
53
+ async commit(
54
+ writes: readonly CalendarWrite[],
55
+ ): Promise<CalendarCommitReport> {
53
56
  const orphan = await prepareCalendarObject(
54
57
  { timezone: "" },
55
58
  {
@@ -60,7 +63,7 @@ class FailingLastWriteStore implements ICalendarStore {
60
63
  },
61
64
  );
62
65
  assert.ok(orphan.ok);
63
- await this.inner.commit([...writes, orphan.value]);
66
+ return this.inner.commit([...writes, orphan.value]);
64
67
  }
65
68
  }
66
69
 
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CalendarCommitReport,
2
3
  CalendarRepositories,
3
4
  CalendarWrite,
4
5
  ICalendarStore,
@@ -15,6 +16,7 @@ import {
15
16
  calendarFeedTokenTable,
16
17
  calendarObjectTable,
17
18
  calendarTable,
19
+ calendarTaskTable,
18
20
  } from "../schema.js";
19
21
  import { CalendarCollectionRepo } from "./calendar-collection.js";
20
22
  import { CalendarEventIndexRepo } from "./calendar-event-index.js";
@@ -55,10 +57,30 @@ export class DrizzleCalendarStore implements ICalendarStore {
55
57
  };
56
58
  }
57
59
 
58
- async commit(writes: readonly CalendarWrite[]): Promise<void> {
59
- await this.batch(
60
- writes.filter(isBatchWrite).flatMap((write) => this.statements(write)),
61
- );
60
+ async commit(
61
+ writes: readonly CalendarWrite[],
62
+ ): Promise<CalendarCommitReport> {
63
+ const items: SqliteBatchItem[] = [];
64
+ const reprojections: { at: number; calendarObjectId: string }[] = [];
65
+ for (const write of writes.filter(isBatchWrite)) {
66
+ items.push(...this.statements(write));
67
+ if (write.kind === "ReprojectObject") {
68
+ reprojections.push({
69
+ at: items.length - 1,
70
+ calendarObjectId: deriveCalendarObjectId(
71
+ write.object.calendarId,
72
+ write.object.resourceName,
73
+ ),
74
+ });
75
+ }
76
+ }
77
+ const results: readonly unknown[] = await this.batch(items);
78
+ const staleReprojections = reprojections
79
+ .filter(({ at }) => {
80
+ const written = results[at];
81
+ return Array.isArray(written) && written.length === 0;
82
+ })
83
+ .map(({ calendarObjectId }) => calendarObjectId);
62
84
  for (const write of writes.filter(isSettlement)) {
63
85
  await this.repos.calendarSuggestion.settle(
64
86
  write.accountConfigId,
@@ -66,6 +88,7 @@ export class DrizzleCalendarStore implements ICalendarStore {
66
88
  write.settle,
67
89
  );
68
90
  }
91
+ return { staleReprojections };
69
92
  }
70
93
 
71
94
  private collection(accountConfigId: string, calendarId: string) {
@@ -167,6 +190,9 @@ export class DrizzleCalendarStore implements ICalendarStore {
167
190
  );
168
191
  // Every statement reads the object it rewrites at the version the
169
192
  // caller read, so a resource edited or deleted since writes nothing.
193
+ // The object's own update runs last, so the occurrence statements still
194
+ // see that version, and it returns the row it wrote, so the commit can
195
+ // tell a stale read from a written one.
170
196
  const asRead = and(
171
197
  eq(calendarObjectTable.calendarObjectId, calendarObjectId),
172
198
  eq(calendarObjectTable.etag, write.readEtag),
@@ -180,17 +206,6 @@ export class DrizzleCalendarStore implements ICalendarStore {
180
206
  const value = <T>(field: T, name: string) => sql<T>`${field}`.as(name);
181
207
  return [
182
208
  this.bump(write.accountConfigId, calendarId),
183
- this.db
184
- .update(calendarObjectTable)
185
- .set({
186
- ...write.object,
187
- syncSequence: this.bumpedSequence(
188
- write.accountConfigId,
189
- calendarId,
190
- ),
191
- updatedAt: now,
192
- })
193
- .where(asRead),
194
209
  this.db
195
210
  .delete(calendarEventIndexTable)
196
211
  .where(
@@ -223,6 +238,18 @@ export class DrizzleCalendarStore implements ICalendarStore {
223
238
  .where(asRead),
224
239
  ),
225
240
  ),
241
+ this.db
242
+ .update(calendarObjectTable)
243
+ .set({
244
+ ...write.object,
245
+ syncSequence: this.bumpedSequence(
246
+ write.accountConfigId,
247
+ calendarId,
248
+ ),
249
+ updatedAt: now,
250
+ })
251
+ .where(asRead)
252
+ .returning({ id: calendarObjectTable.calendarObjectId }),
226
253
  ];
227
254
  }
228
255
  case "DeleteObject":
@@ -250,6 +277,9 @@ export class DrizzleCalendarStore implements ICalendarStore {
250
277
  ];
251
278
  case "DeleteCollection":
252
279
  return [
280
+ this.db
281
+ .delete(calendarTaskTable)
282
+ .where(eq(calendarTaskTable.calendarId, write.calendarId)),
253
283
  this.db
254
284
  .delete(calendarEventIndexTable)
255
285
  .where(eq(calendarEventIndexTable.calendarId, write.calendarId)),
@@ -0,0 +1,103 @@
1
+ import type {
2
+ CalendarTaskItem,
3
+ ICalendarTaskRepository,
4
+ PutCalendarTaskInput,
5
+ } from "@remit/data-ports";
6
+ import { deriveCalendarTaskId } from "@remit/data-ports/id";
7
+ import { and, asc, eq, gte, lt } from "drizzle-orm";
8
+ import type { RepositoryDb } from "../db.js";
9
+ import { calendarTaskTable } from "../schema.js";
10
+
11
+ type DB = RepositoryDb<Record<string, unknown>>;
12
+
13
+ const rowToTask = (
14
+ row: typeof calendarTaskTable.$inferSelect,
15
+ ): CalendarTaskItem => ({
16
+ calendarTaskId: row.calendarTaskId,
17
+ calendarId: row.calendarId,
18
+ resourceName: row.resourceName,
19
+ icalUid: row.icalUid,
20
+ icalData: row.icalData,
21
+ etag: row.etag,
22
+ summary: row.summary,
23
+ description: row.description,
24
+ due: row.due,
25
+ dueAt: row.dueAt,
26
+ allDay: row.allDay,
27
+ completed: row.completed,
28
+ createdAt: row.createdAt,
29
+ updatedAt: row.updatedAt,
30
+ });
31
+
32
+ export class CalendarTaskRepo implements ICalendarTaskRepository {
33
+ constructor(private db: DB) {}
34
+
35
+ async put(input: PutCalendarTaskInput): Promise<CalendarTaskItem> {
36
+ const now = Date.now();
37
+ const calendarTaskId = deriveCalendarTaskId(
38
+ input.calendarId,
39
+ input.resourceName,
40
+ );
41
+ const [row] = await this.db
42
+ .insert(calendarTaskTable)
43
+ .values({ ...input, calendarTaskId, createdAt: now, updatedAt: now })
44
+ .onConflictDoUpdate({
45
+ target: calendarTaskTable.calendarTaskId,
46
+ set: { ...input, updatedAt: now },
47
+ })
48
+ .returning();
49
+ return rowToTask(row);
50
+ }
51
+
52
+ async find(
53
+ calendarId: string,
54
+ calendarTaskId: string,
55
+ ): Promise<CalendarTaskItem | null> {
56
+ const [row] = await this.db
57
+ .select()
58
+ .from(calendarTaskTable)
59
+ .where(
60
+ and(
61
+ eq(calendarTaskTable.calendarId, calendarId),
62
+ eq(calendarTaskTable.calendarTaskId, calendarTaskId),
63
+ ),
64
+ );
65
+ return row ? rowToTask(row) : null;
66
+ }
67
+
68
+ async delete(calendarId: string, calendarTaskId: string): Promise<void> {
69
+ await this.db
70
+ .delete(calendarTaskTable)
71
+ .where(
72
+ and(
73
+ eq(calendarTaskTable.calendarId, calendarId),
74
+ eq(calendarTaskTable.calendarTaskId, calendarTaskId),
75
+ ),
76
+ );
77
+ }
78
+
79
+ async listByDueRange(
80
+ calendarId: string,
81
+ fromAt: string,
82
+ toAt: string,
83
+ ): Promise<CalendarTaskItem[]> {
84
+ const rows = await this.db
85
+ .select()
86
+ .from(calendarTaskTable)
87
+ .where(
88
+ and(
89
+ eq(calendarTaskTable.calendarId, calendarId),
90
+ gte(calendarTaskTable.dueAt, fromAt),
91
+ lt(calendarTaskTable.dueAt, toAt),
92
+ ),
93
+ )
94
+ .orderBy(asc(calendarTaskTable.dueAt));
95
+ return rows.map(rowToTask);
96
+ }
97
+
98
+ async deleteByCalendar(calendarId: string): Promise<void> {
99
+ await this.db
100
+ .delete(calendarTaskTable)
101
+ .where(eq(calendarTaskTable.calendarId, calendarId));
102
+ }
103
+ }
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { syncBatch } from "../batch.js";
3
4
  import { ForbiddenError, NotFoundError } from "../error.js";
4
5
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
6
  import { OutboxMessageRepo } from "./i4-outbox-message.js";
@@ -22,7 +23,7 @@ describe("OutboxMessageRepo", () => {
22
23
 
23
24
  before(async () => {
24
25
  ({ db, close } = await createTestDb());
25
- repo = new OutboxMessageRepo(db as never);
26
+ repo = new OutboxMessageRepo(db as never, syncBatch(db));
26
27
  });
27
28
 
28
29
  after(async () => {
@@ -1,15 +1,19 @@
1
1
  import type {
2
2
  CreateOutboxMessageInput,
3
+ CreateOutboxSchedulingPartInput,
3
4
  IOutboxMessageRepository,
4
5
  OutboxMessageItem,
5
6
  ResultList,
6
7
  UpdateOutboxMessageInput,
7
8
  } from "@remit/data-ports";
9
+ import { base36uuid } from "@remit/data-ports/id";
8
10
  import { and, desc, eq, lt, or } from "drizzle-orm";
11
+ import type { WriteBatch } from "../batch.js";
9
12
  import type { RepositoryDb } from "../db.js";
10
13
  import { ForbiddenError, NotFoundError } from "../error.js";
11
14
  import { decodeToken, resultList } from "../pagination.js";
12
15
  import { outboxMessageTable } from "../schema/i4-outbox-message.js";
16
+ import { outboxSchedulingPartTable } from "../schema.js";
13
17
  import { inList } from "./in-list.js";
14
18
 
15
19
  type DB = RepositoryDb<Record<string, unknown>>;
@@ -45,13 +49,21 @@ function rowToOutboxMessage(
45
49
  }
46
50
 
47
51
  export class OutboxMessageRepo implements IOutboxMessageRepository {
48
- constructor(private db: DB) {}
52
+ constructor(
53
+ private db: DB,
54
+ private batch: WriteBatch,
55
+ ) {}
49
56
 
50
- async create(input: CreateOutboxMessageInput): Promise<OutboxMessageItem> {
57
+ async create(
58
+ input: CreateOutboxMessageInput,
59
+ schedulingPart?: CreateOutboxSchedulingPartInput,
60
+ ): Promise<OutboxMessageItem> {
51
61
  const now = Date.now();
52
- const [row] = await this.db
62
+ const outboxMessageId = base36uuid();
63
+ const insert = this.db
53
64
  .insert(outboxMessageTable)
54
65
  .values({
66
+ outboxMessageId,
55
67
  accountId: input.accountId,
56
68
  accountConfigId: input.accountConfigId,
57
69
  fromAddress: input.fromAddress,
@@ -75,6 +87,21 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
75
87
  updatedAt: now,
76
88
  })
77
89
  .returning();
90
+ if (!schedulingPart) {
91
+ const [row] = await insert;
92
+ return rowToOutboxMessage(row);
93
+ }
94
+ const [rows] = await this.batch([
95
+ insert,
96
+ this.db.insert(outboxSchedulingPartTable).values({
97
+ ...schedulingPart,
98
+ outboxMessageId,
99
+ accountConfigId: input.accountConfigId,
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ }),
103
+ ]);
104
+ const [row] = rows as (typeof outboxMessageTable.$inferSelect)[];
78
105
  return rowToOutboxMessage(row);
79
106
  }
80
107
 
@@ -241,18 +268,12 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
241
268
  return rowToOutboxMessage(row);
242
269
  }
243
270
 
271
+ /** The row and its scheduling part go together, or neither does. */
244
272
  async delete(
245
273
  accountConfigId: string,
246
274
  outboxMessageId: string,
247
275
  ): Promise<void> {
248
- await this.db
249
- .delete(outboxMessageTable)
250
- .where(
251
- and(
252
- eq(outboxMessageTable.accountConfigId, accountConfigId),
253
- eq(outboxMessageTable.outboxMessageId, outboxMessageId),
254
- ),
255
- );
276
+ await this.deleteMany(accountConfigId, [outboxMessageId]);
256
277
  }
257
278
 
258
279
  async deleteMany(
@@ -260,14 +281,24 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
260
281
  outboxMessageIds: string[],
261
282
  ): Promise<void> {
262
283
  if (outboxMessageIds.length === 0) return;
263
- await this.db
264
- .delete(outboxMessageTable)
265
- .where(
266
- and(
267
- eq(outboxMessageTable.accountConfigId, accountConfigId),
268
- inList(outboxMessageTable.outboxMessageId, outboxMessageIds),
284
+ await this.batch([
285
+ this.db
286
+ .delete(outboxMessageTable)
287
+ .where(
288
+ and(
289
+ eq(outboxMessageTable.accountConfigId, accountConfigId),
290
+ inList(outboxMessageTable.outboxMessageId, outboxMessageIds),
291
+ ),
269
292
  ),
270
- );
293
+ this.db
294
+ .delete(outboxSchedulingPartTable)
295
+ .where(
296
+ and(
297
+ eq(outboxSchedulingPartTable.accountConfigId, accountConfigId),
298
+ inList(outboxSchedulingPartTable.outboxMessageId, outboxMessageIds),
299
+ ),
300
+ ),
301
+ ]);
271
302
  }
272
303
 
273
304
  async listByAccount(
@@ -0,0 +1,68 @@
1
+ import type {
2
+ IOutboxSchedulingPartRepository,
3
+ OutboxSchedulingPartItem,
4
+ } from "@remit/data-ports";
5
+ import { and, asc, eq } from "drizzle-orm";
6
+ import type { RepositoryDb } from "../db.js";
7
+ import { outboxMessageTable } from "../schema/i4-outbox-message.js";
8
+ import { outboxSchedulingPartTable } from "../schema.js";
9
+
10
+ type DB = RepositoryDb<Record<string, unknown>>;
11
+
12
+ const rowToPart = (
13
+ row: typeof outboxSchedulingPartTable.$inferSelect,
14
+ ): OutboxSchedulingPartItem => ({
15
+ outboxMessageId: row.outboxMessageId,
16
+ accountConfigId: row.accountConfigId,
17
+ calendarObjectId: row.calendarObjectId,
18
+ recipient: row.recipient,
19
+ method: row.method,
20
+ sequence: row.sequence,
21
+ icalData: row.icalData,
22
+ createdAt: row.createdAt,
23
+ updatedAt: row.updatedAt,
24
+ });
25
+
26
+ export class OutboxSchedulingPartRepo
27
+ implements IOutboxSchedulingPartRepository
28
+ {
29
+ constructor(private db: DB) {}
30
+
31
+ async find(
32
+ outboxMessageId: string,
33
+ ): Promise<OutboxSchedulingPartItem | null> {
34
+ const [row] = await this.db
35
+ .select()
36
+ .from(outboxSchedulingPartTable)
37
+ .where(eq(outboxSchedulingPartTable.outboxMessageId, outboxMessageId));
38
+ return row ? rowToPart(row) : null;
39
+ }
40
+
41
+ /**
42
+ * Joined to the outbox so a part whose message left the outbox by a path
43
+ * that did not take the part with it is never reported as still in flight.
44
+ */
45
+ async listByCalendarObject(
46
+ accountConfigId: string,
47
+ calendarObjectId: string,
48
+ ): Promise<OutboxSchedulingPartItem[]> {
49
+ const rows = await this.db
50
+ .select({ part: outboxSchedulingPartTable })
51
+ .from(outboxSchedulingPartTable)
52
+ .innerJoin(
53
+ outboxMessageTable,
54
+ eq(
55
+ outboxMessageTable.outboxMessageId,
56
+ outboxSchedulingPartTable.outboxMessageId,
57
+ ),
58
+ )
59
+ .where(
60
+ and(
61
+ eq(outboxSchedulingPartTable.accountConfigId, accountConfigId),
62
+ eq(outboxSchedulingPartTable.calendarObjectId, calendarObjectId),
63
+ ),
64
+ )
65
+ .orderBy(asc(outboxSchedulingPartTable.createdAt));
66
+ return rows.map(({ part }) => rowToPart(part));
67
+ }
68
+ }
@@ -11,6 +11,7 @@ export {
11
11
  calendarEventIndexes,
12
12
  calendarFeedTokens,
13
13
  calendarObjects,
14
+ calendarTasks,
14
15
  configImports,
15
16
  filters,
16
17
  labels,
@@ -24,6 +24,7 @@ export {
24
24
  organizeJobRequests,
25
25
  outboxAttachments,
26
26
  outboxMessages,
27
+ outboxSchedulingParts,
27
28
  quarantines,
28
29
  rawMessageStorages,
29
30
  senderSignerStandings,
package/src/schema.ts CHANGED
@@ -11,6 +11,8 @@ export const calendarTable = entities.calendarCollections;
11
11
  export const calendarObjectTable = entities.calendarObjects;
12
12
  export const calendarEventIndexTable = entities.calendarEventIndexes;
13
13
  export const calendarFeedTokenTable = entities.calendarFeedTokens;
14
+ export const calendarTaskTable = entities.calendarTasks;
15
+ export const outboxSchedulingPartTable = entities.outboxSchedulingParts;
14
16
  export const calendarSuggestionTable = entities.calendarSuggestions;
15
17
  export const filterTable = entities.filters;
16
18
  export const labelTable = entities.labels;
@@ -10,6 +10,7 @@ import { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
10
10
  import { CalendarObjectRepo } from "./repos/calendar-object.js";
11
11
  import { DrizzleCalendarStore } from "./repos/calendar-store.js";
12
12
  import { CalendarSuggestionRepo } from "./repos/calendar-suggestion.js";
13
+ import { CalendarTaskRepo } from "./repos/calendar-task.js";
13
14
  import { DrizzleEnvelopeRepository } from "./repos/envelope.js";
14
15
  import { FilterRepo } from "./repos/filter.js";
15
16
  import { AccountRepo } from "./repos/i4-account.js";
@@ -35,6 +36,7 @@ import {
35
36
  type MessageObjectStore,
36
37
  MessageObjects,
37
38
  } from "./repos/message-objects.js";
39
+ import { OutboxSchedulingPartRepo } from "./repos/outbox-scheduling-part.js";
38
40
  import { QuarantineRepo } from "./repos/quarantine.js";
39
41
  import { SyncWarningRepo } from "./repos/sync-warning.js";
40
42
  import { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
@@ -70,7 +72,8 @@ export const composeSqliteRepositories = (
70
72
  mailboxLock: new MailboxLockRepo(mail.db),
71
73
  message,
72
74
  messageFlag: new DrizzleMessageFlagRepository(mail.db),
73
- outboxMessage: new OutboxMessageRepo(mail.db),
75
+ outboxMessage: new OutboxMessageRepo(mail.db, mail.batch),
76
+ outboxSchedulingPart: new OutboxSchedulingPartRepo(mail.db),
74
77
  outboxAttachment: new OutboxAttachmentRepo(mail),
75
78
  threadMessage: new DrizzleThreadMessageRepository(mail, searchIndex),
76
79
  envelope: new DrizzleEnvelopeRepository(mail),
@@ -89,6 +92,7 @@ export const composeSqliteRepositories = (
89
92
  calendarObject: new CalendarObjectRepo(app.db),
90
93
  calendarEventIndex: new CalendarEventIndexRepo(app),
91
94
  calendarFeedToken: new CalendarFeedTokenRepo(app.db),
95
+ calendarTask: new CalendarTaskRepo(app.db),
92
96
  calendarSuggestion,
93
97
  calendarStore: new DrizzleCalendarStore(app, calendarSuggestion),
94
98
  };