@thomasfosterau/pulpit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1272 -2
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +145 -3
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
@@ -0,0 +1,1613 @@
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
+ import * as Cause from "effect/Cause";
91
+ import * as Effect from "effect/Effect";
92
+ import * as HashMap from "effect/HashMap";
93
+ import * as Option from "effect/Option";
94
+ import * as Schema from "effect/Schema";
95
+ import { ResourceHandlers, type Assemble, type Handlers } from "@thomasfosterau/effect-jsonapi";
96
+ import type { Visibility } from "./content.ts";
97
+ import { type ListFilter } from "./filter.ts";
98
+ import { NodeNotFound, type ChainPatch, type ChainState, type ChainWrite, type ColumnValue, type LocalePatch, type LocaleStates, type Precondition, type StaleVersion, type VersionAction, type VersionChainStore, type VersionComparison, type VersionConflict, type VersionNotFound, type VersionSnapshot, type NodeAlreadyExists, type WriteAction, type ChainAppendEvent } from "./version-chain.ts";
99
+ /**
100
+ * The signed-in account the operation acts as: an id and a role on the application's ladder.
101
+ *
102
+ * @since 0.1.0
103
+ */
104
+ export interface ViewerAccount {
105
+ readonly id: string;
106
+ readonly role: string;
107
+ }
108
+ /**
109
+ * The viewer: an account, or none for an anonymous request (effect-auth's session context).
110
+ *
111
+ * @since 0.1.0
112
+ */
113
+ export type Viewer = Option.Option<ViewerAccount>;
114
+ /**
115
+ * The role axes the kernel asks about — each an application-named rung
116
+ * (`VIEW_PRIVATE_MIN_ROLE`, `VIEW_DELETED_MIN_ROLE`, `MODERATE_MIN_ROLE`,
117
+ * `CORPUS_WRITE_MIN_ROLE`) compared by the application's one
118
+ * `hasRoleAtLeast`, which must fail closed on an unknown role. effect-auth's
119
+ * role-ladder factory produces these.
120
+ *
121
+ * @since 0.1.0
122
+ */
123
+ export interface AccessPolicy {
124
+ /** May see `private` rows and drafts (gardeneria: moderator+; website: admin). */
125
+ readonly canViewPrivate: (account: ViewerAccount) => boolean;
126
+ /** May see soft-deleted rows (gardeneria: moderator+; website: admin). */
127
+ readonly canViewDeleted: (account: ViewerAccount) => boolean;
128
+ /** May write someone else's owned row (gardeneria: moderator+). */
129
+ readonly canModerate: (account: ViewerAccount) => boolean;
130
+ /** May write reference data directly (gardeneria: contributor+). */
131
+ readonly canWriteCorpus: (account: ViewerAccount) => boolean;
132
+ }
133
+ /**
134
+ * The follower seam — the `followers` visibility state's one external fact.
135
+ * `isFollower` answers one row's question; `following` answers a list's, as
136
+ * whatever the list filter's `in` node takes (a subquery in SQL, #1107).
137
+ *
138
+ * @since 0.1.0
139
+ */
140
+ export interface FollowerSeam<Following, R> {
141
+ /** Whether `followerId` follows `ownerId`: the single-row question `canViewOwned` asks. */
142
+ readonly isFollower: (followerId: string, ownerId: string) => Effect.Effect<boolean, never, R>;
143
+ /**
144
+ * The owners `followerId` follows, as the list filter's `in` set (a
145
+ * subquery in SQL, never a materialised array).
146
+ */
147
+ readonly following: (followerId: string) => Following;
148
+ }
149
+ /**
150
+ * The fail-closed follower seam: nobody follows anybody. `nobody` is the
151
+ * matches-nothing value of the `in` node (SQL: `(select null::uuid where false)`).
152
+ *
153
+ * @since 0.1.0
154
+ */
155
+ export declare const nobodyFollows: <Following>(nobody: Following) => FollowerSeam<Following, never>;
156
+ declare const Unauthorized_base: Schema.Class<Unauthorized, Schema.TaggedStruct<"Unauthorized", {}>, Cause.YieldableError & {
157
+ readonly brand: unique symbol;
158
+ }>;
159
+ /**
160
+ * No signed-in viewer where one is required: the wire's 401.
161
+ *
162
+ * @since 0.1.0
163
+ */
164
+ export declare class Unauthorized extends Unauthorized_base {
165
+ }
166
+ declare const Forbidden_base: Schema.Class<Forbidden, Schema.TaggedStruct<"Forbidden", {}>, Cause.YieldableError & {
167
+ readonly brand: unique symbol;
168
+ }>;
169
+ /**
170
+ * A signed-in viewer below the rung a write needs: the wire's 403. Never used to hide a row.
171
+ *
172
+ * @since 0.1.0
173
+ */
174
+ export declare class Forbidden extends Forbidden_base {
175
+ }
176
+ declare const InvalidListOrder_base: Schema.Class<InvalidListOrder, Schema.TaggedStruct<"InvalidListOrder", {
177
+ readonly type: Schema.String;
178
+ readonly column: Schema.String;
179
+ }>, Cause.YieldableError & {
180
+ readonly brand: unique symbol;
181
+ }>;
182
+ /**
183
+ * A list order the leaf does not sort by: a key whose column is not sortable
184
+ * (outside the leaf's `sortable` allow-list or, without one, outside the
185
+ * view's declared `LeafStore.columns`), a direction other than `asc` /
186
+ * `desc`, or input not shaped as an order at all (an `orderBy` that is not
187
+ * an array, a key that is not an object, a `column` that is not a string).
188
+ * The wire's 400. The order is checked before the viewer or anything else is
189
+ * read, so it says nothing about any row, but **it does say which columns
190
+ * are sortable**: without a `sortable` allow-list, that a name is (or is
191
+ * not) one of the view's columns. `column` is the caller's own key, never
192
+ * passed to the store, or `""` when the key carries no string column
193
+ * (pulpit#112).
194
+ *
195
+ * @since 0.1.0
196
+ */
197
+ export declare class InvalidListOrder extends InvalidListOrder_base {
198
+ }
199
+ declare const SubtypeDeclarationError_base: Schema.Class<SubtypeDeclarationError, Schema.TaggedStruct<"SubtypeDeclarationError", {
200
+ readonly message: Schema.String;
201
+ }>, Cause.YieldableError & {
202
+ readonly brand: unique symbol;
203
+ }>;
204
+ /**
205
+ * A leaf declared against the rules: a `sortable` entry that is not one of
206
+ * its view's `LeafStore.columns`. Thrown by {@link defineSubtype} when the
207
+ * leaf is declared, never an Effect failure, like `./edges`'
208
+ * `EdgeDeclarationError`: a misspelt allow-list entry must stop its module
209
+ * loading rather than silently leave a column unsortable (pulpit#112).
210
+ *
211
+ * @since 0.1.0
212
+ */
213
+ export declare class SubtypeDeclarationError extends SubtypeDeclarationError_base {
214
+ }
215
+ /**
216
+ * The viewer's owned-material read scope — the three facts a LIST needs to
217
+ * build its filter from, and the set-shaped counterpart of
218
+ * {@link canViewOwned}:
219
+ *
220
+ * - `all` — the view-private rung: no visibility filter;
221
+ * - `public` — anonymous: `visibility = 'public'` only;
222
+ * - `member` — a signed-in account: `public`, OR their own rows, OR
223
+ * `followers` rows whose owner they follow.
224
+ *
225
+ * @since 0.1.0
226
+ */
227
+ export type ReadScope<Following> = {
228
+ readonly kind: "all";
229
+ } | {
230
+ readonly kind: "public";
231
+ } | {
232
+ readonly kind: "member";
233
+ readonly userId: string;
234
+ readonly following: Following;
235
+ };
236
+ /**
237
+ * Resolve a viewer's {@link ReadScope}.
238
+ *
239
+ * @since 0.1.0
240
+ */
241
+ export declare const readScopeFor: <Following>(viewer: Viewer, policy: AccessPolicy, followers: FollowerSeam<Following, unknown>) => ReadScope<Following>;
242
+ /**
243
+ * The facts {@link canViewOwned} reads off an owned row. `draft: false` for a leaf without the axis.
244
+ *
245
+ * @since 0.1.0
246
+ */
247
+ export interface OwnedFacts {
248
+ readonly ownerId: string;
249
+ readonly visibility: Visibility;
250
+ readonly draft: boolean;
251
+ /**
252
+ * A moderated row whose state is not exactly `approved` (pulpit#154);
253
+ * `false` for a leaf without the axis.
254
+ */
255
+ readonly unapproved: boolean;
256
+ }
257
+ /**
258
+ * The owned-material read predicate — the single interpretation of the
259
+ * three-state visibility axis plus the orthogonal `draft` axis (#1118):
260
+ *
261
+ * | visibility | who may view |
262
+ * | ----------- | -------------------------------------------------------------- |
263
+ * | `public` | everyone, anonymous included |
264
+ * | `followers` | the owner; the owner's followers; the view-private rung |
265
+ * | `private` | the owner; the view-private rung |
266
+ *
267
+ * `draft: true` narrows whatever `visibility` allows down to the owner and
268
+ * the rung — a `public` draft is exactly as hidden as a `private` one — and
269
+ * so does `unapproved: true` on a moderated leaf (a `pending` or `rejected`
270
+ * row reads only to its author and the rung, pulpit#154). Never
271
+ * fails; callers turn `false` into {@link NodeNotFound}.
272
+ *
273
+ * `defineSubtype`'s `get` / `getMany` and `./edges`' `canViewEndpoint`
274
+ * apply the same predicate to the row they read. There, an owner column
275
+ * holding no string (a `NULL`, an absent key) reads as no owner: no viewer
276
+ * is its owner and the follower seam is never asked, so the row shows only
277
+ * when `public`, not a draft and not unapproved, or to the rung, as {@link ownedListFilter}
278
+ * lists it (`SEC-AUTH-ENUM-001`).
279
+ *
280
+ * @since 0.1.0
281
+ */
282
+ export declare const canViewOwned: <R>(viewer: Viewer, policy: AccessPolicy, followers: FollowerSeam<unknown, R>, row: OwnedFacts) => Effect.Effect<boolean, never, R>;
283
+ /**
284
+ * The gate selector, mirroring the registry's `referenceData` ⊕ `owned`
285
+ * flags: an `owned` leaf names the VIEW columns its read paths consult,
286
+ * `draftColumn` only when the leaf actually writes the flag, and
287
+ * `moderationColumn` only when it is a moderated family (`./moderation`)
288
+ * whose unapproved rows its readers must not see (pulpit#154). A `reference`
289
+ * leaf names `followersOfColumn` only when its rows may be `followers`-visible
290
+ * (pulpit#92).
291
+ *
292
+ * @since 0.1.0
293
+ */
294
+ export type LeafAccess = {
295
+ readonly kind: "reference";
296
+ /**
297
+ * The view column that plays the owner's part for a `followers` row:
298
+ * it holds the id of the account whose followers may read the row
299
+ * (`PUB-009`). The column is the application's, and holds whatever id
300
+ * its follower seam takes; no id brand is implied. Only the followers
301
+ * check reads it: the account it names and that account's followers
302
+ * read its `followers` rows, but it gets no other owner rights (a
303
+ * `private` row stays the view-private rung's alone), and a value that
304
+ * is not a string names nobody. Absent, or naming a column the view
305
+ * lacks, a `followers` row is hidden below the view-private rung.
306
+ *
307
+ * @since 0.1.0
308
+ */
309
+ readonly followersOfColumn?: string | undefined;
310
+ } | {
311
+ readonly kind: "owned";
312
+ readonly ownerColumn: string;
313
+ readonly visibilityColumn: string;
314
+ readonly draftColumn: Option.Option<string>;
315
+ /**
316
+ * The moderation state column (`ModerationState`). Declared, a row
317
+ * whose state is not exactly `approved` reads only to its owner and
318
+ * the view-private rung, as a draft does; absent, the leaf's reads
319
+ * ignore moderation and the rule is the application's (`PUB-005`).
320
+ * Owned leaves only: a reference leaf has no such axis.
321
+ *
322
+ * @since 0.1.0
323
+ */
324
+ readonly moderationColumn: Option.Option<string>;
325
+ };
326
+ /**
327
+ * The owned-leaf access declaration with the conventional factory-built column names.
328
+ *
329
+ * @since 0.1.0
330
+ */
331
+ export declare const ownedAccess: (options?: {
332
+ readonly draft?: boolean | undefined;
333
+ readonly moderation?: boolean | undefined;
334
+ }) => Extract<LeafAccess, {
335
+ kind: "owned";
336
+ }>;
337
+ /**
338
+ * The owned-leaf LIST filter — {@link ReadScope} rendered over the leaf's
339
+ * declared view columns. When the leaf carries a draft column, every
340
+ * non-owner arm additionally requires `draft = false`, and when it carries a
341
+ * moderation column, `moderation = 'approved'`; the owner's own arm never
342
+ * does, so a member lists their own drafts and unapproved rows. Columns the view lacks
343
+ * fail closed.
344
+ *
345
+ * @since 0.1.0
346
+ */
347
+ export declare const ownedListFilter: <Following>(access: Extract<LeafAccess, {
348
+ kind: "owned";
349
+ }>, viewColumns: ReadonlySet<string>, scope: ReadScope<Following>) => ListFilter<Following>;
350
+ /**
351
+ * The soft-delete arm of every list: tombstones only when the caller asked
352
+ * AND holds the view-deleted rung — asking without the rung changes nothing.
353
+ *
354
+ * @since 0.1.0
355
+ */
356
+ export declare const softDeleteFilter: <Following>(viewer: Viewer, policy: AccessPolicy, includeDeleted: boolean) => ListFilter<Following>;
357
+ /**
358
+ * The reference-leaf privacy arm (`PUB-009`): `visibility = 'public'` unless
359
+ * the viewer holds the view-private rung, or the view has no visibility
360
+ * column (an Entity leaf rides no such axis). Given a followers arm (the
361
+ * leaf's `access`, declaring `followersOfColumn` over a view that carries
362
+ * it, and the follower seam), a signed-in viewer also lists the `followers`
363
+ * rows whose column names them or an account they follow (pulpit#92):
364
+ * `visibility = 'followers' AND (<followersOfColumn> = <viewer> OR
365
+ * <followersOfColumn> IN (<following>))`. Without one (the default,
366
+ * `Option.none()`), a `followers` row is the view-private rung's alone.
367
+ * Pass `Option.some({ access, followers })` whenever the leaf declares
368
+ * `followersOfColumn`: `defineSubtype`'s `get` / `getMany` read the
369
+ * declared column, so the three-argument form agrees with them only for a
370
+ * leaf without it (for one with it, `get` serves `followers` rows this
371
+ * hides).
372
+ *
373
+ * @since 0.1.0
374
+ */
375
+ export declare const referencePrivacyFilter: <Following>(viewer: Viewer, policy: AccessPolicy, viewColumns: ReadonlySet<string>, followersArm?: Option.Option<{
376
+ readonly access: Extract<LeafAccess, {
377
+ kind: "reference";
378
+ }>;
379
+ readonly followers: FollowerSeam<Following, unknown>;
380
+ }>) => ListFilter<Following>;
381
+ /**
382
+ * The gate for a CREATE: reference data → the corpus-write rung; owned → any signed-in account.
383
+ *
384
+ * @since 0.1.0
385
+ */
386
+ export declare const requireCreator: (access: LeafAccess, viewer: Viewer, policy: AccessPolicy) => Effect.Effect<ViewerAccount, Unauthorized | Forbidden>;
387
+ /**
388
+ * The gate for a write to an EXISTING row: reference data → the corpus-write
389
+ * rung; owned material → the row's owner or the moderate rung. A missing
390
+ * owner on an owned leaf is {@link NodeNotFound}, never an open gate.
391
+ *
392
+ * On an owned leaf it never reads the row's visibility, draft or delete state:
393
+ * a signed-in caller who is neither the owner nor at the moderate rung gets
394
+ * {@link Forbidden} for any row with an owner record,
395
+ * hidden or not, so the gate deliberately confirms existence (pulpit#24, an
396
+ * exception to `SEC-AUTH-ENUM-001`; SECURITY.md → "Existence disclosure by the
397
+ * write gates"). An anonymous caller gets `Unauthorized` for every id.
398
+ *
399
+ * On a reference leaf this is the rung check alone: it reads no row, so it
400
+ * answers `Unauthorized` / `Forbidden` alike for every id. `defineSubtype`
401
+ * follows it with the read rung the row demands (pulpit#101): a row its `get`
402
+ * hides from the caller fails {@link NodeNotFound}, as `get` does
403
+ * (`PUB-009`, `SEC-AUTH-ENUM-001`). A caller using this gate directly on a
404
+ * reference row must apply that check too.
405
+ *
406
+ * @since 0.1.0
407
+ */
408
+ export declare const requireWriter: <E, R>(access: LeafAccess, viewer: Viewer, policy: AccessPolicy, row: {
409
+ readonly type: string;
410
+ readonly id: string;
411
+ }, ownerOf: Effect.Effect<Option.Option<string>, E, R>) => Effect.Effect<ViewerAccount, Unauthorized | Forbidden | NodeNotFound | E, R>;
412
+ /**
413
+ * A current-version view row: its node id, its head `version` and `action`,
414
+ * and the leaf's columns. The view projects `version` from the head row
415
+ * (pulpit#65), so a read carries the version an `If-Match` must name: a
416
+ * mapper stamps it on the wire with {@link versionMeta}, and a
417
+ * single-resource response's `ETag` is {@link versionEtag}. `id`, `version`
418
+ * and `action` are the view's own, so no leaf column may take those names.
419
+ *
420
+ * @since 0.1.0
421
+ */
422
+ export interface LeafRow {
423
+ readonly id: string;
424
+ /** The head version, the one an update's `expectedVersion` names. */
425
+ readonly version: bigint;
426
+ readonly action: VersionAction;
427
+ readonly [column: string]: ColumnValue;
428
+ }
429
+ /**
430
+ * The resource-object `meta` a {@link SubtypeDefinition.toResource} mapper
431
+ * stamps: the row's head version as a decimal string (`bigint` has no JSON
432
+ * form). It reaches only a resource the read already serves the viewer, so it
433
+ * discloses nothing about a hidden row (`SEC-AUTH-ENUM-001`); what it does
434
+ * disclose about a shown one is how many versions its chain holds.
435
+ *
436
+ * @example
437
+ * ```ts
438
+ * import { strictEqual } from "node:assert";
439
+ * import { versionMeta } from "@thomasfosterau/pulpit/subtype";
440
+ *
441
+ * const row = { id: "post-1", version: 3n, action: "update" as const, title: "Hello" };
442
+ * const resource = { type: "posts", id: row.id, meta: versionMeta(row) };
443
+ * strictEqual(resource.meta.version, "3");
444
+ * ```
445
+ *
446
+ * @since 0.1.0
447
+ */
448
+ export declare const versionMeta: (row: Pick<LeafRow, "version">) => VersionMeta;
449
+ /**
450
+ * The resource-object `meta` {@link versionMeta} stamps: the head version as
451
+ * a decimal string.
452
+ *
453
+ * @since 0.1.0
454
+ */
455
+ export interface VersionMeta {
456
+ readonly version: string;
457
+ }
458
+ /**
459
+ * What {@link versionEtag} reads: a resource object, by its optional `meta`.
460
+ *
461
+ * @since 0.1.0
462
+ */
463
+ export interface VersionedResource {
464
+ readonly meta?: object | undefined;
465
+ }
466
+ /**
467
+ * A single-resource response's `ETag` header value, read from the
468
+ * resource's `meta.version` ({@link versionMeta}): a strong tag (`"3"`)
469
+ * whose value is the head version, so the `If-Match` a client echoes names
470
+ * the version its write expects ({@link UpdateRequest.expectedVersion}; the
471
+ * header's mapping onto it is effect-jsonapi's, which 0.17.0 lacks). None when the value carries no
472
+ * decimal `meta.version`; a collection response takes none, having no single
473
+ * version to tag. Never fails.
474
+ *
475
+ * @example
476
+ * ```ts
477
+ * import { strictEqual } from "node:assert";
478
+ * import { Option } from "effect";
479
+ * import { versionEtag } from "@thomasfosterau/pulpit/subtype";
480
+ *
481
+ * const read = { type: "posts", id: "post-1", meta: { version: "3" } };
482
+ * strictEqual(Option.getOrElse(versionEtag(read), () => "none"), '"3"');
483
+ * const unstamped = { type: "posts", id: "post-1", meta: {} };
484
+ * strictEqual(Option.isNone(versionEtag(unstamped)), true);
485
+ * ```
486
+ *
487
+ * @since 0.1.0
488
+ */
489
+ export declare const versionEtag: (resource: VersionedResource) => Option.Option<string>;
490
+ /**
491
+ * One list page and the unpaged total of rows matching the filter.
492
+ *
493
+ * @since 0.1.0
494
+ */
495
+ export interface Page<A> {
496
+ readonly rows: ReadonlyArray<A>;
497
+ readonly total: number;
498
+ }
499
+ /**
500
+ * The hard cap on a page (`SEC-INPUT-003`): no request drives an unbounded `LIMIT`.
501
+ *
502
+ * @since 0.1.0
503
+ */
504
+ export declare const MAX_PAGE_LIMIT = 1000;
505
+ /**
506
+ * One key of a list's order: a column of the leaf's current-version view and
507
+ * its direction. A list sorts by its keys in turn with a `NULL` (or absent)
508
+ * column after every value in **both** directions (`NULLS LAST`, which
509
+ * PostgreSQL's `DESC` does not default to), then by `id` ascending, so the
510
+ * order is total and an offset page never skips or repeats a row. A string
511
+ * sorts by code point (SQL's `COLLATE "C"`), a number or `bigint` by value, a
512
+ * boolean `false` first and a `Date` by its instant; `id` closes in byte
513
+ * order too (pulpit#112).
514
+ *
515
+ * **Only scalar orderable columns.** A sort key's column must hold one
516
+ * scalar orderable type: text, numeric, boolean, timestamp or bigint. A
517
+ * `json` / `jsonb` or array column is outside the contract (PostgreSQL
518
+ * refuses to order `json` and orders `jsonb` by its own rules, which no
519
+ * fake reproduces), so neither an adapter nor a leaf's `sortable` list may
520
+ * offer one.
521
+ *
522
+ * **What an order discloses.** The column need not be one the resource
523
+ * exposes: without a `sortable` allow-list on the leaf
524
+ * ({@link SubtypeDefinition.sortable}), **every** declared view column is
525
+ * sortable, so a caller can rank the rows it may already see by a column the
526
+ * API never shows it, and an {@link InvalidListOrder} tells it which names
527
+ * are view columns. No hidden row, nor any total, is revealed.
528
+ *
529
+ * @since 0.1.0
530
+ */
531
+ export interface ListOrder {
532
+ /** A sortable column of the view; any other fails the list with {@link InvalidListOrder}. */
533
+ readonly column: string;
534
+ readonly direction: "asc" | "desc";
535
+ }
536
+ /**
537
+ * What `LeafStore.distinct` reads: the column, the rows' filter and the page
538
+ * window over the distinct values.
539
+ *
540
+ * @since 0.1.0
541
+ */
542
+ export interface DistinctQuery<Following> {
543
+ readonly column: string;
544
+ readonly where: ListFilter<Following>;
545
+ readonly limit: number;
546
+ readonly offset: number;
547
+ }
548
+ /**
549
+ * The read seam over a leaf's fail-closed current-version view, plus the
550
+ * owner stamp. `columns` is the view's column set — what the owned filter
551
+ * checks its declaration against.
552
+ *
553
+ * @since 0.1.0
554
+ */
555
+ export interface LeafStore<Row extends LeafRow, Following, E, R> {
556
+ /** The view's column names; an owned declaration naming a column outside this set reads nothing (`get`, `getMany` or `list`). */
557
+ readonly columns: ReadonlySet<string>;
558
+ /** One row of the current-version view by id, or none. Applies no visibility policy. */
559
+ readonly get: (id: string) => Effect.Effect<Option.Option<Row>, E, R>;
560
+ /** One `id = ANY($ids)` read, never `ids.length` sequential gets. */
561
+ readonly getMany: (ids: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<Row>, E, R>;
562
+ /**
563
+ * The page under `where`, with the unpaged total, ordered totally by
564
+ * `orderBy` and then `id`: `ORDER BY <column> ASC|DESC NULLS LAST, …,
565
+ * id ASC`, every string key under `COLLATE "C"` (see {@link ListOrder}).
566
+ * `orderBy` arrives checked: each column is in `columns`, each direction
567
+ * `asc` or `desc`, no column twice; empty is `id` ascending alone. The
568
+ * order never changes which rows match `where`, nor `total`.
569
+ */
570
+ readonly list: (query: {
571
+ readonly where: ListFilter<Following>;
572
+ readonly orderBy: ReadonlyArray<ListOrder>;
573
+ readonly limit: number;
574
+ readonly offset: number;
575
+ }) => Effect.Effect<Page<Row>, E, R>;
576
+ /**
577
+ * The distinct values of `column` among the view rows matching `where`
578
+ * (pulpit#186): `SELECT DISTINCT <column> … WHERE <where> AND <column> IS
579
+ * NOT NULL ORDER BY <column> COLLATE "C" LIMIT … OFFSET …`, with the
580
+ * unpaged distinct count as `total`. Only string values are distinct
581
+ * values; a `NULL` or non-string one is none. Applies no visibility
582
+ * policy.
583
+ */
584
+ readonly distinct: (query: DistinctQuery<Following>) => Effect.Effect<Page<string>, E, R>;
585
+ /** The owner of an owned node (`core.node_owner`), or none. */
586
+ readonly ownerOf: (id: string) => Effect.Effect<Option.Option<string>, E, R>;
587
+ /** Record a freshly created owned node's owner. */
588
+ readonly stampOwner: (id: string, ownerId: string) => Effect.Effect<void, E, R>;
589
+ }
590
+ /**
591
+ * One publication fact, as a {@link PublicationStore} records it: the
592
+ * version made live (none for an unpublish), the instant it takes effect
593
+ * (now, or a future one: a schedule), who recorded it and when. Facts are
594
+ * appended, never rewritten, so a node's publication history is data
595
+ * beside its version chain (`VERSION-CHAIN-001/002` are untouched).
596
+ *
597
+ * @since 0.1.0
598
+ */
599
+ export interface PublicationFact {
600
+ readonly id: string;
601
+ /** The version made live, or none for an unpublish. */
602
+ readonly version: Option.Option<bigint>;
603
+ /** When the fact takes effect; a future instant schedules it. */
604
+ readonly at: Date;
605
+ readonly actorId: Option.Option<string>;
606
+ readonly recordedAt: Date;
607
+ }
608
+ /**
609
+ * The append-only publication log an application supplies (README → Seams).
610
+ * The **effective** fact is the most recently recorded one whose `at` has
611
+ * arrived: a later record supersedes an earlier schedule, and a schedule
612
+ * takes effect at its stamp with no write (the read-time gate,
613
+ * `isPublishedAt`). Applies no policy and fails only with its `E`.
614
+ *
615
+ * @since 0.1.0
616
+ */
617
+ export interface PublicationStore<E, R> {
618
+ /** Append one fact. */
619
+ readonly record: (fact: PublicationFact) => Effect.Effect<void, E, R>;
620
+ /** The node's effective fact at the store's clock (`now()`), or none. */
621
+ readonly effective: (id: string) => Effect.Effect<Option.Option<PublicationFact>, E, R>;
622
+ /** The node's facts, oldest first, at most `limit` of them. */
623
+ readonly facts: (id: string, limit: number) => Effect.Effect<ReadonlyArray<PublicationFact>, E, R>;
624
+ }
625
+ /**
626
+ * A publish-gated leaf's live-version view, beside its current-version
627
+ * `LeafStore`: per node, the row at the version its effective publication
628
+ * fact names, carrying that version as its `version` (the representation
629
+ * it serves, so its `meta.version` and ETag change only when what readers
630
+ * see does, and never disclose an unpublished edit), the node's head
631
+ * `action` (so a tombstone hides it too), `draft: false`, and that fact's
632
+ * `at` as the row's publication stamp.
633
+ * A node with no live version (never published, or unpublished) has no
634
+ * row: `get` answers none and `getMany` omits it. Applies no visibility
635
+ * policy.
636
+ *
637
+ * @since 0.1.0
638
+ */
639
+ export interface LiveView<Row extends LeafRow, Following, E, R> extends Pick<LeafStore<Row, Following, E, R>, "get" | "getMany"> {
640
+ /**
641
+ * `LeafStore.distinct` over the rows {@link LiveView.list} chooses for
642
+ * the same `headFor` (pulpit#186): a node counts with the row the viewer
643
+ * reads, its head or its live version, or not at all.
644
+ */
645
+ readonly distinct: (query: DistinctQuery<Following> & {
646
+ readonly headFor: ListFilter<Following>;
647
+ }) => Effect.Effect<Page<string>, E, R>;
648
+ /**
649
+ * The page under `where`, ordered as `LeafStore.list` orders, over one
650
+ * row per node chosen per viewer: the **head** row (as the current-version
651
+ * view has it, with `draft` = no live version) for a node whose head
652
+ * matches `headFor` (the viewer's own rows), else the live row, else
653
+ * none.
654
+ */
655
+ readonly list: (query: Parameters<LeafStore<Row, Following, E, R>["list"]>[0] & {
656
+ readonly headFor: ListFilter<Following>;
657
+ }) => Effect.Effect<Page<Row>, E, R>;
658
+ }
659
+ /**
660
+ * A leaf's opt-in to the live pointer (`publishGated`, owner decision on
661
+ * pulpit#46): its live view, its publication log, and the request's
662
+ * preview grant. Without it, head and live are one.
663
+ *
664
+ * @since 0.1.0
665
+ */
666
+ export interface PublicationDeclaration<Row extends LeafRow, Following, E, R> {
667
+ readonly live: LiveView<Row, Following, E, R>;
668
+ readonly facts: PublicationStore<E, R>;
669
+ /**
670
+ * The node id this request holds a preview grant for (a signed, expiring
671
+ * token the application or effect-web verified): `get` of exactly that
672
+ * node serves its head, whoever asks, unless it is tombstoned. Signing
673
+ * and verifying the token is the application's. Absent, no request
674
+ * previews.
675
+ */
676
+ readonly preview?: Effect.Effect<Option.Option<string>, never, R> | undefined;
677
+ }
678
+ /**
679
+ * What `publish` / `unpublish` report: the fact recorded, and the node's
680
+ * head version when it was.
681
+ *
682
+ * @since 0.1.0
683
+ */
684
+ export interface PublicationWrite {
685
+ readonly fact: PublicationFact;
686
+ readonly head: bigint;
687
+ }
688
+ declare const NotPublished_base: Schema.Class<NotPublished, Schema.TaggedStruct<"NotPublished", {
689
+ readonly type: Schema.String;
690
+ readonly id: Schema.String;
691
+ }>, Cause.YieldableError & {
692
+ readonly brand: unique symbol;
693
+ }>;
694
+ /**
695
+ * A publication operation on a node with no live version (`discardDraft`
696
+ * with nothing published): the wire's 409.
697
+ *
698
+ * @since 0.1.0
699
+ */
700
+ export declare class NotPublished extends NotPublished_base {
701
+ }
702
+ /**
703
+ * What `publish` makes live, and when.
704
+ *
705
+ * @since 0.1.0
706
+ */
707
+ export interface PublishOptions {
708
+ /** The version to make live; the head when omitted. */
709
+ readonly version?: bigint | undefined;
710
+ /** When it takes effect; now when omitted, and a future instant schedules it. */
711
+ readonly at?: Date | undefined;
712
+ }
713
+ /**
714
+ * A publish-gated leaf's publication operations. Each runs the leaf's write
715
+ * gate first, and its before hooks (with action `publish` / `unpublish`,
716
+ * an empty patch whose return is ignored: a publication writes no
717
+ * version) before recording; a refusing hook records nothing.
718
+ *
719
+ * @since 0.1.0
720
+ */
721
+ export interface PublicationOps<E, R> {
722
+ /**
723
+ * Make `version` (the head when omitted) live at `at` (now when
724
+ * omitted; a future instant schedules it, and readers see it once it
725
+ * arrives). Fails as `update` does at the gate, `NodeNotFound` for a
726
+ * missing or tombstoned node, and `VersionNotFound` for a version that
727
+ * has no row or is a tombstone. Its lifecycle event fires now, not when
728
+ * `at` arrives.
729
+ */
730
+ readonly publish: (id: string, options?: PublishOptions) => Effect.Effect<PublicationWrite, Gate | WriteRejected | NodeNotFound | VersionNotFound | E, R>;
731
+ /**
732
+ * Take the node's live version down, now. Fails as `update` does at the
733
+ * gate, and `NodeNotFound` for a node with no versions.
734
+ */
735
+ readonly unpublish: (id: string) => Effect.Effect<PublicationWrite, Gate | WriteRejected | NodeNotFound | E, R>;
736
+ /**
737
+ * Throw the unpublished head away: append the live version's state as a
738
+ * new version (`restoreVersion`, never a rewrite). A head that already is
739
+ * the live version writes nothing. Fails `NotPublished` without a live
740
+ * version, and as `restoreVersion` does.
741
+ */
742
+ readonly discardDraft: (id: string, precondition?: Precondition) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | NotPublished | VersionNotFound | StaleVersion | VersionConflict | E, R>;
743
+ /** The node's publication facts, oldest first; gated like `getVersion` (`READ-009`). */
744
+ readonly facts: (id: string) => Effect.Effect<ReadonlyArray<PublicationFact>, Gate | NodeNotFound | E, R>;
745
+ }
746
+ declare const WriteRejected_base: Schema.Class<WriteRejected, Schema.TaggedStruct<"WriteRejected", {
747
+ readonly type: Schema.String;
748
+ readonly id: Schema.String;
749
+ readonly action: Schema.String;
750
+ readonly reason: Schema.String;
751
+ }>, Cause.YieldableError & {
752
+ readonly brand: unique symbol;
753
+ }>;
754
+ /**
755
+ * A before hook's refusal: the write it was shown does not happen, and the
756
+ * caller's transaction rolls back with it. `reason` is the hook's own,
757
+ * for the wire's 422 (or whatever the application maps it to).
758
+ *
759
+ * @since 0.1.0
760
+ */
761
+ export declare class WriteRejected extends WriteRejected_base {
762
+ }
763
+ /**
764
+ * What a {@link BeforeWriteHook} is shown: the chain's {@link ChainAppendEvent}
765
+ * (the action, the node, the head the write extends, the patch), plus the
766
+ * leaf's type and the request's viewer.
767
+ *
768
+ * @since 0.1.0
769
+ */
770
+ export interface BeforeWriteEvent extends ChainAppendEvent {
771
+ readonly type: string;
772
+ readonly viewer: Viewer;
773
+ }
774
+ /**
775
+ * A hook that runs inside the write, after its gate and its head read and
776
+ * before the version is appended, in the caller's transaction. It returns
777
+ * the patch the write applies (the event's, or a transformed one: a derived
778
+ * slug, a normalised field), or fails the write, appending nothing, with
779
+ * {@link WriteRejected} or the store's `E` (an outbox insert that failed). A
780
+ * side effect that must not be lost belongs here, as a row the same
781
+ * transaction writes (an outbox), never in an {@link AfterWriteHook}.
782
+ *
783
+ * @since 0.1.0
784
+ */
785
+ export interface BeforeWriteHook<E, R> {
786
+ /** Names the hook in its span and in a logged failure. */
787
+ readonly name: string;
788
+ readonly run: (event: BeforeWriteEvent) => Effect.Effect<ChainPatch, WriteRejected | E, R>;
789
+ /**
790
+ * The hook's locale-level pass on a localised leaf (pulpit#56): shown the
791
+ * same event, it returns the {@link LocalePatch} the write applies (the
792
+ * event's `locales`, or a transformed one), or fails the write as `run`
793
+ * does. **Fails closed:** a write carrying a locale patch through a hook
794
+ * that declares no `locales` is refused with {@link WriteRejected}, so a
795
+ * hook that validates or rewrites a column can never be bypassed by
796
+ * writing it per locale. A hook with nothing to do per locale passes the
797
+ * event's patch through (`(event) => Effect.succeed(event.locales ?? {})`).
798
+ */
799
+ readonly locales?: ((event: BeforeWriteEvent) => Effect.Effect<LocalePatch, WriteRejected | E, R>) | undefined;
800
+ }
801
+ /**
802
+ * What an {@link AfterWriteHook} is shown, once the write has committed:
803
+ * the leaf's type, the action, the version entry the write landed at, and
804
+ * the resource as its writer now reads it (none for a `delete`, or when the
805
+ * write hid the row from its own writer or the re-read failed).
806
+ *
807
+ * @since 0.1.0
808
+ */
809
+ export interface AfterWriteEvent<Resource> {
810
+ readonly type: string;
811
+ readonly action: WriteAction;
812
+ /**
813
+ * The version the write landed at; for a `publish` or `unpublish`, which
814
+ * append no version, the node's head, so the resource cache's
815
+ * compare-on-set orders it after the writes before it (pulpit#121). The
816
+ * version a `publish` made live is the fact's.
817
+ */
818
+ readonly write: ChainWrite;
819
+ readonly resource: Option.Option<Resource>;
820
+ /** The fact a `publish` / `unpublish` recorded; none for a chain write. */
821
+ readonly publication: Option.Option<PublicationFact>;
822
+ }
823
+ /**
824
+ * A hook that runs after the write's transaction has committed
825
+ * ({@link subtypeHandlers}, which owns the boundary). It never fails the
826
+ * write: its failure, a defect included, goes to the hooks'
827
+ * {@link LifecycleHooks.afterFailure} policy, and the next hook still runs
828
+ * under `"log"`. It runs uninterruptibly, so a committed write always
829
+ * reaches it; a delivery that must survive a crash is an outbox row
830
+ * written by a {@link BeforeWriteHook} instead.
831
+ *
832
+ * @since 0.1.0
833
+ */
834
+ export interface AfterWriteHook<Resource, E, R> {
835
+ /** Names the hook in its span and in a logged failure. */
836
+ readonly name: string;
837
+ readonly run: (event: AfterWriteEvent<Resource>) => Effect.Effect<void, E, R>;
838
+ }
839
+ /**
840
+ * A leaf's write side effects, composed per leaf type by the application:
841
+ * provide them from a `Context` service (a `Layer`) through
842
+ * {@link SubtypeDefinition.hooks}. The hooks run in array order, so their
843
+ * order is deterministic and is the application's to choose; the default is
844
+ * none ({@link noHooks}).
845
+ *
846
+ * Events fire on explicit writes only: `create`, `update`, `restore`,
847
+ * `delete` (not an already tombstoned node's, which writes nothing) and
848
+ * `restoreVersion`. A scheduled version whose `publishedAt` stamp arrives
849
+ * later writes nothing and emits nothing; an application that needs a side
850
+ * effect when scheduled content goes live drives a sweep from its cron with
851
+ * `./publication`'s `duePublications`.
852
+ *
853
+ * @since 0.1.0
854
+ */
855
+ export interface LifecycleHooks<Resource, E, R> {
856
+ readonly before: ReadonlyArray<BeforeWriteHook<E, R>>;
857
+ readonly after: ReadonlyArray<AfterWriteHook<Resource, E, R>>;
858
+ /**
859
+ * What an after hook's failure becomes: `"log"` (the default) logs its
860
+ * cause at error level and goes on; `"defect"` dies with it, so the
861
+ * handler fails with a defect although the write committed.
862
+ */
863
+ readonly afterFailure?: "log" | "defect" | undefined;
864
+ }
865
+ /**
866
+ * No hooks: the default for a leaf that declares none.
867
+ *
868
+ * @since 0.1.0
869
+ */
870
+ export declare const noHooks: <Resource = never, E = never, R = never>() => LifecycleHooks<Resource, E, R>;
871
+ /**
872
+ * Concatenate hook sets in order: `a`'s before hooks run before `b`'s, and
873
+ * likewise after. The first set's `afterFailure` that is given wins.
874
+ *
875
+ * @example
876
+ * ```ts
877
+ * import { strictEqual } from "node:assert";
878
+ * import * as Effect from "effect/Effect";
879
+ * import { composeHooks, noHooks, type BeforeWriteHook } from "@thomasfosterau/pulpit/subtype";
880
+ *
881
+ * const audit: BeforeWriteHook<never, never> = {
882
+ * name: "audit",
883
+ * run: (event) => Effect.succeed(event.patch),
884
+ * };
885
+ * const hooks = composeHooks({ before: [audit], after: [] }, noHooks());
886
+ * strictEqual(hooks.before[0]?.name, "audit");
887
+ * ```
888
+ *
889
+ * @since 0.1.0
890
+ */
891
+ export declare const composeHooks: <Resource, E, R>(...sets: ReadonlyArray<LifecycleHooks<Resource, E, R>>) => LifecycleHooks<Resource, E, R>;
892
+ /**
893
+ * A leaf's declaration: its type and access, its seams, and its row → resource mapper.
894
+ *
895
+ * @since 0.1.0
896
+ */
897
+ export interface SubtypeDefinition<Row extends LeafRow, Resource, Following, E, R> {
898
+ /** The leaf's JSON:API wire type — names spans and tags `NodeNotFound`. */
899
+ readonly type: string;
900
+ /** How the leaf is gated: `reference` corpus material or `owned` per-member rows. */
901
+ readonly access: LeafAccess;
902
+ /** The version-chain store the writes append through. */
903
+ readonly chain: VersionChainStore<E, R>;
904
+ /** The current-version view the reads and lists go through, plus the owner stamp. */
905
+ readonly leaves: LeafStore<Row, Following, E, R>;
906
+ /** The request's viewer (effect-auth's session context). */
907
+ readonly viewer: Effect.Effect<Viewer, never, R>;
908
+ /** The application's role axes. */
909
+ readonly policy: AccessPolicy;
910
+ /** The follower seam; {@link nobodyFollows} when the runtime provides none. */
911
+ readonly followers: FollowerSeam<Following, R>;
912
+ /** Shape a view row into the resource (sync; links resolved by the caller's mapper). */
913
+ readonly toResource: (row: Row) => Resource;
914
+ /**
915
+ * The columns a list may sort by ({@link ListRequest.orderBy}); each must
916
+ * be one of the view's `LeafStore.columns`, holding a scalar orderable
917
+ * type (text, numeric, boolean, timestamp or bigint, never `json` /
918
+ * `jsonb` or an array), or {@link defineSubtype} throws
919
+ * {@link SubtypeDeclarationError}. An order key on any other column fails
920
+ * {@link InvalidListOrder}. With a list, `id` sorts only if listed; `[]`
921
+ * admits only the default `id`-ascending order, so any non-empty
922
+ * {@link ListRequest.orderBy} fails {@link InvalidListOrder}.
923
+ *
924
+ * **Omitted, every declared view column is sortable**, the columns the
925
+ * resource never shows included. That default discloses two things to any
926
+ * caller: the ranking, among the rows it may already see, by any view
927
+ * column (a `rank` or a moderation score it can't read), and, through
928
+ * {@link InvalidListOrder}'s 400, which names are view columns. It never
929
+ * reveals a hidden row nor changes a total (`SEC-AUTH-ENUM-001`). Declare
930
+ * the list to sort only by what the resource exposes (pulpit#112).
931
+ */
932
+ readonly sortable?: ReadonlyArray<string> | undefined;
933
+ /**
934
+ * The leaf's lifecycle hooks, read per write: an Effect, so the
935
+ * application can hold them in a `Context` service and provide it with a
936
+ * `Layer`. The before hooks run inside every write; the after hooks run in
937
+ * {@link subtypeHandlers} once its transaction has committed. Omitted, the
938
+ * leaf has none ({@link noHooks}), and a write reads nothing extra.
939
+ */
940
+ readonly hooks?: Effect.Effect<LifecycleHooks<Resource, E, R>, never, R> | undefined;
941
+ /**
942
+ * The leaf's searchable projection ({@link defineSearchProjection}), the
943
+ * same declaration the application's search-view generator reads. Each
944
+ * field and the language column must be a column of the view, or
945
+ * {@link defineSubtype} throws {@link SubtypeDeclarationError}. Omitted,
946
+ * the leaf is not searched.
947
+ */
948
+ readonly search?: SearchProjection | undefined;
949
+ /**
950
+ * The leaf's locale level ({@link defineLocaleLevel}, pulpit#56): which of
951
+ * its columns are translated, written and read per locale, while the rest
952
+ * are shared by every locale of the node. Each column, and
953
+ * {@link LOCALE_COLUMN}, must be a column of the view, or
954
+ * {@link defineSubtype} throws {@link SubtypeDeclarationError}. Omitted,
955
+ * the leaf is not localised and a write carrying a locale is refused.
956
+ */
957
+ readonly locales?: LocaleLevel | undefined;
958
+ /**
959
+ * The live pointer, opt-in (pulpit#46). With it, the view-private rung,
960
+ * the corpus-write rung on a reference leaf and a row's own owner read
961
+ * the head; everyone else reads the live version (a node with none is
962
+ * hidden from them, which is what `draft` means on such a leaf).
963
+ * Without it, head and live are one.
964
+ */
965
+ readonly publication?: PublicationDeclaration<Row, Following, E, R> | undefined;
966
+ }
967
+ /**
968
+ * A list request, as the decoded query hands it over.
969
+ *
970
+ * @since 0.1.0
971
+ */
972
+ export interface ListRequest<Following = never> {
973
+ readonly limit: number;
974
+ readonly offset: number;
975
+ readonly includeDeleted: boolean;
976
+ /**
977
+ * The family's own extra predicate (a `?filter[published]=`), or `always`.
978
+ * It may read the follower seam's `Following`, as an edge family's
979
+ * `memberFilter` does (pulpit#189), when the leaf's `SubtypeOps` carries it.
980
+ */
981
+ readonly where: ListFilter<Following>;
982
+ /**
983
+ * The order, its keys in turn, closed by `id` ascending with `NULL`s last
984
+ * in both directions (see {@link ListOrder}); absent, empty or `null` (as
985
+ * an unchecked caller may send it, the same as absent) is `id` ascending
986
+ * alone. A column that is not sortable (outside the leaf's
987
+ * `sortable` allow-list or, **when the leaf declares none, any view column
988
+ * at all, the ones the resource never shows included**), a direction other
989
+ * than `asc` / `desc`, or input not shaped as an order (no array, a key
990
+ * that is no object, a `column` that is no string) fails the list with
991
+ * {@link InvalidListOrder}, before the viewer is read; a repeated column
992
+ * adds nothing and is dropped. The order discloses the visible rows'
993
+ * ranking by any sortable column, and the 400 which names are sortable;
994
+ * never a hidden row or a total (see {@link SubtypeDefinition.sortable}).
995
+ */
996
+ readonly orderBy?: ReadonlyArray<ListOrder> | undefined;
997
+ }
998
+ type Gate = Unauthorized | Forbidden;
999
+ /**
1000
+ * What {@link SubtypeOps.owners} reads: the page window, the soft-delete
1001
+ * axis as {@link ListRequest.includeDeleted}, and an optional set of
1002
+ * account ids to narrow to.
1003
+ *
1004
+ * @since 0.1.0
1005
+ */
1006
+ export interface OwnersRequest {
1007
+ readonly limit: number;
1008
+ readonly offset: number;
1009
+ readonly includeDeleted: boolean;
1010
+ readonly among?: ReadonlyArray<string> | undefined;
1011
+ }
1012
+ /**
1013
+ * An update's input: the node, the tri-state patch, an optional expected
1014
+ * version and, for an edit that applies an accepted proposal, the
1015
+ * contributor the version records as `proposedBy` (`./proposals`).
1016
+ *
1017
+ * @since 0.1.0
1018
+ */
1019
+ export interface UpdateRequest extends RestoreRequest {
1020
+ readonly proposedBy?: Option.Option<string> | undefined;
1021
+ }
1022
+ /**
1023
+ * A restore's input: the node, the tri-state patch, a localised leaf's
1024
+ * per-locale patch and an optional expected version.
1025
+ *
1026
+ * @since 0.1.0
1027
+ */
1028
+ export interface RestoreRequest extends Precondition {
1029
+ readonly id: string;
1030
+ readonly patch: ChainPatch;
1031
+ /** A localised leaf's per-locale patch (pulpit#56); absent, every locale carries forward. */
1032
+ readonly locales?: LocalePatch | undefined;
1033
+ }
1034
+ /**
1035
+ * A create's input: a caller-supplied id (minted when none), the initial
1036
+ * state and a localised leaf's locale level at version 0.
1037
+ *
1038
+ * @since 0.1.0
1039
+ */
1040
+ export interface CreateRequest {
1041
+ readonly id: Option.Option<string>;
1042
+ readonly state: ChainState;
1043
+ /** A localised leaf's locale level at version 0 (pulpit#56); absent, none. */
1044
+ readonly locales?: LocaleStates | undefined;
1045
+ }
1046
+ /**
1047
+ * The operations {@link defineSubtype} produces for one leaf. Every write
1048
+ * also fails {@link WriteRejected} when one of the leaf's before hooks
1049
+ * refuses it, after the gate and with nothing appended.
1050
+ *
1051
+ * @since 0.1.0
1052
+ */
1053
+ export interface SubtypeOps<Resource, E, R, Following = never> {
1054
+ /** The leaf's wire type, as declared. */
1055
+ readonly type: string;
1056
+ /** The access declaration the operations gate by. */
1057
+ readonly access: LeafAccess;
1058
+ /**
1059
+ * The leaf's lifecycle hooks, as {@link SubtypeDefinition.hooks} provides
1060
+ * them; {@link subtypeHandlers} runs their after hooks.
1061
+ */
1062
+ readonly hooks: Effect.Effect<LifecycleHooks<Resource, E, R>, never, R>;
1063
+ /** The publication operations, on a leaf that declares the live pointer. */
1064
+ readonly publication: Option.Option<PublicationOps<E, R>>;
1065
+ /**
1066
+ * The resource as an anonymous reader gets it (the live version on a
1067
+ * publish-gated leaf), or none when they can't read it. What a public
1068
+ * cache may hold.
1069
+ */
1070
+ readonly publicResource: (id: string) => Effect.Effect<Option.Option<Resource>, E, R>;
1071
+ /**
1072
+ * The leaf's write gate on `id`, as `update` runs it: the acting account,
1073
+ * or the gate's failure. For a surface outside the chain (a working copy)
1074
+ * that must gate like a write.
1075
+ */
1076
+ readonly authorize: (id: string) => Effect.Effect<ViewerAccount, Gate | NodeNotFound | E, R>;
1077
+ /**
1078
+ * Create the node at version 0, stamping the viewer as owner on an owned
1079
+ * leaf. Fails `Unauthorized` when anonymous, `Forbidden` below the
1080
+ * corpus-write rung on a reference leaf, `NodeAlreadyExists` when a
1081
+ * caller-supplied id is taken, and `WriteRejected` when the write breaks
1082
+ * the leaf's locale level (see {@link SubtypeOps.update}).
1083
+ */
1084
+ readonly create: (input: CreateRequest) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeAlreadyExists | VersionConflict | E, R>;
1085
+ /**
1086
+ * Append a tri-state patch. Fails `Unauthorized` when anonymous,
1087
+ * `Forbidden` below the write rung (the corpus-write rung for reference
1088
+ * data, owner-or-moderator for an owned row), `NodeNotFound` when an
1089
+ * owned row has no owner, when a reference row is one `get` hides from the
1090
+ * caller (`PUB-009`; pulpit#101), or when the node is unknown or
1091
+ * tombstoned, and `StaleVersion` when `input.expectedVersion` is not the
1092
+ * head (checked after the gate, so only a writer learns the head).
1093
+ *
1094
+ * On a localised leaf (pulpit#56) `input.locales` patches the locale
1095
+ * level; the write appends one version of the whole node, however few
1096
+ * locales it touches. Fails `WriteRejected`, after the gate and with
1097
+ * nothing appended, when the shared patch names a translated column, a
1098
+ * locale's patch names a shared one, a locale tag is empty, or a leaf with
1099
+ * no locale level is sent a locale.
1100
+ */
1101
+ readonly update: (input: UpdateRequest) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
1102
+ /**
1103
+ * {@link SubtypeOps.update}, except a tombstone does not fail: the node
1104
+ * comes back live (`VERSION-CHAIN-006`). Fails as `update` does, apart from
1105
+ * the tombstone. On a reference leaf only the view-deleted rung can see a
1106
+ * tombstone, so only it can restore one; anyone else gets `NodeNotFound`.
1107
+ */
1108
+ readonly restore: (input: RestoreRequest) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
1109
+ /**
1110
+ * Tombstone the node (idempotent: an already tombstoned node reports
1111
+ * `alreadyDeleted: true` and writes nothing, whatever the precondition).
1112
+ * Fails `Unauthorized` when anonymous, `Forbidden` below the write rung,
1113
+ * `NodeNotFound` when an owned row has no owner, when a reference row
1114
+ * (an already tombstoned one included) is one `get` hides from the caller
1115
+ * (`PUB-009`; pulpit#101), or when the node is unknown, and
1116
+ * `StaleVersion` when a live node's head is not
1117
+ * `precondition.expectedVersion`.
1118
+ */
1119
+ readonly delete: (id: string, precondition?: Precondition) => Effect.Effect<ChainWrite & {
1120
+ readonly alreadyDeleted: boolean;
1121
+ }, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
1122
+ /**
1123
+ * The row as a resource. Fails `NodeNotFound` when it is missing OR hidden
1124
+ * from the viewer (`SEC-AUTH-ENUM-001`).
1125
+ */
1126
+ readonly get: (id: string) => Effect.Effect<Resource, NodeNotFound | E, R>;
1127
+ /**
1128
+ * The locale-fallback read (pulpit#56): the node's row in the first of
1129
+ * `locales`, in the caller's order, that the version the viewer reads
1130
+ * carries (the head, or on a publish-gated leaf the live version for a
1131
+ * viewer who doesn't read the head), with the shared columns joined in.
1132
+ * Gated by the node's own read gate alone, exactly as `get` is: draft,
1133
+ * visibility and publication are the node's, shared by its locales, so no
1134
+ * locale is ever hidden on its own. Fails `NodeNotFound` when the node is
1135
+ * missing or hidden from the viewer (`SEC-AUTH-ENUM-001`) and when it
1136
+ * carries none of `locales` (or the leaf has no locale level), in one
1137
+ * read of the locale rows after the gate.
1138
+ */
1139
+ /**
1140
+ * The accounts that own at least one row the viewer may list
1141
+ * (pulpit#186): the distinct owner column values of exactly the rows
1142
+ * {@link SubtypeOps.list} would show for `request` (the same scope, and on
1143
+ * a publish-gated leaf the same head-or-live choice), in code-point order,
1144
+ * with the distinct total. `among`, when given, narrows to those account
1145
+ * ids (more than `MAX_SET_VALUES` reads none, `SEC-INPUT-003`). A hidden
1146
+ * row never contributes its owner (`SEC-AUTH-ENUM-001`). A reference leaf
1147
+ * has no owner, so its page is empty. The limit is clamped to
1148
+ * {@link MAX_PAGE_LIMIT}. Fails only with the store's `E`.
1149
+ */
1150
+ readonly owners: (request: OwnersRequest) => Effect.Effect<Page<string>, E, R>;
1151
+ readonly getLocalised: (id: string, locales: ReadonlyArray<string>) => Effect.Effect<LocalisedResource<Resource>, NodeNotFound | E, R>;
1152
+ /** Visible rows by id, in one read; a missing or hidden id is simply absent. */
1153
+ readonly getMany: (ids: ReadonlyArray<string>) => Effect.Effect<HashMap.HashMap<string, Resource>, E, R>;
1154
+ /**
1155
+ * One page of the rows the viewer may see, with the unpaged total, in
1156
+ * `request.orderBy` closed by `id` (`NULL`s last; see {@link ListOrder}).
1157
+ * The limit is clamped to {@link MAX_PAGE_LIMIT}. Fails
1158
+ * {@link InvalidListOrder}, before the viewer or the store is read, when
1159
+ * an order key names a column that is not sortable (see
1160
+ * {@link SubtypeDefinition.sortable}) or is not shaped as an order. The
1161
+ * order never changes which rows are listed, nor the total
1162
+ * (`SEC-AUTH-ENUM-001`).
1163
+ */
1164
+ readonly list: (request: ListRequest<Following>) => Effect.Effect<Page<Resource>, InvalidListOrder | E, R>;
1165
+ /**
1166
+ * Gated like a write (`READ-009`): the history exposes every state,
1167
+ * private and deleted included. Fails as `update` does at the gate (on a
1168
+ * reference leaf, `NodeNotFound` for a row `get` hides from the caller,
1169
+ * pulpit#101), then `VersionNotFound` when the version has no row.
1170
+ */
1171
+ readonly getVersion: (id: string, version: bigint) => Effect.Effect<VersionSnapshot, Gate | NodeNotFound | VersionNotFound | E, R>;
1172
+ /**
1173
+ * The field-level change set from version `from` to version `to`: the
1174
+ * chain's `compare` (two version reads and `diffStates`, see
1175
+ * {@link VersionComparison}) behind exactly {@link SubtypeOps.getVersion}'s
1176
+ * gate (`READ-009`), which both run through, so nothing is read before it
1177
+ * passes. Fails as `getVersion` does: `Unauthorized` when anonymous;
1178
+ * `Forbidden` below the write rung (owner-or-moderator on an owned row,
1179
+ * whatever its visibility, pulpit#24; the corpus-write rung on a reference
1180
+ * leaf); `NodeNotFound` when an owned row has no owner or a reference row
1181
+ * is missing or one `get` hides from the caller (pulpit#101), whichever
1182
+ * version is asked for; then `VersionNotFound` for whichever version
1183
+ * (`from` first) has no row.
1184
+ */
1185
+ readonly compare: (id: string, from: bigint, to: bigint) => Effect.Effect<VersionComparison, Gate | NodeNotFound | VersionNotFound | E, R>;
1186
+ /**
1187
+ * Re-apply the state at `version` as a new version, gated like a write
1188
+ * (on a reference leaf, `NodeNotFound` for a row `get` hides from the
1189
+ * caller, pulpit#101). Fails `VersionNotFound`, before writing anything,
1190
+ * when that version
1191
+ * cannot be fully reconstructed, and `StaleVersion` when the head is not
1192
+ * `precondition.expectedVersion`.
1193
+ */
1194
+ readonly restoreVersion: (id: string, version: bigint, precondition?: Precondition) => Effect.Effect<ChainWrite, Gate | WriteRejected | NodeNotFound | VersionNotFound | StaleVersion | VersionConflict | E, R>;
1195
+ }
1196
+ /**
1197
+ * Bind a leaf's operations. Every produced operation is traced as
1198
+ * `${type}.<operation>`; the write operations run in the caller's
1199
+ * transaction (the boundary is the handler seam's, website #1883).
1200
+ *
1201
+ * Throws {@link SubtypeDeclarationError}, when the leaf is declared, for a
1202
+ * `sortable` entry that is not a column of the view.
1203
+ *
1204
+ * @since 0.1.0
1205
+ */
1206
+ export declare const defineSubtype: <Row extends LeafRow, Resource, Following, E, R>(definition: SubtypeDefinition<Row, Resource, Following, E, R>) => SubtypeOps<Resource, E, R, Following>;
1207
+ /**
1208
+ * A searchable field's rank weight, PostgreSQL's `setweight` labels from the
1209
+ * strongest (`A`) to the weakest (`D`).
1210
+ *
1211
+ * @since 0.1.0
1212
+ */
1213
+ export type SearchWeight = "A" | "B" | "C" | "D";
1214
+ /**
1215
+ * One indexed column of a {@link SearchProjection} and its weight.
1216
+ *
1217
+ * @since 0.1.0
1218
+ */
1219
+ export interface SearchField {
1220
+ readonly column: string;
1221
+ readonly weight: SearchWeight;
1222
+ }
1223
+ /**
1224
+ * A leaf's searchable projection (pulpit#54): which view columns its search
1225
+ * view indexes, at what weight, and the column naming each row's text
1226
+ * search configuration (its language), if any. Declared once, beside the
1227
+ * leaf's registry entry, so the application's search-view generator and
1228
+ * {@link defineSubtype} (`search`) read the same value. The ranking itself
1229
+ * (full text, trigram, vectors, their fusion) is the application's SQL.
1230
+ *
1231
+ * @since 0.1.0
1232
+ */
1233
+ export interface SearchProjection {
1234
+ /** The indexed columns, each once, strongest first by convention. */
1235
+ readonly fields: ReadonlyArray<SearchField>;
1236
+ /** The view column holding each row's text search configuration, or none for one fixed configuration. */
1237
+ readonly languageColumn: Option.Option<string>;
1238
+ }
1239
+ /**
1240
+ * Declare a {@link SearchProjection}. Throws {@link SubtypeDeclarationError}
1241
+ * at import, as a registry violation is, for no field, a column named twice
1242
+ * or a weight outside `A`–`D`; {@link defineSubtype} checks the columns
1243
+ * against the view.
1244
+ *
1245
+ * @example
1246
+ * ```ts
1247
+ * import { strictEqual } from "node:assert";
1248
+ * import { defineSearchProjection } from "@thomasfosterau/pulpit/subtype";
1249
+ *
1250
+ * const posts = defineSearchProjection({
1251
+ * fields: [
1252
+ * { column: "title", weight: "A" },
1253
+ * { column: "excerpt", weight: "B" },
1254
+ * ],
1255
+ * languageColumn: "language",
1256
+ * });
1257
+ * strictEqual(posts.fields.length, 2);
1258
+ * ```
1259
+ *
1260
+ * @since 0.1.0
1261
+ */
1262
+ export declare const defineSearchProjection: (declaration: {
1263
+ readonly fields: ReadonlyArray<SearchField>;
1264
+ readonly languageColumn?: string | undefined;
1265
+ }) => SearchProjection;
1266
+ /**
1267
+ * The read scope a search over one leaf must apply to its hits: exactly the
1268
+ * filter {@link defineSubtype}'s `list` reads under (`list` calls this), the
1269
+ * soft-delete arm, the access kind's privacy arm (owned visibility and draft
1270
+ * through {@link ownedListFilter}; the reference privacy arm with its
1271
+ * followers, `PUB-009`) and the family's own `where`, so a search can never
1272
+ * surface a row `list` hides (`SEC-AUTH-ENUM-001`). The application renders
1273
+ * it into its search query as the list store renders it into the list's. On
1274
+ * a leaf with the live pointer ({@link SubtypeDefinition.publication}) the
1275
+ * search matches over the rows {@link LiveView}'s `list` reads (the viewer's
1276
+ * own at the head, every other node's live row), never every head, so a
1277
+ * match never scores text a reader cannot see.
1278
+ *
1279
+ * @since 0.1.0
1280
+ */
1281
+ export declare const searchScopeFilter: <Following>(leaf: {
1282
+ readonly access: LeafAccess;
1283
+ readonly columns: ReadonlySet<string>;
1284
+ readonly policy: AccessPolicy;
1285
+ readonly followers: FollowerSeam<Following, unknown>;
1286
+ }, viewer: Viewer, request: Pick<ListRequest<Following>, "includeDeleted" | "where">) => ListFilter<Following>;
1287
+ /**
1288
+ * Hydrate ranked search hits through the leaf's gated `getMany`: one read
1289
+ * for every hit, each re-checked against the viewer by the same predicate
1290
+ * `get` applies, so a stale or poisoned index (a hit the search scope should
1291
+ * have excluded, or one hidden since it was indexed) never leaks a row
1292
+ * (`SEC-AUTH-ENUM-001`). The hits keep their rank order, a hidden or
1293
+ * missing one is dropped, and a repeated id keeps its first, best rank.
1294
+ * Fails with the store's `E`.
1295
+ *
1296
+ * @since 0.1.0
1297
+ */
1298
+ export declare const hydrateSearchHits: <Hit extends {
1299
+ readonly id: string;
1300
+ }, Resource, E, R>(ops: Pick<SubtypeOps<Resource, E, R>, "getMany">, hits: ReadonlyArray<Hit>) => Effect.Effect<ReadonlyArray<{
1301
+ readonly hit: Hit;
1302
+ readonly resource: Resource;
1303
+ }>, E, R>;
1304
+ /**
1305
+ * The resource cache's write side: `set` writes the resource through — the
1306
+ * cache itself refuses (and evicts) a non-serveable one, so a
1307
+ * `public → private` flip self-purges — and `delete` evicts. The application
1308
+ * fills it with a cache it owns; no library is expected to provide one
1309
+ * (effect-web dropped its Cloudflare package, and the planned cache with it,
1310
+ * on effect-web#33).
1311
+ *
1312
+ * **Compare-on-set** (pulpit#121). Both calls carry the version they speak
1313
+ * for, and the cache keeps, per id, the highest version it has been given by
1314
+ * either. A `set` below that version is a no-op, and a `delete` (or a refused
1315
+ * `set`) raises it, outliving the entry it evicted. So a racing writer's
1316
+ * late write-through, or a read-through fill that read before a hiding
1317
+ * write, can never re-cache a resource older than one already evicted or
1318
+ * replaced for the id (`CACHE-AUTH-005`). The compare and the write must be
1319
+ * one atomic step in the cache's store: a store with no conditional write
1320
+ * cannot honour the seam. A read-through fill passes the version its read
1321
+ * saw, and goes through `set` like any other.
1322
+ *
1323
+ * @since 0.1.0
1324
+ */
1325
+ export interface ResourceCacheSeam<Resource, R> {
1326
+ /**
1327
+ * Write the resource through, as of `version`: a no-op below the highest
1328
+ * version the cache holds for `id`; the cache refuses (and evicts at
1329
+ * `version`) one that isn't serveable. Never fails.
1330
+ */
1331
+ readonly set: (id: string, resource: Resource, version: bigint) => Effect.Effect<void, never, R>;
1332
+ /**
1333
+ * Evict the resource as of `version`, which a later `set` must reach to
1334
+ * cache it again. Never fails.
1335
+ */
1336
+ readonly delete: (id: string, version: bigint) => Effect.Effect<void, never, R>;
1337
+ }
1338
+ /**
1339
+ * The resource cache's write-through as an {@link AfterWriteHook}: a write
1340
+ * whose writer re-read the resource sets it, and any other (a `delete`, or a
1341
+ * write whose re-read failed or no longer shows the row) evicts it
1342
+ * (`CACHE-AUTH-005`). {@link subtypeHandlers} runs it first, before the
1343
+ * leaf's own after hooks, whenever its config has a cache. Never fails.
1344
+ *
1345
+ * @since 0.1.0
1346
+ */
1347
+ export declare const cacheWriteThrough: <Resource, R>(cache: ResourceCacheSeam<Resource, R>) => AfterWriteHook<Resource, never, R>;
1348
+ /**
1349
+ * What a handler-record write answers: the committed `{ id, version }`, and
1350
+ * the resource as its writer re-read it after the commit. `resource` is
1351
+ * none when the write hid the row from its own writer (the re-read failed
1352
+ * `NodeNotFound`): the write still committed, so it answers the write
1353
+ * rather than a 404 that would invite a duplicating retry (pulpit#107).
1354
+ *
1355
+ * @since 0.1.0
1356
+ */
1357
+ export interface CommittedWrite<Resource> extends ChainWrite {
1358
+ /** The resource as the writer now reads it; none when the write hid it from them. */
1359
+ readonly resource: Option.Option<Resource>;
1360
+ }
1361
+ /**
1362
+ * The handler record a leaf contributes to a JSON:API server's resource
1363
+ * handlers — one entry per endpoint, the writes answering the committed
1364
+ * write and the resource the document assembler wraps
1365
+ * ({@link CommittedWrite}) — plus the version-restore command, which has no
1366
+ * JSON:API endpoint and is run SSR-direct.
1367
+ *
1368
+ * {@link subtypeResourceHandlers} turns the record into effect-jsonapi's
1369
+ * `ResourceHandlers.make` CRUD handlers (pulpit#5).
1370
+ *
1371
+ * Every write re-reads the row after the chain write and hands it to the
1372
+ * leaf's after hooks ({@link LifecycleHooks}), the cache write-through
1373
+ * ({@link cacheWriteThrough}) first, so it writes the resource through the
1374
+ * cache; that write-through is why these exist rather than each route
1375
+ * calling the operation directly: the cache serves
1376
+ * anonymous reads, and a write that skipped it would leave them on the
1377
+ * superseded content until the TTL expired. The re-read runs as the writer,
1378
+ * so a write that hides the row from its own writer fails it
1379
+ * (`NodeNotFound`) after the write committed; the handler then evicts the
1380
+ * entry, as it does for any re-read failure, so the cache never keeps the
1381
+ * superseded resource (`CACHE-AUTH-005`, pulpit#107). A hidden write
1382
+ * answers its {@link CommittedWrite} with no resource, not the
1383
+ * `NodeNotFound`: the write committed. Any other re-read failure (a store
1384
+ * error, a defect, an interrupt) fails the write with its own cause. The
1385
+ * chain write runs inside `transact` (the handler seam's boundary, website
1386
+ * #1883); the re-read stays outside it. An interrupt can't separate a
1387
+ * committed write from its write-through or eviction: a failure of the
1388
+ * boundary after the operation itself succeeded (an interrupt included)
1389
+ * evicts too, since the write may have committed, while a write the gate
1390
+ * or the precondition refuses never touches the cache, even when a
1391
+ * `transact` that retries internally rolled back an earlier attempt that
1392
+ * wrote: only the last attempt counts. That holds for `delete`'s eviction
1393
+ * as well.
1394
+ *
1395
+ * `restore` is the operations' `restore` with the same write-through.
1396
+ * `update`, `restore`, `delete` and `restoreVersion` take the operations' optional
1397
+ * expected version ({@link Precondition}); the HTTP layer maps a request's
1398
+ * `If-Match` onto it and a `StaleVersion` failure onto the 412.
1399
+ *
1400
+ * @since 0.1.0
1401
+ */
1402
+ export interface SubtypeHandlers<Resource, E, R> {
1403
+ readonly list: SubtypeOps<Resource, E, R>["list"];
1404
+ readonly get: SubtypeOps<Resource, E, R>["get"];
1405
+ readonly create: (input: CreateRequest) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeAlreadyExists | NodeNotFound | VersionConflict | E, R>;
1406
+ readonly update: (input: RestoreRequest) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
1407
+ readonly restore: (input: RestoreRequest) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
1408
+ readonly delete: (id: string, precondition?: Precondition) => Effect.Effect<void, Gate | WriteRejected | NodeNotFound | StaleVersion | VersionConflict | E, R>;
1409
+ readonly restoreVersion: (input: {
1410
+ readonly id: string;
1411
+ readonly version: bigint;
1412
+ } & Precondition) => Effect.Effect<CommittedWrite<Resource>, Gate | WriteRejected | NodeNotFound | VersionNotFound | StaleVersion | VersionConflict | E, R>;
1413
+ /**
1414
+ * On a publish-gated leaf, its publication operations, each run in
1415
+ * `transact` and followed by the after hooks (`publish` / `unpublish`,
1416
+ * or `restoreVersion` for `discardDraft`) and the cache refresh. Their
1417
+ * cache stamps are the node's head, so two of them on one node compare
1418
+ * equal: the application runs them one at a time per node, the whole
1419
+ * call under one lock (README → Seams → the live pointer, pulpit#164).
1420
+ */
1421
+ readonly publication: Option.Option<PublicationOps<E, R>>;
1422
+ }
1423
+ /**
1424
+ * What {@link subtypeHandlers} needs besides the operations.
1425
+ *
1426
+ * @since 0.1.0
1427
+ */
1428
+ export interface SubtypeHandlersConfig<Resource, R> {
1429
+ /** The write boundary — always-open: a nested call is a SAVEPOINT (website #1883). */
1430
+ readonly transact: <A, E2>(effect: Effect.Effect<A, E2, R>) => Effect.Effect<A, E2, R>;
1431
+ /**
1432
+ * Whether the caller is already inside a transaction, read once a write's
1433
+ * `transact` has returned. When it is, that `transact` was a SAVEPOINT and
1434
+ * nothing has committed yet, so the write refuses its cache write-through
1435
+ * and evicts instead: a resource that may still roll back is never
1436
+ * cached (`CACHE-AUTH-005`, pulpit#124). Over `effect/sql`, it is whether
1437
+ * the fiber's context holds the client's `TransactionConnection`.
1438
+ */
1439
+ readonly inTransaction: Effect.Effect<boolean, never, R>;
1440
+ /** The leaf's cache, when it has one. */
1441
+ readonly cache: Option.Option<ResourceCacheSeam<Resource, R>>;
1442
+ }
1443
+ /**
1444
+ * Wrap a leaf's operations as its handler record. An owned leaf may still
1445
+ * be cached — `CACHE-AUTH-005` is enforced by the cache's serveability
1446
+ * policy (`./publication`'s `isPubliclyServeable` with `owned: true`), which
1447
+ * only ever holds its public rows, and by the handlers' own eviction when a
1448
+ * committed write can't be re-read or written through (see
1449
+ * {@link SubtypeHandlers}). Each write fails with its operation's error, or
1450
+ * with the re-read's unless that is `NodeNotFound` (a write hidden from its
1451
+ * writer, answered as a {@link CommittedWrite} with no resource).
1452
+ *
1453
+ * @since 0.1.0
1454
+ */
1455
+ export declare const subtypeHandlers: <Resource, E, R>(ops: SubtypeOps<Resource, E, R>, config: SubtypeHandlersConfig<Resource, R>) => SubtypeHandlers<Resource, E, R>;
1456
+ /**
1457
+ * What {@link subtypeResourceHandlers} needs besides the handler record:
1458
+ * effect-jsonapi's `ResourceHandlers.make` config without its `ops`, `cache`
1459
+ * and `transact` (the record already owns the write boundary, the re-read and
1460
+ * the cache write-through), plus the three mappings from the endpoint's
1461
+ * decoded inputs onto the operations' own.
1462
+ *
1463
+ * @since 0.1.0
1464
+ */
1465
+ export interface SubtypeResourceHandlersConfig<TQuery, TCreate, TUpdate extends {
1466
+ readonly id: string;
1467
+ }, V, RV, RL, Targets extends Handlers.IncludeTargets, Type extends string = string> extends Omit<ResourceHandlers.ReadOnlyConfig<TQuery, Assemble.ResourceLike, string, V, never, never, RV, RL, never, Targets>, "ops" | "cache"> {
1468
+ /**
1469
+ * The resource's JSON:API type, which a hidden write's identifier
1470
+ * document names (`data.type`): effect-jsonapi's `hiddenWrite.type`.
1471
+ */
1472
+ readonly type: Type;
1473
+ /** The decoded list query as a {@link ListRequest}: its page, `includeDeleted`, `where` and order. */
1474
+ readonly listRequest: (query: TQuery) => ListRequest;
1475
+ /** The decoded create payload as the operation's input: the client id, if any, and the state. */
1476
+ readonly createInput: (payload: TCreate) => {
1477
+ readonly id: Option.Option<string>;
1478
+ readonly state: ChainState;
1479
+ };
1480
+ /**
1481
+ * The decoded update payload as the operation's input: the id, the
1482
+ * tri-state patch and the optional expected version.
1483
+ */
1484
+ readonly updateInput: (payload: TUpdate) => {
1485
+ readonly id: string;
1486
+ readonly patch: ChainPatch;
1487
+ } & Precondition;
1488
+ }
1489
+ /**
1490
+ * A leaf's {@link SubtypeHandlers} as effect-jsonapi's CRUD handlers
1491
+ * (`ResourceHandlers.make`), keyed by the endpoint names `Group.flat`
1492
+ * declares (pulpit#5). Each write goes through the record, so it keeps the
1493
+ * record's `transact` boundary, writer re-read, lifecycle hooks and cache
1494
+ * write-through (`CACHE-AUTH-005`); effect-jsonapi is given no `cache` and no
1495
+ * `transact` of its own, and its mutation tail re-reads the row once more
1496
+ * with `get` to assemble the document. `list` answers the page with the
1497
+ * unpaged total, and `delete` the 204. `restore` and `restoreVersion` have no
1498
+ * JSON:API endpoint and stay on the record. The handlers fail with the
1499
+ * record's errors (and effect-jsonapi's `Conflict` for an `update` whose
1500
+ * payload id is not the route's, before any operation runs); mapping them to
1501
+ * the wire is the application's `ServerErrors`.
1502
+ *
1503
+ * A `create` / `update` whose committed write hides the row from its writer
1504
+ * (the re-read fails `NodeNotFound`) answers effect-jsonapi's committed
1505
+ * identifier document, `{ data: { type, id, meta: { version } } }` at the
1506
+ * endpoint's own status, not a 404 (effect-jsonapi's `hiddenWrite`,
1507
+ * pulpit#107). Declare the group with `Group.flat({ …, hiddenWrites: true })`
1508
+ * so its `create` / `update` success admits that document.
1509
+ *
1510
+ * @since 0.1.0
1511
+ */
1512
+ export declare const subtypeResourceHandlers: <Resource extends Assemble.ResourceLike, E, R, TQuery, TCreate, TUpdate extends {
1513
+ readonly id: string;
1514
+ }, V = undefined, RV = never, RL = never, Targets extends Handlers.IncludeTargets = never>(handlers: SubtypeHandlers<Resource, E, R>, config: SubtypeResourceHandlersConfig<TQuery, TCreate, TUpdate, V, RV, RL, Targets, Resource["type"]>) => ResourceHandlers.CrudHandlers<TCreate, TUpdate, Resource, string, TQuery, E | InvalidListOrder | NodeAlreadyExists | NodeNotFound | StaleVersion | VersionConflict | WriteRejected | Gate, R, R, RV, RL, never, Targets, never, never, never, [NoInfer<Resource["type"]>] extends [never] ? never : ResourceHandlers.CommittedDocument<NoInfer<Resource["type"]>, string>>;
1515
+ /**
1516
+ * A leaf's locale level (pulpit#56, model (b)): the columns translated per
1517
+ * locale. Every other column of the leaf is shared by all of a node's
1518
+ * locales, so one value serves the whole node; history, draft, visibility and
1519
+ * publication are the node's, never a locale's. Declared once, beside the
1520
+ * leaf's registry entry (flagged `localised`), so the application's view
1521
+ * generator and {@link defineSubtype} (`locales`) read the same value, as a
1522
+ * {@link SearchProjection} is.
1523
+ *
1524
+ * @since 0.1.0
1525
+ */
1526
+ export interface LocaleLevel {
1527
+ /** The translated columns, each once. */
1528
+ readonly columns: ReadonlyArray<string>;
1529
+ }
1530
+ /**
1531
+ * What {@link SubtypeOps.getLocalised} returns: the locale it fell back to
1532
+ * and the resource in that locale.
1533
+ *
1534
+ * @since 0.1.0
1535
+ */
1536
+ export interface LocalisedResource<Resource> {
1537
+ readonly locale: string;
1538
+ readonly resource: Resource;
1539
+ }
1540
+ /**
1541
+ * Declare a {@link LocaleLevel}. Throws {@link SubtypeDeclarationError} at
1542
+ * import, as a registry violation is, for no column, a column named twice or
1543
+ * one the view keeps for itself (`id`, `version`, `action`,
1544
+ * {@link LOCALE_COLUMN}); {@link defineSubtype} checks the columns against
1545
+ * the view.
1546
+ *
1547
+ * @example
1548
+ * ```ts
1549
+ * import { deepStrictEqual } from "node:assert";
1550
+ * import { defineLocaleLevel } from "@thomasfosterau/pulpit/subtype";
1551
+ *
1552
+ * const pages = defineLocaleLevel({ columns: ["title", "body", "slug"] });
1553
+ * deepStrictEqual(pages.columns, ["title", "body", "slug"]);
1554
+ * ```
1555
+ *
1556
+ * @since 0.1.0
1557
+ */
1558
+ export declare const defineLocaleLevel: (declaration: {
1559
+ readonly columns: ReadonlyArray<string>;
1560
+ }) => LocaleLevel;
1561
+ declare const OwnerSetTooLarge_base: Schema.Class<OwnerSetTooLarge, Schema.TaggedStruct<"OwnerSetTooLarge", {
1562
+ readonly type: Schema.String;
1563
+ }>, Cause.YieldableError & {
1564
+ readonly brand: unique symbol;
1565
+ }>;
1566
+ /**
1567
+ * {@link readableOwners} refused a union it could not make exact: a leaf
1568
+ * has more readable owners than one read returns ({@link MAX_PAGE_LIMIT}),
1569
+ * so the union and its total would be short (`SEC-INPUT-003`).
1570
+ *
1571
+ * @since 0.1.0
1572
+ */
1573
+ export declare class OwnerSetTooLarge extends OwnerSetTooLarge_base {
1574
+ }
1575
+ /**
1576
+ * The accounts that own at least one row the viewer may list in any of
1577
+ * `leaves` (pulpit#186): the union of each leaf's
1578
+ * {@link SubtypeOps.owners}, deduplicated in code-point order, paged by
1579
+ * `request`, with the exact union size as `total`. One read per leaf, each
1580
+ * for every owner it has (up to {@link MAX_PAGE_LIMIT}): a leaf with more
1581
+ * fails {@link OwnerSetTooLarge} rather than answer a short union. With
1582
+ * `among` (at most `MAX_SET_VALUES` ids) no leaf can exceed it. A hidden row
1583
+ * never contributes its owner (`SEC-AUTH-ENUM-001`). Fails otherwise only
1584
+ * with the stores' `E`.
1585
+ *
1586
+ * @example
1587
+ * ```ts
1588
+ * import { deepStrictEqual } from "node:assert";
1589
+ * import * as Effect from "effect/Effect";
1590
+ * import { readableOwners } from "@thomasfosterau/pulpit/subtype";
1591
+ *
1592
+ * const leaf = (type: string, owners: ReadonlyArray<string>) => ({
1593
+ * type,
1594
+ * owners: () => Effect.succeed({ rows: owners, total: owners.length }),
1595
+ * });
1596
+ * const page = Effect.runSync(
1597
+ * readableOwners([leaf("posts", ["ana", "bo"]), leaf("pages", ["bo", "cy"])], {
1598
+ * limit: 10,
1599
+ * offset: 0,
1600
+ * includeDeleted: false,
1601
+ * }),
1602
+ * );
1603
+ * deepStrictEqual(page, { rows: ["ana", "bo", "cy"], total: 3 });
1604
+ * ```
1605
+ *
1606
+ * @since 0.1.0
1607
+ */
1608
+ export declare const readableOwners: <E, R>(leaves: ReadonlyArray<{
1609
+ readonly type: string;
1610
+ readonly owners: (request: OwnersRequest) => Effect.Effect<Page<string>, E, R>;
1611
+ }>, request: OwnersRequest) => Effect.Effect<Page<string>, OwnerSetTooLarge | E, R>;
1612
+ export {};
1613
+ //# sourceMappingURL=subtype.d.ts.map