@thomasfosterau/pulpit 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1271 -1
- 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 +138 -4
- 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/registry.ts
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The resource registry's flag vocabulary and construction-time checks** —
|
|
3
|
+
* generic in the tree.
|
|
4
|
+
*
|
|
5
|
+
* An application declares its Node type tree as one ordered list of
|
|
6
|
+
* {@link RegistryEntry}: marker levels (`node`, `entity`, `content`, …) carry
|
|
7
|
+
* no `resource`; concrete leaves carry their `Resource.make` definition, their
|
|
8
|
+
* id brand and their capability flags. Everything else — the branch unions,
|
|
9
|
+
* the per-flag families, the wire-type lists a subtype factory checks its
|
|
10
|
+
* `access` declaration against — derives from that one list. Ported from
|
|
11
|
+
* gardeneria's `packages/models/src/registry.ts` (Website #1011, carried
|
|
12
|
+
* over); the entries there are the app's, the vocabulary and the checks are
|
|
13
|
+
* this module's. A `#NNN` here names a gardeneria issue.
|
|
14
|
+
*
|
|
15
|
+
* **Capability subsets, not tree levels.** The flags:
|
|
16
|
+
*
|
|
17
|
+
* - `pageable` — has a `/type/<id>` route and is a page-family member;
|
|
18
|
+
* - `agent` — an agent-family member (a person, an organisation, a team, a
|
|
19
|
+
* bot, a group);
|
|
20
|
+
* - `owned` — a multi-tenant leaf: `owner_id` on the stable row, a
|
|
21
|
+
* `visibility` on the version row, writes gated by owner-or-rung, reads by
|
|
22
|
+
* the viewer predicate (`./subtype`);
|
|
23
|
+
* - `referenceData` — corpus material: written directly at the corpus-write
|
|
24
|
+
* rung, proposed by anyone signed in, publicly readable;
|
|
25
|
+
* - `searchable` — a search plane emits it;
|
|
26
|
+
* - `routable` — addressed by a path claim on its root node (`./paths`);
|
|
27
|
+
* - `localised` — carries a locale level in its chain (pulpit#56): its
|
|
28
|
+
* translated columns per locale, declared beside the entry with
|
|
29
|
+
* `./subtype`'s `defineLocaleLevel`.
|
|
30
|
+
*
|
|
31
|
+
* `owned` and `referenceData` are mutually exclusive — a leaf sits on one
|
|
32
|
+
* side of the trust model or the other, and the subtype factory reads the
|
|
33
|
+
* same fact from the leaf declaration's `access` (`./subtype`). The flags
|
|
34
|
+
* cross-cut the tree (a garden is `owned` and `pageable`; a comment is
|
|
35
|
+
* `owned` and neither pageable nor searchable), so none can be derived from
|
|
36
|
+
* tree position alone. What IS derived from tree position is branch
|
|
37
|
+
* membership — every resource-bearing leaf whose root is that branch — via
|
|
38
|
+
* {@link branchOf}.
|
|
39
|
+
*
|
|
40
|
+
* **Construction-time checks** ({@link validateRegistry}, thrown at import,
|
|
41
|
+
* the same posture as a subtype factory's coverage check): keys are unique;
|
|
42
|
+
* every `parent` resolves; a capability flag implies a resource; `owned` and
|
|
43
|
+
* `referenceData` never coincide; a leaf's declared brand *is* the resource's
|
|
44
|
+
* own id and its key *is* the resource's wire `type`. An application's
|
|
45
|
+
* tree-specific invariants — gardeneria's `searchType` / `searchViewSchema`
|
|
46
|
+
* pairing, its `dbTable` anchoring lockstep — are passed in as extra checks
|
|
47
|
+
* rather than restated here.
|
|
48
|
+
*
|
|
49
|
+
* Client-safe (no Drizzle) and module-load-time static.
|
|
50
|
+
*
|
|
51
|
+
* @copyright Thomas Foster
|
|
52
|
+
* @module pulpit/registry
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import type { Resource } from "@thomasfosterau/effect-jsonapi";
|
|
57
|
+
import * as Array from "effect/Array";
|
|
58
|
+
import * as HashMap from "effect/HashMap";
|
|
59
|
+
import * as Option from "effect/Option";
|
|
60
|
+
import * as Record from "effect/Record";
|
|
61
|
+
import * as Result from "effect/Result";
|
|
62
|
+
import * as Schema from "effect/Schema";
|
|
63
|
+
|
|
64
|
+
// ── Entry shape ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Any branded-id schema (a UUID string on the wire) an application's ids module declares.
|
|
68
|
+
*
|
|
69
|
+
* @since 0.1.0
|
|
70
|
+
*/
|
|
71
|
+
export type BrandedId = Schema.Codec<unknown, string>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The capability flags, in the order the derived families list them.
|
|
75
|
+
*
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
*/
|
|
78
|
+
export const RESOURCE_FLAGS = [
|
|
79
|
+
"pageable",
|
|
80
|
+
"agent",
|
|
81
|
+
"owned",
|
|
82
|
+
"referenceData",
|
|
83
|
+
"searchable",
|
|
84
|
+
"routable",
|
|
85
|
+
"localised",
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One of {@link RESOURCE_FLAGS}.
|
|
90
|
+
*
|
|
91
|
+
* @since 0.1.0
|
|
92
|
+
*/
|
|
93
|
+
export type RegistryFlag = (typeof RESOURCE_FLAGS)[number];
|
|
94
|
+
|
|
95
|
+
/* oxlint-disable 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 by every reader, so there is no value to model as an `Option` */
|
|
96
|
+
/**
|
|
97
|
+
* One node in an application's registry tree. Marker levels carry no
|
|
98
|
+
* `resource`; concrete leaves carry the `Resource.make` definition. The
|
|
99
|
+
* capability flags are the membership predicates the families / unions
|
|
100
|
+
* filter on; they must be literal `true` so the type-level derivation
|
|
101
|
+
* ({@link PickResources}) can select them.
|
|
102
|
+
*
|
|
103
|
+
* @since 0.1.0
|
|
104
|
+
*/
|
|
105
|
+
export interface RegistryEntry {
|
|
106
|
+
/** Stable key: the JSON:API wire `type` for resource leaves, else a marker / leaf name. */
|
|
107
|
+
readonly key: string;
|
|
108
|
+
/** Parent key in the settled tree — `Option.none()` only for the root. */
|
|
109
|
+
readonly parent: Option.Option<string>;
|
|
110
|
+
/** The `Resource.make` definition; omitted for marker levels. */
|
|
111
|
+
readonly resource?: Resource.Any | undefined;
|
|
112
|
+
/** The application's id brand for this level. For resource leaves it must equal `resource.Id`. */
|
|
113
|
+
readonly id?: BrandedId | undefined;
|
|
114
|
+
/** Page-able: has a `/type/<id>` route and is a page-family member. */
|
|
115
|
+
readonly pageable?: true | undefined;
|
|
116
|
+
/** Agent: an agent-family member. */
|
|
117
|
+
readonly agent?: true | undefined;
|
|
118
|
+
/** Owned: `owner_id` + `visibility`, gated per viewer (never `referenceData`). */
|
|
119
|
+
readonly owned?: true | undefined;
|
|
120
|
+
/** Reference data: corpus-written, proposable, publicly readable (never `owned`). */
|
|
121
|
+
readonly referenceData?: true | undefined;
|
|
122
|
+
/** Searchable: a search plane emits it. */
|
|
123
|
+
readonly searchable?: true | undefined;
|
|
124
|
+
/** Routable: addressed by a path claim on its root node (`./paths`). */
|
|
125
|
+
readonly routable?: true | undefined;
|
|
126
|
+
/** Localised: a locale level in its chain, its translated columns per locale (pulpit#56). */
|
|
127
|
+
readonly localised?: true | undefined;
|
|
128
|
+
}
|
|
129
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Declare an entry's `parent` with its key literal preserved —
|
|
133
|
+
* `parent: parent("place")` rather than `Option.some("place")`, whose `A` is
|
|
134
|
+
* inferred as `string` even under `as const` and would leave every
|
|
135
|
+
* {@link PickChildren} derivation empty. Runtime-identical to `Option.some`.
|
|
136
|
+
*
|
|
137
|
+
* @since 0.1.0
|
|
138
|
+
*/
|
|
139
|
+
export const parent = <const Key extends string>(key: Key): Option.Option<Key> => Option.some(key);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The tree root's `parent`: it has none.
|
|
143
|
+
*
|
|
144
|
+
* @since 0.1.0
|
|
145
|
+
*/
|
|
146
|
+
export const root: Option.Option<never> = Option.none();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A construction-time registry violation — thrown at module load by
|
|
150
|
+
* {@link validateRegistry}, never an Effect failure: no runtime exists yet
|
|
151
|
+
* when a registry is declared, and a malformed tree must stop the module
|
|
152
|
+
* from loading at all rather than surface as a wrong wire contract later.
|
|
153
|
+
*
|
|
154
|
+
* @since 0.1.0
|
|
155
|
+
*/
|
|
156
|
+
export class RegistryDeclarationError extends Schema.TaggedError<
|
|
157
|
+
RegistryDeclarationError,
|
|
158
|
+
{ readonly brand: unique symbol }
|
|
159
|
+
>()("RegistryDeclarationError", { message: Schema.String }) {}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* An application-supplied per-entry invariant, run by {@link validateRegistry}
|
|
163
|
+
* after the generic checks: `Option.some(message)` fails the registry with
|
|
164
|
+
* that message, `Option.none()` passes. `byKey` is the index of every entry
|
|
165
|
+
* so a check can resolve parents.
|
|
166
|
+
*
|
|
167
|
+
* @since 0.1.0
|
|
168
|
+
*/
|
|
169
|
+
export type RegistryCheck = (
|
|
170
|
+
entry: RegistryEntry,
|
|
171
|
+
byKey: HashMap.HashMap<string, RegistryEntry>,
|
|
172
|
+
) => Option.Option<string>;
|
|
173
|
+
|
|
174
|
+
// ── Construction-time checks ──────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
const fail = (message: string): never => {
|
|
177
|
+
throw new RegistryDeclarationError({ message });
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Index entries by key, throwing on a duplicate — the first check, since every
|
|
182
|
+
* other check resolves through the index.
|
|
183
|
+
*
|
|
184
|
+
* @since 0.1.0
|
|
185
|
+
*/
|
|
186
|
+
export const indexByKey = (
|
|
187
|
+
entries: readonly RegistryEntry[],
|
|
188
|
+
): HashMap.HashMap<string, RegistryEntry> =>
|
|
189
|
+
Array.reduce(entries, HashMap.empty<string, RegistryEntry>(), (map, entry) => {
|
|
190
|
+
if (HashMap.has(map, entry.key)) fail(`registry: duplicate key '${entry.key}'`);
|
|
191
|
+
return HashMap.set(map, entry.key, entry);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/** A capability flag implies a resource: a marker level cannot be pageable / owned / …. */
|
|
195
|
+
const checkFlagsHaveResource = (entry: RegistryEntry): void => {
|
|
196
|
+
if (entry.resource !== undefined) return;
|
|
197
|
+
Option.map(
|
|
198
|
+
Array.findFirst(RESOURCE_FLAGS, (flag) => entry[flag] === true),
|
|
199
|
+
(flag) => fail(`'${entry.key}' is flagged '${flag}' but declares no resource`),
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** `owned` ⊕ `referenceData`: a leaf sits on one side of the trust model, never both. */
|
|
204
|
+
const checkTrustAxis = (entry: RegistryEntry): void => {
|
|
205
|
+
if (entry.owned === true && entry.referenceData === true) {
|
|
206
|
+
fail(`'${entry.key}' is flagged both 'owned' and 'referenceData' (a leaf is one or the other)`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Id lockstep: a leaf's declared brand IS the resource's id, and its key IS the wire type. */
|
|
211
|
+
const checkIdLockstep = (entry: RegistryEntry): void => {
|
|
212
|
+
if (entry.resource === undefined) return;
|
|
213
|
+
if (entry.id === undefined) {
|
|
214
|
+
fail(`leaf '${entry.key}' declares a resource but no ids.ts brand`);
|
|
215
|
+
} else if (!Object.is(entry.id, entry.resource.Id)) {
|
|
216
|
+
fail(`'${entry.key}' declares an id brand that is not the resource's own id (ids.ts drift)`);
|
|
217
|
+
} else if (entry.resource.type !== entry.key) {
|
|
218
|
+
fail(`'${entry.key}' key does not match its resource wire type '${entry.resource.type}'`);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Per-entry invariants: parent resolves, flags imply a resource, the trust axis, id lockstep. */
|
|
223
|
+
const checkEntry = (
|
|
224
|
+
entry: RegistryEntry,
|
|
225
|
+
byKey: HashMap.HashMap<string, RegistryEntry>,
|
|
226
|
+
checks: ReadonlyArray<RegistryCheck>,
|
|
227
|
+
): void => {
|
|
228
|
+
if (Option.isSome(entry.parent) && !HashMap.has(byKey, entry.parent.value)) {
|
|
229
|
+
fail(`'${entry.key}' names parent '${entry.parent.value}', which is not a declared entry`);
|
|
230
|
+
}
|
|
231
|
+
checkFlagsHaveResource(entry);
|
|
232
|
+
checkTrustAxis(entry);
|
|
233
|
+
checkIdLockstep(entry);
|
|
234
|
+
Array.forEach(checks, (check) => Option.map(check(entry, byKey), fail));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run every construction-time invariant over `entries`, throwing
|
|
239
|
+
* {@link RegistryDeclarationError} on the first violation. Parametric (rather
|
|
240
|
+
* than closing over an application's registry) so a unit suite can drive
|
|
241
|
+
* each failure path with a malformed fixture — the throws are the contract,
|
|
242
|
+
* not just belt-and-braces. An application calls it once at module load with
|
|
243
|
+
* its real registry, passing its tree-specific `checks`.
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```ts
|
|
247
|
+
* import { throws } from "node:assert";
|
|
248
|
+
* import { Resource } from "@thomasfosterau/effect-jsonapi";
|
|
249
|
+
* import { Registry } from "@thomasfosterau/pulpit";
|
|
250
|
+
* import * as Option from "effect/Option";
|
|
251
|
+
* import * as Schema from "effect/Schema";
|
|
252
|
+
*
|
|
253
|
+
* const PostResource = Resource.make("posts", { attributes: { title: Schema.String } });
|
|
254
|
+
*
|
|
255
|
+
* const REGISTRY = [
|
|
256
|
+
* { key: "node", parent: Registry.root },
|
|
257
|
+
* { key: "content", parent: Registry.parent("node") },
|
|
258
|
+
* {
|
|
259
|
+
* key: "posts",
|
|
260
|
+
* parent: Registry.parent("content"),
|
|
261
|
+
* resource: PostResource,
|
|
262
|
+
* id: PostResource.Id,
|
|
263
|
+
* owned: true,
|
|
264
|
+
* },
|
|
265
|
+
* ] as const satisfies readonly Registry.RegistryEntry[];
|
|
266
|
+
*
|
|
267
|
+
* // A well-formed tree passes.
|
|
268
|
+
* Registry.validateRegistry(REGISTRY);
|
|
269
|
+
*
|
|
270
|
+
* // A parent that names no declared entry is thrown at load, not returned.
|
|
271
|
+
* throws(
|
|
272
|
+
* () =>
|
|
273
|
+
* Registry.validateRegistry([...REGISTRY, { key: "pages", parent: Registry.parent("blog") }]),
|
|
274
|
+
* Registry.RegistryDeclarationError,
|
|
275
|
+
* );
|
|
276
|
+
*
|
|
277
|
+
* // So is an application's own check: here, every leaf must be pageable.
|
|
278
|
+
* throws(
|
|
279
|
+
* () =>
|
|
280
|
+
* Registry.validateRegistry(REGISTRY, [
|
|
281
|
+
* (entry) =>
|
|
282
|
+
* entry.resource !== undefined && entry.pageable !== true
|
|
283
|
+
* ? Option.some(`leaf '${entry.key}' is not pageable`)
|
|
284
|
+
* : Option.none(),
|
|
285
|
+
* ]),
|
|
286
|
+
* Registry.RegistryDeclarationError,
|
|
287
|
+
* );
|
|
288
|
+
* ```
|
|
289
|
+
*
|
|
290
|
+
* @since 0.1.0
|
|
291
|
+
*/
|
|
292
|
+
export const validateRegistry = (
|
|
293
|
+
entries: readonly RegistryEntry[],
|
|
294
|
+
checks: ReadonlyArray<RegistryCheck> = [],
|
|
295
|
+
): void => {
|
|
296
|
+
const byKey = indexByKey(entries);
|
|
297
|
+
Array.forEach(entries, (entry) => checkEntry(entry, byKey, checks));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// ── Branch resolution ─────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The branch root an entry ultimately belongs to — a walk up `parent` until
|
|
304
|
+
* one of the application's `roots` (gardeneria's are `entity`, `content` and
|
|
305
|
+
* `file`). `Option.none()` for the tree root itself, and for an unresolved
|
|
306
|
+
* parent (an unknown parent resolves to nothing rather than throwing —
|
|
307
|
+
* {@link validateRegistry} is where that is an error).
|
|
308
|
+
*
|
|
309
|
+
* @since 0.1.0
|
|
310
|
+
*/
|
|
311
|
+
export const branchOf = <Root extends string>(
|
|
312
|
+
entry: RegistryEntry,
|
|
313
|
+
byKey: HashMap.HashMap<string, RegistryEntry>,
|
|
314
|
+
roots: ReadonlyArray<Root>,
|
|
315
|
+
): Option.Option<Root> => {
|
|
316
|
+
const isRoot = (key: string): key is Root => Array.some(roots, (root) => root === key);
|
|
317
|
+
const walk = (key: string): Option.Option<Root> =>
|
|
318
|
+
isRoot(key)
|
|
319
|
+
? Option.some(key)
|
|
320
|
+
: Option.flatMap(HashMap.get(byKey, key), (level) => Option.flatMap(level.parent, walk));
|
|
321
|
+
return isRoot(entry.key) ? Option.some(entry.key) : Option.flatMap(entry.parent, walk);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// ── Type-level member selection ───────────────────────────────────────
|
|
325
|
+
//
|
|
326
|
+
// `Schema.Union` preserves its members as a tuple (the `const Members`
|
|
327
|
+
// inference), so a derived union keeps the precise member union types. A
|
|
328
|
+
// runtime `.filter().map()` erases that tuple to `Resource.Any[]`, so the
|
|
329
|
+
// exact sub-tuple is recovered at the type level and the runtime array is
|
|
330
|
+
// bridged onto it with a single cast — the array and the type follow the same
|
|
331
|
+
// (registry-declaration) order, which `validateRegistry` guarantees.
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The resources of every entry whose `Flag` capability is literally `true`, as a tuple.
|
|
335
|
+
*
|
|
336
|
+
* @since 0.1.0
|
|
337
|
+
*/
|
|
338
|
+
export type PickResources<
|
|
339
|
+
T extends readonly RegistryEntry[],
|
|
340
|
+
Flag extends RegistryFlag,
|
|
341
|
+
Acc extends readonly Resource.Any[] = readonly [],
|
|
342
|
+
> = T extends readonly [
|
|
343
|
+
infer Head extends RegistryEntry,
|
|
344
|
+
...infer Rest extends readonly RegistryEntry[],
|
|
345
|
+
]
|
|
346
|
+
? Head extends Record<Flag, true>
|
|
347
|
+
? Head extends { readonly resource: infer R extends Resource.Any }
|
|
348
|
+
? PickResources<Rest, Flag, readonly [...Acc, R]>
|
|
349
|
+
: PickResources<Rest, Flag, Acc>
|
|
350
|
+
: PickResources<Rest, Flag, Acc>
|
|
351
|
+
: Acc;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The resources of every entry whose parent key is one of `Parent`, as a
|
|
355
|
+
* tuple — the one-level type-side counterpart of {@link branchOf}. An
|
|
356
|
+
* application types its branch unions from this over the branch root PLUS
|
|
357
|
+
* every marker level beneath it, so a leaf hanging off a marker types into
|
|
358
|
+
* the branch exactly as one hanging off the root does.
|
|
359
|
+
*
|
|
360
|
+
* @since 0.1.0
|
|
361
|
+
*/
|
|
362
|
+
export type PickChildren<
|
|
363
|
+
T extends readonly RegistryEntry[],
|
|
364
|
+
Parent extends string,
|
|
365
|
+
Acc extends readonly Resource.Any[] = readonly [],
|
|
366
|
+
> = T extends readonly [
|
|
367
|
+
infer Head extends RegistryEntry,
|
|
368
|
+
...infer Rest extends readonly RegistryEntry[],
|
|
369
|
+
]
|
|
370
|
+
? Head extends { readonly parent: Option.Option<Parent> }
|
|
371
|
+
? Head extends { readonly resource: infer R extends Resource.Any }
|
|
372
|
+
? PickChildren<Rest, Parent, readonly [...Acc, R]>
|
|
373
|
+
: PickChildren<Rest, Parent, Acc>
|
|
374
|
+
: PickChildren<Rest, Parent, Acc>
|
|
375
|
+
: Acc;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Bridge a runtime member array onto the precise member tuple recovered at the
|
|
379
|
+
* type level. Declared with an `unknown` parameter so the widening happens
|
|
380
|
+
* once, here, rather than as a cast at every projection — the array and the
|
|
381
|
+
* tuple follow the same (registry-declaration) order, which
|
|
382
|
+
* {@link validateRegistry} guarantees.
|
|
383
|
+
*/
|
|
384
|
+
const asMemberTuple = <T extends ReadonlyArray<Schema.Top>>(
|
|
385
|
+
members: ReadonlyArray<Schema.Top>,
|
|
386
|
+
): T =>
|
|
387
|
+
// SAFETY: every caller builds `members` by filtering the same entries the
|
|
388
|
+
// tuple type `T` was derived from, in the same order; the runtime array and
|
|
389
|
+
// the static tuple cannot disagree once `validateRegistry` has run.
|
|
390
|
+
members as T;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The `identifier` (linkage-ref) schema of each resource in a member tuple.
|
|
394
|
+
*
|
|
395
|
+
* @since 0.1.0
|
|
396
|
+
*/
|
|
397
|
+
export type Identifiers<Members extends readonly Resource.Any[]> = {
|
|
398
|
+
readonly [K in keyof Members]: Members[K]["identifier"];
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Runtime counterpart of {@link PickResources}: the resources flagged `flag`,
|
|
403
|
+
* in registry order, typed as the precise member tuple. A capability flag
|
|
404
|
+
* implies a resource ({@link validateRegistry}, which an application runs
|
|
405
|
+
* first), so the projection is total and the bridge cast is sound.
|
|
406
|
+
*
|
|
407
|
+
* @since 0.1.0
|
|
408
|
+
*/
|
|
409
|
+
export const flaggedMembers = <const T extends readonly RegistryEntry[], Flag extends RegistryFlag>(
|
|
410
|
+
entries: T,
|
|
411
|
+
flag: Flag,
|
|
412
|
+
): PickResources<T, Flag> =>
|
|
413
|
+
asMemberTuple<PickResources<T, Flag>>(
|
|
414
|
+
Array.filterMap(entries, (entry): Result.Result<Resource.Any, void> =>
|
|
415
|
+
entry[flag] === true && entry.resource !== undefined
|
|
416
|
+
? Result.succeed(entry.resource)
|
|
417
|
+
: Result.failVoid,
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Runtime counterpart of {@link PickChildren} over a whole branch: every
|
|
423
|
+
* resource-bearing leaf under `root` (one of `roots`), in registry order.
|
|
424
|
+
*
|
|
425
|
+
* The runtime walk is the whole tree; the static tuple is one level deep, so
|
|
426
|
+
* a caller whose leaves hang off marker levels beneath the root names those
|
|
427
|
+
* levels in `parents` (the root plus every marker under it — gardeneria's
|
|
428
|
+
* `entity` branch names `"entity" | "place" | "agent" | …`). A leaf hanging
|
|
429
|
+
* off a marker the caller forgot to name is in the array but not the tuple,
|
|
430
|
+
* which a wire-identity pin catches. `parents` defaults to the root alone.
|
|
431
|
+
*
|
|
432
|
+
* @since 0.1.0
|
|
433
|
+
*/
|
|
434
|
+
export const branchMembers = <
|
|
435
|
+
const T extends readonly RegistryEntry[],
|
|
436
|
+
const Root extends string,
|
|
437
|
+
const Parents extends string = Root,
|
|
438
|
+
>(
|
|
439
|
+
entries: T,
|
|
440
|
+
root: Root,
|
|
441
|
+
roots: ReadonlyArray<string>,
|
|
442
|
+
_parents?: ReadonlyArray<Parents>,
|
|
443
|
+
): PickChildren<T, Parents> => {
|
|
444
|
+
const byKey = indexByKey(entries);
|
|
445
|
+
return asMemberTuple<PickChildren<T, Parents>>(
|
|
446
|
+
Array.filterMap(entries, (entry): Result.Result<Resource.Any, void> =>
|
|
447
|
+
entry.resource !== undefined && Option.contains(branchOf(entry, byKey, roots), root)
|
|
448
|
+
? Result.succeed(entry.resource)
|
|
449
|
+
: Result.failVoid,
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The `identifier` schemas of a member tuple, for a branch's linkage-ref union.
|
|
456
|
+
*
|
|
457
|
+
* @since 0.1.0
|
|
458
|
+
*/
|
|
459
|
+
export const identifiersOf = <const Members extends readonly Resource.Any[]>(
|
|
460
|
+
members: Members,
|
|
461
|
+
): Identifiers<Members> =>
|
|
462
|
+
asMemberTuple<Identifiers<Members>>(Array.map(members, (member) => member.identifier));
|
|
463
|
+
|
|
464
|
+
// ── Derived wire identity ─────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The wire `type` literals of the derived families and branches — what a
|
|
468
|
+
* wire-identity pinning test asserts against the frozen contract, and what a
|
|
469
|
+
* subtype factory checks its per-leaf `access` declaration against
|
|
470
|
+
* (`owned` / `referenceData`).
|
|
471
|
+
*
|
|
472
|
+
* @since 0.1.0
|
|
473
|
+
*/
|
|
474
|
+
export interface RegistryWireTypes<Root extends string> {
|
|
475
|
+
readonly pageable: ReadonlyArray<string>;
|
|
476
|
+
readonly agent: ReadonlyArray<string>;
|
|
477
|
+
readonly owned: ReadonlyArray<string>;
|
|
478
|
+
readonly referenceData: ReadonlyArray<string>;
|
|
479
|
+
readonly searchable: ReadonlyArray<string>;
|
|
480
|
+
readonly routable: ReadonlyArray<string>;
|
|
481
|
+
readonly localised: ReadonlyArray<string>;
|
|
482
|
+
readonly branches: { readonly [K in Root]: ReadonlyArray<string> };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Project the wire types of every flag family and every branch of `roots`.
|
|
487
|
+
* A capability-flagged entry always carries its resource, so the projection
|
|
488
|
+
* never drops one.
|
|
489
|
+
*
|
|
490
|
+
* @since 0.1.0
|
|
491
|
+
*/
|
|
492
|
+
export const registryWireTypes = <Root extends string>(
|
|
493
|
+
entries: readonly RegistryEntry[],
|
|
494
|
+
roots: ReadonlyArray<Root>,
|
|
495
|
+
): RegistryWireTypes<Root> => {
|
|
496
|
+
const byKey = indexByKey(entries);
|
|
497
|
+
const flagged = (flag: RegistryFlag): ReadonlyArray<string> =>
|
|
498
|
+
Array.filterMap(entries, (entry): Result.Result<string, void> =>
|
|
499
|
+
entry[flag] === true && entry.resource !== undefined
|
|
500
|
+
? Result.succeed(entry.resource.type)
|
|
501
|
+
: Result.failVoid,
|
|
502
|
+
);
|
|
503
|
+
const branch = (root: Root): ReadonlyArray<string> =>
|
|
504
|
+
Array.filterMap(entries, (entry): Result.Result<string, void> =>
|
|
505
|
+
entry.resource !== undefined && Option.contains(branchOf(entry, byKey, roots), root)
|
|
506
|
+
? Result.succeed(entry.resource.type)
|
|
507
|
+
: Result.failVoid,
|
|
508
|
+
);
|
|
509
|
+
// SAFETY: `Record.fromEntries` erases the key union to `string`; the entries
|
|
510
|
+
// are built from exactly `roots`, so every `Root` key is present.
|
|
511
|
+
const branches = Record.fromEntries(
|
|
512
|
+
Array.map(roots, (root): [Root, ReadonlyArray<string>] => [root, branch(root)]),
|
|
513
|
+
) as { readonly [K in Root]: ReadonlyArray<string> };
|
|
514
|
+
return {
|
|
515
|
+
pageable: flagged("pageable"),
|
|
516
|
+
agent: flagged("agent"),
|
|
517
|
+
owned: flagged("owned"),
|
|
518
|
+
referenceData: flagged("referenceData"),
|
|
519
|
+
searchable: flagged("searchable"),
|
|
520
|
+
routable: flagged("routable"),
|
|
521
|
+
localised: flagged("localised"),
|
|
522
|
+
branches,
|
|
523
|
+
};
|
|
524
|
+
};
|