@ontrails/core 1.0.0-beta.10 → 1.0.0-beta.12

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 (64) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +1 -0
  4. package/dist/context.d.ts +2 -2
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +12 -7
  7. package/dist/context.js.map +1 -1
  8. package/dist/execute.d.ts +8 -3
  9. package/dist/execute.d.ts.map +1 -1
  10. package/dist/execute.js +25 -6
  11. package/dist/execute.js.map +1 -1
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/service-config.d.ts +22 -0
  17. package/dist/service-config.d.ts.map +1 -0
  18. package/dist/service-config.js +208 -0
  19. package/dist/service-config.js.map +1 -0
  20. package/dist/service.d.ts +75 -0
  21. package/dist/service.d.ts.map +1 -0
  22. package/dist/service.js +56 -0
  23. package/dist/service.js.map +1 -0
  24. package/dist/topo.d.ts +7 -0
  25. package/dist/topo.d.ts.map +1 -1
  26. package/dist/topo.js +37 -8
  27. package/dist/topo.js.map +1 -1
  28. package/dist/trail.d.ts +9 -2
  29. package/dist/trail.d.ts.map +1 -1
  30. package/dist/trail.js +2 -1
  31. package/dist/trail.js.map +1 -1
  32. package/dist/types.d.ts +27 -1
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js +2 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/validate-topo.d.ts.map +1 -1
  37. package/dist/validate-topo.js +16 -0
  38. package/dist/validate-topo.js.map +1 -1
  39. package/dist/validation.d.ts.map +1 -1
  40. package/dist/validation.js +34 -3
  41. package/dist/validation.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/__tests__/context.test.ts +12 -0
  44. package/src/__tests__/dispatch.test.ts +29 -2
  45. package/src/__tests__/execute.test.ts +318 -3
  46. package/src/__tests__/layer.test.ts +3 -2
  47. package/src/__tests__/service-config.test.ts +224 -0
  48. package/src/__tests__/service.test.ts +197 -0
  49. package/src/__tests__/topo.test.ts +71 -0
  50. package/src/__tests__/trail-permit.test.ts +60 -0
  51. package/src/__tests__/trail.test.ts +46 -2
  52. package/src/__tests__/validate-topo.test.ts +45 -1
  53. package/src/__tests__/validation.test.ts +53 -0
  54. package/src/context.ts +18 -9
  55. package/src/execute.ts +63 -9
  56. package/src/index.ts +20 -0
  57. package/src/service-config.ts +354 -0
  58. package/src/service.ts +145 -0
  59. package/src/topo.ts +53 -9
  60. package/src/trail.ts +21 -3
  61. package/src/types.ts +32 -1
  62. package/src/validate-topo.ts +22 -0
  63. package/src/validation.ts +35 -3
  64. package/tsconfig.tsbuildinfo +1 -1
package/src/topo.ts CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { ValidationError } from './errors.js';
6
6
  import type { AnyEvent } from './event.js';
7
+ import type { AnyService } from './service.js';
8
+ import { isService } from './service.js';
7
9
  import type { AnyTrail } from './trail.js';
8
10
 
9
11
  // ---------------------------------------------------------------------------
@@ -14,19 +16,25 @@ export interface Topo {
14
16
  readonly name: string;
15
17
  readonly trails: ReadonlyMap<string, AnyTrail>;
16
18
  readonly events: ReadonlyMap<string, AnyEvent>;
19
+ readonly services: ReadonlyMap<string, AnyService>;
17
20
  readonly count: number;
21
+ readonly serviceCount: number;
18
22
  get(id: string): AnyTrail | undefined;
23
+ getService(id: string): AnyService | undefined;
19
24
  has(id: string): boolean;
25
+ hasService(id: string): boolean;
20
26
  ids(): string[];
27
+ serviceIds(): string[];
21
28
  list(): AnyTrail[];
22
29
  listEvents(): AnyEvent[];
30
+ listServices(): AnyService[];
23
31
  }
24
32
 
25
33
  // ---------------------------------------------------------------------------
26
34
  // Kind discriminant check
27
35
  // ---------------------------------------------------------------------------
28
36
 
29
- type Registrable = AnyTrail | AnyEvent;
37
+ type Registrable = AnyTrail | AnyEvent | AnyService;
30
38
 
31
39
  const isRegistrable = (value: unknown): value is Registrable => {
32
40
  if (typeof value !== 'object' || value === null) {
@@ -43,16 +51,23 @@ const isRegistrable = (value: unknown): value is Registrable => {
43
51
  const createTopo = (
44
52
  name: string,
45
53
  trails: ReadonlyMap<string, AnyTrail>,
46
- events: ReadonlyMap<string, AnyEvent>
54
+ events: ReadonlyMap<string, AnyEvent>,
55
+ services: ReadonlyMap<string, AnyService>
47
56
  ): Topo => ({
48
57
  count: trails.size,
49
58
  events,
50
59
  get(id: string): AnyTrail | undefined {
51
60
  return trails.get(id);
52
61
  },
62
+ getService(id: string): AnyService | undefined {
63
+ return services.get(id);
64
+ },
53
65
  has(id: string): boolean {
54
66
  return trails.has(id);
55
67
  },
68
+ hasService(id: string): boolean {
69
+ return services.has(id);
70
+ },
56
71
 
57
72
  ids(): string[] {
58
73
  return [...trails.keys()];
@@ -65,9 +80,17 @@ const createTopo = (
65
80
  listEvents(): AnyEvent[] {
66
81
  return [...events.values()];
67
82
  },
83
+ listServices(): AnyService[] {
84
+ return [...services.values()];
85
+ },
68
86
 
69
87
  name,
88
+ serviceCount: services.size,
89
+ serviceIds(): string[] {
90
+ return [...services.keys()];
91
+ },
70
92
 
93
+ services,
71
94
  trails,
72
95
  });
73
96
 
@@ -79,7 +102,8 @@ const createTopo = (
79
102
  const register = (
80
103
  value: Registrable,
81
104
  trails: Map<string, AnyTrail>,
82
- events: Map<string, AnyEvent>
105
+ events: Map<string, AnyEvent>,
106
+ services: Map<string, AnyService>
83
107
  ): void => {
84
108
  const { id } = value as { id: string };
85
109
  const registrars: Record<string, () => void> = {
@@ -89,6 +113,12 @@ const register = (
89
113
  }
90
114
  events.set(id, value as AnyEvent);
91
115
  },
116
+ service: () => {
117
+ if (services.has(id)) {
118
+ throw new ValidationError(`Duplicate service ID: "${id}"`);
119
+ }
120
+ services.set(id, value as AnyService);
121
+ },
92
122
  trail: () => {
93
123
  if (trails.has(id)) {
94
124
  throw new ValidationError(`Duplicate trail ID: "${id}"`);
@@ -99,20 +129,34 @@ const register = (
99
129
  registrars[value.kind]?.();
100
130
  };
101
131
 
132
+ const registerModuleValues = (
133
+ mod: Record<string, unknown>,
134
+ trails: Map<string, AnyTrail>,
135
+ events: Map<string, AnyEvent>,
136
+ services: Map<string, AnyService>
137
+ ): void => {
138
+ for (const value of Object.values(mod)) {
139
+ if (isService(value)) {
140
+ register(value, trails, events, services);
141
+ continue;
142
+ }
143
+ if (isRegistrable(value)) {
144
+ register(value, trails, events, services);
145
+ }
146
+ }
147
+ };
148
+
102
149
  export const topo = (
103
150
  name: string,
104
151
  ...modules: Record<string, unknown>[]
105
152
  ): Topo => {
106
153
  const trails = new Map<string, AnyTrail>();
107
154
  const events = new Map<string, AnyEvent>();
155
+ const services = new Map<string, AnyService>();
108
156
 
109
157
  for (const mod of modules) {
110
- for (const value of Object.values(mod)) {
111
- if (isRegistrable(value)) {
112
- register(value, trails, events);
113
- }
114
- }
158
+ registerModuleValues(mod, trails, events, services);
115
159
  }
116
160
 
117
- return createTopo(name, trails, events);
161
+ return createTopo(name, trails, events, services);
118
162
  };
package/src/trail.ts CHANGED
@@ -2,7 +2,12 @@ import type { z } from 'zod';
2
2
 
3
3
  import type { FieldOverride } from './derive.js';
4
4
  import type { Result } from './result.js';
5
- import type { Implementation, TrailContext } from './types.js';
5
+ import type { AnyService } from './service.js';
6
+ import type {
7
+ Implementation,
8
+ PermitRequirement,
9
+ TrailContext,
10
+ } from './types.js';
6
11
 
7
12
  // ---------------------------------------------------------------------------
8
13
  // Trail example
@@ -56,6 +61,10 @@ export interface TrailSpec<I, O> {
56
61
  readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
57
62
  /** IDs of downstream trails this trail may invoke via ctx.follow() */
58
63
  readonly follow?: readonly string[] | undefined;
64
+ /** Services this trail may access via service.from(ctx) */
65
+ readonly services?: readonly AnyService[] | undefined;
66
+ /** Auth requirement: scopes object, 'public', or omitted (undeclared) */
67
+ readonly permit?: PermitRequirement | undefined;
59
68
  }
60
69
 
61
70
  // ---------------------------------------------------------------------------
@@ -68,13 +77,15 @@ export type Intent = 'read' | 'write' | 'destroy';
68
77
  /** A fully-defined trail — the unit of work in the Trails system */
69
78
  export interface Trail<I, O> extends Omit<
70
79
  TrailSpec<I, O>,
71
- 'run' | 'follow' | 'intent'
80
+ 'run' | 'follow' | 'intent' | 'services'
72
81
  > {
73
82
  readonly kind: 'trail';
74
83
  readonly id: string;
75
84
  readonly run: Implementation<I, O>;
76
85
  /** IDs of downstream trails this trail may invoke via ctx.follow() (always present, default []) */
77
86
  readonly follow: readonly string[];
87
+ /** Services this trail may access via service.from(ctx) (always present, default []) */
88
+ readonly services: readonly AnyService[];
78
89
  /** What this trail does to the world (always present, default 'write') */
79
90
  readonly intent: Intent;
80
91
  }
@@ -122,7 +133,13 @@ export function trail<I, O>(
122
133
  throw new TypeError('trail() requires a spec when an id is provided');
123
134
  }
124
135
 
125
- const { run, follow: rawFollow, intent: rawIntent, ...spec } = resolved.spec;
136
+ const {
137
+ run,
138
+ follow: rawFollow,
139
+ intent: rawIntent,
140
+ services: rawServices,
141
+ ...spec
142
+ } = resolved.spec;
126
143
 
127
144
  return Object.freeze({
128
145
  ...spec,
@@ -131,6 +148,7 @@ export function trail<I, O>(
131
148
  intent: rawIntent ?? 'write',
132
149
  kind: 'trail' as const,
133
150
  run: async (input: I, ctx: TrailContext) => await run(input, ctx),
151
+ services: Object.freeze([...(rawServices ?? [])]),
134
152
  });
135
153
  }
136
154
 
package/src/types.ts CHANGED
@@ -17,6 +17,11 @@ export type FollowFn = <O>(
17
17
  input: unknown
18
18
  ) => Promise<Result<O, Error>>;
19
19
 
20
+ /** Resolve a service instance from the current trail context. */
21
+ export type ServiceLookup = <T = unknown>(
22
+ serviceOrId: { readonly id: string } | string
23
+ ) => T;
24
+
20
25
  /** Callback for reporting progress from long-running trails */
21
26
  export type ProgressCallback = (event: ProgressEvent) => void;
22
27
 
@@ -41,16 +46,42 @@ export interface Logger {
41
46
  child(context: Record<string, unknown>): Logger;
42
47
  }
43
48
 
49
+ /** Context extension key for the invoking surface name. */
50
+ export const SURFACE_KEY = '__trails_surface' as const;
51
+
52
+ /** Minimal permit shape available on TrailContext. Permits extends this. */
53
+ export interface BasePermit {
54
+ readonly id: string;
55
+ readonly scopes: readonly string[];
56
+ }
57
+
44
58
  /** Runtime context threaded through every trail execution */
45
59
  export interface TrailContext {
46
60
  readonly requestId: string;
47
61
  readonly signal: AbortSignal;
48
62
  readonly follow?: FollowFn | undefined;
49
- readonly permit?: unknown | undefined;
63
+ readonly permit?: BasePermit;
50
64
  readonly workspaceRoot?: string | undefined;
51
65
  readonly logger?: Logger | undefined;
52
66
  readonly progress?: ProgressCallback | undefined;
53
67
  readonly cwd?: string | undefined;
54
68
  readonly env?: Record<string, string | undefined> | undefined;
55
69
  readonly extensions?: Readonly<Record<string, unknown>> | undefined;
70
+ readonly service?: ServiceLookup | undefined;
56
71
  }
72
+
73
+ /**
74
+ * Permit requirement declared on a trail spec.
75
+ *
76
+ * A scopes object means the trail requires a permit with those scopes.
77
+ * `'public'` means the trail has explicitly opted out of auth.
78
+ * Omitting the field entirely means the trail hasn't declared an auth posture.
79
+ */
80
+ export type PermitRequirement =
81
+ | { readonly scopes: readonly string[] }
82
+ | 'public';
83
+
84
+ /** Input shape used to seed a runtime TrailContext before resolution. */
85
+ export type TrailContextInit = Omit<TrailContext, 'service'> & {
86
+ readonly service?: ServiceLookup | undefined;
87
+ };
@@ -113,6 +113,27 @@ const checkFollows = (
113
113
  return issues;
114
114
  };
115
115
 
116
+ const checkServices = (
117
+ trails: ReadonlyMap<string, AnyTrail>,
118
+ topo: Topo
119
+ ): TopoIssue[] => {
120
+ const issues: TopoIssue[] = [];
121
+
122
+ for (const [id, trail] of trails) {
123
+ for (const declaredService of trail.services) {
124
+ if (!topo.hasService(declaredService.id)) {
125
+ issues.push({
126
+ message: `Service "${declaredService.id}" is not in the topo`,
127
+ rule: 'service-exists',
128
+ trailId: id,
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ return issues;
135
+ };
136
+
116
137
  const checkOneExample = (
117
138
  id: string,
118
139
  example: {
@@ -192,6 +213,7 @@ const checkEventOrigins = (
192
213
  export const validateTopo = (topo: Topo): Result<void, ValidationError> => {
193
214
  const issues = [
194
215
  ...checkFollows(topo.trails, topo),
216
+ ...checkServices(topo.trails, topo),
195
217
  ...checkExamples(topo.trails),
196
218
  ...checkEventOrigins(topo.events, topo),
197
219
  ];
package/src/validation.ts CHANGED
@@ -94,6 +94,34 @@ export const validateOutput = <T>(
94
94
  // Zod → JSON Schema (public API)
95
95
  // ---------------------------------------------------------------------------
96
96
 
97
+ /**
98
+ * Sentinel indicating a dynamic default that should be omitted from schema
99
+ * exports. Zod v4 wraps all defaults in getters; dynamic ones (functions)
100
+ * produce new values on each access. We detect this by reading the getter
101
+ * twice and comparing with `Object.is`. If values differ, the default is
102
+ * dynamic and we cache this sentinel to skip it in future calls.
103
+ */
104
+ const DYNAMIC_DEFAULT = Symbol('DYNAMIC_DEFAULT');
105
+ const defaultValueCache = new WeakMap<object, unknown>();
106
+
107
+ /** Read a Zod v4 default getter twice and decide if it's stable.
108
+ * Uses Object.is for primitives and JSON.stringify for objects/arrays. */
109
+ const resolveDefault = (def: Record<string, unknown>): unknown => {
110
+ try {
111
+ const a = def['defaultValue'];
112
+ const b = def['defaultValue'];
113
+ if (Object.is(a, b)) {
114
+ return a;
115
+ }
116
+ // Object/array defaults produce new references each call but may
117
+ // still be structurally identical (e.g. `() => ({ key: 'val' })`).
118
+ return JSON.stringify(a) === JSON.stringify(b) ? a : DYNAMIC_DEFAULT;
119
+ } catch {
120
+ // BigInt, circular refs, or other non-serializable defaults
121
+ return DYNAMIC_DEFAULT;
122
+ }
123
+ };
124
+
97
125
  /**
98
126
  * Convert common Zod types to a JSON Schema object.
99
127
  *
@@ -142,9 +170,13 @@ export const zodToJsonSchema: JsonSchemaConverter = (
142
170
  default: (value) => {
143
171
  const inner = value._zod.def['innerType'] as unknown as z.ZodType;
144
172
  const innerSchema = zodToJsonSchema(inner);
145
- const rawDefault = value._zod.def['defaultValue'];
146
- innerSchema['default'] =
147
- typeof rawDefault === 'function' ? rawDefault() : rawDefault;
173
+ if (!defaultValueCache.has(value._zod.def)) {
174
+ defaultValueCache.set(value._zod.def, resolveDefault(value._zod.def));
175
+ }
176
+ const cached = defaultValueCache.get(value._zod.def);
177
+ if (cached !== DYNAMIC_DEFAULT) {
178
+ innerSchema['default'] = cached;
179
+ }
148
180
  return innerSchema;
149
181
  },
150
182
  enum: (value) => {
@@ -1 +1 @@
1
- {"root":["./src/blob-ref.ts","./src/branded.ts","./src/collections.ts","./src/context.ts","./src/derive.ts","./src/dispatch.ts","./src/errors.ts","./src/event.ts","./src/execute.ts","./src/fetch.ts","./src/guards.ts","./src/index.ts","./src/layer.ts","./src/path-security.ts","./src/resilience.ts","./src/result.ts","./src/serialization.ts","./src/topo.ts","./src/trail.ts","./src/type-utils.ts","./src/types.ts","./src/validate-topo.ts","./src/validation.ts","./src/workspace.ts","./src/patterns/bulk.ts","./src/patterns/change.ts","./src/patterns/date-range.ts","./src/patterns/index.ts","./src/patterns/pagination.ts","./src/patterns/progress.ts","./src/patterns/sorting.ts","./src/patterns/status.ts","./src/patterns/timestamps.ts","./src/redaction/index.ts","./src/redaction/patterns.ts","./src/redaction/redactor.ts"],"version":"5.9.3"}
1
+ {"root":["./src/blob-ref.ts","./src/branded.ts","./src/collections.ts","./src/context.ts","./src/derive.ts","./src/dispatch.ts","./src/errors.ts","./src/event.ts","./src/execute.ts","./src/fetch.ts","./src/guards.ts","./src/index.ts","./src/layer.ts","./src/path-security.ts","./src/resilience.ts","./src/result.ts","./src/serialization.ts","./src/service-config.ts","./src/service.ts","./src/topo.ts","./src/trail.ts","./src/type-utils.ts","./src/types.ts","./src/validate-topo.ts","./src/validation.ts","./src/workspace.ts","./src/patterns/bulk.ts","./src/patterns/change.ts","./src/patterns/date-range.ts","./src/patterns/index.ts","./src/patterns/pagination.ts","./src/patterns/progress.ts","./src/patterns/sorting.ts","./src/patterns/status.ts","./src/patterns/timestamps.ts","./src/redaction/index.ts","./src/redaction/patterns.ts","./src/redaction/redactor.ts"],"version":"5.9.3"}