@remit/data-ports 0.0.55 → 0.0.57

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.57",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -37,6 +37,10 @@
37
37
  "types": "./src/mutation-events.ts",
38
38
  "default": "./src/mutation-events.ts"
39
39
  },
40
+ "./virtual-copy": {
41
+ "types": "./src/virtual-copy.ts",
42
+ "default": "./src/virtual-copy.ts"
43
+ },
40
44
  "./mailbox-name": {
41
45
  "types": "./src/mailbox-name.ts",
42
46
  "default": "./src/mailbox-name.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,
@@ -95,6 +95,18 @@ export interface IAddressRepository {
95
95
  cursor?: string;
96
96
  limit?: number;
97
97
  }): Promise<ResultList<AddressItem>>;
98
+ /**
99
+ * Every address stored under the configuration, for a reader that must see
100
+ * the whole set: nothing is hidden, so a row the machine marked `junkOnly`
101
+ * still comes back with whatever the user decided about it. Ordered by
102
+ * `addressId`, which no write moves, so a page boundary cannot skip a row
103
+ * while message sync updates counts and timestamps underneath the read.
104
+ */
105
+ listAllByAccountConfigPage(input: {
106
+ accountConfigId: string;
107
+ cursor?: string;
108
+ limit?: number;
109
+ }): Promise<ResultList<AddressItem>>;
98
110
  createEnvelopeAddress(
99
111
  input: CreateEnvelopeAddressInput,
100
112
  ): Promise<EnvelopeAddressItem>;
@@ -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
@@ -92,5 +92,15 @@ export interface IOutboxMessageRepository {
92
92
  accountId: string,
93
93
  options?: { limit?: number; continuationToken?: string },
94
94
  ): Promise<ResultList<OutboxMessageItem>>;
95
+ /**
96
+ * One keyset scan over several accounts, ordered globally by createdAt then
97
+ * outboxMessageId. The continuation token names a position in that single
98
+ * ordering rather than one position per account, so a second page stays
99
+ * coherent however the accounts' rows interleave.
100
+ */
101
+ listByAccounts(
102
+ accountIds: string[],
103
+ options?: { limit?: number; continuationToken?: string },
104
+ ): Promise<ResultList<OutboxMessageItem>>;
95
105
  listQueued(accountId: string): Promise<OutboxMessageItem[]>;
96
106
  }
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
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { MailboxItem } from "./types.js";
4
+ import { isVirtualCopyMailbox } from "./virtual-copy.js";
5
+
6
+ const mailbox = (fullPath: string, specialUse?: string[]): MailboxItem =>
7
+ ({
8
+ mailboxId: `mbx-${fullPath}`,
9
+ fullPath,
10
+ ...(specialUse ? { specialUse } : {}),
11
+ }) as MailboxItem;
12
+
13
+ describe("which folders hold a second copy of mail that lives elsewhere", () => {
14
+ it("reads the special-use the server advertises", () => {
15
+ assert.equal(isVirtualCopyMailbox(mailbox("Everything", ["All"])), true);
16
+ assert.equal(isVirtualCopyMailbox(mailbox("Pinned", ["Flagged"])), true);
17
+ assert.equal(isVirtualCopyMailbox(mailbox("Urgent", ["Important"])), true);
18
+ });
19
+
20
+ it("falls back to the well-known path when the server advertises nothing", () => {
21
+ assert.equal(isVirtualCopyMailbox(mailbox("[Gmail]/All Mail")), true);
22
+ assert.equal(isVirtualCopyMailbox(mailbox("[gmail]/starred")), true);
23
+ });
24
+
25
+ it("matches the whole path, so a folder of the user's own is real mail", () => {
26
+ assert.equal(isVirtualCopyMailbox(mailbox("Starred ideas")), false);
27
+ assert.equal(isVirtualCopyMailbox(mailbox("[Gmail]/All Mail/2024")), false);
28
+ });
29
+
30
+ it("reads an ordinary folder as the place mail lives", () => {
31
+ assert.equal(isVirtualCopyMailbox(mailbox("INBOX")), false);
32
+ assert.equal(isVirtualCopyMailbox(mailbox("Rubbish", ["Junk"])), false);
33
+ });
34
+ });
@@ -0,0 +1,33 @@
1
+ import { MailboxSpecialUse } from "@remit/domain-enums";
2
+ import type { MailboxItem } from "./types.js";
3
+
4
+ /**
5
+ * Special-use folders that hold a second copy of mail already reachable through
6
+ * the folder it actually lives in — Gmail's All Mail, Starred and Important.
7
+ */
8
+ const VIRTUAL_COPY_SPECIAL_USE: readonly string[] = [
9
+ MailboxSpecialUse.All,
10
+ MailboxSpecialUse.Flagged,
11
+ MailboxSpecialUse.Important,
12
+ ];
13
+
14
+ /**
15
+ * Well-known Gmail virtual paths, for servers that do not advertise the
16
+ * special-use attribute. Whole path, never a prefix — a user's own
17
+ * `Starred ideas` folder is real mail.
18
+ */
19
+ const VIRTUAL_COPY_FULL_PATHS: readonly string[] = [
20
+ "[gmail]/all mail",
21
+ "[gmail]/starred",
22
+ "[gmail]/important",
23
+ ];
24
+
25
+ /**
26
+ * Whether this folder holds mail that also lives in a real folder. Such a
27
+ * folder is a view, not a filing location: seeing a message in it says nothing
28
+ * about where the message was filed, so it neither picks which duplicate a
29
+ * listing drops nor decides where a message lives.
30
+ */
31
+ export const isVirtualCopyMailbox = (mailbox: MailboxItem): boolean =>
32
+ mailbox.specialUse?.some((use) => VIRTUAL_COPY_SPECIAL_USE.includes(use)) ===
33
+ true || VIRTUAL_COPY_FULL_PATHS.includes(mailbox.fullPath.toLowerCase());