@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.
- package/LICENSE +21 -0
- package/README.md +1272 -2
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +145 -3
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- 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
|
+
};
|