@company-semantics/contracts 42.1.0 → 44.0.0
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/__tests__/resource-keys.test.ts +44 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +6 -6
- package/src/index.ts +57 -0
- package/src/resource-keys.ts +60 -0
- package/src/user-events/README.md +66 -0
- package/src/user-events/__tests__/README.md +43 -0
- package/src/user-events/__tests__/user-events.test.ts +196 -0
- package/src/user-events/index.ts +27 -0
- package/src/user-events/schemas.ts +214 -0
- package/src/user-notifications/README.md +72 -0
- package/src/user-notifications/__tests__/README.md +43 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +170 -0
- package/src/user-notifications/index.ts +25 -0
- package/src/user-notifications/kinds.ts +69 -0
- package/src/user-notifications/schemas.ts +133 -0
package/package.json
CHANGED
|
@@ -97,3 +97,47 @@ describe("resource-keys: system-scoped internalAdmin* types", () => {
|
|
|
97
97
|
).toBe(false);
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("resource-keys: companyMdAccessRequests (per-doc identity)", () => {
|
|
102
|
+
const DOC_ID = "22222222-2222-4222-8222-222222222222";
|
|
103
|
+
const key: ResourceKey = {
|
|
104
|
+
type: "companyMdAccessRequests",
|
|
105
|
+
orgId: ORG_ID,
|
|
106
|
+
docId: DOC_ID,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
it("serialises to [type, orgId, docId]", () => {
|
|
110
|
+
expect(toQueryKey(key)).toEqual([
|
|
111
|
+
"companyMdAccessRequests",
|
|
112
|
+
ORG_ID,
|
|
113
|
+
DOC_ID,
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("round-trips through fromQueryKey", () => {
|
|
118
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("discriminates two keys differing only in docId", () => {
|
|
122
|
+
const other: ResourceKey = {
|
|
123
|
+
type: "companyMdAccessRequests",
|
|
124
|
+
orgId: ORG_ID,
|
|
125
|
+
docId: "33333333-3333-4333-8333-333333333333",
|
|
126
|
+
};
|
|
127
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
128
|
+
expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does NOT collide with companyMdDoc, whose id field is named `slug`", () => {
|
|
132
|
+
// companyMdDoc carries the stable doc id under a field named `slug`
|
|
133
|
+
// (ADR-BE-315 made slugs only parent-scoped unique). The two keys must stay
|
|
134
|
+
// distinguishable even when they carry the same document id.
|
|
135
|
+
const docKey: ResourceKey = {
|
|
136
|
+
type: "companyMdDoc",
|
|
137
|
+
orgId: ORG_ID,
|
|
138
|
+
slug: DOC_ID,
|
|
139
|
+
};
|
|
140
|
+
expect(matchesResourceKey(toQueryKey(docKey), key)).toBe(false);
|
|
141
|
+
expect(matchesResourceKey(toQueryKey(key), docKey)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '017f7c7f6a72' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '017f7c7f6a72c27413c0329be3eb9ff39547d425f575d570970d6e6920324638' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -3529,10 +3529,10 @@ export interface components {
|
|
|
3529
3529
|
share: {
|
|
3530
3530
|
id: string;
|
|
3531
3531
|
chatId: string;
|
|
3532
|
-
token
|
|
3532
|
+
token?: string;
|
|
3533
3533
|
/** @enum {string} */
|
|
3534
3534
|
visibility: "private" | "public";
|
|
3535
|
-
shareUrl
|
|
3535
|
+
shareUrl?: string;
|
|
3536
3536
|
messageCountAtShare: number;
|
|
3537
3537
|
titleAtShare: string;
|
|
3538
3538
|
/** Format: date-time */
|
|
@@ -3552,10 +3552,10 @@ export interface components {
|
|
|
3552
3552
|
shares: {
|
|
3553
3553
|
id: string;
|
|
3554
3554
|
chatId: string;
|
|
3555
|
-
token
|
|
3555
|
+
token?: string;
|
|
3556
3556
|
/** @enum {string} */
|
|
3557
3557
|
visibility: "private" | "public";
|
|
3558
|
-
shareUrl
|
|
3558
|
+
shareUrl?: string;
|
|
3559
3559
|
messageCountAtShare: number;
|
|
3560
3560
|
titleAtShare: string;
|
|
3561
3561
|
/** Format: date-time */
|
|
@@ -3568,10 +3568,10 @@ export interface components {
|
|
|
3568
3568
|
share: {
|
|
3569
3569
|
id: string;
|
|
3570
3570
|
chatId: string;
|
|
3571
|
-
token
|
|
3571
|
+
token?: string;
|
|
3572
3572
|
/** @enum {string} */
|
|
3573
3573
|
visibility: "private" | "public";
|
|
3574
|
-
shareUrl
|
|
3574
|
+
shareUrl?: string;
|
|
3575
3575
|
messageCountAtShare: number;
|
|
3576
3576
|
titleAtShare: string;
|
|
3577
3577
|
/** Format: date-time */
|
package/src/index.ts
CHANGED
|
@@ -266,6 +266,62 @@ export type {
|
|
|
266
266
|
ActionItemTargetType,
|
|
267
267
|
} from "./action-items/index";
|
|
268
268
|
|
|
269
|
+
// User-scoped push envelope — the frames any domain may put on ONE
|
|
270
|
+
// authenticated user's connection without owning a route or an SSE union of its
|
|
271
|
+
// own. A THIRD vocabulary alongside the two above, and deliberately unlike
|
|
272
|
+
// both: a notification LEAVES the building, an action item is STANDING state,
|
|
273
|
+
// and a user event is a single unreplayable instant meaning "your cache is
|
|
274
|
+
// stale". This stream is a wake-up channel, NOT an ordered event log — no frame
|
|
275
|
+
// carries an SSE `id:` and nothing durable may be hung off it.
|
|
276
|
+
// See src/user-events/README.md.
|
|
277
|
+
export {
|
|
278
|
+
MAX_INVALIDATION_KEYS,
|
|
279
|
+
USER_EVENT_RESYNC_REASONS,
|
|
280
|
+
} from "./user-events/index";
|
|
281
|
+
|
|
282
|
+
export {
|
|
283
|
+
ResourceInvalidatedEventSchema,
|
|
284
|
+
UserEventBaseSchema,
|
|
285
|
+
UserEventConnectedSchema,
|
|
286
|
+
UserEventResyncReasonSchema,
|
|
287
|
+
UserEventResyncSchema,
|
|
288
|
+
UserSseEventSchema,
|
|
289
|
+
} from "./user-events/index";
|
|
290
|
+
|
|
291
|
+
export type {
|
|
292
|
+
NotificationCreatedEvent,
|
|
293
|
+
ResourceInvalidatedEvent,
|
|
294
|
+
UserEventConnected,
|
|
295
|
+
UserEventResync,
|
|
296
|
+
UserEventResyncReason,
|
|
297
|
+
UserSseEvent,
|
|
298
|
+
} from "./user-events/index";
|
|
299
|
+
|
|
300
|
+
// Durable in-app inbox + the merged feed that renders it (ADR-CONT-108). The
|
|
301
|
+
// THIRD vocabulary beside NotificationKind and ActionItemKind: a notification
|
|
302
|
+
// leaves the building, an action item is standing state you can resolve, and
|
|
303
|
+
// one of these is a stored fact you can only look at. Rows hold identifiers
|
|
304
|
+
// only — every prose field is rendered at read time under the reader's CURRENT
|
|
305
|
+
// authority, which is what keeps a stale row from leaking anything.
|
|
306
|
+
// See src/user-notifications/README.md.
|
|
307
|
+
export { USER_NOTIFICATION_KINDS } from "./user-notifications/index";
|
|
308
|
+
|
|
309
|
+
export {
|
|
310
|
+
FeedItemSchema,
|
|
311
|
+
FeedListResponseSchema,
|
|
312
|
+
FeedMarkReadRequestSchema,
|
|
313
|
+
UserNotificationKindSchema,
|
|
314
|
+
UserNotificationSchema,
|
|
315
|
+
} from "./user-notifications/index";
|
|
316
|
+
|
|
317
|
+
export type {
|
|
318
|
+
FeedItem,
|
|
319
|
+
FeedListResponse,
|
|
320
|
+
FeedMarkReadRequest,
|
|
321
|
+
UserNotification,
|
|
322
|
+
UserNotificationKind,
|
|
323
|
+
} from "./user-notifications/index";
|
|
324
|
+
|
|
269
325
|
// Chat domain types
|
|
270
326
|
// @see PRD-00142 for share chat design rationale
|
|
271
327
|
export type {
|
|
@@ -859,6 +915,7 @@ export {
|
|
|
859
915
|
toQueryKey,
|
|
860
916
|
fromQueryKey,
|
|
861
917
|
matchesResourceKey,
|
|
918
|
+
isResourceKeyShape,
|
|
862
919
|
} from "./resource-keys";
|
|
863
920
|
|
|
864
921
|
// Resource response wrapper (typed versioning for cache invalidation)
|
package/src/resource-keys.ts
CHANGED
|
@@ -20,6 +20,15 @@ export type ResourceKey =
|
|
|
20
20
|
| { type: "chat"; orgId: string; chatId: string }
|
|
21
21
|
| { type: "companyMdDoc"; orgId: string; slug: string }
|
|
22
22
|
| { type: "companyMdContextBank"; orgId: string; slug: string }
|
|
23
|
+
// The access requests filed against ONE document — the owner's inbox, read by
|
|
24
|
+
// the Share dialog.
|
|
25
|
+
//
|
|
26
|
+
// Discriminated by `docId`, NOT `slug`, and the difference is load-bearing.
|
|
27
|
+
// `companyMdDoc` above carries the stable doc id under a field named `slug`
|
|
28
|
+
// for historical reasons; ADR-BE-315 made slugs only PARENT-SCOPED unique, so
|
|
29
|
+
// a bare slug does not identify a document. That field name is a wart to be
|
|
30
|
+
// contained, not propagated — a new key gets the honest name.
|
|
31
|
+
| { type: "companyMdAccessRequests"; orgId: string; docId: string }
|
|
23
32
|
| { type: "workspace"; orgId: string }
|
|
24
33
|
| { type: "workspaceDomains"; orgId: string }
|
|
25
34
|
| { type: "authSettings"; orgId: string }
|
|
@@ -62,6 +71,15 @@ export type ResourceKey =
|
|
|
62
71
|
// `timeline`, which are likewise viewer-filtered but org-partitioned — and it
|
|
63
72
|
// inherits `resolveScope`'s impersonation handling for free.
|
|
64
73
|
| { type: "actionItems"; orgId: string }
|
|
74
|
+
// The merged top-of-/me/work read: action items AND the durable inbox
|
|
75
|
+
// (ADR-CONT-108). Org-scoped for the same reason `actionItems` is — the
|
|
76
|
+
// content is per-viewer but the bucket differs per org, so a user-keyed entry
|
|
77
|
+
// would serve one org's feed after switching to another.
|
|
78
|
+
//
|
|
79
|
+
// Distinct from `actionItems` rather than replacing it: that key still backs
|
|
80
|
+
// every badge in the shell, and the two have different invalidation triggers
|
|
81
|
+
// (a notification being READ changes the feed and not the bucket).
|
|
82
|
+
| { type: "feed"; orgId: string }
|
|
65
83
|
// User-scoped
|
|
66
84
|
| { type: "dismissedBanners"; userId: string }
|
|
67
85
|
| { type: "userOrgs"; userId: string }
|
|
@@ -106,6 +124,7 @@ const ORG_SCOPED_TYPES = [
|
|
|
106
124
|
"peopleOrgChart",
|
|
107
125
|
"orgUnitOwners",
|
|
108
126
|
"actionItems",
|
|
127
|
+
"feed",
|
|
109
128
|
] as const;
|
|
110
129
|
|
|
111
130
|
const USER_SCOPED_TYPES = [
|
|
@@ -151,6 +170,8 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
151
170
|
case "companyMdDoc":
|
|
152
171
|
case "companyMdContextBank":
|
|
153
172
|
return [key.type, key.orgId, key.slug] as const;
|
|
173
|
+
case "companyMdAccessRequests":
|
|
174
|
+
return [key.type, key.orgId, key.docId] as const;
|
|
154
175
|
|
|
155
176
|
// OrgUnit identity keys (ADR-BE-120)
|
|
156
177
|
case "orgUnit":
|
|
@@ -190,6 +211,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
190
211
|
case "peopleOrgChart":
|
|
191
212
|
case "orgUnitOwners":
|
|
192
213
|
case "actionItems":
|
|
214
|
+
case "feed":
|
|
193
215
|
return [key.type, key.orgId] as const;
|
|
194
216
|
|
|
195
217
|
// System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources
|
|
@@ -228,6 +250,7 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
228
250
|
chat: "chatId",
|
|
229
251
|
companyMdDoc: "slug",
|
|
230
252
|
companyMdContextBank: "slug",
|
|
253
|
+
companyMdAccessRequests: "docId",
|
|
231
254
|
orgUnit: "unitId",
|
|
232
255
|
orgUnitChildren: "unitId",
|
|
233
256
|
orgUnitAncestors: "unitId",
|
|
@@ -327,6 +350,12 @@ export function matchesResourceKey(
|
|
|
327
350
|
return false;
|
|
328
351
|
if ("slug" in parsed && "slug" in targetKey && parsed.slug !== targetKey.slug)
|
|
329
352
|
return false;
|
|
353
|
+
if (
|
|
354
|
+
"docId" in parsed &&
|
|
355
|
+
"docId" in targetKey &&
|
|
356
|
+
parsed.docId !== targetKey.docId
|
|
357
|
+
)
|
|
358
|
+
return false;
|
|
330
359
|
if (
|
|
331
360
|
"unitId" in parsed &&
|
|
332
361
|
"unitId" in targetKey &&
|
|
@@ -343,6 +372,37 @@ export function matchesResourceKey(
|
|
|
343
372
|
return true;
|
|
344
373
|
}
|
|
345
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Is this the SHAPE of a ResourceKey?
|
|
377
|
+
*
|
|
378
|
+
* A wire-boundary predicate, deliberately NOT a vocabulary check: it asserts a
|
|
379
|
+
* non-empty `type` and exactly one scope discriminator, and says nothing about
|
|
380
|
+
* whether `type` is a member of the union.
|
|
381
|
+
*
|
|
382
|
+
* The asymmetry is the point. `ResourceKey` is a large union whose authority is
|
|
383
|
+
* `toQueryKey`'s exhaustive switch; a Zod mirror of it would be a second source
|
|
384
|
+
* of truth that drifts. Worse, a vocabulary-strict gate would REJECT AN ENTIRE
|
|
385
|
+
* FRAME when a newer backend names a key this client's contracts version has
|
|
386
|
+
* not learned yet — today an unknown key simply matches no query and the rest
|
|
387
|
+
* of the frame still lands, which is the behaviour worth keeping.
|
|
388
|
+
* `matchesResourceKey` is already lenient about unknown types at runtime, so a
|
|
389
|
+
* stricter gate here would be the only place in the pipeline that hard-fails on
|
|
390
|
+
* a key it merely does not recognise.
|
|
391
|
+
*
|
|
392
|
+
* Used by the user-event wire schema (`../user-events`) to validate
|
|
393
|
+
* `resource.invalidated` payloads without importing the union's vocabulary.
|
|
394
|
+
*/
|
|
395
|
+
export function isResourceKeyShape(value: unknown): value is ResourceKey {
|
|
396
|
+
if (typeof value !== "object" || value === null) return false;
|
|
397
|
+
const candidate = value as Record<string, unknown>;
|
|
398
|
+
if (typeof candidate.type !== "string" || candidate.type.length === 0)
|
|
399
|
+
return false;
|
|
400
|
+
const scopeCount = ["orgId", "userId", "scope"].filter(
|
|
401
|
+
(field) => typeof candidate[field] === "string",
|
|
402
|
+
).length;
|
|
403
|
+
return scopeCount === 1;
|
|
404
|
+
}
|
|
405
|
+
|
|
346
406
|
function isReadonlyArray(value: unknown): value is readonly unknown[] {
|
|
347
407
|
return Array.isArray(value);
|
|
348
408
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# user-events/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The published wire contract for the **user-scoped push stream** — the frames any
|
|
6
|
+
domain may send to one authenticated user without owning a route or an SSE union
|
|
7
|
+
of its own.
|
|
8
|
+
|
|
9
|
+
## Invariants
|
|
10
|
+
|
|
11
|
+
- **This stream is a wake-up channel plus ephemeral cache hints. It is NOT an
|
|
12
|
+
ordered event log.** Publishing a user event grants no replay guarantee, and
|
|
13
|
+
no durable effect may be hung off it.
|
|
14
|
+
- **No frame carries an SSE `id:` line.** An id implies a resumable position;
|
|
15
|
+
there is no log behind this stream, so an id would be a lie. Contrast
|
|
16
|
+
`ExecutionSseEvent`, whose `eventSequence` legitimately doubles as
|
|
17
|
+
`Last-Event-ID` because it projects a durable row (ADR-CONT-066).
|
|
18
|
+
- **Delivery is edge-triggered only.** A missed frame may leave caches stale
|
|
19
|
+
until the next authoritative refresh. Acceptable because no correctness
|
|
20
|
+
depends on invalidations — every frame means "re-read this", and the read is
|
|
21
|
+
the authority.
|
|
22
|
+
- **`resource.invalidated.keys` validates shape, not vocabulary.** A
|
|
23
|
+
vocabulary-strict gate would reject an entire frame when a newer server names
|
|
24
|
+
a key this client has not learned. Today an unknown key matches no query and
|
|
25
|
+
the rest of the frame still lands.
|
|
26
|
+
- **`version` is an entity timestamp, never request time.** A request-time value
|
|
27
|
+
silently re-opens the race the client's version gate exists to close.
|
|
28
|
+
- **`v`/`timestamp` are optional on `resource.invalidated`, `resync` and
|
|
29
|
+
`connected`, and required nowhere else.** The invalidation frame was already
|
|
30
|
+
on the wire without them; requiring them would make promotion a breaking
|
|
31
|
+
change dressed as a tidy-up.
|
|
32
|
+
|
|
33
|
+
## Public API
|
|
34
|
+
|
|
35
|
+
| Export | Description |
|
|
36
|
+
| ------------------------------------- | ------------------------------------------------- |
|
|
37
|
+
| `UserSseEventSchema` / `UserSseEvent` | The union. OpenAPI component `UserSseEvent`. |
|
|
38
|
+
| `ResourceInvalidatedEventSchema` | Cache-staleness signal carrying `ResourceKey[]` |
|
|
39
|
+
| `UserEventResyncSchema` | "Could not name what changed; re-read everything" |
|
|
40
|
+
| `UserEventConnectedSchema` | Transport-level open frame |
|
|
41
|
+
| `MAX_INVALIDATION_KEYS` | Per-frame key cap (`16`) |
|
|
42
|
+
| `USER_EVENT_RESYNC_REASONS` | `listen-recovered` \| `payload-over-cap` |
|
|
43
|
+
|
|
44
|
+
## Dependencies
|
|
45
|
+
|
|
46
|
+
- `zod` — schemas are canonical, types are inferred.
|
|
47
|
+
- `../resource-keys` — `ResourceKey`, `isResourceKeyShape`.
|
|
48
|
+
|
|
49
|
+
Nothing else. In particular: no `notifications/`, no `action-items/`.
|
|
50
|
+
|
|
51
|
+
## How this differs from the two adjacent vocabularies
|
|
52
|
+
|
|
53
|
+
Three things in this package can look like "a notification". They are distinct,
|
|
54
|
+
and ADR-CONT-104 forbids collapsing them:
|
|
55
|
+
|
|
56
|
+
- **`NotificationKind`** (`../notifications`) names a message that **leaves the
|
|
57
|
+
building** to a durable address, composed by a render pipeline into prose
|
|
58
|
+
carrying a brand and a year.
|
|
59
|
+
- **`ActionItemKind`** (`../action-items`) names **standing state** — a decision
|
|
60
|
+
you owe — re-derived from source rows on every read, which stops existing when
|
|
61
|
+
someone resolves it.
|
|
62
|
+
- **A user event** is neither. It is a single instant on a socket, unreplayable
|
|
63
|
+
and unaddressable, whose entire meaning is "your cached view is stale".
|
|
64
|
+
|
|
65
|
+
A domain that needs durability emits the durable thing **and** a user event —
|
|
66
|
+
never a user event alone.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# user-events/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONT-107 make that the compiler cannot.
|
|
6
|
+
|
|
7
|
+
- `user-events.test.ts` — four things a type signature does not say:
|
|
8
|
+
1. **Promotion did not change the wire.** The load-bearing test of the whole
|
|
9
|
+
module. `resource.invalidated` was already deployed, emitted by the backend
|
|
10
|
+
and re-declared structurally in the app, carrying neither `v` nor
|
|
11
|
+
`timestamp`. The schema is pinned against that exact object. If it fails,
|
|
12
|
+
publishing the shape broke every client already running.
|
|
13
|
+
2. **An unknown key type is ACCEPTED.** The reason `keys` validates shape and
|
|
14
|
+
not vocabulary. A newer server naming a key this contracts version has not
|
|
15
|
+
learned must cost that one key, never the whole frame. A strict gate here
|
|
16
|
+
would be the only place in the pipeline that hard-fails on version skew.
|
|
17
|
+
3. **An unmodeled frame `type` is REJECTED.** So the client logs it instead of
|
|
18
|
+
silently dropping it. The two rules pull in opposite directions on purpose:
|
|
19
|
+
lenient about payload vocabulary, strict about frame identity.
|
|
20
|
+
4. **`resource.invalidated` rejects an `ActionItem`-shaped payload.** What
|
|
21
|
+
stops the future "just reuse ActionItem on the stream" change. An action
|
|
22
|
+
item is re-derived on every read and resolves when someone acts; it cannot
|
|
23
|
+
be delivered once and forgotten.
|
|
24
|
+
|
|
25
|
+
## Invariants
|
|
26
|
+
|
|
27
|
+
- These tests assert the WIRE, never behaviour — there is no behaviour here.
|
|
28
|
+
Anything needing a socket, a dispatcher or a database belongs in backend's
|
|
29
|
+
`src/user-events/__tests__/`, not here.
|
|
30
|
+
- The disjointness test reads `ACTION_ITEM_KINDS` rather than restating the
|
|
31
|
+
kinds. A hand-copied list would drift and start passing vacuously.
|
|
32
|
+
- Negative cases assert `.success === false` on a frame that is otherwise
|
|
33
|
+
well-formed, mutating one field. A negative test that hand-builds a broken
|
|
34
|
+
object can pass for the wrong reason.
|
|
35
|
+
|
|
36
|
+
## Public API
|
|
37
|
+
|
|
38
|
+
None — test-only.
|
|
39
|
+
|
|
40
|
+
## Dependencies
|
|
41
|
+
|
|
42
|
+
`vitest`, the sibling module under test, `../../action-items` (for the real kind
|
|
43
|
+
list), and `../../resource-keys` (for `isResourceKeyShape`).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-event wire contract's invariants, as tests rather than prose.
|
|
3
|
+
*
|
|
4
|
+
* These lock the claims the README makes that a compiler cannot: that promoting
|
|
5
|
+
* `resource.invalidated` did not change the wire, that key validation is
|
|
6
|
+
* forward-compatible across version skew, and that this vocabulary is separate
|
|
7
|
+
* from the two adjacent ones (ADR-CONT-104).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
MAX_INVALIDATION_KEYS,
|
|
12
|
+
ResourceInvalidatedEventSchema,
|
|
13
|
+
USER_EVENT_RESYNC_REASONS,
|
|
14
|
+
UserEventConnectedSchema,
|
|
15
|
+
UserEventResyncSchema,
|
|
16
|
+
UserSseEventSchema,
|
|
17
|
+
} from "../schemas.js";
|
|
18
|
+
import { ACTION_ITEM_KINDS } from "../../action-items/index.js";
|
|
19
|
+
import { isResourceKeyShape } from "../../resource-keys.js";
|
|
20
|
+
|
|
21
|
+
const ORG_ID = "11111111-1111-4111-8111-111111111111";
|
|
22
|
+
const DOC_ID = "22222222-2222-4222-8222-222222222222";
|
|
23
|
+
|
|
24
|
+
describe("resource.invalidated — promotion must not change the wire", () => {
|
|
25
|
+
it("parses the EXACT object the backend emits today", () => {
|
|
26
|
+
// Verbatim shape of `ResourceInvalidationEvent` in backend
|
|
27
|
+
// src/chat/execution/resource-invalidation.ts: no `v`, no `timestamp`.
|
|
28
|
+
// If this fails, promoting the interface into contracts broke the wire and
|
|
29
|
+
// every already-deployed client stops receiving invalidations.
|
|
30
|
+
const onTheWireToday = {
|
|
31
|
+
type: "resource.invalidated",
|
|
32
|
+
keys: [{ type: "actionItems", orgId: ORG_ID }],
|
|
33
|
+
traceId: "trace-1",
|
|
34
|
+
version: 1785312000000,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
expect(
|
|
38
|
+
ResourceInvalidatedEventSchema.safeParse(onTheWireToday).success,
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts a frame with neither traceId nor the base fields", () => {
|
|
43
|
+
expect(
|
|
44
|
+
ResourceInvalidatedEventSchema.safeParse({
|
|
45
|
+
type: "resource.invalidated",
|
|
46
|
+
keys: [
|
|
47
|
+
{ type: "companyMdAccessRequests", orgId: ORG_ID, docId: DOC_ID },
|
|
48
|
+
],
|
|
49
|
+
version: 0,
|
|
50
|
+
}).success,
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects a request-time version that is not an epoch integer", () => {
|
|
55
|
+
const result = ResourceInvalidatedEventSchema.safeParse({
|
|
56
|
+
type: "resource.invalidated",
|
|
57
|
+
keys: [{ type: "actionItems", orgId: ORG_ID }],
|
|
58
|
+
version: 1.5,
|
|
59
|
+
});
|
|
60
|
+
expect(result.success).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects an empty key list — a frame naming nothing is a bug, not a no-op", () => {
|
|
64
|
+
expect(
|
|
65
|
+
ResourceInvalidatedEventSchema.safeParse({
|
|
66
|
+
type: "resource.invalidated",
|
|
67
|
+
keys: [],
|
|
68
|
+
version: 1,
|
|
69
|
+
}).success,
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("caps keys so the envelope cannot approach the NOTIFY payload limit", () => {
|
|
74
|
+
const overCap = Array.from({ length: MAX_INVALIDATION_KEYS + 1 }, () => ({
|
|
75
|
+
type: "actionItems",
|
|
76
|
+
orgId: ORG_ID,
|
|
77
|
+
}));
|
|
78
|
+
expect(
|
|
79
|
+
ResourceInvalidatedEventSchema.safeParse({
|
|
80
|
+
type: "resource.invalidated",
|
|
81
|
+
keys: overCap,
|
|
82
|
+
version: 1,
|
|
83
|
+
}).success,
|
|
84
|
+
).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("key validation is forward-compatible across version skew", () => {
|
|
89
|
+
it("ACCEPTS a key type this contracts version has never heard of", () => {
|
|
90
|
+
// The whole reason `keys` validates shape and not vocabulary. A newer
|
|
91
|
+
// server naming a key we do not know must not cost us the ENTIRE frame —
|
|
92
|
+
// the unknown key matches no query, the known ones still land.
|
|
93
|
+
const result = ResourceInvalidatedEventSchema.safeParse({
|
|
94
|
+
type: "resource.invalidated",
|
|
95
|
+
keys: [
|
|
96
|
+
{ type: "somethingShippedAfterThisClient", orgId: ORG_ID },
|
|
97
|
+
{ type: "actionItems", orgId: ORG_ID },
|
|
98
|
+
],
|
|
99
|
+
version: 1,
|
|
100
|
+
});
|
|
101
|
+
expect(result.success).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects a key with no scope discriminator", () => {
|
|
105
|
+
expect(isResourceKeyShape({ type: "actionItems" })).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects a key with two scope discriminators", () => {
|
|
109
|
+
expect(
|
|
110
|
+
isResourceKeyShape({ type: "actionItems", orgId: ORG_ID, userId: "u1" }),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects non-objects and an empty type", () => {
|
|
115
|
+
expect(isResourceKeyShape(null)).toBe(false);
|
|
116
|
+
expect(isResourceKeyShape("actionItems")).toBe(false);
|
|
117
|
+
expect(isResourceKeyShape({ type: "", orgId: ORG_ID })).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("resync", () => {
|
|
122
|
+
it("accepts every declared reason", () => {
|
|
123
|
+
for (const reason of USER_EVENT_RESYNC_REASONS) {
|
|
124
|
+
expect(
|
|
125
|
+
UserEventResyncSchema.safeParse({ type: "resync", reason }).success,
|
|
126
|
+
).toBe(true);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects an undeclared reason", () => {
|
|
131
|
+
expect(
|
|
132
|
+
UserEventResyncSchema.safeParse({ type: "resync", reason: "because" })
|
|
133
|
+
.success,
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("the union", () => {
|
|
139
|
+
it("discriminates every member on `type`", () => {
|
|
140
|
+
const frames = [
|
|
141
|
+
{
|
|
142
|
+
type: "resource.invalidated",
|
|
143
|
+
keys: [{ type: "actionItems", orgId: ORG_ID }],
|
|
144
|
+
version: 1,
|
|
145
|
+
},
|
|
146
|
+
{ type: "resync", reason: "listen-recovered" },
|
|
147
|
+
{ type: "connected" },
|
|
148
|
+
];
|
|
149
|
+
for (const frame of frames) {
|
|
150
|
+
expect(UserSseEventSchema.safeParse(frame).success).toBe(true);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rejects an unmodeled type so the client LOGS it rather than silently dropping", () => {
|
|
155
|
+
expect(
|
|
156
|
+
UserSseEventSchema.safeParse({ type: "notification.created", id: "n1" })
|
|
157
|
+
.success,
|
|
158
|
+
).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("models `connected` at all — an unmodeled open frame would log a parse error on every connect", () => {
|
|
162
|
+
expect(
|
|
163
|
+
UserEventConnectedSchema.safeParse({ type: "connected" }).success,
|
|
164
|
+
).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("boundary with the two adjacent vocabularies (ADR-CONT-104)", () => {
|
|
169
|
+
it("shares no member name with ActionItemKind", () => {
|
|
170
|
+
// Not symmetry policing — a shared string would make one of the two
|
|
171
|
+
// meanings lie, since an action item is standing state and a user event is
|
|
172
|
+
// a single unreplayable instant.
|
|
173
|
+
const frameTypes = new Set(["resource.invalidated", "resync", "connected"]);
|
|
174
|
+
for (const kind of ACTION_ITEM_KINDS) {
|
|
175
|
+
expect(frameTypes.has(kind)).toBe(false);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("REJECTS an ActionItem-shaped payload", () => {
|
|
180
|
+
// The test that stops the future "just reuse ActionItem on the stream" PR.
|
|
181
|
+
// An action item is re-derived on every read and resolves when someone
|
|
182
|
+
// acts; it cannot be delivered once and forgotten.
|
|
183
|
+
const actionItemShaped = {
|
|
184
|
+
type: "resource.invalidated",
|
|
185
|
+
id: "companyMd.access_request_pending:req-1",
|
|
186
|
+
kind: "companyMd.access_request_pending",
|
|
187
|
+
target: { type: "company_md", id: DOC_ID },
|
|
188
|
+
unitPath: "acme.sales",
|
|
189
|
+
title: "Maya Chen wants access",
|
|
190
|
+
href: "/@acme/md/doc-1?share=1",
|
|
191
|
+
};
|
|
192
|
+
expect(
|
|
193
|
+
ResourceInvalidatedEventSchema.safeParse(actionItemShaped).success,
|
|
194
|
+
).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-events/ — the frames any domain may put on ONE user's connection.
|
|
3
|
+
*
|
|
4
|
+
* See ./README.md for the domain and for why this is neither
|
|
5
|
+
* `NotificationKind` nor `ActionItemKind`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
MAX_INVALIDATION_KEYS,
|
|
10
|
+
NotificationCreatedEventSchema,
|
|
11
|
+
ResourceInvalidatedEventSchema,
|
|
12
|
+
USER_EVENT_RESYNC_REASONS,
|
|
13
|
+
UserEventBaseSchema,
|
|
14
|
+
UserEventConnectedSchema,
|
|
15
|
+
UserEventResyncReasonSchema,
|
|
16
|
+
UserEventResyncSchema,
|
|
17
|
+
UserSseEventSchema,
|
|
18
|
+
} from "./schemas";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
NotificationCreatedEvent,
|
|
22
|
+
ResourceInvalidatedEvent,
|
|
23
|
+
UserEventConnected,
|
|
24
|
+
UserEventResync,
|
|
25
|
+
UserEventResyncReason,
|
|
26
|
+
UserSseEvent,
|
|
27
|
+
} from "./schemas";
|