@company-semantics/contracts 82.0.0 → 83.1.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/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +137 -0
- package/src/generated/openapi-routes.ts +2 -0
- package/src/index.ts +51 -1
- package/src/user-notifications/README.md +99 -12
- package/src/user-notifications/__tests__/README.md +19 -0
- package/src/user-notifications/__tests__/compile-fixtures.ts +54 -0
- package/src/user-notifications/__tests__/condition-keys.test.ts +126 -0
- package/src/user-notifications/__tests__/feed-lifecycle.test.ts +260 -0
- package/src/user-notifications/__tests__/lifecycle.test.ts +114 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +6 -1
- package/src/user-notifications/condition-keys.ts +64 -0
- package/src/user-notifications/feed-lifecycle.ts +76 -0
- package/src/user-notifications/index.ts +49 -2
- package/src/user-notifications/lifecycle-registry.ts +123 -0
- package/src/user-notifications/lifecycle.ts +75 -0
- package/src/user-notifications/schemas.ts +27 -6
package/package.json
CHANGED
|
@@ -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 = 'e485ddbb7682' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = 'e485ddbb7682e98908329d4b788ce63dd32e00c23a6360e344d953ae9a42fc0e' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -281,6 +281,23 @@ export interface paths {
|
|
|
281
281
|
patch?: never;
|
|
282
282
|
trace?: never;
|
|
283
283
|
};
|
|
284
|
+
"/api/chats/resolve-launch": {
|
|
285
|
+
parameters: {
|
|
286
|
+
query?: never;
|
|
287
|
+
header?: never;
|
|
288
|
+
path?: never;
|
|
289
|
+
cookie?: never;
|
|
290
|
+
};
|
|
291
|
+
get?: never;
|
|
292
|
+
put?: never;
|
|
293
|
+
/** Resolve a launch to its subject’s active chat (read-only) */
|
|
294
|
+
post: operations["resolveChatLaunch"];
|
|
295
|
+
delete?: never;
|
|
296
|
+
options?: never;
|
|
297
|
+
head?: never;
|
|
298
|
+
patch?: never;
|
|
299
|
+
trace?: never;
|
|
300
|
+
};
|
|
284
301
|
"/api/chats/{id}": {
|
|
285
302
|
parameters: {
|
|
286
303
|
query?: never;
|
|
@@ -369,6 +386,23 @@ export interface paths {
|
|
|
369
386
|
patch?: never;
|
|
370
387
|
trace?: never;
|
|
371
388
|
};
|
|
389
|
+
"/api/chats/{id}/start-fresh": {
|
|
390
|
+
parameters: {
|
|
391
|
+
query?: never;
|
|
392
|
+
header?: never;
|
|
393
|
+
path?: never;
|
|
394
|
+
cookie?: never;
|
|
395
|
+
};
|
|
396
|
+
get?: never;
|
|
397
|
+
put?: never;
|
|
398
|
+
/** Start a fresh chat on the same subject, handing over a summary */
|
|
399
|
+
post: operations["startFreshChat"];
|
|
400
|
+
delete?: never;
|
|
401
|
+
options?: never;
|
|
402
|
+
head?: never;
|
|
403
|
+
patch?: never;
|
|
404
|
+
trace?: never;
|
|
405
|
+
};
|
|
372
406
|
"/api/chats/{id}/generate-title": {
|
|
373
407
|
parameters: {
|
|
374
408
|
query?: never;
|
|
@@ -4865,6 +4899,34 @@ export interface components {
|
|
|
4865
4899
|
avatarUrl: string | null;
|
|
4866
4900
|
}[];
|
|
4867
4901
|
};
|
|
4902
|
+
ResolveLaunchResponse: {
|
|
4903
|
+
subjectId: string | null;
|
|
4904
|
+
chatId: string | null;
|
|
4905
|
+
generation: number | null;
|
|
4906
|
+
/** @enum {string} */
|
|
4907
|
+
disposition: "resume-existing-anchor" | "resume-and-launch" | "created-and-launch";
|
|
4908
|
+
};
|
|
4909
|
+
/** @description The launch to resolve against its subject’s active generation. */
|
|
4910
|
+
ResolveLaunchRequest: {
|
|
4911
|
+
launch: {
|
|
4912
|
+
/** @constant */
|
|
4913
|
+
kind: "structure-review";
|
|
4914
|
+
/** Format: uuid */
|
|
4915
|
+
proposalId: string;
|
|
4916
|
+
} | {
|
|
4917
|
+
/** @constant */
|
|
4918
|
+
kind: "workflow";
|
|
4919
|
+
/** Format: uuid */
|
|
4920
|
+
workflowId: string;
|
|
4921
|
+
} | {
|
|
4922
|
+
/** @constant */
|
|
4923
|
+
kind: "input-request";
|
|
4924
|
+
/** @enum {string} */
|
|
4925
|
+
requestType: "org-structure/review" | "source-sync/candidates" | "source-sync/synthesis";
|
|
4926
|
+
/** Format: uuid */
|
|
4927
|
+
requestId: string;
|
|
4928
|
+
};
|
|
4929
|
+
};
|
|
4868
4930
|
GetChatResponse: {
|
|
4869
4931
|
chat: {
|
|
4870
4932
|
id: string;
|
|
@@ -4973,6 +5035,20 @@ export interface components {
|
|
|
4973
5035
|
/** Format: uuid */
|
|
4974
5036
|
userId: string;
|
|
4975
5037
|
};
|
|
5038
|
+
StartFreshChatResponse: {
|
|
5039
|
+
chatId: string;
|
|
5040
|
+
interactionId: string;
|
|
5041
|
+
subjectId: string;
|
|
5042
|
+
generation: number;
|
|
5043
|
+
continuationOf: {
|
|
5044
|
+
chatId: string;
|
|
5045
|
+
};
|
|
5046
|
+
};
|
|
5047
|
+
LaunchTargetSuperseded: {
|
|
5048
|
+
/** @constant */
|
|
5049
|
+
code: "launch_target_superseded";
|
|
5050
|
+
activeChatId: string | null;
|
|
5051
|
+
};
|
|
4976
5052
|
GenerateTitleResponse: {
|
|
4977
5053
|
title: string;
|
|
4978
5054
|
isAutoGenerated: boolean;
|
|
@@ -7629,9 +7705,13 @@ export interface components {
|
|
|
7629
7705
|
href: string | null;
|
|
7630
7706
|
createdAt: string;
|
|
7631
7707
|
readAt: string | null;
|
|
7708
|
+
activityAt: string;
|
|
7709
|
+
occurrenceCount: number;
|
|
7710
|
+
resolvedAt: string | null;
|
|
7632
7711
|
};
|
|
7633
7712
|
})[];
|
|
7634
7713
|
nextCursor: string | null;
|
|
7714
|
+
viewCount: number;
|
|
7635
7715
|
unreadCount: number;
|
|
7636
7716
|
truncated: boolean;
|
|
7637
7717
|
};
|
|
@@ -10050,6 +10130,8 @@ export interface operations {
|
|
|
10050
10130
|
limit?: number;
|
|
10051
10131
|
cursor?: string;
|
|
10052
10132
|
includePinned?: "true" | "false";
|
|
10133
|
+
surface?: "recents";
|
|
10134
|
+
subjectId?: string;
|
|
10053
10135
|
};
|
|
10054
10136
|
header?: never;
|
|
10055
10137
|
path?: never;
|
|
@@ -10136,6 +10218,30 @@ export interface operations {
|
|
|
10136
10218
|
};
|
|
10137
10219
|
};
|
|
10138
10220
|
};
|
|
10221
|
+
resolveChatLaunch: {
|
|
10222
|
+
parameters: {
|
|
10223
|
+
query?: never;
|
|
10224
|
+
header?: never;
|
|
10225
|
+
path?: never;
|
|
10226
|
+
cookie?: never;
|
|
10227
|
+
};
|
|
10228
|
+
requestBody: {
|
|
10229
|
+
content: {
|
|
10230
|
+
"application/json": components["schemas"]["ResolveLaunchRequest"];
|
|
10231
|
+
};
|
|
10232
|
+
};
|
|
10233
|
+
responses: {
|
|
10234
|
+
/** @description Where the launch would land; nothing is written */
|
|
10235
|
+
200: {
|
|
10236
|
+
headers: {
|
|
10237
|
+
[name: string]: unknown;
|
|
10238
|
+
};
|
|
10239
|
+
content: {
|
|
10240
|
+
"application/json": components["schemas"]["ResolveLaunchResponse"];
|
|
10241
|
+
};
|
|
10242
|
+
};
|
|
10243
|
+
};
|
|
10244
|
+
};
|
|
10139
10245
|
getChat: {
|
|
10140
10246
|
parameters: {
|
|
10141
10247
|
query?: never;
|
|
@@ -10326,6 +10432,37 @@ export interface operations {
|
|
|
10326
10432
|
};
|
|
10327
10433
|
};
|
|
10328
10434
|
};
|
|
10435
|
+
startFreshChat: {
|
|
10436
|
+
parameters: {
|
|
10437
|
+
query?: never;
|
|
10438
|
+
header?: never;
|
|
10439
|
+
path: {
|
|
10440
|
+
id: string;
|
|
10441
|
+
};
|
|
10442
|
+
cookie?: never;
|
|
10443
|
+
};
|
|
10444
|
+
requestBody?: never;
|
|
10445
|
+
responses: {
|
|
10446
|
+
/** @description The new active chat of the subject */
|
|
10447
|
+
200: {
|
|
10448
|
+
headers: {
|
|
10449
|
+
[name: string]: unknown;
|
|
10450
|
+
};
|
|
10451
|
+
content: {
|
|
10452
|
+
"application/json": components["schemas"]["StartFreshChatResponse"];
|
|
10453
|
+
};
|
|
10454
|
+
};
|
|
10455
|
+
/** @description The chat is not its subject’s active chat */
|
|
10456
|
+
409: {
|
|
10457
|
+
headers: {
|
|
10458
|
+
[name: string]: unknown;
|
|
10459
|
+
};
|
|
10460
|
+
content: {
|
|
10461
|
+
"application/json": components["schemas"]["LaunchTargetSuperseded"];
|
|
10462
|
+
};
|
|
10463
|
+
};
|
|
10464
|
+
};
|
|
10465
|
+
};
|
|
10329
10466
|
generateChatTitle: {
|
|
10330
10467
|
parameters: {
|
|
10331
10468
|
query?: never;
|
|
@@ -20,6 +20,7 @@ export const openApiRoutes = {
|
|
|
20
20
|
'/api/chats/events': ['GET'],
|
|
21
21
|
'/api/chats/mentionable': ['GET'],
|
|
22
22
|
'/api/chats/proactive/{eventId}': ['GET'],
|
|
23
|
+
'/api/chats/resolve-launch': ['POST'],
|
|
23
24
|
'/api/chats/{chatId}/shares': ['GET', 'POST'],
|
|
24
25
|
'/api/chats/{id}': ['DELETE', 'GET', 'PATCH'],
|
|
25
26
|
'/api/chats/{id}/generate-title': ['POST'],
|
|
@@ -28,6 +29,7 @@ export const openApiRoutes = {
|
|
|
28
29
|
'/api/chats/{id}/participants': ['GET', 'POST'],
|
|
29
30
|
'/api/chats/{id}/participants/{userId}': ['DELETE'],
|
|
30
31
|
'/api/chats/{id}/pin': ['DELETE', 'POST'],
|
|
32
|
+
'/api/chats/{id}/start-fresh': ['POST'],
|
|
31
33
|
'/api/comments': ['GET'],
|
|
32
34
|
'/api/comments/threads': ['POST'],
|
|
33
35
|
'/api/comments/threads/{threadId}': ['DELETE'],
|
package/src/index.ts
CHANGED
|
@@ -327,7 +327,8 @@ export type {
|
|
|
327
327
|
UserSseEvent,
|
|
328
328
|
} from "./user-events/index";
|
|
329
329
|
|
|
330
|
-
// Durable in-app inbox + the merged feed that renders it
|
|
330
|
+
// Durable in-app inbox + the merged feed that renders it
|
|
331
|
+
// (user-notification-inbox-and-merged-feed). The
|
|
331
332
|
// THIRD vocabulary beside NotificationKind and ActionItemKind: a notification
|
|
332
333
|
// leaves the building, an action item is standing state you can resolve, and
|
|
333
334
|
// one of these is a stored fact you can only look at. Rows hold identifiers
|
|
@@ -352,6 +353,55 @@ export type {
|
|
|
352
353
|
UserNotificationKind,
|
|
353
354
|
} from "./user-notifications/index";
|
|
354
355
|
|
|
356
|
+
// Each inbox kind's declared lifecycle — condition, supersession, resolution,
|
|
357
|
+
// retention (notification-lifecycle-is-declared-per-kind) — and the one
|
|
358
|
+
// function that turns a kind + domain ref into its condition/supersession keys.
|
|
359
|
+
export {
|
|
360
|
+
conditionKeyOf,
|
|
361
|
+
NOTIFICATION_ATTENTION_ON_SUPERSEDE,
|
|
362
|
+
NOTIFICATION_LIFECYCLE,
|
|
363
|
+
NOTIFICATION_RESOLUTION_AUDIENCES,
|
|
364
|
+
NOTIFICATION_RETENTION_CLASSES,
|
|
365
|
+
NotificationConditionKeyError,
|
|
366
|
+
notificationKeysOf,
|
|
367
|
+
} from "./user-notifications/index";
|
|
368
|
+
|
|
369
|
+
export type {
|
|
370
|
+
NotificationAttentionOnSupersede,
|
|
371
|
+
NotificationCondition,
|
|
372
|
+
NotificationKeys,
|
|
373
|
+
NotificationLifecycle,
|
|
374
|
+
NotificationResolution,
|
|
375
|
+
NotificationResolutionAudience,
|
|
376
|
+
NotificationRetention,
|
|
377
|
+
} from "./user-notifications/index";
|
|
378
|
+
|
|
379
|
+
// The feed's views, selectors (explicit ids, or a view as of `asOf`) and verb
|
|
380
|
+
// wire — read / unread / delete / restore — for the caller's inbox rows.
|
|
381
|
+
export {
|
|
382
|
+
FEED_SELECTOR_MAX_IDS,
|
|
383
|
+
FEED_VIEWS,
|
|
384
|
+
FeedDeleteRequestSchema,
|
|
385
|
+
FeedDeleteResponseSchema,
|
|
386
|
+
FeedMarkUnreadRequestSchema,
|
|
387
|
+
FeedMutationResponseSchema,
|
|
388
|
+
FeedRestoreRequestSchema,
|
|
389
|
+
FeedRestoreResponseSchema,
|
|
390
|
+
FeedSelectorSchema,
|
|
391
|
+
FeedViewSchema,
|
|
392
|
+
} from "./user-notifications/index";
|
|
393
|
+
|
|
394
|
+
export type {
|
|
395
|
+
FeedDeleteRequest,
|
|
396
|
+
FeedDeleteResponse,
|
|
397
|
+
FeedMarkUnreadRequest,
|
|
398
|
+
FeedMutationResponse,
|
|
399
|
+
FeedRestoreRequest,
|
|
400
|
+
FeedRestoreResponse,
|
|
401
|
+
FeedSelector,
|
|
402
|
+
FeedView,
|
|
403
|
+
} from "./user-notifications/index";
|
|
404
|
+
|
|
355
405
|
// Proactive delivery — how the system speaks first (ADR-CONTRACTS-142). A
|
|
356
406
|
// COMPOSER above the three notification vocabularies, not a fourth peer: the
|
|
357
407
|
// presentation class is a TOTAL FUNCTION from class to surfaces
|
|
@@ -21,8 +21,8 @@ that renders them alongside action items at the top of `/me/work`.
|
|
|
21
21
|
sees that the thing happened — it simply does not link anywhere. "You were
|
|
22
22
|
granted access to something you can no longer see" is true and worth saying.
|
|
23
23
|
- **Feed ordering is grouped, not chronologically interleaved.** Actionable rows
|
|
24
|
-
first, then informational rows
|
|
25
|
-
surface, not one timeline.
|
|
24
|
+
first, then informational rows unread-and-unresolved first, then `activityAt`
|
|
25
|
+
DESC, `id` DESC. "Merged feed" names one visual surface, not one timeline.
|
|
26
26
|
- **Only notifications paginate.** Action items are already bounded and are
|
|
27
27
|
standing state, not a timeline. Page 1 = all action items + the first page of
|
|
28
28
|
notifications; every later page is notifications only. Paginating two
|
|
@@ -30,9 +30,9 @@ that renders them alongside action items at the top of `/me/work`.
|
|
|
30
30
|
- **`unreadCount` is total, never windowed.** A windowed count under-reports the
|
|
31
31
|
moment the inbox exceeds one page, and it drives a badge whose whole job is to
|
|
32
32
|
be trusted.
|
|
33
|
-
- **The cursor encodes `(
|
|
34
|
-
|
|
35
|
-
or skipped.
|
|
33
|
+
- **The cursor is opaque and encodes `(unread, activityAt, id)`**, mirroring the
|
|
34
|
+
ordering. The id breaks ties so two rows with the same activity cannot
|
|
35
|
+
straddle a page boundary and be served twice or skipped.
|
|
36
36
|
- **A row can be BORN READ.** Every proactive kind writes a durable row here
|
|
37
37
|
as its paper trail, but only the class whose badge carrier is `inbox` writes
|
|
38
38
|
it unread; `proactive.org_became_shared` is an `announcement`, so the banner
|
|
@@ -83,15 +83,102 @@ that renders them alongside action items at the top of `/me/work`.
|
|
|
83
83
|
anywhere, and the proactive `unread` flag on a chat summary stays a one-time
|
|
84
84
|
arrival badge that ordinary conversation never re-lights.
|
|
85
85
|
|
|
86
|
+
## Lifecycle
|
|
87
|
+
|
|
88
|
+
A row is the **current attention item for one condition**, not an event record
|
|
89
|
+
(control ADR `notification-inbox-is-an-attention-surface`; contracts ADR
|
|
90
|
+
`notification-lifecycle-is-declared-per-kind`). Every kind declares its
|
|
91
|
+
lifecycle in `NOTIFICATION_LIFECYCLE`, typed
|
|
92
|
+
`Readonly<Record<UserNotificationKind, NotificationLifecycle>>`, so a kind
|
|
93
|
+
without one — or an entry for a kind that does not exist — fails to compile.
|
|
94
|
+
Each entry declares four things:
|
|
95
|
+
|
|
96
|
+
- **Condition — what the row is about.** `none` (an independent delivery with no
|
|
97
|
+
identity), `{ scope: 'kind' }` (one condition per kind), or
|
|
98
|
+
`{ scope: 'fields', fields }` (named fields of the producing domain's ref). The
|
|
99
|
+
condition is the identity RESOLUTION addresses.
|
|
100
|
+
- **Supersession — a property of the condition, never a second identity.**
|
|
101
|
+
`supersedes: true` means a repeat occurrence updates the active row for that
|
|
102
|
+
condition instead of appending another; `supersedes: false` means each
|
|
103
|
+
occurrence is its own row (access requests: every request stands on its own).
|
|
104
|
+
A `none` condition cannot supersede.
|
|
105
|
+
- **Renew vs preserve.** A superseding kind declares `attentionOnSupersede`.
|
|
106
|
+
`renew` restores the kind's normal initial attention state — unread, or read
|
|
107
|
+
when its proactive class is born read (`bornRead` decides, not this registry);
|
|
108
|
+
the repeat is news again. `preserve` keeps the row's current read state; the
|
|
109
|
+
repeat is a quiet update to a standing condition. Only a superseding kind may
|
|
110
|
+
declare it.
|
|
111
|
+
- **Resolution, with an explicit audience.** `never`, or
|
|
112
|
+
`{ mode: 'source', audience }`: the producing DOMAIN resolves the condition,
|
|
113
|
+
for `all-recipients` or only the `recipient`. Source resolution needs a
|
|
114
|
+
condition to address, so `none` + `source` does not compile.
|
|
115
|
+
- **Retention class.** `standard` or `persistent`. Every kind is `standard`
|
|
116
|
+
today; `persistent` must be declared, never accidental. The class is all
|
|
117
|
+
contracts carries — the retention DAY values live in the backend.
|
|
118
|
+
|
|
119
|
+
The union makes the three illegal combinations unrepresentable (source
|
|
120
|
+
resolution without a condition, `attentionOnSupersede` without
|
|
121
|
+
`supersedes: true`, supersession of `none`); `__tests__/compile-fixtures.ts`
|
|
122
|
+
pins each with `@ts-expect-error`.
|
|
123
|
+
|
|
124
|
+
**One key builder.** `conditionKeyOf(kind, domainRef)` is the only way a
|
|
125
|
+
condition key is spelled: the kind itself for `scope: 'kind'`; for
|
|
126
|
+
`scope: 'fields'`, the kind then each named field sorted by name as
|
|
127
|
+
`name=encodeURIComponent(value)`, joined by `|`; `null` for `none`. Values are
|
|
128
|
+
never trimmed or lowercased, and a named field that is absent, not a string, or
|
|
129
|
+
empty throws `NotificationConditionKeyError`. `notificationKeysOf` returns
|
|
130
|
+
`{ conditionKey, supersessionKey }`, where the supersession key is the condition
|
|
131
|
+
key of a superseding kind and `null` otherwise — not a second identity.
|
|
132
|
+
|
|
133
|
+
**The row carries its lifecycle.** `activityAt` is the time of the latest
|
|
134
|
+
occurrence (the ordering and retention clock), `occurrenceCount` how many
|
|
135
|
+
occurrences the row has absorbed by supersession, and `resolvedAt` when the
|
|
136
|
+
domain resolved the condition (`null` while it stands). All three are required.
|
|
137
|
+
|
|
138
|
+
**The verbs take a selector** (`feed-lifecycle.ts`): explicit `ids` (at most
|
|
139
|
+
`FEED_SELECTOR_MAX_IDS`), or a `view` (`all` | `unread`) AS OF `asOf`, so rows
|
|
140
|
+
that arrive after the person acted are never swept in. `viewCount` on the feed
|
|
141
|
+
response counts the notification rows in the requested view. A delete returns
|
|
142
|
+
an opaque `restoreKey`; a restore reports `{ restored, superseded }`, where
|
|
143
|
+
`superseded` counts rows left deleted because a newer active row already
|
|
144
|
+
represents their condition.
|
|
145
|
+
|
|
146
|
+
**There is no Archive.** No archived state, no archived view, no archive verb.
|
|
147
|
+
A row is current, resolved, or deleted — and a deleted row is never served.
|
|
148
|
+
|
|
149
|
+
The per-kind table and the retention and Undo values live in
|
|
150
|
+
company-semantics-control docs/notification-inbox.md; the registry mirrors that
|
|
151
|
+
table entry for entry.
|
|
152
|
+
|
|
86
153
|
## Public API
|
|
87
154
|
|
|
88
|
-
| Export
|
|
89
|
-
|
|
|
90
|
-
| `UserNotificationSchema`
|
|
91
|
-
| `USER_NOTIFICATION_KINDS`
|
|
92
|
-
| `FeedItemSchema`
|
|
93
|
-
| `FeedListResponseSchema`
|
|
94
|
-
| `FeedMarkReadRequestSchema`
|
|
155
|
+
| Export | Description |
|
|
156
|
+
| ------------------------------------- | ------------------------------------------------------------------------- |
|
|
157
|
+
| `UserNotificationSchema` | One informational row, as rendered |
|
|
158
|
+
| `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
|
|
159
|
+
| `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
|
|
160
|
+
| `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, viewCount, unreadCount, truncated |
|
|
161
|
+
| `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` — `{ selector }` |
|
|
162
|
+
| `FEED_VIEWS` / `FeedViewSchema` | `all` \| `unread` |
|
|
163
|
+
| `FeedSelectorSchema` | `{ kind: 'ids', notificationIds }` \| `{ kind: 'view', view, asOf }` |
|
|
164
|
+
| `FEED_SELECTOR_MAX_IDS` | The most ids one explicit selection may name (200) |
|
|
165
|
+
| `FeedMarkUnreadRequestSchema` | Mark unread — `{ selector }` |
|
|
166
|
+
| `FeedDeleteRequestSchema` | Delete — `{ selector }` |
|
|
167
|
+
| `FeedRestoreRequestSchema` | Undo a delete — `{ restoreKey }` |
|
|
168
|
+
| `FeedMutationResponseSchema` | `{ affected }` |
|
|
169
|
+
| `FeedDeleteResponseSchema` | `{ affected, restoreKey }` — the key is opaque |
|
|
170
|
+
| `FeedRestoreResponseSchema` | `{ restored, superseded }` |
|
|
171
|
+
| `NOTIFICATION_LIFECYCLE` | Every kind's declared lifecycle |
|
|
172
|
+
| `NotificationLifecycle` | The lifecycle union (condition, supersession, resolution, …) |
|
|
173
|
+
| `NotificationCondition` | `{ scope: 'kind' }` \| `{ scope: 'fields', fields }` |
|
|
174
|
+
| `NotificationResolution` | `never` \| `{ mode: 'source', audience }` |
|
|
175
|
+
| `NOTIFICATION_ATTENTION_ON_SUPERSEDE` | `renew` \| `preserve` |
|
|
176
|
+
| `NOTIFICATION_RESOLUTION_AUDIENCES` | `all-recipients` \| `recipient` |
|
|
177
|
+
| `NOTIFICATION_RETENTION_CLASSES` | `standard` \| `persistent` |
|
|
178
|
+
| `conditionKeyOf` | The one condition-key builder (kind + domain ref → key) |
|
|
179
|
+
| `notificationKeysOf` | `{ conditionKey, supersessionKey }` for a kind + domain ref |
|
|
180
|
+
| `NotificationKeys` | The pair `notificationKeysOf` returns |
|
|
181
|
+
| `NotificationConditionKeyError` | A named condition field is absent, not a string, or empty |
|
|
95
182
|
|
|
96
183
|
## Dependencies
|
|
97
184
|
|
|
@@ -23,6 +23,25 @@ Locks the claims `../README.md` and ADR-CONT-108 make that the compiler cannot.
|
|
|
23
23
|
trap `action-items/__tests__` guards, and it would wedge the feed badge
|
|
24
24
|
with no error anywhere.
|
|
25
25
|
|
|
26
|
+
- `lifecycle.test.ts` — the per-kind lifecycle registry: every kind in the
|
|
27
|
+
real `USER_NOTIFICATION_KINDS` declares one, a source-resolved kind always has
|
|
28
|
+
a condition, access requests never supersede, and no kind is `persistent`
|
|
29
|
+
yet.
|
|
30
|
+
- `condition-keys.test.ts` — the one key builder's exact format: fields sorted
|
|
31
|
+
by name and URI-encoded (never trimmed or lowercased), a missing or empty
|
|
32
|
+
field throws, an independent kind has no key, a non-superseding kind has no
|
|
33
|
+
supersession key, and ref fields the lifecycle does not name never change it.
|
|
34
|
+
- `feed-lifecycle.test.ts` — the feed wire: an `ids` selector names 1..200
|
|
35
|
+
ids, a `view` selector needs its `asOf` snapshot, there is no `archived`
|
|
36
|
+
view, mark-read takes a selector, a delete returns a restore key and a
|
|
37
|
+
restore counts superseded rows, and the row's `activityAt`,
|
|
38
|
+
`occurrenceCount` and `resolvedAt` (and the response's `viewCount`) are
|
|
39
|
+
required.
|
|
40
|
+
- `compile-fixtures.ts` — not a test file, so `tsc -b` compiles it: the three
|
|
41
|
+
illegal lifecycles, each pinned with `@ts-expect-error`. A directive written
|
|
42
|
+
in a `*.test.ts` is never typechecked; one here fails `pnpm typecheck` with
|
|
43
|
+
TS2578 the moment the union loosens.
|
|
44
|
+
|
|
26
45
|
## Invariants
|
|
27
46
|
|
|
28
47
|
- These assert VOCABULARY and SHAPE, never behavior. Anything needing a
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative typecheck fixtures — the illegal lifecycles the per-kind registry
|
|
3
|
+
* (notification-lifecycle-is-declared-per-kind) refuses, each pinned with
|
|
4
|
+
* `@ts-expect-error`.
|
|
5
|
+
*
|
|
6
|
+
* The directive is the proof. If the `NotificationLifecycle` union ever loosens
|
|
7
|
+
* so one of these becomes representable, the directive goes UNUSED and
|
|
8
|
+
* `pnpm typecheck` fails with TS2578.
|
|
9
|
+
*
|
|
10
|
+
* WHY A COMPILED MODULE. `src/tsconfig.json` excludes every `*.test.ts` file
|
|
11
|
+
* and vitest does not typecheck, so a directive inside `lifecycle.test.ts` is
|
|
12
|
+
* never checked. This file is not a `*.test.ts`, so `tsc -b` compiles it (the
|
|
13
|
+
* same placement as `../../proactive/__tests__/compile-fixtures.ts`), and
|
|
14
|
+
* `lifecycle.test.ts` imports it.
|
|
15
|
+
*
|
|
16
|
+
* Each directive sits IMMEDIATELY above the line the compiler reports on.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { NotificationLifecycle } from "../lifecycle";
|
|
20
|
+
|
|
21
|
+
/** CLAIM 1 — source resolution needs a condition identity to resolve. */
|
|
22
|
+
const sourceWithoutCondition: NotificationLifecycle = {
|
|
23
|
+
condition: "none",
|
|
24
|
+
// @ts-expect-error a condition-less kind resolves `never`
|
|
25
|
+
resolution: { mode: "source", audience: "recipient" },
|
|
26
|
+
retention: "standard",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** CLAIM 2 — attention on supersede means nothing without supersession. */
|
|
30
|
+
const attentionWithoutSupersession: NotificationLifecycle = {
|
|
31
|
+
condition: { scope: "kind" },
|
|
32
|
+
supersedes: false,
|
|
33
|
+
// @ts-expect-error `attentionOnSupersede` exists only when `supersedes: true`
|
|
34
|
+
attentionOnSupersede: "renew",
|
|
35
|
+
resolution: "never",
|
|
36
|
+
retention: "standard",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** CLAIM 3 — a condition-less kind has nothing to supersede by. */
|
|
40
|
+
const supersedingWithoutCondition: NotificationLifecycle = {
|
|
41
|
+
condition: "none",
|
|
42
|
+
// @ts-expect-error a `none` condition carries no `supersedes`
|
|
43
|
+
supersedes: true,
|
|
44
|
+
attentionOnSupersede: "renew",
|
|
45
|
+
resolution: "never",
|
|
46
|
+
retention: "standard",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The fixtures, exported so the directives are not dead code. */
|
|
50
|
+
export const ILLEGAL_LIFECYCLES = [
|
|
51
|
+
sourceWithoutCondition,
|
|
52
|
+
attentionWithoutSupersession,
|
|
53
|
+
supersedingWithoutCondition,
|
|
54
|
+
] as const;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one condition-key builder (notification-lifecycle-is-declared-per-kind).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS TESTED:
|
|
5
|
+
* - A `fields` key is the kind, then each named field sorted by name as
|
|
6
|
+
* name=encodeURIComponent(value), joined by `|` — whatever order the ref
|
|
7
|
+
* lists them in.
|
|
8
|
+
* - A named field that is absent, not a string, or empty throws
|
|
9
|
+
* NotificationConditionKeyError naming the kind and the field.
|
|
10
|
+
* - An independent kind has neither key; a non-superseding kind has a
|
|
11
|
+
* condition key but no supersession key; a `kind` key is the kind itself.
|
|
12
|
+
* - Values are never trimmed or lowercased, and fields the lifecycle does not
|
|
13
|
+
* name never change the key.
|
|
14
|
+
* - Every export is reachable from both the domain barrel and the root barrel.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import * as root from "../../index";
|
|
18
|
+
import * as domain from "../index";
|
|
19
|
+
import {
|
|
20
|
+
conditionKeyOf,
|
|
21
|
+
NotificationConditionKeyError,
|
|
22
|
+
notificationKeysOf,
|
|
23
|
+
} from "../condition-keys";
|
|
24
|
+
|
|
25
|
+
describe("conditionKeyOf / notificationKeysOf", () => {
|
|
26
|
+
it("condition key orders fields by name", () => {
|
|
27
|
+
const a = conditionKeyOf("comment.reply", {
|
|
28
|
+
subjectType: "company_md",
|
|
29
|
+
subjectId: "doc-1",
|
|
30
|
+
});
|
|
31
|
+
const b = conditionKeyOf("comment.reply", {
|
|
32
|
+
subjectId: "doc-1",
|
|
33
|
+
subjectType: "company_md",
|
|
34
|
+
});
|
|
35
|
+
expect(a).toBe("comment.reply|subjectId=doc-1|subjectType=company_md");
|
|
36
|
+
expect(b).toBe(a);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("condition key refuses a missing field", () => {
|
|
40
|
+
const build = () =>
|
|
41
|
+
conditionKeyOf("companyMd.access_requested", { docId: "doc-1" });
|
|
42
|
+
expect(build).toThrow(NotificationConditionKeyError);
|
|
43
|
+
try {
|
|
44
|
+
build();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
expect(error).toMatchObject({
|
|
47
|
+
name: "NotificationConditionKeyError",
|
|
48
|
+
kind: "companyMd.access_requested",
|
|
49
|
+
field: "requestId",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("condition key refuses an empty or non-string field", () => {
|
|
55
|
+
for (const requestId of ["", 42, null]) {
|
|
56
|
+
expect(() =>
|
|
57
|
+
conditionKeyOf("companyMd.access_requested", { requestId }),
|
|
58
|
+
).toThrow(NotificationConditionKeyError);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("an independent kind has no condition key", () => {
|
|
63
|
+
expect(
|
|
64
|
+
notificationKeysOf("companyMd.access_request_approved", {
|
|
65
|
+
requestId: "req-1",
|
|
66
|
+
}),
|
|
67
|
+
).toEqual({ conditionKey: null, supersessionKey: null });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a non-superseding kind has a condition key but no supersession key", () => {
|
|
71
|
+
expect(
|
|
72
|
+
notificationKeysOf("companyMd.access_requested", { requestId: "req-1" }),
|
|
73
|
+
).toEqual({
|
|
74
|
+
conditionKey: "companyMd.access_requested|requestId=req-1",
|
|
75
|
+
supersessionKey: null,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a superseding kind's supersession key is its condition key", () => {
|
|
80
|
+
const keys = notificationKeysOf("chat.mention", { chatId: "chat-1" });
|
|
81
|
+
expect(keys.conditionKey).toBe("chat.mention|chatId=chat-1");
|
|
82
|
+
expect(keys.supersessionKey).toBe(keys.conditionKey);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("a per-kind condition key is the kind itself", () => {
|
|
86
|
+
expect(
|
|
87
|
+
notificationKeysOf("proactive.brief_morning", {
|
|
88
|
+
orgLocalDate: "2026-09-25",
|
|
89
|
+
}),
|
|
90
|
+
).toEqual({
|
|
91
|
+
conditionKey: "proactive.brief_morning",
|
|
92
|
+
supersessionKey: "proactive.brief_morning",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("extra ref fields never change the key", () => {
|
|
97
|
+
const candidates = conditionKeyOf("proactive.source_sync_review_ready", {
|
|
98
|
+
checkpoint: "candidates",
|
|
99
|
+
runId: "run-1",
|
|
100
|
+
});
|
|
101
|
+
const again = conditionKeyOf("proactive.source_sync_review_ready", {
|
|
102
|
+
checkpoint: "candidates",
|
|
103
|
+
batchId: "batch-9",
|
|
104
|
+
});
|
|
105
|
+
expect(candidates).toBe(
|
|
106
|
+
"proactive.source_sync_review_ready|checkpoint=candidates",
|
|
107
|
+
);
|
|
108
|
+
expect(again).toBe(candidates);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("values are URI-encoded, never trimmed or lowercased", () => {
|
|
112
|
+
expect(conditionKeyOf("chat.mention", { chatId: " Chat|A=B " })).toBe(
|
|
113
|
+
`chat.mention|chatId=${encodeURIComponent(" Chat|A=B ")}`,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("every condition-key export is reachable from both barrels", () => {
|
|
118
|
+
for (const barrel of [root, domain]) {
|
|
119
|
+
expect(barrel.conditionKeyOf).toBe(conditionKeyOf);
|
|
120
|
+
expect(barrel.notificationKeysOf).toBe(notificationKeysOf);
|
|
121
|
+
expect(barrel.NotificationConditionKeyError).toBe(
|
|
122
|
+
NotificationConditionKeyError,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|