@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
package/src/edges.ts ADDED
@@ -0,0 +1,1185 @@
1
+ /**
2
+ * **The edge operations factory** — a relation between nodes as its own
3
+ * family: a bounded list, a read by id, an idempotent create and a hard
4
+ * delete, gated by the visibility of the nodes the edge joins.
5
+ *
6
+ * The behavioural spec is gardeneria's `apps/api/src/operations/edge-ops.ts`
7
+ * (`defineEdgeOpsE`), which exists because hand-rolled edge families drift,
8
+ * and the ninth family's viewer gate or `LIMIT` gets forgotten. Its
9
+ * hand-rolled owned-child family (`disorders.ts`, the observation → disorder
10
+ * edge) is the spec for the endpoint gates. A `#NNN` here names a gardeneria
11
+ * issue.
12
+ *
13
+ * ## What an edge is
14
+ *
15
+ * An edge is a plain row in its own table: an id, a `createdAt`, one column
16
+ * per **end** holding the id of the node at that end, and any qualifier
17
+ * columns of the family's own. It is **not** a node: it has no version chain,
18
+ * so the chain invariants (`VERSION-CHAIN-001/002`) do not govern it. A delete
19
+ * is a hard `DELETE`, and there is no update: correcting an edge is deleting
20
+ * it and asserting the right one. A family declares zero, one or two (or
21
+ * more) ends; with none, it is a plain bounded row family (gardeneria's
22
+ * `page_collections`).
23
+ *
24
+ * ## The invariants
25
+ *
26
+ * - **An edge is readable only if the viewer can read every end**
27
+ * (`EDGE-001`). Each end's node is checked against its own leaf's access
28
+ * declaration, as that leaf's own `get` and list filter read it (a
29
+ * reference leaf's followers arm included, pulpit#92). `get` answers a
30
+ * hidden edge as {@link EdgeNotFound} and `list` omits it, never `Forbidden`
31
+ * (`SEC-AUTH-ENUM-001`); a related list (the edges at one node) is a `list`
32
+ * filtered on that end, so it omits every edge whose other end is hidden.
33
+ * - **The list filter and the per-edge predicate never disagree.**
34
+ * {@link endpointFilter} renders the same facts {@link canViewEndpoint}
35
+ * reads, over the ends' joined views; `tests/edges.test.ts` checks every
36
+ * endpoint state × viewer.
37
+ * - **A tombstoned end hides its edges** from every viewer below the
38
+ * view-deleted rung. That rung reads a tombstoned end as the leaf's own
39
+ * `get` reads the tombstone, by its access rule (pulpit#93), so it can
40
+ * list, read and delete an edge whose only hidden end is tombstoned; an
41
+ * end hidden for any other reason (a `private` node below the
42
+ * view-private rung, say) still hides the edge from it. Nothing is
43
+ * deleted: restoring the node (the explicit `restore`,
44
+ * `VERSION-CHAIN-006`) brings its edges back as they were.
45
+ * - **A create names only live nodes the viewer can read.** An end naming a
46
+ * missing, hidden or tombstoned node fails `NodeNotFound` (the
47
+ * view-deleted rung included), so a create cannot confirm a node exists.
48
+ * - **Cardinality is declared, and a create past it fails**
49
+ * ({@link EdgeCardinalityExceeded}). An end's `maxEdges` bounds how many
50
+ * edges may share one node at that end, counting only the edges the
51
+ * caller can read whose every end is live (pulpit#77, pulpit#93). A
52
+ * hidden edge never counts, so a refusal discloses nothing the caller
53
+ * can't read; the cost is a limit that holds only over what each caller
54
+ * sees, and a restore that can bring a node past it (see
55
+ * {@link EdgeEnd.maxEdges}). A re-assertion of a stored edge resolves
56
+ * before the count, so it stays idempotent at a full node.
57
+ * - **A write discloses no hidden edge's state.** A create runs in the
58
+ * order ends → write gate → unique-key collision → cardinality count.
59
+ * Under `"fail"` a collision with a hidden edge answers exactly as one
60
+ * with a readable edge; under `"ignore"` only a readable, live edge
61
+ * resolves, and any other collision is {@link EdgeConflict}. The count
62
+ * reads only readable edges (README → `./edges` → What a write
63
+ * discloses).
64
+ * - **Every list is bounded** (`SEC-INPUT-003`): a page is capped at
65
+ * `MAX_PAGE_LIMIT`.
66
+ *
67
+ * ## The seam
68
+ *
69
+ * The edges are read and written through an {@link EdgeStore} the
70
+ * application supplies (a Drizzle store over the edge table, joining each
71
+ * end's current-version view; see the package README → Seams). Each end
72
+ * reads its nodes through an {@link EndpointView}, which a `./subtype`
73
+ * `LeafStore` already satisfies.
74
+ *
75
+ * @copyright Thomas Foster
76
+ * @module pulpit/edges
77
+ * @since 0.1.0
78
+ */
79
+
80
+ import * as Arr from "effect/Array";
81
+ import * as Effect from "effect/Effect";
82
+ import * as Formatter from "effect/Formatter";
83
+ import * as HashMap from "effect/HashMap";
84
+ import * as Option from "effect/Option";
85
+ import * as Order from "effect/Order";
86
+ import * as Predicate from "effect/Predicate";
87
+ import * as Record from "effect/Record";
88
+ import * as Schema from "effect/Schema";
89
+ import {
90
+ always,
91
+ and,
92
+ anyOf,
93
+ eq,
94
+ gt,
95
+ isIn,
96
+ isNull,
97
+ lt,
98
+ lte,
99
+ isNotIn,
100
+ MAX_SET_VALUES,
101
+ never,
102
+ or,
103
+ type ListFilter,
104
+ } from "./filter.ts";
105
+ import { renameColumns } from "./internal/filter-columns.ts";
106
+ import { canViewOwnedRow, declaresColumns, ownedFacts } from "./internal/owned-columns.ts";
107
+ import { canViewReferenceRow, referenceListFilter } from "./internal/reference-columns.ts";
108
+ import {
109
+ Forbidden,
110
+ MAX_PAGE_LIMIT,
111
+ ownedListFilter,
112
+ readScopeFor,
113
+ Unauthorized,
114
+ type AccessPolicy,
115
+ type FollowerSeam,
116
+ type LeafAccess,
117
+ type LeafRow,
118
+ type Page,
119
+ type Viewer,
120
+ type ViewerAccount,
121
+ } from "./subtype.ts";
122
+ import { NodeNotFound, type ColumnValue } from "./version-chain.ts";
123
+
124
+ /** A column value as text for an error: a string as itself, anything else formatted (never `[object Object]`). */
125
+ const columnText = (value: ColumnValue): string =>
126
+ Predicate.isString(value) ? value : Formatter.format(value);
127
+
128
+ // ── Errors ────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * No edge with this id that the viewer may read: missing, or hidden because
132
+ * an end is (`SEC-AUTH-ENUM-001`, `EDGE-001`). The wire's 404.
133
+ *
134
+ * @since 0.1.0
135
+ */
136
+ export class EdgeNotFound extends Schema.TaggedError<
137
+ EdgeNotFound,
138
+ { readonly brand: unique symbol }
139
+ >()("EdgeNotFound", {
140
+ type: Schema.String,
141
+ id: Schema.String,
142
+ }) {}
143
+
144
+ /**
145
+ * A create collided with a stored edge on the family's unique key, and the
146
+ * family's conflict policy does not resolve it: `onConflict: "fail"`, or the
147
+ * edge it collided with is one the viewer may not read or has a tombstoned
148
+ * end. The wire's 409. It carries only the family's type, so under `"fail"`
149
+ * a collision with a hidden edge is the same answer as one with a readable
150
+ * edge (`SEC-AUTH-ENUM-001`, pulpit#93); under `"ignore"` only a readable,
151
+ * live edge resolves, and any other collision is this error. It says the
152
+ * key is taken, never by which edge.
153
+ *
154
+ * @since 0.1.0
155
+ */
156
+ export class EdgeConflict extends Schema.TaggedError<
157
+ EdgeConflict,
158
+ { readonly brand: unique symbol }
159
+ >()("EdgeConflict", {
160
+ type: Schema.String,
161
+ }) {}
162
+
163
+ /**
164
+ * A create would put more edges at one node than its end's declared
165
+ * `maxEdges` allows, counting only the edges the caller can read whose every
166
+ * end is live ({@link EdgeEnd.maxEdges}). The wire's 409.
167
+ *
168
+ * @since 0.1.0
169
+ */
170
+ export class EdgeCardinalityExceeded extends Schema.TaggedError<
171
+ EdgeCardinalityExceeded,
172
+ { readonly brand: unique symbol }
173
+ >()("EdgeCardinalityExceeded", {
174
+ /** The edge family's wire type. */
175
+ type: Schema.String,
176
+ /** The end (its alias in the declaration) whose limit was reached. */
177
+ end: Schema.String,
178
+ /** The node at that end, which already holds `max` edges the caller can read. */
179
+ nodeId: Schema.String,
180
+ /** The declared limit. */
181
+ max: Schema.Int,
182
+ }) {}
183
+
184
+ /**
185
+ * An edge family declared against the rules: an end's alias contains a `.`,
186
+ * or its `maxEdges` is not a positive safe integer. Thrown by
187
+ * {@link defineEdgeOps} when the family is declared, never an Effect failure,
188
+ * like `RegistryDeclarationError`: a malformed family must stop its module
189
+ * loading rather than fail open (a `NaN` limit admits every create) or
190
+ * refuse every create (a limit of `0`).
191
+ *
192
+ * @since 0.1.0
193
+ */
194
+ export class EdgeDeclarationError extends Schema.TaggedError<
195
+ EdgeDeclarationError,
196
+ { readonly brand: unique symbol }
197
+ >()("EdgeDeclarationError", { message: Schema.String }) {}
198
+
199
+ // ── The ends ──────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * An end's view of its nodes: the endpoint leaf's current-version view
203
+ * column set and its read by id. A `./subtype` `LeafStore` satisfies it, so
204
+ * an end usually passes the endpoint leaf's own store.
205
+ *
206
+ * @since 0.1.0
207
+ */
208
+ export interface EndpointView<E, R> {
209
+ /** The endpoint view's column names; an owned declaration naming a column outside it hides every edge. */
210
+ readonly columns: ReadonlySet<string>;
211
+ /** One node of the current-version view by id, or none. Applies no visibility policy. */
212
+ readonly get: (id: string) => Effect.Effect<Option.Option<LeafRow>, E, R>;
213
+ }
214
+
215
+ /**
216
+ * One end of an edge family: the edge column holding the node id, and how
217
+ * that node is read and gated.
218
+ *
219
+ * @since 0.1.0
220
+ */
221
+ export interface EdgeEnd<E, R> {
222
+ /** The edge column holding this end's node id. */
223
+ readonly column: string;
224
+ /** The endpoint leaf's wire type: names the `NodeNotFound` a create naming a missing or hidden node fails with. */
225
+ readonly type: string;
226
+ /** The endpoint leaf's access declaration, the one its own `defineSubtype` takes. */
227
+ readonly access: LeafAccess;
228
+ /** The endpoint leaf's current-version view. */
229
+ readonly nodes: EndpointView<E, R>;
230
+ /**
231
+ * The most edges one node at this end may hold: a positive safe integer,
232
+ * or {@link defineEdgeOps} throws {@link EdgeDeclarationError}. Absent,
233
+ * unlimited. A create that would exceed it fails
234
+ * {@link EdgeCardinalityExceeded}.
235
+ *
236
+ * A create counts only the edges at the node that the caller can read
237
+ * (`EDGE-001`) and whose every end is live (owner decisions on pulpit#77
238
+ * and pulpit#93), which has three consequences:
239
+ *
240
+ * - **No disclosure.** A hidden edge never counts, so a refusal depends
241
+ * only on edges the caller can read: it can't tell a node holding hidden
242
+ * edges from one holding none.
243
+ * - **A tombstoned partner frees its slot.** An edge whose other end is
244
+ * tombstoned stops counting at once, so retiring a term in a
245
+ * single-choice taxonomy doesn't freeze the nodes it classified.
246
+ * - **The limit is per caller, and can be overshot.** A node may hold
247
+ * more than `maxEdges` stored edges: edges a writer can't read don't
248
+ * count against that writer, and a `restore` of a tombstoned partner
249
+ * brings its edge back without a check (a restore is a chain write and
250
+ * reads no edge). An over-limit node keeps its edges; the next create
251
+ * at it fails until enough are deleted. Only a caller who can read
252
+ * every edge at the node (the view-private rung, typically) is held to
253
+ * the limit over all of them.
254
+ */
255
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions), so a caller can forward its own `undefined` under `exactOptionalPropertyTypes`; the omitted key is the absence, read as such, so there is no value to model as an `Option`
256
+ readonly maxEdges?: number | undefined;
257
+ }
258
+
259
+ /**
260
+ * A family's ends, keyed by alias. The alias is how a list filter names an
261
+ * end's node columns ({@link endpointColumn}), the name under which the store
262
+ * joins that end's view, and the `end` an {@link EdgeCardinalityExceeded}
263
+ * reports. An alias never contains a `.` ({@link defineEdgeOps} throws
264
+ * {@link EdgeDeclarationError} on one), because `alias.column` must name one
265
+ * end's column unambiguously.
266
+ *
267
+ * @since 0.1.0
268
+ */
269
+ export type EdgeEnds<E, R> = { readonly [alias: string]: EdgeEnd<E, R> };
270
+
271
+ /**
272
+ * The list-filter column naming `column` of the node at end `alias`:
273
+ * `alias.column`. {@link endpointFilter} writes these, a family's own list
274
+ * filter may too, and a store renders each against the view it joined under
275
+ * that alias.
276
+ *
277
+ * @since 0.1.0
278
+ */
279
+ export const endpointColumn = (alias: string, column: string): string => `${alias}.${column}`;
280
+
281
+ /** Every column of `filter` rewritten to name the node at end `alias`. */
282
+ const qualify = <Following>(alias: string, filter: ListFilter<Following>): ListFilter<Following> =>
283
+ renameColumns(filter, (column) => endpointColumn(alias, column));
284
+
285
+ /** A live node: its head is a `create` or an `update`, never a tombstone. */
286
+ const isLive = (row: LeafRow): boolean => row.action === "create" || row.action === "update";
287
+
288
+ /** {@link isLive} as a list filter over one view's columns. */
289
+ const liveFilter = <Following>(): ListFilter<Following> =>
290
+ or<Following>(eq("action", "create"), eq("action", "update"));
291
+
292
+ /**
293
+ * Whether the viewer may read the node at one end (`EDGE-001`): it exists,
294
+ * it is live or the viewer holds the view-deleted rung (a tombstone is read
295
+ * as the leaf's own `get` reads it, pulpit#93), and its leaf's access admits
296
+ * the viewer, a tombstone's included. A reference node is
297
+ * readable when its view has no `visibility` column, the viewer holds the
298
+ * view-private rung, it is `public`, or it is `followers` and the viewer is,
299
+ * or follows, the account its leaf's declared `followersOfColumn` names (the
300
+ * predicate `defineSubtype`'s `get` runs, pulpit#92); an owned node through
301
+ * `canViewOwned`'s predicate, where an owner column holding no string reads
302
+ * as no owner, matching no viewer and never asked of the follower seam. An
303
+ * owned declaration naming a column the view lacks reads nothing. Never
304
+ * fails.
305
+ *
306
+ * @since 0.1.0
307
+ */
308
+ export const canViewEndpoint = <E, R>(
309
+ end: EdgeEnd<E, R>,
310
+ viewer: Viewer,
311
+ policy: AccessPolicy,
312
+ followers: FollowerSeam<unknown, R>,
313
+ node: Option.Option<LeafRow>,
314
+ ): Effect.Effect<boolean, never, R> => {
315
+ if (Option.isNone(node)) return Effect.succeed(false);
316
+ if (!isLive(node.value) && !Option.exists(viewer, policy.canViewDeleted)) {
317
+ return Effect.succeed(false);
318
+ }
319
+ const { access } = end;
320
+ const { columns } = end.nodes;
321
+ if (access.kind === "reference") {
322
+ return canViewReferenceRow(access, columns, viewer, policy, followers, node.value);
323
+ }
324
+ if (!declaresColumns(access, columns)) return Effect.succeed(false);
325
+ return canViewOwnedRow(viewer, policy, followers, ownedFacts(access, node.value));
326
+ };
327
+
328
+ /**
329
+ * The list arm of `EDGE-001`: for every end, its node is live (or the viewer
330
+ * holds the view-deleted rung) and readable by the viewer, over the columns
331
+ * {@link endpointColumn} names. The same facts as {@link canViewEndpoint},
332
+ * rendered as a `ListFilter` — the owned arm is `ownedListFilter`, the
333
+ * reference arm `referencePrivacyFilter`'s, followers arm included
334
+ * (pulpit#92) — so the two never disagree. A
335
+ * family with no ends gets `always`.
336
+ *
337
+ * @since 0.1.0
338
+ */
339
+ export const endpointFilter = <Following, E, R>(
340
+ ends: EdgeEnds<E, R>,
341
+ viewer: Viewer,
342
+ policy: AccessPolicy,
343
+ followers: FollowerSeam<Following, unknown>,
344
+ ): ListFilter<Following> => {
345
+ const scope = readScopeFor(viewer, policy, followers);
346
+ const lifecycle = Option.exists(viewer, policy.canViewDeleted) ? always : liveFilter<Following>();
347
+ return and<Following>(
348
+ ...Record.toEntries(ends).map(([alias, end]) =>
349
+ qualify(
350
+ alias,
351
+ and<Following>(
352
+ lifecycle,
353
+ end.access.kind === "owned"
354
+ ? ownedListFilter(end.access, end.nodes.columns, scope)
355
+ : referenceListFilter(end.access, end.nodes.columns, scope),
356
+ ),
357
+ ),
358
+ ),
359
+ );
360
+ };
361
+
362
+ /** Every end's node is live, over the columns {@link endpointColumn} names: the cardinality count's other arm. */
363
+ const liveEndsFilter = <Following, E, R>(ends: EdgeEnds<E, R>): ListFilter<Following> =>
364
+ and<Following>(
365
+ ...Record.toEntries(ends).map(([alias]) => qualify(alias, liveFilter<Following>())),
366
+ );
367
+
368
+ // ── The write gate ────────────────────────────────────────────────────
369
+
370
+ /**
371
+ * What a write gate is asked about: the viewer, the application's role axes,
372
+ * and the edge being written (a create's column values, or a delete's stored
373
+ * row).
374
+ *
375
+ * @since 0.1.0
376
+ */
377
+ export interface EdgeWriteRequest {
378
+ /** The request's viewer. */
379
+ readonly viewer: Viewer;
380
+ /** The application's role axes. */
381
+ readonly policy: AccessPolicy;
382
+ /** The edge's column values: the create's `values(input)`, or the stored row a delete removes. */
383
+ readonly edge: EdgeValues;
384
+ }
385
+
386
+ /**
387
+ * The gate `create` and `delete` run after the ends' visibility check. It
388
+ * fails `Unauthorized` / `Forbidden`, or `NodeNotFound` when it reads an end
389
+ * (an owned-child gate on a node with no owner), or with the store's `E`.
390
+ *
391
+ * @since 0.1.0
392
+ */
393
+ export type EdgeWriteGate<E, R> = (
394
+ request: EdgeWriteRequest,
395
+ ) => Effect.Effect<unknown, Unauthorized | Forbidden | NodeNotFound | E, R>;
396
+
397
+ /**
398
+ * A gate on one rung of the role axes: anonymous fails `Unauthorized`, a
399
+ * signed-in viewer below the rung `Forbidden`. `rungGate((policy) =>
400
+ * policy.canModerate)` is the moderator rung gardeneria's CMS edges take.
401
+ *
402
+ * @since 0.1.0
403
+ */
404
+ export const rungGate =
405
+ (rung: (policy: AccessPolicy) => (account: ViewerAccount) => boolean) =>
406
+ (request: EdgeWriteRequest): Effect.Effect<ViewerAccount, Unauthorized | Forbidden> =>
407
+ Option.match(request.viewer, {
408
+ onNone: () => Effect.fail(new Unauthorized()),
409
+ onSome: (account) =>
410
+ rung(request.policy)(account) ? Effect.succeed(account) : Effect.fail(new Forbidden()),
411
+ });
412
+
413
+ /**
414
+ * The default write gate: the corpus-write rung. Right for an edge joining
415
+ * reference rows, which is itself reference data.
416
+ *
417
+ * @since 0.1.0
418
+ */
419
+ export const corpusWriteGate = rungGate((policy) => policy.canWriteCorpus);
420
+
421
+ // ── The store seam ────────────────────────────────────────────────────
422
+
423
+ /**
424
+ * An edge's column values by name, as a create writes them.
425
+ *
426
+ * @since 0.1.0
427
+ */
428
+ export type EdgeValues = { readonly [column: string]: ColumnValue };
429
+
430
+ /**
431
+ * A stored edge: its id, its creation instant (the list's order), and its columns.
432
+ *
433
+ * @since 0.1.0
434
+ */
435
+ export interface EdgeRow {
436
+ readonly id: string;
437
+ readonly createdAt: Date;
438
+ readonly [column: string]: ColumnValue;
439
+ }
440
+
441
+ /**
442
+ * The persistence seam over one edge table. `E` is the store's own failure,
443
+ * `R` its requirements (a Drizzle store's `CurrentTransaction`: like the
444
+ * chain store, it never opens a transaction). Every method applies no policy.
445
+ *
446
+ * @since 0.1.0
447
+ */
448
+ export interface EdgeStore<Row extends EdgeRow, Following, E, R> {
449
+ /** One edge by id, or none (`LIMIT 1`). */
450
+ readonly get: (id: string) => Effect.Effect<Option.Option<Row>, E, R>;
451
+ /**
452
+ * The page under `where`, with the unpaged total. Each end's
453
+ * current-version view is INNER JOINed on `view.id = edge.<end column>`
454
+ * under the end's alias, so `alias.column` names that view's column and a
455
+ * bare column the edge table's; an edge whose end has no row in its view
456
+ * is never listed. Ordered by `createdAt`, then `id` (a total order, so a
457
+ * page cursor cannot skip or repeat a row, #1231). The total is a window
458
+ * column (`count(*) OVER ()`): exact whenever the page has a row, and `0`
459
+ * for an empty page, a page past the end included.
460
+ */
461
+ readonly list: (query: {
462
+ readonly where: ListFilter<Following>;
463
+ readonly limit: number;
464
+ readonly offset: number;
465
+ }) => Effect.Effect<Page<Row>, E, R>;
466
+ /**
467
+ * How many stored edges hold `nodeId` in `column` and satisfy `where`,
468
+ * over the same joins as {@link EdgeStore.list} (so `where` reads
469
+ * `alias.column` exactly as a list's does). The factory passes the
470
+ * viewer's `EDGE-001` filter and every end's liveness, so only edges the
471
+ * caller can read with live ends are counted (pulpit#77, pulpit#93).
472
+ * Called inside the create's transaction, for the cardinality check, once
473
+ * per bounded end in ascending `nodeId` order. A SQL store locks the node
474
+ * first (`FOR NO KEY UPDATE`, under READ COMMITTED), so two creates at one
475
+ * node serialise, and the sorted order means two creates locking the same
476
+ * pair of nodes cannot deadlock (README → Seams).
477
+ */
478
+ readonly countAt: (query: {
479
+ readonly column: string;
480
+ readonly nodeId: string;
481
+ readonly where: ListFilter<Following>;
482
+ }) => Effect.Effect<number, E, R>;
483
+ /** The id of the stored edge `values` would collide with on the family's unique key, or none (always none without one). */
484
+ readonly existing: (values: EdgeValues) => Effect.Effect<Option.Option<string>, E, R>;
485
+ /** Insert the edge (`ON CONFLICT DO NOTHING RETURNING id`): its new id, or none when the unique key was taken. */
486
+ readonly insert: (values: EdgeValues) => Effect.Effect<Option.Option<string>, E, R>;
487
+ /** Hard-delete the edge; whether a row went. */
488
+ readonly delete: (id: string) => Effect.Effect<boolean, E, R>;
489
+ /**
490
+ * Set the edge's validity end column to `to` and update nothing else
491
+ * (pulpit#210, `EDGE-002`): `UPDATE … SET <column> = $to WHERE id = $id
492
+ * RETURNING id`; whether a row was updated. Required only by a family that
493
+ * declares {@link EdgeDefinition.validity}.
494
+ */
495
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); a family without validity never calls it
496
+ readonly closeValidity?: CloseValidity<E, R> | undefined;
497
+ /**
498
+ * The node ids at `column` of the stored edges whose `otherColumn` is one
499
+ * of `ids` and that satisfy `where`, over the same joins as
500
+ * {@link EdgeStore.list}, as the follower seam's `Following` (pulpit#113):
501
+ * the subquery `SELECT e.<column> FROM <edge> e JOIN <each end's view> …
502
+ * WHERE e.<otherColumn> = ANY($ids) AND <where>` in SQL, never a
503
+ * materialised array, and the set itself in memory. `ids` is never empty
504
+ * and never longer than `MAX_SET_VALUES`: {@link EdgeOps.memberFilter}
505
+ * answers `never` (and {@link EdgeOps.nonMemberFilter} `always`) without asking.
506
+ */
507
+ readonly members: (query: {
508
+ readonly column: string;
509
+ readonly otherColumn: string;
510
+ readonly ids: ReadonlyArray<string>;
511
+ readonly where: ListFilter<Following>;
512
+ }) => Effect.Effect<Following, E, R>;
513
+ /**
514
+ * How many stored edges hold each of `ids` in `column` and satisfy
515
+ * `where`, over the same joins as {@link EdgeStore.list} (pulpit#185): one
516
+ * `SELECT e.<column>, count(*) FROM <edge> e JOIN <each end's view> …
517
+ * WHERE e.<column> = ANY($ids) AND <where> GROUP BY e.<column>`, never a
518
+ * read per id. A node with no such edge has no key. `ids` is never empty,
519
+ * holds no duplicate and is never longer than `MAX_SET_VALUES`:
520
+ * {@link EdgeOps.countBy} answers without asking otherwise.
521
+ */
522
+ readonly countBy: (query: {
523
+ readonly column: string;
524
+ readonly ids: ReadonlyArray<string>;
525
+ readonly where: ListFilter<Following>;
526
+ }) => Effect.Effect<HashMap.HashMap<string, number>, E, R>;
527
+ }
528
+
529
+ // ── The factory ───────────────────────────────────────────────────────
530
+
531
+ /**
532
+ * An edge family's declaration: its type and ends, its seams, its create
533
+ * mapping, conflict policy and write gate, and its row → resource mapper.
534
+ *
535
+ * @since 0.1.0
536
+ */
537
+ export interface EdgeDefinition<Create, Row extends EdgeRow, Resource, Following, E, R> {
538
+ /** The family's JSON:API wire type: names spans and tags `EdgeNotFound`. */
539
+ readonly type: string;
540
+ /** The family's ends by alias; each node must be readable for its edge to be. */
541
+ readonly ends: EdgeEnds<E, R>;
542
+ /** The edge table. */
543
+ readonly edges: EdgeStore<Row, Following, E, R>;
544
+ /** The request's viewer (effect-auth's session context). */
545
+ readonly viewer: Effect.Effect<Viewer, never, R>;
546
+ /** The application's role axes. */
547
+ readonly policy: AccessPolicy;
548
+ /** The follower seam the owned ends read; `nobodyFollows` when the runtime provides none. */
549
+ readonly followers: FollowerSeam<Following, R>;
550
+ /** The edge's column values from the create input; each end's column must hold its node id. */
551
+ readonly values: (input: Create) => EdgeValues;
552
+ /** Shape a stored edge into the resource. */
553
+ readonly toResource: (row: Row) => Resource;
554
+ /**
555
+ * A create colliding with a stored edge on the unique key: `"fail"`
556
+ * ({@link EdgeConflict}, the default) or `"ignore"` (re-assertion is a
557
+ * no-op returning the stored edge's id, when the viewer may read it and
558
+ * its every end is live; otherwise {@link EdgeConflict}).
559
+ */
560
+ // oxlint-disable-next-line effect/prefer-option-over-null -- the optional-field convention (AGENTS.md → Conventions); absent is the default `"fail"`
561
+ readonly onConflict?: "fail" | "ignore" | undefined;
562
+ /** The write gate; {@link corpusWriteGate} when absent. */
563
+ // oxlint-disable-next-line effect/prefer-option-over-null -- the optional-field convention (AGENTS.md → Conventions); absent is the default `corpusWriteGate`
564
+ readonly writeGate?: EdgeWriteGate<E, R> | undefined;
565
+ /**
566
+ * The family's validity interval (pulpit#210): the edge columns holding
567
+ * when it starts and ends to hold (each a `Date`, or `NULL` for open).
568
+ * With it, a create checks `from ≤ to`, `maxEdges` counts only the edges
569
+ * valid at the same time as the new one, and `closeValidity` may end an
570
+ * open interval. Absent, edges have no interval.
571
+ */
572
+ // oxlint-disable-next-line effect/prefer-option-over-null -- the optional-field convention (AGENTS.md → Conventions); absent is "no interval"
573
+ readonly validity?: EdgeValidity | undefined;
574
+ }
575
+
576
+ /**
577
+ * {@link EdgeStore.closeValidity}'s shape: set the edge `id`'s `column` to
578
+ * `to`; whether a row was updated.
579
+ *
580
+ * @since 0.1.0
581
+ */
582
+ export type CloseValidity<E, R> = (
583
+ id: string,
584
+ column: string,
585
+ to: Date,
586
+ ) => Effect.Effect<boolean, E, R>;
587
+
588
+ /**
589
+ * An edge family's validity interval columns (pulpit#210). An edge holds
590
+ * over `[from, to)`: from its `from` instant (or always, when `NULL`) until
591
+ * its `to` instant (or forever, when `NULL`).
592
+ *
593
+ * @since 0.1.0
594
+ */
595
+ export interface EdgeValidity {
596
+ readonly from: string;
597
+ readonly to: string;
598
+ }
599
+
600
+ /**
601
+ * An edge's validity interval is malformed: a bound that is neither a
602
+ * `Date` nor `NULL`, `from` after `to`, or a close that would widen it. The
603
+ * wire's 422.
604
+ *
605
+ * @since 0.1.0
606
+ */
607
+ export class InvalidValidity extends Schema.TaggedError<
608
+ InvalidValidity,
609
+ { readonly brand: unique symbol }
610
+ >()("InvalidValidity", {
611
+ type: Schema.String,
612
+ reason: Schema.String,
613
+ }) {}
614
+
615
+ /**
616
+ * The edges valid at `at` (pulpit#210), as a list filter over a family's
617
+ * {@link EdgeValidity} columns: `from` is `NULL` or at or before `at`, and
618
+ * `to` is `NULL` or after it. Pass it as a list's `where`, so "who held
619
+ * the office on that day" is one gated read.
620
+ *
621
+ * @example
622
+ * ```ts
623
+ * import { strictEqual } from "node:assert";
624
+ * import { validAt } from "@thomasfosterau/pulpit/edges";
625
+ * import { evaluate } from "@thomasfosterau/pulpit/filter";
626
+ *
627
+ * const filter = validAt({ from: "validFrom", to: "validTo" }, new Date("2020-06-01"));
628
+ * const context = { now: new Date(0), contains: () => false };
629
+ * const row = { validFrom: new Date("2020-01-01"), validTo: null };
630
+ * strictEqual(evaluate(filter, row, context), true);
631
+ * strictEqual(evaluate(filter, { ...row, validTo: new Date("2020-03-01") }, context), false);
632
+ * ```
633
+ *
634
+ * @since 0.1.0
635
+ */
636
+ export const validAt = <Following = never>(
637
+ validity: EdgeValidity,
638
+ at: Date,
639
+ ): ListFilter<Following> =>
640
+ and<Following>(
641
+ or<Following>(isNull(validity.from), lte(validity.from, at)),
642
+ or<Following>(isNull(validity.to), gt(validity.to, at)),
643
+ );
644
+
645
+ /**
646
+ * The edges whose interval overlaps `[from, to)` (either bound none for
647
+ * open): `NULL`-or-before `to` on the `from` column, and `NULL`-or-after
648
+ * `from` on the `to` column.
649
+ */
650
+ const overlapping = <Following>(
651
+ validity: EdgeValidity,
652
+ from: Option.Option<Date>,
653
+ to: Option.Option<Date>,
654
+ ): ListFilter<Following> =>
655
+ and<Following>(
656
+ Option.match(to, {
657
+ onNone: (): ListFilter<Following> => always,
658
+ onSome: (end) => or<Following>(isNull(validity.from), lt(validity.from, end)),
659
+ }),
660
+ Option.match(from, {
661
+ onNone: (): ListFilter<Following> => always,
662
+ onSome: (start) => or<Following>(isNull(validity.to), gt(validity.to, start)),
663
+ }),
664
+ );
665
+
666
+ /** A validity bound: a `Date`, none for `NULL` or absent, or a malformed value. */
667
+ const boundOf = (
668
+ values: { readonly [column: string]: ColumnValue },
669
+ column: string,
670
+ ): Option.Option<Option.Option<Date>> =>
671
+ Option.match(Option.fromNullishOr(values[column]), {
672
+ onNone: () => Option.some(Option.none<Date>()),
673
+ onSome: (value) =>
674
+ Predicate.isDate(value) && !Number.isNaN(value.getTime())
675
+ ? Option.some(Option.some(value))
676
+ : Option.none(),
677
+ });
678
+
679
+ /**
680
+ * A list request: the bounded page cursor and the family's own predicate.
681
+ *
682
+ * @since 0.1.0
683
+ */
684
+ export interface EdgeListRequest {
685
+ readonly limit: number;
686
+ readonly offset: number;
687
+ /** The family's predicate over edge columns (and {@link endpointColumn}s), or `always`. */
688
+ readonly where: ListFilter<never>;
689
+ }
690
+
691
+ /**
692
+ * A create's outcome: the edge's id, and whether this call wrote it (`false`
693
+ * when an `"ignore"` family resolved a re-assertion to the stored edge).
694
+ *
695
+ * @since 0.1.0
696
+ */
697
+ export interface EdgeWrite {
698
+ readonly id: string;
699
+ readonly created: boolean;
700
+ }
701
+
702
+ /**
703
+ * The operations {@link defineEdgeOps} produces for one edge family.
704
+ *
705
+ * @since 0.1.0
706
+ */
707
+ export interface EdgeOps<Create, Resource, E, R, Following = never> {
708
+ /**
709
+ * One page of the edges the viewer may read, with the unpaged total as the
710
+ * store reports it (`EDGE-001`: an edge with a hidden or tombstoned end is
711
+ * omitted). The limit is clamped to `MAX_PAGE_LIMIT` (`SEC-INPUT-003`).
712
+ * Fails only with the store's `E`.
713
+ */
714
+ readonly list: (request: EdgeListRequest) => Effect.Effect<Page<Resource>, E, R>;
715
+ /**
716
+ * The edge as a resource. Fails `EdgeNotFound` when it is missing OR an
717
+ * end is hidden from the viewer (`SEC-AUTH-ENUM-001`, `EDGE-001`).
718
+ */
719
+ readonly get: (id: string) => Effect.Effect<Resource, EdgeNotFound | E, R>;
720
+ /**
721
+ * Assert the edge. Fails `NodeNotFound` when an end names a node that is
722
+ * missing, tombstoned or hidden from the viewer (checked before the gate,
723
+ * so a 403 never confirms a node; a tombstone is `NodeNotFound` to the
724
+ * view-deleted rung too); the gate's `Unauthorized` / `Forbidden` (or
725
+ * `NodeNotFound`); `EdgeConflict` on a unique-key collision the conflict
726
+ * policy does not resolve; `EdgeCardinalityExceeded` when an end's node
727
+ * already holds its `maxEdges` edges that the caller can read with every
728
+ * end live. Under `"fail"` a collision with a hidden edge answers exactly
729
+ * as one with a readable edge; under `"ignore"` only a readable, live edge
730
+ * resolves (to `{ created: false, id }`), and any other collision is
731
+ * `EdgeConflict` (`SEC-AUTH-ENUM-001`, pulpit#93). The count reads no
732
+ * hidden edge ({@link EdgeEnd.maxEdges}). An `EdgeConflict` still says the
733
+ * key is taken, which only a family whose unique key leaves an end out
734
+ * can say about a hidden edge (README → `./edges`).
735
+ */
736
+ readonly create: (
737
+ input: Create,
738
+ ) => Effect.Effect<
739
+ EdgeWrite,
740
+ | Unauthorized
741
+ | Forbidden
742
+ | NodeNotFound
743
+ | EdgeConflict
744
+ | EdgeCardinalityExceeded
745
+ | InvalidValidity
746
+ | E,
747
+ R
748
+ >;
749
+ /**
750
+ * End the edge's validity interval at `at` (pulpit#210): the one update an
751
+ * edge takes (`EDGE-002`), which sets only the interval's `to` column and
752
+ * only narrows it: `at` must not be after a closed interval's end, nor
753
+ * before its start. Fails `EdgeNotFound` for a missing or hidden edge
754
+ * (before the gate), the gate's failures, and {@link InvalidValidity} for
755
+ * a family without an interval or an `at` that would widen it.
756
+ */
757
+ readonly closeValidity: (
758
+ id: string,
759
+ at: Date,
760
+ ) => Effect.Effect<
761
+ { readonly id: string },
762
+ Unauthorized | Forbidden | NodeNotFound | EdgeNotFound | InvalidValidity | E,
763
+ R
764
+ >;
765
+ /**
766
+ * Hard-delete the edge. Fails `EdgeNotFound` when it is missing or hidden
767
+ * from the viewer (checked before the gate; the view-deleted rung reads an
768
+ * edge whose only hidden end is tombstoned, so it can delete one), then
769
+ * the gate's failures.
770
+ */
771
+ readonly delete: (
772
+ id: string,
773
+ ) => Effect.Effect<
774
+ { readonly id: string },
775
+ Unauthorized | Forbidden | NodeNotFound | EdgeNotFound | E,
776
+ R
777
+ >;
778
+ /**
779
+ * Edge membership as a list filter for the leaf at `end` (pulpit#113):
780
+ * `id IN (<the end's node ids of the edges whose other end is one of
781
+ * otherIds>)`, so `?categories=1,2` lists the posts holding a
782
+ * `post_category` edge to one of those terms. Only edges the viewer can
783
+ * read count (`EDGE-001`, the same `endpointFilter` as {@link list}): an
784
+ * edge with a hidden end, or a tombstoned end below the view-deleted
785
+ * rung, never makes a row a member, so a hidden term's edges are never
786
+ * confirmed. The filter is the store's `members` subquery under
787
+ * `./filter`'s `in` node, so a `NULL` id is never a member.
788
+ *
789
+ * `end` names the member end by alias, and the family's one other end is
790
+ * the end `otherIds` name. Fails closed, as `never`, without reading the
791
+ * store: no `otherIds`, more than `MAX_SET_VALUES` of them
792
+ * (`SEC-INPUT-003`), an `end` the family doesn't declare, or a family
793
+ * without exactly two ends. Fails only with the store's `E`.
794
+ */
795
+ readonly memberFilter: (
796
+ end: string,
797
+ otherIds: ReadonlyArray<string>,
798
+ ) => Effect.Effect<ListFilter<Following>, E, R>;
799
+ /**
800
+ * Edge non-membership as a list filter for the leaf at `end` (pulpit#191):
801
+ * the complement of {@link EdgeOps.memberFilter} over the same store
802
+ * `members` subquery, `(id IS NULL OR id NOT IN (…))` under `./filter`'s
803
+ * `notIn` node, so `?categories_exclude=1,2` lists the posts holding no
804
+ * readable `post_category` edge to those terms. The member set and this
805
+ * filter partition the rows a leaf's `list` reads.
806
+ *
807
+ * Only edges the viewer can read count as a membership (`EDGE-001`): a row
808
+ * whose only edge to `otherIds` is hidden from the viewer, or has a
809
+ * tombstoned end below the view-deleted rung, is not excluded, so the
810
+ * hidden edge is never confirmed. An exclusion fails closed by excluding
811
+ * nothing: no `otherIds`, more than `MAX_SET_VALUES` of them
812
+ * (`SEC-INPUT-003`; an adapter caps its list before this), an `end` the
813
+ * family doesn't declare, or a family without exactly two ends all give
814
+ * `always` without reading the store. Fails only with the store's `E`.
815
+ */
816
+ readonly nonMemberFilter: (
817
+ end: string,
818
+ otherIds: ReadonlyArray<string>,
819
+ ) => Effect.Effect<ListFilter<Following>, E, R>;
820
+ /**
821
+ * The number of edges the viewer can read at each node of `ids` at end
822
+ * `end` (pulpit#185), in one grouped store read: a taxonomy term's post
823
+ * count, for every term on a page. Only edges {@link list} would show
824
+ * count (`EDGE-001`, the same `endpointFilter`, and `where`, the family's
825
+ * own predicate, as {@link EdgeListRequest.where}): an edge with a hidden
826
+ * end, or a tombstoned end below the view-deleted rung, never counts, so a
827
+ * hidden or tombstoned node at `end` counts `0` and its edges are never
828
+ * confirmed.
829
+ *
830
+ * Every requested id (deduplicated) has a key, `0` for a node with no
831
+ * readable edge. Fails closed, every count `0` without reading the store,
832
+ * for more than `MAX_SET_VALUES` ids (`SEC-INPUT-003`) or an `end` the
833
+ * family doesn't declare; no ids read nothing. Fails only with the
834
+ * store's `E`.
835
+ */
836
+ readonly countBy: (
837
+ end: string,
838
+ ids: ReadonlyArray<string>,
839
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional parameter, `?: T | undefined` (AGENTS.md → Conventions); the omitted value is `always`
840
+ where?: ListFilter<never> | undefined,
841
+ ) => Effect.Effect<HashMap.HashMap<string, number>, E, R>;
842
+ }
843
+
844
+ /** What is wrong with one end's declaration: an alias with a `.`, or a `maxEdges` that is not a positive safe integer. */
845
+ const declarationProblem = <E, R>([alias, end]: readonly [string, EdgeEnd<E, R>]) => {
846
+ if (alias.includes(".")) {
847
+ return Option.some(`the end alias '${alias}' contains a '.', which endpointColumn reserves`);
848
+ }
849
+ const max = end.maxEdges;
850
+ if (max !== undefined && !(Number.isSafeInteger(max) && max > 0)) {
851
+ return Option.some(
852
+ `the end '${alias}' declares maxEdges ${String(max)}, not a positive safe integer`,
853
+ );
854
+ }
855
+ return Option.none<string>();
856
+ };
857
+
858
+ /** Throw {@link EdgeDeclarationError} on the first malformed end. */
859
+ const validateEnds = <E, R>(type: string, ends: EdgeEnds<E, R>): void => {
860
+ const problem = Arr.findFirst(Record.toEntries(ends), declarationProblem);
861
+ if (Option.isSome(problem)) {
862
+ throw new EdgeDeclarationError({ message: `${type}: ${problem.value}` });
863
+ }
864
+ };
865
+
866
+ /** The request's page window: the limit clamped to `[1, MAX_PAGE_LIMIT]` (`SEC-INPUT-003`), the offset to `≥ 0`. */
867
+ const pageWindow = (request: EdgeListRequest) => ({
868
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
869
+ offset: Math.max(0, Math.trunc(request.offset)),
870
+ });
871
+
872
+ /** The ends a create bounds, in the order their nodes are locked: ascending `nodeId`, so no two creates lock a pair in opposite orders. */
873
+ const byNodeId = Order.mapInput(Order.String, (at: { readonly nodeId: string }) => at.nodeId);
874
+
875
+ /**
876
+ * Bind an edge family's operations. Every produced operation is traced as
877
+ * `${type}.<operation>`; the writes run in the caller's transaction (the
878
+ * boundary is the caller's, website #1883), and a create's cardinality
879
+ * count and insert share it (README → Seams).
880
+ *
881
+ * Throws {@link EdgeDeclarationError}, when the family is declared, for an
882
+ * end alias containing a `.` or a `maxEdges` that is not a positive safe
883
+ * integer.
884
+ *
885
+ * @since 0.1.0
886
+ */
887
+ export const defineEdgeOps = <Create, Row extends EdgeRow, Resource, Following, E, R>(
888
+ definition: EdgeDefinition<Create, Row, Resource, Following, E, R>,
889
+ ): EdgeOps<Create, Resource, E, R, Following> => {
890
+ const { type, ends, edges, policy, followers, toResource } = definition;
891
+ validateEnds(type, ends);
892
+ const validity = Option.fromUndefinedOr(definition.validity);
893
+ if (Option.isSome(validity)) {
894
+ if (validity.value.from === validity.value.to) {
895
+ throw new EdgeDeclarationError({ message: `${type}: validity names one column twice` });
896
+ }
897
+ if (edges.closeValidity === undefined) {
898
+ throw new EdgeDeclarationError({
899
+ message: `${type}: validity needs an edge store with closeValidity`,
900
+ });
901
+ }
902
+ }
903
+ const endEntries = Record.toEntries(ends);
904
+ const writeGate: EdgeWriteGate<E, R> = definition.writeGate ?? corpusWriteGate;
905
+ const onConflict = definition.onConflict ?? "fail";
906
+
907
+ /** The node `edge` names at `end`, if the viewer may read it; none for a missing or non-string id. */
908
+ const readableNode = (end: EdgeEnd<E, R>, viewer: Viewer, edge: EdgeValues) => {
909
+ const nodeId = edge[end.column];
910
+ return Predicate.isString(nodeId)
911
+ ? Effect.flatMap(end.nodes.get(nodeId), (node) =>
912
+ Effect.map(canViewEndpoint(end, viewer, policy, followers, node), (readable) =>
913
+ readable ? node : Option.none(),
914
+ ),
915
+ )
916
+ : Effect.succeed(Option.none<LeafRow>());
917
+ };
918
+
919
+ /**
920
+ * The node `edge` names at `end`, if the viewer may read it and it is
921
+ * live: what a create's ends must name, and the per-edge form of the
922
+ * cardinality count's filter (`endpointFilter` and `liveEndsFilter`).
923
+ */
924
+ const liveNode = (end: EdgeEnd<E, R>, viewer: Viewer, edge: EdgeValues) =>
925
+ Effect.map(readableNode(end, viewer, edge), Option.filter(isLive));
926
+
927
+ /** The stored edge, if `node` finds every end's node for the viewer. */
928
+ const storedEdge =
929
+ (node: typeof readableNode) =>
930
+ (viewer: Viewer, id: string): Effect.Effect<Option.Option<Row>, E, R> =>
931
+ Effect.filterOrElse(edges.get(id), Option.isNone, (row) =>
932
+ Effect.map(
933
+ Effect.forEach(endEntries, ([, end]) => node(end, viewer, row.value), {
934
+ concurrency: 1,
935
+ }),
936
+ (found) => (Arr.every(found, Option.isSome) ? row : Option.none<Row>()),
937
+ ),
938
+ );
939
+
940
+ /** The stored edge, if the viewer may read it (`EDGE-001`: every end's node is readable). */
941
+ const visibleEdge = storedEdge(readableNode);
942
+
943
+ /** The stored edge, if the cardinality count would admit it: readable, every end live. */
944
+ const countedEdge = storedEdge(liveNode);
945
+
946
+ /**
947
+ * A unique-key collision, settled by the conflict policy: only ever the id
948
+ * of an edge the count would admit, readable with every end live (owner
949
+ * decision on pulpit#93). The view-deleted rung reads an edge to a
950
+ * tombstone but can't link to one, so it can't resolve to one either.
951
+ */
952
+ const resolveCollision = (viewer: Viewer, id: string) =>
953
+ Effect.gen(function* () {
954
+ if (onConflict === "fail") return yield* new EdgeConflict({ type });
955
+ if (Option.isNone(yield* countedEdge(viewer, id))) return yield* new EdgeConflict({ type });
956
+ return { id, created: false };
957
+ });
958
+
959
+ /**
960
+ * An end's declared `maxEdges`, checked against the edges its node already
961
+ * holds that `counted` admits: those the viewer can read, every end live.
962
+ */
963
+ const withinLimit =
964
+ (counted: ListFilter<Following>) =>
965
+ (at: { readonly alias: string; readonly end: EdgeEnd<E, R>; readonly nodeId: string }) =>
966
+ Option.match(Option.fromNullishOr(at.end.maxEdges), {
967
+ onNone: () => Effect.void,
968
+ onSome: (max) =>
969
+ Effect.flatMap(
970
+ edges.countAt({ column: at.end.column, nodeId: at.nodeId, where: counted }),
971
+ (held) =>
972
+ held >= max
973
+ ? Effect.fail(
974
+ new EdgeCardinalityExceeded({ type, end: at.alias, nodeId: at.nodeId, max }),
975
+ )
976
+ : Effect.void,
977
+ ),
978
+ });
979
+
980
+ const list = Effect.fn(`${type}.list`)(function* (request: EdgeListRequest) {
981
+ const viewer = yield* definition.viewer;
982
+ const where = and<Following>(endpointFilter(ends, viewer, policy, followers), request.where);
983
+ const { rows, total } = yield* edges.list({ where, ...pageWindow(request) });
984
+ return { rows: Arr.map(rows, toResource), total };
985
+ });
986
+
987
+ const get = Effect.fn(`${type}.get`)(function* (id: string) {
988
+ const row = yield* visibleEdge(yield* definition.viewer, id);
989
+ // Hidden and missing are the same answer (SEC-AUTH-ENUM-001).
990
+ if (Option.isNone(row)) return yield* new EdgeNotFound({ type, id });
991
+ return toResource(row.value);
992
+ });
993
+
994
+ /** The create's interval, checked (`from ≤ to`, each a `Date` or open), or none without one. */
995
+ const intervalOf = (values: EdgeValues) =>
996
+ Option.match(validity, {
997
+ onNone: () =>
998
+ Effect.succeed(Option.none<readonly [Option.Option<Date>, Option.Option<Date>]>()),
999
+ onSome: (columns) => {
1000
+ const from = boundOf(values, columns.from);
1001
+ const to = boundOf(values, columns.to);
1002
+ if (Option.isNone(from) || Option.isNone(to)) {
1003
+ return Effect.fail(
1004
+ new InvalidValidity({ type, reason: "a validity bound is neither a date nor open" }),
1005
+ );
1006
+ }
1007
+ const reversed =
1008
+ Option.isSome(from.value) &&
1009
+ Option.isSome(to.value) &&
1010
+ from.value.value.getTime() > to.value.value.getTime();
1011
+ return reversed
1012
+ ? Effect.fail(new InvalidValidity({ type, reason: "validity starts after it ends" }))
1013
+ : Effect.succeedSome([from.value, to.value] as const);
1014
+ },
1015
+ });
1016
+
1017
+ const create = Effect.fn(`${type}.create`)(function* (input: Create) {
1018
+ const viewer = yield* definition.viewer;
1019
+ const values = definition.values(input);
1020
+ // The interval is the input's own shape: checked first, it reads nothing.
1021
+ const interval = yield* intervalOf(values);
1022
+ // Every end first, before the gate: a node the viewer cannot read is
1023
+ // `NodeNotFound` whoever asks, so a gate's 403 never confirms it (#1306).
1024
+ // An end must be live as well as readable: the view-deleted rung reads a
1025
+ // tombstone, and still can't link to one.
1026
+ const nodes = yield* Effect.forEach(
1027
+ endEntries,
1028
+ ([alias, end]) =>
1029
+ Effect.flatMap(liveNode(end, viewer, values), (node) =>
1030
+ Option.match(node, {
1031
+ onNone: () =>
1032
+ Effect.fail(new NodeNotFound({ type: end.type, id: columnText(values[end.column]) })),
1033
+ onSome: (row) => Effect.succeed({ alias, end, nodeId: row.id }),
1034
+ }),
1035
+ ),
1036
+ { concurrency: 1 },
1037
+ );
1038
+ yield* writeGate({ viewer, policy, edge: values });
1039
+ // A re-assertion resolves before the cardinality check, so asserting an
1040
+ // edge that is already there stays idempotent at a full end.
1041
+ const collided = yield* edges.existing(values);
1042
+ if (Option.isSome(collided)) return yield* resolveCollision(viewer, collided.value);
1043
+ // Each bounded end's count locks its node (README → Seams), so the nodes
1044
+ // are visited in one global order: a self-referential family's two
1045
+ // bounded ends cannot deadlock two creates naming the pair both ways.
1046
+ // Only edges the viewer can read with every end live count (pulpit#77,
1047
+ // pulpit#93): a hidden edge must not shape the answer.
1048
+ // With an interval, only the edges valid at the same time count (owner decision on pulpit#210).
1049
+ const counted = and<Following>(
1050
+ endpointFilter(ends, viewer, policy, followers),
1051
+ liveEndsFilter<Following, E, R>(ends),
1052
+ Option.match(Option.all([validity, interval]), {
1053
+ onNone: (): ListFilter<Following> => always,
1054
+ onSome: ([columns, [from, to]]) => overlapping<Following>(columns, from, to),
1055
+ }),
1056
+ );
1057
+ yield* Effect.forEach(Arr.sort(nodes, byNodeId), withinLimit(counted), {
1058
+ concurrency: 1,
1059
+ discard: true,
1060
+ });
1061
+ const inserted = yield* edges.insert(values);
1062
+ if (Option.isSome(inserted)) return { id: inserted.value, created: true };
1063
+ // A racing writer took the key between `existing` and the insert.
1064
+ const raced = yield* edges.existing(values);
1065
+ if (Option.isNone(raced)) return yield* new EdgeConflict({ type });
1066
+ return yield* resolveCollision(viewer, raced.value);
1067
+ });
1068
+
1069
+ const closeValidity = Effect.fn(`${type}.closeValidity`)(function* (id: string, at: Date) {
1070
+ const viewer = yield* definition.viewer;
1071
+ const row = yield* visibleEdge(viewer, id);
1072
+ if (Option.isNone(row)) return yield* new EdgeNotFound({ type, id });
1073
+ yield* writeGate({ viewer, policy, edge: row.value });
1074
+ const close = edges.closeValidity;
1075
+ if (Option.isNone(validity) || close === undefined) {
1076
+ return yield* new InvalidValidity({ type, reason: "the family declares no validity" });
1077
+ }
1078
+ const from = Option.flatten(boundOf(row.value, validity.value.from));
1079
+ const to = Option.flatten(boundOf(row.value, validity.value.to));
1080
+ // Only ever narrowing (EDGE-002): never past a closed end, never before the start.
1081
+ if (
1082
+ Number.isNaN(at.getTime()) ||
1083
+ Option.exists(to, (end) => at.getTime() > end.getTime()) ||
1084
+ Option.exists(from, (start) => at.getTime() < start.getTime())
1085
+ ) {
1086
+ return yield* new InvalidValidity({ type, reason: "a close may only narrow the interval" });
1087
+ }
1088
+ if (!(yield* close(id, validity.value.to, at))) return yield* new EdgeNotFound({ type, id });
1089
+ return { id };
1090
+ });
1091
+
1092
+ const remove = Effect.fn(`${type}.delete`)(function* (id: string) {
1093
+ const viewer = yield* definition.viewer;
1094
+ const row = yield* visibleEdge(viewer, id);
1095
+ if (Option.isNone(row)) return yield* new EdgeNotFound({ type, id });
1096
+ yield* writeGate({ viewer, policy, edge: row.value });
1097
+ // A hard DELETE: an edge has no version chain to append a tombstone to.
1098
+ if (!(yield* edges.delete(id))) return yield* new EdgeNotFound({ type, id });
1099
+ return { id };
1100
+ });
1101
+
1102
+ /**
1103
+ * The store's `members` set for `end` against `otherIds`, under the
1104
+ * viewer's `EDGE-001` filter; none, without reading the store, for no ids,
1105
+ * more than `MAX_SET_VALUES`, a misnamed end or a family of other than two
1106
+ * ends. `memberFilter` reads none as `never`, `nonMemberFilter` as `always`.
1107
+ */
1108
+ const readableMembers = Effect.fnUntraced(function* (
1109
+ end: string,
1110
+ otherIds: ReadonlyArray<string>,
1111
+ ) {
1112
+ const member = Record.get(ends, end);
1113
+ const others = endEntries.filter(([alias]) => alias !== end);
1114
+ const [other] = others;
1115
+ if (Option.isNone(member) || other === undefined || others.length !== 1) {
1116
+ return Option.none<Following>();
1117
+ }
1118
+ const otherColumn = other[1].column;
1119
+ const named = anyOf(otherColumn, otherIds);
1120
+ // No ids, or past MAX_SET_VALUES: `anyOf` is `never`.
1121
+ if (named.kind === "never") return Option.none<Following>();
1122
+ const viewer = yield* definition.viewer;
1123
+ const following = yield* edges.members({
1124
+ column: member.value.column,
1125
+ otherColumn,
1126
+ ids: otherIds,
1127
+ where: and<Following>(endpointFilter(ends, viewer, policy, followers), named),
1128
+ });
1129
+ return Option.some(following);
1130
+ });
1131
+
1132
+ /** A membership filter traced as `${type}.<name>`: `whenNone` for no set, `wrap` over the readable members. */
1133
+ const membershipFilter = (
1134
+ name: string,
1135
+ whenNone: ListFilter<Following>,
1136
+ wrap: (set: Following) => ListFilter<Following>,
1137
+ ) =>
1138
+ Effect.fn(`${type}.${name}`)(function* (end: string, otherIds: ReadonlyArray<string>) {
1139
+ const following = yield* readableMembers(end, otherIds);
1140
+ return Option.match(following, { onNone: () => whenNone, onSome: wrap });
1141
+ });
1142
+
1143
+ const memberFilter = membershipFilter("memberFilter", never, (set) => isIn("id", set));
1144
+ // An exclusion fails closed by excluding nothing.
1145
+ const nonMemberFilter = membershipFilter("nonMemberFilter", always, (set) => isNotIn("id", set));
1146
+
1147
+ const countBy = Effect.fn(`${type}.countBy`)(function* (
1148
+ end: string,
1149
+ ids: ReadonlyArray<string>,
1150
+ where: ListFilter<never> = always,
1151
+ ) {
1152
+ const wanted = Arr.dedupe(ids);
1153
+ const zero = HashMap.fromIterable(Arr.map(wanted, (id) => [id, 0] as const));
1154
+ const counted = Record.get(ends, end);
1155
+ // A misnamed end, no ids, or past MAX_SET_VALUES: every count 0, unread.
1156
+ if (
1157
+ Option.isNone(counted) ||
1158
+ !Arr.isReadonlyArrayNonEmpty(wanted) ||
1159
+ wanted.length > MAX_SET_VALUES
1160
+ ) {
1161
+ return zero;
1162
+ }
1163
+ const viewer = yield* definition.viewer;
1164
+ const counts = yield* edges.countBy({
1165
+ column: counted.value.column,
1166
+ ids: wanted,
1167
+ where: and<Following>(endpointFilter(ends, viewer, policy, followers), where),
1168
+ });
1169
+ return HashMap.union(
1170
+ zero,
1171
+ HashMap.filter(counts, (_, id) => HashMap.has(zero, id)),
1172
+ );
1173
+ });
1174
+
1175
+ return {
1176
+ list,
1177
+ get,
1178
+ create,
1179
+ delete: remove,
1180
+ closeValidity,
1181
+ memberFilter,
1182
+ nonMemberFilter,
1183
+ countBy,
1184
+ };
1185
+ };