@remit/data-ports 0.0.54 → 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.54",
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.test.ts CHANGED
@@ -199,8 +199,34 @@ describe("deriveCalendarSuggestionId", () => {
199
199
  });
200
200
  });
201
201
 
202
+ describe("deriveThreadId", () => {
203
+ const ROOT = "<root@example.com>";
204
+
205
+ // #1017: the account is not part of the name, so every mailbox connected to
206
+ // one config resolves the same root header to one conversation. Keyed on the
207
+ // account, a reply sent from the second account opened a thread of its own.
208
+ it("gives one root header one thread across the accounts of a config", () => {
209
+ assert.equal(deriveThreadId("cfg-1", ROOT), deriveThreadId("cfg-1", ROOT));
210
+ });
211
+
212
+ it("separates two account configs holding the same conversation", () => {
213
+ assert.notEqual(
214
+ deriveThreadId("cfg-1", ROOT),
215
+ deriveThreadId("cfg-2", ROOT),
216
+ );
217
+ });
218
+
219
+ it("separates two conversations under one config", () => {
220
+ assert.notEqual(
221
+ deriveThreadId("cfg-1", ROOT),
222
+ deriveThreadId("cfg-1", "<other@example.com>"),
223
+ );
224
+ });
225
+ });
226
+
202
227
  describe("derived ids are stored primary keys: a changed value orphans every row already written and needs a migration, never a new expectation here", () => {
203
228
  const ACCOUNT = "account-golden";
229
+ const ACCOUNT_CONFIG = "account-config-golden";
204
230
  const MESSAGE = "message-golden";
205
231
  const MAILBOX = "mailbox-golden";
206
232
  const ABSENT_HEADER_SOURCE = {
@@ -259,10 +285,10 @@ describe("derived ids are stored primary keys: a changed value orphans every row
259
285
  );
260
286
  });
261
287
 
262
- it("deriveThreadId pins the thread primary key, lowercased root header included", () => {
288
+ it("deriveThreadId pins the thread primary key, keyed on the account config, lowercased root header included", () => {
263
289
  assert.equal(
264
- deriveThreadId(ACCOUNT, "<Golden.Root@Example.COM>"),
265
- "bcy76e2uat5yq3xd1p7j7z4pr",
290
+ deriveThreadId(ACCOUNT_CONFIG, "<Golden.Root@Example.COM>"),
291
+ "3tl03sgy85b60rfb15u6vtiex",
266
292
  );
267
293
  });
268
294
 
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
@@ -150,12 +159,22 @@ export const deriveCopyMessageId = (
150
159
  REMIT_NAMESPACE,
151
160
  );
152
161
 
162
+ /**
163
+ * Identity of a conversation (issue #1017). Keyed on the account config, not on
164
+ * the account: two mailboxes connected to one config hold the same conversation,
165
+ * and keying on the account gave each of them its own thread rooted at the same
166
+ * header — so a reply sent from the second account opened a conversation of one
167
+ * beside the one it answered.
168
+ *
169
+ * Messages stay per account (`deriveMessageId`). Each account keeps its own copy
170
+ * of a mail, and the copies meet in one thread through the shared root header.
171
+ */
153
172
  export const deriveThreadId = (
154
- accountId: string,
173
+ accountConfigId: string,
155
174
  rootMessageIdHeader: string,
156
175
  ): string =>
157
176
  base36uuidv5(
158
- `thread:${accountId}:${rootMessageIdHeader.toLowerCase()}`,
177
+ `thread:${accountConfigId}:${rootMessageIdHeader.toLowerCase()}`,
159
178
  REMIT_NAMESPACE,
160
179
  );
161
180
 
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