@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/paths.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * **Routable content** — a leaf's path claims, tied to its version chain,
3
+ * the slug aliases a path change leaves behind, and the gated resolution of
4
+ * a path to a node or a redirect.
5
+ *
6
+ * The behavioural spec is gardeneria's `apps/api/src/operations/pages.ts`
7
+ * and `packages/database/src/tables/pages.ts` (one live page per canonical
8
+ * path, old slugs kept in `page_slugs` and answered with a 301). A `#NNN`
9
+ * here names a gardeneria issue.
10
+ *
11
+ * ## The model (owner decision on pulpit#57)
12
+ *
13
+ * - **A claim lives on the root node**: one path per content item. The
14
+ * locale is chosen when the resolved node is read, never by the path, so a
15
+ * slug translation is not a claim of its own (pulpit#56's model (b)).
16
+ * - **Claims follow the node's lifecycle.** A write that leaves the node
17
+ * live claims the path its new state holds in the leaf's path column; a
18
+ * tombstone, or a state with no path, frees the claim; a restore re-claims
19
+ * its path, or fails {@link PathTaken} when another node holds it now.
20
+ * - **A path change records an alias**: the old path resolves as a redirect
21
+ * to the new one, for as long as no other node claims it.
22
+ * - **Resolution is gated** (`SEC-AUTH-ENUM-001`): {@link RoutableOps.resolve}
23
+ * reads the node through the leaf's own `get`, so a hidden node's path,
24
+ * and an alias to a hidden node, is {@link PathNotFound}, never a redirect.
25
+ *
26
+ * Free-form redirects (patterns, a 404 log) are the site's. The path's
27
+ * syntax is the leaf attribute's schema (effect-schema-ext's `Ids.slug`, once
28
+ * it publishes).
29
+ *
30
+ * @copyright Thomas Foster
31
+ * @module pulpit/paths
32
+ * @since 0.1.0
33
+ */
34
+
35
+ import * as Effect from "effect/Effect";
36
+ import * as Equivalence from "effect/Equivalence";
37
+ import * as Option from "effect/Option";
38
+ import * as Predicate from "effect/Predicate";
39
+ import * as Schema from "effect/Schema";
40
+ import type { SubtypeOps } from "./subtype.ts";
41
+ import type { ChainWrite, VersionChainStore } from "./version-chain.ts";
42
+
43
+ // ── The path-store seam ───────────────────────────────────────────────
44
+
45
+ /**
46
+ * Another node holds the path canonically: the wire's 409. It names the
47
+ * path, never the holder, so it says only that the path is taken, as a
48
+ * unique-key conflict does, whoever holds it.
49
+ *
50
+ * @since 0.1.0
51
+ */
52
+ export class PathTaken extends Schema.TaggedError<PathTaken, { readonly brand: unique symbol }>()(
53
+ "PathTaken",
54
+ {
55
+ path: Schema.String,
56
+ },
57
+ ) {}
58
+
59
+ /**
60
+ * No node the viewer can read answers to the path: the wire's 404. A path
61
+ * nobody claims, one whose node is hidden from the viewer, and an alias to a
62
+ * hidden node all answer this, alike (`SEC-AUTH-ENUM-001`).
63
+ *
64
+ * @since 0.1.0
65
+ */
66
+ export class PathNotFound extends Schema.TaggedError<
67
+ PathNotFound,
68
+ { readonly brand: unique symbol }
69
+ >()("PathNotFound", {
70
+ path: Schema.String,
71
+ }) {}
72
+
73
+ /**
74
+ * What a path is stored as: the node it names, and whether it is that
75
+ * node's canonical path or an alias a path change left behind.
76
+ *
77
+ * @since 0.1.0
78
+ */
79
+ export interface PathEntry {
80
+ readonly nodeId: string;
81
+ readonly canonical: boolean;
82
+ }
83
+
84
+ /**
85
+ * The path table of one routable namespace (a collection): each path once,
86
+ * canonical for one node or an alias of one. A store that persists and
87
+ * applies no policy; it runs in the caller's transaction, so a write whose
88
+ * claim fails rolls back with its chain append.
89
+ *
90
+ * @since 0.1.0
91
+ */
92
+ export interface PathStore<E, R> {
93
+ /** The entry for `path`, canonical or alias, or none. */
94
+ readonly lookup: (path: string) => Effect.Effect<Option.Option<PathEntry>, E, R>;
95
+ /** The node's canonical path, or none. */
96
+ readonly canonicalOf: (nodeId: string) => Effect.Effect<Option.Option<string>, E, R>;
97
+ /**
98
+ * Make `path` the node's canonical path, in one step: fails
99
+ * {@link PathTaken} when another node holds it canonically; otherwise
100
+ * replaces an alias at `path` (the node's own, which a revert promotes, or
101
+ * another node's, which the claim takes over) and turns the node's previous
102
+ * canonical path, if any, into its alias.
103
+ */
104
+ readonly claim: (nodeId: string, path: string) => Effect.Effect<void, PathTaken | E, R>;
105
+ /** Free the node's canonical path, if it has one; its aliases stay. */
106
+ readonly release: (nodeId: string) => Effect.Effect<void, E, R>;
107
+ }
108
+
109
+ // ── The routable operations ───────────────────────────────────────────
110
+
111
+ /**
112
+ * What a path resolves to for one viewer: the node's resource at its
113
+ * canonical path, or a redirect from an alias to the canonical path.
114
+ *
115
+ * @since 0.1.0
116
+ */
117
+ export type Resolution<Resource> =
118
+ | { readonly kind: "node"; readonly id: string; readonly resource: Resource }
119
+ | { readonly kind: "redirect"; readonly id: string; readonly path: string };
120
+
121
+ /**
122
+ * A routable leaf's operations: the leaf's own, each write keeping its path
123
+ * claim in step with the state it wrote (and failing {@link PathTaken}), plus
124
+ * the gated {@link resolve}.
125
+ *
126
+ * @since 0.1.0
127
+ */
128
+ export interface RoutableOps<Resource, E, R> extends SubtypeOps<Resource, E | PathTaken, R> {
129
+ /**
130
+ * Resolve a path for the viewer: its node, through the leaf's own `get`, or
131
+ * a redirect from an alias to the node's canonical path. Fails
132
+ * {@link PathNotFound} for an unclaimed path, a hidden node's path, and an
133
+ * alias to a hidden node, alike, and for an alias whose node has no
134
+ * canonical path now (`SEC-AUTH-ENUM-001`); with the store's `E` otherwise.
135
+ */
136
+ readonly resolve: (path: string) => Effect.Effect<Resolution<Resource>, PathNotFound | E, R>;
137
+ }
138
+
139
+ /**
140
+ * What {@link routable} needs besides the leaf's operations.
141
+ *
142
+ * @since 0.1.0
143
+ */
144
+ export interface RoutableConfig<E, R> {
145
+ /** The leaf's path column: a string holds the node's path; any other value, or none, claims nothing. */
146
+ readonly column: string;
147
+ /** The routable namespace's path table. */
148
+ readonly paths: PathStore<E, R>;
149
+ /** The leaf's chain store, read (ungated) for the state a write landed at. */
150
+ readonly chain: VersionChainStore<E, R>;
151
+ }
152
+
153
+ /**
154
+ * Make a leaf routable (pulpit#57): its writes keep the node's path claim in
155
+ * step with its chain, and {@link RoutableOps.resolve} answers a path through
156
+ * the leaf's own read gate. After each write's append, in the same
157
+ * transaction, the claim follows the state the write landed at: a live state
158
+ * with a path claims it (a change leaves the old path as an alias; another
159
+ * node's claim fails {@link PathTaken}), and a tombstone or a state with no
160
+ * path frees it. A restore into a path another node took meanwhile therefore
161
+ * fails `PathTaken`, and its append rolls back with it. Wrap the result with
162
+ * `./subtype`'s `subtypeHandlers` as the leaf's own operations, so its
163
+ * `transact` covers the claims.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * import { strictEqual } from "node:assert";
168
+ * import * as Effect from "effect/Effect";
169
+ * import * as Option from "effect/Option";
170
+ * import { routable } from "@thomasfosterau/pulpit/paths";
171
+ * import { defineSubtype, nobodyFollows } from "@thomasfosterau/pulpit/subtype";
172
+ * import {
173
+ * makeInMemoryChainStore,
174
+ * makeInMemoryLeafStore,
175
+ * makeInMemoryPathStore,
176
+ * } from "@thomasfosterau/pulpit/testing";
177
+ * import * as HashSet from "effect/HashSet";
178
+ *
179
+ * const chain = makeInMemoryChainStore();
180
+ * const pages = routable(
181
+ * defineSubtype({
182
+ * type: "pages",
183
+ * access: { kind: "reference" },
184
+ * chain,
185
+ * leaves: makeInMemoryLeafStore(chain, { columns: ["slug"] }),
186
+ * viewer: Effect.succeed(Option.some({ id: "editor", role: "admin" })),
187
+ * policy: {
188
+ * canViewPrivate: () => true,
189
+ * canViewDeleted: () => true,
190
+ * canModerate: () => true,
191
+ * canWriteCorpus: () => true,
192
+ * },
193
+ * followers: nobodyFollows(HashSet.empty<string>()),
194
+ * toResource: (row) => ({ id: row.id, slug: row["slug"] }),
195
+ * }),
196
+ * { column: "slug", paths: makeInMemoryPathStore(), chain },
197
+ * );
198
+ * const program = Effect.gen(function* () {
199
+ * const { id } = yield* pages.create({ id: Option.none(), state: { slug: "about" } });
200
+ * yield* pages.update({ id, patch: { slug: "about-us" } });
201
+ * return yield* pages.resolve("about");
202
+ * });
203
+ * const resolved = Effect.runSync(program);
204
+ * strictEqual(resolved.kind === "redirect" && resolved.path, "about-us");
205
+ * ```
206
+ *
207
+ * @since 0.1.0
208
+ */
209
+ export const routable = <Resource, E, R>(
210
+ ops: SubtypeOps<Resource, E, R>,
211
+ config: RoutableConfig<E, R>,
212
+ ): RoutableOps<Resource, E, R> => {
213
+ const { column, paths, chain } = config;
214
+
215
+ // The claim the written state calls for: its path while it is live, none
216
+ // for a tombstone or a state whose path column holds no string.
217
+ const follow = ({ id, version }: ChainWrite) =>
218
+ Effect.gen(function* () {
219
+ const written = yield* chain.snapshot(id, version);
220
+ const path = Option.flatMap(written, (snapshot) =>
221
+ snapshot.action === "delete"
222
+ ? Option.none()
223
+ : Option.liftPredicate(snapshot.state[column], Predicate.isString),
224
+ );
225
+ const held = yield* paths.canonicalOf(id);
226
+ if (Option.makeEquivalence(Equivalence.String)(path, held)) return;
227
+ yield* Option.match(path, {
228
+ onNone: () => paths.release(id),
229
+ onSome: (claimed) => paths.claim(id, claimed),
230
+ });
231
+ });
232
+ const claiming = <A extends ChainWrite, E2, R2>(write: Effect.Effect<A, E2, R2>) =>
233
+ Effect.tap(write, follow);
234
+
235
+ const resolve = Effect.fn(`${ops.type}.resolve`)(function* (path: string) {
236
+ const notFound = new PathNotFound({ path });
237
+ const entry = yield* paths.lookup(path);
238
+ if (Option.isNone(entry)) return yield* notFound;
239
+ const { nodeId, canonical } = entry.value;
240
+ // The leaf's own gate: a hidden node answers exactly as no node.
241
+ const resource = yield* ops
242
+ .get(nodeId)
243
+ .pipe(Effect.catchTag("NodeNotFound", () => Effect.fail(notFound)));
244
+ if (canonical) return { kind: "node", id: nodeId, resource } satisfies Resolution<Resource>;
245
+ const target = yield* paths.canonicalOf(nodeId);
246
+ return yield* Option.match(target, {
247
+ onNone: () => Effect.fail(notFound),
248
+ onSome: (to) =>
249
+ Effect.succeed({ kind: "redirect", id: nodeId, path: to } satisfies Resolution<Resource>),
250
+ });
251
+ });
252
+
253
+ return {
254
+ ...ops,
255
+ create: (input) => claiming(ops.create(input)),
256
+ update: (input) => claiming(ops.update(input)),
257
+ restore: (input) => claiming(ops.restore(input)),
258
+ // A delete over a tombstone wrote nothing, and its claim is already free.
259
+ delete: (id, precondition) =>
260
+ Effect.tap(ops.delete(id, precondition), (written) =>
261
+ written.alreadyDeleted ? Effect.void : paths.release(id),
262
+ ),
263
+ restoreVersion: (id, version, precondition) =>
264
+ claiming(ops.restoreVersion(id, version, precondition)),
265
+ resolve,
266
+ };
267
+ };