@thomasfosterau/pulpit 0.0.0-stage → 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 +1272 -2
  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 +145 -3
  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,702 @@
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
+ import * as Effect from "effect/Effect";
67
+ import * as Option from "effect/Option";
68
+ import * as Schema from "effect/Schema";
69
+ /**
70
+ * What one version row recorded: the create, an edit (restores included), or a tombstone.
71
+ *
72
+ * @since 0.1.0
73
+ */
74
+ export declare const VersionAction: Schema.Literals<readonly ["create", "update", "delete"]>;
75
+ /**
76
+ * The {@link VersionAction} literal union: `"create" | "update" | "delete"`.
77
+ *
78
+ * @since 0.1.0
79
+ */
80
+ export type VersionAction = typeof VersionAction.Type;
81
+ /**
82
+ * One column's value in a leaf's state: a JSON value, or one of the two
83
+ * non-JSON scalars a Drizzle row carries (`Date` for timestamps, `bigint` for
84
+ * `bigint` columns). A content-addressed document is carried as its
85
+ * `DocumentHash` (`./documents`' `documentRef`), never as a copy; a document
86
+ * a leaf keeps inline is its parsed JSON.
87
+ *
88
+ * @since 0.1.0
89
+ */
90
+ export type ColumnValue = Schema.Json | Date | bigint;
91
+ /**
92
+ * A leaf's domain state at one version: every column of every level of its
93
+ * chain, keyed by column name (the leaf-most level wins on a shared name).
94
+ * Generated columns (a `search_vector`) are not part of it — they regenerate.
95
+ *
96
+ * @since 0.1.0
97
+ */
98
+ export type ChainState = {
99
+ readonly [column: string]: ColumnValue;
100
+ };
101
+ /**
102
+ * A tri-state patch over a {@link ChainState}: a value sets, `null` clears,
103
+ * an absent or `undefined` key carries the previous version's value forward.
104
+ *
105
+ * @since 0.1.0
106
+ */
107
+ export type ChainPatch = {
108
+ readonly [column: string]: ColumnValue | undefined;
109
+ };
110
+ /**
111
+ * A localised leaf's locale level at one version (pulpit#56, model (b)): each
112
+ * locale the version carries, keyed by its tag (`"en"`, `"fr-CA"`), and that
113
+ * locale's translated columns. The node's shared (untranslated) columns stay
114
+ * in its {@link ChainState}; a translated column lives here only. A store
115
+ * keeps it as the locale level's fact rows, keyed `(id, version, locale)`.
116
+ * A version that carries no locale has none; an absent `locales` and `{}`
117
+ * mean the same.
118
+ *
119
+ * @since 0.1.0
120
+ */
121
+ export type LocaleStates = {
122
+ readonly [locale: string]: ChainState;
123
+ };
124
+ /**
125
+ * A tri-state patch over a {@link LocaleStates}, per locale: a
126
+ * {@link ChainPatch} updates that locale's columns (starting from none for a
127
+ * locale the version did not carry), `null` drops the locale, and an absent
128
+ * or `undefined` key carries it forward unchanged.
129
+ *
130
+ * @since 0.1.0
131
+ */
132
+ export type LocalePatch = {
133
+ readonly [locale: string]: ChainPatch | null | undefined;
134
+ };
135
+ /**
136
+ * One entry in a node's append-only version chain.
137
+ *
138
+ * @since 0.1.0
139
+ */
140
+ export interface VersionChainEntry {
141
+ /** The version number (0 is the create). */
142
+ readonly version: bigint;
143
+ /** The edit action this version recorded. */
144
+ readonly action: VersionAction;
145
+ /** When the version was written. */
146
+ readonly timestamp: Date;
147
+ }
148
+ /**
149
+ * One version's reconstructed state, for the edits (browse / compare) surface.
150
+ *
151
+ * @since 0.1.0
152
+ */
153
+ export interface VersionSnapshot extends VersionChainEntry {
154
+ /** The shared (untranslated) columns, or every column of a leaf with no locale level. */
155
+ readonly state: ChainState;
156
+ /**
157
+ * A localised leaf's locale level at this version (pulpit#56); absent, the
158
+ * version carries no locale. A store reconstructs it with the rest of the
159
+ * snapshot, or reports the version absent.
160
+ */
161
+ readonly locales?: LocaleStates | undefined;
162
+ }
163
+ /**
164
+ * The row a write appends: the entry, the node's leaf type, the acting account, the full state.
165
+ *
166
+ * @since 0.1.0
167
+ */
168
+ export interface VersionRow extends VersionSnapshot {
169
+ readonly id: string;
170
+ /** The concrete leaf's wire type, threaded onto the node row at version 0 (#864 S3). */
171
+ readonly type: string;
172
+ /** The acting account (#1581) — recorded on the version row, never inferred. */
173
+ readonly actorId: Option.Option<string>;
174
+ /**
175
+ * The contributor whose accepted proposal this version applies
176
+ * (`./proposals`), recorded beside the actor, who is the account that
177
+ * accepted it; none for every other write. A store persists it with the
178
+ * version row (README → Seams → `VersionChainStore`).
179
+ */
180
+ readonly proposedBy: Option.Option<string>;
181
+ }
182
+ /**
183
+ * What a chain write returns: the node and the version it landed at.
184
+ *
185
+ * @since 0.1.0
186
+ */
187
+ export interface ChainWrite {
188
+ readonly id: string;
189
+ readonly version: bigint;
190
+ }
191
+ declare const NodeNotFound_base: Schema.Class<NodeNotFound, Schema.TaggedStruct<"NodeNotFound", {
192
+ readonly type: Schema.String;
193
+ readonly id: Schema.String;
194
+ }>, import("effect/Cause").YieldableError & {
195
+ readonly brand: unique symbol;
196
+ }>;
197
+ /**
198
+ * No such node, or (for a plain update) a tombstoned one: the wire's 404.
199
+ *
200
+ * @since 0.1.0
201
+ */
202
+ export declare class NodeNotFound extends NodeNotFound_base {
203
+ }
204
+ declare const VersionNotFound_base: Schema.Class<VersionNotFound, Schema.TaggedStruct<"VersionNotFound", {
205
+ readonly type: Schema.String;
206
+ readonly id: Schema.String;
207
+ readonly version: Schema.BigInt;
208
+ }>, import("effect/Cause").YieldableError & {
209
+ readonly brand: unique symbol;
210
+ }>;
211
+ /**
212
+ * No reconstructable version at `(id, version)`: an unknown version, or a partial chain.
213
+ *
214
+ * @since 0.1.0
215
+ */
216
+ export declare class VersionNotFound extends VersionNotFound_base {
217
+ }
218
+ declare const NodeAlreadyExists_base: Schema.Class<NodeAlreadyExists, Schema.TaggedStruct<"NodeAlreadyExists", {
219
+ readonly type: Schema.String;
220
+ readonly id: Schema.String;
221
+ }>, import("effect/Cause").YieldableError & {
222
+ readonly brand: unique symbol;
223
+ }>;
224
+ /**
225
+ * A create whose caller-supplied id already belongs to a node — never an upsert: the wire's 409.
226
+ *
227
+ * @since 0.1.0
228
+ */
229
+ export declare class NodeAlreadyExists extends NodeAlreadyExists_base {
230
+ }
231
+ declare const VersionConflict_base: Schema.Class<VersionConflict, Schema.TaggedStruct<"VersionConflict", {
232
+ readonly id: Schema.String;
233
+ readonly version: Schema.BigInt;
234
+ }>, import("effect/Cause").YieldableError & {
235
+ readonly brand: unique symbol;
236
+ }>;
237
+ /**
238
+ * The store refused an append because `(id, version)` is already taken — a
239
+ * concurrent writer got there first. Raised by the store (the `(id, version)`
240
+ * primary key is what enforces append-only under concurrency); never retried
241
+ * here, because a retry would silently base the write on a version the
242
+ * caller never read.
243
+ *
244
+ * @since 0.1.0
245
+ */
246
+ export declare class VersionConflict extends VersionConflict_base {
247
+ }
248
+ declare const StaleVersion_base: Schema.Class<StaleVersion, Schema.TaggedStruct<"StaleVersion", {
249
+ readonly type: Schema.String;
250
+ readonly id: Schema.String;
251
+ /** The version the caller expected the head to be. */
252
+ readonly expected: Schema.BigInt;
253
+ /** The head version the write found. */
254
+ readonly current: Schema.BigInt;
255
+ }>, import("effect/Cause").YieldableError & {
256
+ readonly brand: unique symbol;
257
+ }>;
258
+ /**
259
+ * An expected-version precondition that no longer holds: the head moved past
260
+ * the version the caller last read, so the write would have overwritten an
261
+ * edit the caller never saw. Nothing was written. Carries the head the write
262
+ * would have extended (`current`), so the caller can re-read and re-apply;
263
+ * the wire's 412 (a failed `If-Match`) or 409.
264
+ *
265
+ * A writer that passes the check but loses the race to the append fails
266
+ * {@link VersionConflict} instead: the same stale read, caught one step later
267
+ * by the store's key.
268
+ *
269
+ * @since 0.1.0
270
+ */
271
+ export declare class StaleVersion extends StaleVersion_base {
272
+ }
273
+ declare const BrokenChain_base: Schema.Class<BrokenChain, Schema.TaggedStruct<"BrokenChain", {
274
+ readonly type: Schema.String;
275
+ readonly id: Schema.String;
276
+ readonly version: Schema.BigInt;
277
+ }>, import("effect/Cause").YieldableError & {
278
+ readonly brand: unique symbol;
279
+ }>;
280
+ /**
281
+ * The chain's head version has no reconstructable state — the store broke its
282
+ * own contract (a head it reports must be readable). A defect, never a typed
283
+ * failure: there is nothing a caller could do about it.
284
+ *
285
+ * @since 0.1.0
286
+ */
287
+ export declare class BrokenChain extends BrokenChain_base {
288
+ }
289
+ /**
290
+ * The persistence seam the chain operations run over. `E` is the store's own
291
+ * failure (a Drizzle store's `EffectDrizzleQueryError`); `R` its requirements
292
+ * (a Drizzle store's `CurrentTransaction` — the operations never open a
293
+ * transaction themselves: `transact` is always-open and the boundary belongs
294
+ * to the caller, website #1883).
295
+ *
296
+ * Every method reads or writes exactly what it says and applies no policy.
297
+ *
298
+ * @since 0.1.0
299
+ */
300
+ export interface VersionChainStore<E, R> {
301
+ /** The node's latest version entry, or none for an id with no versions. */
302
+ readonly head: (id: string) => Effect.Effect<Option.Option<VersionChainEntry>, E, R>;
303
+ /**
304
+ * The full state at `(id, version)`, or none when the version does not
305
+ * exist OR any level of the chain has no row there — a version that cannot
306
+ * be fully reconstructed is reported as absent, never as a partial state.
307
+ * On a localised leaf the snapshot carries its locale level too: every
308
+ * `(id, version, locale)` fact row, as `locales` (pulpit#56).
309
+ */
310
+ readonly snapshot: (id: string, version: bigint) => Effect.Effect<Option.Option<VersionSnapshot>, E, R>;
311
+ /**
312
+ * Append one version row — at version 0 together with the node's identity
313
+ * row, and one locale-level row per locale `row.locales` carries. Fails {@link VersionConflict} when `(id, version)` already exists
314
+ * (at version 0: the id already belongs to a node).
315
+ */
316
+ readonly append: (row: VersionRow) => Effect.Effect<void, E | VersionConflict, R>;
317
+ /** The chain's entries, oldest first, at most `limit` of them. */
318
+ readonly entries: (id: string, limit: number) => Effect.Effect<ReadonlyArray<VersionChainEntry>, E, R>;
319
+ /** Mint a fresh node id (a UUID) when the caller supplies none. */
320
+ readonly mintId: Effect.Effect<string, E, R>;
321
+ }
322
+ /**
323
+ * The hard cap on a history read (`PERF-DB-001`): a chain is tiny in practice; this bounds a pathological one.
324
+ *
325
+ * @since 0.1.0
326
+ */
327
+ export declare const MAX_HISTORY_LIMIT = 1000;
328
+ /**
329
+ * Apply a tri-state patch to a state: a value sets, `null` clears, `undefined` carries forward.
330
+ *
331
+ * @since 0.1.0
332
+ */
333
+ export declare const applyPatch: (state: ChainState, patch: ChainPatch) => {
334
+ [x: string]: ColumnValue;
335
+ };
336
+ /**
337
+ * Apply a {@link LocalePatch} to a locale level: a patch updates its locale
338
+ * (from nothing for a locale `locales` lacks) with {@link applyPatch}, `null`
339
+ * drops the locale, `undefined` carries it forward. Pure and total.
340
+ *
341
+ * @example
342
+ * ```ts
343
+ * import { deepStrictEqual } from "node:assert";
344
+ * import { applyLocalePatch } from "@thomasfosterau/pulpit/version-chain";
345
+ *
346
+ * const before = { en: { title: "Hello" }, fr: { title: "Bonjour" } };
347
+ * deepStrictEqual(applyLocalePatch(before, { en: { title: "Hi" }, fr: null, de: { title: "Hallo" } }), {
348
+ * en: { title: "Hi" },
349
+ * de: { title: "Hallo" },
350
+ * });
351
+ * ```
352
+ *
353
+ * @since 0.1.0
354
+ */
355
+ export declare const applyLocalePatch: (locales: LocaleStates, patch: LocalePatch) => LocaleStates;
356
+ /**
357
+ * The locale level a snapshot carries, `{}` for none.
358
+ *
359
+ * @since 0.1.0
360
+ */
361
+ export declare const localesOf: (snapshot: Pick<VersionSnapshot, "locales">) => LocaleStates;
362
+ /**
363
+ * How one column differs between two states, read from the first state to
364
+ * the second: `added` (absent before, present after), `removed` (present
365
+ * before, absent after) or `changed` (present on both sides, structurally
366
+ * unequal). A `null` is a present value: a column cleared to `null` is
367
+ * `changed`, never `removed`, and a column absent from a state is a column
368
+ * that state does not have, not one it cleared.
369
+ *
370
+ * @since 0.1.0
371
+ */
372
+ export type AttributeChange = {
373
+ readonly _tag: "added";
374
+ readonly after: ColumnValue;
375
+ } | {
376
+ readonly _tag: "removed";
377
+ readonly before: ColumnValue;
378
+ } | {
379
+ readonly _tag: "changed";
380
+ readonly before: ColumnValue;
381
+ readonly after: ColumnValue;
382
+ };
383
+ /**
384
+ * The field-level change set between two states, keyed by column: one
385
+ * {@link AttributeChange} per column that differs. An unchanged column has
386
+ * no key, so two equal states diff to `{}`.
387
+ *
388
+ * @since 0.1.0
389
+ */
390
+ export type StateDiff = {
391
+ readonly [column: string]: AttributeChange;
392
+ };
393
+ /**
394
+ * The field-level change set from state `from` to state `to`: every column
395
+ * that is added, removed or changed, with its before and after values;
396
+ * unchanged columns are omitted. Pure and total, with no error channel.
397
+ *
398
+ * Presence is read per key, so the tri-state distinction survives: a column
399
+ * holding `null` is present (a clear), and only a column with no key is
400
+ * absent. Values compare structurally with `Equal.equals`: JSON objects and
401
+ * arrays by content (key order ignored), a `Date` by its instant, a `bigint`
402
+ * by value, and a `Date` never equals the string of its instant. A
403
+ * content-addressed document is held as its `DocumentHash`, so it compares by
404
+ * hash: changed or unchanged, never a textual diff, which is the UI's job.
405
+ *
406
+ * The diff is directed: swapping the arguments swaps `added` and `removed`
407
+ * and each change's `before` and `after`. A state's values are compared as
408
+ * immutable (Effect caches structural hashes), as a {@link ChainState} is.
409
+ *
410
+ * @since 0.1.0
411
+ */
412
+ export declare const diffStates: (from: ChainState, to: ChainState) => StateDiff;
413
+ /**
414
+ * How one locale differs between two versions' locale levels, read from the
415
+ * first to the second: `added` (the second carries it, the first did not,
416
+ * with its columns), `removed` (the reverse) or `changed` (both carry it, and
417
+ * its columns differ: their {@link StateDiff}).
418
+ *
419
+ * @since 0.1.0
420
+ */
421
+ export type LocaleChange = {
422
+ readonly _tag: "added";
423
+ readonly after: ChainState;
424
+ } | {
425
+ readonly _tag: "removed";
426
+ readonly before: ChainState;
427
+ } | {
428
+ readonly _tag: "changed";
429
+ readonly changes: StateDiff;
430
+ };
431
+ /**
432
+ * The per-locale change set between two locale levels, keyed by locale: one
433
+ * {@link LocaleChange} per locale that differs. An unchanged locale has no
434
+ * key.
435
+ *
436
+ * @since 0.1.0
437
+ */
438
+ export type LocaleDiff = {
439
+ readonly [locale: string]: LocaleChange;
440
+ };
441
+ /**
442
+ * The per-locale change set from locale level `from` to locale level `to`
443
+ * (pulpit#56): every locale added, removed, or whose columns changed by
444
+ * {@link diffStates}; an unchanged locale is omitted. Pure and total.
445
+ *
446
+ * @example
447
+ * ```ts
448
+ * import { deepStrictEqual } from "node:assert";
449
+ * import { diffLocales } from "@thomasfosterau/pulpit/version-chain";
450
+ *
451
+ * deepStrictEqual(
452
+ * diffLocales({ en: { title: "Hello" }, fr: { title: "Bonjour" } }, { en: { title: "Hi" }, de: { title: "Hallo" } }),
453
+ * {
454
+ * en: { _tag: "changed", changes: { title: { _tag: "changed", before: "Hello", after: "Hi" } } },
455
+ * fr: { _tag: "removed", before: { title: "Bonjour" } },
456
+ * de: { _tag: "added", after: { title: "Hallo" } },
457
+ * },
458
+ * );
459
+ * ```
460
+ *
461
+ * @since 0.1.0
462
+ */
463
+ export declare const diffLocales: (from: LocaleStates, to: LocaleStates) => LocaleDiff;
464
+ /**
465
+ * What {@link VersionChainOps.compare} returns: both versions as read
466
+ * (entry and full state) and the {@link StateDiff} from the first to the
467
+ * second.
468
+ *
469
+ * A tombstone is not an attribute: it is the version's `action`, so it shows
470
+ * as `from.action` / `to.action` being `"delete"` and never as a key in
471
+ * `changes`. A tombstone carries its state forward, so a version compared
472
+ * with the tombstone that followed it has no changes; a history view labels
473
+ * the version deleted from its action and lists only what the content did.
474
+ *
475
+ * @since 0.1.0
476
+ */
477
+ export interface VersionComparison {
478
+ /** The version compared from, with its state. */
479
+ readonly from: VersionSnapshot;
480
+ /** The version compared to, with its state. */
481
+ readonly to: VersionSnapshot;
482
+ /** `diffStates(from.state, to.state)`: the shared columns. */
483
+ readonly changes: StateDiff;
484
+ /**
485
+ * `diffLocales` over the two versions' locale levels (pulpit#56): the
486
+ * per-locale changes, `{}` for a leaf with no locale level. Kept beside
487
+ * `changes` rather than inside it, since a column may itself be named
488
+ * `locales`.
489
+ */
490
+ readonly locales: LocaleDiff;
491
+ }
492
+ /**
493
+ * The input to {@link VersionChainOps.create}: the node id, the leaf's
494
+ * initial state, the acting account.
495
+ *
496
+ * @since 0.1.0
497
+ */
498
+ export interface CreateInput {
499
+ /** A caller-supplied node id (client-generated ids); minted by the store when absent. */
500
+ readonly id: Option.Option<string>;
501
+ /** The leaf's full state at version 0: its shared columns, on a localised leaf. */
502
+ readonly state: ChainState;
503
+ /** A localised leaf's locale level at version 0 (pulpit#56); absent, none. */
504
+ readonly locales?: LocaleStates | undefined;
505
+ /** The acting account, recorded on the version row; none for a write with no session behind it. */
506
+ readonly actorId: Option.Option<string>;
507
+ }
508
+ /**
509
+ * An optimistic-concurrency precondition on a write to an existing node: the
510
+ * head version the caller last read. Every write that extends a chain
511
+ * ({@link VersionChainOps.update}, {@link VersionChainOps.restore},
512
+ * {@link VersionChainOps.delete}, {@link VersionChainOps.restoreVersion})
513
+ * accepts one.
514
+ *
515
+ * @since 0.1.0
516
+ */
517
+ export interface Precondition {
518
+ /**
519
+ * The head version the caller last read. When present, the write fails
520
+ * {@link StaleVersion}, writing nothing, unless the head it extends is
521
+ * exactly this version; absent (or `undefined`), the write extends whatever
522
+ * the head is.
523
+ */
524
+ readonly expectedVersion?: bigint | undefined;
525
+ }
526
+ /**
527
+ * The input to {@link VersionChainOps.update} and
528
+ * {@link VersionChainOps.restore}: the node, a tri-state patch, the acting
529
+ * account, and an optional expected version.
530
+ *
531
+ * @since 0.1.0
532
+ */
533
+ export interface UpdateInput extends Precondition {
534
+ /** The node to append to. */
535
+ readonly id: string;
536
+ /**
537
+ * The tri-state patch over the current state: a value sets, `null` clears,
538
+ * an absent key carries forward.
539
+ */
540
+ readonly patch: ChainPatch;
541
+ /**
542
+ * A localised leaf's per-locale patch ({@link LocalePatch}, pulpit#56):
543
+ * absent, every locale carries forward. Even a write that touches one
544
+ * locale appends one version of the whole node.
545
+ */
546
+ readonly locales?: LocalePatch | undefined;
547
+ /** The acting account, recorded on the version row; none for a write with no session behind it. */
548
+ readonly actorId: Option.Option<string>;
549
+ /**
550
+ * The contributor whose proposal this edit applies, recorded on the
551
+ * version row as `proposedBy`; absent (or `undefined`) for an ordinary
552
+ * edit. `./proposals`' `accept` sets it.
553
+ */
554
+ readonly proposedBy?: Option.Option<string> | undefined;
555
+ }
556
+ /**
557
+ * The chain operations for one leaf type, bound to a store.
558
+ *
559
+ * @since 0.1.0
560
+ */
561
+ export interface VersionChainOps<E, R> {
562
+ /** Insert the node and its version 0 (`create`). Fails {@link NodeAlreadyExists} on a taken id. */
563
+ readonly create: (input: CreateInput) => Effect.Effect<ChainWrite, E | NodeAlreadyExists | VersionConflict, R>;
564
+ /**
565
+ * Append an `update` version carrying the patch over the current state.
566
+ * Fails {@link NodeNotFound} for an unknown id and for a tombstoned node,
567
+ * and {@link StaleVersion} when `input.expectedVersion` is not the head.
568
+ */
569
+ readonly update: (input: UpdateInput) => Effect.Effect<ChainWrite, E | NodeNotFound | StaleVersion | VersionConflict, R>;
570
+ /**
571
+ * {@link update}, except a tombstone does not fail: the explicit restore
572
+ * surface (`VERSION-CHAIN-006`) — the node comes back live. Fails
573
+ * {@link NodeNotFound} for an unknown id and {@link StaleVersion} when
574
+ * `input.expectedVersion` is not the head.
575
+ */
576
+ readonly restore: (input: UpdateInput) => Effect.Effect<ChainWrite, E | NodeNotFound | StaleVersion | VersionConflict, R>;
577
+ /**
578
+ * Append a `delete` version carrying the state forward. Idempotent: on a
579
+ * tombstoned node it returns the existing tombstone with
580
+ * `alreadyDeleted: true` and writes nothing, whatever the precondition
581
+ * says (the delete it asked for has happened). Fails {@link NodeNotFound}
582
+ * for an unknown id and {@link StaleVersion} when a live node's head is
583
+ * not `precondition.expectedVersion`.
584
+ */
585
+ readonly delete: (id: string, actorId: Option.Option<string>, precondition?: Precondition) => Effect.Effect<ChainWrite & {
586
+ readonly alreadyDeleted: boolean;
587
+ }, E | NodeNotFound | StaleVersion | VersionConflict, R>;
588
+ /** The reconstructed state at `(id, version)`. Read-only. */
589
+ readonly getVersion: (id: string, version: bigint) => Effect.Effect<VersionSnapshot, E | VersionNotFound, R>;
590
+ /**
591
+ * The field-level change set from version `from` to version `to` of one
592
+ * node: two {@link VersionChainOps.getVersion} reads and {@link diffStates}.
593
+ * Read-only. Fails {@link VersionNotFound} for whichever version (`from`
594
+ * first) cannot be fully reconstructed. A tombstone on either side is that
595
+ * side's `action`, never an entry in `changes` (see
596
+ * {@link VersionComparison}). Ungated, like `getVersion`: `./subtype` gates
597
+ * history reads (`READ-009`), and its `SubtypeOps.compare` is this behind
598
+ * `getVersion`'s gate.
599
+ */
600
+ readonly compare: (id: string, from: bigint, to: bigint) => Effect.Effect<VersionComparison, E | VersionNotFound, R>;
601
+ /**
602
+ * Re-apply the full snapshot at `(id, version)` as a NEW `update` version —
603
+ * over a tombstone too. Fails {@link VersionNotFound}, before writing
604
+ * anything, when the version cannot be fully reconstructed, and
605
+ * {@link StaleVersion} when the head is not
606
+ * `precondition.expectedVersion` (the precondition is on the head the
607
+ * restore extends, not on the version it restores).
608
+ */
609
+ readonly restoreVersion: (id: string, version: bigint, actorId: Option.Option<string>, precondition?: Precondition) => Effect.Effect<ChainWrite, E | VersionNotFound | StaleVersion | VersionConflict, R>;
610
+ /**
611
+ * The chain, oldest first, at most `limit` entries (default and cap
612
+ * {@link MAX_HISTORY_LIMIT}). Fails {@link NodeNotFound} when the id has no
613
+ * versions, so a caller surfaces the 404 a missing resource would.
614
+ */
615
+ readonly history: (id: string, limit?: number) => Effect.Effect<ReadonlyArray<VersionChainEntry>, E | NodeNotFound, R>;
616
+ }
617
+ /**
618
+ * The write that is about to append: the chain's `action` vocabulary, with
619
+ * the two explicit restore surfaces named apart (`VERSION-CHAIN-006`), so a
620
+ * hook can tell a resurrection from an edit. `restore` and `restoreVersion`
621
+ * still append an `update` row. `publish` and `unpublish` are `./subtype`'s
622
+ * publication operations, which record a publication fact and append no
623
+ * version; the chain itself never emits them.
624
+ *
625
+ * @since 0.1.0
626
+ */
627
+ export declare const WriteAction: Schema.Literals<readonly ["create", "update", "delete", "restore", "restoreVersion", "publish", "unpublish"]>;
628
+ /**
629
+ * The {@link WriteAction} literal union.
630
+ *
631
+ * @since 0.1.0
632
+ */
633
+ export type WriteAction = typeof WriteAction.Type;
634
+ /**
635
+ * What a {@link ChainHooks.beforeAppend} hook is shown: the write, the node,
636
+ * the head it extends, and the patch it is about to apply.
637
+ *
638
+ * - `create` — `prior` is none, and `patch` is the whole initial state;
639
+ * - `update` / `restore` — `prior` is the head, `patch` the caller's tri-state patch;
640
+ * - `delete` — `prior` is the live head, `patch` is `{}` (the tombstone
641
+ * carries the head's state forward); an already tombstoned node writes
642
+ * nothing and runs no hook;
643
+ * - `restoreVersion` — `prior` is the head, `patch` the whole state of the
644
+ * version being restored.
645
+ *
646
+ * The version written is the hook's patch applied to `prior`'s state (to
647
+ * nothing for `create` and `restoreVersion`, whose patch is the whole state).
648
+ *
649
+ * On a localised leaf (pulpit#56) `locales` is the write's locale patch, in
650
+ * the same reading: the whole locale level for `create` and
651
+ * `restoreVersion`, the caller's {@link LocalePatch} for `update` /
652
+ * `restore`, `{}` for `delete`. `beforeAppend`'s patch is the shared
653
+ * columns' alone; `beforeAppendLocales` returns the locale patch the write
654
+ * applies (see {@link ChainHooks}).
655
+ *
656
+ * @since 0.1.0
657
+ */
658
+ export interface ChainAppendEvent {
659
+ readonly action: WriteAction;
660
+ readonly id: string;
661
+ /** The head the append extends (its full state), or none for a create. */
662
+ readonly prior: Option.Option<VersionSnapshot>;
663
+ readonly patch: ChainPatch;
664
+ /** The write's locale patch (see above); absent or `{}`, every locale carries forward. */
665
+ readonly locales?: LocalePatch | undefined;
666
+ /** The acting account the row will record. */
667
+ readonly actorId: Option.Option<string>;
668
+ }
669
+ /**
670
+ * The chain's hook point, bound once by {@link defineVersionChain}.
671
+ *
672
+ * @since 0.1.0
673
+ */
674
+ export interface ChainHooks<E, R> {
675
+ /**
676
+ * Runs after the head is read and before the row is appended, in the
677
+ * caller's transaction: its patch replaces the event's, and a failure
678
+ * (its `E`) fails the write with nothing appended. It sees the very head
679
+ * the append extends, so the expected-version check (`appendNext`) still
680
+ * runs against that head after it. Absent, nothing extra is read.
681
+ */
682
+ readonly beforeAppend?: ((event: ChainAppendEvent) => Effect.Effect<ChainPatch, E, R>) | undefined;
683
+ /**
684
+ * The locale level's counterpart of `beforeAppend` (pulpit#56): runs at the
685
+ * same point, shown the same event, and its {@link LocalePatch} replaces
686
+ * the event's `locales`, so a hook that validates or transforms a column
687
+ * does so in every locale too. Absent, the event's locale patch is applied
688
+ * as given.
689
+ */
690
+ readonly beforeAppendLocales?: ((event: ChainAppendEvent) => Effect.Effect<LocalePatch, E, R>) | undefined;
691
+ }
692
+ /**
693
+ * Bind the chain operations for one leaf `type` to a store. Every produced
694
+ * operation is traced as `${type}.<operation>`. `hooks.beforeAppend`, when
695
+ * given, runs before every append (see {@link ChainHooks}); its failure is
696
+ * each write's `E`.
697
+ *
698
+ * @since 0.1.0
699
+ */
700
+ export declare const defineVersionChain: <E, R>(type: string, store: VersionChainStore<E, R>, hooks?: ChainHooks<E, R>) => VersionChainOps<E, R>;
701
+ export {};
702
+ //# sourceMappingURL=version-chain.d.ts.map