@ontrails/core 0.2.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 (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
package/src/draft.ts ADDED
@@ -0,0 +1,350 @@
1
+ import type { AnyEntity } from './entity.js';
2
+ import { getEntityReferences } from './entity.js';
3
+ import { ValidationError } from './errors.js';
4
+ import type { AnySignal } from './signal.js';
5
+ import type { AnyResource } from './resource.js';
6
+ import { Result } from './result.js';
7
+ import type { Topo } from './topo.js';
8
+ import type { AnyTrail } from './trail.js';
9
+
10
+ export const DRAFT_ID_PREFIX = '_draft.';
11
+
12
+ export type DraftDependencyKind =
13
+ | 'compose'
14
+ | 'entity'
15
+ | 'resource'
16
+ | 'replaced-by'
17
+ | 'schema-reference'
18
+ | 'signal-fire'
19
+ | 'signal-on'
20
+ | 'signal-from';
21
+
22
+ export interface DraftDependency {
23
+ readonly fromId: string;
24
+ readonly kind: DraftDependencyKind;
25
+ readonly toId: string;
26
+ }
27
+
28
+ export interface DraftDiagnostic {
29
+ readonly id: string;
30
+ readonly kind: 'entity' | 'resource' | 'signal' | 'trail' | 'unknown';
31
+ readonly message: string;
32
+ readonly rule: 'draft-contamination' | 'draft-id';
33
+ readonly via?: DraftDependencyKind | undefined;
34
+ readonly dependsOn?: string | undefined;
35
+ }
36
+
37
+ /**
38
+ * @deprecated Use {@link DraftDiagnostic}. Kept as a source-compatible alias
39
+ * during the v1 vocabulary cutover.
40
+ */
41
+ export interface DraftFinding extends DraftDiagnostic {
42
+ readonly id: DraftDiagnostic['id'];
43
+ }
44
+
45
+ export interface DraftReport {
46
+ readonly contaminatedIds: ReadonlySet<string>;
47
+ readonly declaredDraftIds: ReadonlySet<string>;
48
+ readonly dependencies: readonly DraftDependency[];
49
+ readonly findings: readonly DraftDiagnostic[];
50
+ }
51
+
52
+ interface DraftReason {
53
+ readonly dependsOn: string;
54
+ readonly via: DraftDependencyKind;
55
+ }
56
+
57
+ type TopoNode = AnyEntity | AnyResource | AnySignal | AnyTrail;
58
+
59
+ const replacedByTarget = (value: TopoNode): string | undefined => {
60
+ const raw = value as unknown as { replacedBy?: unknown };
61
+ return typeof raw.replacedBy === 'string' ? raw.replacedBy : undefined;
62
+ };
63
+
64
+ export const isDraftId = (id: string): boolean =>
65
+ id.startsWith(DRAFT_ID_PREFIX);
66
+
67
+ const dependenciesFromIds = (
68
+ fromId: string,
69
+ toIds: readonly string[],
70
+ kind: DraftDependencyKind
71
+ ): DraftDependency[] =>
72
+ toIds.map((toId) => ({
73
+ fromId,
74
+ kind,
75
+ toId,
76
+ }));
77
+
78
+ const dependencyFromTarget = (
79
+ fromId: string,
80
+ toId: string | undefined,
81
+ kind: DraftDependencyKind
82
+ ): DraftDependency[] =>
83
+ toId === undefined
84
+ ? []
85
+ : [
86
+ {
87
+ fromId,
88
+ kind,
89
+ toId,
90
+ },
91
+ ];
92
+
93
+ const trailDependencies = (trail: AnyTrail): DraftDependency[] => [
94
+ ...dependenciesFromIds(
95
+ trail.id,
96
+ (trail.entities ?? []).map((entity) => entity.name),
97
+ 'entity'
98
+ ),
99
+ ...dependenciesFromIds(trail.id, trail.composes, 'compose'),
100
+ ...dependenciesFromIds(
101
+ trail.id,
102
+ trail.resources.map(({ id }) => id),
103
+ 'resource'
104
+ ),
105
+ ...dependenciesFromIds(trail.id, trail.fires ?? [], 'signal-fire'),
106
+ ...dependenciesFromIds(trail.id, trail.on ?? [], 'signal-on'),
107
+ ...dependencyFromTarget(trail.id, replacedByTarget(trail), 'replaced-by'),
108
+ ];
109
+
110
+ const entityDependencies = (entity: AnyEntity): DraftDependency[] =>
111
+ dependenciesFromIds(
112
+ entity.name,
113
+ getEntityReferences(entity).map((reference) => reference.entity),
114
+ 'schema-reference'
115
+ );
116
+
117
+ const signalDependencies = (signal: AnySignal): DraftDependency[] => [
118
+ ...dependenciesFromIds(signal.id, signal.from ?? [], 'signal-from'),
119
+ ...dependencyFromTarget(signal.id, replacedByTarget(signal), 'replaced-by'),
120
+ ];
121
+
122
+ const resourceDependencies = (resource: AnyResource): DraftDependency[] =>
123
+ dependencyFromTarget(resource.id, replacedByTarget(resource), 'replaced-by');
124
+
125
+ const nodeKind = (
126
+ id: string,
127
+ entities: ReadonlyMap<string, AnyEntity>,
128
+ trails: ReadonlyMap<string, AnyTrail>,
129
+ signals: ReadonlyMap<string, AnySignal>,
130
+ resources: ReadonlyMap<string, AnyResource>
131
+ ): DraftDiagnostic['kind'] => {
132
+ if (entities.has(id)) {
133
+ return 'entity';
134
+ }
135
+ if (trails.has(id)) {
136
+ return 'trail';
137
+ }
138
+ if (signals.has(id)) {
139
+ return 'signal';
140
+ }
141
+ if (resources.has(id)) {
142
+ return 'resource';
143
+ }
144
+ return 'unknown';
145
+ };
146
+
147
+ const displayKind = (kind: DraftDiagnostic['kind']): string =>
148
+ kind === 'unknown' ? 'Node' : kind[0]?.toUpperCase() + kind.slice(1);
149
+
150
+ const draftIdsFromKeys = (keys: Iterable<string>): string[] =>
151
+ [...keys].filter(isDraftId);
152
+
153
+ const collectDeclaredDraftIds = (topo: Topo): ReadonlySet<string> =>
154
+ new Set([
155
+ ...draftIdsFromKeys(topo.entities.keys()),
156
+ ...draftIdsFromKeys(topo.trails.keys()),
157
+ ...draftIdsFromKeys(topo.signals.keys()),
158
+ ...draftIdsFromKeys(topo.resources.keys()),
159
+ ]);
160
+
161
+ const findingForDraftId = (
162
+ id: string,
163
+ kind: DraftDiagnostic['kind']
164
+ ): DraftDiagnostic => ({
165
+ id,
166
+ kind,
167
+ message: `${displayKind(id ? kind : 'unknown')} "${id}" is draft and cannot appear in the established graph.`,
168
+ rule: 'draft-id',
169
+ });
170
+
171
+ const findingForContamination = (
172
+ id: string,
173
+ kind: DraftDiagnostic['kind'],
174
+ reason: DraftReason
175
+ ): DraftDiagnostic => {
176
+ const dependencyLabel = isDraftId(reason.dependsOn)
177
+ ? `draft "${reason.dependsOn}"`
178
+ : `draft-contaminated "${reason.dependsOn}"`;
179
+
180
+ return {
181
+ dependsOn: reason.dependsOn,
182
+ id,
183
+ kind,
184
+ message:
185
+ `Established ${kind} "${id}" depends on ${dependencyLabel} ` +
186
+ `via ${reason.via} and cannot appear in the established graph.`,
187
+ rule: 'draft-contamination',
188
+ via: reason.via,
189
+ };
190
+ };
191
+
192
+ const contaminationReason = (
193
+ dependency: DraftDependency,
194
+ contaminatedIds: ReadonlySet<string>
195
+ ): DraftReason | undefined => {
196
+ if (contaminatedIds.has(dependency.fromId)) {
197
+ return undefined;
198
+ }
199
+
200
+ if (!isDraftId(dependency.toId) && !contaminatedIds.has(dependency.toId)) {
201
+ return undefined;
202
+ }
203
+
204
+ return {
205
+ dependsOn: dependency.toId,
206
+ via: dependency.kind,
207
+ };
208
+ };
209
+
210
+ const markContaminatedDependency = (
211
+ dependency: DraftDependency,
212
+ contaminatedIds: Set<string>,
213
+ reasons: Map<string, DraftReason>
214
+ ): boolean => {
215
+ const reason = contaminationReason(dependency, contaminatedIds);
216
+
217
+ if (reason === undefined) {
218
+ return false;
219
+ }
220
+
221
+ contaminatedIds.add(dependency.fromId);
222
+ reasons.set(dependency.fromId, reason);
223
+ return true;
224
+ };
225
+
226
+ const propagateContaminatedIds = (
227
+ declaredDraftIds: ReadonlySet<string>,
228
+ dependencies: readonly DraftDependency[]
229
+ ): { contaminatedIds: Set<string>; reasons: Map<string, DraftReason> } => {
230
+ const contaminatedIds = new Set<string>(declaredDraftIds);
231
+ const reasons = new Map<string, DraftReason>();
232
+
233
+ const visit = (): void => {
234
+ if (
235
+ dependencies.some((dependency) =>
236
+ markContaminatedDependency(dependency, contaminatedIds, reasons)
237
+ )
238
+ ) {
239
+ visit();
240
+ }
241
+ };
242
+
243
+ visit();
244
+ return { contaminatedIds, reasons };
245
+ };
246
+
247
+ const contaminationFindingForId = (
248
+ id: string,
249
+ declaredDraftIds: ReadonlySet<string>,
250
+ reasons: ReadonlyMap<string, DraftReason>,
251
+ entities: ReadonlyMap<string, AnyEntity>,
252
+ trails: ReadonlyMap<string, AnyTrail>,
253
+ signals: ReadonlyMap<string, AnySignal>,
254
+ resources: ReadonlyMap<string, AnyResource>
255
+ ): DraftDiagnostic | undefined => {
256
+ if (declaredDraftIds.has(id)) {
257
+ return undefined;
258
+ }
259
+
260
+ const reason = reasons.get(id);
261
+ if (reason === undefined) {
262
+ return undefined;
263
+ }
264
+
265
+ return findingForContamination(
266
+ id,
267
+ nodeKind(id, entities, trails, signals, resources),
268
+ reason
269
+ );
270
+ };
271
+
272
+ const collectFindings = (
273
+ declaredDraftIds: ReadonlySet<string>,
274
+ contaminatedIds: ReadonlySet<string>,
275
+ reasons: ReadonlyMap<string, DraftReason>,
276
+ entities: ReadonlyMap<string, AnyEntity>,
277
+ trails: ReadonlyMap<string, AnyTrail>,
278
+ signals: ReadonlyMap<string, AnySignal>,
279
+ resources: ReadonlyMap<string, AnyResource>
280
+ ): DraftDiagnostic[] => [
281
+ ...[...declaredDraftIds]
282
+ .toSorted()
283
+ .map((id) =>
284
+ findingForDraftId(id, nodeKind(id, entities, trails, signals, resources))
285
+ ),
286
+ ...[...contaminatedIds].toSorted().flatMap((id) => {
287
+ const finding = contaminationFindingForId(
288
+ id,
289
+ declaredDraftIds,
290
+ reasons,
291
+ entities,
292
+ trails,
293
+ signals,
294
+ resources
295
+ );
296
+
297
+ return finding === undefined ? [] : [finding];
298
+ }),
299
+ ];
300
+
301
+ const collectDependencies = (topo: Topo): DraftDependency[] => [
302
+ ...[...topo.entities.values()].flatMap(entityDependencies),
303
+ ...[...topo.trails.values()].flatMap(trailDependencies),
304
+ ...[...topo.signals.values()].flatMap(signalDependencies),
305
+ ...[...topo.resources.values()].flatMap(resourceDependencies),
306
+ ];
307
+
308
+ export const deriveDraftReport = (topo: Topo): DraftReport => {
309
+ const declaredDraftIds = collectDeclaredDraftIds(topo);
310
+ const dependencies = collectDependencies(topo);
311
+ const { contaminatedIds, reasons } = propagateContaminatedIds(
312
+ declaredDraftIds,
313
+ dependencies
314
+ );
315
+ const findings = collectFindings(
316
+ declaredDraftIds,
317
+ contaminatedIds,
318
+ reasons,
319
+ topo.entities,
320
+ topo.trails,
321
+ topo.signals,
322
+ topo.resources
323
+ );
324
+
325
+ return {
326
+ contaminatedIds,
327
+ declaredDraftIds,
328
+ dependencies,
329
+ findings,
330
+ };
331
+ };
332
+
333
+ export const validateDraftFreeTopo = (
334
+ topo: Topo
335
+ ): Result<void, ValidationError> => {
336
+ const analysis = deriveDraftReport(topo);
337
+
338
+ if (analysis.findings.length === 0) {
339
+ return Result.ok();
340
+ }
341
+
342
+ return Result.err(
343
+ new ValidationError(
344
+ `Established topo validation failed with ${analysis.findings.length} draft issue(s)`,
345
+ {
346
+ context: { issues: analysis.findings },
347
+ }
348
+ )
349
+ );
350
+ };
package/src/entity.ts ADDED
@@ -0,0 +1,346 @@
1
+ import { z } from 'zod';
2
+
3
+ import type { Branded } from './branded.js';
4
+
5
+ /**
6
+ * Runtime options for an entity declaration.
7
+ */
8
+ export interface EntityOptions<
9
+ TShape extends z.ZodRawShape,
10
+ TIdentity extends keyof TShape & string,
11
+ > {
12
+ /** Field name that acts as the entity's primary identity. */
13
+ readonly identity: TIdentity;
14
+ /** Example instances validated against the entity schema at declaration time. */
15
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
16
+ /** Reserved for future entity-specific design; trail versioning is trail-only. */
17
+ readonly version?: never;
18
+ }
19
+
20
+ /** Type-level brand name applied to an entity's identity schema. */
21
+ export type EntityIdBrand<TName extends string> = `${Capitalize<TName>}Id`;
22
+
23
+ type BrandedSchema<
24
+ TSchema extends z.core.$ZodType,
25
+ TBrand extends string,
26
+ > = TSchema & z.ZodType<Branded<z.output<TSchema>, TBrand>>;
27
+
28
+ type BrandableSchema<TSchema extends z.core.$ZodType> = TSchema & {
29
+ brand<TBrand extends string>(): BrandedSchema<TSchema, TBrand>;
30
+ };
31
+
32
+ /** Output value of a branded entity identity schema. */
33
+ export type EntityIdValue<
34
+ TSchema extends z.core.$ZodType,
35
+ TName extends string,
36
+ > = Branded<z.output<TSchema>, EntityIdBrand<TName>>;
37
+
38
+ /** Runtime metadata attached to schemas returned from `entity.id()`. */
39
+ export interface EntityIdMetadata<
40
+ TName extends string = string,
41
+ TIdentity extends string = string,
42
+ > {
43
+ readonly entity: TName;
44
+ readonly identity: TIdentity;
45
+ }
46
+
47
+ /** A structural entity reference declared by another entity field schema. */
48
+ export interface EntityReference<
49
+ TName extends string = string,
50
+ TIdentity extends string = string,
51
+ > extends EntityIdMetadata<TName, TIdentity> {
52
+ readonly field: string;
53
+ }
54
+
55
+ /** Symbol used to tag branded entity reference schemas at runtime. */
56
+ export const ENTITY_ID_METADATA = Symbol.for('@ontrails/core/entity-id');
57
+
58
+ /**
59
+ * Module-level WeakMap storing entity identity metadata keyed by schema object.
60
+ *
61
+ * First-write-wins: when multiple entities share the same underlying schema
62
+ * (e.g. `entity('admin', { id: user.shape.id }, ...)`), the first entity to
63
+ * brand the schema claims it. Subsequent calls skip the write to prevent
64
+ * silent metadata corruption.
65
+ */
66
+ const entityIdMetadata = new WeakMap<object, EntityIdMetadata>();
67
+
68
+ /**
69
+ * An entity identity schema branded for one entity and tagged with runtime
70
+ * metadata so the topo layer can recognize declared references later on.
71
+ */
72
+ export type EntityIdSchema<
73
+ TSchema extends z.core.$ZodType = z.core.$ZodType,
74
+ TName extends string = string,
75
+ TIdentity extends string = string,
76
+ > = BrandedSchema<TSchema, EntityIdBrand<TName>> & {
77
+ /** @deprecated Use `getEntityIdMetadata()` — metadata lives in a WeakMap, not on the schema. */
78
+ readonly [ENTITY_ID_METADATA]?: EntityIdMetadata<TName, TIdentity>;
79
+ };
80
+
81
+ /**
82
+ * A first-class domain object with schema, identity metadata, and examples.
83
+ *
84
+ * An entity behaves like the `ZodObject` it wraps, so standard Zod composition
85
+ * helpers such as `.pick()`, `.extend()`, and `.array()` continue to work.
86
+ */
87
+ export type Entity<
88
+ TName extends string = string,
89
+ TShape extends z.ZodRawShape = z.ZodRawShape,
90
+ TIdentity extends keyof TShape & string = keyof TShape & string,
91
+ > = z.ZodObject<TShape> & {
92
+ readonly kind: 'entity';
93
+ readonly name: TName;
94
+ readonly identity: TIdentity;
95
+ readonly identitySchema: TShape[TIdentity];
96
+ readonly id: () => EntityIdSchema<TShape[TIdentity], TName, TIdentity>;
97
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
98
+ };
99
+
100
+ const formatExampleIssues = (issues: readonly z.core.$ZodIssue[]): string =>
101
+ issues
102
+ .map((issue) => {
103
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
104
+ return `${path}: ${issue.message}`;
105
+ })
106
+ .join('; ');
107
+
108
+ const assertIdentityField = <
109
+ TShape extends z.ZodRawShape,
110
+ TIdentity extends keyof TShape & string,
111
+ >(
112
+ name: string,
113
+ shape: TShape,
114
+ identity: TIdentity
115
+ ): void => {
116
+ if (!Object.hasOwn(shape, identity)) {
117
+ throw new TypeError(
118
+ `entity("${name}") identity "${identity}" must match a declared field`
119
+ );
120
+ }
121
+ };
122
+
123
+ const assertExamples = <TShape extends z.ZodRawShape>(
124
+ name: string,
125
+ schema: z.ZodObject<TShape>,
126
+ examples: readonly z.output<z.ZodObject<TShape>>[]
127
+ ): void => {
128
+ for (const [index, example] of examples.entries()) {
129
+ const parsed = schema.safeParse(example);
130
+ if (!parsed.success) {
131
+ throw new TypeError(
132
+ `entity("${name}") example ${index} is invalid: ${formatExampleIssues(parsed.error.issues)}`
133
+ );
134
+ }
135
+ }
136
+ };
137
+
138
+ const validateExamples = <TShape extends z.ZodRawShape>(
139
+ name: string,
140
+ schema: z.ZodObject<TShape>,
141
+ examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined
142
+ ): void => {
143
+ if (examples) {
144
+ assertExamples(name, schema, examples);
145
+ }
146
+ };
147
+
148
+ const brandIdentitySchema = <
149
+ TSchema extends z.core.$ZodType,
150
+ TName extends string,
151
+ TIdentity extends string,
152
+ >(
153
+ entity: TName,
154
+ identity: TIdentity,
155
+ schema: TSchema
156
+ ): EntityIdSchema<TSchema, TName, TIdentity> => {
157
+ const branded = (schema as BrandableSchema<TSchema>).brand<
158
+ EntityIdBrand<TName>
159
+ >();
160
+
161
+ // First-write-wins: if another entity already claimed this schema object
162
+ // (possible when Zod v4 brand() returns `this`), preserve the original
163
+ // metadata rather than silently overwriting it.
164
+ if (!entityIdMetadata.has(branded)) {
165
+ entityIdMetadata.set(branded, {
166
+ entity,
167
+ identity,
168
+ } satisfies EntityIdMetadata<TName, TIdentity>);
169
+ }
170
+
171
+ return branded as EntityIdSchema<TSchema, TName, TIdentity>;
172
+ };
173
+
174
+ const attachEntityMetadata = <
175
+ TName extends string,
176
+ TShape extends z.ZodRawShape,
177
+ TIdentity extends keyof TShape & string,
178
+ >(
179
+ schema: z.ZodObject<TShape>,
180
+ metadata: {
181
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
182
+ readonly idSchema: EntityIdSchema<TShape[TIdentity], TName, TIdentity>;
183
+ readonly identity: TIdentity;
184
+ readonly identitySchema: TShape[TIdentity];
185
+ readonly name: TName;
186
+ }
187
+ ): void => {
188
+ Object.defineProperties(schema, {
189
+ examples: {
190
+ enumerable: true,
191
+ value: metadata.examples,
192
+ writable: false,
193
+ },
194
+ id: {
195
+ enumerable: true,
196
+ value: () => metadata.idSchema,
197
+ writable: false,
198
+ },
199
+ identity: {
200
+ enumerable: true,
201
+ value: metadata.identity,
202
+ writable: false,
203
+ },
204
+ identitySchema: {
205
+ enumerable: true,
206
+ value: metadata.identitySchema,
207
+ writable: false,
208
+ },
209
+ kind: {
210
+ enumerable: true,
211
+ value: 'entity',
212
+ writable: false,
213
+ },
214
+ name: {
215
+ enumerable: true,
216
+ value: metadata.name,
217
+ writable: false,
218
+ },
219
+ });
220
+ };
221
+
222
+ /** Read entity identity metadata from the module-level WeakMap, if present. */
223
+ const readMetadata = (schema: unknown): EntityIdMetadata | undefined =>
224
+ typeof schema === 'object' && schema !== null
225
+ ? entityIdMetadata.get(schema)
226
+ : undefined;
227
+
228
+ /** Resolve the inner schema from a Zod wrapper (ZodOptional, ZodNullable, etc.). */
229
+ const unwrapInner = (schema: unknown): unknown => {
230
+ const def = (schema as { _def?: Record<string, unknown> })._def;
231
+ return (def?.['innerType'] ?? def?.['schema']) as unknown;
232
+ };
233
+
234
+ /**
235
+ * Walk through Zod wrapper layers searching for `ENTITY_ID_METADATA`.
236
+ *
237
+ * `.nullish()` produces `ZodOptional<ZodNullable<T>>` — two wrapper levels —
238
+ * so a single-step unwrap is insufficient. This iterates until it finds the
239
+ * metadata or exhausts all wrapper layers.
240
+ */
241
+ const unwrapToMetadata = (schema: unknown): EntityIdMetadata | undefined => {
242
+ let current: unknown = schema;
243
+ while (typeof current === 'object' && current !== null) {
244
+ const inner = unwrapInner(current);
245
+ if (typeof inner !== 'object' || inner === null) {
246
+ return undefined;
247
+ }
248
+ const metadata = readMetadata(inner);
249
+ if (metadata !== undefined) {
250
+ return metadata;
251
+ }
252
+ current = inner;
253
+ }
254
+ return undefined;
255
+ };
256
+
257
+ /**
258
+ * Read entity-reference metadata from a schema returned by `entity.id()`.
259
+ *
260
+ * When the schema is wrapped by Zod combinators (`.optional()`, `.nullable()`,
261
+ * `.default()`, `.nullish()`, etc.) the `ENTITY_ID_METADATA` symbol lives on
262
+ * the inner schema, not on the wrapper. The unwrap handles arbitrarily nested
263
+ * wrapper levels.
264
+ */
265
+ export const getEntityIdMetadata = (
266
+ schema: unknown
267
+ ): EntityIdMetadata | undefined =>
268
+ readMetadata(schema) ?? unwrapToMetadata(schema);
269
+
270
+ /** Inspect an entity schema for fields that reference other entities via `.id()`. */
271
+ export const getEntityReferences = (
272
+ entity: AnyEntity
273
+ ): readonly EntityReference[] =>
274
+ Object.entries(entity.shape)
275
+ .flatMap(([field, schema]) => {
276
+ if (field === entity.identity) {
277
+ return [];
278
+ }
279
+ const metadata = getEntityIdMetadata(schema);
280
+ if (metadata === undefined) {
281
+ return [];
282
+ }
283
+
284
+ return [{ field, ...metadata }];
285
+ })
286
+ .toSorted((left, right) =>
287
+ left.field === right.field
288
+ ? left.entity.localeCompare(right.entity)
289
+ : left.field.localeCompare(right.field)
290
+ );
291
+
292
+ /**
293
+ * Create an entity definition from a raw Zod object shape.
294
+ *
295
+ * @example
296
+ * ```typescript
297
+ * const user = entity(
298
+ * 'user',
299
+ * {
300
+ * id: z.string().uuid(),
301
+ * email: z.string().email(),
302
+ * name: z.string(),
303
+ * },
304
+ * { identity: 'id' }
305
+ * );
306
+ * ```
307
+ */
308
+ export const entity = <
309
+ TName extends string,
310
+ TShape extends z.ZodRawShape,
311
+ TIdentity extends keyof TShape & string,
312
+ >(
313
+ name: TName,
314
+ shape: TShape,
315
+ options: EntityOptions<TShape, TIdentity>
316
+ ): Entity<TName, TShape, TIdentity> => {
317
+ assertIdentityField(name, shape, options.identity);
318
+
319
+ const schema = z.object(shape);
320
+ validateExamples(name, schema, options.examples);
321
+
322
+ const identitySchema = shape[options.identity];
323
+ if (!identitySchema) {
324
+ throw new TypeError(
325
+ `entity("${name}") identity "${options.identity}" must resolve to a schema`
326
+ );
327
+ }
328
+
329
+ const idSchema = brandIdentitySchema(name, options.identity, identitySchema);
330
+ const examples = options.examples
331
+ ? Object.freeze([...options.examples])
332
+ : undefined;
333
+
334
+ attachEntityMetadata(schema, {
335
+ examples,
336
+ idSchema,
337
+ identity: options.identity,
338
+ identitySchema,
339
+ name,
340
+ });
341
+
342
+ return schema as Entity<TName, TShape, TIdentity>;
343
+ };
344
+
345
+ /** Existential type for heterogeneous entity collections. */
346
+ export type AnyEntity = Entity<string, z.ZodRawShape, string>;