@remit/data-ports 0.0.55 → 0.0.56

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/data-ports",
3
- "version": "0.0.55",
3
+ "version": "0.0.56",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -204,6 +204,26 @@ export function calendarCollectionRepositoryConformance(
204
204
  assert.equal(reread.syncSequence, 5);
205
205
  });
206
206
 
207
+ // Every write and every delete of an object in the collection goes through
208
+ // this bump, which makes the collection the one timestamp that only moves
209
+ // forward — the calendar feed serves it as Last-Modified, and a validator
210
+ // that moved backwards on a delete would tell a subscriber its stale copy
211
+ // is still good.
212
+ test("bumpSyncSequence carries the collection's modification time forward", async () => {
213
+ const accountConfigId = harness.makeId();
214
+ const calendar = await repo.create({
215
+ accountConfigId,
216
+ urlSegment: "touched",
217
+ displayName: "Touched",
218
+ });
219
+
220
+ await repo.bumpSyncSequence(accountConfigId, calendar.calendarId);
221
+ const bumped = await repo.get(accountConfigId, calendar.calendarId);
222
+
223
+ assert.ok(bumped.updatedAt >= calendar.updatedAt);
224
+ assert.equal(bumped.createdAt, calendar.createdAt);
225
+ });
226
+
207
227
  test("bumpSyncSequence throws a not-found error for a missing collection", async () => {
208
228
  await assert.rejects(
209
229
  repo.bumpSyncSequence(harness.makeId(), harness.makeId()),
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { deriveCalendarFeedTokenId } from "../id.js";
4
+ import type { ICalendarFeedTokenRepository } from "../interfaces/calendar-feed-token.js";
5
+ import type { RepositoryConformanceHarness } from "./harness.js";
6
+
7
+ const hashOf = (label: string): string => label.padEnd(64, "0");
8
+
9
+ export function calendarFeedTokenRepositoryConformance(
10
+ harness: RepositoryConformanceHarness<ICalendarFeedTokenRepository>,
11
+ ): void {
12
+ describe("ICalendarFeedTokenRepository conformance", () => {
13
+ let repo: ICalendarFeedTokenRepository;
14
+
15
+ before(async () => {
16
+ repo = await harness.createRepository();
17
+ });
18
+
19
+ after(() => harness.teardown());
20
+
21
+ test("put derives the id from the calendar and starts unrotated", async () => {
22
+ const accountConfigId = harness.makeId();
23
+ const calendarId = harness.makeId();
24
+
25
+ const minted = await repo.put({
26
+ accountConfigId,
27
+ calendarId,
28
+ tokenHash: hashOf("a"),
29
+ });
30
+
31
+ assert.equal(minted.feedTokenId, deriveCalendarFeedTokenId(calendarId));
32
+ assert.equal(minted.calendarId, calendarId);
33
+ assert.equal(minted.tokenHash, hashOf("a"));
34
+ assert.equal(minted.rotatedAt, 0);
35
+ assert.ok(minted.createdAt > 0);
36
+ });
37
+
38
+ test("a second put rotates the one address rather than adding another", async () => {
39
+ const accountConfigId = harness.makeId();
40
+ const calendarId = harness.makeId();
41
+
42
+ const minted = await repo.put({
43
+ accountConfigId,
44
+ calendarId,
45
+ tokenHash: hashOf("b"),
46
+ });
47
+ const rotated = await repo.put({
48
+ accountConfigId,
49
+ calendarId,
50
+ tokenHash: hashOf("c"),
51
+ });
52
+
53
+ assert.equal(rotated.feedTokenId, minted.feedTokenId);
54
+ assert.equal(rotated.tokenHash, hashOf("c"));
55
+ assert.equal(
56
+ rotated.createdAt,
57
+ minted.createdAt,
58
+ "the feed is the same feed under a new secret",
59
+ );
60
+ assert.ok(
61
+ rotated.rotatedAt > 0,
62
+ "a replacement is distinguishable from a first mint",
63
+ );
64
+ assert.equal(
65
+ await repo.findByTokenHash(hashOf("b")),
66
+ null,
67
+ "the address that was rotated away no longer resolves",
68
+ );
69
+ assert.equal(
70
+ (await repo.findByTokenHash(hashOf("c")))?.calendarId,
71
+ calendarId,
72
+ );
73
+ });
74
+
75
+ test("findByCalendar answers only for the account config that owns it", async () => {
76
+ const accountConfigId = harness.makeId();
77
+ const stranger = harness.makeId();
78
+ const calendarId = harness.makeId();
79
+ await repo.put({
80
+ accountConfigId,
81
+ calendarId,
82
+ tokenHash: hashOf("d"),
83
+ });
84
+
85
+ assert.equal(
86
+ (await repo.findByCalendar(accountConfigId, calendarId))?.tokenHash,
87
+ hashOf("d"),
88
+ );
89
+ assert.equal(await repo.findByCalendar(stranger, calendarId), null);
90
+ assert.equal(
91
+ await repo.findByCalendar(accountConfigId, harness.makeId()),
92
+ null,
93
+ );
94
+ });
95
+
96
+ test("findByTokenHash is unscoped, and names the owner it found", async () => {
97
+ const accountConfigId = harness.makeId();
98
+ const calendarId = harness.makeId();
99
+ await repo.put({
100
+ accountConfigId,
101
+ calendarId,
102
+ tokenHash: hashOf("e"),
103
+ });
104
+
105
+ const found = await repo.findByTokenHash(hashOf("e"));
106
+
107
+ assert.equal(found?.accountConfigId, accountConfigId);
108
+ assert.equal(found?.calendarId, calendarId);
109
+ assert.equal(await repo.findByTokenHash(hashOf("nothing")), null);
110
+ });
111
+
112
+ test("delete stops the address resolving", async () => {
113
+ const accountConfigId = harness.makeId();
114
+ const calendarId = harness.makeId();
115
+ await repo.put({
116
+ accountConfigId,
117
+ calendarId,
118
+ tokenHash: hashOf("f"),
119
+ });
120
+
121
+ await repo.delete(accountConfigId, calendarId);
122
+
123
+ assert.equal(await repo.findByTokenHash(hashOf("f")), null);
124
+ assert.equal(
125
+ await repo.findByCalendar(accountConfigId, calendarId),
126
+ null,
127
+ );
128
+ });
129
+
130
+ test("delete leaves another account config's address alone", async () => {
131
+ const accountConfigId = harness.makeId();
132
+ const stranger = harness.makeId();
133
+ const calendarId = harness.makeId();
134
+ await repo.put({
135
+ accountConfigId,
136
+ calendarId,
137
+ tokenHash: hashOf("g"),
138
+ });
139
+
140
+ await repo.delete(stranger, calendarId);
141
+
142
+ assert.equal(
143
+ (await repo.findByTokenHash(hashOf("g")))?.calendarId,
144
+ calendarId,
145
+ );
146
+ });
147
+ });
148
+ }
@@ -1,5 +1,6 @@
1
1
  export { calendarCollectionRepositoryConformance } from "./calendar-collection.js";
2
2
  export { calendarEventIndexRepositoryConformance } from "./calendar-event-index.js";
3
+ export { calendarFeedTokenRepositoryConformance } from "./calendar-feed-token.js";
3
4
  export { calendarObjectRepositoryConformance } from "./calendar-object.js";
4
5
  export { calendarSuggestionRepositoryConformance } from "./calendar-suggestion.js";
5
6
  export type { RepositoryConformanceHarness } from "./harness.js";
package/src/id.ts CHANGED
@@ -47,6 +47,15 @@ export const deriveCalendarId = (
47
47
  REMIT_NAMESPACE,
48
48
  );
49
49
 
50
+ /**
51
+ * Identity of a calendar's feed address (issue #1067). Derived from the
52
+ * collection, so one calendar holds one active token by construction: rotating
53
+ * rewrites the row it already has instead of leaving a second live address
54
+ * nobody can see to revoke.
55
+ */
56
+ export const deriveCalendarFeedTokenId = (calendarId: string): string =>
57
+ base36uuidv5(`calendarfeedtoken:${calendarId}`, REMIT_NAMESPACE);
58
+
50
59
  /**
51
60
  * Identity of a calendar resource (issue #15). Derived from its collection and
52
61
  * its resource name — the last path segment of the resource's URL — so a
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export type { IAccountSettingRepository } from "./interfaces/account-setting.js"
6
6
  export type { IAddressRepository } from "./interfaces/address.js";
7
7
  export type { ICalendarCollectionRepository } from "./interfaces/calendar-collection.js";
8
8
  export type { ICalendarEventIndexRepository } from "./interfaces/calendar-event-index.js";
9
+ export type { ICalendarFeedTokenRepository } from "./interfaces/calendar-feed-token.js";
9
10
  export type { ICalendarObjectRepository } from "./interfaces/calendar-object.js";
10
11
  export type {
11
12
  ICalendarSuggestionRepository,
@@ -75,6 +76,7 @@ export type {
75
76
  BodyPartUpsertInput,
76
77
  CalendarCollectionItem,
77
78
  CalendarEventIndexItem,
79
+ CalendarFeedTokenItem,
78
80
  CalendarObjectItem,
79
81
  CalendarOccurrenceInput,
80
82
  CalendarSuggestionItem,
@@ -122,6 +124,7 @@ export type {
122
124
  OrganizeJobRequestItem,
123
125
  OutboxAttachmentItem,
124
126
  OutboxMessageItem,
127
+ PutCalendarFeedTokenInput,
125
128
  PutCalendarObjectInput,
126
129
  PutCalendarSuggestionInput,
127
130
  PutMessageFlagPushInput,
@@ -0,0 +1,38 @@
1
+ import type {
2
+ CalendarFeedTokenItem,
3
+ PutCalendarFeedTokenInput,
4
+ } from "../types.js";
5
+
6
+ export interface ICalendarFeedTokenRepository {
7
+ /**
8
+ * Mints the calendar's feed address, or replaces the token it already has.
9
+ *
10
+ * One statement, and an upsert on the derived `feedTokenId`: a calendar holds
11
+ * exactly one active address, so there is no state in which a revoked token
12
+ * still resolves alongside its replacement. `createdAt` survives a
13
+ * replacement — the feed is the same feed under a new secret — and
14
+ * `rotatedAt` is stamped only when a row was already there, so a caller can
15
+ * tell a fresh address from a rotated one without a prior read.
16
+ */
17
+ put(input: PutCalendarFeedTokenInput): Promise<CalendarFeedTokenItem>;
18
+ /**
19
+ * The address a calendar holds, or `null` when it has none. Scoped by account
20
+ * config: a management read is always somebody's, and an unscoped one would
21
+ * answer for a calendar the caller does not own.
22
+ */
23
+ findByCalendar(
24
+ accountConfigId: string,
25
+ calendarId: string,
26
+ ): Promise<CalendarFeedTokenItem | null>;
27
+ /**
28
+ * The row a token hash names, or `null` when nothing answers to it.
29
+ *
30
+ * Deliberately unscoped, and the only read that is: a subscriber arrives with
31
+ * no session at all, so the hash is the whole lookup key and the row it finds
32
+ * is what names the owner. The caller still compares the stored hash against
33
+ * the presented one before serving anything.
34
+ */
35
+ findByTokenHash(tokenHash: string): Promise<CalendarFeedTokenItem | null>;
36
+ /** Revokes the calendar's address. Every subscribed client stops resolving. */
37
+ delete(accountConfigId: string, calendarId: string): Promise<void>;
38
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ICalendarCollectionRepository } from "./calendar-collection.js";
2
2
  import type { ICalendarEventIndexRepository } from "./calendar-event-index.js";
3
+ import type { ICalendarFeedTokenRepository } from "./calendar-feed-token.js";
3
4
  import type { ICalendarObjectRepository } from "./calendar-object.js";
4
5
  import type { ICalendarSuggestionRepository } from "./calendar-suggestion.js";
5
6
 
@@ -18,6 +19,13 @@ export interface CalendarUnitOfWorkRepositories {
18
19
  * with no event behind it, is a state nothing later can repair.
19
20
  */
20
21
  calendarSuggestion: ICalendarSuggestionRepository;
22
+ /**
23
+ * Present so removing a calendar takes its feed address with it (issue
24
+ * #1067). A token row outliving its collection is a credential pointing at
25
+ * nothing — it answers 404, but nobody can see it to revoke it, and a later
26
+ * calendar under the same derived id would inherit it.
27
+ */
28
+ calendarFeedToken: ICalendarFeedTokenRepository;
21
29
  }
22
30
 
23
31
  /**
@@ -52,13 +52,12 @@ export interface IOutboxAttachmentRepository {
52
52
  * from these rows and the receiving server rejects it.
53
53
  *
54
54
  * A count-then-insert only satisfies that under serializable isolation or an
55
- * external lock. **The drizzle/SQLite implementation gets it from neither the
56
- * schema nor the isolation level:** `runInTransaction` issues a SAVEPOINT,
57
- * which begins a DEFERRED transaction, and what actually orders concurrent
58
- * reservations is the module-level write queue in `tx.ts` — correct on a
59
- * single-process deployment, which is what that backend is. Two writer
60
- * processes against one SQLite file would fail loud
61
- * (`SQLITE_BUSY_SNAPSHOT`), not silently over-admit.
55
+ * external lock. **The drizzle/SQLite implementation gets it from the lock,
56
+ * not from the schema or the isolation level:** `runInTransaction` opens a
57
+ * unit with `BEGIN IMMEDIATE`, so it holds the file's write lock from before
58
+ * its first read, and the module-level write queue in `tx.ts` orders the
59
+ * units inside one process. Two writer processes against one SQLite file are
60
+ * ordered by that lock rather than over-admitting.
62
61
  *
63
62
  * **An adapter without serializable isolation must not do a
64
63
  * read-then-write.** On DynamoDB in particular, a Query followed by a
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  BodyPartStorageSchema,
9
9
  CalendarCollectionSchema,
10
10
  CalendarEventIndexSchema,
11
+ CalendarFeedTokenSchema,
11
12
  CalendarObjectSchema,
12
13
  CalendarSuggestionSchema,
13
14
  ConfigImportSchema,
@@ -584,6 +585,16 @@ export type UpdateCalendarCollectionInput = Partial<
584
585
  Pick<CalendarCollectionItem, "displayName" | "color" | "timezone">
585
586
  >;
586
587
 
588
+ export type CalendarFeedTokenItem = z.infer<typeof CalendarFeedTokenSchema>;
589
+
590
+ // `feedTokenId` is derived from `calendarId`, which is what makes one active
591
+ // token per calendar structural rather than enforced. `rotatedAt` is the
592
+ // repository's to stamp: only it can tell a mint from a replacement.
593
+ export type PutCalendarFeedTokenInput = Omit<
594
+ CalendarFeedTokenItem,
595
+ "feedTokenId" | "rotatedAt" | "createdAt" | "updatedAt"
596
+ >;
597
+
587
598
  /**
588
599
  * A whole calendar resource. Every projected column is supplied together with
589
600
  * the `icalData` it was projected from — the repository never derives one from