@thomasfosterau/pulpit 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
package/src/subtype.ts ADDED
@@ -0,0 +1,3095 @@
1
+ /**
2
+ * **The subtype operation factory** — a leaf's CRUD, version history and
3
+ * list, gated by its access kind, over the version chain and a
4
+ * current-version view.
5
+ *
6
+ * The behavioural spec is gardeneria's `apps/api/src/operations/subtype.ts`
7
+ * (`defineNodeSubtypeE`, `LeafAccess`, the gates `requireWriterE` /
8
+ * `requireCreatorE` / `stampOwnerE` / `ownedListFilterE`), its
9
+ * `packages/database/src/guards.ts` (`canViewOwnedE`, `ownedReadScopeE`) and
10
+ * website's `apps/api/src/operations/to-resource.ts`
11
+ * (`loadListVisibilityFilters`, the two-state list predicate). A `#NNN` here
12
+ * names a gardeneria issue.
13
+ *
14
+ * ## Access kinds
15
+ *
16
+ * - `reference` — corpus material. Create / update / delete / restore gate on
17
+ * the corpus-write rung; when the view has a `visibility` column, reads
18
+ * show only `public` rows below the view-private rung, apart from the
19
+ * soft-delete axis, and the `followers` rows whose declared
20
+ * `followersOfColumn` names the viewer or an account they follow
21
+ * (pulpit#92). A
22
+ * write or history read to an existing row also needs the row to be one
23
+ * `get` would serve the caller (pulpit#101).
24
+ * - `owned` — a member's own row. Create gates on any signed-in account and
25
+ * stamps the owner; every other write loads the owner and gates on
26
+ * owner-or-moderator (a missing owner is `NotFound`, never an open gate);
27
+ * `get` applies {@link canViewOwned} to the row; `list` applies the same
28
+ * facts as a {@link ListFilter} ({@link ownedListFilter}), so the two can
29
+ * never disagree.
30
+ *
31
+ * ## Invariants
32
+ *
33
+ * - A read (`get` / `getMany` / `list`) answers a hidden row as
34
+ * {@link NodeNotFound} or omits it, never {@link Forbidden}
35
+ * (`SEC-AUTH-ENUM-001`): a 403 would confirm the row exists. The write and
36
+ * history gate ({@link requireWriter}) does answer `Forbidden` for an
37
+ * existing owned row the caller doesn't own, whatever its visibility, so
38
+ * it deliberately confirms existence to a signed-in caller (pulpit#24;
39
+ * SECURITY.md → "Existence disclosure by the write gates"). On a reference
40
+ * leaf, a corpus writer's write or history read to a row `get` hides from
41
+ * them fails `NodeNotFound`, as `get` does, before the chain is reached
42
+ * (pulpit#101, `PUB-009`).
43
+ * - A list's order ({@link ListOrder}) only places the rows its filters
44
+ * chose: it never changes membership or the total, and a column that is
45
+ * not sortable (outside the leaf's `sortable` allow-list or, without one,
46
+ * the view's declared `columns`) fails {@link InvalidListOrder} before
47
+ * anything is read, never reaching the store (pulpit#112).
48
+ * - Soft-deleted rows are visible only to the view-deleted rung, and a list
49
+ * includes them only when the caller also asked (`includeDeleted`).
50
+ * - A declaration naming columns the view lacks fails closed — nothing lists,
51
+ * and `get` / `getMany` show nothing.
52
+ * - The follower seam, when a runtime does not provide one, answers "nobody
53
+ * follows anybody" — a `followers` row is then its owner's and the rung's
54
+ * alone. Fail closed.
55
+ *
56
+ * ## Lifecycle hooks
57
+ *
58
+ * A leaf's write side effects compose as {@link LifecycleHooks} (pulpit#45):
59
+ * before hooks run inside each write, after its gate and before the append,
60
+ * and may transform the patch or refuse the write ({@link WriteRejected});
61
+ * after hooks run in {@link subtypeHandlers} once the write's transaction
62
+ * has returned, and never fail it. The cache write-through is the first
63
+ * after hook ({@link cacheWriteThrough}). Events fire on explicit writes
64
+ * only: a future `publishedAt` arriving emits nothing (`./publication`'s
65
+ * `duePublications` drives an application's sweep instead).
66
+ *
67
+ * ## Seams
68
+ *
69
+ * Three things the kernel does not own, each an explicit parameter rather
70
+ * than an import (see the package README → Seams for each one's expected
71
+ * shape and which package, if any, fills it):
72
+ *
73
+ * - the **viewer** and the **role axes** ({@link Viewer}, {@link AccessPolicy})
74
+ * and the **follower** answer — effect-auth's session context, role ladder
75
+ * and `IsFollower`. Its read scope (`OwnedAccess` / `Guard` / `Ladder` /
76
+ * `Cacheability`) is on effect-auth's `trunk` but unpublished (release PR
77
+ * effect-auth#177);
78
+ * - the **current-version view** ({@link LeafStore}) — a Drizzle store over
79
+ * effect-drizzle-ext's `makeCurrentVersionView`;
80
+ * - the **handlers config** the factory's operations plug into
81
+ * ({@link subtypeHandlers}) — the record a JSON:API server's resource
82
+ * handlers are expected to accept (effect-jsonapi's `ResourceHandlers`,
83
+ * exported since 0.15.0 but not yet wired here; pulpit#5), with the
84
+ * write-through to the resource cache the application fills.
85
+ *
86
+ * @copyright Thomas Foster
87
+ * @module pulpit/subtype
88
+ * @since 0.1.0
89
+ */
90
+
91
+ import * as Array from "effect/Array";
92
+ import * as Arr from "effect/Array";
93
+ import * as Cause from "effect/Cause";
94
+ import * as Clock from "effect/Clock";
95
+ import * as DateTime from "effect/DateTime";
96
+ import * as Effect from "effect/Effect";
97
+ import * as Exit from "effect/Exit";
98
+ import * as HashMap from "effect/HashMap";
99
+ import * as HashSet from "effect/HashSet";
100
+ import * as Match from "effect/Match";
101
+ import * as Option from "effect/Option";
102
+ import * as Predicate from "effect/Predicate";
103
+ import * as Record from "effect/Record";
104
+ import * as Ref from "effect/Ref";
105
+ import * as Str from "effect/String";
106
+ import * as Schema from "effect/Schema";
107
+ import { ResourceHandlers, type Assemble, type Handlers } from "@thomasfosterau/effect-jsonapi";
108
+ import type { Visibility } from "./content.ts";
109
+ import {
110
+ always,
111
+ and,
112
+ anyOf,
113
+ eq,
114
+ isIn,
115
+ locale as localeIn,
116
+ LOCALE_COLUMN,
117
+ ne,
118
+ never,
119
+ or,
120
+ type ListFilter,
121
+ } from "./filter.ts";
122
+ import { byCodePoint } from "./internal/list-order.ts";
123
+ import { canViewOwnedRow, declaresColumns, ownedFacts } from "./internal/owned-columns.ts";
124
+ import { canViewReferenceRow, referenceListFilter } from "./internal/reference-columns.ts";
125
+ import {
126
+ defineVersionChain,
127
+ NodeNotFound,
128
+ type ChainPatch,
129
+ type ChainState,
130
+ type ChainWrite,
131
+ type ColumnValue,
132
+ type LocalePatch,
133
+ type LocaleStates,
134
+ type Precondition,
135
+ type StaleVersion,
136
+ type VersionAction,
137
+ type VersionChainStore,
138
+ type VersionComparison,
139
+ type VersionConflict,
140
+ type VersionNotFound,
141
+ type VersionSnapshot,
142
+ type NodeAlreadyExists,
143
+ type WriteAction,
144
+ type ChainAppendEvent,
145
+ MAX_HISTORY_LIMIT,
146
+ VersionNotFound as VersionNotFoundError,
147
+ } from "./version-chain.ts";
148
+
149
+ /** The write clock: the current instant, as the `Date` a fact stores. */
150
+ const nowDate: Effect.Effect<Date> = Effect.map(Clock.currentTimeMillis, (millis) =>
151
+ DateTime.toDate(DateTime.makeUnsafe(millis)),
152
+ );
153
+
154
+ // ── The viewer seam ───────────────────────────────────────────────────
155
+
156
+ /**
157
+ * The signed-in account the operation acts as: an id and a role on the application's ladder.
158
+ *
159
+ * @since 0.1.0
160
+ */
161
+ export interface ViewerAccount {
162
+ readonly id: string;
163
+ readonly role: string;
164
+ }
165
+
166
+ /**
167
+ * The viewer: an account, or none for an anonymous request (effect-auth's session context).
168
+ *
169
+ * @since 0.1.0
170
+ */
171
+ export type Viewer = Option.Option<ViewerAccount>;
172
+
173
+ /**
174
+ * The role axes the kernel asks about — each an application-named rung
175
+ * (`VIEW_PRIVATE_MIN_ROLE`, `VIEW_DELETED_MIN_ROLE`, `MODERATE_MIN_ROLE`,
176
+ * `CORPUS_WRITE_MIN_ROLE`) compared by the application's one
177
+ * `hasRoleAtLeast`, which must fail closed on an unknown role. effect-auth's
178
+ * role-ladder factory produces these.
179
+ *
180
+ * @since 0.1.0
181
+ */
182
+ export interface AccessPolicy {
183
+ /** May see `private` rows and drafts (gardeneria: moderator+; website: admin). */
184
+ readonly canViewPrivate: (account: ViewerAccount) => boolean;
185
+ /** May see soft-deleted rows (gardeneria: moderator+; website: admin). */
186
+ readonly canViewDeleted: (account: ViewerAccount) => boolean;
187
+ /** May write someone else's owned row (gardeneria: moderator+). */
188
+ readonly canModerate: (account: ViewerAccount) => boolean;
189
+ /** May write reference data directly (gardeneria: contributor+). */
190
+ readonly canWriteCorpus: (account: ViewerAccount) => boolean;
191
+ }
192
+
193
+ /**
194
+ * The follower seam — the `followers` visibility state's one external fact.
195
+ * `isFollower` answers one row's question; `following` answers a list's, as
196
+ * whatever the list filter's `in` node takes (a subquery in SQL, #1107).
197
+ *
198
+ * @since 0.1.0
199
+ */
200
+ export interface FollowerSeam<Following, R> {
201
+ /** Whether `followerId` follows `ownerId`: the single-row question `canViewOwned` asks. */
202
+ readonly isFollower: (followerId: string, ownerId: string) => Effect.Effect<boolean, never, R>;
203
+ /**
204
+ * The owners `followerId` follows, as the list filter's `in` set (a
205
+ * subquery in SQL, never a materialised array).
206
+ */
207
+ readonly following: (followerId: string) => Following;
208
+ }
209
+
210
+ /**
211
+ * The fail-closed follower seam: nobody follows anybody. `nobody` is the
212
+ * matches-nothing value of the `in` node (SQL: `(select null::uuid where false)`).
213
+ *
214
+ * @since 0.1.0
215
+ */
216
+ export const nobodyFollows = <Following>(nobody: Following): FollowerSeam<Following, never> => ({
217
+ isFollower: () => Effect.succeed(false),
218
+ following: () => nobody,
219
+ });
220
+
221
+ // ── Errors ────────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * No signed-in viewer where one is required: the wire's 401.
225
+ *
226
+ * @since 0.1.0
227
+ */
228
+ export class Unauthorized extends Schema.TaggedError<
229
+ Unauthorized,
230
+ { readonly brand: unique symbol }
231
+ >()("Unauthorized", {}) {}
232
+
233
+ /**
234
+ * A signed-in viewer below the rung a write needs: the wire's 403. Never used to hide a row.
235
+ *
236
+ * @since 0.1.0
237
+ */
238
+ export class Forbidden extends Schema.TaggedError<Forbidden, { readonly brand: unique symbol }>()(
239
+ "Forbidden",
240
+ {},
241
+ ) {}
242
+
243
+ /**
244
+ * A list order the leaf does not sort by: a key whose column is not sortable
245
+ * (outside the leaf's `sortable` allow-list or, without one, outside the
246
+ * view's declared `LeafStore.columns`), a direction other than `asc` /
247
+ * `desc`, or input not shaped as an order at all (an `orderBy` that is not
248
+ * an array, a key that is not an object, a `column` that is not a string).
249
+ * The wire's 400. The order is checked before the viewer or anything else is
250
+ * read, so it says nothing about any row, but **it does say which columns
251
+ * are sortable**: without a `sortable` allow-list, that a name is (or is
252
+ * not) one of the view's columns. `column` is the caller's own key, never
253
+ * passed to the store, or `""` when the key carries no string column
254
+ * (pulpit#112).
255
+ *
256
+ * @since 0.1.0
257
+ */
258
+ export class InvalidListOrder extends Schema.TaggedError<
259
+ InvalidListOrder,
260
+ { readonly brand: unique symbol }
261
+ >()("InvalidListOrder", {
262
+ type: Schema.String,
263
+ column: Schema.String,
264
+ }) {}
265
+
266
+ /**
267
+ * A leaf declared against the rules: a `sortable` entry that is not one of
268
+ * its view's `LeafStore.columns`. Thrown by {@link defineSubtype} when the
269
+ * leaf is declared, never an Effect failure, like `./edges`'
270
+ * `EdgeDeclarationError`: a misspelt allow-list entry must stop its module
271
+ * loading rather than silently leave a column unsortable (pulpit#112).
272
+ *
273
+ * @since 0.1.0
274
+ */
275
+ export class SubtypeDeclarationError extends Schema.TaggedError<
276
+ SubtypeDeclarationError,
277
+ { readonly brand: unique symbol }
278
+ >()("SubtypeDeclarationError", { message: Schema.String }) {}
279
+
280
+ // ── Read scope and the row predicate ──────────────────────────────────
281
+
282
+ /**
283
+ * The viewer's owned-material read scope — the three facts a LIST needs to
284
+ * build its filter from, and the set-shaped counterpart of
285
+ * {@link canViewOwned}:
286
+ *
287
+ * - `all` — the view-private rung: no visibility filter;
288
+ * - `public` — anonymous: `visibility = 'public'` only;
289
+ * - `member` — a signed-in account: `public`, OR their own rows, OR
290
+ * `followers` rows whose owner they follow.
291
+ *
292
+ * @since 0.1.0
293
+ */
294
+ export type ReadScope<Following> =
295
+ | { readonly kind: "all" }
296
+ | { readonly kind: "public" }
297
+ | { readonly kind: "member"; readonly userId: string; readonly following: Following };
298
+
299
+ /**
300
+ * Resolve a viewer's {@link ReadScope}.
301
+ *
302
+ * @since 0.1.0
303
+ */
304
+ export const readScopeFor = <Following>(
305
+ viewer: Viewer,
306
+ policy: AccessPolicy,
307
+ followers: FollowerSeam<Following, unknown>,
308
+ ): ReadScope<Following> =>
309
+ Option.match(viewer, {
310
+ onNone: (): ReadScope<Following> => ({ kind: "public" }),
311
+ onSome: (account): ReadScope<Following> =>
312
+ policy.canViewPrivate(account)
313
+ ? { kind: "all" }
314
+ : { kind: "member", userId: account.id, following: followers.following(account.id) },
315
+ });
316
+
317
+ /**
318
+ * The facts {@link canViewOwned} reads off an owned row. `draft: false` for a leaf without the axis.
319
+ *
320
+ * @since 0.1.0
321
+ */
322
+ export interface OwnedFacts {
323
+ readonly ownerId: string;
324
+ readonly visibility: Visibility;
325
+ readonly draft: boolean;
326
+ /**
327
+ * A moderated row whose state is not exactly `approved` (pulpit#154);
328
+ * `false` for a leaf without the axis.
329
+ */
330
+ readonly unapproved: boolean;
331
+ }
332
+
333
+ /**
334
+ * The owned-material read predicate — the single interpretation of the
335
+ * three-state visibility axis plus the orthogonal `draft` axis (#1118):
336
+ *
337
+ * | visibility | who may view |
338
+ * | ----------- | -------------------------------------------------------------- |
339
+ * | `public` | everyone, anonymous included |
340
+ * | `followers` | the owner; the owner's followers; the view-private rung |
341
+ * | `private` | the owner; the view-private rung |
342
+ *
343
+ * `draft: true` narrows whatever `visibility` allows down to the owner and
344
+ * the rung — a `public` draft is exactly as hidden as a `private` one — and
345
+ * so does `unapproved: true` on a moderated leaf (a `pending` or `rejected`
346
+ * row reads only to its author and the rung, pulpit#154). Never
347
+ * fails; callers turn `false` into {@link NodeNotFound}.
348
+ *
349
+ * `defineSubtype`'s `get` / `getMany` and `./edges`' `canViewEndpoint`
350
+ * apply the same predicate to the row they read. There, an owner column
351
+ * holding no string (a `NULL`, an absent key) reads as no owner: no viewer
352
+ * is its owner and the follower seam is never asked, so the row shows only
353
+ * when `public`, not a draft and not unapproved, or to the rung, as {@link ownedListFilter}
354
+ * lists it (`SEC-AUTH-ENUM-001`).
355
+ *
356
+ * @since 0.1.0
357
+ */
358
+ export const canViewOwned = <R>(
359
+ viewer: Viewer,
360
+ policy: AccessPolicy,
361
+ followers: FollowerSeam<unknown, R>,
362
+ row: OwnedFacts,
363
+ ): Effect.Effect<boolean, never, R> =>
364
+ canViewOwnedRow(viewer, policy, followers, { ...row, ownerId: Option.some(row.ownerId) });
365
+
366
+ // ── Leaf access and the list filters ──────────────────────────────────
367
+
368
+ /**
369
+ * The gate selector, mirroring the registry's `referenceData` ⊕ `owned`
370
+ * flags: an `owned` leaf names the VIEW columns its read paths consult,
371
+ * `draftColumn` only when the leaf actually writes the flag, and
372
+ * `moderationColumn` only when it is a moderated family (`./moderation`)
373
+ * whose unapproved rows its readers must not see (pulpit#154). A `reference`
374
+ * leaf names `followersOfColumn` only when its rows may be `followers`-visible
375
+ * (pulpit#92).
376
+ *
377
+ * @since 0.1.0
378
+ */
379
+ export type LeafAccess =
380
+ | {
381
+ readonly kind: "reference";
382
+ /**
383
+ * The view column that plays the owner's part for a `followers` row:
384
+ * it holds the id of the account whose followers may read the row
385
+ * (`PUB-009`). The column is the application's, and holds whatever id
386
+ * its follower seam takes; no id brand is implied. Only the followers
387
+ * check reads it: the account it names and that account's followers
388
+ * read its `followers` rows, but it gets no other owner rights (a
389
+ * `private` row stays the view-private rung's alone), and a value that
390
+ * is not a string names nobody. Absent, or naming a column the view
391
+ * lacks, a `followers` row is hidden below the view-private rung.
392
+ *
393
+ * @since 0.1.0
394
+ */
395
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so the existing `{ kind: "reference" }` declarations keep compiling and a caller can forward its own `undefined`; the omitted key is the absence, read as such by every reader
396
+ readonly followersOfColumn?: string | undefined;
397
+ }
398
+ | {
399
+ readonly kind: "owned";
400
+ readonly ownerColumn: string;
401
+ readonly visibilityColumn: string;
402
+ readonly draftColumn: Option.Option<string>;
403
+ /**
404
+ * The moderation state column (`ModerationState`). Declared, a row
405
+ * whose state is not exactly `approved` reads only to its owner and
406
+ * the view-private rung, as a draft does; absent, the leaf's reads
407
+ * ignore moderation and the rule is the application's (`PUB-005`).
408
+ * Owned leaves only: a reference leaf has no such axis.
409
+ *
410
+ * @since 0.1.0
411
+ */
412
+ readonly moderationColumn: Option.Option<string>;
413
+ };
414
+
415
+ /**
416
+ * The owned-leaf access declaration with the conventional factory-built column names.
417
+ *
418
+ * @since 0.1.0
419
+ */
420
+ export const ownedAccess = (
421
+ options: {
422
+ // 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`
423
+ readonly draft?: boolean | undefined;
424
+ // oxlint-disable-next-line effect/prefer-option-over-null -- as `draft`: the omitted key is "no moderation column"
425
+ readonly moderation?: boolean | undefined;
426
+ } = {},
427
+ ): Extract<LeafAccess, { kind: "owned" }> => ({
428
+ kind: "owned",
429
+ ownerColumn: "ownerId",
430
+ visibilityColumn: "visibility",
431
+ draftColumn: options.draft === true ? Option.some("draft") : Option.none(),
432
+ moderationColumn: options.moderation === true ? Option.some("moderationState") : Option.none(),
433
+ });
434
+
435
+ /**
436
+ * The owned-leaf LIST filter — {@link ReadScope} rendered over the leaf's
437
+ * declared view columns. When the leaf carries a draft column, every
438
+ * non-owner arm additionally requires `draft = false`, and when it carries a
439
+ * moderation column, `moderation = 'approved'`; the owner's own arm never
440
+ * does, so a member lists their own drafts and unapproved rows. Columns the view lacks
441
+ * fail closed.
442
+ *
443
+ * @since 0.1.0
444
+ */
445
+ export const ownedListFilter = <Following>(
446
+ access: Extract<LeafAccess, { kind: "owned" }>,
447
+ viewColumns: ReadonlySet<string>,
448
+ scope: ReadScope<Following>,
449
+ ): ListFilter<Following> => {
450
+ if (!declaresColumns(access, viewColumns)) return never;
451
+ const published = (filter: ListFilter<Following>): ListFilter<Following> =>
452
+ and(
453
+ filter,
454
+ ...Option.toArray(Option.map(access.draftColumn, (draft) => eq(draft, false))),
455
+ ...Option.toArray(
456
+ Option.map(access.moderationColumn, (moderation) => eq(moderation, "approved")),
457
+ ),
458
+ );
459
+ return Match.value(scope).pipe(
460
+ Match.discriminatorsExhaustive("kind")({
461
+ all: (): ListFilter<Following> => always,
462
+ public: () => published(eq(access.visibilityColumn, "public")),
463
+ member: (member) =>
464
+ or(
465
+ published(eq(access.visibilityColumn, "public")),
466
+ eq(access.ownerColumn, member.userId),
467
+ published(
468
+ and(
469
+ eq(access.visibilityColumn, "followers"),
470
+ isIn(access.ownerColumn, member.following),
471
+ ),
472
+ ),
473
+ ),
474
+ }),
475
+ );
476
+ };
477
+
478
+ /**
479
+ * The soft-delete arm of every list: tombstones only when the caller asked
480
+ * AND holds the view-deleted rung — asking without the rung changes nothing.
481
+ *
482
+ * @since 0.1.0
483
+ */
484
+ export const softDeleteFilter = <Following>(
485
+ viewer: Viewer,
486
+ policy: AccessPolicy,
487
+ includeDeleted: boolean,
488
+ ): ListFilter<Following> =>
489
+ includeDeleted && Option.exists(viewer, policy.canViewDeleted) ? always : ne("action", "delete");
490
+
491
+ /**
492
+ * The reference-leaf privacy arm (`PUB-009`): `visibility = 'public'` unless
493
+ * the viewer holds the view-private rung, or the view has no visibility
494
+ * column (an Entity leaf rides no such axis). Given a followers arm (the
495
+ * leaf's `access`, declaring `followersOfColumn` over a view that carries
496
+ * it, and the follower seam), a signed-in viewer also lists the `followers`
497
+ * rows whose column names them or an account they follow (pulpit#92):
498
+ * `visibility = 'followers' AND (<followersOfColumn> = <viewer> OR
499
+ * <followersOfColumn> IN (<following>))`. Without one (the default,
500
+ * `Option.none()`), a `followers` row is the view-private rung's alone.
501
+ * Pass `Option.some({ access, followers })` whenever the leaf declares
502
+ * `followersOfColumn`: `defineSubtype`'s `get` / `getMany` read the
503
+ * declared column, so the three-argument form agrees with them only for a
504
+ * leaf without it (for one with it, `get` serves `followers` rows this
505
+ * hides).
506
+ *
507
+ * @since 0.1.0
508
+ */
509
+ export const referencePrivacyFilter = <Following>(
510
+ viewer: Viewer,
511
+ policy: AccessPolicy,
512
+ viewColumns: ReadonlySet<string>,
513
+ followersArm: Option.Option<{
514
+ readonly access: Extract<LeafAccess, { kind: "reference" }>;
515
+ readonly followers: FollowerSeam<Following, unknown>;
516
+ }> = Option.none(),
517
+ ): ListFilter<Following> =>
518
+ Option.match(followersArm, {
519
+ onNone: () => referenceListFilter<Following>({}, viewColumns, publicOrAll(viewer, policy)),
520
+ onSome: (arm) =>
521
+ referenceListFilter(arm.access, viewColumns, readScopeFor(viewer, policy, arm.followers)),
522
+ });
523
+
524
+ /** A read scope without the follower set: a member reads as the anonymous do. */
525
+ const publicOrAll = <Following>(viewer: Viewer, policy: AccessPolicy): ReadScope<Following> =>
526
+ Option.exists(viewer, policy.canViewPrivate) ? { kind: "all" } : { kind: "public" };
527
+
528
+ // ── The write gates ───────────────────────────────────────────────────
529
+
530
+ const signedIn = (viewer: Viewer): Effect.Effect<ViewerAccount, Unauthorized> =>
531
+ Effect.fromOption(viewer, () => new Unauthorized());
532
+
533
+ const atRung = (
534
+ viewer: Viewer,
535
+ admits: (account: ViewerAccount) => boolean,
536
+ ): Effect.Effect<ViewerAccount, Unauthorized | Forbidden> =>
537
+ Effect.filterOrFail(signedIn(viewer), admits, () => new Forbidden());
538
+
539
+ /**
540
+ * The gate for a CREATE: reference data → the corpus-write rung; owned → any signed-in account.
541
+ *
542
+ * @since 0.1.0
543
+ */
544
+ export const requireCreator = (
545
+ access: LeafAccess,
546
+ viewer: Viewer,
547
+ policy: AccessPolicy,
548
+ ): Effect.Effect<ViewerAccount, Unauthorized | Forbidden> =>
549
+ access.kind === "reference" ? atRung(viewer, policy.canWriteCorpus) : signedIn(viewer);
550
+
551
+ /**
552
+ * The gate for a write to an EXISTING row: reference data → the corpus-write
553
+ * rung; owned material → the row's owner or the moderate rung. A missing
554
+ * owner on an owned leaf is {@link NodeNotFound}, never an open gate.
555
+ *
556
+ * On an owned leaf it never reads the row's visibility, draft or delete state:
557
+ * a signed-in caller who is neither the owner nor at the moderate rung gets
558
+ * {@link Forbidden} for any row with an owner record,
559
+ * hidden or not, so the gate deliberately confirms existence (pulpit#24, an
560
+ * exception to `SEC-AUTH-ENUM-001`; SECURITY.md → "Existence disclosure by the
561
+ * write gates"). An anonymous caller gets `Unauthorized` for every id.
562
+ *
563
+ * On a reference leaf this is the rung check alone: it reads no row, so it
564
+ * answers `Unauthorized` / `Forbidden` alike for every id. `defineSubtype`
565
+ * follows it with the read rung the row demands (pulpit#101): a row its `get`
566
+ * hides from the caller fails {@link NodeNotFound}, as `get` does
567
+ * (`PUB-009`, `SEC-AUTH-ENUM-001`). A caller using this gate directly on a
568
+ * reference row must apply that check too.
569
+ *
570
+ * @since 0.1.0
571
+ */
572
+ export const requireWriter = <E, R>(
573
+ access: LeafAccess,
574
+ viewer: Viewer,
575
+ policy: AccessPolicy,
576
+ row: { readonly type: string; readonly id: string },
577
+ ownerOf: Effect.Effect<Option.Option<string>, E, R>,
578
+ ): Effect.Effect<ViewerAccount, Unauthorized | Forbidden | NodeNotFound | E, R> =>
579
+ access.kind === "reference"
580
+ ? atRung(viewer, policy.canWriteCorpus)
581
+ : Effect.gen(function* () {
582
+ const account = yield* signedIn(viewer);
583
+ const owner = yield* ownerOf;
584
+ if (Option.isNone(owner)) return yield* new NodeNotFound(row);
585
+ if (account.id === owner.value || policy.canModerate(account)) return account;
586
+ return yield* new Forbidden();
587
+ });
588
+
589
+ // ── The current-version view seam ─────────────────────────────────────
590
+
591
+ /**
592
+ * A current-version view row: its node id, its head `version` and `action`,
593
+ * and the leaf's columns. The view projects `version` from the head row
594
+ * (pulpit#65), so a read carries the version an `If-Match` must name: a
595
+ * mapper stamps it on the wire with {@link versionMeta}, and a
596
+ * single-resource response's `ETag` is {@link versionEtag}. `id`, `version`
597
+ * and `action` are the view's own, so no leaf column may take those names.
598
+ *
599
+ * @since 0.1.0
600
+ */
601
+ export interface LeafRow {
602
+ readonly id: string;
603
+ /** The head version, the one an update's `expectedVersion` names. */
604
+ readonly version: bigint;
605
+ readonly action: VersionAction;
606
+ readonly [column: string]: ColumnValue;
607
+ }
608
+
609
+ /**
610
+ * The resource-object `meta` a {@link SubtypeDefinition.toResource} mapper
611
+ * stamps: the row's head version as a decimal string (`bigint` has no JSON
612
+ * form). It reaches only a resource the read already serves the viewer, so it
613
+ * discloses nothing about a hidden row (`SEC-AUTH-ENUM-001`); what it does
614
+ * disclose about a shown one is how many versions its chain holds.
615
+ *
616
+ * @example
617
+ * ```ts
618
+ * import { strictEqual } from "node:assert";
619
+ * import { versionMeta } from "@thomasfosterau/pulpit/subtype";
620
+ *
621
+ * const row = { id: "post-1", version: 3n, action: "update" as const, title: "Hello" };
622
+ * const resource = { type: "posts", id: row.id, meta: versionMeta(row) };
623
+ * strictEqual(resource.meta.version, "3");
624
+ * ```
625
+ *
626
+ * @since 0.1.0
627
+ */
628
+ export const versionMeta = (row: Pick<LeafRow, "version">): VersionMeta => ({
629
+ version: String(row.version),
630
+ });
631
+
632
+ /**
633
+ * The resource-object `meta` {@link versionMeta} stamps: the head version as
634
+ * a decimal string.
635
+ *
636
+ * @since 0.1.0
637
+ */
638
+ export interface VersionMeta {
639
+ readonly version: string;
640
+ }
641
+
642
+ /**
643
+ * What {@link versionEtag} reads: a resource object, by its optional `meta`.
644
+ *
645
+ * @since 0.1.0
646
+ */
647
+ export interface VersionedResource {
648
+ // oxlint-disable-next-line effect/prefer-option-over-null -- a JSON:API resource object's optional member, `?: T | undefined` (AGENTS.md → Conventions)
649
+ readonly meta?: object | undefined;
650
+ }
651
+
652
+ // A version is a non-negative integer with no leading zero: anything else in
653
+ // `meta.version` is not one this kernel stamped.
654
+ const StampedVersion = Schema.Struct({
655
+ meta: Schema.Struct({
656
+ version: Schema.String.check(
657
+ Schema.isPattern(/^(?:0|[1-9][0-9]*)$/, { description: "a head version in decimal" }),
658
+ ),
659
+ }),
660
+ });
661
+
662
+ /**
663
+ * A single-resource response's `ETag` header value, read from the
664
+ * resource's `meta.version` ({@link versionMeta}): a strong tag (`"3"`)
665
+ * whose value is the head version, so the `If-Match` a client echoes names
666
+ * the version its write expects ({@link UpdateRequest.expectedVersion}; the
667
+ * header's mapping onto it is effect-jsonapi's, which 0.17.0 lacks). None when the value carries no
668
+ * decimal `meta.version`; a collection response takes none, having no single
669
+ * version to tag. Never fails.
670
+ *
671
+ * @example
672
+ * ```ts
673
+ * import { strictEqual } from "node:assert";
674
+ * import { Option } from "effect";
675
+ * import { versionEtag } from "@thomasfosterau/pulpit/subtype";
676
+ *
677
+ * const read = { type: "posts", id: "post-1", meta: { version: "3" } };
678
+ * strictEqual(Option.getOrElse(versionEtag(read), () => "none"), '"3"');
679
+ * const unstamped = { type: "posts", id: "post-1", meta: {} };
680
+ * strictEqual(Option.isNone(versionEtag(unstamped)), true);
681
+ * ```
682
+ *
683
+ * @since 0.1.0
684
+ */
685
+ export const versionEtag = (resource: VersionedResource): Option.Option<string> =>
686
+ Option.map(
687
+ Schema.decodeUnknownOption(StampedVersion)(resource),
688
+ ({ meta }) => `"${meta.version}"`,
689
+ );
690
+
691
+ /**
692
+ * One list page and the unpaged total of rows matching the filter.
693
+ *
694
+ * @since 0.1.0
695
+ */
696
+ export interface Page<A> {
697
+ readonly rows: ReadonlyArray<A>;
698
+ readonly total: number;
699
+ }
700
+
701
+ /**
702
+ * The hard cap on a page (`SEC-INPUT-003`): no request drives an unbounded `LIMIT`.
703
+ *
704
+ * @since 0.1.0
705
+ */
706
+ export const MAX_PAGE_LIMIT = 1000;
707
+
708
+ /**
709
+ * One key of a list's order: a column of the leaf's current-version view and
710
+ * its direction. A list sorts by its keys in turn with a `NULL` (or absent)
711
+ * column after every value in **both** directions (`NULLS LAST`, which
712
+ * PostgreSQL's `DESC` does not default to), then by `id` ascending, so the
713
+ * order is total and an offset page never skips or repeats a row. A string
714
+ * sorts by code point (SQL's `COLLATE "C"`), a number or `bigint` by value, a
715
+ * boolean `false` first and a `Date` by its instant; `id` closes in byte
716
+ * order too (pulpit#112).
717
+ *
718
+ * **Only scalar orderable columns.** A sort key's column must hold one
719
+ * scalar orderable type: text, numeric, boolean, timestamp or bigint. A
720
+ * `json` / `jsonb` or array column is outside the contract (PostgreSQL
721
+ * refuses to order `json` and orders `jsonb` by its own rules, which no
722
+ * fake reproduces), so neither an adapter nor a leaf's `sortable` list may
723
+ * offer one.
724
+ *
725
+ * **What an order discloses.** The column need not be one the resource
726
+ * exposes: without a `sortable` allow-list on the leaf
727
+ * ({@link SubtypeDefinition.sortable}), **every** declared view column is
728
+ * sortable, so a caller can rank the rows it may already see by a column the
729
+ * API never shows it, and an {@link InvalidListOrder} tells it which names
730
+ * are view columns. No hidden row, nor any total, is revealed.
731
+ *
732
+ * @since 0.1.0
733
+ */
734
+ export interface ListOrder {
735
+ /** A sortable column of the view; any other fails the list with {@link InvalidListOrder}. */
736
+ readonly column: string;
737
+ readonly direction: "asc" | "desc";
738
+ }
739
+
740
+ /**
741
+ * What `LeafStore.distinct` reads: the column, the rows' filter and the page
742
+ * window over the distinct values.
743
+ *
744
+ * @since 0.1.0
745
+ */
746
+ export interface DistinctQuery<Following> {
747
+ readonly column: string;
748
+ readonly where: ListFilter<Following>;
749
+ readonly limit: number;
750
+ readonly offset: number;
751
+ }
752
+
753
+ /**
754
+ * The read seam over a leaf's fail-closed current-version view, plus the
755
+ * owner stamp. `columns` is the view's column set — what the owned filter
756
+ * checks its declaration against.
757
+ *
758
+ * @since 0.1.0
759
+ */
760
+ export interface LeafStore<Row extends LeafRow, Following, E, R> {
761
+ /** The view's column names; an owned declaration naming a column outside this set reads nothing (`get`, `getMany` or `list`). */
762
+ readonly columns: ReadonlySet<string>;
763
+ /** One row of the current-version view by id, or none. Applies no visibility policy. */
764
+ readonly get: (id: string) => Effect.Effect<Option.Option<Row>, E, R>;
765
+ /** One `id = ANY($ids)` read, never `ids.length` sequential gets. */
766
+ readonly getMany: (ids: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<Row>, E, R>;
767
+ /**
768
+ * The page under `where`, with the unpaged total, ordered totally by
769
+ * `orderBy` and then `id`: `ORDER BY <column> ASC|DESC NULLS LAST, …,
770
+ * id ASC`, every string key under `COLLATE "C"` (see {@link ListOrder}).
771
+ * `orderBy` arrives checked: each column is in `columns`, each direction
772
+ * `asc` or `desc`, no column twice; empty is `id` ascending alone. The
773
+ * order never changes which rows match `where`, nor `total`.
774
+ */
775
+ readonly list: (query: {
776
+ readonly where: ListFilter<Following>;
777
+ readonly orderBy: ReadonlyArray<ListOrder>;
778
+ readonly limit: number;
779
+ readonly offset: number;
780
+ }) => Effect.Effect<Page<Row>, E, R>;
781
+ /**
782
+ * The distinct values of `column` among the view rows matching `where`
783
+ * (pulpit#186): `SELECT DISTINCT <column> … WHERE <where> AND <column> IS
784
+ * NOT NULL ORDER BY <column> COLLATE "C" LIMIT … OFFSET …`, with the
785
+ * unpaged distinct count as `total`. Only string values are distinct
786
+ * values; a `NULL` or non-string one is none. Applies no visibility
787
+ * policy.
788
+ */
789
+ readonly distinct: (query: DistinctQuery<Following>) => Effect.Effect<Page<string>, E, R>;
790
+ /** The owner of an owned node (`core.node_owner`), or none. */
791
+ readonly ownerOf: (id: string) => Effect.Effect<Option.Option<string>, E, R>;
792
+ /** Record a freshly created owned node's owner. */
793
+ readonly stampOwner: (id: string, ownerId: string) => Effect.Effect<void, E, R>;
794
+ }
795
+
796
+ // ── The publication lifecycle seam ────────────────────────────────────
797
+
798
+ /**
799
+ * One publication fact, as a {@link PublicationStore} records it: the
800
+ * version made live (none for an unpublish), the instant it takes effect
801
+ * (now, or a future one: a schedule), who recorded it and when. Facts are
802
+ * appended, never rewritten, so a node's publication history is data
803
+ * beside its version chain (`VERSION-CHAIN-001/002` are untouched).
804
+ *
805
+ * @since 0.1.0
806
+ */
807
+ export interface PublicationFact {
808
+ readonly id: string;
809
+ /** The version made live, or none for an unpublish. */
810
+ readonly version: Option.Option<bigint>;
811
+ /** When the fact takes effect; a future instant schedules it. */
812
+ readonly at: Date;
813
+ readonly actorId: Option.Option<string>;
814
+ readonly recordedAt: Date;
815
+ }
816
+
817
+ /**
818
+ * The append-only publication log an application supplies (README → Seams).
819
+ * The **effective** fact is the most recently recorded one whose `at` has
820
+ * arrived: a later record supersedes an earlier schedule, and a schedule
821
+ * takes effect at its stamp with no write (the read-time gate,
822
+ * `isPublishedAt`). Applies no policy and fails only with its `E`.
823
+ *
824
+ * @since 0.1.0
825
+ */
826
+ export interface PublicationStore<E, R> {
827
+ /** Append one fact. */
828
+ readonly record: (fact: PublicationFact) => Effect.Effect<void, E, R>;
829
+ /** The node's effective fact at the store's clock (`now()`), or none. */
830
+ readonly effective: (id: string) => Effect.Effect<Option.Option<PublicationFact>, E, R>;
831
+ /** The node's facts, oldest first, at most `limit` of them. */
832
+ readonly facts: (
833
+ id: string,
834
+ limit: number,
835
+ ) => Effect.Effect<ReadonlyArray<PublicationFact>, E, R>;
836
+ }
837
+
838
+ /**
839
+ * A publish-gated leaf's live-version view, beside its current-version
840
+ * `LeafStore`: per node, the row at the version its effective publication
841
+ * fact names, carrying that version as its `version` (the representation
842
+ * it serves, so its `meta.version` and ETag change only when what readers
843
+ * see does, and never disclose an unpublished edit), the node's head
844
+ * `action` (so a tombstone hides it too), `draft: false`, and that fact's
845
+ * `at` as the row's publication stamp.
846
+ * A node with no live version (never published, or unpublished) has no
847
+ * row: `get` answers none and `getMany` omits it. Applies no visibility
848
+ * policy.
849
+ *
850
+ * @since 0.1.0
851
+ */
852
+ export interface LiveView<Row extends LeafRow, Following, E, R> extends Pick<
853
+ LeafStore<Row, Following, E, R>,
854
+ "get" | "getMany"
855
+ > {
856
+ /**
857
+ * `LeafStore.distinct` over the rows {@link LiveView.list} chooses for
858
+ * the same `headFor` (pulpit#186): a node counts with the row the viewer
859
+ * reads, its head or its live version, or not at all.
860
+ */
861
+ readonly distinct: (
862
+ query: DistinctQuery<Following> & { readonly headFor: ListFilter<Following> },
863
+ ) => Effect.Effect<Page<string>, E, R>;
864
+ /**
865
+ * The page under `where`, ordered as `LeafStore.list` orders, over one
866
+ * row per node chosen per viewer: the **head** row (as the current-version
867
+ * view has it, with `draft` = no live version) for a node whose head
868
+ * matches `headFor` (the viewer's own rows), else the live row, else
869
+ * none.
870
+ */
871
+ readonly list: (
872
+ query: Parameters<LeafStore<Row, Following, E, R>["list"]>[0] & {
873
+ readonly headFor: ListFilter<Following>;
874
+ },
875
+ ) => Effect.Effect<Page<Row>, E, R>;
876
+ }
877
+
878
+ /**
879
+ * A leaf's opt-in to the live pointer (`publishGated`, owner decision on
880
+ * pulpit#46): its live view, its publication log, and the request's
881
+ * preview grant. Without it, head and live are one.
882
+ *
883
+ * @since 0.1.0
884
+ */
885
+ export interface PublicationDeclaration<Row extends LeafRow, Following, E, R> {
886
+ readonly live: LiveView<Row, Following, E, R>;
887
+ readonly facts: PublicationStore<E, R>;
888
+ /**
889
+ * The node id this request holds a preview grant for (a signed, expiring
890
+ * token the application or effect-web verified): `get` of exactly that
891
+ * node serves its head, whoever asks, unless it is tombstoned. Signing
892
+ * and verifying the token is the application's. Absent, no request
893
+ * previews.
894
+ */
895
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no preview grants"
896
+ readonly preview?: Effect.Effect<Option.Option<string>, never, R> | undefined;
897
+ }
898
+
899
+ /**
900
+ * What `publish` / `unpublish` report: the fact recorded, and the node's
901
+ * head version when it was.
902
+ *
903
+ * @since 0.1.0
904
+ */
905
+ export interface PublicationWrite {
906
+ readonly fact: PublicationFact;
907
+ readonly head: bigint;
908
+ }
909
+
910
+ /**
911
+ * A publication operation on a node with no live version (`discardDraft`
912
+ * with nothing published): the wire's 409.
913
+ *
914
+ * @since 0.1.0
915
+ */
916
+ export class NotPublished extends Schema.TaggedError<
917
+ NotPublished,
918
+ { readonly brand: unique symbol }
919
+ >()("NotPublished", {
920
+ type: Schema.String,
921
+ id: Schema.String,
922
+ }) {}
923
+
924
+ /**
925
+ * What `publish` makes live, and when.
926
+ *
927
+ * @since 0.1.0
928
+ */
929
+ export interface PublishOptions {
930
+ /** The version to make live; the head when omitted. */
931
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); omitted is the head
932
+ readonly version?: bigint | undefined;
933
+ /** When it takes effect; now when omitted, and a future instant schedules it. */
934
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); omitted is now
935
+ readonly at?: Date | undefined;
936
+ }
937
+
938
+ /**
939
+ * A publish-gated leaf's publication operations. Each runs the leaf's write
940
+ * gate first, and its before hooks (with action `publish` / `unpublish`,
941
+ * an empty patch whose return is ignored: a publication writes no
942
+ * version) before recording; a refusing hook records nothing.
943
+ *
944
+ * @since 0.1.0
945
+ */
946
+ export interface PublicationOps<E, R> {
947
+ /**
948
+ * Make `version` (the head when omitted) live at `at` (now when
949
+ * omitted; a future instant schedules it, and readers see it once it
950
+ * arrives). Fails as `update` does at the gate, `NodeNotFound` for a
951
+ * missing or tombstoned node, and `VersionNotFound` for a version that
952
+ * has no row or is a tombstone. Its lifecycle event fires now, not when
953
+ * `at` arrives.
954
+ */
955
+ readonly publish: (
956
+ id: string,
957
+ options?: PublishOptions,
958
+ ) => Effect.Effect<
959
+ PublicationWrite,
960
+ Gate | WriteRejected | NodeNotFound | VersionNotFound | E,
961
+ R
962
+ >;
963
+ /**
964
+ * Take the node's live version down, now. Fails as `update` does at the
965
+ * gate, and `NodeNotFound` for a node with no versions.
966
+ */
967
+ readonly unpublish: (
968
+ id: string,
969
+ ) => Effect.Effect<PublicationWrite, Gate | WriteRejected | NodeNotFound | E, R>;
970
+ /**
971
+ * Throw the unpublished head away: append the live version's state as a
972
+ * new version (`restoreVersion`, never a rewrite). A head that already is
973
+ * the live version writes nothing. Fails `NotPublished` without a live
974
+ * version, and as `restoreVersion` does.
975
+ */
976
+ readonly discardDraft: (
977
+ id: string,
978
+ precondition?: Precondition,
979
+ ) => Effect.Effect<
980
+ ChainWrite,
981
+ | Gate
982
+ | WriteRejected
983
+ | NodeNotFound
984
+ | NotPublished
985
+ | VersionNotFound
986
+ | StaleVersion
987
+ | VersionConflict
988
+ | E,
989
+ R
990
+ >;
991
+ /** The node's publication facts, oldest first; gated like `getVersion` (`READ-009`). */
992
+ readonly facts: (
993
+ id: string,
994
+ ) => Effect.Effect<ReadonlyArray<PublicationFact>, Gate | NodeNotFound | E, R>;
995
+ }
996
+
997
+ // ── The lifecycle hook seam ───────────────────────────────────────────
998
+
999
+ /**
1000
+ * A before hook's refusal: the write it was shown does not happen, and the
1001
+ * caller's transaction rolls back with it. `reason` is the hook's own,
1002
+ * for the wire's 422 (or whatever the application maps it to).
1003
+ *
1004
+ * @since 0.1.0
1005
+ */
1006
+ export class WriteRejected extends Schema.TaggedError<
1007
+ WriteRejected,
1008
+ { readonly brand: unique symbol }
1009
+ >()("WriteRejected", {
1010
+ type: Schema.String,
1011
+ id: Schema.String,
1012
+ action: Schema.String,
1013
+ reason: Schema.String,
1014
+ }) {}
1015
+
1016
+ /**
1017
+ * What a {@link BeforeWriteHook} is shown: the chain's {@link ChainAppendEvent}
1018
+ * (the action, the node, the head the write extends, the patch), plus the
1019
+ * leaf's type and the request's viewer.
1020
+ *
1021
+ * @since 0.1.0
1022
+ */
1023
+ export interface BeforeWriteEvent extends ChainAppendEvent {
1024
+ readonly type: string;
1025
+ readonly viewer: Viewer;
1026
+ }
1027
+
1028
+ /**
1029
+ * A hook that runs inside the write, after its gate and its head read and
1030
+ * before the version is appended, in the caller's transaction. It returns
1031
+ * the patch the write applies (the event's, or a transformed one: a derived
1032
+ * slug, a normalised field), or fails the write, appending nothing, with
1033
+ * {@link WriteRejected} or the store's `E` (an outbox insert that failed). A
1034
+ * side effect that must not be lost belongs here, as a row the same
1035
+ * transaction writes (an outbox), never in an {@link AfterWriteHook}.
1036
+ *
1037
+ * @since 0.1.0
1038
+ */
1039
+ export interface BeforeWriteHook<E, R> {
1040
+ /** Names the hook in its span and in a logged failure. */
1041
+ readonly name: string;
1042
+ readonly run: (event: BeforeWriteEvent) => Effect.Effect<ChainPatch, WriteRejected | E, R>;
1043
+ /**
1044
+ * The hook's locale-level pass on a localised leaf (pulpit#56): shown the
1045
+ * same event, it returns the {@link LocalePatch} the write applies (the
1046
+ * event's `locales`, or a transformed one), or fails the write as `run`
1047
+ * does. **Fails closed:** a write carrying a locale patch through a hook
1048
+ * that declares no `locales` is refused with {@link WriteRejected}, so a
1049
+ * hook that validates or rewrites a column can never be bypassed by
1050
+ * writing it per locale. A hook with nothing to do per locale passes the
1051
+ * event's patch through (`(event) => Effect.succeed(event.locales ?? {})`).
1052
+ */
1053
+ /* oxlint-disable effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "refuses every locale patch" */
1054
+ readonly locales?:
1055
+ | ((event: BeforeWriteEvent) => Effect.Effect<LocalePatch, WriteRejected | E, R>)
1056
+ | undefined;
1057
+ /* oxlint-enable effect/prefer-option-over-null */
1058
+ }
1059
+
1060
+ /**
1061
+ * What an {@link AfterWriteHook} is shown, once the write has committed:
1062
+ * the leaf's type, the action, the version entry the write landed at, and
1063
+ * the resource as its writer now reads it (none for a `delete`, or when the
1064
+ * write hid the row from its own writer or the re-read failed).
1065
+ *
1066
+ * @since 0.1.0
1067
+ */
1068
+ export interface AfterWriteEvent<Resource> {
1069
+ readonly type: string;
1070
+ readonly action: WriteAction;
1071
+ /**
1072
+ * The version the write landed at; for a `publish` or `unpublish`, which
1073
+ * append no version, the node's head, so the resource cache's
1074
+ * compare-on-set orders it after the writes before it (pulpit#121). The
1075
+ * version a `publish` made live is the fact's.
1076
+ */
1077
+ readonly write: ChainWrite;
1078
+ readonly resource: Option.Option<Resource>;
1079
+ /** The fact a `publish` / `unpublish` recorded; none for a chain write. */
1080
+ readonly publication: Option.Option<PublicationFact>;
1081
+ }
1082
+
1083
+ /**
1084
+ * A hook that runs after the write's transaction has committed
1085
+ * ({@link subtypeHandlers}, which owns the boundary). It never fails the
1086
+ * write: its failure, a defect included, goes to the hooks'
1087
+ * {@link LifecycleHooks.afterFailure} policy, and the next hook still runs
1088
+ * under `"log"`. It runs uninterruptibly, so a committed write always
1089
+ * reaches it; a delivery that must survive a crash is an outbox row
1090
+ * written by a {@link BeforeWriteHook} instead.
1091
+ *
1092
+ * @since 0.1.0
1093
+ */
1094
+ export interface AfterWriteHook<Resource, E, R> {
1095
+ /** Names the hook in its span and in a logged failure. */
1096
+ readonly name: string;
1097
+ readonly run: (event: AfterWriteEvent<Resource>) => Effect.Effect<void, E, R>;
1098
+ }
1099
+
1100
+ /**
1101
+ * A leaf's write side effects, composed per leaf type by the application:
1102
+ * provide them from a `Context` service (a `Layer`) through
1103
+ * {@link SubtypeDefinition.hooks}. The hooks run in array order, so their
1104
+ * order is deterministic and is the application's to choose; the default is
1105
+ * none ({@link noHooks}).
1106
+ *
1107
+ * Events fire on explicit writes only: `create`, `update`, `restore`,
1108
+ * `delete` (not an already tombstoned node's, which writes nothing) and
1109
+ * `restoreVersion`. A scheduled version whose `publishedAt` stamp arrives
1110
+ * later writes nothing and emits nothing; an application that needs a side
1111
+ * effect when scheduled content goes live drives a sweep from its cron with
1112
+ * `./publication`'s `duePublications`.
1113
+ *
1114
+ * @since 0.1.0
1115
+ */
1116
+ export interface LifecycleHooks<Resource, E, R> {
1117
+ readonly before: ReadonlyArray<BeforeWriteHook<E, R>>;
1118
+ readonly after: ReadonlyArray<AfterWriteHook<Resource, E, R>>;
1119
+ /**
1120
+ * What an after hook's failure becomes: `"log"` (the default) logs its
1121
+ * cause at error level and goes on; `"defect"` dies with it, so the
1122
+ * handler fails with a defect although the write committed.
1123
+ */
1124
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is the `"log"` default
1125
+ readonly afterFailure?: "log" | "defect" | undefined;
1126
+ }
1127
+
1128
+ /**
1129
+ * No hooks: the default for a leaf that declares none.
1130
+ *
1131
+ * @since 0.1.0
1132
+ */
1133
+ export const noHooks = <Resource = never, E = never, R = never>(): LifecycleHooks<
1134
+ Resource,
1135
+ E,
1136
+ R
1137
+ > => ({ before: [], after: [] });
1138
+
1139
+ /**
1140
+ * Concatenate hook sets in order: `a`'s before hooks run before `b`'s, and
1141
+ * likewise after. The first set's `afterFailure` that is given wins.
1142
+ *
1143
+ * @example
1144
+ * ```ts
1145
+ * import { strictEqual } from "node:assert";
1146
+ * import * as Effect from "effect/Effect";
1147
+ * import { composeHooks, noHooks, type BeforeWriteHook } from "@thomasfosterau/pulpit/subtype";
1148
+ *
1149
+ * const audit: BeforeWriteHook<never, never> = {
1150
+ * name: "audit",
1151
+ * run: (event) => Effect.succeed(event.patch),
1152
+ * };
1153
+ * const hooks = composeHooks({ before: [audit], after: [] }, noHooks());
1154
+ * strictEqual(hooks.before[0]?.name, "audit");
1155
+ * ```
1156
+ *
1157
+ * @since 0.1.0
1158
+ */
1159
+ export const composeHooks = <Resource, E, R>(
1160
+ ...sets: ReadonlyArray<LifecycleHooks<Resource, E, R>>
1161
+ ): LifecycleHooks<Resource, E, R> => {
1162
+ const afterFailure = Array.findFirst(sets, (set) => set.afterFailure !== undefined);
1163
+ return {
1164
+ before: Array.flatMap(sets, (set) => set.before),
1165
+ after: Array.flatMap(sets, (set) => set.after),
1166
+ afterFailure: Option.getOrUndefined(
1167
+ Option.flatMapNullishOr(afterFailure, (set) => set.afterFailure),
1168
+ ),
1169
+ };
1170
+ };
1171
+
1172
+ /** Run the before hooks in order, each shown the patch the previous one returned. */
1173
+ const runBefore = <E, R>(
1174
+ hooks: ReadonlyArray<BeforeWriteHook<E, R>>,
1175
+ event: BeforeWriteEvent,
1176
+ ): Effect.Effect<ChainPatch, WriteRejected | E, R> =>
1177
+ Effect.reduce(
1178
+ hooks,
1179
+ () => event.patch,
1180
+ (patch, hook) =>
1181
+ hook.run({ ...event, patch }).pipe(Effect.withSpan(`${event.type}.before.${hook.name}`)),
1182
+ );
1183
+
1184
+ /**
1185
+ * Run the before hooks' locale passes in order, each shown the previous
1186
+ * one's locale patch. A hook with no `locales` pass refuses a write that
1187
+ * carries any locale change (fail closed, pulpit#56) and passes `{}` on.
1188
+ */
1189
+ const runBeforeLocales = <E, R>(
1190
+ hooks: ReadonlyArray<BeforeWriteHook<E, R>>,
1191
+ event: BeforeWriteEvent,
1192
+ ): Effect.Effect<LocalePatch, WriteRejected | E, R> =>
1193
+ Effect.reduce(
1194
+ hooks,
1195
+ (): LocalePatch => event.locales ?? {},
1196
+ (locales, hook) =>
1197
+ hook.locales === undefined
1198
+ ? Array.isReadonlyArrayNonEmpty(writtenKeys(locales))
1199
+ ? Effect.fail(
1200
+ new WriteRejected({
1201
+ type: event.type,
1202
+ id: event.id,
1203
+ action: event.action,
1204
+ reason: `before hook '${hook.name}' does not handle a locale patch`,
1205
+ }),
1206
+ )
1207
+ : Effect.succeed(locales)
1208
+ : hook
1209
+ .locales({ ...event, locales })
1210
+ .pipe(Effect.withSpan(`${event.type}.before.${hook.name}.locales`)),
1211
+ );
1212
+
1213
+ /** Run the after hooks in order, each failure handed to the policy, never to the write. */
1214
+ const runAfter = <Resource, E, R>(
1215
+ hooks: LifecycleHooks<Resource, E, R>,
1216
+ event: AfterWriteEvent<Resource>,
1217
+ ): Effect.Effect<void, never, R> =>
1218
+ Effect.forEach(
1219
+ hooks.after,
1220
+ (hook) =>
1221
+ hook.run(event).pipe(
1222
+ Effect.withSpan(`${event.type}.after.${hook.name}`),
1223
+ Effect.catchCause((cause) =>
1224
+ hooks.afterFailure === "defect"
1225
+ ? Effect.die(Cause.squash(cause))
1226
+ : Effect.logError(`${event.type}.after.${hook.name} failed`, cause),
1227
+ ),
1228
+ ),
1229
+ { concurrency: 1, discard: true },
1230
+ );
1231
+
1232
+ // ── The factory ───────────────────────────────────────────────────────
1233
+
1234
+ /**
1235
+ * A key's column when it is an object with a string `column`, else `""`.
1236
+ * Typed as the request types it, but read as a caller may really send it.
1237
+ */
1238
+ const columnOf = (key: ListOrder | ReadonlyArray<ListOrder>): string =>
1239
+ Predicate.isObject(key) && Predicate.isString(key.column) ? key.column : "";
1240
+
1241
+ /**
1242
+ * A list request's order as the store receives it: an array whose every key
1243
+ * is an object with a sortable string `column` and a direction `asc` or
1244
+ * `desc`, or {@link InvalidListOrder} for the first key that isn't (or for an
1245
+ * `orderBy` that is no array), so no unsortable name reaches SQL and no
1246
+ * out-of-type input dies. A repeated column is dropped (its first key
1247
+ * already ordered every tie it could), which bounds the keys by the view's
1248
+ * columns; each key is rebuilt, so nothing but `column` and `direction`
1249
+ * reaches the store.
1250
+ */
1251
+ const checkedOrder = (
1252
+ type: string,
1253
+ isSortable: (column: string) => boolean,
1254
+ keys: ReadonlyArray<ListOrder>,
1255
+ ): Effect.Effect<ReadonlyArray<ListOrder>, InvalidListOrder> => {
1256
+ // The request's types are not checked at runtime: a decoded query or an
1257
+ // untyped caller can send any value here, so every guard reads it as such.
1258
+ const isKey = (key: ListOrder): boolean =>
1259
+ Predicate.isObject(key) &&
1260
+ Predicate.isString(key.column) &&
1261
+ isSortable(key.column) &&
1262
+ (key.direction === "asc" || key.direction === "desc");
1263
+ // A boolean, not a guard: narrowing a typed array to `Array<unknown>` would lose its keys' type.
1264
+ const isList = (value: ReadonlyArray<ListOrder>): boolean => Array.isArray(value);
1265
+ if (!isList(keys)) return Effect.fail(new InvalidListOrder({ type, column: columnOf(keys) }));
1266
+ return Option.match(
1267
+ Array.findFirst(keys, (key) => !isKey(key)),
1268
+ {
1269
+ onNone: () =>
1270
+ Effect.succeed(
1271
+ Array.map(
1272
+ Array.dedupeWith(keys, (self, that) => self.column === that.column),
1273
+ ({ column, direction }): ListOrder => ({ column, direction }),
1274
+ ),
1275
+ ),
1276
+ onSome: (key) => Effect.fail(new InvalidListOrder({ type, column: columnOf(key) })),
1277
+ },
1278
+ );
1279
+ };
1280
+
1281
+ /**
1282
+ * The leaf's sortable-column test, its `sortable` allow-list checked against
1283
+ * the view's columns once, when the leaf is declared: an entry outside them
1284
+ * throws {@link SubtypeDeclarationError}. Without an allow-list, every
1285
+ * declared view column is sortable.
1286
+ */
1287
+ const sortableColumns = (
1288
+ type: string,
1289
+ columns: ReadonlySet<string>,
1290
+ // oxlint-disable-next-line effect/prefer-option-over-null -- `SubtypeDefinition.sortable` as declared, an optional `?: T | undefined` field (AGENTS.md → Conventions)
1291
+ sortable: ReadonlyArray<string> | undefined,
1292
+ ): ((column: string) => boolean) => {
1293
+ if (sortable === undefined) return (column) => columns.has(column);
1294
+ const undeclared = Array.findFirst(sortable, (column) => !columns.has(column));
1295
+ if (Option.isSome(undeclared)) {
1296
+ throw new SubtypeDeclarationError({
1297
+ message: `${type}: sortable names '${undeclared.value}', which is not a column of the view`,
1298
+ });
1299
+ }
1300
+ return (column) => Array.contains(sortable, column);
1301
+ };
1302
+
1303
+ /**
1304
+ * A leaf's declaration: its type and access, its seams, and its row → resource mapper.
1305
+ *
1306
+ * @since 0.1.0
1307
+ */
1308
+ export interface SubtypeDefinition<Row extends LeafRow, Resource, Following, E, R> {
1309
+ /** The leaf's JSON:API wire type — names spans and tags `NodeNotFound`. */
1310
+ readonly type: string;
1311
+ /** How the leaf is gated: `reference` corpus material or `owned` per-member rows. */
1312
+ readonly access: LeafAccess;
1313
+ /** The version-chain store the writes append through. */
1314
+ readonly chain: VersionChainStore<E, R>;
1315
+ /** The current-version view the reads and lists go through, plus the owner stamp. */
1316
+ readonly leaves: LeafStore<Row, Following, E, R>;
1317
+ /** The request's viewer (effect-auth's session context). */
1318
+ readonly viewer: Effect.Effect<Viewer, never, R>;
1319
+ /** The application's role axes. */
1320
+ readonly policy: AccessPolicy;
1321
+ /** The follower seam; {@link nobodyFollows} when the runtime provides none. */
1322
+ readonly followers: FollowerSeam<Following, R>;
1323
+ /** Shape a view row into the resource (sync; links resolved by the caller's mapper). */
1324
+ readonly toResource: (row: Row) => Resource;
1325
+ /**
1326
+ * The columns a list may sort by ({@link ListRequest.orderBy}); each must
1327
+ * be one of the view's `LeafStore.columns`, holding a scalar orderable
1328
+ * type (text, numeric, boolean, timestamp or bigint, never `json` /
1329
+ * `jsonb` or an array), or {@link defineSubtype} throws
1330
+ * {@link SubtypeDeclarationError}. An order key on any other column fails
1331
+ * {@link InvalidListOrder}. With a list, `id` sorts only if listed; `[]`
1332
+ * admits only the default `id`-ascending order, so any non-empty
1333
+ * {@link ListRequest.orderBy} fails {@link InvalidListOrder}.
1334
+ *
1335
+ * **Omitted, every declared view column is sortable**, the columns the
1336
+ * resource never shows included. That default discloses two things to any
1337
+ * caller: the ranking, among the rows it may already see, by any view
1338
+ * column (a `rank` or a moderation score it can't read), and, through
1339
+ * {@link InvalidListOrder}'s 400, which names are view columns. It never
1340
+ * reveals a hidden row nor changes a total (`SEC-AUTH-ENUM-001`). Declare
1341
+ * the list to sort only by what the resource exposes (pulpit#112).
1342
+ */
1343
+ // 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 every-declared-column default
1344
+ readonly sortable?: ReadonlyArray<string> | undefined;
1345
+ /**
1346
+ * The leaf's lifecycle hooks, read per write: an Effect, so the
1347
+ * application can hold them in a `Context` service and provide it with a
1348
+ * `Layer`. The before hooks run inside every write; the after hooks run in
1349
+ * {@link subtypeHandlers} once its transaction has committed. Omitted, the
1350
+ * leaf has none ({@link noHooks}), and a write reads nothing extra.
1351
+ */
1352
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no hooks"
1353
+ readonly hooks?: Effect.Effect<LifecycleHooks<Resource, E, R>, never, R> | undefined;
1354
+ /**
1355
+ * The leaf's searchable projection ({@link defineSearchProjection}), the
1356
+ * same declaration the application's search-view generator reads. Each
1357
+ * field and the language column must be a column of the view, or
1358
+ * {@link defineSubtype} throws {@link SubtypeDeclarationError}. Omitted,
1359
+ * the leaf is not searched.
1360
+ */
1361
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "not searchable"
1362
+ readonly search?: SearchProjection | undefined;
1363
+ /**
1364
+ * The leaf's locale level ({@link defineLocaleLevel}, pulpit#56): which of
1365
+ * its columns are translated, written and read per locale, while the rest
1366
+ * are shared by every locale of the node. Each column, and
1367
+ * {@link LOCALE_COLUMN}, must be a column of the view, or
1368
+ * {@link defineSubtype} throws {@link SubtypeDeclarationError}. Omitted,
1369
+ * the leaf is not localised and a write carrying a locale is refused.
1370
+ */
1371
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "not localised"
1372
+ readonly locales?: LocaleLevel | undefined;
1373
+ /**
1374
+ * The live pointer, opt-in (pulpit#46). With it, the view-private rung,
1375
+ * the corpus-write rung on a reference leaf and a row's own owner read
1376
+ * the head; everyone else reads the live version (a node with none is
1377
+ * hidden from them, which is what `draft` means on such a leaf).
1378
+ * Without it, head and live are one.
1379
+ */
1380
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no live pointer"
1381
+ readonly publication?: PublicationDeclaration<Row, Following, E, R> | undefined;
1382
+ }
1383
+
1384
+ /**
1385
+ * A list request, as the decoded query hands it over.
1386
+ *
1387
+ * @since 0.1.0
1388
+ */
1389
+ export interface ListRequest<Following = never> {
1390
+ readonly limit: number;
1391
+ readonly offset: number;
1392
+ readonly includeDeleted: boolean;
1393
+ /**
1394
+ * The family's own extra predicate (a `?filter[published]=`), or `always`.
1395
+ * It may read the follower seam's `Following`, as an edge family's
1396
+ * `memberFilter` does (pulpit#189), when the leaf's `SubtypeOps` carries it.
1397
+ */
1398
+ readonly where: ListFilter<Following>;
1399
+ /**
1400
+ * The order, its keys in turn, closed by `id` ascending with `NULL`s last
1401
+ * in both directions (see {@link ListOrder}); absent, empty or `null` (as
1402
+ * an unchecked caller may send it, the same as absent) is `id` ascending
1403
+ * alone. A column that is not sortable (outside the leaf's
1404
+ * `sortable` allow-list or, **when the leaf declares none, any view column
1405
+ * at all, the ones the resource never shows included**), a direction other
1406
+ * than `asc` / `desc`, or input not shaped as an order (no array, a key
1407
+ * that is no object, a `column` that is no string) fails the list with
1408
+ * {@link InvalidListOrder}, before the viewer is read; a repeated column
1409
+ * adds nothing and is dropped. The order discloses the visible rows'
1410
+ * ranking by any sortable column, and the 400 which names are sortable;
1411
+ * never a hidden row or a total (see {@link SubtypeDefinition.sortable}).
1412
+ */
1413
+ // 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 default order
1414
+ readonly orderBy?: ReadonlyArray<ListOrder> | undefined;
1415
+ }
1416
+
1417
+ type Gate = Unauthorized | Forbidden;
1418
+
1419
+ /**
1420
+ * What {@link SubtypeOps.owners} reads: the page window, the soft-delete
1421
+ * axis as {@link ListRequest.includeDeleted}, and an optional set of
1422
+ * account ids to narrow to.
1423
+ *
1424
+ * @since 0.1.0
1425
+ */
1426
+ export interface OwnersRequest {
1427
+ readonly limit: number;
1428
+ readonly offset: number;
1429
+ readonly includeDeleted: boolean;
1430
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is every owner
1431
+ readonly among?: ReadonlyArray<string> | undefined;
1432
+ }
1433
+
1434
+ /**
1435
+ * An update's input: the node, the tri-state patch, an optional expected
1436
+ * version and, for an edit that applies an accepted proposal, the
1437
+ * contributor the version records as `proposedBy` (`./proposals`).
1438
+ *
1439
+ * @since 0.1.0
1440
+ */
1441
+ export interface UpdateRequest extends RestoreRequest {
1442
+ // 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`
1443
+ readonly proposedBy?: Option.Option<string> | undefined;
1444
+ }
1445
+
1446
+ /**
1447
+ * A restore's input: the node, the tri-state patch, a localised leaf's
1448
+ * per-locale patch and an optional expected version.
1449
+ *
1450
+ * @since 0.1.0
1451
+ */
1452
+ export interface RestoreRequest extends Precondition {
1453
+ readonly id: string;
1454
+ readonly patch: ChainPatch;
1455
+ /** A localised leaf's per-locale patch (pulpit#56); absent, every locale carries forward. */
1456
+ // 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
1457
+ readonly locales?: LocalePatch | undefined;
1458
+ }
1459
+
1460
+ /**
1461
+ * A create's input: a caller-supplied id (minted when none), the initial
1462
+ * state and a localised leaf's locale level at version 0.
1463
+ *
1464
+ * @since 0.1.0
1465
+ */
1466
+ export interface CreateRequest {
1467
+ readonly id: Option.Option<string>;
1468
+ readonly state: ChainState;
1469
+ /** A localised leaf's locale level at version 0 (pulpit#56); absent, none. */
1470
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no locale"
1471
+ readonly locales?: LocaleStates | undefined;
1472
+ }
1473
+
1474
+ /**
1475
+ * The operations {@link defineSubtype} produces for one leaf. Every write
1476
+ * also fails {@link WriteRejected} when one of the leaf's before hooks
1477
+ * refuses it, after the gate and with nothing appended.
1478
+ *
1479
+ * @since 0.1.0
1480
+ */
1481
+ export interface SubtypeOps<Resource, E, R, Following = never> {
1482
+ /** The leaf's wire type, as declared. */
1483
+ readonly type: string;
1484
+ /** The access declaration the operations gate by. */
1485
+ readonly access: LeafAccess;
1486
+ /**
1487
+ * The leaf's lifecycle hooks, as {@link SubtypeDefinition.hooks} provides
1488
+ * them; {@link subtypeHandlers} runs their after hooks.
1489
+ */
1490
+ readonly hooks: Effect.Effect<LifecycleHooks<Resource, E, R>, never, R>;
1491
+ /** The publication operations, on a leaf that declares the live pointer. */
1492
+ readonly publication: Option.Option<PublicationOps<E, R>>;
1493
+ /**
1494
+ * The resource as an anonymous reader gets it (the live version on a
1495
+ * publish-gated leaf), or none when they can't read it. What a public
1496
+ * cache may hold.
1497
+ */
1498
+ readonly publicResource: (id: string) => Effect.Effect<Option.Option<Resource>, E, R>;
1499
+ /**
1500
+ * The leaf's write gate on `id`, as `update` runs it: the acting account,
1501
+ * or the gate's failure. For a surface outside the chain (a working copy)
1502
+ * that must gate like a write.
1503
+ */
1504
+ readonly authorize: (id: string) => Effect.Effect<ViewerAccount, Gate | NodeNotFound | E, R>;
1505
+ /**
1506
+ * Create the node at version 0, stamping the viewer as owner on an owned
1507
+ * leaf. Fails `Unauthorized` when anonymous, `Forbidden` below the
1508
+ * corpus-write rung on a reference leaf, `NodeAlreadyExists` when a
1509
+ * caller-supplied id is taken, and `WriteRejected` when the write breaks
1510
+ * the leaf's locale level (see {@link SubtypeOps.update}).
1511
+ */
1512
+ readonly create: (
1513
+ input: CreateRequest,
1514
+ ) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeAlreadyExists | VersionConflict | E, R>;
1515
+ /**
1516
+ * Append a tri-state patch. Fails `Unauthorized` when anonymous,
1517
+ * `Forbidden` below the write rung (the corpus-write rung for reference
1518
+ * data, owner-or-moderator for an owned row), `NodeNotFound` when an
1519
+ * owned row has no owner, when a reference row is one `get` hides from the
1520
+ * caller (`PUB-009`; pulpit#101), or when the node is unknown or
1521
+ * tombstoned, and `StaleVersion` when `input.expectedVersion` is not the
1522
+ * head (checked after the gate, so only a writer learns the head).
1523
+ *
1524
+ * On a localised leaf (pulpit#56) `input.locales` patches the locale
1525
+ * level; the write appends one version of the whole node, however few
1526
+ * locales it touches. Fails `WriteRejected`, after the gate and with
1527
+ * nothing appended, when the shared patch names a translated column, a
1528
+ * locale's patch names a shared one, a locale tag is empty, or a leaf with
1529
+ * no locale level is sent a locale.
1530
+ */
1531
+ readonly update: (
1532
+ input: UpdateRequest,
1533
+ ) => Effect.Effect<
1534
+ ChainWrite,
1535
+ Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E,
1536
+ R
1537
+ >;
1538
+ /**
1539
+ * {@link SubtypeOps.update}, except a tombstone does not fail: the node
1540
+ * comes back live (`VERSION-CHAIN-006`). Fails as `update` does, apart from
1541
+ * the tombstone. On a reference leaf only the view-deleted rung can see a
1542
+ * tombstone, so only it can restore one; anyone else gets `NodeNotFound`.
1543
+ */
1544
+ readonly restore: (
1545
+ input: RestoreRequest,
1546
+ ) => Effect.Effect<
1547
+ ChainWrite,
1548
+ Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E,
1549
+ R
1550
+ >;
1551
+ /**
1552
+ * Tombstone the node (idempotent: an already tombstoned node reports
1553
+ * `alreadyDeleted: true` and writes nothing, whatever the precondition).
1554
+ * Fails `Unauthorized` when anonymous, `Forbidden` below the write rung,
1555
+ * `NodeNotFound` when an owned row has no owner, when a reference row
1556
+ * (an already tombstoned one included) is one `get` hides from the caller
1557
+ * (`PUB-009`; pulpit#101), or when the node is unknown, and
1558
+ * `StaleVersion` when a live node's head is not
1559
+ * `precondition.expectedVersion`.
1560
+ */
1561
+ readonly delete: (
1562
+ id: string,
1563
+ precondition?: Precondition,
1564
+ ) => Effect.Effect<
1565
+ ChainWrite & { readonly alreadyDeleted: boolean },
1566
+ Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E,
1567
+ R
1568
+ >;
1569
+ /**
1570
+ * The row as a resource. Fails `NodeNotFound` when it is missing OR hidden
1571
+ * from the viewer (`SEC-AUTH-ENUM-001`).
1572
+ */
1573
+ readonly get: (id: string) => Effect.Effect<Resource, NodeNotFound | E, R>;
1574
+ /**
1575
+ * The locale-fallback read (pulpit#56): the node's row in the first of
1576
+ * `locales`, in the caller's order, that the version the viewer reads
1577
+ * carries (the head, or on a publish-gated leaf the live version for a
1578
+ * viewer who doesn't read the head), with the shared columns joined in.
1579
+ * Gated by the node's own read gate alone, exactly as `get` is: draft,
1580
+ * visibility and publication are the node's, shared by its locales, so no
1581
+ * locale is ever hidden on its own. Fails `NodeNotFound` when the node is
1582
+ * missing or hidden from the viewer (`SEC-AUTH-ENUM-001`) and when it
1583
+ * carries none of `locales` (or the leaf has no locale level), in one
1584
+ * read of the locale rows after the gate.
1585
+ */
1586
+ /**
1587
+ * The accounts that own at least one row the viewer may list
1588
+ * (pulpit#186): the distinct owner column values of exactly the rows
1589
+ * {@link SubtypeOps.list} would show for `request` (the same scope, and on
1590
+ * a publish-gated leaf the same head-or-live choice), in code-point order,
1591
+ * with the distinct total. `among`, when given, narrows to those account
1592
+ * ids (more than `MAX_SET_VALUES` reads none, `SEC-INPUT-003`). A hidden
1593
+ * row never contributes its owner (`SEC-AUTH-ENUM-001`). A reference leaf
1594
+ * has no owner, so its page is empty. The limit is clamped to
1595
+ * {@link MAX_PAGE_LIMIT}. Fails only with the store's `E`.
1596
+ */
1597
+ readonly owners: (request: OwnersRequest) => Effect.Effect<Page<string>, E, R>;
1598
+ readonly getLocalised: (
1599
+ id: string,
1600
+ locales: ReadonlyArray<string>,
1601
+ ) => Effect.Effect<LocalisedResource<Resource>, NodeNotFound | E, R>;
1602
+ /** Visible rows by id, in one read; a missing or hidden id is simply absent. */
1603
+ readonly getMany: (
1604
+ ids: ReadonlyArray<string>,
1605
+ ) => Effect.Effect<HashMap.HashMap<string, Resource>, E, R>;
1606
+ /**
1607
+ * One page of the rows the viewer may see, with the unpaged total, in
1608
+ * `request.orderBy` closed by `id` (`NULL`s last; see {@link ListOrder}).
1609
+ * The limit is clamped to {@link MAX_PAGE_LIMIT}. Fails
1610
+ * {@link InvalidListOrder}, before the viewer or the store is read, when
1611
+ * an order key names a column that is not sortable (see
1612
+ * {@link SubtypeDefinition.sortable}) or is not shaped as an order. The
1613
+ * order never changes which rows are listed, nor the total
1614
+ * (`SEC-AUTH-ENUM-001`).
1615
+ */
1616
+ readonly list: (
1617
+ request: ListRequest<Following>,
1618
+ ) => Effect.Effect<Page<Resource>, InvalidListOrder | E, R>;
1619
+ /**
1620
+ * Gated like a write (`READ-009`): the history exposes every state,
1621
+ * private and deleted included. Fails as `update` does at the gate (on a
1622
+ * reference leaf, `NodeNotFound` for a row `get` hides from the caller,
1623
+ * pulpit#101), then `VersionNotFound` when the version has no row.
1624
+ */
1625
+ readonly getVersion: (
1626
+ id: string,
1627
+ version: bigint,
1628
+ ) => Effect.Effect<VersionSnapshot, Gate | NodeNotFound | VersionNotFound | E, R>;
1629
+ /**
1630
+ * The field-level change set from version `from` to version `to`: the
1631
+ * chain's `compare` (two version reads and `diffStates`, see
1632
+ * {@link VersionComparison}) behind exactly {@link SubtypeOps.getVersion}'s
1633
+ * gate (`READ-009`), which both run through, so nothing is read before it
1634
+ * passes. Fails as `getVersion` does: `Unauthorized` when anonymous;
1635
+ * `Forbidden` below the write rung (owner-or-moderator on an owned row,
1636
+ * whatever its visibility, pulpit#24; the corpus-write rung on a reference
1637
+ * leaf); `NodeNotFound` when an owned row has no owner or a reference row
1638
+ * is missing or one `get` hides from the caller (pulpit#101), whichever
1639
+ * version is asked for; then `VersionNotFound` for whichever version
1640
+ * (`from` first) has no row.
1641
+ */
1642
+ readonly compare: (
1643
+ id: string,
1644
+ from: bigint,
1645
+ to: bigint,
1646
+ ) => Effect.Effect<VersionComparison, Gate | NodeNotFound | VersionNotFound | E, R>;
1647
+ /**
1648
+ * Re-apply the state at `version` as a new version, gated like a write
1649
+ * (on a reference leaf, `NodeNotFound` for a row `get` hides from the
1650
+ * caller, pulpit#101). Fails `VersionNotFound`, before writing anything,
1651
+ * when that version
1652
+ * cannot be fully reconstructed, and `StaleVersion` when the head is not
1653
+ * `precondition.expectedVersion`.
1654
+ */
1655
+ readonly restoreVersion: (
1656
+ id: string,
1657
+ version: bigint,
1658
+ precondition?: Precondition,
1659
+ ) => Effect.Effect<
1660
+ ChainWrite,
1661
+ Gate | WriteRejected | NodeNotFound | VersionNotFound | StaleVersion | VersionConflict | E,
1662
+ R
1663
+ >;
1664
+ }
1665
+
1666
+ /**
1667
+ * Bind a leaf's operations. Every produced operation is traced as
1668
+ * `${type}.<operation>`; the write operations run in the caller's
1669
+ * transaction (the boundary is the handler seam's, website #1883).
1670
+ *
1671
+ * Throws {@link SubtypeDeclarationError}, when the leaf is declared, for a
1672
+ * `sortable` entry that is not a column of the view.
1673
+ *
1674
+ * @since 0.1.0
1675
+ */
1676
+ export const defineSubtype = <Row extends LeafRow, Resource, Following, E, R>(
1677
+ definition: SubtypeDefinition<Row, Resource, Following, E, R>,
1678
+ ): SubtypeOps<Resource, E, R, Following> => {
1679
+ const { type, access, leaves, policy, followers, toResource } = definition;
1680
+ const hooks = definition.hooks ?? Effect.succeed(noHooks<Resource, E, R>());
1681
+ // The history reads run no hook, so they read through a chain bound without one.
1682
+ const history = defineVersionChain(type, definition.chain);
1683
+ // Bound only for a leaf that declares hooks, so a leaf without them reads nothing extra.
1684
+ const chain = defineVersionChain<E | WriteRejected, R>(
1685
+ type,
1686
+ definition.chain,
1687
+ definition.hooks === undefined
1688
+ ? {}
1689
+ : {
1690
+ beforeAppend: (event) =>
1691
+ Effect.flatMap(hooks, ({ before }) =>
1692
+ Array.isReadonlyArrayNonEmpty(before)
1693
+ ? Effect.flatMap(definition.viewer, (viewer) =>
1694
+ runBefore(before, { ...event, type, viewer }),
1695
+ )
1696
+ : Effect.succeed(event.patch),
1697
+ ),
1698
+ beforeAppendLocales: (event) =>
1699
+ Effect.flatMap(hooks, ({ before }) =>
1700
+ Array.isReadonlyArrayNonEmpty(before)
1701
+ ? Effect.flatMap(definition.viewer, (viewer) =>
1702
+ runBeforeLocales(before, { ...event, type, viewer }),
1703
+ )
1704
+ : Effect.succeed(event.locales ?? {}),
1705
+ ),
1706
+ },
1707
+ );
1708
+ const actor = (account: ViewerAccount) => Option.some(account.id);
1709
+
1710
+ // The view's column set is fixed at bind time, so the owned leaf's
1711
+ // declared-column check is answered once, not per row.
1712
+ const ownedColumnsDeclared = access.kind === "owned" && declaresColumns(access, leaves.columns);
1713
+ // Checked here, so a misspelt `sortable` entry throws when the leaf is declared.
1714
+ const isSortable = sortableColumns(type, leaves.columns, definition.sortable);
1715
+ // Checked here too, so a projection naming a column the view lacks throws when the leaf is declared.
1716
+ if (definition.search !== undefined) checkSearchProjection(definition.search, leaves.columns);
1717
+ // And the locale level, so a translated column the view lacks throws at declaration.
1718
+ const localeLevel = Option.fromUndefinedOr(definition.locales);
1719
+ if (Option.isSome(localeLevel)) {
1720
+ checkLocaleLevel(localeLevel.value, leaves.columns, [
1721
+ ...accessColumns(access),
1722
+ ...(definition.publication === undefined ? [] : PUBLICATION_COLUMNS),
1723
+ ]);
1724
+ }
1725
+
1726
+ /**
1727
+ * The write's locale level checked against the declaration (pulpit#56):
1728
+ * shared columns in `state`, translated ones per locale, every locale tag
1729
+ * non-empty; a leaf with none takes no locale. Runs after the gate.
1730
+ */
1731
+ const lockedLocales = (
1732
+ id: string,
1733
+ action: WriteAction,
1734
+ state: ChainPatch,
1735
+ locales: LocalePatch,
1736
+ ): Effect.Effect<void, WriteRejected> =>
1737
+ Option.match(
1738
+ localeViolation(
1739
+ Option.map(localeLevel, (level) => level.columns),
1740
+ state,
1741
+ locales,
1742
+ ),
1743
+ {
1744
+ onNone: () => Effect.void,
1745
+ onSome: (reason) => Effect.fail(new WriteRejected({ type, id, action, reason })),
1746
+ },
1747
+ );
1748
+
1749
+ /**
1750
+ * The row's own visibility, by access kind; the soft-delete axis is checked
1751
+ * by the caller. It fails closed exactly where the list filter does, so
1752
+ * `get` / `getMany` never serve a row `list` hides (SEC-AUTH-ENUM-001): an
1753
+ * owned leaf whose view lacks a declared column shows no row, as
1754
+ * `ownedListFilter` lists none; a reference row shows only when the view
1755
+ * has no `visibility` column, the viewer holds the view-private rung, its
1756
+ * visibility is exactly `public`, or it is exactly `followers` and the
1757
+ * viewer is, or follows, the account its declared `followersOfColumn` names, as
1758
+ * `referencePrivacyFilter` lists (pulpit#92). The reference arm is one
1759
+ * shared predicate with `./edges`' `canViewEndpoint`.
1760
+ */
1761
+ const rowVisible = (viewer: Viewer, row: Row): Effect.Effect<boolean, never, R> => {
1762
+ if (access.kind === "reference") {
1763
+ return canViewReferenceRow(access, leaves.columns, viewer, policy, followers, row);
1764
+ }
1765
+ return ownedColumnsDeclared
1766
+ ? canViewOwnedRow(viewer, policy, followers, ownedFacts(access, row))
1767
+ : Effect.succeed(false);
1768
+ };
1769
+
1770
+ const visible = (viewer: Viewer, row: Row): Effect.Effect<boolean, never, R> =>
1771
+ row.action === "delete" && !Option.exists(viewer, policy.canViewDeleted)
1772
+ ? Effect.succeed(false)
1773
+ : rowVisible(viewer, row);
1774
+
1775
+ const publication = Option.fromUndefinedOr(definition.publication);
1776
+
1777
+ /** Whether the viewer's rung reads every head: the view-private rung, or a reference leaf's corpus writers. */
1778
+ const readsHeadByRung = (viewer: Viewer): boolean =>
1779
+ Option.exists(
1780
+ viewer,
1781
+ (account) =>
1782
+ policy.canViewPrivate(account) ||
1783
+ (access.kind === "reference" && policy.canWriteCorpus(account)),
1784
+ );
1785
+
1786
+ /** Whether the viewer reads this node's head: by rung, or as its owner. */
1787
+ const readsHeadOf = (viewer: Viewer, head: Row): boolean =>
1788
+ readsHeadByRung(viewer) ||
1789
+ (access.kind === "owned" &&
1790
+ Option.exists(viewer, (account) =>
1791
+ Option.contains(ownedFacts(access, head).ownerId, account.id),
1792
+ ));
1793
+
1794
+ /**
1795
+ * The row the viewer reads for a node whose head is `head`: the head
1796
+ * itself, or on a publish-gated leaf, for a viewer who doesn't read it,
1797
+ * the live row (none without one).
1798
+ */
1799
+ const chosenRow = (viewer: Viewer, head: Row): Effect.Effect<Option.Option<Row>, E, R> =>
1800
+ Option.match(publication, {
1801
+ onNone: () => Effect.succeedSome(head),
1802
+ onSome: ({ live }) =>
1803
+ readsHeadOf(viewer, head) ? Effect.succeedSome(head) : live.get(head.id),
1804
+ });
1805
+
1806
+ /**
1807
+ * The row `get` serves the viewer: a missing or hidden row is the same
1808
+ * {@link NodeNotFound} (SEC-AUTH-ENUM-001). `get` and the reference write
1809
+ * gate both read through it, so they can never disagree (pulpit#101).
1810
+ */
1811
+ const visibleRow = (viewer: Viewer, id: string): Effect.Effect<Row, NodeNotFound | E, R> =>
1812
+ Effect.gen(function* () {
1813
+ const head = yield* leaves.get(id);
1814
+ const row = Option.isSome(head) ? yield* chosenRow(viewer, head.value) : Option.none();
1815
+ if (Option.isSome(row) && (yield* visible(viewer, row.value))) return row.value;
1816
+ return yield* new NodeNotFound({ type, id });
1817
+ });
1818
+
1819
+ /** Whether this request holds a preview grant for `id` (a publish-gated leaf's alone). */
1820
+ const previewing = (id: string): Effect.Effect<boolean, never, R> =>
1821
+ Option.match(
1822
+ Option.flatMapNullishOr(publication, (declared) => declared.preview),
1823
+ {
1824
+ onNone: () => Effect.succeed(false),
1825
+ onSome: (grant) => Effect.map(grant, (granted) => Option.contains(granted, id)),
1826
+ },
1827
+ );
1828
+
1829
+ /**
1830
+ * The gate for a write or history read to an existing row. On a reference
1831
+ * leaf, the corpus-write rung and then the read rung the row demands: a
1832
+ * corpus writer who can't `get` the row learns `NodeNotFound`, exactly as
1833
+ * from `get`, before any content is read or written (pulpit#101,
1834
+ * `PUB-009`). The rung check runs first and reads no row, so a caller below
1835
+ * it gets `Unauthorized` / `Forbidden` for every id alike. An owned leaf's
1836
+ * gate is unchanged (pulpit#24).
1837
+ */
1838
+ const writer = (viewer: Viewer, id: string) =>
1839
+ Effect.tap(requireWriter(access, viewer, policy, { type, id }, leaves.ownerOf(id)), () =>
1840
+ access.kind === "reference" ? Effect.asVoid(visibleRow(viewer, id)) : Effect.void,
1841
+ );
1842
+
1843
+ const create = Effect.fn(`${type}.create`)(function* (input: CreateRequest) {
1844
+ const account = yield* requireCreator(access, yield* definition.viewer, policy);
1845
+ yield* lockedLocales(
1846
+ Option.getOrElse(input.id, () => ""),
1847
+ "create",
1848
+ input.state,
1849
+ input.locales ?? {},
1850
+ );
1851
+ const written = yield* chain.create({ ...input, actorId: actor(account) });
1852
+ if (access.kind === "owned") yield* leaves.stampOwner(written.id, account.id);
1853
+ return written;
1854
+ });
1855
+
1856
+ const update = Effect.fn(`${type}.update`)(function* (input: UpdateRequest) {
1857
+ const account = yield* writer(yield* definition.viewer, input.id);
1858
+ yield* lockedLocales(input.id, "update", input.patch, input.locales ?? {});
1859
+ return yield* chain.update({ ...input, actorId: actor(account) });
1860
+ });
1861
+
1862
+ const restore = Effect.fn(`${type}.restore`)(function* (input: RestoreRequest) {
1863
+ const account = yield* writer(yield* definition.viewer, input.id);
1864
+ yield* lockedLocales(input.id, "restore", input.patch, input.locales ?? {});
1865
+ return yield* chain.restore({ ...input, actorId: actor(account) });
1866
+ });
1867
+
1868
+ const softDelete = Effect.fn(`${type}.delete`)(function* (
1869
+ id: string,
1870
+ precondition: Precondition = {},
1871
+ ) {
1872
+ const account = yield* writer(yield* definition.viewer, id);
1873
+ return yield* chain.delete(id, actor(account), precondition);
1874
+ });
1875
+
1876
+ const get = Effect.fn(`${type}.get`)(function* (id: string) {
1877
+ // A preview grant serves exactly its node's head, unless tombstoned.
1878
+ if (yield* previewing(id)) {
1879
+ const head = yield* leaves.get(id);
1880
+ if (Option.isSome(head) && head.value.action !== "delete") return toResource(head.value);
1881
+ }
1882
+ // Hidden and missing are the same answer (SEC-AUTH-ENUM-001).
1883
+ return toResource(yield* visibleRow(yield* definition.viewer, id));
1884
+ });
1885
+
1886
+ const owners = Effect.fn(`${type}.owners`)(function* (request: OwnersRequest) {
1887
+ if (access.kind !== "owned") return { rows: [], total: 0 };
1888
+ const viewer = yield* definition.viewer;
1889
+ const among = request.among === undefined ? always : anyOf(access.ownerColumn, request.among);
1890
+ const query = {
1891
+ column: access.ownerColumn,
1892
+ where: searchScopeFilter({ access, columns: leaves.columns, policy, followers }, viewer, {
1893
+ includeDeleted: request.includeDeleted,
1894
+ where: among,
1895
+ }),
1896
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
1897
+ offset: Math.max(0, Math.trunc(request.offset)),
1898
+ };
1899
+ // The same choice `list` makes: the head by rung, else live rows and the viewer's own heads.
1900
+ return yield* Option.match(publication, {
1901
+ onNone: () => leaves.distinct(query),
1902
+ onSome: ({ live }) =>
1903
+ readsHeadByRung(viewer)
1904
+ ? leaves.distinct(query)
1905
+ : live.distinct({ ...query, headFor: ownHeads(viewer) }),
1906
+ });
1907
+ });
1908
+
1909
+ const getLocalised = Effect.fn(`${type}.getLocalised`)(function* (
1910
+ id: string,
1911
+ locales: ReadonlyArray<string>,
1912
+ ) {
1913
+ const viewer = yield* definition.viewer;
1914
+ // The node's own gate, exactly as `get` runs it: a preview grant reads
1915
+ // its node's head unless tombstoned, anyone else the row `get` serves.
1916
+ const preview = (yield* previewing(id))
1917
+ ? Option.filter(yield* leaves.get(id), (head) => head.action !== "delete")
1918
+ : Option.none<Row>();
1919
+ const shown = Option.isSome(preview) ? preview.value : yield* visibleRow(viewer, id);
1920
+ if (Option.isNone(localeLevel)) return yield* new NodeNotFound({ type, id });
1921
+ // One read of the requested locales' rows, from the version the gate chose.
1922
+ const wanted = Array.dedupe(locales);
1923
+ const query = {
1924
+ where: and<Following>(eq("id", id), localeIn(wanted)),
1925
+ orderBy: [],
1926
+ limit: Math.max(1, Math.min(wanted.length, MAX_PAGE_LIMIT)),
1927
+ offset: 0,
1928
+ };
1929
+ const page = yield* Option.match(publication, {
1930
+ onNone: () => leaves.list(query),
1931
+ onSome: ({ live }) =>
1932
+ Option.isSome(preview) || readsHeadOf(viewer, shown)
1933
+ ? leaves.list(query)
1934
+ : live.list({ ...query, headFor: never }),
1935
+ });
1936
+ // Defence in depth: each locale row passes the node's read gate itself,
1937
+ // so a store that joined a locale row onto other access facts serves none.
1938
+ const readable = Option.isSome(preview)
1939
+ ? page.rows
1940
+ : yield* Effect.filter(page.rows, (row) => visible(viewer, row));
1941
+ const found = Array.findFirst(wanted, (tag) =>
1942
+ Array.findFirst(readable, (row) => row[LOCALE_COLUMN] === tag).pipe(
1943
+ Option.map((row): LocalisedResource<Resource> => ({
1944
+ locale: tag,
1945
+ resource: toResource(row),
1946
+ })),
1947
+ ),
1948
+ );
1949
+ if (Option.isNone(found)) return yield* new NodeNotFound({ type, id });
1950
+ return found.value;
1951
+ });
1952
+
1953
+ /** The rows a viewer reads for these heads: the heads they read, and one live read for the rest. */
1954
+ const chosenRows = (viewer: Viewer, heads: ReadonlyArray<Row>) =>
1955
+ Option.match(publication, {
1956
+ onNone: () => Effect.succeed(heads),
1957
+ onSome: ({ live }) => {
1958
+ const own = Array.filter(heads, (head) => readsHeadOf(viewer, head));
1959
+ const others = Array.filter(heads, (head) => !readsHeadOf(viewer, head));
1960
+ return Array.isReadonlyArrayNonEmpty(others)
1961
+ ? Effect.map(
1962
+ live.getMany(Array.map(others, (head) => head.id)),
1963
+ (lives): ReadonlyArray<Row> => [...own, ...lives],
1964
+ )
1965
+ : Effect.succeed(own);
1966
+ },
1967
+ });
1968
+
1969
+ const getMany = Effect.fn(`${type}.getMany`)(function* (ids: ReadonlyArray<string>) {
1970
+ if (!Array.isReadonlyArrayNonEmpty(ids)) return HashMap.empty<string, Resource>();
1971
+ const viewer = yield* definition.viewer;
1972
+ const rows = yield* chosenRows(viewer, yield* leaves.getMany(Array.dedupe(ids)));
1973
+ const shown = yield* Effect.filter(rows, (row) => visible(viewer, row));
1974
+ return HashMap.fromIterable(Array.map(shown, (row) => [row.id, toResource(row)] as const));
1975
+ });
1976
+
1977
+ /** The heads a viewer below the head-reading rungs reads: their own, on an owned leaf. */
1978
+ const ownHeads = (viewer: Viewer): ListFilter<Following> =>
1979
+ access.kind === "owned"
1980
+ ? Option.match(viewer, {
1981
+ onNone: (): ListFilter<Following> => never,
1982
+ onSome: (account) => eq(access.ownerColumn, account.id),
1983
+ })
1984
+ : never;
1985
+
1986
+ const list = Effect.fn(`${type}.list`)(function* (request: ListRequest<Following>) {
1987
+ const orderBy = yield* checkedOrder(type, isSortable, request.orderBy ?? []);
1988
+ const viewer = yield* definition.viewer;
1989
+ const where = searchScopeFilter(
1990
+ { access, columns: leaves.columns, policy, followers },
1991
+ viewer,
1992
+ request,
1993
+ );
1994
+ const query = {
1995
+ where,
1996
+ orderBy,
1997
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
1998
+ offset: Math.max(0, Math.trunc(request.offset)),
1999
+ };
2000
+ // On a publish-gated leaf, a viewer below the head-reading rungs lists
2001
+ // live rows, and their own rows at the head, as `get` reads them.
2002
+ const page = yield* Option.match(publication, {
2003
+ onNone: () => leaves.list(query),
2004
+ onSome: ({ live }) =>
2005
+ readsHeadByRung(viewer)
2006
+ ? leaves.list(query)
2007
+ : live.list({ ...query, headFor: ownHeads(viewer) }),
2008
+ });
2009
+ return { rows: page.rows.map(toResource), total: page.total };
2010
+ });
2011
+
2012
+ /**
2013
+ * A history read (`READ-009`): the viewer's {@link writer} gate on `id`,
2014
+ * then `read`. `getVersion` and `compare` both run through it, so their
2015
+ * gate is one code path and `read` never runs for a caller it refuses.
2016
+ */
2017
+ const historyRead = <A, E2>(id: string, read: Effect.Effect<A, E2, R>) =>
2018
+ Effect.andThen(
2019
+ Effect.flatMap(definition.viewer, (viewer) => writer(viewer, id)),
2020
+ read,
2021
+ );
2022
+
2023
+ const getVersion = Effect.fn(`${type}.getVersion`)(function* (id: string, version: bigint) {
2024
+ return yield* historyRead(id, history.getVersion(id, version));
2025
+ });
2026
+
2027
+ const compare = Effect.fn(`${type}.compare`)(function* (id: string, from: bigint, to: bigint) {
2028
+ return yield* historyRead(id, history.compare(id, from, to));
2029
+ });
2030
+
2031
+ const restoreVersion = Effect.fn(`${type}.restoreVersion`)(function* (
2032
+ id: string,
2033
+ version: bigint,
2034
+ precondition: Precondition = {},
2035
+ ) {
2036
+ const account = yield* writer(yield* definition.viewer, id);
2037
+ return yield* chain.restoreVersion(id, version, actor(account), precondition);
2038
+ });
2039
+
2040
+ const publicResource = Effect.fn(`${type}.publicResource`)(function* (id: string) {
2041
+ return yield* visibleRow(Option.none(), id).pipe(
2042
+ Effect.map((row) => Option.some(toResource(row))),
2043
+ Effect.catchTag("NodeNotFound", () => Effect.succeed(Option.none<Resource>())),
2044
+ );
2045
+ });
2046
+
2047
+ const authorize = Effect.fn(`${type}.authorize`)(function* (id: string) {
2048
+ return yield* writer(yield* definition.viewer, id);
2049
+ });
2050
+
2051
+ /** A publication's before hooks: shown the head, an empty patch; the returned patch is ignored. */
2052
+ const beforePublication = (
2053
+ action: "publish" | "unpublish",
2054
+ id: string,
2055
+ viewer: Viewer,
2056
+ account: ViewerAccount,
2057
+ ) =>
2058
+ definition.hooks === undefined
2059
+ ? Effect.void
2060
+ : Effect.gen(function* () {
2061
+ const { before } = yield* hooks;
2062
+ if (!Array.isReadonlyArrayNonEmpty(before)) return;
2063
+ const head = yield* definition.chain.head(id);
2064
+ const prior = Option.isSome(head)
2065
+ ? yield* definition.chain.snapshot(id, head.value.version)
2066
+ : Option.none();
2067
+ yield* runBefore(before, {
2068
+ action,
2069
+ id,
2070
+ prior,
2071
+ patch: {},
2072
+ actorId: actor(account),
2073
+ type,
2074
+ viewer,
2075
+ });
2076
+ });
2077
+
2078
+ const publicationOps = Option.map(publication, ({ facts }): PublicationOps<E, R> => ({
2079
+ publish: Effect.fn(`${type}.publish`)(function* (id: string, options: PublishOptions = {}) {
2080
+ const viewer = yield* definition.viewer;
2081
+ const account = yield* writer(viewer, id);
2082
+ const head = yield* definition.chain.head(id);
2083
+ if (Option.isNone(head) || head.value.action === "delete") {
2084
+ return yield* new NodeNotFound({ type, id });
2085
+ }
2086
+ const version = options.version ?? head.value.version;
2087
+ const snapshot = yield* definition.chain.snapshot(id, version);
2088
+ if (Option.isNone(snapshot) || snapshot.value.action === "delete") {
2089
+ return yield* new VersionNotFoundError({ type, id, version });
2090
+ }
2091
+ yield* beforePublication("publish", id, viewer, account);
2092
+ const now = yield* nowDate;
2093
+ const fact: PublicationFact = {
2094
+ id,
2095
+ version: Option.some(version),
2096
+ at: options.at ?? now,
2097
+ actorId: actor(account),
2098
+ recordedAt: now,
2099
+ };
2100
+ yield* facts.record(fact);
2101
+ return { fact, head: head.value.version };
2102
+ }),
2103
+ unpublish: Effect.fn(`${type}.unpublish`)(function* (id: string) {
2104
+ const viewer = yield* definition.viewer;
2105
+ const account = yield* writer(viewer, id);
2106
+ const head = yield* definition.chain.head(id);
2107
+ if (Option.isNone(head)) return yield* new NodeNotFound({ type, id });
2108
+ yield* beforePublication("unpublish", id, viewer, account);
2109
+ const now = yield* nowDate;
2110
+ const fact: PublicationFact = {
2111
+ id,
2112
+ version: Option.none(),
2113
+ at: now,
2114
+ actorId: actor(account),
2115
+ recordedAt: now,
2116
+ };
2117
+ yield* facts.record(fact);
2118
+ return { fact, head: head.value.version };
2119
+ }),
2120
+ discardDraft: Effect.fn(`${type}.discardDraft`)(function* (
2121
+ id: string,
2122
+ precondition: Precondition = {},
2123
+ ) {
2124
+ const account = yield* writer(yield* definition.viewer, id);
2125
+ const live = Option.flatMap(yield* facts.effective(id), (fact) => fact.version);
2126
+ if (Option.isNone(live)) return yield* new NotPublished({ type, id });
2127
+ const head = yield* definition.chain.head(id);
2128
+ if (Option.exists(head, (entry) => entry.version === live.value)) {
2129
+ return { id, version: live.value };
2130
+ }
2131
+ return yield* chain.restoreVersion(id, live.value, actor(account), precondition);
2132
+ }),
2133
+ facts: Effect.fn(`${type}.publicationFacts`)(function* (id: string) {
2134
+ return yield* historyRead(id, facts.facts(id, MAX_HISTORY_LIMIT));
2135
+ }),
2136
+ }));
2137
+
2138
+ return {
2139
+ type,
2140
+ access,
2141
+ hooks,
2142
+ publication: publicationOps,
2143
+ publicResource,
2144
+ authorize,
2145
+ create,
2146
+ update,
2147
+ restore,
2148
+ delete: softDelete,
2149
+ get,
2150
+ getLocalised,
2151
+ owners,
2152
+ getMany,
2153
+ list,
2154
+ getVersion,
2155
+ compare,
2156
+ restoreVersion,
2157
+ };
2158
+ };
2159
+
2160
+ // ── Search ────────────────────────────────────────────────────────────
2161
+
2162
+ /**
2163
+ * A searchable field's rank weight, PostgreSQL's `setweight` labels from the
2164
+ * strongest (`A`) to the weakest (`D`).
2165
+ *
2166
+ * @since 0.1.0
2167
+ */
2168
+ export type SearchWeight = "A" | "B" | "C" | "D";
2169
+
2170
+ /**
2171
+ * One indexed column of a {@link SearchProjection} and its weight.
2172
+ *
2173
+ * @since 0.1.0
2174
+ */
2175
+ export interface SearchField {
2176
+ readonly column: string;
2177
+ readonly weight: SearchWeight;
2178
+ }
2179
+
2180
+ /**
2181
+ * A leaf's searchable projection (pulpit#54): which view columns its search
2182
+ * view indexes, at what weight, and the column naming each row's text
2183
+ * search configuration (its language), if any. Declared once, beside the
2184
+ * leaf's registry entry, so the application's search-view generator and
2185
+ * {@link defineSubtype} (`search`) read the same value. The ranking itself
2186
+ * (full text, trigram, vectors, their fusion) is the application's SQL.
2187
+ *
2188
+ * @since 0.1.0
2189
+ */
2190
+ export interface SearchProjection {
2191
+ /** The indexed columns, each once, strongest first by convention. */
2192
+ readonly fields: ReadonlyArray<SearchField>;
2193
+ /** The view column holding each row's text search configuration, or none for one fixed configuration. */
2194
+ readonly languageColumn: Option.Option<string>;
2195
+ }
2196
+
2197
+ const checkSearchProjection = (projection: SearchProjection, columns: ReadonlySet<string>) => {
2198
+ const named = [
2199
+ ...Array.map(projection.fields, (field) => field.column),
2200
+ ...Option.toArray(projection.languageColumn),
2201
+ ];
2202
+ const undeclared = Array.findFirst(named, (column) => !columns.has(column));
2203
+ if (Option.isSome(undeclared)) {
2204
+ throw new SubtypeDeclarationError({
2205
+ message: `search names '${undeclared.value}', which is not a column of the view`,
2206
+ });
2207
+ }
2208
+ };
2209
+
2210
+ /**
2211
+ * Declare a {@link SearchProjection}. Throws {@link SubtypeDeclarationError}
2212
+ * at import, as a registry violation is, for no field, a column named twice
2213
+ * or a weight outside `A`–`D`; {@link defineSubtype} checks the columns
2214
+ * against the view.
2215
+ *
2216
+ * @example
2217
+ * ```ts
2218
+ * import { strictEqual } from "node:assert";
2219
+ * import { defineSearchProjection } from "@thomasfosterau/pulpit/subtype";
2220
+ *
2221
+ * const posts = defineSearchProjection({
2222
+ * fields: [
2223
+ * { column: "title", weight: "A" },
2224
+ * { column: "excerpt", weight: "B" },
2225
+ * ],
2226
+ * languageColumn: "language",
2227
+ * });
2228
+ * strictEqual(posts.fields.length, 2);
2229
+ * ```
2230
+ *
2231
+ * @since 0.1.0
2232
+ */
2233
+ export const defineSearchProjection = (declaration: {
2234
+ readonly fields: ReadonlyArray<SearchField>;
2235
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is one fixed configuration
2236
+ readonly languageColumn?: string | undefined;
2237
+ }): SearchProjection => {
2238
+ if (!Array.isReadonlyArrayNonEmpty(declaration.fields)) {
2239
+ throw new SubtypeDeclarationError({ message: "search declares no field" });
2240
+ }
2241
+ const repeated = Array.findFirst(declaration.fields, (field, index) =>
2242
+ declaration.fields.slice(0, index).some((earlier) => earlier.column === field.column),
2243
+ );
2244
+ if (Option.isSome(repeated)) {
2245
+ throw new SubtypeDeclarationError({
2246
+ message: `search names '${repeated.value.column}' twice`,
2247
+ });
2248
+ }
2249
+ const unweighted = Array.findFirst(
2250
+ declaration.fields,
2251
+ (field) => !Array.contains(["A", "B", "C", "D"], field.weight),
2252
+ );
2253
+ if (Option.isSome(unweighted)) {
2254
+ throw new SubtypeDeclarationError({
2255
+ message: `search weighs '${unweighted.value.column}' outside A–D`,
2256
+ });
2257
+ }
2258
+ return {
2259
+ fields: declaration.fields,
2260
+ languageColumn: Option.fromNullishOr(declaration.languageColumn),
2261
+ };
2262
+ };
2263
+
2264
+ /**
2265
+ * The read scope a search over one leaf must apply to its hits: exactly the
2266
+ * filter {@link defineSubtype}'s `list` reads under (`list` calls this), the
2267
+ * soft-delete arm, the access kind's privacy arm (owned visibility and draft
2268
+ * through {@link ownedListFilter}; the reference privacy arm with its
2269
+ * followers, `PUB-009`) and the family's own `where`, so a search can never
2270
+ * surface a row `list` hides (`SEC-AUTH-ENUM-001`). The application renders
2271
+ * it into its search query as the list store renders it into the list's. On
2272
+ * a leaf with the live pointer ({@link SubtypeDefinition.publication}) the
2273
+ * search matches over the rows {@link LiveView}'s `list` reads (the viewer's
2274
+ * own at the head, every other node's live row), never every head, so a
2275
+ * match never scores text a reader cannot see.
2276
+ *
2277
+ * @since 0.1.0
2278
+ */
2279
+ export const searchScopeFilter = <Following>(
2280
+ leaf: {
2281
+ readonly access: LeafAccess;
2282
+ readonly columns: ReadonlySet<string>;
2283
+ readonly policy: AccessPolicy;
2284
+ readonly followers: FollowerSeam<Following, unknown>;
2285
+ },
2286
+ viewer: Viewer,
2287
+ request: Pick<ListRequest<Following>, "includeDeleted" | "where">,
2288
+ ): ListFilter<Following> => {
2289
+ const { access, columns, policy, followers } = leaf;
2290
+ const privacy =
2291
+ access.kind === "owned"
2292
+ ? ownedListFilter(access, columns, readScopeFor(viewer, policy, followers))
2293
+ : referencePrivacyFilter(viewer, policy, columns, Option.some({ access, followers }));
2294
+ return and<Following>(
2295
+ softDeleteFilter(viewer, policy, request.includeDeleted),
2296
+ privacy,
2297
+ request.where,
2298
+ );
2299
+ };
2300
+
2301
+ /**
2302
+ * Hydrate ranked search hits through the leaf's gated `getMany`: one read
2303
+ * for every hit, each re-checked against the viewer by the same predicate
2304
+ * `get` applies, so a stale or poisoned index (a hit the search scope should
2305
+ * have excluded, or one hidden since it was indexed) never leaks a row
2306
+ * (`SEC-AUTH-ENUM-001`). The hits keep their rank order, a hidden or
2307
+ * missing one is dropped, and a repeated id keeps its first, best rank.
2308
+ * Fails with the store's `E`.
2309
+ *
2310
+ * @since 0.1.0
2311
+ */
2312
+ export const hydrateSearchHits = <Hit extends { readonly id: string }, Resource, E, R>(
2313
+ ops: Pick<SubtypeOps<Resource, E, R>, "getMany">,
2314
+ hits: ReadonlyArray<Hit>,
2315
+ ): Effect.Effect<ReadonlyArray<{ readonly hit: Hit; readonly resource: Resource }>, E, R> => {
2316
+ const ranked = Array.dedupeWith(hits, (a, b) => a.id === b.id);
2317
+ return Effect.map(ops.getMany(Array.map(ranked, (hit) => hit.id)), (shown) =>
2318
+ Array.flatMap(ranked, (hit) =>
2319
+ Option.toArray(Option.map(HashMap.get(shown, hit.id), (resource) => ({ hit, resource }))),
2320
+ ),
2321
+ );
2322
+ };
2323
+
2324
+ // ── The handlers seam ─────────────────────────────────────────────────
2325
+
2326
+ /**
2327
+ * The resource cache's write side: `set` writes the resource through — the
2328
+ * cache itself refuses (and evicts) a non-serveable one, so a
2329
+ * `public → private` flip self-purges — and `delete` evicts. The application
2330
+ * fills it with a cache it owns; no library is expected to provide one
2331
+ * (effect-web dropped its Cloudflare package, and the planned cache with it,
2332
+ * on effect-web#33).
2333
+ *
2334
+ * **Compare-on-set** (pulpit#121). Both calls carry the version they speak
2335
+ * for, and the cache keeps, per id, the highest version it has been given by
2336
+ * either. A `set` below that version is a no-op, and a `delete` (or a refused
2337
+ * `set`) raises it, outliving the entry it evicted. So a racing writer's
2338
+ * late write-through, or a read-through fill that read before a hiding
2339
+ * write, can never re-cache a resource older than one already evicted or
2340
+ * replaced for the id (`CACHE-AUTH-005`). The compare and the write must be
2341
+ * one atomic step in the cache's store: a store with no conditional write
2342
+ * cannot honour the seam. A read-through fill passes the version its read
2343
+ * saw, and goes through `set` like any other.
2344
+ *
2345
+ * @since 0.1.0
2346
+ */
2347
+ export interface ResourceCacheSeam<Resource, R> {
2348
+ /**
2349
+ * Write the resource through, as of `version`: a no-op below the highest
2350
+ * version the cache holds for `id`; the cache refuses (and evicts at
2351
+ * `version`) one that isn't serveable. Never fails.
2352
+ */
2353
+ readonly set: (id: string, resource: Resource, version: bigint) => Effect.Effect<void, never, R>;
2354
+ /**
2355
+ * Evict the resource as of `version`, which a later `set` must reach to
2356
+ * cache it again. Never fails.
2357
+ */
2358
+ readonly delete: (id: string, version: bigint) => Effect.Effect<void, never, R>;
2359
+ }
2360
+
2361
+ /**
2362
+ * The resource cache's write-through as an {@link AfterWriteHook}: a write
2363
+ * whose writer re-read the resource sets it, and any other (a `delete`, or a
2364
+ * write whose re-read failed or no longer shows the row) evicts it
2365
+ * (`CACHE-AUTH-005`). {@link subtypeHandlers} runs it first, before the
2366
+ * leaf's own after hooks, whenever its config has a cache. Never fails.
2367
+ *
2368
+ * @since 0.1.0
2369
+ */
2370
+ export const cacheWriteThrough = <Resource, R>(
2371
+ cache: ResourceCacheSeam<Resource, R>,
2372
+ ): AfterWriteHook<Resource, never, R> => ({
2373
+ name: "cache",
2374
+ run: ({ write, resource }) =>
2375
+ Option.match(resource, {
2376
+ onNone: () => cache.delete(write.id, write.version),
2377
+ onSome: (value) => cache.set(write.id, value, write.version),
2378
+ }),
2379
+ });
2380
+
2381
+ /**
2382
+ * What a handler-record write answers: the committed `{ id, version }`, and
2383
+ * the resource as its writer re-read it after the commit. `resource` is
2384
+ * none when the write hid the row from its own writer (the re-read failed
2385
+ * `NodeNotFound`): the write still committed, so it answers the write
2386
+ * rather than a 404 that would invite a duplicating retry (pulpit#107).
2387
+ *
2388
+ * @since 0.1.0
2389
+ */
2390
+ export interface CommittedWrite<Resource> extends ChainWrite {
2391
+ /** The resource as the writer now reads it; none when the write hid it from them. */
2392
+ readonly resource: Option.Option<Resource>;
2393
+ }
2394
+
2395
+ /**
2396
+ * The handler record a leaf contributes to a JSON:API server's resource
2397
+ * handlers — one entry per endpoint, the writes answering the committed
2398
+ * write and the resource the document assembler wraps
2399
+ * ({@link CommittedWrite}) — plus the version-restore command, which has no
2400
+ * JSON:API endpoint and is run SSR-direct.
2401
+ *
2402
+ * {@link subtypeResourceHandlers} turns the record into effect-jsonapi's
2403
+ * `ResourceHandlers.make` CRUD handlers (pulpit#5).
2404
+ *
2405
+ * Every write re-reads the row after the chain write and hands it to the
2406
+ * leaf's after hooks ({@link LifecycleHooks}), the cache write-through
2407
+ * ({@link cacheWriteThrough}) first, so it writes the resource through the
2408
+ * cache; that write-through is why these exist rather than each route
2409
+ * calling the operation directly: the cache serves
2410
+ * anonymous reads, and a write that skipped it would leave them on the
2411
+ * superseded content until the TTL expired. The re-read runs as the writer,
2412
+ * so a write that hides the row from its own writer fails it
2413
+ * (`NodeNotFound`) after the write committed; the handler then evicts the
2414
+ * entry, as it does for any re-read failure, so the cache never keeps the
2415
+ * superseded resource (`CACHE-AUTH-005`, pulpit#107). A hidden write
2416
+ * answers its {@link CommittedWrite} with no resource, not the
2417
+ * `NodeNotFound`: the write committed. Any other re-read failure (a store
2418
+ * error, a defect, an interrupt) fails the write with its own cause. The
2419
+ * chain write runs inside `transact` (the handler seam's boundary, website
2420
+ * #1883); the re-read stays outside it. An interrupt can't separate a
2421
+ * committed write from its write-through or eviction: a failure of the
2422
+ * boundary after the operation itself succeeded (an interrupt included)
2423
+ * evicts too, since the write may have committed, while a write the gate
2424
+ * or the precondition refuses never touches the cache, even when a
2425
+ * `transact` that retries internally rolled back an earlier attempt that
2426
+ * wrote: only the last attempt counts. That holds for `delete`'s eviction
2427
+ * as well.
2428
+ *
2429
+ * `restore` is the operations' `restore` with the same write-through.
2430
+ * `update`, `restore`, `delete` and `restoreVersion` take the operations' optional
2431
+ * expected version ({@link Precondition}); the HTTP layer maps a request's
2432
+ * `If-Match` onto it and a `StaleVersion` failure onto the 412.
2433
+ *
2434
+ * @since 0.1.0
2435
+ */
2436
+ export interface SubtypeHandlers<Resource, E, R> {
2437
+ readonly list: SubtypeOps<Resource, E, R>["list"];
2438
+ readonly get: SubtypeOps<Resource, E, R>["get"];
2439
+ readonly create: (
2440
+ input: CreateRequest,
2441
+ ) => Effect.Effect<
2442
+ CommittedWrite<Resource>,
2443
+ Gate | WriteRejected | NodeAlreadyExists | NodeNotFound | VersionConflict | E,
2444
+ R
2445
+ >;
2446
+ readonly update: (
2447
+ input: RestoreRequest,
2448
+ ) => Effect.Effect<
2449
+ CommittedWrite<Resource>,
2450
+ Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E,
2451
+ R
2452
+ >;
2453
+ readonly restore: (
2454
+ input: RestoreRequest,
2455
+ ) => Effect.Effect<
2456
+ CommittedWrite<Resource>,
2457
+ Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E,
2458
+ R
2459
+ >;
2460
+ readonly delete: (
2461
+ id: string,
2462
+ precondition?: Precondition,
2463
+ ) => Effect.Effect<
2464
+ void,
2465
+ Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E,
2466
+ R
2467
+ >;
2468
+ readonly restoreVersion: (
2469
+ input: { readonly id: string; readonly version: bigint } & Precondition,
2470
+ ) => Effect.Effect<
2471
+ CommittedWrite<Resource>,
2472
+ Gate | WriteRejected | NodeNotFound | VersionNotFound | StaleVersion | VersionConflict | E,
2473
+ R
2474
+ >;
2475
+ /**
2476
+ * On a publish-gated leaf, its publication operations, each run in
2477
+ * `transact` and followed by the after hooks (`publish` / `unpublish`,
2478
+ * or `restoreVersion` for `discardDraft`) and the cache refresh. Their
2479
+ * cache stamps are the node's head, so two of them on one node compare
2480
+ * equal: the application runs them one at a time per node, the whole
2481
+ * call under one lock (README → Seams → the live pointer, pulpit#164).
2482
+ */
2483
+ readonly publication: Option.Option<PublicationOps<E, R>>;
2484
+ }
2485
+
2486
+ /**
2487
+ * What {@link subtypeHandlers} needs besides the operations.
2488
+ *
2489
+ * @since 0.1.0
2490
+ */
2491
+ export interface SubtypeHandlersConfig<Resource, R> {
2492
+ /** The write boundary — always-open: a nested call is a SAVEPOINT (website #1883). */
2493
+ readonly transact: <A, E2>(effect: Effect.Effect<A, E2, R>) => Effect.Effect<A, E2, R>;
2494
+ /**
2495
+ * Whether the caller is already inside a transaction, read once a write's
2496
+ * `transact` has returned. When it is, that `transact` was a SAVEPOINT and
2497
+ * nothing has committed yet, so the write refuses its cache write-through
2498
+ * and evicts instead: a resource that may still roll back is never
2499
+ * cached (`CACHE-AUTH-005`, pulpit#124). Over `effect/sql`, it is whether
2500
+ * the fiber's context holds the client's `TransactionConnection`.
2501
+ */
2502
+ readonly inTransaction: Effect.Effect<boolean, never, R>;
2503
+ /** The leaf's cache, when it has one. */
2504
+ readonly cache: Option.Option<ResourceCacheSeam<Resource, R>>;
2505
+ }
2506
+
2507
+ /**
2508
+ * Wrap a leaf's operations as its handler record. An owned leaf may still
2509
+ * be cached — `CACHE-AUTH-005` is enforced by the cache's serveability
2510
+ * policy (`./publication`'s `isPubliclyServeable` with `owned: true`), which
2511
+ * only ever holds its public rows, and by the handlers' own eviction when a
2512
+ * committed write can't be re-read or written through (see
2513
+ * {@link SubtypeHandlers}). Each write fails with its operation's error, or
2514
+ * with the re-read's unless that is `NodeNotFound` (a write hidden from its
2515
+ * writer, answered as a {@link CommittedWrite} with no resource).
2516
+ *
2517
+ * @since 0.1.0
2518
+ */
2519
+ export const subtypeHandlers = <Resource, E, R>(
2520
+ ops: SubtypeOps<Resource, E, R>,
2521
+ config: SubtypeHandlersConfig<Resource, R>,
2522
+ ): SubtypeHandlers<Resource, E, R> => {
2523
+ const evict = ({ id, version }: ChainWrite) =>
2524
+ Option.match(config.cache, {
2525
+ onNone: () => Effect.void,
2526
+ onSome: (cache) => cache.delete(id, version),
2527
+ });
2528
+
2529
+ // A write runs under `uninterruptibleMask`: the chain write (inside
2530
+ // `transact`) and the re-read stay interruptible through `restore`, but
2531
+ // the step between them, and the cache step after, cannot be skipped by
2532
+ // an interrupt, so a committed write always reaches its write-through or
2533
+ // eviction (`CACHE-AUTH-005`). Once the operation itself has succeeded
2534
+ // (its gate passed and the chain write ran), any later failure of the
2535
+ // boundary, an interrupt included, evicts: the write may have committed.
2536
+ // A request the gate refused never evicts, so a forbidden request can't
2537
+ // bust the cache. The flag is cleared at the start of each attempt, so a
2538
+ // `transact` that retries internally, rolling back an attempt that wrote,
2539
+ // evicts only when its last attempt wrote.
2540
+ const committed = <A extends ChainWrite, E2>(
2541
+ restore: <AX, EX, RX>(effect: Effect.Effect<AX, EX, RX>) => Effect.Effect<AX, EX, RX>,
2542
+ operation: Effect.Effect<A, E2, R>,
2543
+ ) =>
2544
+ Effect.flatMap(Ref.make(Option.none<ChainWrite>()), (wrote) =>
2545
+ restore(
2546
+ config.transact(
2547
+ Effect.andThen(
2548
+ Ref.set(wrote, Option.none()),
2549
+ Effect.tap(operation, ({ id, version }) =>
2550
+ Ref.set(wrote, Option.some({ id, version })),
2551
+ ),
2552
+ ),
2553
+ ),
2554
+ ).pipe(
2555
+ Effect.onError(() =>
2556
+ Effect.flatMap(
2557
+ Ref.get(wrote),
2558
+ Option.match({ onNone: () => Effect.void, onSome: evict }),
2559
+ ),
2560
+ ),
2561
+ ),
2562
+ );
2563
+
2564
+ /**
2565
+ * The cache write-through. On a publish-gated leaf the writer's re-read
2566
+ * is the head, which must never reach a public cache, so the cache is
2567
+ * given the resource as an anonymous reader gets it (the live version),
2568
+ * or evicts (`CACHE-AUTH-005`).
2569
+ */
2570
+ const cacheHook = (cache: ResourceCacheSeam<Resource, R>): AfterWriteHook<Resource, never, R> =>
2571
+ Option.isNone(ops.publication)
2572
+ ? cacheWriteThrough(cache)
2573
+ : {
2574
+ name: "cache",
2575
+ run: (event) =>
2576
+ ops.publicResource(event.write.id).pipe(
2577
+ Effect.flatMap((resource) => cacheWriteThrough(cache).run({ ...event, resource })),
2578
+ Effect.catchCause(() => cache.delete(event.write.id, event.write.version)),
2579
+ ),
2580
+ };
2581
+
2582
+ /**
2583
+ * The cache step inside an outer transaction: the write hasn't committed,
2584
+ * so evict at its version and cache nothing (pulpit#124).
2585
+ */
2586
+ const evictHook = (
2587
+ cache: ResourceCacheSeam<Resource, R>,
2588
+ ): AfterWriteHook<Resource, never, R> => ({
2589
+ name: "cache",
2590
+ run: ({ write }) => cache.delete(write.id, write.version),
2591
+ });
2592
+
2593
+ /**
2594
+ * The after hooks for one write whose `transact` has returned: the cache
2595
+ * step first (when the leaf has a cache; a write-through, or an eviction
2596
+ * inside an outer transaction), then the leaf's own, in order.
2597
+ */
2598
+ const after = (event: AfterWriteEvent<Resource>) =>
2599
+ Effect.flatMap(Effect.zip(ops.hooks, config.inTransaction), ([hooks, nested]) =>
2600
+ runAfter(
2601
+ {
2602
+ ...hooks,
2603
+ after: [
2604
+ ...Option.toArray(Option.map(config.cache, nested ? evictHook : cacheHook)),
2605
+ ...hooks.after,
2606
+ ],
2607
+ },
2608
+ event,
2609
+ ),
2610
+ );
2611
+
2612
+ /**
2613
+ * A publication fact, recorded in `transact`, then the after hooks with
2614
+ * the resource as the writer now reads it.
2615
+ */
2616
+ const published = <E2>(
2617
+ action: "publish" | "unpublish",
2618
+ operation: Effect.Effect<PublicationWrite, E2, R>,
2619
+ ) =>
2620
+ Effect.uninterruptibleMask((restore) =>
2621
+ Effect.tap(restore(config.transact(operation)), ({ fact, head }) =>
2622
+ Effect.flatMap(Effect.option(restore(ops.get(fact.id))), (resource) =>
2623
+ after({
2624
+ type: ops.type,
2625
+ action,
2626
+ write: { id: fact.id, version: head },
2627
+ resource,
2628
+ publication: Option.some(fact),
2629
+ }),
2630
+ ),
2631
+ ),
2632
+ );
2633
+
2634
+ // The re-read runs as the writer, after the write committed, and its
2635
+ // outcome is the after hooks' `resource`. When it fails (the write hid
2636
+ // the row from its own writer: a corpus writer below the view-private
2637
+ // rung making a reference row `private`; a store error; an interrupt),
2638
+ // the resource is none and the cache hook evicts: the cache must never go
2639
+ // on serving the superseded resource, whatever the writer may read now
2640
+ // (`CACHE-AUTH-005`, pulpit#107). A `NodeNotFound` re-read is a write
2641
+ // hidden from its writer: it answers the committed write with no resource,
2642
+ // not a 404 for a write that committed. Any other failure fails the
2643
+ // handler with the re-read's own cause.
2644
+ const write = <E2>(
2645
+ action: WriteAction,
2646
+ operation: Effect.Effect<ChainWrite, E2, R>,
2647
+ ): Effect.Effect<CommittedWrite<Resource>, E2 | E, R> =>
2648
+ Effect.uninterruptibleMask((restore) =>
2649
+ Effect.flatMap(committed(restore, operation), (written) =>
2650
+ Effect.flatMap(Effect.exit(restore(ops.get(written.id))), (reread) =>
2651
+ Effect.andThen(
2652
+ after({
2653
+ type: ops.type,
2654
+ action,
2655
+ write: { id: written.id, version: written.version },
2656
+ resource: Exit.isSuccess(reread) ? Option.some(reread.value) : Option.none(),
2657
+ publication: Option.none(),
2658
+ }),
2659
+ reread.pipe(
2660
+ Effect.map(Option.some),
2661
+ Effect.catchTag("NodeNotFound", () => Effect.succeedNone),
2662
+ Effect.map((resource): CommittedWrite<Resource> => ({
2663
+ id: written.id,
2664
+ version: written.version,
2665
+ resource,
2666
+ })),
2667
+ ),
2668
+ ),
2669
+ ),
2670
+ ),
2671
+ );
2672
+
2673
+ return {
2674
+ list: ops.list,
2675
+ get: ops.get,
2676
+ create: (input) => write("create", ops.create(input)),
2677
+ update: (input) => write("update", ops.update(input)),
2678
+ restore: (input) => write("restore", ops.restore(input)),
2679
+ // An already tombstoned node wrote nothing, so it emits nothing; its
2680
+ // cache entry is still evicted, as every delete's is.
2681
+ delete: (id, precondition) =>
2682
+ Effect.uninterruptibleMask((restore) =>
2683
+ Effect.flatMap(committed(restore, ops.delete(id, precondition)), (written) =>
2684
+ written.alreadyDeleted
2685
+ ? evict(written)
2686
+ : after({
2687
+ type: ops.type,
2688
+ action: "delete",
2689
+ write: { id: written.id, version: written.version },
2690
+ resource: Option.none(),
2691
+ publication: Option.none(),
2692
+ }),
2693
+ ),
2694
+ ),
2695
+ restoreVersion: (input) =>
2696
+ write(
2697
+ "restoreVersion",
2698
+ ops.restoreVersion(input.id, input.version, { expectedVersion: input.expectedVersion }),
2699
+ ),
2700
+ publication: Option.map(ops.publication, (publication): PublicationOps<E, R> => ({
2701
+ publish: (id, options) => published("publish", publication.publish(id, options)),
2702
+ unpublish: (id) => published("unpublish", publication.unpublish(id)),
2703
+ discardDraft: (id, precondition) =>
2704
+ Effect.uninterruptibleMask((restore) =>
2705
+ Effect.tap(committed(restore, publication.discardDraft(id, precondition)), (written) =>
2706
+ Effect.flatMap(Effect.option(restore(ops.get(written.id))), (resource) =>
2707
+ after({
2708
+ type: ops.type,
2709
+ action: "restoreVersion",
2710
+ write: written,
2711
+ resource,
2712
+ publication: Option.none(),
2713
+ }),
2714
+ ),
2715
+ ),
2716
+ ),
2717
+ facts: publication.facts,
2718
+ })),
2719
+ };
2720
+ };
2721
+
2722
+ // ── effect-jsonapi's ResourceHandlers ─────────────────────────────────
2723
+
2724
+ /**
2725
+ * What {@link subtypeResourceHandlers} needs besides the handler record:
2726
+ * effect-jsonapi's `ResourceHandlers.make` config without its `ops`, `cache`
2727
+ * and `transact` (the record already owns the write boundary, the re-read and
2728
+ * the cache write-through), plus the three mappings from the endpoint's
2729
+ * decoded inputs onto the operations' own.
2730
+ *
2731
+ * @since 0.1.0
2732
+ */
2733
+ export interface SubtypeResourceHandlersConfig<
2734
+ TQuery,
2735
+ TCreate,
2736
+ TUpdate extends { readonly id: string },
2737
+ V,
2738
+ RV,
2739
+ RL,
2740
+ Targets extends Handlers.IncludeTargets,
2741
+ Type extends string = string,
2742
+ > extends Omit<
2743
+ ResourceHandlers.ReadOnlyConfig<
2744
+ TQuery,
2745
+ Assemble.ResourceLike,
2746
+ string,
2747
+ V,
2748
+ never,
2749
+ never,
2750
+ RV,
2751
+ RL,
2752
+ never,
2753
+ Targets
2754
+ >,
2755
+ "ops" | "cache"
2756
+ > {
2757
+ /**
2758
+ * The resource's JSON:API type, which a hidden write's identifier
2759
+ * document names (`data.type`): effect-jsonapi's `hiddenWrite.type`.
2760
+ */
2761
+ readonly type: Type;
2762
+ /** The decoded list query as a {@link ListRequest}: its page, `includeDeleted`, `where` and order. */
2763
+ readonly listRequest: (query: TQuery) => ListRequest;
2764
+ /** The decoded create payload as the operation's input: the client id, if any, and the state. */
2765
+ readonly createInput: (payload: TCreate) => {
2766
+ readonly id: Option.Option<string>;
2767
+ readonly state: ChainState;
2768
+ };
2769
+ /**
2770
+ * The decoded update payload as the operation's input: the id, the
2771
+ * tri-state patch and the optional expected version.
2772
+ */
2773
+ readonly updateInput: (
2774
+ payload: TUpdate,
2775
+ ) => { readonly id: string; readonly patch: ChainPatch } & Precondition;
2776
+ }
2777
+
2778
+ /**
2779
+ * A leaf's {@link SubtypeHandlers} as effect-jsonapi's CRUD handlers
2780
+ * (`ResourceHandlers.make`), keyed by the endpoint names `Group.flat`
2781
+ * declares (pulpit#5). Each write goes through the record, so it keeps the
2782
+ * record's `transact` boundary, writer re-read, lifecycle hooks and cache
2783
+ * write-through (`CACHE-AUTH-005`); effect-jsonapi is given no `cache` and no
2784
+ * `transact` of its own, and its mutation tail re-reads the row once more
2785
+ * with `get` to assemble the document. `list` answers the page with the
2786
+ * unpaged total, and `delete` the 204. `restore` and `restoreVersion` have no
2787
+ * JSON:API endpoint and stay on the record. The handlers fail with the
2788
+ * record's errors (and effect-jsonapi's `Conflict` for an `update` whose
2789
+ * payload id is not the route's, before any operation runs); mapping them to
2790
+ * the wire is the application's `ServerErrors`.
2791
+ *
2792
+ * A `create` / `update` whose committed write hides the row from its writer
2793
+ * (the re-read fails `NodeNotFound`) answers effect-jsonapi's committed
2794
+ * identifier document, `{ data: { type, id, meta: { version } } }` at the
2795
+ * endpoint's own status, not a 404 (effect-jsonapi's `hiddenWrite`,
2796
+ * pulpit#107). Declare the group with `Group.flat({ …, hiddenWrites: true })`
2797
+ * so its `create` / `update` success admits that document.
2798
+ *
2799
+ * @since 0.1.0
2800
+ */
2801
+ export const subtypeResourceHandlers = <
2802
+ Resource extends Assemble.ResourceLike,
2803
+ E,
2804
+ R,
2805
+ TQuery,
2806
+ TCreate,
2807
+ TUpdate extends { readonly id: string },
2808
+ V = undefined,
2809
+ RV = never,
2810
+ RL = never,
2811
+ Targets extends Handlers.IncludeTargets = never,
2812
+ >(
2813
+ handlers: SubtypeHandlers<Resource, E, R>,
2814
+ config: SubtypeResourceHandlersConfig<
2815
+ TQuery,
2816
+ TCreate,
2817
+ TUpdate,
2818
+ V,
2819
+ RV,
2820
+ RL,
2821
+ Targets,
2822
+ Resource["type"]
2823
+ >,
2824
+ ) => {
2825
+ const { type, listRequest, createInput, updateInput, ...rest } = config;
2826
+ return ResourceHandlers.make<
2827
+ TQuery,
2828
+ TCreate,
2829
+ TUpdate,
2830
+ Resource,
2831
+ string,
2832
+ V,
2833
+ | E
2834
+ | Gate
2835
+ | InvalidListOrder
2836
+ | WriteRejected
2837
+ | NodeAlreadyExists
2838
+ | NodeNotFound
2839
+ | StaleVersion
2840
+ | VersionConflict,
2841
+ R,
2842
+ R,
2843
+ RV,
2844
+ RL,
2845
+ never,
2846
+ Targets,
2847
+ never,
2848
+ never,
2849
+ never,
2850
+ Resource["type"]
2851
+ >({
2852
+ ...rest,
2853
+ hiddenWrite: { type, when: (error) => Predicate.isTagged(error, "NodeNotFound") },
2854
+ ops: {
2855
+ list: (query) =>
2856
+ Effect.map(handlers.list(listRequest(query)), (page) => ({
2857
+ items: page.rows,
2858
+ totalCount: page.total,
2859
+ })),
2860
+ get: handlers.get,
2861
+ // The committed version rides along: a write hidden from its writer
2862
+ // answers it as `data.meta.version` (pulpit#107).
2863
+ create: (payload) =>
2864
+ Effect.map(handlers.create(createInput(payload)), ({ id, version }) => ({ id, version })),
2865
+ update: (payload) =>
2866
+ Effect.map(handlers.update(updateInput(payload)), ({ id, version }) => ({ id, version })),
2867
+ delete: (id) => handlers.delete(id),
2868
+ },
2869
+ });
2870
+ };
2871
+
2872
+ // ── Localisation ──────────────────────────────────────────────────────
2873
+
2874
+ /**
2875
+ * A leaf's locale level (pulpit#56, model (b)): the columns translated per
2876
+ * locale. Every other column of the leaf is shared by all of a node's
2877
+ * locales, so one value serves the whole node; history, draft, visibility and
2878
+ * publication are the node's, never a locale's. Declared once, beside the
2879
+ * leaf's registry entry (flagged `localised`), so the application's view
2880
+ * generator and {@link defineSubtype} (`locales`) read the same value, as a
2881
+ * {@link SearchProjection} is.
2882
+ *
2883
+ * @since 0.1.0
2884
+ */
2885
+ export interface LocaleLevel {
2886
+ /** The translated columns, each once. */
2887
+ readonly columns: ReadonlyArray<string>;
2888
+ }
2889
+
2890
+ /**
2891
+ * What {@link SubtypeOps.getLocalised} returns: the locale it fell back to
2892
+ * and the resource in that locale.
2893
+ *
2894
+ * @since 0.1.0
2895
+ */
2896
+ export interface LocalisedResource<Resource> {
2897
+ readonly locale: string;
2898
+ readonly resource: Resource;
2899
+ }
2900
+
2901
+ /** The names a view keeps for itself: no translated column may take one. */
2902
+ const RESERVED_COLUMNS = HashSet.make("id", "version", "action", LOCALE_COLUMN);
2903
+
2904
+ /**
2905
+ * The columns a leaf's read gate reads (pulpit#56): none of them may be
2906
+ * translated, so every locale row carries the node's one owner, visibility,
2907
+ * draft and moderation state.
2908
+ */
2909
+ const accessColumns = (access: LeafAccess): ReadonlyArray<string> =>
2910
+ access.kind === "owned"
2911
+ ? [
2912
+ access.ownerColumn,
2913
+ access.visibilityColumn,
2914
+ ...Option.toArray(access.draftColumn),
2915
+ ...Option.toArray(access.moderationColumn),
2916
+ ]
2917
+ : ["visibility", ...(access.followersOfColumn === undefined ? [] : [access.followersOfColumn])];
2918
+
2919
+ /** The columns a live view stamps on a publish-gated leaf's rows. */
2920
+ const PUBLICATION_COLUMNS: ReadonlyArray<string> = ["draft", "publishedAt"];
2921
+
2922
+ const checkLocaleLevel = (
2923
+ level: LocaleLevel,
2924
+ columns: ReadonlySet<string>,
2925
+ gated: ReadonlyArray<string>,
2926
+ ) => {
2927
+ const gate = Array.findFirst(level.columns, (column) => gated.includes(column));
2928
+ if (Option.isSome(gate)) {
2929
+ throw new SubtypeDeclarationError({
2930
+ message: `locales names '${gate.value}', which the read gate reads: it must be shared`,
2931
+ });
2932
+ }
2933
+ const undeclared = Array.findFirst(
2934
+ [...level.columns, LOCALE_COLUMN],
2935
+ (column) => !columns.has(column),
2936
+ );
2937
+ if (Option.isSome(undeclared)) {
2938
+ throw new SubtypeDeclarationError({
2939
+ message: `locales names '${undeclared.value}', which is not a column of the view`,
2940
+ });
2941
+ }
2942
+ };
2943
+
2944
+ /** The keys of a patch that set or clear a value (an `undefined` key carries forward). */
2945
+ const writtenKeys = <A>(patch: Record.ReadonlyRecord<string, A>): ReadonlyArray<string> =>
2946
+ Array.filter(Record.keys(patch), (key) => patch[key] !== undefined);
2947
+
2948
+ /**
2949
+ * Why a write breaks the locale level, or none: `translated` is the leaf's
2950
+ * declared columns (none for a leaf with no locale level).
2951
+ */
2952
+ const localeViolation = (
2953
+ translated: Option.Option<ReadonlyArray<string>>,
2954
+ state: ChainPatch,
2955
+ locales: LocalePatch,
2956
+ ): Option.Option<string> => {
2957
+ const tags = writtenKeys(locales);
2958
+ if (Option.isNone(translated)) {
2959
+ return Array.isReadonlyArrayNonEmpty(tags)
2960
+ ? Option.some("the leaf declares no locale level")
2961
+ : Option.none();
2962
+ }
2963
+ const isTranslated = (column: string) => translated.value.includes(column);
2964
+ const shared = Array.findFirst(writtenKeys(state), isTranslated);
2965
+ if (Option.isSome(shared)) {
2966
+ return Option.some(`'${shared.value}' is translated: write it per locale`);
2967
+ }
2968
+ if (tags.some(Str.isEmpty)) return Option.some("a locale tag is empty");
2969
+ return Array.findFirst(tags, (tag) =>
2970
+ Option.fromNullishOr(locales[tag]).pipe(
2971
+ Option.flatMap((change) =>
2972
+ Array.findFirst(writtenKeys(change), (column) => !isTranslated(column)),
2973
+ ),
2974
+ Option.map(
2975
+ (column) =>
2976
+ `locale '${tag}' names '${column}', a shared column: write it once, for every locale`,
2977
+ ),
2978
+ ),
2979
+ );
2980
+ };
2981
+
2982
+ /**
2983
+ * Declare a {@link LocaleLevel}. Throws {@link SubtypeDeclarationError} at
2984
+ * import, as a registry violation is, for no column, a column named twice or
2985
+ * one the view keeps for itself (`id`, `version`, `action`,
2986
+ * {@link LOCALE_COLUMN}); {@link defineSubtype} checks the columns against
2987
+ * the view.
2988
+ *
2989
+ * @example
2990
+ * ```ts
2991
+ * import { deepStrictEqual } from "node:assert";
2992
+ * import { defineLocaleLevel } from "@thomasfosterau/pulpit/subtype";
2993
+ *
2994
+ * const pages = defineLocaleLevel({ columns: ["title", "body", "slug"] });
2995
+ * deepStrictEqual(pages.columns, ["title", "body", "slug"]);
2996
+ * ```
2997
+ *
2998
+ * @since 0.1.0
2999
+ */
3000
+ export const defineLocaleLevel = (declaration: {
3001
+ readonly columns: ReadonlyArray<string>;
3002
+ }): LocaleLevel => {
3003
+ if (!Array.isReadonlyArrayNonEmpty(declaration.columns)) {
3004
+ throw new SubtypeDeclarationError({ message: "locales declares no column" });
3005
+ }
3006
+ const repeated = Array.findFirst(declaration.columns, (column, index) =>
3007
+ declaration.columns.slice(0, index).includes(column),
3008
+ );
3009
+ if (Option.isSome(repeated)) {
3010
+ throw new SubtypeDeclarationError({ message: `locales names '${repeated.value}' twice` });
3011
+ }
3012
+ const reserved = Array.findFirst(declaration.columns, (column) =>
3013
+ HashSet.has(RESERVED_COLUMNS, column),
3014
+ );
3015
+ if (Option.isSome(reserved)) {
3016
+ throw new SubtypeDeclarationError({
3017
+ message: `locales names '${reserved.value}', which the view keeps for itself`,
3018
+ });
3019
+ }
3020
+ return { columns: [...declaration.columns] };
3021
+ };
3022
+
3023
+ // ── Owners across leaves ──────────────────────────────────────────────
3024
+
3025
+ /**
3026
+ * {@link readableOwners} refused a union it could not make exact: a leaf
3027
+ * has more readable owners than one read returns ({@link MAX_PAGE_LIMIT}),
3028
+ * so the union and its total would be short (`SEC-INPUT-003`).
3029
+ *
3030
+ * @since 0.1.0
3031
+ */
3032
+ export class OwnerSetTooLarge extends Schema.TaggedError<
3033
+ OwnerSetTooLarge,
3034
+ { readonly brand: unique symbol }
3035
+ >()("OwnerSetTooLarge", {
3036
+ type: Schema.String,
3037
+ }) {}
3038
+
3039
+ /**
3040
+ * The accounts that own at least one row the viewer may list in any of
3041
+ * `leaves` (pulpit#186): the union of each leaf's
3042
+ * {@link SubtypeOps.owners}, deduplicated in code-point order, paged by
3043
+ * `request`, with the exact union size as `total`. One read per leaf, each
3044
+ * for every owner it has (up to {@link MAX_PAGE_LIMIT}): a leaf with more
3045
+ * fails {@link OwnerSetTooLarge} rather than answer a short union. With
3046
+ * `among` (at most `MAX_SET_VALUES` ids) no leaf can exceed it. A hidden row
3047
+ * never contributes its owner (`SEC-AUTH-ENUM-001`). Fails otherwise only
3048
+ * with the stores' `E`.
3049
+ *
3050
+ * @example
3051
+ * ```ts
3052
+ * import { deepStrictEqual } from "node:assert";
3053
+ * import * as Effect from "effect/Effect";
3054
+ * import { readableOwners } from "@thomasfosterau/pulpit/subtype";
3055
+ *
3056
+ * const leaf = (type: string, owners: ReadonlyArray<string>) => ({
3057
+ * type,
3058
+ * owners: () => Effect.succeed({ rows: owners, total: owners.length }),
3059
+ * });
3060
+ * const page = Effect.runSync(
3061
+ * readableOwners([leaf("posts", ["ana", "bo"]), leaf("pages", ["bo", "cy"])], {
3062
+ * limit: 10,
3063
+ * offset: 0,
3064
+ * includeDeleted: false,
3065
+ * }),
3066
+ * );
3067
+ * deepStrictEqual(page, { rows: ["ana", "bo", "cy"], total: 3 });
3068
+ * ```
3069
+ *
3070
+ * @since 0.1.0
3071
+ */
3072
+ export const readableOwners = <E, R>(
3073
+ leaves: ReadonlyArray<{
3074
+ readonly type: string;
3075
+ readonly owners: (request: OwnersRequest) => Effect.Effect<Page<string>, E, R>;
3076
+ }>,
3077
+ request: OwnersRequest,
3078
+ ): Effect.Effect<Page<string>, OwnerSetTooLarge | E, R> =>
3079
+ Effect.gen(function* () {
3080
+ const every: OwnersRequest = { ...request, limit: MAX_PAGE_LIMIT, offset: 0 };
3081
+ const pages = yield* Effect.forEach(
3082
+ leaves,
3083
+ (leaf) =>
3084
+ Effect.flatMap(leaf.owners(every), (page) =>
3085
+ page.total > page.rows.length
3086
+ ? Effect.fail(new OwnerSetTooLarge({ type: leaf.type }))
3087
+ : Effect.succeed(page.rows),
3088
+ ),
3089
+ { concurrency: 1 },
3090
+ );
3091
+ const union = Arr.sort(Array.dedupe(Array.flatten(pages)), byCodePoint);
3092
+ const limit = Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT));
3093
+ const offset = Math.max(0, Math.trunc(request.offset));
3094
+ return { rows: union.slice(offset, offset + limit), total: union.length };
3095
+ });