@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
@@ -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
+ };