diff --git a/.agents/skills/trails-adrs/scripts/adr.ts b/.agents/skills/trails-adrs/scripts/adr.ts index a1cda00a9..f74305216 100644 --- a/.agents/skills/trails-adrs/scripts/adr.ts +++ b/.agents/skills/trails-adrs/scripts/adr.ts @@ -25,7 +25,10 @@ import { dirname, join } from 'node:path'; import { parseArgs, shouldApply, previewBanner, printHelp } from './lib/cli.ts'; import type { Args } from './lib/cli.ts'; -import { writeDecisionMap } from './lib/decision-map.ts'; +import { + buildDecisionMapContents, + writeDecisionMap, +} from './lib/decision-map.ts'; import { listNumberedAdrs, listDrafts, @@ -40,7 +43,7 @@ import { extractTitle, serializeFrontmatter } from './lib/frontmatter.ts'; import type { Frontmatter } from './lib/frontmatter.ts'; import { gitMove } from './lib/git.ts'; import { rebuildIndex } from './lib/index.ts'; -import { ADR_DIR, DRAFTS_DIR, INDEX_PATH, MAP_PATH } from './lib/paths.ts'; +import { ADR_DIR, DRAFTS_DIR, INDEX_PATH } from './lib/paths.ts'; import { fixCrossReferences, rewriteDraftLinks, @@ -477,21 +480,29 @@ const cmdCheck = (args: Args): void => { report('error', 'README.md', 'index file does not exist'); } - console.log('Checking decision map...'); - if (!existsSync(MAP_PATH)) { - report( - 'warn', - 'decision-map.json', - 'decision map does not exist — run "adr map" to generate' - ); - } - if (fix && fixes > 0) { console.log(`\nApplied ${fixes} fixes. Rebuilding index and map...`); rebuildIndex(); writeDecisionMap(); } + console.log('Checking decision map...'); + for (const artifact of buildDecisionMapContents()) { + const label = artifact.path.slice(process.cwd().length + 1); + if (!existsSync(artifact.path)) { + report('error', label, 'generated ADR artifact is missing'); + continue; + } + const committed = readFileSync(artifact.path, 'utf8'); + if (committed !== artifact.content) { + report( + 'error', + label, + 'generated ADR artifact is stale; run "bun scripts/adr.ts map"' + ); + } + } + console.log(`\n${errors} errors, ${warnings} warnings`); if (errors > 0) { process.exit(1); diff --git a/.agents/skills/trails-adrs/scripts/lib/decision-map.ts b/.agents/skills/trails-adrs/scripts/lib/decision-map.ts index e78ca6f53..c536913b4 100644 --- a/.agents/skills/trails-adrs/scripts/lib/decision-map.ts +++ b/.agents/skills/trails-adrs/scripts/lib/decision-map.ts @@ -369,34 +369,40 @@ const stringifyFormattedJson = ( return JSON.stringify(value); }; -const writeJson = (path: string, data: unknown): void => { - writeFileSync(path, `${stringifyFormattedJson(data)}\n`, 'utf8'); -}; - /** - * Write all generated ADR artifacts: + * Build all generated ADR artifacts: * - docs/adr/decision-map.json (accepted ADRs only — stable) * - docs/adr/drafts/decision-map.json (drafts only — changes with drafts) * - docs/adr/drafts/README.md (generated index) */ -export const writeDecisionMap = (): void => { +export const buildDecisionMapContents = (): readonly { + readonly path: string; + readonly content: string; +}[] => { const allFiles = [...listNumberedAdrs(), ...listDrafts()]; const numberedEntries = buildNumberedEntries(allFiles); const draftEntries = buildDraftEntries(allFiles); - // Accepted ADR map (stable — only changes when ADRs are promoted/modified) const acceptedMap: DecisionMap = { entries: numberedEntries, version: 1 }; - writeJson(MAP_PATH, acceptedMap); - console.log(`Updated ${MAP_PATH}`); + const draftsMap: DecisionMap = { entries: draftEntries, version: 1 }; + const draftsIndex = buildDraftsIndex(draftEntries, numberedEntries); + + return [ + { content: `${stringifyFormattedJson(acceptedMap)}\n`, path: MAP_PATH }, + { + content: `${stringifyFormattedJson(draftsMap)}\n`, + path: DRAFTS_MAP_PATH, + }, + { content: draftsIndex, path: DRAFTS_INDEX_PATH }, + ]; +}; - // Drafts map (changes with draft edits) +/** Write all generated ADR artifacts. */ +export const writeDecisionMap = (): void => { mkdirSync(DRAFTS_DIR, { recursive: true }); - const draftsMap: DecisionMap = { entries: draftEntries, version: 1 }; - writeJson(DRAFTS_MAP_PATH, draftsMap); - console.log(`Updated ${DRAFTS_MAP_PATH}`); - // Drafts README - const draftsIndex = buildDraftsIndex(draftEntries, numberedEntries); - writeFileSync(DRAFTS_INDEX_PATH, draftsIndex, 'utf8'); - console.log(`Updated ${DRAFTS_INDEX_PATH}`); + for (const artifact of buildDecisionMapContents()) { + writeFileSync(artifact.path, artifact.content, 'utf8'); + console.log(`Updated ${artifact.path}`); + } }; diff --git a/.changeset/trl-774-resource-context-hygiene.md b/.changeset/trl-774-resource-context-hygiene.md new file mode 100644 index 000000000..2136dfe6b --- /dev/null +++ b/.changeset/trl-774-resource-context-hygiene.md @@ -0,0 +1,9 @@ +--- +"@ontrails/config": patch +"@ontrails/core": patch +"@ontrails/permits": patch +"@ontrails/topographer": patch +--- + +Clean up resource context naming in shipped source and examples so resource +factories consistently use resource vocabulary. diff --git a/.claude/skills/trails-adrs/scripts/adr.ts b/.claude/skills/trails-adrs/scripts/adr.ts index a1cda00a9..f74305216 100644 --- a/.claude/skills/trails-adrs/scripts/adr.ts +++ b/.claude/skills/trails-adrs/scripts/adr.ts @@ -25,7 +25,10 @@ import { dirname, join } from 'node:path'; import { parseArgs, shouldApply, previewBanner, printHelp } from './lib/cli.ts'; import type { Args } from './lib/cli.ts'; -import { writeDecisionMap } from './lib/decision-map.ts'; +import { + buildDecisionMapContents, + writeDecisionMap, +} from './lib/decision-map.ts'; import { listNumberedAdrs, listDrafts, @@ -40,7 +43,7 @@ import { extractTitle, serializeFrontmatter } from './lib/frontmatter.ts'; import type { Frontmatter } from './lib/frontmatter.ts'; import { gitMove } from './lib/git.ts'; import { rebuildIndex } from './lib/index.ts'; -import { ADR_DIR, DRAFTS_DIR, INDEX_PATH, MAP_PATH } from './lib/paths.ts'; +import { ADR_DIR, DRAFTS_DIR, INDEX_PATH } from './lib/paths.ts'; import { fixCrossReferences, rewriteDraftLinks, @@ -477,21 +480,29 @@ const cmdCheck = (args: Args): void => { report('error', 'README.md', 'index file does not exist'); } - console.log('Checking decision map...'); - if (!existsSync(MAP_PATH)) { - report( - 'warn', - 'decision-map.json', - 'decision map does not exist — run "adr map" to generate' - ); - } - if (fix && fixes > 0) { console.log(`\nApplied ${fixes} fixes. Rebuilding index and map...`); rebuildIndex(); writeDecisionMap(); } + console.log('Checking decision map...'); + for (const artifact of buildDecisionMapContents()) { + const label = artifact.path.slice(process.cwd().length + 1); + if (!existsSync(artifact.path)) { + report('error', label, 'generated ADR artifact is missing'); + continue; + } + const committed = readFileSync(artifact.path, 'utf8'); + if (committed !== artifact.content) { + report( + 'error', + label, + 'generated ADR artifact is stale; run "bun scripts/adr.ts map"' + ); + } + } + console.log(`\n${errors} errors, ${warnings} warnings`); if (errors > 0) { process.exit(1); diff --git a/.claude/skills/trails-adrs/scripts/lib/decision-map.ts b/.claude/skills/trails-adrs/scripts/lib/decision-map.ts index e78ca6f53..c536913b4 100644 --- a/.claude/skills/trails-adrs/scripts/lib/decision-map.ts +++ b/.claude/skills/trails-adrs/scripts/lib/decision-map.ts @@ -369,34 +369,40 @@ const stringifyFormattedJson = ( return JSON.stringify(value); }; -const writeJson = (path: string, data: unknown): void => { - writeFileSync(path, `${stringifyFormattedJson(data)}\n`, 'utf8'); -}; - /** - * Write all generated ADR artifacts: + * Build all generated ADR artifacts: * - docs/adr/decision-map.json (accepted ADRs only — stable) * - docs/adr/drafts/decision-map.json (drafts only — changes with drafts) * - docs/adr/drafts/README.md (generated index) */ -export const writeDecisionMap = (): void => { +export const buildDecisionMapContents = (): readonly { + readonly path: string; + readonly content: string; +}[] => { const allFiles = [...listNumberedAdrs(), ...listDrafts()]; const numberedEntries = buildNumberedEntries(allFiles); const draftEntries = buildDraftEntries(allFiles); - // Accepted ADR map (stable — only changes when ADRs are promoted/modified) const acceptedMap: DecisionMap = { entries: numberedEntries, version: 1 }; - writeJson(MAP_PATH, acceptedMap); - console.log(`Updated ${MAP_PATH}`); + const draftsMap: DecisionMap = { entries: draftEntries, version: 1 }; + const draftsIndex = buildDraftsIndex(draftEntries, numberedEntries); + + return [ + { content: `${stringifyFormattedJson(acceptedMap)}\n`, path: MAP_PATH }, + { + content: `${stringifyFormattedJson(draftsMap)}\n`, + path: DRAFTS_MAP_PATH, + }, + { content: draftsIndex, path: DRAFTS_INDEX_PATH }, + ]; +}; - // Drafts map (changes with draft edits) +/** Write all generated ADR artifacts. */ +export const writeDecisionMap = (): void => { mkdirSync(DRAFTS_DIR, { recursive: true }); - const draftsMap: DecisionMap = { entries: draftEntries, version: 1 }; - writeJson(DRAFTS_MAP_PATH, draftsMap); - console.log(`Updated ${DRAFTS_MAP_PATH}`); - // Drafts README - const draftsIndex = buildDraftsIndex(draftEntries, numberedEntries); - writeFileSync(DRAFTS_INDEX_PATH, draftsIndex, 'utf8'); - console.log(`Updated ${DRAFTS_INDEX_PATH}`); + for (const artifact of buildDecisionMapContents()) { + writeFileSync(artifact.path, artifact.content, 'utf8'); + console.log(`Updated ${artifact.path}`); + } }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c6cde86..1c30f09d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: - name: Warden run: bun packages/warden/bin/warden.ts --ci --apps trails,trails-demo + - name: ADR check + run: bun scripts/adr.ts check + changeset: name: Changeset if: github.event_name == 'pull_request' diff --git a/docs/adr/0009-first-class-resources.md b/docs/adr/0009-first-class-resources.md index fe6669fae..c62756467 100644 --- a/docs/adr/0009-first-class-resources.md +++ b/docs/adr/0009-first-class-resources.md @@ -62,13 +62,15 @@ Resources are frozen definition objects with `kind: 'resource'`, parallel to `tr ```typescript const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => + Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), dispose: (conn) => conn.close(), health: (conn) => conn.ping(), mock: () => createInMemoryDb(), description: 'Primary database connection', }); -// svc is ResourceContext — env, cwd, workspaceRoot only. Not the full TrailContext. +// resourceCtx is ResourceContext: env, cwd, workspaceRoot, and config. +// It is not the full TrailContext. ``` The type is inferred from the `create` factory's return value. `db` knows it produces a `Database` instance. No manual generic annotation needed. @@ -267,11 +269,13 @@ A Trails-native package can ship both a resource and a layer that uses it: ```typescript // @ontrails/storage could provide: export const storageResource = resource('storage', { /* ... */ }); -export const transactionLayer = (svc: Resource): Layer => ({ +export const transactionLayer = ( + storageResource: Resource +): Layer => ({ name: 'transaction', wrap: (trail, impl) => async (input, ctx) => { if (trail.intent === 'read') return impl(input, ctx); - const store = svc.from(ctx); + const store = storageResource.from(ctx); return store.withTransaction(() => impl(input, ctx)); }, }); diff --git a/docs/adr/0049-composition-is-compose-not-cross.md b/docs/adr/0049-composition-is-compose-not-cross.md index f78e691c2..3901cf2ef 100644 --- a/docs/adr/0049-composition-is-compose-not-cross.md +++ b/docs/adr/0049-composition-is-compose-not-cross.md @@ -4,9 +4,9 @@ slug: composition-is-compose-not-cross title: Composition is `compose`, not `cross` status: accepted created: 2026-05-25 -updated: 2026-05-26 +updated: 2026-06-09 owners: ['[galligan](https://github.com/galligan)'] -depends_on: [1, 24, 28] +depends_on: [1, 3, 24, 28] --- # ADR-0049: Composition is `compose`, not `cross` @@ -148,6 +148,7 @@ Per ADR-0001's in-place cutover precedent[^cutover], the ADR record itself is up - TRL-784 — the tracking issue carrying the full file:line census, the cutover blast radius, and the dependency blockers.[^trl784] - `.agents/memory/decisions.md` — the logged decision (2026-05-24, verb reversed call→compose 2026-05-25). - [ADR-0001: Naming Conventions](0001-naming-conventions.md#a-note-on-the-adr-record) — the brand-vs-plain heuristic and the in-place cutover precedent; gains a Cutover 4 log entry pointing here. +- [ADR-0003: Unified Trail Primitive](0003-unified-trail-primitive.md) — the unified trail primitive whose composition field and runtime call were renamed in place. - [ADR-0024: Typed Trail Composition](0024-typed-trail-composition.md) — the composition contract this renames in place (`crossInput` → `composeInput`). - [ADR-0028: Concurrent Trail Composition](0028-concurrent-crossing.md) — the concurrent `ctx.compose([...])` overload after this in-place rename. - The prior `follow`→`cross` cutover, encoded in `scripts/vocab-cutover-rewrite.ts` — the codemod precedent this extends. diff --git a/docs/adr/decision-map.json b/docs/adr/decision-map.json index fc516807d..9ccfb3d33 100644 --- a/docs/adr/decision-map.json +++ b/docs/adr/decision-map.json @@ -421,6 +421,11 @@ "from": "0048", "fromPath": "docs/adr/0048-trail-versioning-v3.md" }, + { + "context": "- [ADR-0003: Unified Trail Primitive](0003-unified-trail-primitive.md) — the unified trail primitive whose composition field and runtime call were renamed in place.", + "from": "0049", + "fromPath": "docs/adr/0049-composition-is-compose-not-cross.md" + }, { "context": "- docs/adr/0003-unified-trail-primitive.md", "from": "20260331", @@ -2013,7 +2018,7 @@ "status": "accepted", "superseded_by": null, "title": "Adapter Extraction and Composition Around Core Contracts", - "updated": "2026-05-16" + "updated": "2026-06-09" }, { "created": "2026-04-09", @@ -2754,7 +2759,7 @@ }, { "created": "2026-05-25", - "depends_on": ["1", "24", "28"], + "depends_on": ["1", "3", "24", "28"], "inbound": [ { "context": "- **Cutover 4** ([ADR-0049](0049-composition-is-compose-not-cross.md)): Composition vocabulary cleanup. Retired `cross` / `crosses` in favor of `compose` / `composes`, including `ctx.compose()`, `comp", @@ -2769,7 +2774,7 @@ "status": "accepted", "superseded_by": null, "title": "Composition is `compose`, not `cross`", - "updated": "2026-05-26" + "updated": "2026-06-09" } ], "version": 1 diff --git a/docs/adr/drafts/decision-map.json b/docs/adr/drafts/decision-map.json index f6400cb71..5faaf8424 100644 --- a/docs/adr/drafts/decision-map.json +++ b/docs/adr/drafts/decision-map.json @@ -289,7 +289,7 @@ "status": "draft", "superseded_by": null, "title": "Release Provenance as Lifecycle Projection", - "updated": "2026-06-08" + "updated": "2026-06-09" } ], "version": 1 diff --git a/knip.config.ts b/knip.config.ts index c926dc1f9..5624daa76 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -94,7 +94,6 @@ const config: KnipConfig = { workspaces: { '.': { entry: [ - 'scripts/adr.ts', 'scripts/bootstrap/main.ts', 'scripts/verify-oxc-resolver-published.ts', 'scripts/__tests__/**/*.ts', diff --git a/lefthook.yml b/lefthook.yml index c5f12c40c..efe591b1c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -44,6 +44,10 @@ pre-push: env: GIT_PAGER: cat run: bun trails warden --pre-push + adr: + env: + GIT_PAGER: cat + run: bun scripts/adr.ts check test: env: GIT_PAGER: cat diff --git a/packages/config/src/__tests__/config-resource.test.ts b/packages/config/src/__tests__/config-resource.test.ts index 333f6931b..21de0aa05 100644 --- a/packages/config/src/__tests__/config-resource.test.ts +++ b/packages/config/src/__tests__/config-resource.test.ts @@ -6,7 +6,7 @@ import type { ConfigState } from '../registry.js'; import { clearConfigState, registerConfigState } from '../registry.js'; /** Stub ResourceContext for create calls. */ -const stubSvcCtx = { +const stubResourceCtx = { config: undefined, cwd: '/tmp', env: {}, @@ -51,7 +51,7 @@ describe('configResource', () => { const state: ConfigState = { resolved: { port: 3000 }, schema }; registerConfigState(state); - const result = await configResource.create(stubSvcCtx); + const result = await configResource.create(stubResourceCtx); expect(result.isOk()).toBe(true); const value = result.unwrap() as ConfigState; @@ -69,7 +69,7 @@ describe('configResource', () => { }; registerConfigState(state); - const result = await configResource.create(stubSvcCtx); + const result = await configResource.create(stubResourceCtx); expect(result.isOk()).toBe(true); const value = result.unwrap() as ConfigState; @@ -78,7 +78,7 @@ describe('configResource', () => { }); test('returns Result.err when no state is registered', async () => { - const result = await configResource.create(stubSvcCtx); + const result = await configResource.create(stubResourceCtx); expect(result.isErr()).toBe(true); expect(result.error.message).toContain('Config state not registered'); diff --git a/packages/config/src/compose.ts b/packages/config/src/compose.ts index 265f9d8de..055d9bbef 100644 --- a/packages/config/src/compose.ts +++ b/packages/config/src/compose.ts @@ -39,8 +39,9 @@ export const collectResourceConfigs = ( resources .filter( ( - svc - ): svc is ResourceWithOptionalConfig & { readonly config: z.ZodType } => - svc.config !== undefined + resource + ): resource is ResourceWithOptionalConfig & { + readonly config: z.ZodType; + } => resource.config !== undefined ) - .map((svc) => ({ resourceId: svc.id, schema: svc.config })); + .map((resource) => ({ resourceId: resource.id, schema: resource.config })); diff --git a/packages/core/src/__tests__/service-config.test.ts b/packages/core/src/__tests__/resource-config.test.ts similarity index 81% rename from packages/core/src/__tests__/service-config.test.ts rename to packages/core/src/__tests__/resource-config.test.ts index 28d27c80c..cda2a1f86 100644 --- a/packages/core/src/__tests__/service-config.test.ts +++ b/packages/core/src/__tests__/resource-config.test.ts @@ -13,11 +13,11 @@ import { trail } from '../trail.js'; // --------------------------------------------------------------------------- const nextId = (name: string): string => - `test.svc-config.${name}.${Bun.randomUUIDv7()}`; + `test.resource-config.${name}.${Bun.randomUUIDv7()}`; const createSingletonConfigTrail = (id: string) => { const captures = { createCalls: 0 }; - const svc = resource(id, { + const configuredResource = resource(id, { config: z.object({ key: z.string() }), create: (ctx) => { captures.createCalls += 1; @@ -30,15 +30,15 @@ const createSingletonConfigTrail = (id: string) => { return { captures, - trail: trail('svc-config.singleton', { + trail: trail('resource-config.singleton', { blaze: (_input, ctx) => Result.ok({ - createCall: svc.from(ctx).createCall, - key: svc.from(ctx).key, + createCall: configuredResource.from(ctx).createCall, + key: configuredResource.from(ctx).key, }), input: z.object({}), output: z.object({ createCall: z.number(), key: z.string() }), - resources: [svc], + resources: [configuredResource], }), }; }; @@ -48,19 +48,19 @@ const createSingletonConfigTrail = (id: string) => { // --------------------------------------------------------------------------- describe('ResourceContext.config', () => { - test('resource with config schema receives validated config in svc.config', async () => { + test('resource with config schema receives validated config in resourceCtx.config', async () => { const id = nextId('typed-config'); let capturedConfig: unknown; const db = resource(id, { config: z.object({ poolSize: z.number(), url: z.string().url() }), - create: (svc) => { - capturedConfig = svc.config; + create: (resourceCtx) => { + capturedConfig = resourceCtx.config; return Result.ok({ connected: true }); }, }); - const dbTrail = trail('svc-config.typed', { + const dbTrail = trail('resource-config.typed', { blaze: (_input, ctx) => Result.ok({ connected: db.from(ctx).connected }), input: z.object({}), output: z.object({ connected: z.boolean() }), @@ -81,18 +81,18 @@ describe('ResourceContext.config', () => { expect(capturedConfig).toEqual({ poolSize: 5, url: 'https://example.com' }); }); - test('resource without config still works — svc.config is undefined', async () => { + test('resource without config still works — resourceCtx.config is undefined', async () => { const id = nextId('no-config'); let capturedConfig: unknown = 'sentinel'; const counter = resource(id, { - create: (svc) => { - capturedConfig = svc.config; + create: (resourceCtx) => { + capturedConfig = resourceCtx.config; return Result.ok(42); }, }); - const counterTrail = trail('svc-config.no-config', { + const counterTrail = trail('resource-config.no-config', { blaze: (_input, ctx) => Result.ok({ value: counter.from(ctx) }), input: z.object({}), resources: [counter], @@ -112,7 +112,7 @@ describe('ResourceContext.config', () => { create: () => Result.ok({ connected: true }), }); - const dbTrail = trail('svc-config.invalid', { + const dbTrail = trail('resource-config.invalid', { blaze: () => Result.ok(null), input: z.object({}), resources: [db], @@ -140,7 +140,7 @@ describe('ResourceContext.config', () => { create: () => Result.ok({ connected: true }), }); - const dbTrail = trail('svc-config.missing', { + const dbTrail = trail('resource-config.missing', { blaze: () => Result.ok(null), input: z.object({}), resources: [db], @@ -156,7 +156,7 @@ describe('ResourceContext.config', () => { const id = nextId('defaulted-config'); let capturedConfig: unknown; - const svc = resource(id, { + const configuredResource = resource(id, { config: z.object({ mode: z.literal('noop') }).default({ mode: 'noop' }), create: (ctx) => { capturedConfig = ctx.config; @@ -164,14 +164,15 @@ describe('ResourceContext.config', () => { }, }); - const svcTrail = trail('svc-config.defaulted', { - blaze: (_input, ctx) => Result.ok({ mode: svc.from(ctx).mode }), + const configuredResourceTrail = trail('resource-config.defaulted', { + blaze: (_input, ctx) => + Result.ok({ mode: configuredResource.from(ctx).mode }), input: z.object({}), output: z.object({ mode: z.literal('noop') }), - resources: [svc], + resources: [configuredResource], }); - const result = await executeTrail(svcTrail, {}); + const result = await executeTrail(configuredResourceTrail, {}); expect(result.isOk()).toBe(true); expect(result.unwrap()).toEqual({ mode: 'noop' }); @@ -186,7 +187,7 @@ describe('ResourceContext.config', () => { create: () => Result.ok({ connected: true }), }); - const dbTrail = trail('svc-config.override', { + const dbTrail = trail('resource-config.override', { blaze: (_input, ctx) => Result.ok({ value: db.from(ctx) as number }), input: z.object({}), output: z.object({ value: z.number() }), @@ -203,7 +204,7 @@ describe('ResourceContext.config', () => { const id = nextId('options-config'); const captures: unknown[] = []; - const svc = resource(id, { + const configuredResource = resource(id, { config: z.object({ key: z.string() }), create: (ctx) => { captures.push(ctx.config); @@ -211,15 +212,16 @@ describe('ResourceContext.config', () => { }, }); - const svcTrail = trail('svc-config.options', { - blaze: (_input, ctx) => Result.ok({ key: svc.from(ctx).key }), + const configuredResourceTrail = trail('resource-config.options', { + blaze: (_input, ctx) => + Result.ok({ key: configuredResource.from(ctx).key }), input: z.object({}), output: z.object({ key: z.string() }), - resources: [svc], + resources: [configuredResource], }); const result = await executeTrail( - svcTrail, + configuredResourceTrail, {}, { configValues: { [id]: { key: 'hello' } }, diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts index 065835787..f943e64fd 100644 --- a/packages/core/src/resource.ts +++ b/packages/core/src/resource.ts @@ -9,7 +9,7 @@ import type { z } from 'zod'; * * Resources are app-level singletons, so they intentionally do not receive * the full per-request TrailContext. When a resource declares a `config` schema, - * the validated config is passed as `svc.config`. + * the validated config is passed as `resourceCtx.config`. */ export type ResourceContext = Pick< TrailContext, @@ -32,7 +32,7 @@ export interface ResourceUnmockable { export interface ResourceSpec { /** Create the resource instance from stable process-scoped context. */ readonly create: ( - svc: ResourceContext + resourceCtx: ResourceContext ) => Result | Promise>; /** Config schema — when present, config is validated and passed to `create`. */ readonly config?: z.ZodType | undefined; diff --git a/packages/permits/src/__tests__/auth-resource.test.ts b/packages/permits/src/__tests__/auth-resource.test.ts index b9db59290..27e289726 100644 --- a/packages/permits/src/__tests__/auth-resource.test.ts +++ b/packages/permits/src/__tests__/auth-resource.test.ts @@ -12,7 +12,7 @@ import { TEST_SECRET, signJwt } from './helpers/jwt.js'; const testInput = ( overrides?: Partial ): PermitExtractionInput => ({ - requestId: 'test-svc-req', + requestId: 'test-resource-req', surface: 'http', ...overrides, }); @@ -21,7 +21,7 @@ const testInput = ( // Helpers // --------------------------------------------------------------------------- -const testSvcCtx: ResourceContext = { +const testResourceCtx: ResourceContext = { config: authResourceConfigSchema.parse(), cwd: '/tmp', env: {}, @@ -59,7 +59,7 @@ describe('authResource', () => { }); test('create returns Result.ok with an AuthAdapter', async () => { - const result = await authResource.create(testSvcCtx); + const result = await authResource.create(testResourceCtx); expect(result.isOk()).toBe(true); const adapter = result.unwrap() as AuthAdapter; @@ -76,7 +76,7 @@ describe('authResource', () => { ); const result = await authResource.create({ - ...testSvcCtx, + ...testResourceCtx, config: { adapter: 'jwt', secret: TEST_SECRET }, }); diff --git a/packages/permits/src/auth-resource.ts b/packages/permits/src/auth-resource.ts index 0cc0e8382..902c14f31 100644 --- a/packages/permits/src/auth-resource.ts +++ b/packages/permits/src/auth-resource.ts @@ -82,7 +82,8 @@ const createAdapter = (config: AuthResourceConfig): AuthAdapter => { */ export const authResource = resource('auth', { config: authResourceConfigSchema, - create: (svc) => Result.ok(createAdapter(svc.config as AuthResourceConfig)), + create: (resourceCtx) => + Result.ok(createAdapter(resourceCtx.config as AuthResourceConfig)), description: 'Authentication adapter', meta: { category: 'infrastructure' }, mock: createNoopAdapter, diff --git a/packages/testing/src/__tests__/composes.test.ts b/packages/testing/src/__tests__/composes.test.ts index 05325094f..ca4591bb8 100644 --- a/packages/testing/src/__tests__/composes.test.ts +++ b/packages/testing/src/__tests__/composes.test.ts @@ -242,7 +242,7 @@ const mockDbResource = resource('db.mock.composes', { mock: () => ({ source: 'mock' }), }); -const provisionLeafTrail = trail('resource.leaf', { +const resourceMockLeafTrail = trail('resource.leaf', { blaze: (_input, ctx) => Result.ok({ childSource: mockDbResource.from(ctx).source }), description: 'Leaf trail that reads from a resource', @@ -251,7 +251,7 @@ const provisionLeafTrail = trail('resource.leaf', { resources: [mockDbResource], }); -const provisionRootTrail = trail('resource.root', { +const resourceMockRootTrail = trail('resource.root', { blaze: async (_input, ctx: TrailContext) => { if (!ctx.compose) { return Result.err(new Error('compose not available')); @@ -277,8 +277,8 @@ const provisionRootTrail = trail('resource.root', { resources: [mockDbResource], }); -const provisionTrailsMap = new Map([ - ['resource.leaf', provisionLeafTrail], +const resourceMockTrailsMap = new Map([ + ['resource.leaf', resourceMockLeafTrail], ]); const statefulMockDbResource = resource('db.mock.composes.stateful', { @@ -474,7 +474,7 @@ const unrelatedResourceTrailsMap = new Map([ describe('testComposes resource mocks', () => { // eslint-disable-next-line jest/require-hook testComposes( - provisionRootTrail, + resourceMockRootTrail, [ { description: 'propagates auto-resolved resource mocks through compose', @@ -482,7 +482,7 @@ describe('testComposes resource mocks', () => { input: {}, }, ], - { trails: provisionTrailsMap } + { trails: resourceMockTrailsMap } ); }); @@ -509,7 +509,7 @@ describe('testComposes resource mocks are fresh per scenario', () => { describe('testComposes explicit resource overrides', () => { // eslint-disable-next-line jest/require-hook testComposes( - provisionRootTrail, + resourceMockRootTrail, [ { description: 'propagates explicit resource overrides through compose', @@ -519,7 +519,7 @@ describe('testComposes explicit resource overrides', () => { ], { resources: { 'db.mock.composes': { source: 'override' } }, - trails: provisionTrailsMap, + trails: resourceMockTrailsMap, } ); }); @@ -558,7 +558,7 @@ describe('testComposes resource declarations', () => { describe('testComposes only resolves mocks for trails under test', () => { // eslint-disable-next-line jest/require-hook testComposes( - provisionRootTrail, + resourceMockRootTrail, [ { description: 'unrelated resource mocks are not resolved', @@ -568,7 +568,7 @@ describe('testComposes only resolves mocks for trails under test', () => { ], { trails: new Map([ - ...provisionTrailsMap.entries(), + ...resourceMockTrailsMap.entries(), ...unrelatedResourceTrailsMap.entries(), ]), } diff --git a/packages/topographer/src/topo-store.ts b/packages/topographer/src/topo-store.ts index 688116de3..91b8cbc64 100644 --- a/packages/topographer/src/topo-store.ts +++ b/packages/topographer/src/topo-store.ts @@ -704,10 +704,11 @@ export const unpinTopoSnapshot = ( }; export const topoStore = resource('topo.store', { - create: (svc) => + create: (resourceCtx) => Result.ok( createTopoStore({ - rootDir: svc.workspaceRoot ?? svc.cwd ?? resolveStoreRootDir(), + rootDir: + resourceCtx.workspaceRoot ?? resourceCtx.cwd ?? resolveStoreRootDir(), }) ), description: 'Read-only query access to saved topo state in trails.db', diff --git a/packages/warden/src/__tests__/resource-mock-coverage.test.ts b/packages/warden/src/__tests__/resource-mock-coverage.test.ts index f25c8939d..76e3405ab 100644 --- a/packages/warden/src/__tests__/resource-mock-coverage.test.ts +++ b/packages/warden/src/__tests__/resource-mock-coverage.test.ts @@ -8,7 +8,7 @@ describe('resource-mock-coverage', () => { test('flags a resource definition with create but no mock or unmockable', () => { const code = ` const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), });`; const diagnostics = resourceMockCoverage.check(code, TEST_FILE); expect(diagnostics.length).toBe(1); @@ -21,7 +21,7 @@ const db = resource('db.main', { test('allows a resource definition with a mock factory', () => { const code = ` const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), mock: () => createInMemoryDb(), });`; expect(resourceMockCoverage.check(code, TEST_FILE)).toHaveLength(0); diff --git a/plugin/skills/trails/SKILL.md b/plugin/skills/trails/SKILL.md index 969198edf..3fb23eb0c 100644 --- a/plugin/skills/trails/SKILL.md +++ b/plugin/skills/trails/SKILL.md @@ -196,7 +196,7 @@ Resources declare infrastructure dependencies — databases, API clients, caches ```typescript const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), dispose: (conn) => conn.close(), health: (conn) => conn.ping(), mock: () => createInMemoryDb(), diff --git a/plugin/skills/trails/examples/composition.md b/plugin/skills/trails/examples/composition.md index 908087dc4..8493c4c1d 100644 --- a/plugin/skills/trails/examples/composition.md +++ b/plugin/skills/trails/examples/composition.md @@ -32,7 +32,7 @@ async function createOrder(items: CartItem[], customerId: string) { import { resource, Result } from '@ontrails/core'; export const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), dispose: (conn) => conn.close(), mock: () => createInMemoryDb(), description: 'Primary database connection', diff --git a/plugin/skills/trails/examples/express-handler.md b/plugin/skills/trails/examples/express-handler.md index e0aff678f..82886c997 100644 --- a/plugin/skills/trails/examples/express-handler.md +++ b/plugin/skills/trails/examples/express-handler.md @@ -42,7 +42,7 @@ app.delete('/projects/:id', async (req, res) => { import { resource, Result } from '@ontrails/core'; export const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), dispose: (conn) => conn.close(), health: (conn) => conn.ping(), mock: () => createInMemoryDb(), diff --git a/plugin/skills/trails/references/common-pitfalls.md b/plugin/skills/trails/references/common-pitfalls.md index 439bf4e78..66dd83237 100644 --- a/plugin/skills/trails/references/common-pitfalls.md +++ b/plugin/skills/trails/references/common-pitfalls.md @@ -122,7 +122,7 @@ blaze: async (input, ctx) => { ```typescript // Define once const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), mock: () => createInMemoryDb(), }); @@ -146,7 +146,7 @@ const search = trail('search', { ```typescript const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), mock: () => createInMemoryDb(), // enables zero-config testAll(graph) }); ``` @@ -157,15 +157,15 @@ const db = resource('db.main', { **Why it's wrong:** Resource factories receive `ResourceContext` — a narrow subset with stable process-scoped fields (`env`, `cwd`, `workspaceRoot`, and validated `config` when the resource declares a config schema). Resources are singletons resolved once per process, not per request. Surface-specific state would be stale after the first resolution. -**Fix:** Keep resource factories surface-agnostic. Use `svc.config` for declared resource config or `svc.env` for one-off environment values: +**Fix:** Keep resource factories surface-agnostic. Use `resourceCtx.config` for declared resource config or `resourceCtx.env` for one-off environment values: ```typescript import { z } from 'zod'; const api = resource('api.client', { config: z.object({ baseUrl: z.string().url() }), - create: (svc) => Result.ok(new ApiClient(svc.config.baseUrl)), - // Not this: create: (svc) => new ApiClient(process.argv[2]) + create: (resourceCtx) => Result.ok(new ApiClient(resourceCtx.config.baseUrl)), + // Not this: create: (resourceCtx) => new ApiClient(process.argv[2]) }); ``` diff --git a/plugin/skills/trails/references/getting-started.md b/plugin/skills/trails/references/getting-started.md index 386a59ff5..84a1f90b3 100644 --- a/plugin/skills/trails/references/getting-started.md +++ b/plugin/skills/trails/references/getting-started.md @@ -233,7 +233,7 @@ Define a resource in `src/resources/db.ts`: import { resource, Result } from '@ontrails/core'; export const db = resource('db.main', { - create: (svc) => Result.ok(openDatabase(svc.env?.DATABASE_URL)), + create: (resourceCtx) => Result.ok(openDatabase(resourceCtx.env?.DATABASE_URL)), dispose: (conn) => conn.close(), mock: () => createInMemoryDb(), });