@thomasfosterau/pulpit 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
@@ -0,0 +1,1300 @@
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 Array from "effect/Array";
91
+ import * as Arr from "effect/Array";
92
+ import * as Cause from "effect/Cause";
93
+ import * as Clock from "effect/Clock";
94
+ import * as DateTime from "effect/DateTime";
95
+ import * as Effect from "effect/Effect";
96
+ import * as Exit from "effect/Exit";
97
+ import * as HashMap from "effect/HashMap";
98
+ import * as HashSet from "effect/HashSet";
99
+ import * as Match from "effect/Match";
100
+ import * as Option from "effect/Option";
101
+ import * as Predicate from "effect/Predicate";
102
+ import * as Record from "effect/Record";
103
+ import * as Ref from "effect/Ref";
104
+ import * as Str from "effect/String";
105
+ import * as Schema from "effect/Schema";
106
+ import { ResourceHandlers } from "@thomasfosterau/effect-jsonapi";
107
+ import { always, and, anyOf, eq, isIn, locale as localeIn, LOCALE_COLUMN, ne, never, or, } from "./filter.js";
108
+ import { byCodePoint } from "./internal/list-order.js";
109
+ import { canViewOwnedRow, declaresColumns, ownedFacts } from "./internal/owned-columns.js";
110
+ import { canViewReferenceRow, referenceListFilter } from "./internal/reference-columns.js";
111
+ import { defineVersionChain, NodeNotFound, MAX_HISTORY_LIMIT, VersionNotFound as VersionNotFoundError, } from "./version-chain.js";
112
+ /** The write clock: the current instant, as the `Date` a fact stores. */
113
+ const nowDate = Effect.map(Clock.currentTimeMillis, (millis) => DateTime.toDate(DateTime.makeUnsafe(millis)));
114
+ /**
115
+ * The fail-closed follower seam: nobody follows anybody. `nobody` is the
116
+ * matches-nothing value of the `in` node (SQL: `(select null::uuid where false)`).
117
+ *
118
+ * @since 0.1.0
119
+ */
120
+ export const nobodyFollows = (nobody) => ({
121
+ isFollower: () => Effect.succeed(false),
122
+ following: () => nobody,
123
+ });
124
+ // ── Errors ────────────────────────────────────────────────────────────
125
+ /**
126
+ * No signed-in viewer where one is required: the wire's 401.
127
+ *
128
+ * @since 0.1.0
129
+ */
130
+ export class Unauthorized extends Schema.TaggedError()("Unauthorized", {}) {
131
+ }
132
+ /**
133
+ * A signed-in viewer below the rung a write needs: the wire's 403. Never used to hide a row.
134
+ *
135
+ * @since 0.1.0
136
+ */
137
+ export class Forbidden extends Schema.TaggedError()("Forbidden", {}) {
138
+ }
139
+ /**
140
+ * A list order the leaf does not sort by: a key whose column is not sortable
141
+ * (outside the leaf's `sortable` allow-list or, without one, outside the
142
+ * view's declared `LeafStore.columns`), a direction other than `asc` /
143
+ * `desc`, or input not shaped as an order at all (an `orderBy` that is not
144
+ * an array, a key that is not an object, a `column` that is not a string).
145
+ * The wire's 400. The order is checked before the viewer or anything else is
146
+ * read, so it says nothing about any row, but **it does say which columns
147
+ * are sortable**: without a `sortable` allow-list, that a name is (or is
148
+ * not) one of the view's columns. `column` is the caller's own key, never
149
+ * passed to the store, or `""` when the key carries no string column
150
+ * (pulpit#112).
151
+ *
152
+ * @since 0.1.0
153
+ */
154
+ export class InvalidListOrder extends Schema.TaggedError()("InvalidListOrder", {
155
+ type: Schema.String,
156
+ column: Schema.String,
157
+ }) {
158
+ }
159
+ /**
160
+ * A leaf declared against the rules: a `sortable` entry that is not one of
161
+ * its view's `LeafStore.columns`. Thrown by {@link defineSubtype} when the
162
+ * leaf is declared, never an Effect failure, like `./edges`'
163
+ * `EdgeDeclarationError`: a misspelt allow-list entry must stop its module
164
+ * loading rather than silently leave a column unsortable (pulpit#112).
165
+ *
166
+ * @since 0.1.0
167
+ */
168
+ export class SubtypeDeclarationError extends Schema.TaggedError()("SubtypeDeclarationError", { message: Schema.String }) {
169
+ }
170
+ /**
171
+ * Resolve a viewer's {@link ReadScope}.
172
+ *
173
+ * @since 0.1.0
174
+ */
175
+ export const readScopeFor = (viewer, policy, followers) => Option.match(viewer, {
176
+ onNone: () => ({ kind: "public" }),
177
+ onSome: (account) => policy.canViewPrivate(account)
178
+ ? { kind: "all" }
179
+ : { kind: "member", userId: account.id, following: followers.following(account.id) },
180
+ });
181
+ /**
182
+ * The owned-material read predicate — the single interpretation of the
183
+ * three-state visibility axis plus the orthogonal `draft` axis (#1118):
184
+ *
185
+ * | visibility | who may view |
186
+ * | ----------- | -------------------------------------------------------------- |
187
+ * | `public` | everyone, anonymous included |
188
+ * | `followers` | the owner; the owner's followers; the view-private rung |
189
+ * | `private` | the owner; the view-private rung |
190
+ *
191
+ * `draft: true` narrows whatever `visibility` allows down to the owner and
192
+ * the rung — a `public` draft is exactly as hidden as a `private` one — and
193
+ * so does `unapproved: true` on a moderated leaf (a `pending` or `rejected`
194
+ * row reads only to its author and the rung, pulpit#154). Never
195
+ * fails; callers turn `false` into {@link NodeNotFound}.
196
+ *
197
+ * `defineSubtype`'s `get` / `getMany` and `./edges`' `canViewEndpoint`
198
+ * apply the same predicate to the row they read. There, an owner column
199
+ * holding no string (a `NULL`, an absent key) reads as no owner: no viewer
200
+ * is its owner and the follower seam is never asked, so the row shows only
201
+ * when `public`, not a draft and not unapproved, or to the rung, as {@link ownedListFilter}
202
+ * lists it (`SEC-AUTH-ENUM-001`).
203
+ *
204
+ * @since 0.1.0
205
+ */
206
+ export const canViewOwned = (viewer, policy, followers, row) => canViewOwnedRow(viewer, policy, followers, { ...row, ownerId: Option.some(row.ownerId) });
207
+ /**
208
+ * The owned-leaf access declaration with the conventional factory-built column names.
209
+ *
210
+ * @since 0.1.0
211
+ */
212
+ export const ownedAccess = (options = {}) => ({
213
+ kind: "owned",
214
+ ownerColumn: "ownerId",
215
+ visibilityColumn: "visibility",
216
+ draftColumn: options.draft === true ? Option.some("draft") : Option.none(),
217
+ moderationColumn: options.moderation === true ? Option.some("moderationState") : Option.none(),
218
+ });
219
+ /**
220
+ * The owned-leaf LIST filter — {@link ReadScope} rendered over the leaf's
221
+ * declared view columns. When the leaf carries a draft column, every
222
+ * non-owner arm additionally requires `draft = false`, and when it carries a
223
+ * moderation column, `moderation = 'approved'`; the owner's own arm never
224
+ * does, so a member lists their own drafts and unapproved rows. Columns the view lacks
225
+ * fail closed.
226
+ *
227
+ * @since 0.1.0
228
+ */
229
+ export const ownedListFilter = (access, viewColumns, scope) => {
230
+ if (!declaresColumns(access, viewColumns))
231
+ return never;
232
+ const published = (filter) => and(filter, ...Option.toArray(Option.map(access.draftColumn, (draft) => eq(draft, false))), ...Option.toArray(Option.map(access.moderationColumn, (moderation) => eq(moderation, "approved"))));
233
+ return Match.value(scope).pipe(Match.discriminatorsExhaustive("kind")({
234
+ all: () => always,
235
+ public: () => published(eq(access.visibilityColumn, "public")),
236
+ member: (member) => or(published(eq(access.visibilityColumn, "public")), eq(access.ownerColumn, member.userId), published(and(eq(access.visibilityColumn, "followers"), isIn(access.ownerColumn, member.following)))),
237
+ }));
238
+ };
239
+ /**
240
+ * The soft-delete arm of every list: tombstones only when the caller asked
241
+ * AND holds the view-deleted rung — asking without the rung changes nothing.
242
+ *
243
+ * @since 0.1.0
244
+ */
245
+ export const softDeleteFilter = (viewer, policy, includeDeleted) => includeDeleted && Option.exists(viewer, policy.canViewDeleted) ? always : ne("action", "delete");
246
+ /**
247
+ * The reference-leaf privacy arm (`PUB-009`): `visibility = 'public'` unless
248
+ * the viewer holds the view-private rung, or the view has no visibility
249
+ * column (an Entity leaf rides no such axis). Given a followers arm (the
250
+ * leaf's `access`, declaring `followersOfColumn` over a view that carries
251
+ * it, and the follower seam), a signed-in viewer also lists the `followers`
252
+ * rows whose column names them or an account they follow (pulpit#92):
253
+ * `visibility = 'followers' AND (<followersOfColumn> = <viewer> OR
254
+ * <followersOfColumn> IN (<following>))`. Without one (the default,
255
+ * `Option.none()`), a `followers` row is the view-private rung's alone.
256
+ * Pass `Option.some({ access, followers })` whenever the leaf declares
257
+ * `followersOfColumn`: `defineSubtype`'s `get` / `getMany` read the
258
+ * declared column, so the three-argument form agrees with them only for a
259
+ * leaf without it (for one with it, `get` serves `followers` rows this
260
+ * hides).
261
+ *
262
+ * @since 0.1.0
263
+ */
264
+ export const referencePrivacyFilter = (viewer, policy, viewColumns, followersArm = Option.none()) => Option.match(followersArm, {
265
+ onNone: () => referenceListFilter({}, viewColumns, publicOrAll(viewer, policy)),
266
+ onSome: (arm) => referenceListFilter(arm.access, viewColumns, readScopeFor(viewer, policy, arm.followers)),
267
+ });
268
+ /** A read scope without the follower set: a member reads as the anonymous do. */
269
+ const publicOrAll = (viewer, policy) => Option.exists(viewer, policy.canViewPrivate) ? { kind: "all" } : { kind: "public" };
270
+ // ── The write gates ───────────────────────────────────────────────────
271
+ const signedIn = (viewer) => Effect.fromOption(viewer, () => new Unauthorized());
272
+ const atRung = (viewer, admits) => Effect.filterOrFail(signedIn(viewer), admits, () => new Forbidden());
273
+ /**
274
+ * The gate for a CREATE: reference data → the corpus-write rung; owned → any signed-in account.
275
+ *
276
+ * @since 0.1.0
277
+ */
278
+ export const requireCreator = (access, viewer, policy) => access.kind === "reference" ? atRung(viewer, policy.canWriteCorpus) : signedIn(viewer);
279
+ /**
280
+ * The gate for a write to an EXISTING row: reference data → the corpus-write
281
+ * rung; owned material → the row's owner or the moderate rung. A missing
282
+ * owner on an owned leaf is {@link NodeNotFound}, never an open gate.
283
+ *
284
+ * On an owned leaf it never reads the row's visibility, draft or delete state:
285
+ * a signed-in caller who is neither the owner nor at the moderate rung gets
286
+ * {@link Forbidden} for any row with an owner record,
287
+ * hidden or not, so the gate deliberately confirms existence (pulpit#24, an
288
+ * exception to `SEC-AUTH-ENUM-001`; SECURITY.md → "Existence disclosure by the
289
+ * write gates"). An anonymous caller gets `Unauthorized` for every id.
290
+ *
291
+ * On a reference leaf this is the rung check alone: it reads no row, so it
292
+ * answers `Unauthorized` / `Forbidden` alike for every id. `defineSubtype`
293
+ * follows it with the read rung the row demands (pulpit#101): a row its `get`
294
+ * hides from the caller fails {@link NodeNotFound}, as `get` does
295
+ * (`PUB-009`, `SEC-AUTH-ENUM-001`). A caller using this gate directly on a
296
+ * reference row must apply that check too.
297
+ *
298
+ * @since 0.1.0
299
+ */
300
+ export const requireWriter = (access, viewer, policy, row, ownerOf) => access.kind === "reference"
301
+ ? atRung(viewer, policy.canWriteCorpus)
302
+ : Effect.gen(function* () {
303
+ const account = yield* signedIn(viewer);
304
+ const owner = yield* ownerOf;
305
+ if (Option.isNone(owner))
306
+ return yield* new NodeNotFound(row);
307
+ if (account.id === owner.value || policy.canModerate(account))
308
+ return account;
309
+ return yield* new Forbidden();
310
+ });
311
+ /**
312
+ * The resource-object `meta` a {@link SubtypeDefinition.toResource} mapper
313
+ * stamps: the row's head version as a decimal string (`bigint` has no JSON
314
+ * form). It reaches only a resource the read already serves the viewer, so it
315
+ * discloses nothing about a hidden row (`SEC-AUTH-ENUM-001`); what it does
316
+ * disclose about a shown one is how many versions its chain holds.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * import { strictEqual } from "node:assert";
321
+ * import { versionMeta } from "@thomasfosterau/pulpit/subtype";
322
+ *
323
+ * const row = { id: "post-1", version: 3n, action: "update" as const, title: "Hello" };
324
+ * const resource = { type: "posts", id: row.id, meta: versionMeta(row) };
325
+ * strictEqual(resource.meta.version, "3");
326
+ * ```
327
+ *
328
+ * @since 0.1.0
329
+ */
330
+ export const versionMeta = (row) => ({
331
+ version: String(row.version),
332
+ });
333
+ // A version is a non-negative integer with no leading zero: anything else in
334
+ // `meta.version` is not one this kernel stamped.
335
+ const StampedVersion = Schema.Struct({
336
+ meta: Schema.Struct({
337
+ version: Schema.String.check(Schema.isPattern(/^(?:0|[1-9][0-9]*)$/, { description: "a head version in decimal" })),
338
+ }),
339
+ });
340
+ /**
341
+ * A single-resource response's `ETag` header value, read from the
342
+ * resource's `meta.version` ({@link versionMeta}): a strong tag (`"3"`)
343
+ * whose value is the head version, so the `If-Match` a client echoes names
344
+ * the version its write expects ({@link UpdateRequest.expectedVersion}; the
345
+ * header's mapping onto it is effect-jsonapi's, which 0.17.0 lacks). None when the value carries no
346
+ * decimal `meta.version`; a collection response takes none, having no single
347
+ * version to tag. Never fails.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * import { strictEqual } from "node:assert";
352
+ * import { Option } from "effect";
353
+ * import { versionEtag } from "@thomasfosterau/pulpit/subtype";
354
+ *
355
+ * const read = { type: "posts", id: "post-1", meta: { version: "3" } };
356
+ * strictEqual(Option.getOrElse(versionEtag(read), () => "none"), '"3"');
357
+ * const unstamped = { type: "posts", id: "post-1", meta: {} };
358
+ * strictEqual(Option.isNone(versionEtag(unstamped)), true);
359
+ * ```
360
+ *
361
+ * @since 0.1.0
362
+ */
363
+ export const versionEtag = (resource) => Option.map(Schema.decodeUnknownOption(StampedVersion)(resource), ({ meta }) => `"${meta.version}"`);
364
+ /**
365
+ * The hard cap on a page (`SEC-INPUT-003`): no request drives an unbounded `LIMIT`.
366
+ *
367
+ * @since 0.1.0
368
+ */
369
+ export const MAX_PAGE_LIMIT = 1000;
370
+ /**
371
+ * A publication operation on a node with no live version (`discardDraft`
372
+ * with nothing published): the wire's 409.
373
+ *
374
+ * @since 0.1.0
375
+ */
376
+ export class NotPublished extends Schema.TaggedError()("NotPublished", {
377
+ type: Schema.String,
378
+ id: Schema.String,
379
+ }) {
380
+ }
381
+ // ── The lifecycle hook seam ───────────────────────────────────────────
382
+ /**
383
+ * A before hook's refusal: the write it was shown does not happen, and the
384
+ * caller's transaction rolls back with it. `reason` is the hook's own,
385
+ * for the wire's 422 (or whatever the application maps it to).
386
+ *
387
+ * @since 0.1.0
388
+ */
389
+ export class WriteRejected extends Schema.TaggedError()("WriteRejected", {
390
+ type: Schema.String,
391
+ id: Schema.String,
392
+ action: Schema.String,
393
+ reason: Schema.String,
394
+ }) {
395
+ }
396
+ /**
397
+ * No hooks: the default for a leaf that declares none.
398
+ *
399
+ * @since 0.1.0
400
+ */
401
+ export const noHooks = () => ({ before: [], after: [] });
402
+ /**
403
+ * Concatenate hook sets in order: `a`'s before hooks run before `b`'s, and
404
+ * likewise after. The first set's `afterFailure` that is given wins.
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * import { strictEqual } from "node:assert";
409
+ * import * as Effect from "effect/Effect";
410
+ * import { composeHooks, noHooks, type BeforeWriteHook } from "@thomasfosterau/pulpit/subtype";
411
+ *
412
+ * const audit: BeforeWriteHook<never, never> = {
413
+ * name: "audit",
414
+ * run: (event) => Effect.succeed(event.patch),
415
+ * };
416
+ * const hooks = composeHooks({ before: [audit], after: [] }, noHooks());
417
+ * strictEqual(hooks.before[0]?.name, "audit");
418
+ * ```
419
+ *
420
+ * @since 0.1.0
421
+ */
422
+ export const composeHooks = (...sets) => {
423
+ const afterFailure = Array.findFirst(sets, (set) => set.afterFailure !== undefined);
424
+ return {
425
+ before: Array.flatMap(sets, (set) => set.before),
426
+ after: Array.flatMap(sets, (set) => set.after),
427
+ afterFailure: Option.getOrUndefined(Option.flatMapNullishOr(afterFailure, (set) => set.afterFailure)),
428
+ };
429
+ };
430
+ /** Run the before hooks in order, each shown the patch the previous one returned. */
431
+ const runBefore = (hooks, event) => Effect.reduce(hooks, () => event.patch, (patch, hook) => hook.run({ ...event, patch }).pipe(Effect.withSpan(`${event.type}.before.${hook.name}`)));
432
+ /**
433
+ * Run the before hooks' locale passes in order, each shown the previous
434
+ * one's locale patch. A hook with no `locales` pass refuses a write that
435
+ * carries any locale change (fail closed, pulpit#56) and passes `{}` on.
436
+ */
437
+ const runBeforeLocales = (hooks, event) => Effect.reduce(hooks, () => event.locales ?? {}, (locales, hook) => hook.locales === undefined
438
+ ? Array.isReadonlyArrayNonEmpty(writtenKeys(locales))
439
+ ? Effect.fail(new WriteRejected({
440
+ type: event.type,
441
+ id: event.id,
442
+ action: event.action,
443
+ reason: `before hook '${hook.name}' does not handle a locale patch`,
444
+ }))
445
+ : Effect.succeed(locales)
446
+ : hook
447
+ .locales({ ...event, locales })
448
+ .pipe(Effect.withSpan(`${event.type}.before.${hook.name}.locales`)));
449
+ /** Run the after hooks in order, each failure handed to the policy, never to the write. */
450
+ const runAfter = (hooks, event) => Effect.forEach(hooks.after, (hook) => hook.run(event).pipe(Effect.withSpan(`${event.type}.after.${hook.name}`), Effect.catchCause((cause) => hooks.afterFailure === "defect"
451
+ ? Effect.die(Cause.squash(cause))
452
+ : Effect.logError(`${event.type}.after.${hook.name} failed`, cause))), { concurrency: 1, discard: true });
453
+ // ── The factory ───────────────────────────────────────────────────────
454
+ /**
455
+ * A key's column when it is an object with a string `column`, else `""`.
456
+ * Typed as the request types it, but read as a caller may really send it.
457
+ */
458
+ const columnOf = (key) => Predicate.isObject(key) && Predicate.isString(key.column) ? key.column : "";
459
+ /**
460
+ * A list request's order as the store receives it: an array whose every key
461
+ * is an object with a sortable string `column` and a direction `asc` or
462
+ * `desc`, or {@link InvalidListOrder} for the first key that isn't (or for an
463
+ * `orderBy` that is no array), so no unsortable name reaches SQL and no
464
+ * out-of-type input dies. A repeated column is dropped (its first key
465
+ * already ordered every tie it could), which bounds the keys by the view's
466
+ * columns; each key is rebuilt, so nothing but `column` and `direction`
467
+ * reaches the store.
468
+ */
469
+ const checkedOrder = (type, isSortable, keys) => {
470
+ // The request's types are not checked at runtime: a decoded query or an
471
+ // untyped caller can send any value here, so every guard reads it as such.
472
+ const isKey = (key) => Predicate.isObject(key) &&
473
+ Predicate.isString(key.column) &&
474
+ isSortable(key.column) &&
475
+ (key.direction === "asc" || key.direction === "desc");
476
+ // A boolean, not a guard: narrowing a typed array to `Array<unknown>` would lose its keys' type.
477
+ const isList = (value) => Array.isArray(value);
478
+ if (!isList(keys))
479
+ return Effect.fail(new InvalidListOrder({ type, column: columnOf(keys) }));
480
+ return Option.match(Array.findFirst(keys, (key) => !isKey(key)), {
481
+ onNone: () => Effect.succeed(Array.map(Array.dedupeWith(keys, (self, that) => self.column === that.column), ({ column, direction }) => ({ column, direction }))),
482
+ onSome: (key) => Effect.fail(new InvalidListOrder({ type, column: columnOf(key) })),
483
+ });
484
+ };
485
+ /**
486
+ * The leaf's sortable-column test, its `sortable` allow-list checked against
487
+ * the view's columns once, when the leaf is declared: an entry outside them
488
+ * throws {@link SubtypeDeclarationError}. Without an allow-list, every
489
+ * declared view column is sortable.
490
+ */
491
+ const sortableColumns = (type, columns,
492
+ // oxlint-disable-next-line effect/prefer-option-over-null -- `SubtypeDefinition.sortable` as declared, an optional `?: T | undefined` field (AGENTS.md → Conventions)
493
+ sortable) => {
494
+ if (sortable === undefined)
495
+ return (column) => columns.has(column);
496
+ const undeclared = Array.findFirst(sortable, (column) => !columns.has(column));
497
+ if (Option.isSome(undeclared)) {
498
+ throw new SubtypeDeclarationError({
499
+ message: `${type}: sortable names '${undeclared.value}', which is not a column of the view`,
500
+ });
501
+ }
502
+ return (column) => Array.contains(sortable, column);
503
+ };
504
+ /**
505
+ * Bind a leaf's operations. Every produced operation is traced as
506
+ * `${type}.<operation>`; the write operations run in the caller's
507
+ * transaction (the boundary is the handler seam's, website #1883).
508
+ *
509
+ * Throws {@link SubtypeDeclarationError}, when the leaf is declared, for a
510
+ * `sortable` entry that is not a column of the view.
511
+ *
512
+ * @since 0.1.0
513
+ */
514
+ export const defineSubtype = (definition) => {
515
+ const { type, access, leaves, policy, followers, toResource } = definition;
516
+ const hooks = definition.hooks ?? Effect.succeed(noHooks());
517
+ // The history reads run no hook, so they read through a chain bound without one.
518
+ const history = defineVersionChain(type, definition.chain);
519
+ // Bound only for a leaf that declares hooks, so a leaf without them reads nothing extra.
520
+ const chain = defineVersionChain(type, definition.chain, definition.hooks === undefined
521
+ ? {}
522
+ : {
523
+ beforeAppend: (event) => Effect.flatMap(hooks, ({ before }) => Array.isReadonlyArrayNonEmpty(before)
524
+ ? Effect.flatMap(definition.viewer, (viewer) => runBefore(before, { ...event, type, viewer }))
525
+ : Effect.succeed(event.patch)),
526
+ beforeAppendLocales: (event) => Effect.flatMap(hooks, ({ before }) => Array.isReadonlyArrayNonEmpty(before)
527
+ ? Effect.flatMap(definition.viewer, (viewer) => runBeforeLocales(before, { ...event, type, viewer }))
528
+ : Effect.succeed(event.locales ?? {})),
529
+ });
530
+ const actor = (account) => Option.some(account.id);
531
+ // The view's column set is fixed at bind time, so the owned leaf's
532
+ // declared-column check is answered once, not per row.
533
+ const ownedColumnsDeclared = access.kind === "owned" && declaresColumns(access, leaves.columns);
534
+ // Checked here, so a misspelt `sortable` entry throws when the leaf is declared.
535
+ const isSortable = sortableColumns(type, leaves.columns, definition.sortable);
536
+ // Checked here too, so a projection naming a column the view lacks throws when the leaf is declared.
537
+ if (definition.search !== undefined)
538
+ checkSearchProjection(definition.search, leaves.columns);
539
+ // And the locale level, so a translated column the view lacks throws at declaration.
540
+ const localeLevel = Option.fromUndefinedOr(definition.locales);
541
+ if (Option.isSome(localeLevel)) {
542
+ checkLocaleLevel(localeLevel.value, leaves.columns, [
543
+ ...accessColumns(access),
544
+ ...(definition.publication === undefined ? [] : PUBLICATION_COLUMNS),
545
+ ]);
546
+ }
547
+ /**
548
+ * The write's locale level checked against the declaration (pulpit#56):
549
+ * shared columns in `state`, translated ones per locale, every locale tag
550
+ * non-empty; a leaf with none takes no locale. Runs after the gate.
551
+ */
552
+ const lockedLocales = (id, action, state, locales) => Option.match(localeViolation(Option.map(localeLevel, (level) => level.columns), state, locales), {
553
+ onNone: () => Effect.void,
554
+ onSome: (reason) => Effect.fail(new WriteRejected({ type, id, action, reason })),
555
+ });
556
+ /**
557
+ * The row's own visibility, by access kind; the soft-delete axis is checked
558
+ * by the caller. It fails closed exactly where the list filter does, so
559
+ * `get` / `getMany` never serve a row `list` hides (SEC-AUTH-ENUM-001): an
560
+ * owned leaf whose view lacks a declared column shows no row, as
561
+ * `ownedListFilter` lists none; a reference row shows only when the view
562
+ * has no `visibility` column, the viewer holds the view-private rung, its
563
+ * visibility is exactly `public`, or it is exactly `followers` and the
564
+ * viewer is, or follows, the account its declared `followersOfColumn` names, as
565
+ * `referencePrivacyFilter` lists (pulpit#92). The reference arm is one
566
+ * shared predicate with `./edges`' `canViewEndpoint`.
567
+ */
568
+ const rowVisible = (viewer, row) => {
569
+ if (access.kind === "reference") {
570
+ return canViewReferenceRow(access, leaves.columns, viewer, policy, followers, row);
571
+ }
572
+ return ownedColumnsDeclared
573
+ ? canViewOwnedRow(viewer, policy, followers, ownedFacts(access, row))
574
+ : Effect.succeed(false);
575
+ };
576
+ const visible = (viewer, row) => row.action === "delete" && !Option.exists(viewer, policy.canViewDeleted)
577
+ ? Effect.succeed(false)
578
+ : rowVisible(viewer, row);
579
+ const publication = Option.fromUndefinedOr(definition.publication);
580
+ /** Whether the viewer's rung reads every head: the view-private rung, or a reference leaf's corpus writers. */
581
+ const readsHeadByRung = (viewer) => Option.exists(viewer, (account) => policy.canViewPrivate(account) ||
582
+ (access.kind === "reference" && policy.canWriteCorpus(account)));
583
+ /** Whether the viewer reads this node's head: by rung, or as its owner. */
584
+ const readsHeadOf = (viewer, head) => readsHeadByRung(viewer) ||
585
+ (access.kind === "owned" &&
586
+ Option.exists(viewer, (account) => Option.contains(ownedFacts(access, head).ownerId, account.id)));
587
+ /**
588
+ * The row the viewer reads for a node whose head is `head`: the head
589
+ * itself, or on a publish-gated leaf, for a viewer who doesn't read it,
590
+ * the live row (none without one).
591
+ */
592
+ const chosenRow = (viewer, head) => Option.match(publication, {
593
+ onNone: () => Effect.succeedSome(head),
594
+ onSome: ({ live }) => readsHeadOf(viewer, head) ? Effect.succeedSome(head) : live.get(head.id),
595
+ });
596
+ /**
597
+ * The row `get` serves the viewer: a missing or hidden row is the same
598
+ * {@link NodeNotFound} (SEC-AUTH-ENUM-001). `get` and the reference write
599
+ * gate both read through it, so they can never disagree (pulpit#101).
600
+ */
601
+ const visibleRow = (viewer, id) => Effect.gen(function* () {
602
+ const head = yield* leaves.get(id);
603
+ const row = Option.isSome(head) ? yield* chosenRow(viewer, head.value) : Option.none();
604
+ if (Option.isSome(row) && (yield* visible(viewer, row.value)))
605
+ return row.value;
606
+ return yield* new NodeNotFound({ type, id });
607
+ });
608
+ /** Whether this request holds a preview grant for `id` (a publish-gated leaf's alone). */
609
+ const previewing = (id) => Option.match(Option.flatMapNullishOr(publication, (declared) => declared.preview), {
610
+ onNone: () => Effect.succeed(false),
611
+ onSome: (grant) => Effect.map(grant, (granted) => Option.contains(granted, id)),
612
+ });
613
+ /**
614
+ * The gate for a write or history read to an existing row. On a reference
615
+ * leaf, the corpus-write rung and then the read rung the row demands: a
616
+ * corpus writer who can't `get` the row learns `NodeNotFound`, exactly as
617
+ * from `get`, before any content is read or written (pulpit#101,
618
+ * `PUB-009`). The rung check runs first and reads no row, so a caller below
619
+ * it gets `Unauthorized` / `Forbidden` for every id alike. An owned leaf's
620
+ * gate is unchanged (pulpit#24).
621
+ */
622
+ const writer = (viewer, id) => Effect.tap(requireWriter(access, viewer, policy, { type, id }, leaves.ownerOf(id)), () => access.kind === "reference" ? Effect.asVoid(visibleRow(viewer, id)) : Effect.void);
623
+ const create = Effect.fn(`${type}.create`)(function* (input) {
624
+ const account = yield* requireCreator(access, yield* definition.viewer, policy);
625
+ yield* lockedLocales(Option.getOrElse(input.id, () => ""), "create", input.state, input.locales ?? {});
626
+ const written = yield* chain.create({ ...input, actorId: actor(account) });
627
+ if (access.kind === "owned")
628
+ yield* leaves.stampOwner(written.id, account.id);
629
+ return written;
630
+ });
631
+ const update = Effect.fn(`${type}.update`)(function* (input) {
632
+ const account = yield* writer(yield* definition.viewer, input.id);
633
+ yield* lockedLocales(input.id, "update", input.patch, input.locales ?? {});
634
+ return yield* chain.update({ ...input, actorId: actor(account) });
635
+ });
636
+ const restore = Effect.fn(`${type}.restore`)(function* (input) {
637
+ const account = yield* writer(yield* definition.viewer, input.id);
638
+ yield* lockedLocales(input.id, "restore", input.patch, input.locales ?? {});
639
+ return yield* chain.restore({ ...input, actorId: actor(account) });
640
+ });
641
+ const softDelete = Effect.fn(`${type}.delete`)(function* (id, precondition = {}) {
642
+ const account = yield* writer(yield* definition.viewer, id);
643
+ return yield* chain.delete(id, actor(account), precondition);
644
+ });
645
+ const get = Effect.fn(`${type}.get`)(function* (id) {
646
+ // A preview grant serves exactly its node's head, unless tombstoned.
647
+ if (yield* previewing(id)) {
648
+ const head = yield* leaves.get(id);
649
+ if (Option.isSome(head) && head.value.action !== "delete")
650
+ return toResource(head.value);
651
+ }
652
+ // Hidden and missing are the same answer (SEC-AUTH-ENUM-001).
653
+ return toResource(yield* visibleRow(yield* definition.viewer, id));
654
+ });
655
+ const owners = Effect.fn(`${type}.owners`)(function* (request) {
656
+ if (access.kind !== "owned")
657
+ return { rows: [], total: 0 };
658
+ const viewer = yield* definition.viewer;
659
+ const among = request.among === undefined ? always : anyOf(access.ownerColumn, request.among);
660
+ const query = {
661
+ column: access.ownerColumn,
662
+ where: searchScopeFilter({ access, columns: leaves.columns, policy, followers }, viewer, {
663
+ includeDeleted: request.includeDeleted,
664
+ where: among,
665
+ }),
666
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
667
+ offset: Math.max(0, Math.trunc(request.offset)),
668
+ };
669
+ // The same choice `list` makes: the head by rung, else live rows and the viewer's own heads.
670
+ return yield* Option.match(publication, {
671
+ onNone: () => leaves.distinct(query),
672
+ onSome: ({ live }) => readsHeadByRung(viewer)
673
+ ? leaves.distinct(query)
674
+ : live.distinct({ ...query, headFor: ownHeads(viewer) }),
675
+ });
676
+ });
677
+ const getLocalised = Effect.fn(`${type}.getLocalised`)(function* (id, locales) {
678
+ const viewer = yield* definition.viewer;
679
+ // The node's own gate, exactly as `get` runs it: a preview grant reads
680
+ // its node's head unless tombstoned, anyone else the row `get` serves.
681
+ const preview = (yield* previewing(id))
682
+ ? Option.filter(yield* leaves.get(id), (head) => head.action !== "delete")
683
+ : Option.none();
684
+ const shown = Option.isSome(preview) ? preview.value : yield* visibleRow(viewer, id);
685
+ if (Option.isNone(localeLevel))
686
+ return yield* new NodeNotFound({ type, id });
687
+ // One read of the requested locales' rows, from the version the gate chose.
688
+ const wanted = Array.dedupe(locales);
689
+ const query = {
690
+ where: and(eq("id", id), localeIn(wanted)),
691
+ orderBy: [],
692
+ limit: Math.max(1, Math.min(wanted.length, MAX_PAGE_LIMIT)),
693
+ offset: 0,
694
+ };
695
+ const page = yield* Option.match(publication, {
696
+ onNone: () => leaves.list(query),
697
+ onSome: ({ live }) => Option.isSome(preview) || readsHeadOf(viewer, shown)
698
+ ? leaves.list(query)
699
+ : live.list({ ...query, headFor: never }),
700
+ });
701
+ // Defence in depth: each locale row passes the node's read gate itself,
702
+ // so a store that joined a locale row onto other access facts serves none.
703
+ const readable = Option.isSome(preview)
704
+ ? page.rows
705
+ : yield* Effect.filter(page.rows, (row) => visible(viewer, row));
706
+ const found = Array.findFirst(wanted, (tag) => Array.findFirst(readable, (row) => row[LOCALE_COLUMN] === tag).pipe(Option.map((row) => ({
707
+ locale: tag,
708
+ resource: toResource(row),
709
+ }))));
710
+ if (Option.isNone(found))
711
+ return yield* new NodeNotFound({ type, id });
712
+ return found.value;
713
+ });
714
+ /** The rows a viewer reads for these heads: the heads they read, and one live read for the rest. */
715
+ const chosenRows = (viewer, heads) => Option.match(publication, {
716
+ onNone: () => Effect.succeed(heads),
717
+ onSome: ({ live }) => {
718
+ const own = Array.filter(heads, (head) => readsHeadOf(viewer, head));
719
+ const others = Array.filter(heads, (head) => !readsHeadOf(viewer, head));
720
+ return Array.isReadonlyArrayNonEmpty(others)
721
+ ? Effect.map(live.getMany(Array.map(others, (head) => head.id)), (lives) => [...own, ...lives])
722
+ : Effect.succeed(own);
723
+ },
724
+ });
725
+ const getMany = Effect.fn(`${type}.getMany`)(function* (ids) {
726
+ if (!Array.isReadonlyArrayNonEmpty(ids))
727
+ return HashMap.empty();
728
+ const viewer = yield* definition.viewer;
729
+ const rows = yield* chosenRows(viewer, yield* leaves.getMany(Array.dedupe(ids)));
730
+ const shown = yield* Effect.filter(rows, (row) => visible(viewer, row));
731
+ return HashMap.fromIterable(Array.map(shown, (row) => [row.id, toResource(row)]));
732
+ });
733
+ /** The heads a viewer below the head-reading rungs reads: their own, on an owned leaf. */
734
+ const ownHeads = (viewer) => access.kind === "owned"
735
+ ? Option.match(viewer, {
736
+ onNone: () => never,
737
+ onSome: (account) => eq(access.ownerColumn, account.id),
738
+ })
739
+ : never;
740
+ const list = Effect.fn(`${type}.list`)(function* (request) {
741
+ const orderBy = yield* checkedOrder(type, isSortable, request.orderBy ?? []);
742
+ const viewer = yield* definition.viewer;
743
+ const where = searchScopeFilter({ access, columns: leaves.columns, policy, followers }, viewer, request);
744
+ const query = {
745
+ where,
746
+ orderBy,
747
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
748
+ offset: Math.max(0, Math.trunc(request.offset)),
749
+ };
750
+ // On a publish-gated leaf, a viewer below the head-reading rungs lists
751
+ // live rows, and their own rows at the head, as `get` reads them.
752
+ const page = yield* Option.match(publication, {
753
+ onNone: () => leaves.list(query),
754
+ onSome: ({ live }) => readsHeadByRung(viewer)
755
+ ? leaves.list(query)
756
+ : live.list({ ...query, headFor: ownHeads(viewer) }),
757
+ });
758
+ return { rows: page.rows.map(toResource), total: page.total };
759
+ });
760
+ /**
761
+ * A history read (`READ-009`): the viewer's {@link writer} gate on `id`,
762
+ * then `read`. `getVersion` and `compare` both run through it, so their
763
+ * gate is one code path and `read` never runs for a caller it refuses.
764
+ */
765
+ const historyRead = (id, read) => Effect.andThen(Effect.flatMap(definition.viewer, (viewer) => writer(viewer, id)), read);
766
+ const getVersion = Effect.fn(`${type}.getVersion`)(function* (id, version) {
767
+ return yield* historyRead(id, history.getVersion(id, version));
768
+ });
769
+ const compare = Effect.fn(`${type}.compare`)(function* (id, from, to) {
770
+ return yield* historyRead(id, history.compare(id, from, to));
771
+ });
772
+ const restoreVersion = Effect.fn(`${type}.restoreVersion`)(function* (id, version, precondition = {}) {
773
+ const account = yield* writer(yield* definition.viewer, id);
774
+ return yield* chain.restoreVersion(id, version, actor(account), precondition);
775
+ });
776
+ const publicResource = Effect.fn(`${type}.publicResource`)(function* (id) {
777
+ return yield* visibleRow(Option.none(), id).pipe(Effect.map((row) => Option.some(toResource(row))), Effect.catchTag("NodeNotFound", () => Effect.succeed(Option.none())));
778
+ });
779
+ const authorize = Effect.fn(`${type}.authorize`)(function* (id) {
780
+ return yield* writer(yield* definition.viewer, id);
781
+ });
782
+ /** A publication's before hooks: shown the head, an empty patch; the returned patch is ignored. */
783
+ const beforePublication = (action, id, viewer, account) => definition.hooks === undefined
784
+ ? Effect.void
785
+ : Effect.gen(function* () {
786
+ const { before } = yield* hooks;
787
+ if (!Array.isReadonlyArrayNonEmpty(before))
788
+ return;
789
+ const head = yield* definition.chain.head(id);
790
+ const prior = Option.isSome(head)
791
+ ? yield* definition.chain.snapshot(id, head.value.version)
792
+ : Option.none();
793
+ yield* runBefore(before, {
794
+ action,
795
+ id,
796
+ prior,
797
+ patch: {},
798
+ actorId: actor(account),
799
+ type,
800
+ viewer,
801
+ });
802
+ });
803
+ const publicationOps = Option.map(publication, ({ facts }) => ({
804
+ publish: Effect.fn(`${type}.publish`)(function* (id, options = {}) {
805
+ const viewer = yield* definition.viewer;
806
+ const account = yield* writer(viewer, id);
807
+ const head = yield* definition.chain.head(id);
808
+ if (Option.isNone(head) || head.value.action === "delete") {
809
+ return yield* new NodeNotFound({ type, id });
810
+ }
811
+ const version = options.version ?? head.value.version;
812
+ const snapshot = yield* definition.chain.snapshot(id, version);
813
+ if (Option.isNone(snapshot) || snapshot.value.action === "delete") {
814
+ return yield* new VersionNotFoundError({ type, id, version });
815
+ }
816
+ yield* beforePublication("publish", id, viewer, account);
817
+ const now = yield* nowDate;
818
+ const fact = {
819
+ id,
820
+ version: Option.some(version),
821
+ at: options.at ?? now,
822
+ actorId: actor(account),
823
+ recordedAt: now,
824
+ };
825
+ yield* facts.record(fact);
826
+ return { fact, head: head.value.version };
827
+ }),
828
+ unpublish: Effect.fn(`${type}.unpublish`)(function* (id) {
829
+ const viewer = yield* definition.viewer;
830
+ const account = yield* writer(viewer, id);
831
+ const head = yield* definition.chain.head(id);
832
+ if (Option.isNone(head))
833
+ return yield* new NodeNotFound({ type, id });
834
+ yield* beforePublication("unpublish", id, viewer, account);
835
+ const now = yield* nowDate;
836
+ const fact = {
837
+ id,
838
+ version: Option.none(),
839
+ at: now,
840
+ actorId: actor(account),
841
+ recordedAt: now,
842
+ };
843
+ yield* facts.record(fact);
844
+ return { fact, head: head.value.version };
845
+ }),
846
+ discardDraft: Effect.fn(`${type}.discardDraft`)(function* (id, precondition = {}) {
847
+ const account = yield* writer(yield* definition.viewer, id);
848
+ const live = Option.flatMap(yield* facts.effective(id), (fact) => fact.version);
849
+ if (Option.isNone(live))
850
+ return yield* new NotPublished({ type, id });
851
+ const head = yield* definition.chain.head(id);
852
+ if (Option.exists(head, (entry) => entry.version === live.value)) {
853
+ return { id, version: live.value };
854
+ }
855
+ return yield* chain.restoreVersion(id, live.value, actor(account), precondition);
856
+ }),
857
+ facts: Effect.fn(`${type}.publicationFacts`)(function* (id) {
858
+ return yield* historyRead(id, facts.facts(id, MAX_HISTORY_LIMIT));
859
+ }),
860
+ }));
861
+ return {
862
+ type,
863
+ access,
864
+ hooks,
865
+ publication: publicationOps,
866
+ publicResource,
867
+ authorize,
868
+ create,
869
+ update,
870
+ restore,
871
+ delete: softDelete,
872
+ get,
873
+ getLocalised,
874
+ owners,
875
+ getMany,
876
+ list,
877
+ getVersion,
878
+ compare,
879
+ restoreVersion,
880
+ };
881
+ };
882
+ const checkSearchProjection = (projection, columns) => {
883
+ const named = [
884
+ ...Array.map(projection.fields, (field) => field.column),
885
+ ...Option.toArray(projection.languageColumn),
886
+ ];
887
+ const undeclared = Array.findFirst(named, (column) => !columns.has(column));
888
+ if (Option.isSome(undeclared)) {
889
+ throw new SubtypeDeclarationError({
890
+ message: `search names '${undeclared.value}', which is not a column of the view`,
891
+ });
892
+ }
893
+ };
894
+ /**
895
+ * Declare a {@link SearchProjection}. Throws {@link SubtypeDeclarationError}
896
+ * at import, as a registry violation is, for no field, a column named twice
897
+ * or a weight outside `A`–`D`; {@link defineSubtype} checks the columns
898
+ * against the view.
899
+ *
900
+ * @example
901
+ * ```ts
902
+ * import { strictEqual } from "node:assert";
903
+ * import { defineSearchProjection } from "@thomasfosterau/pulpit/subtype";
904
+ *
905
+ * const posts = defineSearchProjection({
906
+ * fields: [
907
+ * { column: "title", weight: "A" },
908
+ * { column: "excerpt", weight: "B" },
909
+ * ],
910
+ * languageColumn: "language",
911
+ * });
912
+ * strictEqual(posts.fields.length, 2);
913
+ * ```
914
+ *
915
+ * @since 0.1.0
916
+ */
917
+ export const defineSearchProjection = (declaration) => {
918
+ if (!Array.isReadonlyArrayNonEmpty(declaration.fields)) {
919
+ throw new SubtypeDeclarationError({ message: "search declares no field" });
920
+ }
921
+ const repeated = Array.findFirst(declaration.fields, (field, index) => declaration.fields.slice(0, index).some((earlier) => earlier.column === field.column));
922
+ if (Option.isSome(repeated)) {
923
+ throw new SubtypeDeclarationError({
924
+ message: `search names '${repeated.value.column}' twice`,
925
+ });
926
+ }
927
+ const unweighted = Array.findFirst(declaration.fields, (field) => !Array.contains(["A", "B", "C", "D"], field.weight));
928
+ if (Option.isSome(unweighted)) {
929
+ throw new SubtypeDeclarationError({
930
+ message: `search weighs '${unweighted.value.column}' outside A–D`,
931
+ });
932
+ }
933
+ return {
934
+ fields: declaration.fields,
935
+ languageColumn: Option.fromNullishOr(declaration.languageColumn),
936
+ };
937
+ };
938
+ /**
939
+ * The read scope a search over one leaf must apply to its hits: exactly the
940
+ * filter {@link defineSubtype}'s `list` reads under (`list` calls this), the
941
+ * soft-delete arm, the access kind's privacy arm (owned visibility and draft
942
+ * through {@link ownedListFilter}; the reference privacy arm with its
943
+ * followers, `PUB-009`) and the family's own `where`, so a search can never
944
+ * surface a row `list` hides (`SEC-AUTH-ENUM-001`). The application renders
945
+ * it into its search query as the list store renders it into the list's. On
946
+ * a leaf with the live pointer ({@link SubtypeDefinition.publication}) the
947
+ * search matches over the rows {@link LiveView}'s `list` reads (the viewer's
948
+ * own at the head, every other node's live row), never every head, so a
949
+ * match never scores text a reader cannot see.
950
+ *
951
+ * @since 0.1.0
952
+ */
953
+ export const searchScopeFilter = (leaf, viewer, request) => {
954
+ const { access, columns, policy, followers } = leaf;
955
+ const privacy = access.kind === "owned"
956
+ ? ownedListFilter(access, columns, readScopeFor(viewer, policy, followers))
957
+ : referencePrivacyFilter(viewer, policy, columns, Option.some({ access, followers }));
958
+ return and(softDeleteFilter(viewer, policy, request.includeDeleted), privacy, request.where);
959
+ };
960
+ /**
961
+ * Hydrate ranked search hits through the leaf's gated `getMany`: one read
962
+ * for every hit, each re-checked against the viewer by the same predicate
963
+ * `get` applies, so a stale or poisoned index (a hit the search scope should
964
+ * have excluded, or one hidden since it was indexed) never leaks a row
965
+ * (`SEC-AUTH-ENUM-001`). The hits keep their rank order, a hidden or
966
+ * missing one is dropped, and a repeated id keeps its first, best rank.
967
+ * Fails with the store's `E`.
968
+ *
969
+ * @since 0.1.0
970
+ */
971
+ export const hydrateSearchHits = (ops, hits) => {
972
+ const ranked = Array.dedupeWith(hits, (a, b) => a.id === b.id);
973
+ return Effect.map(ops.getMany(Array.map(ranked, (hit) => hit.id)), (shown) => Array.flatMap(ranked, (hit) => Option.toArray(Option.map(HashMap.get(shown, hit.id), (resource) => ({ hit, resource })))));
974
+ };
975
+ /**
976
+ * The resource cache's write-through as an {@link AfterWriteHook}: a write
977
+ * whose writer re-read the resource sets it, and any other (a `delete`, or a
978
+ * write whose re-read failed or no longer shows the row) evicts it
979
+ * (`CACHE-AUTH-005`). {@link subtypeHandlers} runs it first, before the
980
+ * leaf's own after hooks, whenever its config has a cache. Never fails.
981
+ *
982
+ * @since 0.1.0
983
+ */
984
+ export const cacheWriteThrough = (cache) => ({
985
+ name: "cache",
986
+ run: ({ write, resource }) => Option.match(resource, {
987
+ onNone: () => cache.delete(write.id, write.version),
988
+ onSome: (value) => cache.set(write.id, value, write.version),
989
+ }),
990
+ });
991
+ /**
992
+ * Wrap a leaf's operations as its handler record. An owned leaf may still
993
+ * be cached — `CACHE-AUTH-005` is enforced by the cache's serveability
994
+ * policy (`./publication`'s `isPubliclyServeable` with `owned: true`), which
995
+ * only ever holds its public rows, and by the handlers' own eviction when a
996
+ * committed write can't be re-read or written through (see
997
+ * {@link SubtypeHandlers}). Each write fails with its operation's error, or
998
+ * with the re-read's unless that is `NodeNotFound` (a write hidden from its
999
+ * writer, answered as a {@link CommittedWrite} with no resource).
1000
+ *
1001
+ * @since 0.1.0
1002
+ */
1003
+ export const subtypeHandlers = (ops, config) => {
1004
+ const evict = ({ id, version }) => Option.match(config.cache, {
1005
+ onNone: () => Effect.void,
1006
+ onSome: (cache) => cache.delete(id, version),
1007
+ });
1008
+ // A write runs under `uninterruptibleMask`: the chain write (inside
1009
+ // `transact`) and the re-read stay interruptible through `restore`, but
1010
+ // the step between them, and the cache step after, cannot be skipped by
1011
+ // an interrupt, so a committed write always reaches its write-through or
1012
+ // eviction (`CACHE-AUTH-005`). Once the operation itself has succeeded
1013
+ // (its gate passed and the chain write ran), any later failure of the
1014
+ // boundary, an interrupt included, evicts: the write may have committed.
1015
+ // A request the gate refused never evicts, so a forbidden request can't
1016
+ // bust the cache. The flag is cleared at the start of each attempt, so a
1017
+ // `transact` that retries internally, rolling back an attempt that wrote,
1018
+ // evicts only when its last attempt wrote.
1019
+ const committed = (restore, operation) => Effect.flatMap(Ref.make(Option.none()), (wrote) => restore(config.transact(Effect.andThen(Ref.set(wrote, Option.none()), Effect.tap(operation, ({ id, version }) => Ref.set(wrote, Option.some({ id, version })))))).pipe(Effect.onError(() => Effect.flatMap(Ref.get(wrote), Option.match({ onNone: () => Effect.void, onSome: evict })))));
1020
+ /**
1021
+ * The cache write-through. On a publish-gated leaf the writer's re-read
1022
+ * is the head, which must never reach a public cache, so the cache is
1023
+ * given the resource as an anonymous reader gets it (the live version),
1024
+ * or evicts (`CACHE-AUTH-005`).
1025
+ */
1026
+ const cacheHook = (cache) => Option.isNone(ops.publication)
1027
+ ? cacheWriteThrough(cache)
1028
+ : {
1029
+ name: "cache",
1030
+ run: (event) => ops.publicResource(event.write.id).pipe(Effect.flatMap((resource) => cacheWriteThrough(cache).run({ ...event, resource })), Effect.catchCause(() => cache.delete(event.write.id, event.write.version))),
1031
+ };
1032
+ /**
1033
+ * The cache step inside an outer transaction: the write hasn't committed,
1034
+ * so evict at its version and cache nothing (pulpit#124).
1035
+ */
1036
+ const evictHook = (cache) => ({
1037
+ name: "cache",
1038
+ run: ({ write }) => cache.delete(write.id, write.version),
1039
+ });
1040
+ /**
1041
+ * The after hooks for one write whose `transact` has returned: the cache
1042
+ * step first (when the leaf has a cache; a write-through, or an eviction
1043
+ * inside an outer transaction), then the leaf's own, in order.
1044
+ */
1045
+ const after = (event) => Effect.flatMap(Effect.zip(ops.hooks, config.inTransaction), ([hooks, nested]) => runAfter({
1046
+ ...hooks,
1047
+ after: [
1048
+ ...Option.toArray(Option.map(config.cache, nested ? evictHook : cacheHook)),
1049
+ ...hooks.after,
1050
+ ],
1051
+ }, event));
1052
+ /**
1053
+ * A publication fact, recorded in `transact`, then the after hooks with
1054
+ * the resource as the writer now reads it.
1055
+ */
1056
+ const published = (action, operation) => Effect.uninterruptibleMask((restore) => Effect.tap(restore(config.transact(operation)), ({ fact, head }) => Effect.flatMap(Effect.option(restore(ops.get(fact.id))), (resource) => after({
1057
+ type: ops.type,
1058
+ action,
1059
+ write: { id: fact.id, version: head },
1060
+ resource,
1061
+ publication: Option.some(fact),
1062
+ }))));
1063
+ // The re-read runs as the writer, after the write committed, and its
1064
+ // outcome is the after hooks' `resource`. When it fails (the write hid
1065
+ // the row from its own writer: a corpus writer below the view-private
1066
+ // rung making a reference row `private`; a store error; an interrupt),
1067
+ // the resource is none and the cache hook evicts: the cache must never go
1068
+ // on serving the superseded resource, whatever the writer may read now
1069
+ // (`CACHE-AUTH-005`, pulpit#107). A `NodeNotFound` re-read is a write
1070
+ // hidden from its writer: it answers the committed write with no resource,
1071
+ // not a 404 for a write that committed. Any other failure fails the
1072
+ // handler with the re-read's own cause.
1073
+ const write = (action, operation) => Effect.uninterruptibleMask((restore) => Effect.flatMap(committed(restore, operation), (written) => Effect.flatMap(Effect.exit(restore(ops.get(written.id))), (reread) => Effect.andThen(after({
1074
+ type: ops.type,
1075
+ action,
1076
+ write: { id: written.id, version: written.version },
1077
+ resource: Exit.isSuccess(reread) ? Option.some(reread.value) : Option.none(),
1078
+ publication: Option.none(),
1079
+ }), reread.pipe(Effect.map(Option.some), Effect.catchTag("NodeNotFound", () => Effect.succeedNone), Effect.map((resource) => ({
1080
+ id: written.id,
1081
+ version: written.version,
1082
+ resource,
1083
+ })))))));
1084
+ return {
1085
+ list: ops.list,
1086
+ get: ops.get,
1087
+ create: (input) => write("create", ops.create(input)),
1088
+ update: (input) => write("update", ops.update(input)),
1089
+ restore: (input) => write("restore", ops.restore(input)),
1090
+ // An already tombstoned node wrote nothing, so it emits nothing; its
1091
+ // cache entry is still evicted, as every delete's is.
1092
+ delete: (id, precondition) => Effect.uninterruptibleMask((restore) => Effect.flatMap(committed(restore, ops.delete(id, precondition)), (written) => written.alreadyDeleted
1093
+ ? evict(written)
1094
+ : after({
1095
+ type: ops.type,
1096
+ action: "delete",
1097
+ write: { id: written.id, version: written.version },
1098
+ resource: Option.none(),
1099
+ publication: Option.none(),
1100
+ }))),
1101
+ restoreVersion: (input) => write("restoreVersion", ops.restoreVersion(input.id, input.version, { expectedVersion: input.expectedVersion })),
1102
+ publication: Option.map(ops.publication, (publication) => ({
1103
+ publish: (id, options) => published("publish", publication.publish(id, options)),
1104
+ unpublish: (id) => published("unpublish", publication.unpublish(id)),
1105
+ discardDraft: (id, precondition) => Effect.uninterruptibleMask((restore) => Effect.tap(committed(restore, publication.discardDraft(id, precondition)), (written) => Effect.flatMap(Effect.option(restore(ops.get(written.id))), (resource) => after({
1106
+ type: ops.type,
1107
+ action: "restoreVersion",
1108
+ write: written,
1109
+ resource,
1110
+ publication: Option.none(),
1111
+ })))),
1112
+ facts: publication.facts,
1113
+ })),
1114
+ };
1115
+ };
1116
+ /**
1117
+ * A leaf's {@link SubtypeHandlers} as effect-jsonapi's CRUD handlers
1118
+ * (`ResourceHandlers.make`), keyed by the endpoint names `Group.flat`
1119
+ * declares (pulpit#5). Each write goes through the record, so it keeps the
1120
+ * record's `transact` boundary, writer re-read, lifecycle hooks and cache
1121
+ * write-through (`CACHE-AUTH-005`); effect-jsonapi is given no `cache` and no
1122
+ * `transact` of its own, and its mutation tail re-reads the row once more
1123
+ * with `get` to assemble the document. `list` answers the page with the
1124
+ * unpaged total, and `delete` the 204. `restore` and `restoreVersion` have no
1125
+ * JSON:API endpoint and stay on the record. The handlers fail with the
1126
+ * record's errors (and effect-jsonapi's `Conflict` for an `update` whose
1127
+ * payload id is not the route's, before any operation runs); mapping them to
1128
+ * the wire is the application's `ServerErrors`.
1129
+ *
1130
+ * A `create` / `update` whose committed write hides the row from its writer
1131
+ * (the re-read fails `NodeNotFound`) answers effect-jsonapi's committed
1132
+ * identifier document, `{ data: { type, id, meta: { version } } }` at the
1133
+ * endpoint's own status, not a 404 (effect-jsonapi's `hiddenWrite`,
1134
+ * pulpit#107). Declare the group with `Group.flat({ …, hiddenWrites: true })`
1135
+ * so its `create` / `update` success admits that document.
1136
+ *
1137
+ * @since 0.1.0
1138
+ */
1139
+ export const subtypeResourceHandlers = (handlers, config) => {
1140
+ const { type, listRequest, createInput, updateInput, ...rest } = config;
1141
+ return ResourceHandlers.make({
1142
+ ...rest,
1143
+ hiddenWrite: { type, when: (error) => Predicate.isTagged(error, "NodeNotFound") },
1144
+ ops: {
1145
+ list: (query) => Effect.map(handlers.list(listRequest(query)), (page) => ({
1146
+ items: page.rows,
1147
+ totalCount: page.total,
1148
+ })),
1149
+ get: handlers.get,
1150
+ // The committed version rides along: a write hidden from its writer
1151
+ // answers it as `data.meta.version` (pulpit#107).
1152
+ create: (payload) => Effect.map(handlers.create(createInput(payload)), ({ id, version }) => ({ id, version })),
1153
+ update: (payload) => Effect.map(handlers.update(updateInput(payload)), ({ id, version }) => ({ id, version })),
1154
+ delete: (id) => handlers.delete(id),
1155
+ },
1156
+ });
1157
+ };
1158
+ /** The names a view keeps for itself: no translated column may take one. */
1159
+ const RESERVED_COLUMNS = HashSet.make("id", "version", "action", LOCALE_COLUMN);
1160
+ /**
1161
+ * The columns a leaf's read gate reads (pulpit#56): none of them may be
1162
+ * translated, so every locale row carries the node's one owner, visibility,
1163
+ * draft and moderation state.
1164
+ */
1165
+ const accessColumns = (access) => access.kind === "owned"
1166
+ ? [
1167
+ access.ownerColumn,
1168
+ access.visibilityColumn,
1169
+ ...Option.toArray(access.draftColumn),
1170
+ ...Option.toArray(access.moderationColumn),
1171
+ ]
1172
+ : ["visibility", ...(access.followersOfColumn === undefined ? [] : [access.followersOfColumn])];
1173
+ /** The columns a live view stamps on a publish-gated leaf's rows. */
1174
+ const PUBLICATION_COLUMNS = ["draft", "publishedAt"];
1175
+ const checkLocaleLevel = (level, columns, gated) => {
1176
+ const gate = Array.findFirst(level.columns, (column) => gated.includes(column));
1177
+ if (Option.isSome(gate)) {
1178
+ throw new SubtypeDeclarationError({
1179
+ message: `locales names '${gate.value}', which the read gate reads: it must be shared`,
1180
+ });
1181
+ }
1182
+ const undeclared = Array.findFirst([...level.columns, LOCALE_COLUMN], (column) => !columns.has(column));
1183
+ if (Option.isSome(undeclared)) {
1184
+ throw new SubtypeDeclarationError({
1185
+ message: `locales names '${undeclared.value}', which is not a column of the view`,
1186
+ });
1187
+ }
1188
+ };
1189
+ /** The keys of a patch that set or clear a value (an `undefined` key carries forward). */
1190
+ const writtenKeys = (patch) => Array.filter(Record.keys(patch), (key) => patch[key] !== undefined);
1191
+ /**
1192
+ * Why a write breaks the locale level, or none: `translated` is the leaf's
1193
+ * declared columns (none for a leaf with no locale level).
1194
+ */
1195
+ const localeViolation = (translated, state, locales) => {
1196
+ const tags = writtenKeys(locales);
1197
+ if (Option.isNone(translated)) {
1198
+ return Array.isReadonlyArrayNonEmpty(tags)
1199
+ ? Option.some("the leaf declares no locale level")
1200
+ : Option.none();
1201
+ }
1202
+ const isTranslated = (column) => translated.value.includes(column);
1203
+ const shared = Array.findFirst(writtenKeys(state), isTranslated);
1204
+ if (Option.isSome(shared)) {
1205
+ return Option.some(`'${shared.value}' is translated: write it per locale`);
1206
+ }
1207
+ if (tags.some(Str.isEmpty))
1208
+ return Option.some("a locale tag is empty");
1209
+ return Array.findFirst(tags, (tag) => Option.fromNullishOr(locales[tag]).pipe(Option.flatMap((change) => Array.findFirst(writtenKeys(change), (column) => !isTranslated(column))), Option.map((column) => `locale '${tag}' names '${column}', a shared column: write it once, for every locale`)));
1210
+ };
1211
+ /**
1212
+ * Declare a {@link LocaleLevel}. Throws {@link SubtypeDeclarationError} at
1213
+ * import, as a registry violation is, for no column, a column named twice or
1214
+ * one the view keeps for itself (`id`, `version`, `action`,
1215
+ * {@link LOCALE_COLUMN}); {@link defineSubtype} checks the columns against
1216
+ * the view.
1217
+ *
1218
+ * @example
1219
+ * ```ts
1220
+ * import { deepStrictEqual } from "node:assert";
1221
+ * import { defineLocaleLevel } from "@thomasfosterau/pulpit/subtype";
1222
+ *
1223
+ * const pages = defineLocaleLevel({ columns: ["title", "body", "slug"] });
1224
+ * deepStrictEqual(pages.columns, ["title", "body", "slug"]);
1225
+ * ```
1226
+ *
1227
+ * @since 0.1.0
1228
+ */
1229
+ export const defineLocaleLevel = (declaration) => {
1230
+ if (!Array.isReadonlyArrayNonEmpty(declaration.columns)) {
1231
+ throw new SubtypeDeclarationError({ message: "locales declares no column" });
1232
+ }
1233
+ const repeated = Array.findFirst(declaration.columns, (column, index) => declaration.columns.slice(0, index).includes(column));
1234
+ if (Option.isSome(repeated)) {
1235
+ throw new SubtypeDeclarationError({ message: `locales names '${repeated.value}' twice` });
1236
+ }
1237
+ const reserved = Array.findFirst(declaration.columns, (column) => HashSet.has(RESERVED_COLUMNS, column));
1238
+ if (Option.isSome(reserved)) {
1239
+ throw new SubtypeDeclarationError({
1240
+ message: `locales names '${reserved.value}', which the view keeps for itself`,
1241
+ });
1242
+ }
1243
+ return { columns: [...declaration.columns] };
1244
+ };
1245
+ // ── Owners across leaves ──────────────────────────────────────────────
1246
+ /**
1247
+ * {@link readableOwners} refused a union it could not make exact: a leaf
1248
+ * has more readable owners than one read returns ({@link MAX_PAGE_LIMIT}),
1249
+ * so the union and its total would be short (`SEC-INPUT-003`).
1250
+ *
1251
+ * @since 0.1.0
1252
+ */
1253
+ export class OwnerSetTooLarge extends Schema.TaggedError()("OwnerSetTooLarge", {
1254
+ type: Schema.String,
1255
+ }) {
1256
+ }
1257
+ /**
1258
+ * The accounts that own at least one row the viewer may list in any of
1259
+ * `leaves` (pulpit#186): the union of each leaf's
1260
+ * {@link SubtypeOps.owners}, deduplicated in code-point order, paged by
1261
+ * `request`, with the exact union size as `total`. One read per leaf, each
1262
+ * for every owner it has (up to {@link MAX_PAGE_LIMIT}): a leaf with more
1263
+ * fails {@link OwnerSetTooLarge} rather than answer a short union. With
1264
+ * `among` (at most `MAX_SET_VALUES` ids) no leaf can exceed it. A hidden row
1265
+ * never contributes its owner (`SEC-AUTH-ENUM-001`). Fails otherwise only
1266
+ * with the stores' `E`.
1267
+ *
1268
+ * @example
1269
+ * ```ts
1270
+ * import { deepStrictEqual } from "node:assert";
1271
+ * import * as Effect from "effect/Effect";
1272
+ * import { readableOwners } from "@thomasfosterau/pulpit/subtype";
1273
+ *
1274
+ * const leaf = (type: string, owners: ReadonlyArray<string>) => ({
1275
+ * type,
1276
+ * owners: () => Effect.succeed({ rows: owners, total: owners.length }),
1277
+ * });
1278
+ * const page = Effect.runSync(
1279
+ * readableOwners([leaf("posts", ["ana", "bo"]), leaf("pages", ["bo", "cy"])], {
1280
+ * limit: 10,
1281
+ * offset: 0,
1282
+ * includeDeleted: false,
1283
+ * }),
1284
+ * );
1285
+ * deepStrictEqual(page, { rows: ["ana", "bo", "cy"], total: 3 });
1286
+ * ```
1287
+ *
1288
+ * @since 0.1.0
1289
+ */
1290
+ export const readableOwners = (leaves, request) => Effect.gen(function* () {
1291
+ const every = { ...request, limit: MAX_PAGE_LIMIT, offset: 0 };
1292
+ const pages = yield* Effect.forEach(leaves, (leaf) => Effect.flatMap(leaf.owners(every), (page) => page.total > page.rows.length
1293
+ ? Effect.fail(new OwnerSetTooLarge({ type: leaf.type }))
1294
+ : Effect.succeed(page.rows)), { concurrency: 1 });
1295
+ const union = Arr.sort(Array.dedupe(Array.flatten(pages)), byCodePoint);
1296
+ const limit = Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT));
1297
+ const offset = Math.max(0, Math.trunc(request.offset));
1298
+ return { rows: union.slice(offset, offset + limit), total: union.length };
1299
+ });
1300
+ //# sourceMappingURL=subtype.js.map