@startsimpli/ui 0.4.141 → 0.4.142

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.
Files changed (28) hide show
  1. package/package.json +3 -3
  2. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  3. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  4. package/src/components/activity/types.ts +10 -5
  5. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  6. package/src/components/collection/InlineReviewActions.tsx +3 -2
  7. package/src/components/collection/ReviewDrawer.tsx +35 -7
  8. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  9. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  10. package/src/components/collection/data.ts +16 -1
  11. package/src/components/collection/index.ts +2 -1
  12. package/src/components/collection/scoped-client.ts +4 -1
  13. package/src/components/collection/types.ts +19 -1
  14. package/src/components/history/FieldAttribution.tsx +308 -0
  15. package/src/components/history/RecordActivityPanel.tsx +344 -0
  16. package/src/components/history/RecordHistoryPanel.tsx +331 -15
  17. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  18. package/src/components/history/RevisionFields.tsx +69 -3
  19. package/src/components/history/StaleSaveDialog.tsx +28 -7
  20. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  21. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  22. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  23. package/src/components/history/__tests__/restore.test.ts +209 -0
  24. package/src/components/history/index.ts +72 -1
  25. package/src/components/history/restore.ts +295 -0
  26. package/src/components/history/revision-feed.ts +105 -0
  27. package/src/components/history/revision-history.ts +22 -0
  28. package/src/components/history/types.ts +153 -1
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Restore, the pure half (bd startsim-zkyk4; server bd startsim-ti1yv).
3
+ *
4
+ * `POST /entities/<id>/revisions/<N>/restore/` puts one field — or the whole
5
+ * record — back to how it stood AFTER revision N, as an ordinary new write. This
6
+ * module answers the two questions the panel has to answer before and after that
7
+ * call, without React and without a backend:
8
+ *
9
+ * 1. WHAT WOULD CHANGE ({@link restorePreview}). The confirm dialog shows this,
10
+ * and it must agree with the server EXACTLY or the dialog promises one change
11
+ * and the server makes another. The server's rule (`_state_at` in
12
+ * tenant-starter/apps/api/revision_history.py): start from the record as it is
13
+ * now and undo, newest first, every edit after N — a key whose `before` is
14
+ * absent was created after N and is removed. That is a fold over exactly the
15
+ * rows NEWER than N, and the panel already holds them: the trail is read
16
+ * newest first, so any row the reader can see has every newer row loaded
17
+ * above it. Values come from the trail alone — `after` of the newest row that
18
+ * touched a field is its current value, because the chain is gapless.
19
+ *
20
+ * 2. WHAT THE SERVER SAID ({@link parseRestoreOutcome}) — resolved or thrown,
21
+ * snake or camel, with a sentence for every refusal the route can give.
22
+ */
23
+ import { parseStaleSaveRefusal, toVersion } from './conditional-write';
24
+ import { normalizeRevision, type FoldedFieldChange, type NormalizedRevision } from './revision-history';
25
+ import type { EntityRevision } from './types';
26
+
27
+ /** One side of a previewed value. `has: false` means the key is ABSENT — a
28
+ * different fact from a stored `null`. */
29
+ export interface RestoreSide {
30
+ has: boolean;
31
+ value?: unknown;
32
+ }
33
+
34
+ export interface RestoreFieldChange {
35
+ field: string;
36
+ /** What the field holds now. */
37
+ current: RestoreSide;
38
+ /** What the restore would put back. `has: false` = the key is removed. */
39
+ restored: RestoreSide;
40
+ }
41
+
42
+ export interface RestorePreview {
43
+ /** The version being restored to. */
44
+ target: number;
45
+ /** The newest version in the trail the preview was computed over. */
46
+ newest: number;
47
+ /** Fields that would move, in a stable order. Withheld and unknowable fields
48
+ * are NOT here — see {@link withheld} and {@link unknowable}. */
49
+ changes: RestoreFieldChange[];
50
+ /** The name column, on a whole-record restore that would move it. */
51
+ name?: Omit<RestoreFieldChange, 'field'>;
52
+ /** Nothing would change. The server answers a no-op with `revision: null`. */
53
+ noop: boolean;
54
+ /** Every row between N and the newest is loaded. When false, the preview is
55
+ * NOT shown as a confident answer — a hole would hide a change. */
56
+ complete: boolean;
57
+ /** A whole restore leaves these as they are: the reader may not see them. */
58
+ withheld: string[];
59
+ /** A truncated or omitted side after N: the value at N is not known exactly,
60
+ * and the server refuses with a 409. */
61
+ unknowable: string[];
62
+ }
63
+
64
+ function same(a: RestoreSide, b: RestoreSide): boolean {
65
+ if (a.has !== b.has) return false;
66
+ if (!a.has) return true;
67
+ if (a.value === b.value) return true;
68
+ try {
69
+ return JSON.stringify(a.value) === JSON.stringify(b.value);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function sideOf(change: FoldedFieldChange, which: 'before' | 'after'): RestoreSide {
76
+ return which === 'before'
77
+ ? change.hasBefore
78
+ ? { has: true, value: change.before }
79
+ : { has: false }
80
+ : change.hasAfter
81
+ ? { has: true, value: change.after }
82
+ : { has: false };
83
+ }
84
+
85
+ /**
86
+ * What a restore to version `target` would change, computed exactly as the
87
+ * server computes it. `fields` narrows to a field restore; omit it for the whole
88
+ * record (which also covers the name column).
89
+ *
90
+ * `revisions` is the trail as the route returns it — newest first — and may
91
+ * contain rows at or below `target`; only rows NEWER than it are folded.
92
+ */
93
+ export function restorePreview(
94
+ revisions: EntityRevision[],
95
+ target: number,
96
+ options: {
97
+ fields?: string[];
98
+ /** The rows came from a `?field=` read: they are that field's own trail, so
99
+ * version gaps between them are other fields' edits, not holes. Only
100
+ * meaningful with `fields`. */
101
+ narrowed?: boolean;
102
+ } = {},
103
+ ): RestorePreview {
104
+ const only = options.fields ? new Set(options.fields) : null;
105
+ const rows: NormalizedRevision[] = (revisions ?? [])
106
+ .map(normalizeRevision)
107
+ .sort((a, b) => b.version - a.version);
108
+ const newest = rows.length ? rows[0]!.version : target;
109
+ const later = rows.filter((r) => r.version > target);
110
+
111
+ // GAPLESS by construction server-side, so a count mismatch is a hole in what
112
+ // was LOADED (or a lost revision) — either way, not a preview to trust.
113
+ const versions = new Set(later.map((r) => r.version));
114
+ const complete =
115
+ (options.narrowed === true && !!only) || versions.size === Math.max(0, newest - target);
116
+
117
+ const current = new Map<string, RestoreSide>();
118
+ const restored = new Map<string, RestoreSide>();
119
+ const withheld = new Set<string>();
120
+ const unknowable = new Set<string>();
121
+ let nameCurrent: RestoreSide | undefined;
122
+ let nameRestored: RestoreSide | undefined;
123
+
124
+ // Newest first: the first row seen for a field gives its CURRENT value, and
125
+ // each older row overwrites what the restore would put back, so the last one
126
+ // standing is the oldest row after N — exactly the server's undo order.
127
+ for (const row of later) {
128
+ for (const [field, change] of Object.entries(row.changed)) {
129
+ if (only && !only.has(field)) continue;
130
+ if (change.withheld) {
131
+ withheld.add(field);
132
+ continue;
133
+ }
134
+ if (change.omitted || change.truncated) {
135
+ unknowable.add(field);
136
+ continue;
137
+ }
138
+ if (!current.has(field)) current.set(field, sideOf(change, 'after'));
139
+ restored.set(field, sideOf(change, 'before'));
140
+ }
141
+ if (!only) {
142
+ if (row.valuesOmitted) {
143
+ unknowable.add('name');
144
+ } else if (row.name) {
145
+ if (row.name.truncated) unknowable.add('name');
146
+ else {
147
+ if (!nameCurrent) nameCurrent = sideOf(row.name, 'after');
148
+ nameRestored = sideOf(row.name, 'before');
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ const changes: RestoreFieldChange[] = [];
155
+ for (const field of [...restored.keys()].sort()) {
156
+ if (unknowable.has(field) || withheld.has(field)) continue;
157
+ const c = current.get(field)!;
158
+ const r = restored.get(field)!;
159
+ if (!same(c, r)) changes.push({ field, current: c, restored: r });
160
+ }
161
+ const name =
162
+ nameCurrent && nameRestored && !same(nameCurrent, nameRestored)
163
+ ? { current: nameCurrent, restored: nameRestored }
164
+ : undefined;
165
+
166
+ const unknown = [...unknowable].filter((f) => !withheld.has(f)).sort();
167
+ return {
168
+ target,
169
+ newest,
170
+ changes,
171
+ ...(name ? { name } : {}),
172
+ noop: changes.length === 0 && !name && unknown.length === 0,
173
+ complete,
174
+ withheld: [...withheld].sort(),
175
+ unknowable: unknown,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Per field, the newest version that changed it, over the rows loaded. A field
181
+ * restore at version N is offered only where this is NEWER than N — on the row
182
+ * whose value IS the current one, a restore would change nothing.
183
+ */
184
+ export function latestFieldVersions(revisions: EntityRevision[]): Record<string, number> {
185
+ const out: Record<string, number> = {};
186
+ for (const row of (revisions ?? []).map(normalizeRevision)) {
187
+ for (const field of Object.keys(row.changed)) {
188
+ if ((out[field] ?? -1) < row.version) out[field] = row.version;
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /* -------------------------------------------------------------------------- */
195
+ /* READING THE ANSWER */
196
+ /* -------------------------------------------------------------------------- */
197
+
198
+ export interface RestoreSummary {
199
+ restoredFrom?: number;
200
+ /** The keys that actually moved. */
201
+ fields: string[];
202
+ /** The new version, or `null` when nothing changed (no revision written). */
203
+ revision: number | null;
204
+ }
205
+
206
+ export type ParsedRestoreOutcome =
207
+ | {
208
+ kind: 'restored';
209
+ /** The record as the server saved it — prime the host cache with this. */
210
+ record: Record<string, unknown>;
211
+ /** The record's version after the call (unchanged on a no-op). */
212
+ version?: number;
213
+ summary: RestoreSummary;
214
+ }
215
+ | { kind: 'stale'; currentVersion?: number; detail?: string }
216
+ | { kind: 'refused'; status?: number; message: string };
217
+
218
+ function statusOf(source: unknown): number | undefined {
219
+ if (!source || typeof source !== 'object') return undefined;
220
+ const c = source as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } };
221
+ for (const raw of [c.status, c.statusCode, c.response?.status]) {
222
+ const v = toVersion(raw);
223
+ if (v !== undefined) return v;
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ function bodyOf(source: unknown): Record<string, unknown> {
229
+ if (!source || typeof source !== 'object') return {};
230
+ const c = source as { body?: unknown; data?: unknown; response?: { data?: unknown; body?: unknown } };
231
+ const raw = c.body ?? c.data ?? c.response?.data ?? c.response?.body;
232
+ return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
233
+ }
234
+
235
+ /** DRF's error bodies: `{detail}`, or `{field: [msg]}` for a 400. */
236
+ function serverSentence(body: Record<string, unknown>): string | undefined {
237
+ if (typeof body.detail === 'string' && body.detail) return body.detail;
238
+ for (const value of Object.values(body)) {
239
+ if (typeof value === 'string' && value) return value;
240
+ if (Array.isArray(value)) {
241
+ const first = value.find((v) => typeof v === 'string' && v);
242
+ if (first) return first as string;
243
+ }
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ const FALLBACK: Record<number, string> = {
249
+ 400: 'This restore was refused — the field may no longer be declared on this type, or it would move the record out of its scope.',
250
+ 403: 'You do not have access to one of these fields, so you cannot restore it.',
251
+ 404: 'That version is no longer available — the record, or your access to it, has changed.',
252
+ 409: 'The history cannot say exactly what the value was at that version, so it cannot be restored.',
253
+ 428: 'A restore must say which version of the record it was chosen from. Reload and try again.',
254
+ };
255
+
256
+ /**
257
+ * What the route answered, from a resolved outcome OR a thrown error — a plain
258
+ * `fetch` resolves non-2xx and a wrapper throws, and a restore must not fall
259
+ * through to a generic failure on either.
260
+ */
261
+ export function parseRestoreOutcome(source: unknown): ParsedRestoreOutcome {
262
+ const refusal = parseStaleSaveRefusal(source);
263
+ if (refusal) {
264
+ return {
265
+ kind: 'stale',
266
+ ...(refusal.currentVersion !== undefined ? { currentVersion: refusal.currentVersion } : {}),
267
+ ...(refusal.detail ? { detail: refusal.detail } : {}),
268
+ };
269
+ }
270
+ const status = statusOf(source);
271
+ const body = bodyOf(source);
272
+ const ok = status === undefined ? source instanceof Error === false && 'restore' in body : status >= 200 && status < 300;
273
+ if (!ok) {
274
+ const message =
275
+ serverSentence(body) ??
276
+ (status !== undefined ? FALLBACK[status] : undefined) ??
277
+ (source instanceof Error && source.message ? source.message : undefined) ??
278
+ 'The restore did not go through. Please try again.';
279
+ return { kind: 'refused', ...(status !== undefined ? { status } : {}), message };
280
+ }
281
+ const raw = body.restore && typeof body.restore === 'object' ? (body.restore as Record<string, unknown>) : {};
282
+ const from = toVersion(raw.restored_from ?? raw.restoredFrom);
283
+ const revision = toVersion(raw.revision);
284
+ const version = toVersion(body.version);
285
+ return {
286
+ kind: 'restored',
287
+ record: body,
288
+ ...(version !== undefined ? { version } : {}),
289
+ summary: {
290
+ ...(from !== undefined ? { restoredFrom: from } : {}),
291
+ fields: Array.isArray(raw.fields) ? raw.fields.filter((f): f is string => typeof f === 'string') : [],
292
+ revision: revision ?? null,
293
+ },
294
+ };
295
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The cross-record edit feed, the pure half (bd startsim-ucg5u; route bd
3
+ * startsim-ivr3n `GET /api/v1/revisions/`).
4
+ *
5
+ * Two rules, each with a sharp edge on the wire:
6
+ *
7
+ * 1. ONLY THE PARAMS THE ROUTE ACCEPTS, NEVER BLANK. The route 400s a parameter
8
+ * it does not recognise AND a blank `actor` or `scope` — a filter that could
9
+ * have narrowed and did not is how a feed silently shows everything. So
10
+ * {@link revisionFeedParams} is an allowlist that drops empty values, and the
11
+ * panel sends nothing else (no `q`, no `action`, never `page`).
12
+ * 2. SITTINGS NEVER CROSS RECORDS. `foldRevisions` folds adjacent rows by the
13
+ * same actor and assumes ONE entity. In a feed, two records saved a second
14
+ * apart by the same person would fold into one sitting with a meaningless
15
+ * version span. {@link foldFeed} folds only runs on the same record.
16
+ */
17
+ import { COLLAPSE_WINDOW_MS, foldRevisions, type RevisionSitting } from './revision-history';
18
+ import type { RevisionFeedEntity, RevisionFeedFilters, RevisionFeedRow } from './types';
19
+
20
+ /** The filter keys the route accepts, in the order a URL shows them. */
21
+ export const REVISION_FEED_FILTER_KEYS = ['actor', 'actor_kind', 'type', 'scope', 'since', 'until'] as const;
22
+
23
+ /** The actor kinds the route accepts for `actor_kind`. */
24
+ export const REVISION_FEED_ACTOR_KINDS = ['person', 'machine', 'unknown'] as const;
25
+
26
+ /** Filters → query params: the allowlist, with blanks OMITTED. */
27
+ export function revisionFeedParams(filters: RevisionFeedFilters | Record<string, unknown>): RevisionFeedFilters {
28
+ const out: Record<string, string> = {};
29
+ const source = (filters ?? {}) as Record<string, unknown>;
30
+ for (const key of REVISION_FEED_FILTER_KEYS) {
31
+ const value = source[key];
32
+ if (typeof value === 'string' && value.trim()) out[key] = value.trim();
33
+ }
34
+ return out as RevisionFeedFilters;
35
+ }
36
+
37
+ /** Read the filters out of URL search params (or anything with `get`). The
38
+ * host's page owns the URL; this only knows the spelling. */
39
+ export function revisionFeedFiltersFrom(params: { get(name: string): string | null } | null | undefined): RevisionFeedFilters {
40
+ const out: Record<string, string> = {};
41
+ if (!params) return out;
42
+ for (const key of REVISION_FEED_FILTER_KEYS) {
43
+ const v = params.get(key);
44
+ if (v && v.trim()) out[key] = v.trim();
45
+ }
46
+ return out as RevisionFeedFilters;
47
+ }
48
+
49
+ export interface FeedSitting {
50
+ /** Stable key: the entity plus the sitting's newest row. */
51
+ id: string;
52
+ entity: RevisionFeedEntity | null;
53
+ sitting: RevisionSitting;
54
+ }
55
+
56
+ function entityId(row: RevisionFeedRow): string {
57
+ return row.entity?.id ? String(row.entity.id) : '';
58
+ }
59
+
60
+ /** Fold a newest-first feed page into sittings, never across records. */
61
+ export function foldFeed(rows: RevisionFeedRow[], windowMs: number = COLLAPSE_WINDOW_MS): FeedSitting[] {
62
+ const out: FeedSitting[] = [];
63
+ let run: RevisionFeedRow[] = [];
64
+ const flush = () => {
65
+ if (!run.length) return;
66
+ const entity = run[0]!.entity ?? null;
67
+ for (const sitting of foldRevisions(run, windowMs)) {
68
+ out.push({ id: `${entityId(run[0]!)}:${sitting.id}`, entity, sitting });
69
+ }
70
+ run = [];
71
+ };
72
+ for (const row of rows ?? []) {
73
+ if (run.length && entityId(run[run.length - 1]!) !== entityId(row)) flush();
74
+ run.push(row);
75
+ }
76
+ flush();
77
+ return out;
78
+ }
79
+
80
+ /** Monday 00:00 in the READER's timezone, as an ISO instant. "This week" means
81
+ * the reader's week; sending the UTC day boundary instead would, east of
82
+ * Greenwich, ask the server for a `since` in the future early on a Monday. */
83
+ export function startOfThisWeek(now: Date = new Date()): string {
84
+ const d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
85
+ d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // back to Monday
86
+ return d.toISOString();
87
+ }
88
+
89
+ /** A `YYYY-MM-DD` date input → the instant that local day starts / ends. */
90
+ export function localDayStart(date: string): string {
91
+ const [y, m, d] = date.split('-').map(Number);
92
+ return new Date(y!, (m ?? 1) - 1, d ?? 1, 0, 0, 0, 0).toISOString();
93
+ }
94
+ export function localDayEnd(date: string): string {
95
+ const [y, m, d] = date.split('-').map(Number);
96
+ return new Date(y!, (m ?? 1) - 1, d ?? 1, 23, 59, 59, 999).toISOString();
97
+ }
98
+ /** An instant → the local `YYYY-MM-DD` a date input shows. */
99
+ export function localDateInput(iso?: string): string {
100
+ if (!iso) return '';
101
+ const d = new Date(iso);
102
+ if (Number.isNaN(d.getTime())) return '';
103
+ const pad = (n: number) => String(n).padStart(2, '0');
104
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
105
+ }
@@ -194,6 +194,9 @@ export interface NormalizedRevision {
194
194
  valuesOmitted: boolean;
195
195
  /** A machine ATTEMPT: something was held back and nothing moved. */
196
196
  isAttempt: boolean;
197
+ /** Set on a `source: "restore"` row: the version it put back
198
+ * (bd startsim-ti1yv). */
199
+ restoredFrom?: number;
197
200
  }
198
201
 
199
202
  export function normalizeRevision(revision: EntityRevision): NormalizedRevision {
@@ -213,6 +216,9 @@ export function normalizeRevision(revision: EntityRevision): NormalizedRevision
213
216
  }
214
217
 
215
218
  const preserved = strings(metadata.preserved);
219
+ const restoredRaw = metadata.restored_from ?? metadata.restoredFrom;
220
+ const restoredFrom =
221
+ typeof restoredRaw === 'number' && Number.isInteger(restoredRaw) ? restoredRaw : undefined;
216
222
  return {
217
223
  id: String(revision.id ?? ''),
218
224
  version: typeof revision.version === 'number' ? revision.version : 0,
@@ -233,6 +239,7 @@ export function normalizeRevision(revision: EntityRevision): NormalizedRevision
233
239
  redacted,
234
240
  valuesOmitted,
235
241
  isAttempt: preserved.length > 0 && Object.keys(changed).length === 0,
242
+ ...(restoredFrom !== undefined ? { restoredFrom } : {}),
236
243
  };
237
244
  }
238
245
 
@@ -272,6 +279,9 @@ export interface RevisionSitting {
272
279
  redacted: string[];
273
280
  valuesOmitted: boolean;
274
281
  isAttempt: boolean;
282
+ /** A restore is always its own sitting, so this is the restore's source
283
+ * version when the sitting IS one. */
284
+ restoredFrom?: number;
275
285
  }
276
286
 
277
287
  /**
@@ -309,6 +319,10 @@ function sameSitting(
309
319
  if (newer.actorKindSource !== older.actorKindSource) return false;
310
320
  if (newer.isAttempt !== older.isAttempt) return false;
311
321
  if (older.action === 'created' || newer.action === 'created') return false;
322
+ // 5. A RESTORE IS ITS OWN ROW. Folded into the edit before it, "restored
323
+ // from v2" would vanish into "Edited blog" and the one fact a restore row
324
+ // carries — which version came back, and who chose it — would be lost.
325
+ if (newer.restoredFrom !== undefined || older.restoredFrom !== undefined) return false;
312
326
  if (!newer.createdAt || !older.createdAt) return false;
313
327
  const gap = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
314
328
  return gap >= 0 && gap <= windowMs;
@@ -364,6 +378,7 @@ function startSitting(revision: NormalizedRevision): RevisionSitting {
364
378
  redacted: [...revision.redacted],
365
379
  valuesOmitted: revision.valuesOmitted,
366
380
  isAttempt: revision.isAttempt,
381
+ ...(revision.restoredFrom !== undefined ? { restoredFrom: revision.restoredFrom } : {}),
367
382
  };
368
383
  }
369
384
 
@@ -472,6 +487,13 @@ export function sittingHeadline(
472
487
  }
473
488
  const fields = sittingFields(sitting);
474
489
  const renamed = sitting.name !== undefined;
490
+ if (sitting.restoredFrom !== undefined) {
491
+ const from = `v${sitting.restoredFrom}`;
492
+ if (!fields.length) return renamed ? `Restored the name from ${from}` : `Restored from ${from} — nothing changed`;
493
+ return fields.length <= 3
494
+ ? `Restored ${joinNames(fields.map(fieldLabel))} from ${from}`
495
+ : `Restored ${fields.length} fields from ${from}`;
496
+ }
475
497
  if (sitting.action === 'created') {
476
498
  return fields.length ? `Created the record with ${fields.length} ${fields.length === 1 ? 'field' : 'fields'}` : 'Created the record';
477
499
  }
@@ -45,7 +45,7 @@ export type RevisionAction = 'created' | 'updated' | 'deleted';
45
45
 
46
46
  /** Which endpoint wrote. `server` is a management command or a worker — a real
47
47
  * write that must not claim to be an endpoint. */
48
- export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server';
48
+ export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server' | 'restore';
49
49
 
50
50
  /** `none` always, today; `matched` arrives with the conditional write
51
51
  * (bd startsim-3c2wc). */
@@ -98,6 +98,14 @@ export interface RevisionMetadata {
98
98
  values_omitted?: boolean;
99
99
  /** Camelised alias — see the header. */
100
100
  valuesOmitted?: boolean;
101
+ /** On a `source: "restore"` row: the version whose values it put back
102
+ * (bd startsim-ti1yv). */
103
+ restored_from?: number;
104
+ restoredFrom?: number;
105
+ /** On a FIELD restore: the fields the caller named. Absent on a whole-record
106
+ * restore. */
107
+ restored_fields?: string[];
108
+ restoredFields?: string[];
101
109
  }
102
110
 
103
111
  /** One row of the append-only revision trail. Only `id` and `version` are
@@ -135,6 +143,9 @@ export interface EntityRevision {
135
143
  * was held back. */
136
144
  export interface RevisionPage {
137
145
  count?: number;
146
+ /** The resolved history policy for this record's TYPE (bd startsim-jkkn7.17).
147
+ * `enabled: false` means new edits are not recorded. */
148
+ history?: RevisionHistoryPolicy | null;
138
149
  next?: string | null;
139
150
  previous?: string | null;
140
151
  results?: EntityRevision[];
@@ -148,6 +159,12 @@ export interface RevisionPage {
148
159
  export interface RevisionQuery {
149
160
  page?: number;
150
161
  pageSize?: number;
162
+ /** Narrow to revisions that changed this field — `?field=` (bd
163
+ * startsim-jkkn7.8). A key-existence test server-side, so the result is the
164
+ * field's own trail; it misses held-back re-sends and name changes (bd
165
+ * startsim-jkkn7.18), which is why a narrowed panel offers no whole-record
166
+ * restore. */
167
+ field?: string;
151
168
  // Index signature so this satisfies an API client's query-params type; the
152
169
  // injected client is the app's own.
153
170
  [key: string]: unknown;
@@ -164,4 +181,139 @@ export interface RevisionQuery {
164
181
  */
165
182
  export interface RevisionClient {
166
183
  list: (query: RevisionQuery) => Promise<RevisionPage>;
184
+ /**
185
+ * `POST /entities/<id>/revisions/<N>/restore/` (bd startsim-ti1yv). Optional:
186
+ * a host that omits it gets a read-only panel with no restore actions.
187
+ *
188
+ * Like {@link ConditionalWriteClient.write} it may RESOLVE with a non-2xx
189
+ * outcome or THROW one carrying `status`/`body`; both are understood. Send
190
+ * `expectedVersion` in ONE spelling only (body `expected_version` or
191
+ * `If-Match`) — the server refuses if either of two disagrees.
192
+ */
193
+ restore?: (request: RestoreRequest) => Promise<RestoreOutcome>;
194
+ }
195
+
196
+ /** The resolved history policy, as the revisions envelope and the field-authors
197
+ * read both carry it. */
198
+ export interface RevisionHistoryPolicy {
199
+ enabled?: boolean;
200
+ /** `type`, `"*"`, or `default` — who decided. */
201
+ declared_by?: string;
202
+ declaredBy?: string;
203
+ }
204
+
205
+ /** One restore. `fields` omitted restores the WHOLE record to version N. */
206
+ export interface RestoreRequest {
207
+ /** N — the record is put back to how it stood AFTER revision N. */
208
+ version: number;
209
+ fields?: string[];
210
+ /** REQUIRED by the route (428 without it): the version the caller believes is
211
+ * current. */
212
+ expectedVersion: number;
213
+ }
214
+
215
+ /** What the transport hands back — the same shape as a conditional write. */
216
+ export interface RestoreOutcome {
217
+ status?: number;
218
+ body?: unknown;
219
+ headers?: Record<string, string | null | undefined> | { get(name: string): string | null | undefined };
220
+ }
221
+
222
+ /* -------------------------------------------------------------------------- */
223
+ /* FIELD AUTHORS — GET /entities/<id>/field-authors/ (bd startsim-6sso4) */
224
+ /* -------------------------------------------------------------------------- */
225
+
226
+ /** The latest author of ONE field. Snake and camel both declared — see the
227
+ * spelling trap in this module's header. */
228
+ export interface FieldAuthor {
229
+ version?: number;
230
+ at?: string | null;
231
+ actor_sub?: string | null;
232
+ actorSub?: string | null;
233
+ actor_label?: string | null;
234
+ actorLabel?: string | null;
235
+ actor_kind?: ActorKind | string | null;
236
+ actorKind?: ActorKind | string | null;
237
+ actor_kind_source?: ActorKindSource | string | null;
238
+ actorKindSource?: ActorKindSource | string | null;
239
+ source?: string | null;
240
+ restored_from?: number | null;
241
+ restoredFrom?: number | null;
242
+ }
243
+
244
+ /** Where the trail begins for this record. Live rows PREDATE the trail, so a
245
+ * field with no entry means "not edited since this", never "never edited". */
246
+ export interface FieldAuthorsTrailStart {
247
+ version?: number;
248
+ at?: string | null;
249
+ from_creation?: boolean;
250
+ fromCreation?: boolean;
251
+ }
252
+
253
+ export interface FieldAuthorsResponse {
254
+ entity_id?: string;
255
+ entityId?: string;
256
+ version?: number;
257
+ history?: RevisionHistoryPolicy | null;
258
+ /** `false` when history is off for this type: `fields` is then `null` and
259
+ * NOTHING may be inferred about who edited what. */
260
+ recorded?: boolean;
261
+ trail_starts?: FieldAuthorsTrailStart | null;
262
+ trailStarts?: FieldAuthorsTrailStart | null;
263
+ /** Withheld fields are omitted entirely. `null` when not recorded. */
264
+ fields?: Record<string, FieldAuthor> | null;
265
+ /** The name COLUMN's latest author, namespaced away from data keys. */
266
+ name?: FieldAuthor | null;
267
+ /** The server's sentence when `recorded` is false. */
268
+ detail?: string;
269
+ }
270
+
271
+ export interface FieldAuthorsClient {
272
+ get: () => Promise<FieldAuthorsResponse>;
273
+ }
274
+
275
+ /* -------------------------------------------------------------------------- */
276
+ /* THE FEED — GET /api/v1/revisions/ (bd startsim-ivr3n) */
277
+ /* -------------------------------------------------------------------------- */
278
+
279
+ /** The record a feed row belongs to. */
280
+ export interface RevisionFeedEntity {
281
+ id: string;
282
+ type?: string;
283
+ name?: string | null;
284
+ }
285
+
286
+ /** One feed row: a revision plus the record it changed. */
287
+ export interface RevisionFeedRow extends EntityRevision {
288
+ entity?: RevisionFeedEntity | null;
289
+ }
290
+
291
+ /** The filters the route accepts, keyed exactly as the URL spells them so a
292
+ * host can round-trip them through search params unchanged. */
293
+ export interface RevisionFeedFilters {
294
+ /** An actor SUB, never a label. */
295
+ actor?: string;
296
+ actor_kind?: string;
297
+ type?: string;
298
+ scope?: string;
299
+ since?: string;
300
+ until?: string;
301
+ }
302
+
303
+ export interface RevisionFeedQuery extends RevisionFeedFilters {
304
+ cursor?: string;
305
+ pageSize?: number;
306
+ [key: string]: unknown;
307
+ }
308
+
309
+ /** NO `count` — the route does not compute one, and `?page=` is a 400. */
310
+ export interface RevisionFeedPage {
311
+ next?: string | null;
312
+ next_cursor?: string | null;
313
+ nextCursor?: string | null;
314
+ results?: RevisionFeedRow[];
315
+ }
316
+
317
+ export interface RevisionFeedClient {
318
+ list: (query: RevisionFeedQuery) => Promise<RevisionFeedPage>;
167
319
  }