@startsimpli/ui 0.4.136 → 0.4.137

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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The record-version precondition, client side — "send the version on every
3
+ * write" (bd startsim-jkkn7.3; server bd startsim-3c2wc; design bd
4
+ * startsim-j4kx6 §6).
5
+ *
6
+ * Pure and React-free on purpose, like `revision-history.ts` beside it: the
7
+ * rules below are the half that decides whether a save is guarded at all, and
8
+ * they are worth testing without a DOM. {@link useConditionalSave} is the
9
+ * stateful half and {@link StaleSaveDialog} is what a person sees.
10
+ *
11
+ * ── THE POSTURE, AND WHY A MISSING VERSION MUST STILL SAVE ──────────────────
12
+ *
13
+ * The server is ENFORCED WHEN PRESENT, OPTIONAL WHEN ABSENT, and that is not a
14
+ * tolerance to be tidied up later — it is the only posture that could be rolled
15
+ * to seven live tenants in one merge, because every caller in production sends
16
+ * no version. So every function here fails toward "send nothing and let the
17
+ * write through", NEVER toward "refuse to save because we could not work out a
18
+ * version". A conflict guard that can make a save impossible is a worse bug
19
+ * than the overwrite it prevents.
20
+ *
21
+ * ── WHERE THE VERSION COMES FROM, AND THE ONE THE CONTRACT GETS WRONG ───────
22
+ *
23
+ * Three exposures exist server-side: the read-only `version` field, the `ETag`
24
+ * on the detail read, and the `ETag` on the WRITE response — the last being what
25
+ * lets a 1,200 ms autosave guard itself without a GET between every save.
26
+ *
27
+ * BUT THE WRITE RESPONSE'S HEADER IS NOT RELIABLY READABLE IN A BROWSER, and
28
+ * this module is the place that has to know it. `ETag` is not a CORS-safelisted
29
+ * response header, `tenant-starter/config/settings/base.py` sets
30
+ * `CORS_ALLOWED_ORIGINS` and `CORS_ALLOW_CREDENTIALS` and NO
31
+ * `CORS_EXPOSE_HEADERS`, and django-cors-headers defaults that to `[]`. So a
32
+ * fork served from its own origin reads the header fine and any cross-origin
33
+ * caller reads `null` — silently, and only on the write path, which is the one
34
+ * place a missing validator turns the guard off.
35
+ *
36
+ * The fix costs nothing, because the server stamps the header FROM the body:
37
+ * `EntityViewSet._offer_etag` reads `response.data["version"]`, so ETag-present
38
+ * and body-`version`-present are the same condition and the two can never
39
+ * disagree. {@link versionFromWriteOutcome} therefore reads the BODY FIRST and
40
+ * falls back to the header. Body-first is strictly more robust, and the
41
+ * server's own docstring says a client "may read either".
42
+ */
43
+ import type { EntityRevision } from './types';
44
+
45
+ /** The header spelling. Whatever a read emitted as `ETag`, sent back. */
46
+ export const PRECONDITION_HEADER = 'If-Match';
47
+
48
+ /** The body spelling, for callers behind a proxy that strips `If-Match`. It is
49
+ * a TOP-LEVEL field and must never be folded into `data` — inside the blob it
50
+ * stops being a precondition and starts being stored on the record. */
51
+ export const PRECONDITION_BODY_FIELD = 'expected_version';
52
+
53
+ /** The refusal. 412 and not 409: the write is well-formed and would be legal,
54
+ * and the only thing wrong with it is the validator the caller supplied. */
55
+ export const STALE_SAVE_STATUS = 412;
56
+
57
+ /** Which spelling to send. `header` by default — it leaves the request body
58
+ * completely untouched, so there is no path by which a precondition can end up
59
+ * written onto the record. */
60
+ export type PreconditionSpelling = 'header' | 'body';
61
+
62
+ /** Anything we can read a response header out of: a real `Headers`, or the
63
+ * plain object an API wrapper hands back. */
64
+ export interface HeaderReader {
65
+ get(name: string): string | null | undefined;
66
+ }
67
+
68
+ export type ResponseHeaders =
69
+ | HeaderReader
70
+ | Record<string, string | null | undefined>;
71
+
72
+ /**
73
+ * An integer version, and ONLY an integer.
74
+ *
75
+ * `version` IS LEGITIMATELY 0 — every record that predates the revision trail
76
+ * reads 0, and 0 is a version the server accepts back — so `if (version)` is a
77
+ * bug that silently unguards exactly the oldest records. Booleans are excluded
78
+ * explicitly because `Number.isInteger` already rejects them but a reader
79
+ * should not have to check.
80
+ */
81
+ export function isVersion(value: unknown): value is number {
82
+ return typeof value === 'number' && Number.isInteger(value);
83
+ }
84
+
85
+ /** Coerce what a JSON client might hand us. A numeric STRING is accepted
86
+ * because clients stringify integers routinely and the server honours one; a
87
+ * bool is not, because `expected_version: true` is not a version anybody
88
+ * meant. */
89
+ export function toVersion(value: unknown): number | undefined {
90
+ if (isVersion(value)) return value;
91
+ if (typeof value === 'string') {
92
+ const trimmed = value.trim();
93
+ if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /** The strong validator for a row at `version` — quoted, no `W/`, matching
99
+ * `apps/api/preconditions.etag`. RFC 9110 §13.1.1 requires STRONG comparison
100
+ * for `If-Match`, so a weak tag could not legally do this job. */
101
+ export function etagFor(version: number): string {
102
+ return `"${version}"`;
103
+ }
104
+
105
+ function headerValue(headers: ResponseHeaders | undefined, name: string): string | undefined {
106
+ if (!headers) return undefined;
107
+ const reader = headers as HeaderReader;
108
+ if (typeof reader.get === 'function') {
109
+ const found = reader.get(name);
110
+ return typeof found === 'string' ? found : undefined;
111
+ }
112
+ // A plain record, whose casing we do not control.
113
+ const lower = name.toLowerCase();
114
+ for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
115
+ if (key.toLowerCase() === lower && typeof value === 'string') return value;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * The version an `ETag` names, or `undefined` when it names none.
122
+ *
123
+ * `*` RETURNS UNDEFINED RATHER THAN A MATCH, which mirrors the server: it
124
+ * asserts only that a current representation exists, so it is no guard against
125
+ * a lost update at all, and sending it back would record `precondition: none`
126
+ * on the trail for a write the client believed it had checked. A list is parsed
127
+ * (RFC 9110 allows `"1", "3"`) and a `W/` prefix is tolerated, because tolerating
128
+ * what we will never emit costs one line and a mystery 412 costs an afternoon.
129
+ */
130
+ export function versionFromEtag(raw: unknown): number | undefined {
131
+ if (typeof raw !== 'string' || !raw.trim()) return undefined;
132
+ for (const part of raw.split(',')) {
133
+ let tag = part.trim();
134
+ if (tag.slice(0, 2).toUpperCase() === 'W/') tag = tag.slice(2).trim();
135
+ tag = tag.replace(/^"|"$/g, '').trim();
136
+ if (!tag || tag === '*') continue;
137
+ const version = toVersion(tag);
138
+ if (version !== undefined) return version;
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /** The version on a record payload — the read-only `version` field, which no
144
+ * case transform renames. */
145
+ export function versionFromRecord(record: unknown): number | undefined {
146
+ if (!record || typeof record !== 'object') return undefined;
147
+ return toVersion((record as { version?: unknown }).version);
148
+ }
149
+
150
+ /**
151
+ * The version a WRITE just produced — the one that makes the next autosave free.
152
+ *
153
+ * Body first, header second. See this module's header for why: the body field is
154
+ * always readable and the header is not, and the server derives one from the
155
+ * other so they cannot disagree.
156
+ */
157
+ export function versionFromWriteOutcome(outcome: {
158
+ body?: unknown;
159
+ headers?: ResponseHeaders;
160
+ }): number | undefined {
161
+ const fromBody = versionFromRecord(outcome?.body);
162
+ if (fromBody !== undefined) return fromBody;
163
+ return versionFromEtag(headerValue(outcome?.headers, 'ETag'));
164
+ }
165
+
166
+ /** What a write should carry. Exactly one of the two is ever populated. */
167
+ export interface PreconditionParts {
168
+ headers: Record<string, string>;
169
+ /** Merge at the TOP LEVEL of the request body, never inside `data`. */
170
+ body: Record<string, number>;
171
+ /** Whether this actually asserts a version. False when no version was known —
172
+ * the write still goes, unguarded and visibly so (`precondition: "none"`). */
173
+ guards: boolean;
174
+ }
175
+
176
+ /**
177
+ * The precondition for a held version, in one spelling.
178
+ *
179
+ * NEVER BOTH SPELLINGS AT ONCE, and this is the subtle one. `preconditions.
180
+ * enforce` checks BOTH sources rather than the first it finds, and refuses if
181
+ * EITHER disagrees — deliberately, since a caller holding two beliefs about the
182
+ * row can be right about at most one. So a request carrying a stale `If-Match`
183
+ * beside a fresh `expected_version` refuses ITSELF, with a 412 that looks for
184
+ * all the world like a real conflict. One spelling per call.
185
+ */
186
+ export function preconditionFor(
187
+ version: number | undefined,
188
+ spelling: PreconditionSpelling = 'header',
189
+ ): PreconditionParts {
190
+ if (!isVersion(version)) return { headers: {}, body: {}, guards: false };
191
+ return spelling === 'body'
192
+ ? { headers: {}, body: { [PRECONDITION_BODY_FIELD]: version }, guards: true }
193
+ : { headers: { [PRECONDITION_HEADER]: etagFor(version) }, body: {}, guards: true };
194
+ }
195
+
196
+ /** What the server said when it refused. Both keys are optional: the dialog has
197
+ * to open and be useful on `current_version` alone, and on neither. */
198
+ export interface StaleSaveRefusal {
199
+ /** The version the record is at NOW. Retrying with exactly this value lands —
200
+ * which is why it must survive as an INTEGER and not as `"3"`. */
201
+ currentVersion?: number;
202
+ /** The server's sentence, which names both versions and is safe to show. */
203
+ detail?: string;
204
+ }
205
+
206
+ function statusOf(source: unknown): number | undefined {
207
+ if (!source || typeof source !== 'object') return undefined;
208
+ const candidate = source as {
209
+ status?: unknown;
210
+ statusCode?: unknown;
211
+ response?: { status?: unknown };
212
+ };
213
+ for (const raw of [candidate.status, candidate.statusCode, candidate.response?.status]) {
214
+ const value = toVersion(raw);
215
+ if (value !== undefined) return value;
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function bodyOf(source: unknown): unknown {
221
+ if (!source || typeof source !== 'object') return undefined;
222
+ const candidate = source as {
223
+ body?: unknown;
224
+ data?: unknown;
225
+ response?: { data?: unknown; body?: unknown };
226
+ };
227
+ return (
228
+ candidate.body ??
229
+ candidate.data ??
230
+ candidate.response?.data ??
231
+ candidate.response?.body
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Read a 412 out of an outcome OR a thrown error, or `undefined` when it is not
237
+ * one.
238
+ *
239
+ * BOTH SHAPES, because the two client styles in this monorepo disagree about
240
+ * which a failure is: a plain `fetch` resolves with `{ status: 412 }` and a
241
+ * wrapper throws. A guard that only understood one would let the other fall
242
+ * through to a generic "save failed" — losing the one piece of data the whole
243
+ * recovery depends on.
244
+ *
245
+ * `currentVersion` IS READ IN BOTH SPELLINGS. The shared API client camelCases
246
+ * every response key recursively and has no preserve list, so it renames
247
+ * `current_version` to `currentVersion` on the ERROR body too — same trap this
248
+ * module's neighbours carry, and the error path is where it would be found last.
249
+ */
250
+ export function parseStaleSaveRefusal(source: unknown): StaleSaveRefusal | undefined {
251
+ if (statusOf(source) !== STALE_SAVE_STATUS) return undefined;
252
+ const body = bodyOf(source);
253
+ const record = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
254
+ const currentVersion = toVersion(record.current_version ?? record.currentVersion);
255
+ const detail = typeof record.detail === 'string' ? record.detail : undefined;
256
+ return {
257
+ ...(currentVersion !== undefined ? { currentVersion } : {}),
258
+ ...(detail ? { detail } : {}),
259
+ };
260
+ }
261
+
262
+ /** What `/api/v1/capabilities/` says about the guard. */
263
+ export interface ConditionalWriteCapability {
264
+ /** The build enforces a precondition it is handed. */
265
+ conditionalWrites: boolean;
266
+ /** Whether a write MUST carry one. False everywhere today. */
267
+ preconditionRequired: boolean;
268
+ /** The guard does nothing on a type whose history has been silenced, and a
269
+ * client cannot tell which types those are — so UI copy must not promise
270
+ * protection per record. */
271
+ inertWithoutTypeHistory: boolean;
272
+ header: string;
273
+ bodyField: string;
274
+ }
275
+
276
+ /**
277
+ * The honest default for a build that has not answered: NOT GUARDED.
278
+ *
279
+ * A fork built before bd startsim-3c2wc accepts `expected_version` and silently
280
+ * ignores it, answering 200 either way — so a client cannot learn this from a
281
+ * write and must not assume it.
282
+ */
283
+ export const CONDITIONAL_WRITES_UNKNOWN: ConditionalWriteCapability = {
284
+ conditionalWrites: false,
285
+ preconditionRequired: false,
286
+ inertWithoutTypeHistory: false,
287
+ header: PRECONDITION_HEADER,
288
+ bodyField: PRECONDITION_BODY_FIELD,
289
+ };
290
+
291
+ /**
292
+ * Read the capabilities document, defensively and in either spelling.
293
+ *
294
+ * WHAT THIS IS FOR, AND WHAT IT IS NOT FOR. It decides what the UI may CLAIM,
295
+ * not whether to send a precondition. Sending one to an older build is inert,
296
+ * not harmful — unknown body fields are ignored and an unknown header is
297
+ * ignored — so gating the send on this would buy nothing and would turn a
298
+ * failed capability fetch into an unguarded save. Send always; promise only
299
+ * when this says `conditionalWrites`.
300
+ */
301
+ export function readConditionalWriteCapability(doc: unknown): ConditionalWriteCapability {
302
+ if (!doc || typeof doc !== 'object') return CONDITIONAL_WRITES_UNKNOWN;
303
+ const root = doc as Record<string, unknown>;
304
+ const entities = root.entities && typeof root.entities === 'object'
305
+ ? (root.entities as Record<string, unknown>)
306
+ : root;
307
+ const precondition =
308
+ entities.precondition && typeof entities.precondition === 'object'
309
+ ? (entities.precondition as Record<string, unknown>)
310
+ : {};
311
+ const flag = (...keys: string[]) =>
312
+ keys.some((key) => entities[key] === true || precondition[key] === true);
313
+ const text = (fallback: string, ...keys: string[]) => {
314
+ for (const key of keys) {
315
+ const value = precondition[key];
316
+ if (typeof value === 'string' && value) return value;
317
+ }
318
+ return fallback;
319
+ };
320
+ return {
321
+ conditionalWrites: flag('conditional_writes', 'conditionalWrites'),
322
+ preconditionRequired: flag('precondition_required', 'preconditionRequired'),
323
+ inertWithoutTypeHistory: flag('inert_without_type_history', 'inertWithoutTypeHistory'),
324
+ header: text(PRECONDITION_HEADER, 'header'),
325
+ bodyField: text(PRECONDITION_BODY_FIELD, 'body_field', 'bodyField'),
326
+ };
327
+ }
328
+
329
+ /** Row 0 of `/api/v1/entities/<id>/revisions/` is the NEWEST — the route orders
330
+ * `-version, -created_at` — so the newest row is who to name in the dialog.
331
+ * Asserted here rather than assumed at the call site, because an ordering flip
332
+ * would make the dialog confidently name whoever CREATED the record. */
333
+ export function newestRevision(rows: EntityRevision[] | undefined): EntityRevision | undefined {
334
+ if (!rows || rows.length === 0) return undefined;
335
+ return rows.reduce((newest, row) => {
336
+ const a = isVersion(row?.version) ? row.version : -1;
337
+ const b = isVersion(newest?.version) ? newest.version : -1;
338
+ return a > b ? row : newest;
339
+ });
340
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
- * @startsimpli/ui/history — the shared record history panel (bd startsim-g4rwo).
2
+ * @startsimpli/ui/history — the client half of record versions: the shared
3
+ * history panel (bd startsim-g4rwo) AND the conditional-write guard that keeps
4
+ * a reviewer's text when a stale save is refused (bd startsim-jkkn7.3).
3
5
  *
4
6
  * "Who edited what piece of the text and when", over the append-only revision
5
7
  * trail (bd startsim-o1qib). A page-level composer plus the pure helpers it
@@ -7,6 +9,13 @@
7
9
  * classNames rather than its own timeline, its own fold and its own idea of
8
10
  * what `{}` means (rule #9).
9
11
  *
12
+ * THE TWO HALVES SHIP FROM ONE SUBPATH DELIBERATELY. The conflict dialog reads
13
+ * the SAME trail through the SAME injected {@link RevisionClient} and names its
14
+ * actor through the SAME `ActorIdentity`, because the 412 carries no identity by
15
+ * design (bd startsim-j4kx6). A second subpath would be a second place for the
16
+ * three-state actor vocabulary to drift, and that vocabulary is the one thing in
17
+ * this epic that must not.
18
+ *
10
19
  * THE ONE THING A CONSUMER MUST NOT GET WRONG: `actorKind` is THREE-state, and
11
20
  * `unknown` is the common case for genuine human edits for up to 14 days after
12
21
  * rollout. Render it through this panel — or through `ActorIdentity` with
@@ -47,6 +56,60 @@ export type {
47
56
 
48
57
  export { diffLines, unifiedTextDiff } from './text-diff';
49
58
 
59
+ /* -------------------------------------------------------------------------- */
60
+ /* THE CONDITIONAL WRITE (bd startsim-jkkn7.3) */
61
+ /* */
62
+ /* Send the version on every write; on a 412 keep the reviewer's text, pause */
63
+ /* autosave and say who changed the record. The ONE contract a consumer must */
64
+ /* not break: never adopt `current_version` and retry on the client's own */
65
+ /* initiative — that is the overwrite the 412 just prevented. */
66
+ /* -------------------------------------------------------------------------- */
67
+
68
+ export { useConditionalSave } from './useConditionalSave';
69
+ export type {
70
+ ConditionalSave,
71
+ ConditionalSaveResult,
72
+ ConditionalWriteAttempt,
73
+ ConditionalWriteClient,
74
+ ConditionalWriteOutcome,
75
+ IdentityGap,
76
+ StaleSaveConflict,
77
+ UseConditionalSaveOptions,
78
+ } from './useConditionalSave';
79
+
80
+ export { StaleSaveDialog } from './StaleSaveDialog';
81
+ export type { StaleSaveDialogProps } from './StaleSaveDialog';
82
+
83
+ export {
84
+ STALE_SAVE_DIALOG_DEFAULTS,
85
+ type StaleSaveDialogClassNames,
86
+ } from './stale-save-dialog-default-class-names';
87
+
88
+ export {
89
+ CONDITIONAL_WRITES_UNKNOWN,
90
+ PRECONDITION_BODY_FIELD,
91
+ PRECONDITION_HEADER,
92
+ STALE_SAVE_STATUS,
93
+ etagFor,
94
+ isVersion,
95
+ newestRevision,
96
+ parseStaleSaveRefusal,
97
+ preconditionFor,
98
+ readConditionalWriteCapability,
99
+ toVersion,
100
+ versionFromEtag,
101
+ versionFromRecord,
102
+ versionFromWriteOutcome,
103
+ } from './conditional-write';
104
+ export type {
105
+ ConditionalWriteCapability,
106
+ HeaderReader,
107
+ PreconditionParts,
108
+ PreconditionSpelling,
109
+ ResponseHeaders,
110
+ StaleSaveRefusal,
111
+ } from './conditional-write';
112
+
50
113
  export type {
51
114
  ActorKind,
52
115
  ActorKindSource,
@@ -0,0 +1,45 @@
1
+ /** Default Tailwind classes for {@link StaleSaveDialog}. bd startsim-jkkn7.3.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe, and no
3
+ * literal colour, so a dark tenant theme recolours this without a fork. */
4
+
5
+ export interface StaleSaveDialogClassNames {
6
+ backdrop?: string;
7
+ dialog?: string;
8
+ header?: string;
9
+ title?: string;
10
+ /** The line that tells the reviewer their text is still here. */
11
+ reassurance?: string;
12
+ body?: string;
13
+ /** The server's sentence, and the "we could not say who" sentences. */
14
+ notice?: string;
15
+ fieldList?: string;
16
+ field?: string;
17
+ footer?: string;
18
+ primaryAction?: string;
19
+ secondaryAction?: string;
20
+ /** The one action that throws the reviewer's own text away. */
21
+ destructiveAction?: string;
22
+ }
23
+
24
+ const BUTTON = 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium disabled:opacity-50';
25
+
26
+ export const STALE_SAVE_DIALOG_DEFAULTS: Required<StaleSaveDialogClassNames> = {
27
+ backdrop: 'fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4',
28
+ dialog: 'w-full max-w-lg rounded-lg border border-border bg-card shadow-lg',
29
+ header: 'space-y-2 border-b border-border px-5 py-4',
30
+ title: 'flex flex-wrap items-center gap-x-1.5 gap-y-1 font-medium text-foreground',
31
+ // NOT a warning colour. The reviewer's work is safe; this sentence is the
32
+ // reassurance, and painting it like a failure would say the opposite.
33
+ reassurance: 'text-sm text-foreground',
34
+ body: 'space-y-3 px-5 py-4',
35
+ notice: 'text-xs text-muted-foreground',
36
+ fieldList: 'flex flex-wrap gap-1',
37
+ field: 'rounded bg-muted px-1.5 py-0.5 text-xs text-foreground',
38
+ footer: 'flex flex-wrap items-center justify-end gap-2 border-t border-border px-5 py-3',
39
+ primaryAction: `${BUTTON} bg-primary text-primary-foreground`,
40
+ secondaryAction: `${BUTTON} border border-border bg-background text-foreground`,
41
+ // Underplayed ON PURPOSE. It is the only control here that destroys the text
42
+ // this dialog exists to protect, so it must never be the thing a hurried
43
+ // reviewer clicks by muscle memory.
44
+ destructiveAction: 'text-xs text-muted-foreground underline underline-offset-2',
45
+ };