@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 +1 -1
- package/src/conformance/calendar-collection.ts +20 -0
- package/src/conformance/calendar-feed-token.ts +148 -0
- package/src/conformance/index.ts +1 -0
- package/src/id.ts +9 -0
- package/src/index.ts +3 -0
- package/src/interfaces/calendar-feed-token.ts +38 -0
- package/src/interfaces/calendar-unit-of-work.ts +8 -0
- package/src/interfaces/outbox-attachment.ts +6 -7
- package/src/types.ts +11 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/conformance/index.ts
CHANGED
|
@@ -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
|
|
56
|
-
* schema
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|