@remit/drizzle-service 0.0.67 → 0.0.69

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.67",
3
+ "version": "0.0.69",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { CreateFailedConflictError, NotFoundError } from "./error.js";
2
2
  export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
3
3
  export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
4
4
  export { CalendarObjectRepo } from "./repos/calendar-object.js";
5
+ export { CalendarSuggestionRepo } from "./repos/calendar-suggestion.js";
5
6
  export { DrizzleCalendarUnitOfWork } from "./repos/calendar-unit-of-work.js";
6
7
  export {
7
8
  type CascadeDeleteLogger,
@@ -72,6 +72,37 @@ export class CalendarCollectionRepo implements ICalendarCollectionRepository {
72
72
  return rowToCalendar(row);
73
73
  }
74
74
 
75
+ /**
76
+ * One statement, so the loser of a race is told the segment is taken rather
77
+ * than handed the winner's calendar. A read followed by an insert would let
78
+ * both callers see the segment free and one of them silently write into the
79
+ * other's collection.
80
+ */
81
+ async createExclusive(
82
+ input: CreateCalendarCollectionInput,
83
+ ): Promise<CalendarCollectionItem | null> {
84
+ const now = Date.now();
85
+ const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
86
+ const [row] = await this.db
87
+ .insert(calendarTable)
88
+ .values({
89
+ calendarId: deriveCalendarId(input.accountConfigId, urlSegment),
90
+ accountConfigId: input.accountConfigId,
91
+ urlSegment,
92
+ displayName: input.displayName,
93
+ color: input.color ?? "Cal1",
94
+ componentSet: input.componentSet ?? "VeventOnly",
95
+ source: input.source ?? "UserCreated",
96
+ timezone: input.timezone ?? "",
97
+ syncSequence: 0,
98
+ createdAt: now,
99
+ updatedAt: now,
100
+ })
101
+ .onConflictDoNothing({ target: calendarTable.calendarId })
102
+ .returning();
103
+ return row ? rowToCalendar(row) : null;
104
+ }
105
+
75
106
  async get(
76
107
  accountConfigId: string,
77
108
  calendarId: string,
@@ -0,0 +1,73 @@
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 { ICalendarUnitOfWork } 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 { createSqliteTestDb } from "../test-db-sqlite.js";
12
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
13
+ import { DrizzleCalendarUnitOfWork } from "./calendar-unit-of-work.js";
14
+
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 unitOfWork: ICalendarUnitOfWork;
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
+ unitOfWork = new DrizzleCalendarUnitOfWork(db);
31
+ });
32
+
33
+ after(() => close());
34
+
35
+ test("leaves one calendar behind when several first reads arrive together", async () => {
36
+ const provisioned = await Promise.all(
37
+ Array.from({ length: 8 }, () =>
38
+ provisionDefaultCalendar(unitOfWork, ACCOUNT_CONFIG_ID),
39
+ ),
40
+ );
41
+
42
+ const ids = new Set(provisioned.map((collection) => collection.calendarId));
43
+ assert.equal(ids.size, 1, "every caller was handed the same calendar");
44
+
45
+ const rows = await new CalendarCollectionRepo(db).listByAccountConfig(
46
+ ACCOUNT_CONFIG_ID,
47
+ );
48
+ assert.equal(rows.length, 1);
49
+ assert.equal(rows[0]?.urlSegment, "default");
50
+ assert.equal(
51
+ rows[0]?.syncSequence,
52
+ 0,
53
+ "a losing provision did not reset a collection the winner may already have written to",
54
+ );
55
+ });
56
+
57
+ test("a later provision returns the stored collection rather than resetting it", async () => {
58
+ const repo = new CalendarCollectionRepo(db);
59
+ const [before] = await repo.listByAccountConfig(ACCOUNT_CONFIG_ID);
60
+ assert.ok(before);
61
+ await repo.bumpSyncSequence(ACCOUNT_CONFIG_ID, before.calendarId);
62
+
63
+ const again = await provisionDefaultCalendar(
64
+ unitOfWork,
65
+ ACCOUNT_CONFIG_ID,
66
+ "A different name",
67
+ );
68
+
69
+ assert.equal(again.calendarId, before.calendarId);
70
+ assert.equal(again.displayName, before.displayName);
71
+ assert.equal(again.syncSequence, 1);
72
+ });
73
+ });
@@ -19,6 +19,9 @@ function rowToOccurrence(
19
19
  startAt: row.startAt,
20
20
  endAt: row.endAt,
21
21
  allDay: row.allDay,
22
+ summary: row.summary,
23
+ status: row.status,
24
+ transparency: row.transparency,
22
25
  createdAt: row.createdAt,
23
26
  updatedAt: row.updatedAt,
24
27
  };
@@ -44,6 +47,9 @@ export class CalendarEventIndexRepo implements ICalendarEventIndexRepository {
44
47
  startAt: occurrence.startAt,
45
48
  endAt: occurrence.endAt,
46
49
  allDay: occurrence.allDay,
50
+ summary: occurrence.summary,
51
+ status: occurrence.status,
52
+ transparency: occurrence.transparency,
47
53
  createdAt: now,
48
54
  updatedAt: now,
49
55
  })),
@@ -4,7 +4,7 @@ import type {
4
4
  PutCalendarObjectInput,
5
5
  } from "@remit/data-ports";
6
6
  import { deriveCalendarObjectId } from "@remit/data-ports/id";
7
- import { and, asc, eq, gt } from "drizzle-orm";
7
+ import { and, asc, eq, gt, lt, ne } from "drizzle-orm";
8
8
  import type { Db } from "../db.js";
9
9
  import { NotFoundError } from "../error.js";
10
10
  import { calendarObjectTable } from "../schema.js";
@@ -82,6 +82,22 @@ export class CalendarObjectRepo implements ICalendarObjectRepository {
82
82
  return rowToCalendarObject(row);
83
83
  }
84
84
 
85
+ async find(
86
+ calendarId: string,
87
+ calendarObjectId: string,
88
+ ): Promise<CalendarObjectItem | null> {
89
+ const [row] = await this.db
90
+ .select()
91
+ .from(calendarObjectTable)
92
+ .where(
93
+ and(
94
+ eq(calendarObjectTable.calendarId, calendarId),
95
+ eq(calendarObjectTable.calendarObjectId, calendarObjectId),
96
+ ),
97
+ );
98
+ return row ? rowToCalendarObject(row) : null;
99
+ }
100
+
85
101
  async delete(calendarId: string, calendarObjectId: string): Promise<void> {
86
102
  await this.db
87
103
  .delete(calendarObjectTable)
@@ -139,6 +155,24 @@ export class CalendarObjectRepo implements ICalendarObjectRepository {
139
155
  return rows.map(rowToCalendarObject);
140
156
  }
141
157
 
158
+ async listIncompleteExpansions(
159
+ calendarId: string,
160
+ instant: string,
161
+ ): Promise<CalendarObjectItem[]> {
162
+ const rows = await this.db
163
+ .select()
164
+ .from(calendarObjectTable)
165
+ .where(
166
+ and(
167
+ eq(calendarObjectTable.calendarId, calendarId),
168
+ ne(calendarObjectTable.expandedThrough, ""),
169
+ lt(calendarObjectTable.expandedThrough, instant),
170
+ ),
171
+ )
172
+ .orderBy(asc(calendarObjectTable.calendarObjectId));
173
+ return rows.map(rowToCalendarObject);
174
+ }
175
+
142
176
  async listChangedSince(
143
177
  calendarId: string,
144
178
  syncSequence: number,
@@ -0,0 +1,23 @@
1
+ import { calendarSuggestionRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { calendarSuggestionTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ calendarSuggestionRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ calendarSuggestions: calendarSuggestionTable,
14
+ });
15
+ close = closeDb;
16
+ return new CalendarSuggestionRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -0,0 +1,223 @@
1
+ import type {
2
+ CalendarSuggestionItem,
3
+ ICalendarSuggestionRepository,
4
+ PutCalendarSuggestionInput,
5
+ ResultList,
6
+ SettleCalendarSuggestionInput,
7
+ } from "@remit/data-ports";
8
+ import { deriveCalendarSuggestionId } from "@remit/data-ports/id";
9
+ import { CalendarSuggestionState } from "@remit/domain-enums";
10
+ import { and, asc, desc, eq, lt, or } from "drizzle-orm";
11
+ import type { Db } from "../db.js";
12
+ import { NotFoundError } from "../error.js";
13
+ import { decodeToken, resultList } from "../pagination.js";
14
+ import { calendarSuggestionTable } from "../schema.js";
15
+
16
+ type DB = Db<Record<string, unknown>>;
17
+
18
+ function rowToCalendarSuggestion(
19
+ row: typeof calendarSuggestionTable.$inferSelect,
20
+ ): CalendarSuggestionItem {
21
+ return {
22
+ suggestionId: row.suggestionId,
23
+ accountConfigId: row.accountConfigId,
24
+ messageId: row.messageId,
25
+ bodyPartId: row.bodyPartId,
26
+ icalUid: row.icalUid,
27
+ sequence: row.sequence,
28
+ method: row.method,
29
+ source: row.source,
30
+ state: row.state,
31
+ summary: row.summary,
32
+ dtStart: row.dtStart,
33
+ dtEnd: row.dtEnd,
34
+ allDay: row.allDay,
35
+ location: row.location,
36
+ organizer: row.organizer,
37
+ zoneCertainty: row.zoneCertainty,
38
+ icalData: row.icalData,
39
+ acceptedCalendarObjectId: row.acceptedCalendarObjectId,
40
+ createdAt: row.createdAt,
41
+ updatedAt: row.updatedAt,
42
+ };
43
+ }
44
+
45
+ export class CalendarSuggestionRepo implements ICalendarSuggestionRepository {
46
+ constructor(private db: DB) {}
47
+
48
+ async put(
49
+ input: PutCalendarSuggestionInput,
50
+ ): Promise<CalendarSuggestionItem> {
51
+ const now = Date.now();
52
+ const suggestionId = deriveCalendarSuggestionId(
53
+ input.messageId,
54
+ input.bodyPartId,
55
+ input.icalUid,
56
+ );
57
+ const [row] = await this.db
58
+ .insert(calendarSuggestionTable)
59
+ .values({
60
+ ...input,
61
+ suggestionId,
62
+ state: CalendarSuggestionState.Pending,
63
+ acceptedCalendarObjectId: "",
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ })
67
+ .onConflictDoUpdate({
68
+ target: [
69
+ calendarSuggestionTable.accountConfigId,
70
+ calendarSuggestionTable.suggestionId,
71
+ ],
72
+ // `state` and `acceptedCalendarObjectId` are deliberately absent from
73
+ // the update set: they carry what a person decided, and a producer
74
+ // re-reading the message must not walk that back to Pending.
75
+ set: { ...input, updatedAt: now },
76
+ })
77
+ .returning();
78
+ return rowToCalendarSuggestion(row);
79
+ }
80
+
81
+ async get(
82
+ accountConfigId: string,
83
+ suggestionId: string,
84
+ ): Promise<CalendarSuggestionItem> {
85
+ const [row] = await this.db
86
+ .select()
87
+ .from(calendarSuggestionTable)
88
+ .where(
89
+ and(
90
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
91
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
92
+ ),
93
+ );
94
+ if (!row) {
95
+ throw new NotFoundError(`Calendar suggestion not found: ${suggestionId}`);
96
+ }
97
+ return rowToCalendarSuggestion(row);
98
+ }
99
+
100
+ async listByMessage(
101
+ accountConfigId: string,
102
+ messageId: string,
103
+ ): Promise<CalendarSuggestionItem[]> {
104
+ const rows = await this.db
105
+ .select()
106
+ .from(calendarSuggestionTable)
107
+ .where(
108
+ and(
109
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
110
+ eq(calendarSuggestionTable.messageId, messageId),
111
+ ),
112
+ )
113
+ .orderBy(
114
+ asc(calendarSuggestionTable.createdAt),
115
+ asc(calendarSuggestionTable.suggestionId),
116
+ );
117
+ return rows.map(rowToCalendarSuggestion);
118
+ }
119
+
120
+ /**
121
+ * Newest first, paged on a keyset over `(createdAt, suggestionId)` — the
122
+ * exact trailing members of the `byState` index's sort key, read backwards.
123
+ * The order is the index's own, never a sort applied on top of it.
124
+ */
125
+ async listByState(
126
+ accountConfigId: string,
127
+ state: CalendarSuggestionItem["state"],
128
+ options?: { limit?: number; continuationToken?: string },
129
+ ): Promise<ResultList<CalendarSuggestionItem>> {
130
+ const limit = options?.limit ?? 100;
131
+ const cursor = options?.continuationToken
132
+ ? decodeToken(options.continuationToken)
133
+ : undefined;
134
+ const after = cursor
135
+ ? {
136
+ createdAt: cursor.createdAt as number,
137
+ suggestionId: cursor.suggestionId as string,
138
+ }
139
+ : undefined;
140
+
141
+ const rows = await this.db
142
+ .select()
143
+ .from(calendarSuggestionTable)
144
+ .where(
145
+ and(
146
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
147
+ eq(calendarSuggestionTable.state, state),
148
+ after
149
+ ? or(
150
+ lt(calendarSuggestionTable.createdAt, after.createdAt),
151
+ and(
152
+ eq(calendarSuggestionTable.createdAt, after.createdAt),
153
+ lt(calendarSuggestionTable.suggestionId, after.suggestionId),
154
+ ),
155
+ )
156
+ : undefined,
157
+ ),
158
+ )
159
+ .orderBy(
160
+ desc(calendarSuggestionTable.createdAt),
161
+ desc(calendarSuggestionTable.suggestionId),
162
+ )
163
+ .limit(limit);
164
+
165
+ const items = rows.map(rowToCalendarSuggestion);
166
+ const lastItem = items[items.length - 1];
167
+ return resultList(
168
+ items,
169
+ limit,
170
+ lastItem
171
+ ? {
172
+ createdAt: lastItem.createdAt,
173
+ suggestionId: lastItem.suggestionId,
174
+ }
175
+ : undefined,
176
+ );
177
+ }
178
+
179
+ async settle(
180
+ accountConfigId: string,
181
+ suggestionId: string,
182
+ input: SettleCalendarSuggestionInput,
183
+ ): Promise<CalendarSuggestionItem> {
184
+ const [row] = await this.db
185
+ .update(calendarSuggestionTable)
186
+ .set({ ...input, updatedAt: Date.now() })
187
+ .where(
188
+ and(
189
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
190
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
191
+ ),
192
+ )
193
+ .returning();
194
+ if (!row) {
195
+ throw new NotFoundError(`Calendar suggestion not found: ${suggestionId}`);
196
+ }
197
+ return rowToCalendarSuggestion(row);
198
+ }
199
+
200
+ async supersedeIfPending(
201
+ accountConfigId: string,
202
+ suggestionId: string,
203
+ ): Promise<CalendarSuggestionItem | null> {
204
+ const [row] = await this.db
205
+ .update(calendarSuggestionTable)
206
+ .set({
207
+ state: CalendarSuggestionState.Superseded,
208
+ acceptedCalendarObjectId: "",
209
+ updatedAt: Date.now(),
210
+ })
211
+ .where(
212
+ and(
213
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
214
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
215
+ // The condition is the point: a card the user answered between
216
+ // the producer's read and this write must keep their answer.
217
+ eq(calendarSuggestionTable.state, CalendarSuggestionState.Pending),
218
+ ),
219
+ )
220
+ .returning();
221
+ return row ? rowToCalendarSuggestion(row) : null;
222
+ }
223
+ }
@@ -7,6 +7,7 @@ import { runInTransaction } from "../tx.js";
7
7
  import { CalendarCollectionRepo } from "./calendar-collection.js";
8
8
  import { CalendarEventIndexRepo } from "./calendar-event-index.js";
9
9
  import { CalendarObjectRepo } from "./calendar-object.js";
10
+ import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
10
11
 
11
12
  type DB = Db<Record<string, unknown>>;
12
13
 
@@ -21,6 +22,7 @@ export class DrizzleCalendarUnitOfWork implements ICalendarUnitOfWork {
21
22
  calendarCollection: new CalendarCollectionRepo(tx as never),
22
23
  calendarObject: new CalendarObjectRepo(tx as never),
23
24
  calendarEventIndex: new CalendarEventIndexRepo(tx as never),
25
+ calendarSuggestion: new CalendarSuggestionRepo(tx as never),
24
26
  }),
25
27
  );
26
28
  }
package/src/schema.ts CHANGED
@@ -11,6 +11,7 @@ import * as entities from "@remit/drizzle-sqlite-schema";
11
11
  export const calendarTable = entities.calendarCollections;
12
12
  export const calendarObjectTable = entities.calendarObjects;
13
13
  export const calendarEventIndexTable = entities.calendarEventIndexes;
14
+ export const calendarSuggestionTable = entities.calendarSuggestions;
14
15
  export const filterAnchorTable = entities.filterAnchors;
15
16
  export const filterTable = entities.filters;
16
17
  export const labelTable = entities.labels;