@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.
- package/LICENSE +21 -0
- package/README.md +1272 -2
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +145 -3
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- 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
|
+
};
|