@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-scoped push envelope — every frame the server sends to ONE
|
|
3
|
+
* authenticated user on a long-lived connection, regardless of domain.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT A FIFTH `<Domain>SseEvent`. `ChatSseEvent`,
|
|
6
|
+
* `ExecutionSseEvent`, `CompanyMdCollabSseEvent` and `ImpersonationSseEvent`
|
|
7
|
+
* each describe ONE domain's traffic on ONE route. This union describes the
|
|
8
|
+
* frames any domain may put on the user's connection WITHOUT owning a route or
|
|
9
|
+
* a union of its own — which is exactly what `resource.invalidated` has been
|
|
10
|
+
* doing unpublished (backend `src/chat/execution/resource-invalidation.ts`,
|
|
11
|
+
* redeclared structurally in app `src/hooks/useChatEvents.ts`) since before
|
|
12
|
+
* there was a place to put it.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS STREAM IS, STATED SO NOBODY ASSUMES MORE:
|
|
15
|
+
*
|
|
16
|
+
* It is a wake-up channel plus ephemeral cache hints. It is NOT a general
|
|
17
|
+
* ordered event log. Publishing a user event grants NO replay guarantee.
|
|
18
|
+
*
|
|
19
|
+
* Two rules encode that, and both are load-bearing:
|
|
20
|
+
*
|
|
21
|
+
* - **No frame carries an SSE `id:` line.** An id implies a resumable position;
|
|
22
|
+
* there is no log behind this stream, so an id would be a lie. (Contrast
|
|
23
|
+
* `ExecutionSseEvent`, whose `eventSequence` doubles as `Last-Event-ID`
|
|
24
|
+
* because it projects a durable `execution_events` row — ADR-CONT-066.)
|
|
25
|
+
* - **Delivery is edge-triggered only.** Missing a frame may leave caches stale
|
|
26
|
+
* until the next authoritative refresh. That is acceptable because NO
|
|
27
|
+
* CORRECTNESS DEPENDS ON INVALIDATIONS — every frame means "re-read this",
|
|
28
|
+
* and the read is the authority. Nothing durable may ever be hung off this
|
|
29
|
+
* stream without first giving it a real commit-ordered log.
|
|
30
|
+
*/
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import type { ResourceKey } from "../resource-keys";
|
|
33
|
+
import { isResourceKeyShape } from "../resource-keys";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Local, not chat's. `chat/schemas.ts` owns a structurally identical helper;
|
|
37
|
+
* importing it would make a chat frame change a user-events breaking change.
|
|
38
|
+
*/
|
|
39
|
+
const IsoDateString = z.string().datetime();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fields every frame carries.
|
|
43
|
+
*
|
|
44
|
+
* Structurally identical to chat's `BaseEventSchema` on purpose, so folding
|
|
45
|
+
* chat onto this envelope later is a no-op — but deliberately NOT the same
|
|
46
|
+
* object, for the reason above.
|
|
47
|
+
*/
|
|
48
|
+
export const UserEventBaseSchema = z.object({
|
|
49
|
+
v: z.literal(1),
|
|
50
|
+
timestamp: IsoDateString,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// resource.invalidated — promoted verbatim from the ad-hoc backend interface
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/** Upper bound on keys per frame. Keeps the envelope far under NOTIFY's limit. */
|
|
58
|
+
export const MAX_INVALIDATION_KEYS = 16;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A cache-staleness signal. The client refreshes the named keys and shows the
|
|
62
|
+
* user nothing.
|
|
63
|
+
*
|
|
64
|
+
* `keys` validates SHAPE, not VOCABULARY — see `isResourceKeyShape` for why a
|
|
65
|
+
* vocabulary-strict gate here would reject whole frames on version skew.
|
|
66
|
+
*
|
|
67
|
+
* `v` and `timestamp` are OPTIONAL HERE AND NOWHERE ELSE. The frame already on
|
|
68
|
+
* the wire carries neither; requiring them would make promotion a breaking
|
|
69
|
+
* change dressed up as a tidy-up. Same carve-out, same reason, as
|
|
70
|
+
* `CompanyMdCollabConnectedEventSchema`.
|
|
71
|
+
*/
|
|
72
|
+
export const ResourceInvalidatedEventSchema = UserEventBaseSchema.partial({
|
|
73
|
+
v: true,
|
|
74
|
+
timestamp: true,
|
|
75
|
+
}).extend({
|
|
76
|
+
type: z.literal("resource.invalidated"),
|
|
77
|
+
keys: z
|
|
78
|
+
.array(z.custom<ResourceKey>(isResourceKeyShape))
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(MAX_INVALIDATION_KEYS),
|
|
81
|
+
/** Correlates the frame with the mutation that caused it. */
|
|
82
|
+
traceId: z.string().optional(),
|
|
83
|
+
/**
|
|
84
|
+
* Entity version — MUST be the entity's own `updatedAt`/`resolvedAt` as epoch
|
|
85
|
+
* ms, NEVER request time. The client's version gate compares it against the
|
|
86
|
+
* cached payload's `version`; a request-time value silently re-opens the race
|
|
87
|
+
* the gate exists to close. (The invariant the backend module already states,
|
|
88
|
+
* published here so it binds the wire rather than one emitter.)
|
|
89
|
+
*/
|
|
90
|
+
version: z.number().int().nonnegative(),
|
|
91
|
+
});
|
|
92
|
+
export type ResourceInvalidatedEvent = z.infer<
|
|
93
|
+
typeof ResourceInvalidatedEventSchema
|
|
94
|
+
>;
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// resync — freshness when precision is unavailable
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why the server could not name what changed.
|
|
102
|
+
*
|
|
103
|
+
* - `listen-recovered` — the LISTEN connection was down and frames were missed.
|
|
104
|
+
* - `payload-over-cap` — a single frame exceeded the NOTIFY payload limit.
|
|
105
|
+
*/
|
|
106
|
+
export const USER_EVENT_RESYNC_REASONS = [
|
|
107
|
+
"listen-recovered",
|
|
108
|
+
"payload-over-cap",
|
|
109
|
+
] as const;
|
|
110
|
+
export const UserEventResyncReasonSchema = z.enum(USER_EVENT_RESYNC_REASONS);
|
|
111
|
+
export type UserEventResyncReason = z.infer<typeof UserEventResyncReasonSchema>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* "I could not tell you precisely what changed; re-read everything."
|
|
115
|
+
*
|
|
116
|
+
* The client invalidates every resource it subscribes to. One frame type covers
|
|
117
|
+
* both reasons because they mean the same thing to a client, and because the
|
|
118
|
+
* alternative in each case is worse:
|
|
119
|
+
*
|
|
120
|
+
* - Truncating an over-cap frame yields a SHORTENED `keys` array — a stale
|
|
121
|
+
* cache that looks fresh, which is undetectable downstream.
|
|
122
|
+
* - Dropping it instead leaves that user stale until their next reconnect,
|
|
123
|
+
* which may be hours away.
|
|
124
|
+
*
|
|
125
|
+
* **Precision is expendable; freshness is not.** A resync costs one refetch
|
|
126
|
+
* wave and is always correct.
|
|
127
|
+
*/
|
|
128
|
+
export const UserEventResyncSchema = UserEventBaseSchema.partial({
|
|
129
|
+
v: true,
|
|
130
|
+
timestamp: true,
|
|
131
|
+
}).extend({
|
|
132
|
+
type: z.literal("resync"),
|
|
133
|
+
reason: UserEventResyncReasonSchema,
|
|
134
|
+
});
|
|
135
|
+
export type UserEventResync = z.infer<typeof UserEventResyncSchema>;
|
|
136
|
+
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// notification.created — an ACCELERATOR, never the source of truth
|
|
139
|
+
// =============================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A durable notification row was written for this user.
|
|
143
|
+
*
|
|
144
|
+
* AN ACCELERATOR ONLY. The row is already committed and the inbox
|
|
145
|
+
* (`GET /api/me/feed`) is the authority; this frame exists so a connected
|
|
146
|
+
* client sees it without waiting for a refetch. A client that misses it loses
|
|
147
|
+
* nothing — the row is still there on the next read, which is exactly why this
|
|
148
|
+
* stream is allowed to stay best-effort.
|
|
149
|
+
*
|
|
150
|
+
* That is also why it carries only the id and the unread count, not the
|
|
151
|
+
* rendered notification. Rendering happens at READ TIME under the reader's
|
|
152
|
+
* current authority (see `UserNotificationSchema`); a pre-rendered payload on
|
|
153
|
+
* the wire would be a snapshot of authority taken at write time, which is the
|
|
154
|
+
* one thing that must not be cached. It would also put entity content on a
|
|
155
|
+
* `pg_notify` payload, which the transport forbids for its own reasons.
|
|
156
|
+
*
|
|
157
|
+
* There is deliberately NO `notification.read` counterpart. Read state is a
|
|
158
|
+
* mutable column on a row whose id was allocated at creation, so a read event
|
|
159
|
+
* has no valid position in a stream ordered by anything — it would either move
|
|
160
|
+
* an id backwards or reuse one already observed. Read state is reconciled from
|
|
161
|
+
* the authoritative query instead.
|
|
162
|
+
*/
|
|
163
|
+
export const NotificationCreatedEventSchema = UserEventBaseSchema.partial({
|
|
164
|
+
v: true,
|
|
165
|
+
timestamp: true,
|
|
166
|
+
}).extend({
|
|
167
|
+
type: z.literal("notification.created"),
|
|
168
|
+
/** The row id, so a client that already has it can dedupe. */
|
|
169
|
+
notificationId: z.string(),
|
|
170
|
+
/** Total unread after this write, so the badge updates without a fetch. */
|
|
171
|
+
unreadCount: z.number().int().nonnegative(),
|
|
172
|
+
});
|
|
173
|
+
export type NotificationCreatedEvent = z.infer<
|
|
174
|
+
typeof NotificationCreatedEventSchema
|
|
175
|
+
>;
|
|
176
|
+
|
|
177
|
+
// =============================================================================
|
|
178
|
+
// connected — transport-level
|
|
179
|
+
// =============================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The stream is open.
|
|
183
|
+
*
|
|
184
|
+
* Modeled for the same reason `CompanyMdCollabConnectedEventSchema` is: without
|
|
185
|
+
* it, a client that validates EVERY frame logs a parse error on a perfectly
|
|
186
|
+
* normal connect.
|
|
187
|
+
*/
|
|
188
|
+
export const UserEventConnectedSchema = UserEventBaseSchema.partial({
|
|
189
|
+
v: true,
|
|
190
|
+
timestamp: true,
|
|
191
|
+
}).extend({ type: z.literal("connected") });
|
|
192
|
+
export type UserEventConnected = z.infer<typeof UserEventConnectedSchema>;
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// The union
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every frame the user-scoped stream emits.
|
|
200
|
+
*
|
|
201
|
+
* Registered as the OpenAPI component `UserSseEvent`, per the `ChatSseEvent` /
|
|
202
|
+
* `ExecutionSseEvent` / `CompanyMdCollabSseEvent` precedent.
|
|
203
|
+
*
|
|
204
|
+
* `server_drain` is deliberately NOT modeled: the stream already emits it under
|
|
205
|
+
* its own SSE `event:` name and the client binds a dedicated out-of-band
|
|
206
|
+
* listener for it. Adding it here would create two handling paths for one frame.
|
|
207
|
+
*/
|
|
208
|
+
export const UserSseEventSchema = z.discriminatedUnion("type", [
|
|
209
|
+
ResourceInvalidatedEventSchema,
|
|
210
|
+
NotificationCreatedEventSchema,
|
|
211
|
+
UserEventResyncSchema,
|
|
212
|
+
UserEventConnectedSchema,
|
|
213
|
+
]);
|
|
214
|
+
export type UserSseEvent = z.infer<typeof UserSseEventSchema>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# user-notifications/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The published shapes of the **durable in-app inbox** — informational facts
|
|
6
|
+
addressed to one user, which they can only look at — and of the **merged feed**
|
|
7
|
+
that renders them alongside action items at the top of `/me/work`.
|
|
8
|
+
|
|
9
|
+
## Invariants
|
|
10
|
+
|
|
11
|
+
- **A stored row holds IDENTIFIERS ONLY**: `kind` plus a `ref` of ids. No
|
|
12
|
+
titles, no paths, no names, no bodies. Everything in `UserNotificationSchema`
|
|
13
|
+
that reads like prose is produced at READ TIME by the producing domain's
|
|
14
|
+
renderer, under the reader's CURRENT authority.
|
|
15
|
+
- **A stored row therefore never contains protected content**, so a stale row
|
|
16
|
+
cannot leak one. This is the invariant the whole design turns on: it is what
|
|
17
|
+
makes copy evolution a renderer change rather than a migration, and what makes
|
|
18
|
+
revocation a re-check rather than a backfill.
|
|
19
|
+
- **`href` is nullable and a tombstone is a legitimate render.** A reader who
|
|
20
|
+
lost access, or whose client cannot decode the stored `ref_version`, still
|
|
21
|
+
sees that the thing happened — it simply does not link anywhere. "You were
|
|
22
|
+
granted access to something you can no longer see" is true and worth saying.
|
|
23
|
+
- **Feed ordering is grouped, not chronologically interleaved.** Actionable rows
|
|
24
|
+
first, then informational rows newest-first. "Merged feed" names one visual
|
|
25
|
+
surface, not one timeline.
|
|
26
|
+
- **Only notifications paginate.** Action items are already bounded and are
|
|
27
|
+
standing state, not a timeline. Page 1 = all action items + the first page of
|
|
28
|
+
notifications; every later page is notifications only. Paginating two
|
|
29
|
+
independent sources against one cursor is where merged feeds go wrong.
|
|
30
|
+
- **`unreadCount` is total, never windowed.** A windowed count under-reports the
|
|
31
|
+
moment the inbox exceeds one page, and it drives a badge whose whole job is to
|
|
32
|
+
be trusted.
|
|
33
|
+
- **The cursor encodes `(createdAt, id)`.** The id breaks ties so two rows
|
|
34
|
+
written in one transaction cannot straddle a page boundary and be served twice
|
|
35
|
+
or skipped.
|
|
36
|
+
|
|
37
|
+
## Public API
|
|
38
|
+
|
|
39
|
+
| Export | Description |
|
|
40
|
+
| --------------------------- | -------------------------------------------------------------- |
|
|
41
|
+
| `UserNotificationSchema` | One informational row, as rendered |
|
|
42
|
+
| `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
|
|
43
|
+
| `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
|
|
44
|
+
| `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, unreadCount, truncated |
|
|
45
|
+
| `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` |
|
|
46
|
+
|
|
47
|
+
## Dependencies
|
|
48
|
+
|
|
49
|
+
- `zod` — schemas are canonical, types are inferred.
|
|
50
|
+
- `../action-items` — `ActionItemSchema`, `ActionItemTargetSchema`. The feed
|
|
51
|
+
UNIONS with action items; it does not redefine them, and it does not modify
|
|
52
|
+
them.
|
|
53
|
+
|
|
54
|
+
## How this differs from the two vocabularies beside it
|
|
55
|
+
|
|
56
|
+
Three things in this package look like "a notification". ADR-CONT-104 forbids
|
|
57
|
+
collapsing the first two; this adds a third rather than bending either:
|
|
58
|
+
|
|
59
|
+
- **`NotificationKind`** (`../notifications`) names a message that **leaves the
|
|
60
|
+
building** — composed into prose, delivered once to an address, lifecycle over
|
|
61
|
+
at send.
|
|
62
|
+
- **`ActionItemKind`** (`../action-items`) names **standing state** — a decision
|
|
63
|
+
you owe, re-derived on every read, gone the moment anyone resolves it. Never
|
|
64
|
+
stored, no read state.
|
|
65
|
+
- **`UserNotificationKind`** names a **stored fact about the past**, addressed to
|
|
66
|
+
one person, that they can only look at. `action-items/kinds.ts` named this gap
|
|
67
|
+
itself: _"a kind whose item cannot be acted on is not an action item — that is
|
|
68
|
+
the informational feed, which this layer does not model (yet)."_
|
|
69
|
+
|
|
70
|
+
They are not in bijection. `companyMd.access_request_pending` is an action item
|
|
71
|
+
and deliberately has **no** member here: the owner can resolve it, so modelling
|
|
72
|
+
it in both places would give one fact two lifecycles that could disagree.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# user-notifications/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONT-108 make that the compiler cannot.
|
|
6
|
+
|
|
7
|
+
- `vocabulary.test.ts` — four things a type signature does not say:
|
|
8
|
+
1. **The three vocabularies stay disjoint.** `UserNotificationKind`,
|
|
9
|
+
`ActionItemKind` and `NotificationKind` overlap in subject matter and look
|
|
10
|
+
mergeable; they are not, because a notification's lifecycle ends at send, an
|
|
11
|
+
action item's ends when anyone resolves it, and one of these ends never.
|
|
12
|
+
Specifically: `companyMd.access_request_pending` must NOT appear here — it
|
|
13
|
+
is resolvable, so modelling it in both places would give one fact two
|
|
14
|
+
lifecycles that could disagree.
|
|
15
|
+
2. **A tombstone parses.** `href: null` is a legitimate render, not a
|
|
16
|
+
degenerate one: a reader who lost access still learns the thing happened.
|
|
17
|
+
If this stops parsing, revocation silently becomes an error path.
|
|
18
|
+
3. **`FeedItem` discriminates on `row`, not on field-sniffing.** A client must
|
|
19
|
+
never have to infer "informational" from the presence of `readAt`.
|
|
20
|
+
4. **The response carries no field named `version`.** The app's
|
|
21
|
+
`invalidateResource` probes cached payloads for that name structurally and
|
|
22
|
+
SKIPS the invalidation when the cached value is at least as new — the same
|
|
23
|
+
trap `action-items/__tests__` guards, and it would wedge the feed badge
|
|
24
|
+
with no error anywhere.
|
|
25
|
+
|
|
26
|
+
## Invariants
|
|
27
|
+
|
|
28
|
+
- These assert VOCABULARY and SHAPE, never behaviour. Anything needing a
|
|
29
|
+
renderer, a database or an authority gate belongs in backend's
|
|
30
|
+
`src/user-notifications/__tests__/`.
|
|
31
|
+
- The disjointness test reads the real kind arrays rather than restating them. A
|
|
32
|
+
hand-copied list drifts and starts passing vacuously.
|
|
33
|
+
- Negative cases mutate ONE field of a well-formed fixture. A hand-built 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 modules under test, and `../../action-items` /
|
|
43
|
+
`../../notifications` for the real kind lists.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox vocabulary's invariants, as tests rather than prose (ADR-CONT-108).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { USER_NOTIFICATION_KINDS } from "../kinds.js";
|
|
6
|
+
import {
|
|
7
|
+
FeedItemSchema,
|
|
8
|
+
FeedListResponseSchema,
|
|
9
|
+
UserNotificationSchema,
|
|
10
|
+
} from "../schemas.js";
|
|
11
|
+
import { ACTION_ITEM_KINDS } from "../../action-items/index.js";
|
|
12
|
+
import { NOTIFICATION_DEFINITIONS } from "../../notifications/index.js";
|
|
13
|
+
|
|
14
|
+
const ORG_DOC = "22222222-2222-4222-8222-222222222222";
|
|
15
|
+
|
|
16
|
+
function makeNotification(over: Record<string, unknown> = {}) {
|
|
17
|
+
return {
|
|
18
|
+
id: "n-1",
|
|
19
|
+
kind: "companyMd.access_request_approved",
|
|
20
|
+
target: { type: "company_md", id: ORG_DOC },
|
|
21
|
+
unitPath: "acme.sales",
|
|
22
|
+
title: "Dev Patel approved your request",
|
|
23
|
+
detail: null,
|
|
24
|
+
contextLabel: "Q3 OKRs",
|
|
25
|
+
href: "/@acme/md/doc-1",
|
|
26
|
+
createdAt: "2026-07-30T12:00:00.000Z",
|
|
27
|
+
readAt: null,
|
|
28
|
+
...over,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("the three vocabularies stay disjoint", () => {
|
|
33
|
+
it("shares no member with ActionItemKind", () => {
|
|
34
|
+
// They overlap in SUBJECT MATTER and look mergeable. They are not: an
|
|
35
|
+
// action item is standing state that ends when anyone resolves it; one of
|
|
36
|
+
// these is a stored fact that ends never.
|
|
37
|
+
const actionItems = new Set<string>(ACTION_ITEM_KINDS);
|
|
38
|
+
for (const kind of USER_NOTIFICATION_KINDS) {
|
|
39
|
+
expect(actionItems.has(kind)).toBe(false);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does NOT model the pending request, which is resolvable", () => {
|
|
44
|
+
// The load-bearing case. `companyMd.access_request_pending` is an ACTION
|
|
45
|
+
// ITEM — the owner can settle it. A durable twin would give one fact two
|
|
46
|
+
// lifecycles that could disagree: resolve the action item and the stored
|
|
47
|
+
// row would still claim something is pending.
|
|
48
|
+
expect(USER_NOTIFICATION_KINDS).not.toContain(
|
|
49
|
+
"companyMd.access_request_pending",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("names kinds in {domain}.{type} form, matching both siblings", () => {
|
|
54
|
+
for (const kind of USER_NOTIFICATION_KINDS) {
|
|
55
|
+
expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("overlaps NotificationKind by NAME without inheriting its lifecycle", () => {
|
|
60
|
+
// Reading the real definitions rather than restating them, so this cannot
|
|
61
|
+
// pass vacuously. An overlap here is EXPECTED and fine — the email and the
|
|
62
|
+
// stored row are two different things about one event. What must not happen
|
|
63
|
+
// is one union being derived from the other.
|
|
64
|
+
const sent = new Set(Object.keys(NOTIFICATION_DEFINITIONS));
|
|
65
|
+
const shared = USER_NOTIFICATION_KINDS.filter((k) => sent.has(k));
|
|
66
|
+
expect(shared.length).toBeGreaterThan(0);
|
|
67
|
+
expect(USER_NOTIFICATION_KINDS.length).not.toBe(sent.size);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("UserNotificationSchema", () => {
|
|
72
|
+
it("parses a well-formed row", () => {
|
|
73
|
+
expect(UserNotificationSchema.safeParse(makeNotification()).success).toBe(
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("parses a TOMBSTONE — href null is a legitimate render", () => {
|
|
79
|
+
// A reader who lost access, or whose client cannot decode the stored
|
|
80
|
+
// ref_version, still learns the thing happened. If this stops parsing,
|
|
81
|
+
// revocation silently becomes an error path instead of a render.
|
|
82
|
+
expect(
|
|
83
|
+
UserNotificationSchema.safeParse(makeNotification({ href: null }))
|
|
84
|
+
.success,
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("carries read state, unlike an action item", () => {
|
|
89
|
+
const read = UserNotificationSchema.parse(
|
|
90
|
+
makeNotification({ readAt: "2026-07-30T13:00:00.000Z" }),
|
|
91
|
+
);
|
|
92
|
+
expect(read.readAt).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects an unknown kind", () => {
|
|
96
|
+
expect(
|
|
97
|
+
UserNotificationSchema.safeParse(makeNotification({ kind: "made.up" }))
|
|
98
|
+
.success,
|
|
99
|
+
).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("FeedItem discriminates on `row`", () => {
|
|
104
|
+
it("accepts an informational row", () => {
|
|
105
|
+
const parsed = FeedItemSchema.safeParse({
|
|
106
|
+
row: "informational",
|
|
107
|
+
notification: makeNotification(),
|
|
108
|
+
});
|
|
109
|
+
expect(parsed.success).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("accepts an actionable row", () => {
|
|
113
|
+
const parsed = FeedItemSchema.safeParse({
|
|
114
|
+
row: "actionable",
|
|
115
|
+
item: {
|
|
116
|
+
id: "companyMd.access_request_pending:req-1",
|
|
117
|
+
kind: "companyMd.access_request_pending",
|
|
118
|
+
target: { type: "company_md", id: ORG_DOC },
|
|
119
|
+
unitPath: null,
|
|
120
|
+
title: "Maya Chen wants access",
|
|
121
|
+
detail: null,
|
|
122
|
+
contextLabel: "Q3 OKRs",
|
|
123
|
+
href: "/@acme/md/doc-1?share=1&request=req-1",
|
|
124
|
+
createdAt: "2026-07-30T12:00:00.000Z",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
expect(parsed.success).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("cannot be satisfied by field-sniffing — the tag is required", () => {
|
|
131
|
+
// A client must never infer "informational" from the presence of `readAt`.
|
|
132
|
+
expect(
|
|
133
|
+
FeedItemSchema.safeParse({ notification: makeNotification() }).success,
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("FeedListResponse", () => {
|
|
139
|
+
const base = {
|
|
140
|
+
items: [],
|
|
141
|
+
nextCursor: null,
|
|
142
|
+
unreadCount: 0,
|
|
143
|
+
truncated: false,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
it("parses an empty feed", () => {
|
|
147
|
+
expect(FeedListResponseSchema.safeParse(base).success).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("carries a cursor for the next page of informational rows", () => {
|
|
151
|
+
expect(
|
|
152
|
+
FeedListResponseSchema.safeParse({ ...base, nextCursor: "opaque" })
|
|
153
|
+
.success,
|
|
154
|
+
).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("has NO field named `version`", () => {
|
|
158
|
+
// The app's invalidateResource probes cached payloads for that name
|
|
159
|
+
// structurally and SKIPS the invalidation when the cached value is at least
|
|
160
|
+
// as new. A field of that name here would wedge the feed badge with no
|
|
161
|
+
// error anywhere. Same trap `action-items/__tests__` guards.
|
|
162
|
+
expect(Object.keys(FeedListResponseSchema.shape)).not.toContain("version");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects a negative unread count", () => {
|
|
166
|
+
expect(
|
|
167
|
+
FeedListResponseSchema.safeParse({ ...base, unreadCount: -1 }).success,
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-notifications/ — the durable in-app inbox, and the merged feed that
|
|
3
|
+
* renders it alongside action items.
|
|
4
|
+
*
|
|
5
|
+
* See ./README.md for the domain, and ADR-CONT-108 for why this is a third
|
|
6
|
+
* vocabulary rather than a bend in either of the two beside it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { USER_NOTIFICATION_KINDS } from "./kinds";
|
|
10
|
+
export type { UserNotificationKind } from "./kinds";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
FeedItemSchema,
|
|
14
|
+
FeedListResponseSchema,
|
|
15
|
+
FeedMarkReadRequestSchema,
|
|
16
|
+
UserNotificationKindSchema,
|
|
17
|
+
UserNotificationSchema,
|
|
18
|
+
} from "./schemas";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
FeedItem,
|
|
22
|
+
FeedListResponse,
|
|
23
|
+
FeedMarkReadRequest,
|
|
24
|
+
UserNotification,
|
|
25
|
+
} from "./schemas";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What kinds of durable in-app notification exist.
|
|
3
|
+
*
|
|
4
|
+
* A user notification is an INFORMATIONAL row addressed to one user: something
|
|
5
|
+
* that happened which they should know about and cannot act on. It is stored,
|
|
6
|
+
* it has read state, and it survives the user being offline.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS IS A THIRD VOCABULARY, alongside `NotificationKind` and
|
|
9
|
+
* `ActionItemKind` (ADR-CONT-104 forbids collapsing those two; this adds a
|
|
10
|
+
* third rather than bending either):
|
|
11
|
+
*
|
|
12
|
+
* - `NotificationKind` names a message that LEAVES THE BUILDING — an email or a
|
|
13
|
+
* Slack post, composed by a render pipeline into prose carrying a brand and a
|
|
14
|
+
* year, delivered once to an address. Its lifecycle ends at send.
|
|
15
|
+
* - `ActionItemKind` names STANDING STATE — a decision you owe, re-derived from
|
|
16
|
+
* its domain's pending rows on every read, which stops existing the moment
|
|
17
|
+
* anyone resolves it. It is never stored and has no read state, because
|
|
18
|
+
* "resolved" is the only clearing mechanism it needs.
|
|
19
|
+
* - A user notification is neither. It is a FACT about the past, addressed to
|
|
20
|
+
* one person, which they can only look at. `action-items/kinds.ts` already
|
|
21
|
+
* named this gap: "a kind whose item cannot be acted on is not an action item
|
|
22
|
+
* — that is the informational feed, which this layer does not model (yet)."
|
|
23
|
+
* This is that layer.
|
|
24
|
+
*
|
|
25
|
+
* The three are not in bijection and never will be. `auth.otp` is a
|
|
26
|
+
* notification and can be neither of the others. `execution.confirmation_pending`
|
|
27
|
+
* is an action item with no notification. `companyMd.access_request_approved` is
|
|
28
|
+
* all three shapes' subject matter and gets a member in each, because the email,
|
|
29
|
+
* the (now absent) pending decision, and the durable "you were granted access"
|
|
30
|
+
* row are three different things with three different lifecycles.
|
|
31
|
+
*
|
|
32
|
+
* INVARIANTS:
|
|
33
|
+
* - `{domain}.{type}` dot notation, matching both sibling unions.
|
|
34
|
+
* - These strings go on the wire AND into a database column. Renaming one is a
|
|
35
|
+
* migration, not a tidy-up.
|
|
36
|
+
* - A kind earns membership by being something a user should be able to come
|
|
37
|
+
* back to. If it is only worth seeing while connected, it is a transient
|
|
38
|
+
* frame on the user-event stream, not a row here.
|
|
39
|
+
* - A kind's row stores IDENTIFIERS ONLY (see `./schemas`). Copy lives in the
|
|
40
|
+
* producing domain's renderer and is resolved at read time.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// UserNotificationKind Union
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* New kinds MUST be added to:
|
|
49
|
+
* 1. This union
|
|
50
|
+
* 2. A `NotificationRenderer` in the PRODUCING domain, which applies that
|
|
51
|
+
* domain's own authority gate in bulk and returns a tombstone when the
|
|
52
|
+
* reader may no longer see the referenced entity
|
|
53
|
+
*
|
|
54
|
+
* The three members below are the informational half of the company.md
|
|
55
|
+
* request-access lifecycle. Note what is NOT here:
|
|
56
|
+
* `companyMd.access_request_pending` is an ACTION ITEM — the owner can resolve
|
|
57
|
+
* it, so it is standing state, and modelling it here as well would give one
|
|
58
|
+
* fact two lifecycles that could disagree.
|
|
59
|
+
*/
|
|
60
|
+
export const USER_NOTIFICATION_KINDS = [
|
|
61
|
+
/** Someone asked for access to a doc you own. Informational twin of the action item. */
|
|
62
|
+
"companyMd.access_requested",
|
|
63
|
+
/** Your request was approved — you now have access. */
|
|
64
|
+
"companyMd.access_request_approved",
|
|
65
|
+
/** Your request was declined. */
|
|
66
|
+
"companyMd.access_request_denied",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];
|