@thomasfosterau/pulpit 0.0.0 → 0.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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * **The node / version chain discipline** — create, append, tombstone,
3
+ * restore, history and field-level compare over an append-only chain,
4
+ * generic in the leaf.
5
+ *
6
+ * Every node carries an append-only chain of `(version, action, timestamp,
7
+ * actor)` rows (`READ-009` / `PUB-003`): each edit appends a row rather than
8
+ * rewriting in place, and "repointing the current version" is nothing but
9
+ * that append — the current version is the chain's maximum, resolved by the
10
+ * current-version view. The behavioural spec is gardeneria's
11
+ * `packages/database/src/nodes.ts` (`createNodeE` / `updateNodeE` /
12
+ * `deleteNodeE`), the leaf-chain half of `apps/api/src/operations/subtype.ts`
13
+ * (`defineVersionOpsE`, the merge / carry-forward writers) and
14
+ * `version-history.ts`; a `#NNN` here names a gardeneria issue.
15
+ *
16
+ * ## The invariants
17
+ *
18
+ * - **Append-only** (`VERSION-CHAIN-001/002`). Every write lands at
19
+ * `N_max + 1`; no existing version row is ever updated, deleted or
20
+ * reordered. A restore is a new version whose content came from history.
21
+ * The one exception is erasure (`./purge`), which deletes a node's whole
22
+ * chain, never part of one.
23
+ * - **Create is never an upsert.** A caller-supplied id that already belongs
24
+ * to a node fails {@link NodeAlreadyExists} (F-OP-02) rather than minting a
25
+ * version onto it.
26
+ * - **Update carries forward.** A patch is tri-state per column: a value
27
+ * sets, `null` clears, an absent (or `undefined`) key carries the previous
28
+ * version's value forward. Never `input.x ?? null`.
29
+ * - **Delete is a tombstone that carries the state forward**, so a `delete`
30
+ * version is itself a complete snapshot — which is what makes restoring
31
+ * *from* a tombstone possible. It is idempotent: on an already tombstoned
32
+ * node it returns the existing tombstone and writes nothing.
33
+ * - **A plain update never resurrects** (`VERSION-CHAIN-006`). Over a
34
+ * tombstone it fails {@link NodeNotFound}; only the explicit `restore` and
35
+ * `restoreVersion` write over one.
36
+ * - **No partial restore.** `restoreVersion` reads the whole snapshot first
37
+ * and fails {@link VersionNotFound} when any level of the chain has no row
38
+ * at that version — before anything is written.
39
+ * - **Every chain read is bounded** (`PERF-DB-001`): `history` takes a
40
+ * `limit`, capped at {@link MAX_HISTORY_LIMIT}.
41
+ * - **An expected version is a precondition on the append.** A write to an
42
+ * existing node (`update`, `restore`, `delete`, `restoreVersion`) may
43
+ * carry the head version its caller last read ({@link Precondition}); a
44
+ * head that has moved fails {@link StaleVersion} and nothing is written.
45
+ * It is checked against the very head the append extends, and the append
46
+ * lands at that head + 1, so a writer that slips in between the read and
47
+ * the append takes the `(id, version)` key first and this one fails
48
+ * {@link VersionConflict}: the store's primary key is what makes the check
49
+ * atomic. Without one, a write extends whatever the head is (last writer
50
+ * wins on a column two patches both set).
51
+ *
52
+ * ## The seam
53
+ *
54
+ * The chain is read and written through a {@link VersionChainStore} the
55
+ * application supplies — in production, a Drizzle store over
56
+ * effect-drizzle-ext's `./versioned-chain` tables and views, which is not yet
57
+ * published (see the package README → Seams for the expected table shape).
58
+ * The operations here own the discipline; the store owns persistence
59
+ * and nothing else. Authorisation is not this module's concern either: the
60
+ * subtype factory (`./subtype`) gates before it calls in.
61
+ *
62
+ * @copyright Thomas Foster
63
+ * @module pulpit/version-chain
64
+ * @since 0.1.0
65
+ */
66
+
67
+ import * as Array from "effect/Array";
68
+ import * as Clock from "effect/Clock";
69
+ import * as DateTime from "effect/DateTime";
70
+ import * as Effect from "effect/Effect";
71
+ import * as Equal from "effect/Equal";
72
+ import * as Option from "effect/Option";
73
+ import * as Record from "effect/Record";
74
+ import * as Schema from "effect/Schema";
75
+
76
+ // ── Vocabulary ────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * What one version row recorded: the create, an edit (restores included), or a tombstone.
80
+ *
81
+ * @since 0.1.0
82
+ */
83
+ export const VersionAction = Schema.Literals(["create", "update", "delete"]);
84
+ /**
85
+ * The {@link VersionAction} literal union: `"create" | "update" | "delete"`.
86
+ *
87
+ * @since 0.1.0
88
+ */
89
+ export type VersionAction = typeof VersionAction.Type;
90
+
91
+ /**
92
+ * One column's value in a leaf's state: a JSON value, or one of the two
93
+ * non-JSON scalars a Drizzle row carries (`Date` for timestamps, `bigint` for
94
+ * `bigint` columns). A content-addressed document is carried as its
95
+ * `DocumentHash` (`./documents`' `documentRef`), never as a copy; a document
96
+ * a leaf keeps inline is its parsed JSON.
97
+ *
98
+ * @since 0.1.0
99
+ */
100
+ export type ColumnValue = Schema.Json | Date | bigint;
101
+
102
+ /**
103
+ * A leaf's domain state at one version: every column of every level of its
104
+ * chain, keyed by column name (the leaf-most level wins on a shared name).
105
+ * Generated columns (a `search_vector`) are not part of it — they regenerate.
106
+ *
107
+ * @since 0.1.0
108
+ */
109
+ export type ChainState = { readonly [column: string]: ColumnValue };
110
+
111
+ /**
112
+ * A tri-state patch over a {@link ChainState}: a value sets, `null` clears,
113
+ * an absent or `undefined` key carries the previous version's value forward.
114
+ *
115
+ * @since 0.1.0
116
+ */
117
+ // oxlint-disable-next-line effect/prefer-option-over-null -- the tri-state rule IS the `undefined` arm: it is how a decoded update input says "not supplied", and an `Option` here would have to be unwrapped from every generated update projection first
118
+ export type ChainPatch = { readonly [column: string]: ColumnValue | undefined };
119
+
120
+ /**
121
+ * A localised leaf's locale level at one version (pulpit#56, model (b)): each
122
+ * locale the version carries, keyed by its tag (`"en"`, `"fr-CA"`), and that
123
+ * locale's translated columns. The node's shared (untranslated) columns stay
124
+ * in its {@link ChainState}; a translated column lives here only. A store
125
+ * keeps it as the locale level's fact rows, keyed `(id, version, locale)`.
126
+ * A version that carries no locale has none; an absent `locales` and `{}`
127
+ * mean the same.
128
+ *
129
+ * @since 0.1.0
130
+ */
131
+ export type LocaleStates = { readonly [locale: string]: ChainState };
132
+
133
+ /**
134
+ * A tri-state patch over a {@link LocaleStates}, per locale: a
135
+ * {@link ChainPatch} updates that locale's columns (starting from none for a
136
+ * locale the version did not carry), `null` drops the locale, and an absent
137
+ * or `undefined` key carries it forward unchanged.
138
+ *
139
+ * @since 0.1.0
140
+ */
141
+ // oxlint-disable-next-line effect/prefer-option-over-null -- the tri-state rule, one level up: `null` drops a locale and `undefined` carries it forward, as a decoded update input says it
142
+ export type LocalePatch = { readonly [locale: string]: ChainPatch | null | undefined };
143
+
144
+ /**
145
+ * One entry in a node's append-only version chain.
146
+ *
147
+ * @since 0.1.0
148
+ */
149
+ export interface VersionChainEntry {
150
+ /** The version number (0 is the create). */
151
+ readonly version: bigint;
152
+ /** The edit action this version recorded. */
153
+ readonly action: VersionAction;
154
+ /** When the version was written. */
155
+ readonly timestamp: Date;
156
+ }
157
+
158
+ /**
159
+ * One version's reconstructed state, for the edits (browse / compare) surface.
160
+ *
161
+ * @since 0.1.0
162
+ */
163
+ export interface VersionSnapshot extends VersionChainEntry {
164
+ /** The shared (untranslated) columns, or every column of a leaf with no locale level. */
165
+ readonly state: ChainState;
166
+ /**
167
+ * A localised leaf's locale level at this version (pulpit#56); absent, the
168
+ * version carries no locale. A store reconstructs it with the rest of the
169
+ * snapshot, or reports the version absent.
170
+ */
171
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no locale", the same as `{}`
172
+ readonly locales?: LocaleStates | undefined;
173
+ }
174
+
175
+ /**
176
+ * The row a write appends: the entry, the node's leaf type, the acting account, the full state.
177
+ *
178
+ * @since 0.1.0
179
+ */
180
+ export interface VersionRow extends VersionSnapshot {
181
+ readonly id: string;
182
+ /** The concrete leaf's wire type, threaded onto the node row at version 0 (#864 S3). */
183
+ readonly type: string;
184
+ /** The acting account (#1581) — recorded on the version row, never inferred. */
185
+ readonly actorId: Option.Option<string>;
186
+ /**
187
+ * The contributor whose accepted proposal this version applies
188
+ * (`./proposals`), recorded beside the actor, who is the account that
189
+ * accepted it; none for every other write. A store persists it with the
190
+ * version row (README → Seams → `VersionChainStore`).
191
+ */
192
+ readonly proposedBy: Option.Option<string>;
193
+ }
194
+
195
+ /**
196
+ * What a chain write returns: the node and the version it landed at.
197
+ *
198
+ * @since 0.1.0
199
+ */
200
+ export interface ChainWrite {
201
+ readonly id: string;
202
+ readonly version: bigint;
203
+ }
204
+
205
+ // ── Errors ────────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * No such node, or (for a plain update) a tombstoned one: the wire's 404.
209
+ *
210
+ * @since 0.1.0
211
+ */
212
+ export class NodeNotFound extends Schema.TaggedError<
213
+ NodeNotFound,
214
+ { readonly brand: unique symbol }
215
+ >()("NodeNotFound", {
216
+ type: Schema.String,
217
+ id: Schema.String,
218
+ }) {}
219
+
220
+ /**
221
+ * No reconstructable version at `(id, version)`: an unknown version, or a partial chain.
222
+ *
223
+ * @since 0.1.0
224
+ */
225
+ export class VersionNotFound extends Schema.TaggedError<
226
+ VersionNotFound,
227
+ { readonly brand: unique symbol }
228
+ >()("VersionNotFound", {
229
+ type: Schema.String,
230
+ id: Schema.String,
231
+ version: Schema.BigInt,
232
+ }) {}
233
+
234
+ /**
235
+ * A create whose caller-supplied id already belongs to a node — never an upsert: the wire's 409.
236
+ *
237
+ * @since 0.1.0
238
+ */
239
+ export class NodeAlreadyExists extends Schema.TaggedError<
240
+ NodeAlreadyExists,
241
+ { readonly brand: unique symbol }
242
+ >()("NodeAlreadyExists", { type: Schema.String, id: Schema.String }) {}
243
+
244
+ /**
245
+ * The store refused an append because `(id, version)` is already taken — a
246
+ * concurrent writer got there first. Raised by the store (the `(id, version)`
247
+ * primary key is what enforces append-only under concurrency); never retried
248
+ * here, because a retry would silently base the write on a version the
249
+ * caller never read.
250
+ *
251
+ * @since 0.1.0
252
+ */
253
+ export class VersionConflict extends Schema.TaggedError<
254
+ VersionConflict,
255
+ { readonly brand: unique symbol }
256
+ >()("VersionConflict", {
257
+ id: Schema.String,
258
+ version: Schema.BigInt,
259
+ }) {}
260
+
261
+ /**
262
+ * An expected-version precondition that no longer holds: the head moved past
263
+ * the version the caller last read, so the write would have overwritten an
264
+ * edit the caller never saw. Nothing was written. Carries the head the write
265
+ * would have extended (`current`), so the caller can re-read and re-apply;
266
+ * the wire's 412 (a failed `If-Match`) or 409.
267
+ *
268
+ * A writer that passes the check but loses the race to the append fails
269
+ * {@link VersionConflict} instead: the same stale read, caught one step later
270
+ * by the store's key.
271
+ *
272
+ * @since 0.1.0
273
+ */
274
+ export class StaleVersion extends Schema.TaggedError<
275
+ StaleVersion,
276
+ { readonly brand: unique symbol }
277
+ >()("StaleVersion", {
278
+ type: Schema.String,
279
+ id: Schema.String,
280
+ /** The version the caller expected the head to be. */
281
+ expected: Schema.BigInt,
282
+ /** The head version the write found. */
283
+ current: Schema.BigInt,
284
+ }) {}
285
+
286
+ /**
287
+ * The chain's head version has no reconstructable state — the store broke its
288
+ * own contract (a head it reports must be readable). A defect, never a typed
289
+ * failure: there is nothing a caller could do about it.
290
+ *
291
+ * @since 0.1.0
292
+ */
293
+ export class BrokenChain extends Schema.TaggedError<
294
+ BrokenChain,
295
+ { readonly brand: unique symbol }
296
+ >()("BrokenChain", {
297
+ type: Schema.String,
298
+ id: Schema.String,
299
+ version: Schema.BigInt,
300
+ }) {}
301
+
302
+ // ── The seam ──────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * The persistence seam the chain operations run over. `E` is the store's own
306
+ * failure (a Drizzle store's `EffectDrizzleQueryError`); `R` its requirements
307
+ * (a Drizzle store's `CurrentTransaction` — the operations never open a
308
+ * transaction themselves: `transact` is always-open and the boundary belongs
309
+ * to the caller, website #1883).
310
+ *
311
+ * Every method reads or writes exactly what it says and applies no policy.
312
+ *
313
+ * @since 0.1.0
314
+ */
315
+ export interface VersionChainStore<E, R> {
316
+ /** The node's latest version entry, or none for an id with no versions. */
317
+ readonly head: (id: string) => Effect.Effect<Option.Option<VersionChainEntry>, E, R>;
318
+ /**
319
+ * The full state at `(id, version)`, or none when the version does not
320
+ * exist OR any level of the chain has no row there — a version that cannot
321
+ * be fully reconstructed is reported as absent, never as a partial state.
322
+ * On a localised leaf the snapshot carries its locale level too: every
323
+ * `(id, version, locale)` fact row, as `locales` (pulpit#56).
324
+ */
325
+ readonly snapshot: (
326
+ id: string,
327
+ version: bigint,
328
+ ) => Effect.Effect<Option.Option<VersionSnapshot>, E, R>;
329
+ /**
330
+ * Append one version row — at version 0 together with the node's identity
331
+ * row, and one locale-level row per locale `row.locales` carries. Fails {@link VersionConflict} when `(id, version)` already exists
332
+ * (at version 0: the id already belongs to a node).
333
+ */
334
+ readonly append: (row: VersionRow) => Effect.Effect<void, E | VersionConflict, R>;
335
+ /** The chain's entries, oldest first, at most `limit` of them. */
336
+ readonly entries: (
337
+ id: string,
338
+ limit: number,
339
+ ) => Effect.Effect<ReadonlyArray<VersionChainEntry>, E, R>;
340
+ /** Mint a fresh node id (a UUID) when the caller supplies none. */
341
+ readonly mintId: Effect.Effect<string, E, R>;
342
+ }
343
+
344
+ // ── The operations ────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * The hard cap on a history read (`PERF-DB-001`): a chain is tiny in practice; this bounds a pathological one.
348
+ *
349
+ * @since 0.1.0
350
+ */
351
+ export const MAX_HISTORY_LIMIT = 1000;
352
+
353
+ /**
354
+ * Apply a tri-state patch to a state: a value sets, `null` clears, `undefined` carries forward.
355
+ *
356
+ * @since 0.1.0
357
+ */
358
+ export const applyPatch = (state: ChainState, patch: ChainPatch) => ({
359
+ ...state,
360
+ ...Record.filter(patch, (value): value is ColumnValue => value !== undefined),
361
+ });
362
+
363
+ /**
364
+ * Apply a {@link LocalePatch} to a locale level: a patch updates its locale
365
+ * (from nothing for a locale `locales` lacks) with {@link applyPatch}, `null`
366
+ * drops the locale, `undefined` carries it forward. Pure and total.
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * import { deepStrictEqual } from "node:assert";
371
+ * import { applyLocalePatch } from "@thomasfosterau/pulpit/version-chain";
372
+ *
373
+ * const before = { en: { title: "Hello" }, fr: { title: "Bonjour" } };
374
+ * deepStrictEqual(applyLocalePatch(before, { en: { title: "Hi" }, fr: null, de: { title: "Hallo" } }), {
375
+ * en: { title: "Hi" },
376
+ * de: { title: "Hallo" },
377
+ * });
378
+ * ```
379
+ *
380
+ * @since 0.1.0
381
+ */
382
+ export const applyLocalePatch = (locales: LocaleStates, patch: LocalePatch): LocaleStates =>
383
+ Record.reduce(patch, locales, (acc, change, locale) => {
384
+ if (change === undefined) return acc;
385
+ if (change === null) return Record.remove(acc, locale);
386
+ return {
387
+ ...acc,
388
+ [locale]: applyPatch(
389
+ Option.getOrElse(Record.get(acc, locale), () => ({})),
390
+ change,
391
+ ),
392
+ };
393
+ });
394
+
395
+ /**
396
+ * The locale level a snapshot carries, `{}` for none.
397
+ *
398
+ * @since 0.1.0
399
+ */
400
+ export const localesOf = (snapshot: Pick<VersionSnapshot, "locales">): LocaleStates =>
401
+ snapshot.locales ?? {};
402
+
403
+ // ── Version compare ───────────────────────────────────────────────────
404
+
405
+ /**
406
+ * How one column differs between two states, read from the first state to
407
+ * the second: `added` (absent before, present after), `removed` (present
408
+ * before, absent after) or `changed` (present on both sides, structurally
409
+ * unequal). A `null` is a present value: a column cleared to `null` is
410
+ * `changed`, never `removed`, and a column absent from a state is a column
411
+ * that state does not have, not one it cleared.
412
+ *
413
+ * @since 0.1.0
414
+ */
415
+ export type AttributeChange =
416
+ | { readonly _tag: "added"; readonly after: ColumnValue }
417
+ | { readonly _tag: "removed"; readonly before: ColumnValue }
418
+ | { readonly _tag: "changed"; readonly before: ColumnValue; readonly after: ColumnValue };
419
+
420
+ /**
421
+ * The field-level change set between two states, keyed by column: one
422
+ * {@link AttributeChange} per column that differs. An unchanged column has
423
+ * no key, so two equal states diff to `{}`.
424
+ *
425
+ * @since 0.1.0
426
+ */
427
+ export type StateDiff = { readonly [column: string]: AttributeChange };
428
+
429
+ /** One column's change, or none when both sides hold structurally equal values. */
430
+ const changeOf = (
431
+ before: Option.Option<ColumnValue>,
432
+ after: Option.Option<ColumnValue>,
433
+ ): Option.Option<AttributeChange> => {
434
+ if (Option.isNone(before)) {
435
+ return Option.map(after, (value): AttributeChange => ({ _tag: "added", after: value }));
436
+ }
437
+ if (Option.isNone(after)) return Option.some({ _tag: "removed", before: before.value });
438
+ return Equal.equals(before.value, after.value)
439
+ ? Option.none()
440
+ : Option.some({ _tag: "changed", before: before.value, after: after.value });
441
+ };
442
+
443
+ /**
444
+ * The field-level change set from state `from` to state `to`: every column
445
+ * that is added, removed or changed, with its before and after values;
446
+ * unchanged columns are omitted. Pure and total, with no error channel.
447
+ *
448
+ * Presence is read per key, so the tri-state distinction survives: a column
449
+ * holding `null` is present (a clear), and only a column with no key is
450
+ * absent. Values compare structurally with `Equal.equals`: JSON objects and
451
+ * arrays by content (key order ignored), a `Date` by its instant, a `bigint`
452
+ * by value, and a `Date` never equals the string of its instant. A
453
+ * content-addressed document is held as its `DocumentHash`, so it compares by
454
+ * hash: changed or unchanged, never a textual diff, which is the UI's job.
455
+ *
456
+ * The diff is directed: swapping the arguments swaps `added` and `removed`
457
+ * and each change's `before` and `after`. A state's values are compared as
458
+ * immutable (Effect caches structural hashes), as a {@link ChainState} is.
459
+ *
460
+ * @since 0.1.0
461
+ */
462
+ export const diffStates = (from: ChainState, to: ChainState): StateDiff =>
463
+ Record.fromEntries(
464
+ Array.flatMap(Array.dedupe([...Record.keys(from), ...Record.keys(to)]), (column) =>
465
+ Array.fromOption(
466
+ Option.map(
467
+ changeOf(Record.get(from, column), Record.get(to, column)),
468
+ (change) => [column, change] as const,
469
+ ),
470
+ ),
471
+ ),
472
+ );
473
+
474
+ /**
475
+ * How one locale differs between two versions' locale levels, read from the
476
+ * first to the second: `added` (the second carries it, the first did not,
477
+ * with its columns), `removed` (the reverse) or `changed` (both carry it, and
478
+ * its columns differ: their {@link StateDiff}).
479
+ *
480
+ * @since 0.1.0
481
+ */
482
+ export type LocaleChange =
483
+ | { readonly _tag: "added"; readonly after: ChainState }
484
+ | { readonly _tag: "removed"; readonly before: ChainState }
485
+ | { readonly _tag: "changed"; readonly changes: StateDiff };
486
+
487
+ /**
488
+ * The per-locale change set between two locale levels, keyed by locale: one
489
+ * {@link LocaleChange} per locale that differs. An unchanged locale has no
490
+ * key.
491
+ *
492
+ * @since 0.1.0
493
+ */
494
+ export type LocaleDiff = { readonly [locale: string]: LocaleChange };
495
+
496
+ /** One locale's change, or none when both sides carry it with equal columns (or neither does). */
497
+ const localeChangeOf = (
498
+ before: Option.Option<ChainState>,
499
+ after: Option.Option<ChainState>,
500
+ ): Option.Option<LocaleChange> => {
501
+ if (Option.isNone(before)) {
502
+ return Option.map(after, (state): LocaleChange => ({ _tag: "added", after: state }));
503
+ }
504
+ if (Option.isNone(after)) return Option.some({ _tag: "removed", before: before.value });
505
+ const changes = diffStates(before.value, after.value);
506
+ return Record.isEmptyRecord(changes) ? Option.none() : Option.some({ _tag: "changed", changes });
507
+ };
508
+
509
+ /**
510
+ * The per-locale change set from locale level `from` to locale level `to`
511
+ * (pulpit#56): every locale added, removed, or whose columns changed by
512
+ * {@link diffStates}; an unchanged locale is omitted. Pure and total.
513
+ *
514
+ * @example
515
+ * ```ts
516
+ * import { deepStrictEqual } from "node:assert";
517
+ * import { diffLocales } from "@thomasfosterau/pulpit/version-chain";
518
+ *
519
+ * deepStrictEqual(
520
+ * diffLocales({ en: { title: "Hello" }, fr: { title: "Bonjour" } }, { en: { title: "Hi" }, de: { title: "Hallo" } }),
521
+ * {
522
+ * en: { _tag: "changed", changes: { title: { _tag: "changed", before: "Hello", after: "Hi" } } },
523
+ * fr: { _tag: "removed", before: { title: "Bonjour" } },
524
+ * de: { _tag: "added", after: { title: "Hallo" } },
525
+ * },
526
+ * );
527
+ * ```
528
+ *
529
+ * @since 0.1.0
530
+ */
531
+ export const diffLocales = (from: LocaleStates, to: LocaleStates): LocaleDiff =>
532
+ Record.fromEntries(
533
+ Array.flatMap(Array.dedupe([...Record.keys(from), ...Record.keys(to)]), (locale) =>
534
+ Array.fromOption(
535
+ Option.map(
536
+ localeChangeOf(Record.get(from, locale), Record.get(to, locale)),
537
+ (change) => [locale, change] as const,
538
+ ),
539
+ ),
540
+ ),
541
+ );
542
+
543
+ /**
544
+ * What {@link VersionChainOps.compare} returns: both versions as read
545
+ * (entry and full state) and the {@link StateDiff} from the first to the
546
+ * second.
547
+ *
548
+ * A tombstone is not an attribute: it is the version's `action`, so it shows
549
+ * as `from.action` / `to.action` being `"delete"` and never as a key in
550
+ * `changes`. A tombstone carries its state forward, so a version compared
551
+ * with the tombstone that followed it has no changes; a history view labels
552
+ * the version deleted from its action and lists only what the content did.
553
+ *
554
+ * @since 0.1.0
555
+ */
556
+ export interface VersionComparison {
557
+ /** The version compared from, with its state. */
558
+ readonly from: VersionSnapshot;
559
+ /** The version compared to, with its state. */
560
+ readonly to: VersionSnapshot;
561
+ /** `diffStates(from.state, to.state)`: the shared columns. */
562
+ readonly changes: StateDiff;
563
+ /**
564
+ * `diffLocales` over the two versions' locale levels (pulpit#56): the
565
+ * per-locale changes, `{}` for a leaf with no locale level. Kept beside
566
+ * `changes` rather than inside it, since a column may itself be named
567
+ * `locales`.
568
+ */
569
+ readonly locales: LocaleDiff;
570
+ }
571
+
572
+ /**
573
+ * The input to {@link VersionChainOps.create}: the node id, the leaf's
574
+ * initial state, the acting account.
575
+ *
576
+ * @since 0.1.0
577
+ */
578
+ export interface CreateInput {
579
+ /** A caller-supplied node id (client-generated ids); minted by the store when absent. */
580
+ readonly id: Option.Option<string>;
581
+ /** The leaf's full state at version 0: its shared columns, on a localised leaf. */
582
+ readonly state: ChainState;
583
+ /** A localised leaf's locale level at version 0 (pulpit#56); absent, none. */
584
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no locale"
585
+ readonly locales?: LocaleStates | undefined;
586
+ /** The acting account, recorded on the version row; none for a write with no session behind it. */
587
+ readonly actorId: Option.Option<string>;
588
+ }
589
+
590
+ /**
591
+ * An optimistic-concurrency precondition on a write to an existing node: the
592
+ * head version the caller last read. Every write that extends a chain
593
+ * ({@link VersionChainOps.update}, {@link VersionChainOps.restore},
594
+ * {@link VersionChainOps.delete}, {@link VersionChainOps.restoreVersion})
595
+ * accepts one.
596
+ *
597
+ * @since 0.1.0
598
+ */
599
+ export interface Precondition {
600
+ /**
601
+ * The head version the caller last read. When present, the write fails
602
+ * {@link StaleVersion}, writing nothing, unless the head it extends is
603
+ * exactly this version; absent (or `undefined`), the write extends whatever
604
+ * the head is.
605
+ */
606
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined` under `exactOptionalPropertyTypes`; the omitted key is the absence, read as such by every reader, so there is no value to model as an `Option`
607
+ readonly expectedVersion?: bigint | undefined;
608
+ }
609
+
610
+ /**
611
+ * The input to {@link VersionChainOps.update} and
612
+ * {@link VersionChainOps.restore}: the node, a tri-state patch, the acting
613
+ * account, and an optional expected version.
614
+ *
615
+ * @since 0.1.0
616
+ */
617
+ export interface UpdateInput extends Precondition {
618
+ /** The node to append to. */
619
+ readonly id: string;
620
+ /**
621
+ * The tri-state patch over the current state: a value sets, `null` clears,
622
+ * an absent key carries forward.
623
+ */
624
+ readonly patch: ChainPatch;
625
+ /**
626
+ * A localised leaf's per-locale patch ({@link LocalePatch}, pulpit#56):
627
+ * absent, every locale carries forward. Even a write that touches one
628
+ * locale appends one version of the whole node.
629
+ */
630
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key carries every locale forward
631
+ readonly locales?: LocalePatch | undefined;
632
+ /** The acting account, recorded on the version row; none for a write with no session behind it. */
633
+ readonly actorId: Option.Option<string>;
634
+ /**
635
+ * The contributor whose proposal this edit applies, recorded on the
636
+ * version row as `proposedBy`; absent (or `undefined`) for an ordinary
637
+ * edit. `./proposals`' `accept` sets it.
638
+ */
639
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no proposal", and a given value is itself an `Option`
640
+ readonly proposedBy?: Option.Option<string> | undefined;
641
+ }
642
+
643
+ /**
644
+ * The chain operations for one leaf type, bound to a store.
645
+ *
646
+ * @since 0.1.0
647
+ */
648
+ export interface VersionChainOps<E, R> {
649
+ /** Insert the node and its version 0 (`create`). Fails {@link NodeAlreadyExists} on a taken id. */
650
+ readonly create: (
651
+ input: CreateInput,
652
+ ) => Effect.Effect<ChainWrite, E | NodeAlreadyExists | VersionConflict, R>;
653
+ /**
654
+ * Append an `update` version carrying the patch over the current state.
655
+ * Fails {@link NodeNotFound} for an unknown id and for a tombstoned node,
656
+ * and {@link StaleVersion} when `input.expectedVersion` is not the head.
657
+ */
658
+ readonly update: (
659
+ input: UpdateInput,
660
+ ) => Effect.Effect<ChainWrite, E | NodeNotFound | StaleVersion | VersionConflict, R>;
661
+ /**
662
+ * {@link update}, except a tombstone does not fail: the explicit restore
663
+ * surface (`VERSION-CHAIN-006`) — the node comes back live. Fails
664
+ * {@link NodeNotFound} for an unknown id and {@link StaleVersion} when
665
+ * `input.expectedVersion` is not the head.
666
+ */
667
+ readonly restore: (
668
+ input: UpdateInput,
669
+ ) => Effect.Effect<ChainWrite, E | NodeNotFound | StaleVersion | VersionConflict, R>;
670
+ /**
671
+ * Append a `delete` version carrying the state forward. Idempotent: on a
672
+ * tombstoned node it returns the existing tombstone with
673
+ * `alreadyDeleted: true` and writes nothing, whatever the precondition
674
+ * says (the delete it asked for has happened). Fails {@link NodeNotFound}
675
+ * for an unknown id and {@link StaleVersion} when a live node's head is
676
+ * not `precondition.expectedVersion`.
677
+ */
678
+ readonly delete: (
679
+ id: string,
680
+ actorId: Option.Option<string>,
681
+ precondition?: Precondition,
682
+ ) => Effect.Effect<
683
+ ChainWrite & { readonly alreadyDeleted: boolean },
684
+ E | NodeNotFound | StaleVersion | VersionConflict,
685
+ R
686
+ >;
687
+ /** The reconstructed state at `(id, version)`. Read-only. */
688
+ readonly getVersion: (
689
+ id: string,
690
+ version: bigint,
691
+ ) => Effect.Effect<VersionSnapshot, E | VersionNotFound, R>;
692
+ /**
693
+ * The field-level change set from version `from` to version `to` of one
694
+ * node: two {@link VersionChainOps.getVersion} reads and {@link diffStates}.
695
+ * Read-only. Fails {@link VersionNotFound} for whichever version (`from`
696
+ * first) cannot be fully reconstructed. A tombstone on either side is that
697
+ * side's `action`, never an entry in `changes` (see
698
+ * {@link VersionComparison}). Ungated, like `getVersion`: `./subtype` gates
699
+ * history reads (`READ-009`), and its `SubtypeOps.compare` is this behind
700
+ * `getVersion`'s gate.
701
+ */
702
+ readonly compare: (
703
+ id: string,
704
+ from: bigint,
705
+ to: bigint,
706
+ ) => Effect.Effect<VersionComparison, E | VersionNotFound, R>;
707
+ /**
708
+ * Re-apply the full snapshot at `(id, version)` as a NEW `update` version —
709
+ * over a tombstone too. Fails {@link VersionNotFound}, before writing
710
+ * anything, when the version cannot be fully reconstructed, and
711
+ * {@link StaleVersion} when the head is not
712
+ * `precondition.expectedVersion` (the precondition is on the head the
713
+ * restore extends, not on the version it restores).
714
+ */
715
+ readonly restoreVersion: (
716
+ id: string,
717
+ version: bigint,
718
+ actorId: Option.Option<string>,
719
+ precondition?: Precondition,
720
+ ) => Effect.Effect<ChainWrite, E | VersionNotFound | StaleVersion | VersionConflict, R>;
721
+ /**
722
+ * The chain, oldest first, at most `limit` entries (default and cap
723
+ * {@link MAX_HISTORY_LIMIT}). Fails {@link NodeNotFound} when the id has no
724
+ * versions, so a caller surfaces the 404 a missing resource would.
725
+ */
726
+ readonly history: (
727
+ id: string,
728
+ limit?: number,
729
+ ) => Effect.Effect<ReadonlyArray<VersionChainEntry>, E | NodeNotFound, R>;
730
+ }
731
+
732
+ // ── The before-append hook ────────────────────────────────────────────
733
+
734
+ /**
735
+ * The write that is about to append: the chain's `action` vocabulary, with
736
+ * the two explicit restore surfaces named apart (`VERSION-CHAIN-006`), so a
737
+ * hook can tell a resurrection from an edit. `restore` and `restoreVersion`
738
+ * still append an `update` row. `publish` and `unpublish` are `./subtype`'s
739
+ * publication operations, which record a publication fact and append no
740
+ * version; the chain itself never emits them.
741
+ *
742
+ * @since 0.1.0
743
+ */
744
+ export const WriteAction = Schema.Literals([
745
+ "create",
746
+ "update",
747
+ "delete",
748
+ "restore",
749
+ "restoreVersion",
750
+ "publish",
751
+ "unpublish",
752
+ ]);
753
+ /**
754
+ * The {@link WriteAction} literal union.
755
+ *
756
+ * @since 0.1.0
757
+ */
758
+ export type WriteAction = typeof WriteAction.Type;
759
+
760
+ /**
761
+ * What a {@link ChainHooks.beforeAppend} hook is shown: the write, the node,
762
+ * the head it extends, and the patch it is about to apply.
763
+ *
764
+ * - `create` — `prior` is none, and `patch` is the whole initial state;
765
+ * - `update` / `restore` — `prior` is the head, `patch` the caller's tri-state patch;
766
+ * - `delete` — `prior` is the live head, `patch` is `{}` (the tombstone
767
+ * carries the head's state forward); an already tombstoned node writes
768
+ * nothing and runs no hook;
769
+ * - `restoreVersion` — `prior` is the head, `patch` the whole state of the
770
+ * version being restored.
771
+ *
772
+ * The version written is the hook's patch applied to `prior`'s state (to
773
+ * nothing for `create` and `restoreVersion`, whose patch is the whole state).
774
+ *
775
+ * On a localised leaf (pulpit#56) `locales` is the write's locale patch, in
776
+ * the same reading: the whole locale level for `create` and
777
+ * `restoreVersion`, the caller's {@link LocalePatch} for `update` /
778
+ * `restore`, `{}` for `delete`. `beforeAppend`'s patch is the shared
779
+ * columns' alone; `beforeAppendLocales` returns the locale patch the write
780
+ * applies (see {@link ChainHooks}).
781
+ *
782
+ * @since 0.1.0
783
+ */
784
+ export interface ChainAppendEvent {
785
+ readonly action: WriteAction;
786
+ readonly id: string;
787
+ /** The head the append extends (its full state), or none for a create. */
788
+ readonly prior: Option.Option<VersionSnapshot>;
789
+ readonly patch: ChainPatch;
790
+ /** The write's locale patch (see above); absent or `{}`, every locale carries forward. */
791
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key carries every locale forward
792
+ readonly locales?: LocalePatch | undefined;
793
+ /** The acting account the row will record. */
794
+ readonly actorId: Option.Option<string>;
795
+ }
796
+
797
+ /**
798
+ * The chain's hook point, bound once by {@link defineVersionChain}.
799
+ *
800
+ * @since 0.1.0
801
+ */
802
+ export interface ChainHooks<E, R> {
803
+ /**
804
+ * Runs after the head is read and before the row is appended, in the
805
+ * caller's transaction: its patch replaces the event's, and a failure
806
+ * (its `E`) fails the write with nothing appended. It sees the very head
807
+ * the append extends, so the expected-version check (`appendNext`) still
808
+ * runs against that head after it. Absent, nothing extra is read.
809
+ */
810
+ /* oxlint-disable effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined`; the omitted key is "no hook", read as such by `defineVersionChain` */
811
+ readonly beforeAppend?:
812
+ | ((event: ChainAppendEvent) => Effect.Effect<ChainPatch, E, R>)
813
+ | undefined;
814
+ /**
815
+ * The locale level's counterpart of `beforeAppend` (pulpit#56): runs at the
816
+ * same point, shown the same event, and its {@link LocalePatch} replaces
817
+ * the event's `locales`, so a hook that validates or transforms a column
818
+ * does so in every locale too. Absent, the event's locale patch is applied
819
+ * as given.
820
+ */
821
+ readonly beforeAppendLocales?:
822
+ | ((event: ChainAppendEvent) => Effect.Effect<LocalePatch, E, R>)
823
+ | undefined;
824
+ /* oxlint-enable effect/prefer-option-over-null */
825
+ }
826
+
827
+ const isVersionConflict = Schema.is(VersionConflict);
828
+
829
+ /** What a write stores: the shared state and, when it carries any locale, the locale level. */
830
+ type Written = Pick<VersionSnapshot, "state" | "locales">;
831
+
832
+ /** The write clock: the current instant, as the `Date` a version row stores. */
833
+ const now: Effect.Effect<Date> = Effect.map(Clock.currentTimeMillis, (millis) =>
834
+ DateTime.toDate(DateTime.makeUnsafe(millis)),
835
+ );
836
+
837
+ /**
838
+ * Bind the chain operations for one leaf `type` to a store. Every produced
839
+ * operation is traced as `${type}.<operation>`. `hooks.beforeAppend`, when
840
+ * given, runs before every append (see {@link ChainHooks}); its failure is
841
+ * each write's `E`.
842
+ *
843
+ * @since 0.1.0
844
+ */
845
+ export const defineVersionChain = <E, R>(
846
+ type: string,
847
+ store: VersionChainStore<E, R>,
848
+ hooks: ChainHooks<E, R> = {},
849
+ ): VersionChainOps<E, R> => {
850
+ const { beforeAppend, beforeAppendLocales } = hooks;
851
+
852
+ /**
853
+ * The state a write stores: the hook's patch (or the caller's, without a
854
+ * hook) over `base`, the prior state or nothing.
855
+ */
856
+ const nextState = (
857
+ event: ChainAppendEvent,
858
+ base: Pick<VersionSnapshot, "state" | "locales">,
859
+ ): Effect.Effect<Written, E, R> =>
860
+ Effect.gen(function* () {
861
+ const patch = beforeAppend === undefined ? event.patch : yield* beforeAppend(event);
862
+ const localePatch =
863
+ beforeAppendLocales === undefined
864
+ ? (event.locales ?? {})
865
+ : yield* beforeAppendLocales(event);
866
+ const state = applyPatch(base.state, patch);
867
+ const locales = applyLocalePatch(localesOf(base), localePatch);
868
+ return Record.isEmptyRecord(locales) ? { state } : { state, locales };
869
+ });
870
+
871
+ /**
872
+ * Append at `previous + 1`, after checking the precondition against that
873
+ * same `previous`. The check and the append's version come from one read,
874
+ * so a writer that lands in between takes `previous + 1` first and this
875
+ * append fails the store's `(id, version)` key ({@link VersionConflict}):
876
+ * success means the head was still `expectedVersion` when the row went in.
877
+ */
878
+ const appendNext = (
879
+ id: string,
880
+ previous: VersionChainEntry,
881
+ action: VersionAction,
882
+ content: Written,
883
+ actorId: Option.Option<string>,
884
+ precondition: Precondition,
885
+ proposedBy: Option.Option<string> = Option.none(),
886
+ ): Effect.Effect<ChainWrite, E | StaleVersion | VersionConflict, R> =>
887
+ Effect.gen(function* () {
888
+ const expected = precondition.expectedVersion;
889
+ if (expected !== undefined && expected !== previous.version) {
890
+ return yield* new StaleVersion({ type, id, expected, current: previous.version });
891
+ }
892
+ const version = previous.version + 1n;
893
+ const timestamp = yield* now;
894
+ yield* store.append({
895
+ id,
896
+ type,
897
+ version,
898
+ action,
899
+ timestamp,
900
+ ...content,
901
+ actorId,
902
+ proposedBy,
903
+ });
904
+ return { id, version };
905
+ });
906
+
907
+ /** The current state: the head's snapshot. A head without a snapshot is a broken chain — a defect, not a miss. */
908
+ const current = (id: string) =>
909
+ Effect.gen(function* () {
910
+ const head = yield* store.head(id);
911
+ if (Option.isNone(head)) return Option.none();
912
+ const snapshot = yield* store.snapshot(id, head.value.version);
913
+ if (Option.isNone(snapshot)) {
914
+ return yield* Effect.die(new BrokenChain({ type, id, version: head.value.version }));
915
+ }
916
+ return Option.some(snapshot.value);
917
+ });
918
+
919
+ /**
920
+ * The head's full state for a hook to see, read only when there is a hook
921
+ * (`restoreVersion` otherwise needs the head entry alone).
922
+ */
923
+ const hookPrior = (id: string) =>
924
+ beforeAppend === undefined && beforeAppendLocales === undefined
925
+ ? Effect.succeed(Option.none<VersionSnapshot>())
926
+ : current(id);
927
+
928
+ const create = Effect.fn(`${type}.create`)(function* (input: CreateInput) {
929
+ const id = Option.isSome(input.id) ? input.id.value : yield* store.mintId;
930
+ if (Option.isSome(input.id) && Option.isSome(yield* store.head(id))) {
931
+ return yield* new NodeAlreadyExists({ type, id });
932
+ }
933
+ const content = yield* nextState(
934
+ {
935
+ action: "create",
936
+ id,
937
+ prior: Option.none(),
938
+ patch: input.state,
939
+ locales: input.locales ?? {},
940
+ actorId: input.actorId,
941
+ },
942
+ { state: {} },
943
+ );
944
+ const timestamp = yield* now;
945
+ yield* store
946
+ .append({
947
+ id,
948
+ type,
949
+ version: 0n,
950
+ action: "create",
951
+ timestamp,
952
+ ...content,
953
+ actorId: input.actorId,
954
+ proposedBy: Option.none(),
955
+ })
956
+ .pipe(
957
+ // A racing create that took the id between the check above and this
958
+ // append surfaces as the store's version-0 collision.
959
+ Effect.mapError((error) =>
960
+ isVersionConflict(error) && error.version === 0n
961
+ ? new NodeAlreadyExists({ type, id })
962
+ : error,
963
+ ),
964
+ );
965
+ return { id, version: 0n };
966
+ });
967
+
968
+ const merge = (input: UpdateInput, allowDeleted: boolean) =>
969
+ Effect.gen(function* () {
970
+ const head = yield* current(input.id);
971
+ if (Option.isNone(head) || (head.value.action === "delete" && !allowDeleted)) {
972
+ return yield* new NodeNotFound({ type, id: input.id });
973
+ }
974
+ const content = yield* nextState(
975
+ {
976
+ action: allowDeleted ? "restore" : "update",
977
+ id: input.id,
978
+ prior: head,
979
+ patch: input.patch,
980
+ locales: input.locales ?? {},
981
+ actorId: input.actorId,
982
+ },
983
+ head.value,
984
+ );
985
+ return yield* appendNext(
986
+ input.id,
987
+ head.value,
988
+ "update",
989
+ content,
990
+ input.actorId,
991
+ input,
992
+ input.proposedBy,
993
+ );
994
+ });
995
+
996
+ const update = Effect.fn(`${type}.update`)(function* (input: UpdateInput) {
997
+ return yield* merge(input, false);
998
+ });
999
+
1000
+ const restore = Effect.fn(`${type}.restore`)(function* (input: UpdateInput) {
1001
+ return yield* merge(input, true);
1002
+ });
1003
+
1004
+ const softDelete = Effect.fn(`${type}.delete`)(function* (
1005
+ id: string,
1006
+ actorId: Option.Option<string>,
1007
+ precondition: Precondition = {},
1008
+ ) {
1009
+ const head = yield* current(id);
1010
+ if (Option.isNone(head)) return yield* new NodeNotFound({ type, id });
1011
+ if (head.value.action === "delete") {
1012
+ return { id, version: head.value.version, alreadyDeleted: true };
1013
+ }
1014
+ const content = yield* nextState(
1015
+ { action: "delete", id, prior: head, patch: {}, actorId },
1016
+ head.value,
1017
+ );
1018
+ const written = yield* appendNext(id, head.value, "delete", content, actorId, precondition);
1019
+ return { ...written, alreadyDeleted: false };
1020
+ });
1021
+
1022
+ const getVersion = Effect.fn(`${type}.getVersion`)(function* (id: string, version: bigint) {
1023
+ const snapshot = yield* store.snapshot(id, version);
1024
+ if (Option.isNone(snapshot)) return yield* new VersionNotFound({ type, id, version });
1025
+ return snapshot.value;
1026
+ });
1027
+
1028
+ const compare = Effect.fn(`${type}.compare`)(function* (id: string, from: bigint, to: bigint) {
1029
+ const before = yield* getVersion(id, from);
1030
+ const after = yield* getVersion(id, to);
1031
+ return {
1032
+ from: before,
1033
+ to: after,
1034
+ changes: diffStates(before.state, after.state),
1035
+ locales: diffLocales(localesOf(before), localesOf(after)),
1036
+ };
1037
+ });
1038
+
1039
+ const restoreVersion = Effect.fn(`${type}.restoreVersion`)(function* (
1040
+ id: string,
1041
+ version: bigint,
1042
+ actorId: Option.Option<string>,
1043
+ precondition: Precondition = {},
1044
+ ) {
1045
+ // Read the whole historical state FIRST, so an unreconstructable version
1046
+ // fails before the chain is touched at all.
1047
+ const snapshot = yield* store.snapshot(id, version);
1048
+ if (Option.isNone(snapshot)) return yield* new VersionNotFound({ type, id, version });
1049
+ const prior = yield* hookPrior(id);
1050
+ const head = Option.isSome(prior) ? prior : yield* store.head(id);
1051
+ if (Option.isNone(head)) return yield* new VersionNotFound({ type, id, version });
1052
+ // Every locale is restored together with the shared columns: the
1053
+ // locale patch is the whole historical level, over nothing.
1054
+ const content = yield* nextState(
1055
+ {
1056
+ action: "restoreVersion",
1057
+ id,
1058
+ prior,
1059
+ patch: snapshot.value.state,
1060
+ locales: localesOf(snapshot.value),
1061
+ actorId,
1062
+ },
1063
+ { state: {} },
1064
+ );
1065
+ // `update`, not a fourth action: a restore is an ordinary edit whose
1066
+ // content came from history, and `action` stays the currency / tombstone
1067
+ // axis every fail-closed view reads (#864 A8).
1068
+ return yield* appendNext(id, head.value, "update", content, actorId, precondition);
1069
+ });
1070
+
1071
+ const history = Effect.fn(`${type}.history`)(function* (
1072
+ id: string,
1073
+ limit: number = MAX_HISTORY_LIMIT,
1074
+ ) {
1075
+ const bounded = Math.max(1, Math.min(Math.trunc(limit), MAX_HISTORY_LIMIT));
1076
+ const entries = yield* store.entries(id, bounded);
1077
+ return yield* Array.match(entries, {
1078
+ onEmpty: () => Effect.fail(new NodeNotFound({ type, id })),
1079
+ onNonEmpty: (nonEmpty) => Effect.succeed<ReadonlyArray<VersionChainEntry>>(nonEmpty),
1080
+ });
1081
+ });
1082
+
1083
+ return {
1084
+ create,
1085
+ update,
1086
+ restore,
1087
+ delete: softDelete,
1088
+ getVersion,
1089
+ compare,
1090
+ restoreVersion,
1091
+ history,
1092
+ };
1093
+ };