From 288de5c29a808ad99a866cb228d2f12c0fdaf9ee Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:37:16 +0700 Subject: [PATCH 01/18] feat(cli-runtime): add typed agent command engine Introduce registry-backed command contracts, schema validation, bindings, redaction, structured rendering, discovery, documentation projections, cancellation, and stable execution outcomes for reusable CLI operations. --- packages/cli-runtime/README.md | 67 ++ .../__tests__/discovery-docs.test.ts | 156 +++ .../cli-runtime/__tests__/execution.test.ts | 296 +++++ .../cli-runtime/__tests__/hardening.test.ts | 904 +++++++++++++++ .../__tests__/registry-bindings.test.ts | 187 +++ packages/cli-runtime/jest.config.js | 17 + packages/cli-runtime/package.json | 57 + packages/cli-runtime/scripts/finalize-esm.js | 43 + packages/cli-runtime/src/bindings.ts | 381 +++++++ packages/cli-runtime/src/contracts.ts | 563 +++++++++ packages/cli-runtime/src/discovery.ts | 590 ++++++++++ packages/cli-runtime/src/docs.ts | 365 ++++++ packages/cli-runtime/src/errors.ts | 117 ++ packages/cli-runtime/src/execution.ts | 1010 +++++++++++++++++ packages/cli-runtime/src/index.ts | 14 + packages/cli-runtime/src/redaction.ts | 200 ++++ packages/cli-runtime/src/registry.ts | 920 +++++++++++++++ packages/cli-runtime/src/render.ts | 127 +++ packages/cli-runtime/src/schema.ts | 178 +++ packages/cli-runtime/src/settings.ts | 246 ++++ packages/cli-runtime/tsconfig.esm.json | 9 + packages/cli-runtime/tsconfig.json | 9 + 22 files changed, 6456 insertions(+) create mode 100644 packages/cli-runtime/README.md create mode 100644 packages/cli-runtime/__tests__/discovery-docs.test.ts create mode 100644 packages/cli-runtime/__tests__/execution.test.ts create mode 100644 packages/cli-runtime/__tests__/hardening.test.ts create mode 100644 packages/cli-runtime/__tests__/registry-bindings.test.ts create mode 100644 packages/cli-runtime/jest.config.js create mode 100644 packages/cli-runtime/package.json create mode 100644 packages/cli-runtime/scripts/finalize-esm.js create mode 100644 packages/cli-runtime/src/bindings.ts create mode 100644 packages/cli-runtime/src/contracts.ts create mode 100644 packages/cli-runtime/src/discovery.ts create mode 100644 packages/cli-runtime/src/docs.ts create mode 100644 packages/cli-runtime/src/errors.ts create mode 100644 packages/cli-runtime/src/execution.ts create mode 100644 packages/cli-runtime/src/index.ts create mode 100644 packages/cli-runtime/src/redaction.ts create mode 100644 packages/cli-runtime/src/registry.ts create mode 100644 packages/cli-runtime/src/render.ts create mode 100644 packages/cli-runtime/src/schema.ts create mode 100644 packages/cli-runtime/src/settings.ts create mode 100644 packages/cli-runtime/tsconfig.esm.json create mode 100644 packages/cli-runtime/tsconfig.json diff --git a/packages/cli-runtime/README.md b/packages/cli-runtime/README.md new file mode 100644 index 0000000000..9f0b4045fe --- /dev/null +++ b/packages/cli-runtime/README.md @@ -0,0 +1,67 @@ +# @constructive-io/cli-runtime + +Typed command contracts and terminal-independent execution for Constructive CLIs. The package owns JSON Schema validation, strict argument binding, structured errors, redacted JSON/JSONL events, discovery documents, completions, and ownership-safe local documentation export. + +It deliberately has no dependency on Incur, Inquirerer, PGPM, GraphQL, or a global logger. Interactive prompts and process I/O belong to the consuming terminal adapter. + +```ts +import { + createCommandRegistry, + defineCommand, + executeCommand, + Type, +} from '@constructive-io/cli-runtime'; + +const inspect = defineCommand({ + id: 'project.inspect', + path: ['inspect'], + summary: 'Inspect the current project.', + input: Type.Object( + { depth: Type.Optional(Type.Number({ minimum: 0 })) }, + { additionalProperties: false } + ), + output: Type.Object({ root: Type.String() }, { additionalProperties: false }), + bindings: [ + { + property: 'depth', + sources: [{ kind: 'option', name: 'depth' }], + valueType: 'number', + description: 'Maximum inspection depth.', + }, + ], + examples: [{ argv: ['inspect', '--depth', '2'] }], + lifecycle: 'finite', + effect: 'read', + async execute(_input, context) { + return { data: { root: context.cwd } }; + }, +}); + +const registry = createCommandRegistry([inspect]); +const outcome = await executeCommand( + registry, + inspect, + { depth: 2 }, + { + cwd: '/workspace/app', + mode: 'agent', + env: {}, + signal: new AbortController().signal, + } +); +``` + +Streaming adapters should pass a protocol sink and `captureEvents: false`; the +terminal envelope remains available as `outcome.terminalEvent` without retaining +an unbounded domain-event transcript. Mark secret-bearing option and environment +bindings with `sensitive: true`. Environment names such as `CNC_TOKEN` and +`PGPASSWORD` are also recognized automatically, and all results, errors, and +events are redacted and schema-validated immediately before publication. +If an operation loads a secret from storage or a remote credential provider, +it must call `context.registerSensitiveValue(secret)` before the value can +reach a result, event, warning, error, artifact, or recovery action. + +The runtime serializes domain events in call order, drains unawaited emissions +before publishing the terminal event, and seals the reporter afterward. Result +and error next actions are validated against the active registry, including the +target command's input schema, before they cross the protocol boundary. diff --git a/packages/cli-runtime/__tests__/discovery-docs.test.ts b/packages/cli-runtime/__tests__/discovery-docs.test.ts new file mode 100644 index 0000000000..d8fa5d6fc0 --- /dev/null +++ b/packages/cli-runtime/__tests__/discovery-docs.test.ts @@ -0,0 +1,156 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + CommandCatalogEntrySchema, + CommandSchemaDocumentSchema, + compileSchema, + createCommandRegistry, + defineCommand, + exportDocumentation, + generateCompletion, + generateDocumentation, + getHelpDocument, + HelpDocumentSchema, + renderHelp, + Type, +} from '../src'; + +function command(id = 'project.inspect', path = ['project', 'inspect']) { + return defineCommand({ + id, + path, + summary: 'Inspect a project.', + input: Type.Object( + { depth: Type.Optional(Type.Integer()) }, + { additionalProperties: false } + ), + output: Type.Object( + { root: Type.String() }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'depth', + sources: [{ kind: 'option', name: 'depth', short: 'd' }], + valueType: 'number' as const, + description: 'Maximum depth.', + }, + ], + examples: [ + { description: 'Inspect two levels.', argv: [...path, '--depth', '2'] }, + ], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute(_input, context) { + return { data: { root: context.cwd } }; + }, + }); +} + +describe('discovery projections and documentation ownership', () => { + let directory: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'cli-runtime-')); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('derives human help, JSON help, schemas, and completions from one registry', () => { + const registry = createCommandRegistry([command()]); + const catalog = registry.catalog(); + const schema = registry.schema('project.inspect'); + const help = getHelpDocument(registry, ['project', 'inspect']); + expect( + compileSchema(Type.Array(CommandCatalogEntrySchema)).validate(catalog) + ).toBe(true); + expect(compileSchema(CommandSchemaDocumentSchema).validate(schema)).toBe( + true + ); + expect(compileSchema(HelpDocumentSchema).validate(help)).toBe(true); + expect(renderHelp(registry)).toContain('project inspect'); + expect(renderHelp(registry, ['project', 'inspect'])).toContain('--depth'); + expect(help.command?.id).toBe('project.inspect'); + expect(generateCompletion(registry, 'bash')).toContain('project'); + expect(generateCompletion(registry, 'zsh')).toContain('#compdef cnc'); + expect(generateCompletion(registry, 'fish')).toContain('complete -c cnc'); + }); + + it('plans dry runs without writing and makes repeated exports idempotent', async () => { + const registry = createCommandRegistry([command()]); + const documentation = generateDocumentation(registry, { + toolVersion: '7.31.0', + }); + const dryRun = await exportDocumentation(directory, documentation, { + dryRun: true, + }); + expect(dryRun.applied).toBe(false); + expect(dryRun.plan.entries.some((entry) => entry.action === 'create')).toBe( + true + ); + await expect( + readFile(join(directory, 'catalog.json')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + + expect((await exportDocumentation(directory, documentation)).applied).toBe( + true + ); + expect( + JSON.parse(await readFile(join(directory, 'catalog.json'), 'utf8')).tool + ).toBe('cnc'); + const repeated = await exportDocumentation(directory, documentation, { + dryRun: true, + }); + expect( + repeated.plan.entries.every((entry) => entry.action === 'unchanged') + ).toBe(true); + }); + + it('never overwrites unowned or manually modified files', async () => { + const registry = createCommandRegistry([command()]); + const documentation = generateDocumentation(registry, { + toolVersion: '7.31.0', + }); + await writeFile(join(directory, 'catalog.json'), 'user-owned\n'); + const unowned = await exportDocumentation(directory, documentation); + expect(unowned.applied).toBe(false); + expect(unowned.plan.conflicts).toContain('catalog.json'); + expect(await readFile(join(directory, 'catalog.json'), 'utf8')).toBe( + 'user-owned\n' + ); + + await rm(directory, { recursive: true, force: true }); + await mkdir(directory); + await exportDocumentation(directory, documentation); + const commandPage = join(directory, 'commands/project.inspect.md'); + await writeFile(commandPage, 'manual edit\n'); + const modified = await exportDocumentation(directory, documentation); + expect(modified.applied).toBe(false); + expect(modified.plan.conflicts).toContain('commands/project.inspect.md'); + expect(await readFile(commandPage, 'utf8')).toBe('manual edit\n'); + }); + + it('prunes only previously owned, unmodified stale files', async () => { + const original = createCommandRegistry([ + command(), + command('project.status', ['project', 'status']), + ]); + await exportDocumentation( + directory, + generateDocumentation(original, { toolVersion: '7.31.0' }) + ); + const reduced = createCommandRegistry([command()]); + const result = await exportDocumentation( + directory, + generateDocumentation(reduced, { toolVersion: '7.31.0' }) + ); + expect(result.applied).toBe(true); + await expect( + readFile(join(directory, 'commands/project.status.md')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/packages/cli-runtime/__tests__/execution.test.ts b/packages/cli-runtime/__tests__/execution.test.ts new file mode 100644 index 0000000000..8dd094faab --- /dev/null +++ b/packages/cli-runtime/__tests__/execution.test.ts @@ -0,0 +1,296 @@ +import { + assertFormatAllowed, + CliError, + createCommandRegistry, + createOperationContext, + createRedactor, + defineCommand, + executeCommand, + exitCodeForOutcome, + renderExecution, + resolveExecutionSettings, + Type, +} from '../src'; + +function createProgressCommand() { + return defineCommand({ + id: 'work.run', + path: ['work', 'run'], + summary: 'Run work.', + input: Type.Object( + { name: Type.String() }, + { additionalProperties: false } + ), + output: Type.Object( + { token: Type.String(), note: Type.String() }, + { additionalProperties: false } + ), + events: Type.Object( + { + event: Type.Literal('work.progress'), + percent: Type.Number({ minimum: 0, maximum: 100 }), + }, + { additionalProperties: false } + ), + bindings: [ + { property: 'name', sources: [{ kind: 'positional', index: 0 }] }, + ], + examples: [{ argv: ['work', 'run', 'test'] }], + lifecycle: 'finite' as const, + effect: 'write' as const, + async execute(_input, context) { + expect(context.cwd).toBe('/workspace'); + expect(context.env.CNC_CONTEXT).toBe('preview'); + await context.events.emit({ event: 'work.progress', percent: 50 }); + return { data: { token: 'top-secret', note: 'value=top-secret' } }; + }, + }); +} + +describe('operation execution and protocol rendering', () => { + it('validates, redacts, and streams a single terminal protocol event', async () => { + const command = createProgressCommand(); + const registry = createCommandRegistry([command]); + const times = [new Date(1000), new Date(1500), new Date(2000)]; + const streamed: unknown[] = []; + const outcome = await executeCommand( + registry, + command, + { name: 'demo' }, + { + cwd: '/workspace', + mode: 'agent', + env: { CNC_CONTEXT: 'preview' }, + operationId: 'run_1', + now: () => times.shift()!, + redaction: { sensitiveValues: ['top-secret'] }, + sink: (event) => { + streamed.push(event); + }, + } + ); + + expect(outcome.status).toBe('completed'); + expect(outcome.result).toEqual({ + data: { token: '[REDACTED]', note: 'value=[REDACTED]' }, + }); + expect(outcome.protocolEvents.map((event) => event.event)).toEqual([ + 'operation.started', + 'work.progress', + 'operation.completed', + ]); + expect(streamed).toEqual(outcome.protocolEvents); + expect(exitCodeForOutcome(outcome)).toBe(0); + + const json = renderExecution(outcome, 'json'); + expect(json.stderr).toBe(''); + expect(json.stdout.trim().split('\n')).toHaveLength(1); + expect(JSON.parse(json.stdout).event).toBe('operation.completed'); + + const jsonl = renderExecution(outcome, 'jsonl'); + expect(jsonl.stderr).toBe(''); + expect( + jsonl.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line).event) + ).toEqual(['operation.started', 'work.progress', 'operation.completed']); + }); + + it('redacts secrets registered after an operation starts', async () => { + const command = defineCommand({ + id: 'secret.load', + path: ['secret', 'load'], + summary: 'Load a secret.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Object( + { message: Type.String() }, + { additionalProperties: false } + ), + events: Type.Object( + { event: Type.Literal('secret.loaded'), message: Type.String() }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['secret', 'load'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute(_input, context) { + const secret = 'loaded-during-execution'; + context.registerSensitiveValue(secret); + await context.events.emit({ + event: 'secret.loaded', + message: `saw ${secret}`, + }); + return { data: { message: `saw ${secret}` } }; + }, + }); + const registry = createCommandRegistry([command]); + const outcome = await executeCommand( + registry, + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + } + ); + + expect(outcome.status).toBe('completed'); + expect(outcome.result).toEqual({ data: { message: 'saw [REDACTED]' } }); + expect(outcome.protocolEvents[1]).toEqual( + expect.objectContaining({ + event: 'secret.loaded', + message: 'saw [REDACTED]', + }) + ); + expect(JSON.stringify(outcome)).not.toContain('loaded-during-execution'); + }); + + it('maps known, internal, and cancelled failures to stable exit codes', async () => { + const known = defineCommand({ + id: 'failure.known', + path: ['failure', 'known'], + summary: 'Fail safely.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Null(), + bindings: [], + examples: [{ argv: ['failure', 'known'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + throw new CliError({ + code: 'REMOTE_UNAVAILABLE', + category: 'network', + message: 'Remote unavailable.', + retryable: true, + }); + }, + }); + const invalid = defineCommand({ + ...known, + id: 'failure.invalid', + path: ['failure', 'invalid'], + examples: [{ argv: ['failure', 'invalid'] }], + async execute(): Promise { + return { data: 'wrong' }; + }, + }); + const cancelled = defineCommand({ + ...known, + id: 'failure.cancelled', + path: ['failure', 'cancelled'], + examples: [{ argv: ['failure', 'cancelled'] }], + async execute(_input, context) { + await new Promise((resolve) => + context.signal.addEventListener('abort', resolve, { once: true }) + ); + return { data: null }; + }, + }); + const registry = createCommandRegistry([known, invalid, cancelled]); + + const knownOutcome = await executeCommand( + registry, + known, + {}, + { cwd: '/workspace', mode: 'agent' } + ); + expect(knownOutcome.error).toEqual( + expect.objectContaining({ code: 'REMOTE_UNAVAILABLE', retryable: true }) + ); + expect(exitCodeForOutcome(knownOutcome)).toBe(1); + + const invalidOutcome = await executeCommand( + registry, + invalid, + {}, + { cwd: '/workspace', mode: 'agent' } + ); + expect(invalidOutcome.error?.code).toBe('CLI_INTERNAL_ERROR'); + expect(exitCodeForOutcome(invalidOutcome)).toBe(70); + + const controller = new AbortController(); + const promise = executeCommand( + registry, + cancelled, + {}, + { + cwd: '/workspace', + mode: 'agent', + signal: controller.signal, + } + ); + controller.abort('stop'); + const cancelledOutcome = await promise; + expect(cancelledOutcome.status).toBe('cancelled'); + expect(exitCodeForOutcome(cancelledOutcome)).toBe(130); + }); + + it('enforces mode and long-running format rules', () => { + expect( + resolveExecutionSettings({ + agent: true, + stdinIsTTY: true, + stdoutIsTTY: true, + }) + ).toEqual( + expect.objectContaining({ + mode: 'agent', + format: 'jsonl', + interactive: false, + }) + ); + expect( + resolveExecutionSettings({ stdinIsTTY: true, stdoutIsTTY: false }) + ).toEqual( + expect.objectContaining({ + mode: 'human', + format: 'human', + interactive: false, + terminalEffects: false, + }) + ); + expect(() => + resolveExecutionSettings({ + agent: true, + format: 'human', + stdinIsTTY: true, + stdoutIsTTY: true, + }) + ).toThrow(expect.objectContaining({ code: 'CLI_MODE_CONFLICT' })); + + const command = { + ...createProgressCommand(), + lifecycle: 'long-running' as const, + }; + expect(() => assertFormatAllowed(command, 'json')).toThrow( + expect.objectContaining({ code: 'CLI_FORMAT_UNSUPPORTED' }) + ); + }); + + it('requires absolute operation directories and recursively handles cycles and shared values', () => { + expect(() => + createOperationContext({ + cwd: 'relative', + mode: 'agent', + env: {}, + signal: new AbortController().signal, + }) + ).toThrow(expect.objectContaining({ code: 'CLI_CWD_NOT_ABSOLUTE' })); + + const shared = { value: 'ok' }; + const input: Record = { + accessToken: 'hidden', + first: shared, + second: shared, + }; + input.self = input; + expect(createRedactor()(input)).toEqual({ + accessToken: '[REDACTED]', + first: { value: 'ok' }, + second: { value: 'ok' }, + self: '[Circular]', + }); + }); +}); diff --git a/packages/cli-runtime/__tests__/hardening.test.ts b/packages/cli-runtime/__tests__/hardening.test.ts new file mode 100644 index 0000000000..72f3ec4535 --- /dev/null +++ b/packages/cli-runtime/__tests__/hardening.test.ts @@ -0,0 +1,904 @@ +import { spawnSync } from 'node:child_process'; + +import { + ArtifactSchema, + assertJsonValue, + bindArguments, + CliError, + CommandCatalogEntrySchema, + CommandDefinition, + CommandSchemaDocumentSchema, + compileSchema, + createCommandRegistry, + createFailureOutcome, + createRedactor, + defineCommand, + DomainProtocolEventSchema, + ExecutionOutcomeSchema, + generateCompletion, + HelpDocumentSchema, + NextActionSchema, + OperationCancelledEventSchema, + OperationCompletedEventSchema, + OperationFailedEventSchema, + OperationResultSchema, + OperationStartedEventSchema, + ProtocolEventSchema, + renderExecution, + renderHelp, + resolveExecutionSettings, + SafetyCapabilitiesSchema, + StructuredErrorSchema, + TerminalProtocolEventSchema, + Type, + WarningSchema, + executeCommand, +} from '../src'; + +function baseCommand(overrides: Partial = {}) { + return defineCommand({ + id: 'safe.run', + path: ['safe', 'run'], + summary: 'Run safely.', + input: Type.Object( + { value: Type.Optional(Type.String()) }, + { additionalProperties: false } + ), + output: Type.Object( + { ok: Type.Boolean() }, + { additionalProperties: false } + ), + bindings: [ + { property: 'value', sources: [{ kind: 'option', name: 'value' }] }, + ], + examples: [{ argv: ['safe', 'run', '--value', 'demo'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + return { data: { ok: true } }; + }, + ...overrides, + } as CommandDefinition); +} + +describe('runtime protocol hardening', () => { + it('exports compilable schemas for every public result and envelope', () => { + for (const schema of [ + WarningSchema, + ArtifactSchema, + NextActionSchema, + SafetyCapabilitiesSchema, + CommandCatalogEntrySchema, + CommandSchemaDocumentSchema, + HelpDocumentSchema, + OperationResultSchema, + StructuredErrorSchema, + OperationStartedEventSchema, + DomainProtocolEventSchema, + OperationCompletedEventSchema, + OperationFailedEventSchema, + OperationCancelledEventSchema, + TerminalProtocolEventSchema, + ProtocolEventSchema, + ExecutionOutcomeSchema, + ]) { + expect(() => compileSchema(schema)).not.toThrow(); + } + }); + + it('validates redacted results and errors before publishing them', async () => { + const command = defineCommand({ + id: 'redaction.contract', + path: ['redaction', 'contract'], + summary: 'Test redaction contracts.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Object( + { secret: Type.Literal('allowed') }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['redaction', 'contract'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + return { data: { secret: 'allowed' as const } }; + }, + }); + const registry = createCommandRegistry([command]); + const outcome = await executeCommand( + registry, + command, + {}, + { cwd: '/workspace', mode: 'agent' } + ); + + expect(outcome.status).toBe('failed'); + expect(outcome.error?.code).toBe('CLI_INTERNAL_ERROR'); + expect( + compileSchema(ProtocolEventSchema).validate(outcome.terminalEvent) + ).toBe(true); + + const malformedKnownError = new CliError({ + code: 'BAD_DETAILS', + category: 'operation', + message: 'Could not use env-secret.', + details: { amount: BigInt(1) }, + }); + const failure = await createFailureOutcome({ + commandId: 'adapter.failure', + error: malformedKnownError, + redaction: { sensitiveValues: ['env-secret'] }, + }); + expect(failure.error?.code).toBe('CLI_INTERNAL_ERROR'); + expect(JSON.stringify(failure)).not.toContain('env-secret'); + }); + + it('validates result and error next actions against the active registry', async () => { + const recovery = defineCommand({ + id: 'recovery.inspect', + path: ['recovery', 'inspect'], + summary: 'Inspect recovery state.', + input: Type.Object( + { operationId: Type.String({ minLength: 1 }) }, + { additionalProperties: false } + ), + output: Type.Null(), + bindings: [ + { + property: 'operationId', + sources: [{ kind: 'positional', index: 0 }], + }, + ], + examples: [{ argv: ['recovery', 'inspect', 'op_1'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + return { data: null }; + }, + }); + const commandWithAction = ( + id: string, + nextAction: { + commandId: string; + input: Record; + reason: string; + }, + asError = false + ) => + defineCommand({ + id, + path: id.split('.'), + summary: 'Return a next action.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Null(), + bindings: [], + examples: [{ argv: id.split('.') }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + if (asError) { + throw new CliError({ + code: 'RECOVERY_REQUIRED', + category: 'operation', + message: 'Recovery is required.', + nextActions: [nextAction], + }); + } + return { data: null, nextActions: [nextAction] }; + }, + }); + + const valid = commandWithAction('action.valid', { + commandId: 'recovery.inspect', + input: { operationId: 'op_1' }, + reason: 'Inspect the failed operation.', + }); + const unknown = commandWithAction('action.unknown', { + commandId: 'recovery.missing', + input: {}, + reason: 'Try a missing command.', + }); + const malformedError = commandWithAction( + 'action.error', + { + commandId: 'recovery.inspect', + input: {}, + reason: 'Inspect without the required operation ID.', + }, + true + ); + const registry = createCommandRegistry([ + recovery, + valid, + unknown, + malformedError, + ]); + + const validOutcome = await executeCommand( + registry, + valid, + {}, + { + cwd: '/workspace', + mode: 'agent', + } + ); + expect(validOutcome.status).toBe('completed'); + expect(validOutcome.result?.nextActions).toEqual([ + expect.objectContaining({ commandId: 'recovery.inspect' }), + ]); + + for (const command of [unknown, malformedError]) { + const outcome = await executeCommand( + registry, + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + } + ); + expect(outcome.status).toBe('failed'); + expect(outcome.error).toMatchObject({ + code: 'CLI_INTERNAL_ERROR', + category: 'internal', + }); + } + }); + + it('serializes, drains, and seals operation event reporting', async () => { + type OrderedEvent = { event: 'ordered.progress'; index: number }; + let emitAfterCompletion: + | ((event: OrderedEvent) => Promise) + | undefined; + const ordered = defineCommand({ + id: 'ordered.run', + path: ['ordered', 'run'], + summary: 'Emit ordered progress.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Null(), + events: Type.Object( + { + event: Type.Literal('ordered.progress'), + index: Type.Integer(), + }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['ordered', 'run'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute(_input, context) { + emitAfterCompletion = context.events.emit; + void context.events.emit({ event: 'ordered.progress', index: 1 }); + void context.events.emit({ event: 'ordered.progress', index: 2 }); + return { data: null }; + }, + }); + const streamed: Array<{ event: string; index?: number }> = []; + const registry = createCommandRegistry([ordered]); + const outcome = await executeCommand( + registry, + ordered, + {}, + { + cwd: '/workspace', + mode: 'agent', + sink: async (event) => { + if (event.event === 'ordered.progress' && event.index === 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + streamed.push(event as { event: string; index?: number }); + }, + } + ); + + expect(outcome.status).toBe('completed'); + expect( + streamed.map(({ event, index }) => `${event}:${index ?? ''}`) + ).toEqual([ + 'operation.started:', + 'ordered.progress:1', + 'ordered.progress:2', + 'operation.completed:', + ]); + const beforeLateEmit = JSON.stringify(outcome.protocolEvents); + await expect( + emitAfterCompletion!({ event: 'ordered.progress', index: 3 }) + ).rejects.toMatchObject({ code: 'CLI_EVENT_REPORTER_CLOSED' }); + expect(JSON.stringify(outcome.protocolEvents)).toBe(beforeLateEmit); + }); + + it('turns an unawaited invalid event into an internal terminal failure', async () => { + const invalidEvent = defineCommand({ + id: 'event.invalid', + path: ['event', 'invalid'], + summary: 'Emit invalid progress.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Null(), + events: Type.Object( + { + event: Type.Literal('event.progress'), + index: Type.Integer(), + }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['event', 'invalid'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute(_input, context) { + void context.events.emit({ + event: 'event.progress', + index: 'invalid', + } as never); + return { data: null }; + }, + }); + const registry = createCommandRegistry([invalidEvent]); + const outcome = await executeCommand( + registry, + invalidEvent, + {}, + { + cwd: '/workspace', + mode: 'agent', + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CLI_INTERNAL_ERROR', category: 'internal' }, + }); + expect(outcome.protocolEvents.map(({ event }) => event)).toEqual([ + 'operation.started', + 'operation.failed', + ]); + }); + + it('always terminalizes invalid operation identity and clock failures', async () => { + const command = baseCommand(); + const registry = createCommandRegistry([command]); + + const invalidIdentity = await executeCommand( + registry, + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + operationId: '', + } + ); + expect(invalidIdentity).toMatchObject({ + status: 'failed', + error: { code: 'CLI_OPERATION_ID_INVALID', category: 'invocation' }, + }); + expect(invalidIdentity.operationId.length).toBeGreaterThan(0); + expect(invalidIdentity.protocolEvents.map(({ event }) => event)).toEqual([ + 'operation.started', + 'operation.failed', + ]); + expect( + invalidIdentity.protocolEvents.every( + ({ operationId }) => operationId === invalidIdentity.operationId + ) + ).toBe(true); + + let clockCalls = 0; + const clockFailure = await executeCommand( + registry, + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + now: () => { + clockCalls += 1; + if (clockCalls === 1) return new Date(0); + throw new Error('clock implementation failed'); + }, + } + ); + expect(clockFailure).toMatchObject({ + status: 'failed', + error: { code: 'CLI_INTERNAL_ERROR', category: 'internal' }, + }); + expect(clockFailure.protocolEvents.map(({ event }) => event)).toEqual([ + 'operation.started', + 'operation.failed', + ]); + + const adapterFailure = await createFailureOutcome({ + commandId: 'adapter.failure', + error: new Error('original adapter error'), + now: () => new Date(Number.NaN), + }); + expect(adapterFailure).toMatchObject({ + status: 'failed', + error: { code: 'CLI_INTERNAL_ERROR', category: 'internal' }, + }); + expect(adapterFailure.protocolEvents.map(({ event }) => event)).toEqual([ + 'operation.started', + 'operation.failed', + ]); + }); + + it('redacts prototype-looking keys without prototype mutation and preserves sparse shape', () => { + const input = JSON.parse( + '{"__proto__":{"polluted":true},"accessToken":"secret-value"}' + ) as Record; + const redacted = createRedactor()(input); + expect(Object.getPrototypeOf(redacted)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(redacted, '__proto__')).toBe( + true + ); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + expect(redacted.accessToken).toBe('[REDACTED]'); + expect( + createRedactor()({ + tokenCount: 3, + credentials: { + value: 'secret', + pin: 1234, + enabled: true, + issuedAt: new Date('2026-07-20T00:00:00.000Z'), + }, + }) + ).toEqual({ + tokenCount: '[REDACTED]', + credentials: { + value: '[REDACTED]', + pin: '[REDACTED]', + enabled: '[REDACTED]', + issuedAt: '[REDACTED]', + }, + }); + expect( + createRedactor({ sensitiveValues: ['2468', 'false'] })({ + amount: 2468, + enabled: false, + safeAmount: 1357, + }) + ).toEqual({ + amount: '[REDACTED]', + enabled: '[REDACTED]', + safeAmount: 1357, + }); + + const sparse = new Array(3); + sparse[2] = 'value'; + const safeSparse = createRedactor()(sparse); + expect(safeSparse).toHaveLength(3); + expect(0 in safeSparse).toBe(false); + expect(() => assertJsonValue(safeSparse)).toThrow( + expect.objectContaining({ code: 'CLI_JSON_VALUE_INVALID' }) + ); + }); + + it('deep-snapshots and freezes command contracts without freezing caller functions', () => { + const original = baseCommand(); + const execute = original.execute; + const registry = createCommandRegistry([original]); + const stored = registry.requireById('safe.run'); + + (original.path as string[])[0] = 'mutated'; + (original.bindings[0].sources[0] as { name: string }).name = 'mutated'; + ( + original.input as unknown as { properties: { value: { type: string } } } + ).properties.value.type = 'number'; + + expect(stored.path).toEqual(['safe', 'run']); + expect((stored.bindings[0].sources[0] as { name: string }).name).toBe( + 'value' + ); + expect( + (stored.input as unknown as { properties: { value: { type: string } } }) + .properties.value.type + ).toBe('string'); + expect(Object.isFrozen(stored.input)).toBe(true); + expect(Object.isFrozen(stored.bindings[0])).toBe(true); + expect(Object.isFrozen(execute)).toBe(false); + }); + + it('rejects malformed bindings and collisions with global flags', () => { + const cases: Partial[] = [ + { + bindings: [ + { property: 'value', sources: [{ kind: 'option', name: 'Value' }] }, + ], + }, + { + bindings: [ + { property: 'value', sources: [{ kind: 'option', name: 'format' }] }, + ], + }, + { + bindings: [ + { + property: 'value', + sources: [{ kind: 'option', name: 'value', short: 'h' }], + }, + ], + }, + { + bindings: [ + { + property: 'value', + sources: [{ kind: 'option', name: 'value', aliases: ['--legacy'] }], + }, + ], + }, + { + bindings: [ + { property: 'value', sources: [{ kind: 'positional', index: 1 }] }, + ], + examples: [{ argv: ['safe', 'run', 'value'] }], + }, + { + bindings: [ + { + property: 'value', + sources: [{ kind: 'option', name: 'value' }], + repeated: true, + }, + ], + }, + { + bindings: [ + { + property: 'value', + sources: [{ kind: 'option', name: 'value', negatable: true }], + }, + ], + }, + ]; + for (const contract of cases) + expect(() => createCommandRegistry([baseCommand(contract)])).toThrow(); + }); + + it('distinguishes long and short options and supports deferred validation and secret collection', () => { + const command = defineCommand({ + id: 'binding.secrets', + path: ['binding', 'secrets'], + summary: 'Bind secrets.', + input: Type.Object( + { + required: Type.String(), + token: Type.Optional(Type.String()), + password: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ), + output: Type.Null(), + bindings: [ + { property: 'required', sources: [] }, + { + property: 'token', + sources: [ + { + kind: 'option', + name: 'token-value', + short: 't', + sensitive: true, + }, + ], + }, + { + property: 'password', + sources: [{ kind: 'environment', name: 'SERVICE_PASSWORD' }], + }, + ], + examples: [{ argv: ['binding', 'secrets', '--token-value', 'example'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + return { data: null }; + }, + }); + // The example intentionally relies on adapter-collected required input; the + // registry validates its argv while deferring the adapter-owned field. + const registry = createCommandRegistry([command]); + const bound = bindArguments(command, { + argv: ['-t', 'option-secret'], + env: { SERVICE_PASSWORD: 'env-secret' }, + validate: false, + }); + expect(bound.sensitiveValues).toEqual(['option-secret', 'env-secret']); + expect(() => + bindArguments(command, { argv: ['--t', 'value'], validate: false }) + ).toThrow(expect.objectContaining({ code: 'CLI_OPTION_UNKNOWN' })); + expect(() => + bindArguments(command, { + argv: ['-token-value', 'value'], + validate: false, + }) + ).toThrow(expect.objectContaining({ code: 'CLI_OPTION_UNKNOWN' })); + expect(() => + bindArguments(command, { argv: [], validate: true }, registry) + ).toThrow(expect.objectContaining({ code: 'CLI_INPUT_INVALID' })); + }); + + it('quotes examples and emits parser-aware shell completions', () => { + const command = defineCommand({ + id: 'quote.run', + path: ['quote', 'run'], + summary: 'Quote values.', + input: Type.Object( + { + value: Type.String(), + depth: Type.Optional(Type.Integer()), + postgis: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } + ), + output: Type.Null(), + bindings: [ + { property: 'value', sources: [{ kind: 'positional', index: 0 }] }, + { + property: 'depth', + sources: [{ kind: 'option', name: 'depth', short: 'd' }], + valueType: 'number', + }, + { + property: 'postgis', + sources: [ + { + kind: 'option', + name: 'postgis', + aliases: ['spatial'], + deprecatedAliases: ['postGis'], + negatable: true, + }, + ], + valueType: 'boolean', + }, + ], + examples: [{ argv: ['quote', 'run', '$(touch /tmp/runtime-owned)'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute() { + return { data: null }; + }, + }); + const registry = createCommandRegistry([command]); + expect(renderHelp(registry, command.path)).toContain( + "'$(touch /tmp/runtime-owned)'" + ); + for (const shell of ['bash', 'zsh'] as const) { + const script = generateCompletion(registry, shell); + const parsed = spawnSync('bash', ['-n'], { + input: script, + encoding: 'utf8', + }); + expect(parsed.status).toBe(0); + } + + const bash = generateCompletion(registry, 'bash'); + const complete = (words: string[]): string[] => { + const quoted = words + .map((word) => `'${word.replace(/'/g, `'"'"'`)}'`) + .join(' '); + const completed = spawnSync( + 'bash', + [ + '-c', + `${bash}\nCOMP_WORDS=(${quoted})\nCOMP_CWORD=$((${words.length} - 1))\n_cnc_completion\nprintf '%s\\n' "${'${COMPREPLY[@]}'}"`, + ], + { encoding: 'utf8' } + ); + expect(completed.status).toBe(0); + return completed.stdout + .split('\n') + .map((candidate) => candidate.trim()) + .filter(Boolean); + }; + + expect(complete(['cnc', ''])).toEqual( + expect.arrayContaining(['quote', '--agent', '--format']) + ); + expect( + complete([ + 'cnc', + '--format', + 'json', + 'quote', + 'run', + '--depth', + '2', + '--p', + ]) + ).toEqual(expect.arrayContaining(['--postgis'])); + expect(complete(['cnc', 'quote', 'run', '--depth=2', '--no-s'])).toEqual( + expect.arrayContaining(['--no-spatial']) + ); + expect(complete(['cnc', 'quote', 'run', '-'])).toEqual( + expect.arrayContaining(['-d']) + ); + expect(complete(['cnc', 'quote', 'run', '--depth', ''])).toEqual([]); + expect(complete(['cnc', '--format', ''])).toEqual([ + 'human', + 'json', + 'jsonl', + ]); + expect(bash).not.toContain('--postGis'); + + const fish = generateCompletion(registry, 'fish'); + expect(fish).toContain('-l agent'); + expect(fish).toContain('-l no-postgis'); + expect(fish).toContain('__cnc_at_path'); + expect(fish).toContain('-n __cnc_options_active -l agent'); + expect(fish).not.toContain('__fish_seen_subcommand_from'); + expect(fish).not.toContain('-l postGis'); + }); + + it('bounds streaming memory and turns sink failures into deterministic internal outcomes', async () => { + const command = defineCommand({ + id: 'stream.run', + path: ['stream', 'run'], + summary: 'Stream work.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Null(), + events: Type.Object( + { event: Type.Literal('stream.progress'), index: Type.Integer() }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['stream', 'run'] }], + lifecycle: 'long-running' as const, + effect: 'service' as const, + async execute(_input, context) { + for (let index = 0; index < 100; index += 1) { + await context.events.emit({ event: 'stream.progress', index }); + } + return { data: null }; + }, + }); + const registry = createCommandRegistry([command]); + let delivered = 0; + const streamed = await executeCommand( + registry, + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + captureEvents: false, + sink: () => { + delivered += 1; + }, + } + ); + expect(delivered).toBe(102); + expect(streamed.protocolEvents).toEqual([]); + expect(streamed.terminalEvent.event).toBe('operation.completed'); + expect(JSON.parse(renderExecution(streamed, 'json').stdout).event).toBe( + 'operation.completed' + ); + expect(() => renderExecution(streamed, 'jsonl')).toThrow( + /transcript capture/ + ); + + let calls = 0; + const failed = await executeCommand( + registry, + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + sink: () => { + calls += 1; + if (calls === 2) throw new Error('writer closed'); + }, + } + ); + expect(failed.status).toBe('failed'); + expect(failed.error?.code).toBe('CLI_PROTOCOL_SINK_FAILED'); + expect( + failed.protocolEvents.filter( + (event) => + event.event.startsWith('operation.') && + event.event !== 'operation.started' + ) + ).toHaveLength(1); + }); + + it('preserves deprecation warnings on failures and redacts secrets inferred from env names', async () => { + const command = defineCommand({ + id: 'warning.fail', + path: ['warning', 'fail'], + summary: 'Fail with a warning.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Null(), + bindings: [], + examples: [{ argv: ['warning', 'fail'] }], + lifecycle: 'finite' as const, + effect: 'read' as const, + async execute(_input, context) { + throw new CliError({ + code: 'EXPECTED_FAILURE', + category: 'operation', + message: `Rejected ${context.env.CNC_TOKEN}, ${context.env.PGPASSWORD}, ${context.env.PGPASSFILE}, and ${context.env.SUPABASE_SERVICE_ROLE_KEY} at ${context.env.DATABASE_URL}`, + }); + }, + }); + const outcome = await executeCommand( + createCommandRegistry([command]), + command, + {}, + { + cwd: '/workspace', + mode: 'agent', + env: { + CNC_TOKEN: 'automatic-secret', + PGPASSWORD: 'delimiter-free-password', + PGPASSFILE: '/private/credential-file', + SUPABASE_SERVICE_ROLE_KEY: 'service-role-secret', + DATABASE_URL: 'postgres://user:database-secret@example.test/app', + }, + initialWarnings: [{ code: 'CLI_DEPRECATED', message: 'Old spelling.' }], + } + ); + expect(outcome.warnings).toEqual([ + { code: 'CLI_DEPRECATED', message: 'Old spelling.' }, + ]); + expect(outcome.terminalEvent).toEqual( + expect.objectContaining({ warnings: outcome.warnings }) + ); + expect(outcome.error?.message).toBe( + 'Rejected [REDACTED], [REDACTED], [REDACTED], and [REDACTED] at [REDACTED]' + ); + expect(JSON.stringify(outcome)).not.toContain('delimiter-free-password'); + expect(JSON.stringify(outcome)).not.toContain('/private/credential-file'); + expect(JSON.stringify(outcome)).not.toContain('service-role-secret'); + expect(renderExecution(outcome, 'human').stderr).toContain( + 'Warning [CLI_DEPRECATED]' + ); + }); + + it('recognizes cancellation errors created in another JavaScript realm', async () => { + const outcome = await createFailureOutcome({ + commandId: 'adapter.cancelled', + error: Object.create( + { name: 'AbortError' }, + { message: { value: 'cross-realm cancellation', enumerable: false } } + ), + }); + + expect(outcome.status).toBe('cancelled'); + expect(outcome.error?.code).toBe('OPERATION_CANCELLED'); + expect(outcome.terminalEvent.event).toBe('operation.cancelled'); + }); + + it('keeps CI and non-TTY effects disabled when interactive prompts are explicitly enabled', () => { + expect( + resolveExecutionSettings({ + interactive: true, + ci: true, + stdinIsTTY: true, + stdoutIsTTY: true, + }) + ).toEqual( + expect.objectContaining({ + interactive: true, + terminalEffects: false, + mayOpenBrowser: false, + checkForUpdates: false, + }) + ); + expect( + resolveExecutionSettings({ + interactive: true, + stdinIsTTY: true, + stdoutIsTTY: false, + }) + ).toEqual( + expect.objectContaining({ + interactive: true, + terminalEffects: false, + mayOpenBrowser: false, + }) + ); + }); +}); diff --git a/packages/cli-runtime/__tests__/registry-bindings.test.ts b/packages/cli-runtime/__tests__/registry-bindings.test.ts new file mode 100644 index 0000000000..d8d82e9164 --- /dev/null +++ b/packages/cli-runtime/__tests__/registry-bindings.test.ts @@ -0,0 +1,187 @@ +import { + bindArguments, + CommandRegistry, + ContractError, + createCommandRegistry, + defineCommand, + InvocationError, + Type, +} from '../src'; + +function createExampleCommand() { + return defineCommand({ + id: 'example.run', + path: ['example', 'run'], + summary: 'Run the example.', + input: Type.Object( + { + name: Type.String({ minLength: 1 }), + count: Type.Optional(Type.Integer({ minimum: 1 })), + force: Type.Optional(Type.Boolean()), + tags: Type.Optional(Type.Array(Type.String())), + token: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ), + output: Type.Object( + { ok: Type.Boolean() }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'name', + sources: [{ kind: 'positional', index: 0, name: 'name' }], + description: 'Example name.', + }, + { + property: 'count', + sources: [ + { kind: 'option', name: 'count', deprecatedAliases: ['Count'] }, + ], + valueType: 'number' as const, + }, + { + property: 'force', + sources: [ + { kind: 'option', name: 'force', short: 'f', negatable: true }, + ], + valueType: 'boolean' as const, + }, + { + property: 'tags', + sources: [{ kind: 'option', name: 'tag' }], + repeated: true, + }, + { + property: 'token', + sources: [ + { kind: 'option', name: 'token' }, + { kind: 'environment', name: 'CNC_TOKEN', sensitive: true }, + ], + conflict: 'error' as const, + }, + ], + examples: [{ argv: ['example', 'run', 'demo'] }], + lifecycle: 'finite' as const, + effect: 'write' as const, + async execute() { + return { data: { ok: true } }; + }, + }); +} + +describe('command registry and argument bindings', () => { + it('binds typed positional, option, repeated, negated, and environment sources', () => { + const command = createExampleCommand(); + const registry = createCommandRegistry([command]); + const bound = bindArguments( + command, + { + argv: ['demo', '--count=2', '--force', '--tag', 'one', '--tag=two'], + env: { CNC_TOKEN: 'very-secret-token' }, + }, + registry + ); + + expect(bound.input).toEqual({ + name: 'demo', + count: 2, + force: true, + tags: ['one', 'two'], + token: 'very-secret-token', + }); + expect(bound.sensitiveValues).toEqual(['very-secret-token']); + + expect( + bindArguments(command, { argv: ['demo', '--no-force'] }, registry).input + .force + ).toBe(false); + }); + + it('reports compatibility aliases without weakening strict parsing', () => { + const command = createExampleCommand(); + const registry = createCommandRegistry([command]); + const bound = bindArguments( + command, + { argv: ['demo', '--Count', '4'] }, + registry + ); + expect(bound.input.count).toBe(4); + expect(bound.warnings).toEqual([ + expect.objectContaining({ + code: 'CLI_DEPRECATED', + message: expect.stringContaining('--count'), + }), + ]); + expect(() => + bindArguments(command, { argv: ['demo', '--unknown'] }, registry) + ).toThrow(InvocationError); + expect(() => + bindArguments(command, { argv: ['demo', 'surplus'] }, registry) + ).toThrow(expect.objectContaining({ code: 'CLI_ARGUMENT_SURPLUS' })); + }); + + it('rejects ambiguous and invalid values before execution', () => { + const command = createExampleCommand(); + const registry = createCommandRegistry([command]); + expect(() => + bindArguments( + command, + { + argv: ['demo', '--token', 'flag-token'], + env: { CNC_TOKEN: 'env-token' }, + }, + registry + ) + ).toThrow(expect.objectContaining({ code: 'CLI_INPUT_SOURCE_CONFLICT' })); + expect(() => + bindArguments(command, { argv: ['demo', '--count', 'NaN'] }, registry) + ).toThrow(expect.objectContaining({ code: 'CLI_OPTION_VALUE_INVALID' })); + expect(() => bindArguments(command, { argv: [] }, registry)).toThrow( + expect.objectContaining({ code: 'CLI_INPUT_INVALID' }) + ); + }); + + it('provides deterministic discovery and longest-path resolution', () => { + const command = createExampleCommand(); + const registry = createCommandRegistry([command]); + const resolved = registry.resolve(['example', 'run', 'demo']); + expect(resolved).toEqual({ + command: registry.requireById(command.id), + consumed: 2, + }); + expect(resolved?.command).not.toBe(command); + expect(Object.isFrozen(resolved?.command)).toBe(true); + expect(registry.catalog()).toEqual([ + expect.objectContaining({ + id: 'example.run', + path: ['example', 'run'], + effect: 'write', + }), + ]); + const schema = registry.schema('example.run'); + expect(schema.protocolVersion).toBe('constructive.dev/cli/v1'); + expect(schema.input).not.toBe(command.input); + expect(schema.bindings).not.toBe(command.bindings); + }); + + it('fails registry construction for duplicate or malformed contracts', () => { + const command = createExampleCommand(); + expect(() => new CommandRegistry([command, command])).toThrow( + expect.objectContaining({ code: 'CLI_COMMAND_ID_DUPLICATE' }) + ); + + const malformed = { ...command, id: 'Bad ID' }; + expect(() => createCommandRegistry([malformed])).toThrow(ContractError); + + const badEvents = { + ...command, + id: 'example.bad', + path: ['example', 'bad'], + events: Type.String(), + }; + expect(() => createCommandRegistry([badEvents])).toThrow( + expect.objectContaining({ code: 'CLI_EVENT_SCHEMA_INVALID' }) + ); + }); +}); diff --git a/packages/cli-runtime/jest.config.js b/packages/cli-runtime/jest.config.js new file mode 100644 index 0000000000..d977a1e638 --- /dev/null +++ b/packages/cli-runtime/jest.config.js @@ -0,0 +1,17 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], +}; diff --git a/packages/cli-runtime/package.json b/packages/cli-runtime/package.json new file mode 100644 index 0000000000..b7a0dbfeca --- /dev/null +++ b/packages/cli-runtime/package.json @@ -0,0 +1,57 @@ +{ + "name": "@constructive-io/cli-runtime", + "version": "0.1.0", + "description": "Typed, machine-readable command runtime for Constructive CLIs", + "author": "Constructive ", + "license": "MIT", + "homepage": "https://github.com/constructive-io/constructive", + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "engines": { + "node": ">=18.17.0" + }, + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./esm/index.js", + "require": "./index.js" + } + }, + "files": [ + "**/*" + ], + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build && node ./scripts/finalize-esm.js", + "build:dev": "makage build --dev && node ./scripts/finalize-esm.js", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.49", + "ajv": "^8.20.0" + }, + "devDependencies": { + "makage": "^0.3.0" + }, + "keywords": [ + "cli", + "agent", + "json-schema", + "constructive" + ] +} diff --git a/packages/cli-runtime/scripts/finalize-esm.js b/packages/cli-runtime/scripts/finalize-esm.js new file mode 100644 index 0000000000..42f9ceace4 --- /dev/null +++ b/packages/cli-runtime/scripts/finalize-esm.js @@ -0,0 +1,43 @@ +const { + existsSync, + readdirSync, + readFileSync, + writeFileSync, +} = require('node:fs'); +const { dirname, join, resolve } = require('node:path'); + +const directory = join(__dirname, '..', 'dist', 'esm'); +const relativeImport = + /(\bfrom\s+['"]|\bimport\s*\(\s*['"])(\.{1,2}\/[^'"]+?)(['"]\s*\)?)/g; + +const rewriteDirectory = (current) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + rewriteDirectory(path); + continue; + } + if (!entry.name.endsWith('.js')) continue; + const source = readFileSync(path, 'utf8'); + const rewritten = source.replace( + relativeImport, + (_match, prefix, specifier, suffix) => { + if (/\.(?:js|json|mjs|cjs)$/.test(specifier)) { + return `${prefix}${specifier}${suffix}`; + } + const resolved = resolve(dirname(path), specifier); + const completed = existsSync(`${resolved}.js`) + ? `${specifier}.js` + : existsSync(join(resolved, 'index.js')) + ? `${specifier}/index.js` + : `${specifier}.js`; + return `${prefix}${completed}${suffix}`; + } + ); + writeFileSync(path, rewritten); + } +}; + +rewriteDirectory(directory); + +writeFileSync(join(directory, 'package.json'), '{"type":"module"}\n'); diff --git a/packages/cli-runtime/src/bindings.ts b/packages/cli-runtime/src/bindings.ts new file mode 100644 index 0000000000..af214a9029 --- /dev/null +++ b/packages/cli-runtime/src/bindings.ts @@ -0,0 +1,381 @@ +import { Static, TSchema } from '@sinclair/typebox'; + +import { + BindArgumentsOptions, + BindingSource, + BoundArguments, + CommandDefinition, + InputBinding, + OperationWarning, + OptionBindingSource, +} from './contracts'; +import { InvocationError } from './errors'; +import { isSensitiveKey } from './redaction'; +import type { CommandRegistry } from './registry'; +import { compileSchema, SchemaIssue } from './schema'; + +interface OptionLookup { + binding: InputBinding; + source: OptionBindingSource; + deprecated: boolean; +} + +interface SuppliedValue { + source: BindingSource; + sourceKey: string; + values: unknown[]; +} + +function parseBoolean(value: string, label: string): boolean { + if (['true', '1', 'yes'].includes(value.toLowerCase())) return true; + if (['false', '0', 'no'].includes(value.toLowerCase())) return false; + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + `${label} expects a boolean value.` + ); +} + +function parseValue( + value: string, + binding: InputBinding, + label: string +): unknown { + switch (binding.valueType ?? 'string') { + case 'string': + return value; + case 'number': { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + `${label} expects a finite number.` + ); + } + return parsed; + } + case 'boolean': + return parseBoolean(value, label); + case 'json': + try { + return JSON.parse(value) as unknown; + } catch { + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + `${label} expects valid JSON.` + ); + } + } +} + +function addOptionNames( + lookup: Map, + binding: InputBinding, + source: OptionBindingSource +): void { + lookup.set(source.name, { binding, source, deprecated: false }); + for (const alias of source.aliases ?? []) + lookup.set(alias, { binding, source, deprecated: false }); + for (const alias of source.deprecatedAliases ?? []) + lookup.set(alias, { binding, source, deprecated: true }); +} + +function validationMessage( + command: CommandDefinition, + issues: SchemaIssue[] +): InvocationError { + const first = issues[0]; + return new InvocationError( + 'CLI_INPUT_INVALID', + first === undefined + ? `Input for "${command.path.join(' ')}" is invalid.` + : `Input ${first.path} ${first.message}.`, + { path: first?.path, details: { issues } } + ); +} + +/** Strictly binds argv and an explicit environment snapshot into command input. */ +export function bindArguments< + TInput extends TSchema, + TOutput extends TSchema, + TEvent extends TSchema, +>( + command: CommandDefinition, + options: BindArgumentsOptions, + registry?: CommandRegistry +): BoundArguments> { + const strict = options.strict ?? true; + const env = options.env ?? {}; + const longOptionLookup = new Map(); + const shortOptionLookup = new Map(); + const positionals: string[] = []; + const supplied = new Map>(); + const warnings: OperationWarning[] = options.warnings ?? []; + const sensitiveValues: string[] = []; + + for (const binding of command.bindings) { + for (const source of binding.sources) { + if (source.kind === 'option') { + addOptionNames(longOptionLookup, binding, source); + if (source.short !== undefined) { + shortOptionLookup.set(source.short, { + binding, + source, + deprecated: false, + }); + } + } + } + } + + const addSupplied = ( + binding: InputBinding, + source: BindingSource, + sourceKey: string, + value: unknown + ): void => { + let bySource = supplied.get(binding.property); + if (bySource === undefined) { + bySource = new Map(); + supplied.set(binding.property, bySource); + } + let entry = bySource.get(sourceKey); + if (entry === undefined) { + entry = { source, sourceKey, values: [] }; + bySource.set(sourceKey, entry); + } + entry.values.push(value); + }; + + let optionsEnded = false; + for (let index = 0; index < options.argv.length; index += 1) { + const token = options.argv[index]; + if (!optionsEnded && token === '--') { + optionsEnded = true; + continue; + } + if (optionsEnded || token === '-' || !token.startsWith('-')) { + positionals.push(token); + continue; + } + + const isLong = token.startsWith('--'); + const withoutDashes = token.slice(isLong ? 2 : 1); + const equalsIndex = withoutDashes.indexOf('='); + let optionName = + equalsIndex === -1 ? withoutDashes : withoutDashes.slice(0, equalsIndex); + let inlineValue = + equalsIndex === -1 ? undefined : withoutDashes.slice(equalsIndex + 1); + let negated = false; + let matched = (isLong ? longOptionLookup : shortOptionLookup).get( + optionName + ); + + if (matched === undefined && isLong && optionName.startsWith('no-')) { + const positiveName = optionName.slice(3); + const candidate = longOptionLookup.get(positiveName); + if (candidate?.source.negatable) { + optionName = positiveName; + matched = candidate; + negated = true; + } + } + + if (matched === undefined) { + if (strict) { + const safeOption = token.split('=', 1)[0]; + throw new InvocationError( + 'CLI_OPTION_UNKNOWN', + `Unknown option "${safeOption}".`, + { + details: { option: safeOption }, + } + ); + } + positionals.push(token); + continue; + } + + const label = `--${matched.source.name}`; + let value: unknown; + if ((matched.binding.valueType ?? 'string') === 'boolean') { + if (negated) { + if (inlineValue !== undefined) { + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + `Negated option "${token}" cannot have a value.` + ); + } + value = false; + } else if (inlineValue !== undefined) { + value = parseBoolean(inlineValue, label); + } else { + value = true; + } + } else { + if (negated) { + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + `Option "${label}" cannot be negated.` + ); + } + if (inlineValue === undefined) { + index += 1; + inlineValue = options.argv[index]; + } + if (inlineValue === undefined) { + throw new InvocationError( + 'CLI_OPTION_VALUE_MISSING', + `Option "${label}" requires a value.` + ); + } + value = parseValue(inlineValue, matched.binding, label); + } + + addSupplied( + matched.binding, + matched.source, + `option:${matched.source.name}`, + value + ); + if (matched.source.sensitive) { + const secret = typeof value === 'string' ? value : inlineValue; + if (secret !== undefined && secret.length > 0) + sensitiveValues.push(secret); + } + if (matched.deprecated) { + warnings.push({ + code: 'CLI_DEPRECATED', + message: `Option "${token.split('=')[0]}" is deprecated; use "--${matched.source.name}".`, + }); + } + } + + for (const binding of command.bindings) { + for (const source of binding.sources) { + if (source.kind === 'positional') { + const rawValues = source.variadic + ? positionals.slice(source.index) + : positionals.slice(source.index, source.index + 1); + for (const rawValue of rawValues) { + addSupplied( + binding, + source, + `positional:${source.index}`, + parseValue(rawValue, binding, source.name ?? binding.property) + ); + } + } + if (source.kind === 'environment') { + const rawValue = env[source.name]; + if (rawValue !== undefined) { + addSupplied( + binding, + source, + `environment:${source.name}`, + parseValue(rawValue, binding, source.name) + ); + if (source.sensitive || isSensitiveKey(source.name)) + sensitiveValues.push(rawValue); + } + } + if (source.kind === 'constant') + addSupplied( + binding, + source, + `constant:${binding.property}`, + source.value + ); + } + } + + const positionalSources = command.bindings.flatMap((binding) => + binding.sources.filter( + (source): source is Extract => + source.kind === 'positional' + ) + ); + const variadic = positionalSources.find((source) => source.variadic); + const acceptedPositionals = + variadic === undefined + ? positionalSources.reduce( + (maximum, source) => Math.max(maximum, source.index + 1), + 0 + ) + : Number.POSITIVE_INFINITY; + if (strict && positionals.length > acceptedPositionals) { + throw new InvocationError( + 'CLI_ARGUMENT_SURPLUS', + 'Unexpected positional arguments were provided.', + { + details: { + firstUnexpectedIndex: acceptedPositionals, + count: positionals.length - acceptedPositionals, + }, + } + ); + } + + const input: Record = Object.create(null) as Record< + string, + unknown + >; + for (const binding of command.bindings) { + const bySource = supplied.get(binding.property); + if (bySource === undefined || bySource.size === 0) continue; + const ordered = binding.sources + .map((source) => { + const key = + source.kind === 'option' + ? `option:${source.name}` + : source.kind === 'positional' + ? `positional:${source.index}` + : source.kind === 'environment' + ? `environment:${source.name}` + : `constant:${binding.property}`; + return bySource.get(key); + }) + .filter((entry): entry is SuppliedValue => entry !== undefined); + + const explicitlySupplied = ordered.filter( + (entry) => entry.source.kind !== 'constant' + ); + if (binding.conflict === 'error' && explicitlySupplied.length > 1) { + throw new InvocationError( + 'CLI_INPUT_SOURCE_CONFLICT', + `Input "${binding.property}" was supplied by more than one source.`, + { + details: { + sources: explicitlySupplied.map((entry) => entry.sourceKey), + }, + } + ); + } + const selected = ordered[0]; + if (!binding.repeated && selected.values.length > 1) { + throw new InvocationError( + 'CLI_OPTION_REPEATED', + `Input "${binding.property}" may only be supplied once.` + ); + } + input[binding.property] = binding.repeated + ? selected.values + : selected.values[0]; + } + + if (options.validate !== false) { + const issues = + registry === undefined + ? (() => { + const validator = compileSchema>(command.input); + return validator.validate(input) ? [] : validator.issues(); + })() + : registry.validateInput(command.id, input); + if (issues.length > 0) throw validationMessage(command, issues); + } + + return { + input: input as Static, + warnings, + sensitiveValues: [...new Set(sensitiveValues)], + }; +} diff --git a/packages/cli-runtime/src/contracts.ts b/packages/cli-runtime/src/contracts.ts new file mode 100644 index 0000000000..b3bdf0f115 --- /dev/null +++ b/packages/cli-runtime/src/contracts.ts @@ -0,0 +1,563 @@ +import { Static, TSchema, Type } from '@sinclair/typebox'; + +export const PROTOCOL_VERSION = 'constructive.dev/cli/v1' as const; + +export const ExecutionModeSchema = Type.Union([ + Type.Literal('human'), + Type.Literal('agent'), + Type.Literal('ci'), +]); +export type ExecutionMode = Static; + +export const OutputFormatSchema = Type.Union([ + Type.Literal('human'), + Type.Literal('json'), + Type.Literal('jsonl'), +]); +export type OutputFormat = Static; + +export const CommandLifecycleSchema = Type.Union([ + Type.Literal('finite'), + Type.Literal('long-running'), +]); +export type CommandLifecycle = Static; + +export const CommandEffectSchema = Type.Union([ + Type.Literal('read'), + Type.Literal('write'), + Type.Literal('destructive'), + Type.Literal('service'), +]); +export type CommandEffect = Static; + +export const WarningSchema = Type.Object( + { + code: Type.String({ minLength: 1 }), + message: Type.String({ minLength: 1 }), + path: Type.Optional(Type.String()), + }, + { additionalProperties: false } +); +export type OperationWarning = Static; + +export const ArtifactSchema = Type.Object( + { + type: Type.String({ minLength: 1 }), + path: Type.String({ minLength: 1 }), + digest: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + }, + { additionalProperties: false } +); +export type OperationArtifact = Static; + +export const NextActionSchema = Type.Object( + { + commandId: Type.String({ minLength: 1 }), + input: Type.Record(Type.String(), Type.Unknown()), + reason: Type.String({ minLength: 1 }), + }, + { additionalProperties: false } +); +export type NextAction = Static; + +export interface OperationResult { + data: T; + warnings?: OperationWarning[]; + artifacts?: OperationArtifact[]; + nextActions?: NextAction[]; +} + +/** The schema shared by results whose command-specific data schema is not known. */ +export const OperationResultSchema = Type.Object( + { + data: Type.Unknown(), + warnings: Type.Optional(Type.Array(WarningSchema)), + artifacts: Type.Optional(Type.Array(ArtifactSchema)), + nextActions: Type.Optional(Type.Array(NextActionSchema)), + }, + { additionalProperties: false } +); + +/** Builds the exact wire-result schema for a command output schema. */ +export function operationResultSchema(data: TData) { + return Type.Object( + { + data, + warnings: Type.Optional(Type.Array(WarningSchema)), + artifacts: Type.Optional(Type.Array(ArtifactSchema)), + nextActions: Type.Optional(Type.Array(NextActionSchema)), + }, + { additionalProperties: false } + ); +} + +export const ErrorCategorySchema = Type.Union([ + Type.Literal('invocation'), + Type.Literal('validation'), + Type.Literal('configuration'), + Type.Literal('authentication'), + Type.Literal('authorization'), + Type.Literal('conflict'), + Type.Literal('network'), + Type.Literal('operation'), + Type.Literal('internal'), +]); +export type ErrorCategory = Static; + +export const StructuredErrorSchema = Type.Object( + { + code: Type.String({ minLength: 1 }), + category: ErrorCategorySchema, + message: Type.String({ minLength: 1 }), + path: Type.Optional(Type.String()), + details: Type.Optional(Type.Unknown()), + retryable: Type.Boolean(), + nextActions: Type.Optional(Type.Array(NextActionSchema)), + }, + { additionalProperties: false } +); +export type StructuredError = Static; + +export type BindingValueType = 'string' | 'number' | 'boolean' | 'json'; + +export interface PositionalBindingSource { + kind: 'positional'; + index: number; + name?: string; + variadic?: boolean; +} + +export interface OptionBindingSource { + kind: 'option'; + /** Canonical long name without the leading `--`. */ + name: string; + /** Additional names without leading dashes. */ + aliases?: string[]; + /** Aliases retained for compatibility which produce CLI_DEPRECATED warnings. */ + deprecatedAliases?: string[]; + short?: string; + negatable?: boolean; + /** Marks values supplied through this option as secrets for protocol redaction. */ + sensitive?: boolean; +} + +export interface EnvironmentBindingSource { + kind: 'environment'; + name: string; + sensitive?: boolean; +} + +export interface ConstantBindingSource { + kind: 'constant'; + value: unknown; +} + +export type BindingSource = + | PositionalBindingSource + | OptionBindingSource + | EnvironmentBindingSource + | ConstantBindingSource; + +export interface InputBinding { + /** Top-level input property populated by this binding. */ + property: string; + /** Sources are evaluated in order. */ + sources: BindingSource[]; + valueType?: BindingValueType; + repeated?: boolean; + /** Reject invocations which provide more than one source. */ + conflict?: 'first' | 'error'; + description?: string; + valueName?: string; +} + +export interface SafetyCapabilities { + dryRun?: boolean; + idempotencyKey?: boolean; + confirmation?: boolean; + destructiveAcknowledgements?: readonly string[]; +} + +export const SafetyCapabilitiesSchema = Type.Object( + { + dryRun: Type.Optional(Type.Boolean()), + idempotencyKey: Type.Optional(Type.Boolean()), + confirmation: Type.Optional(Type.Boolean()), + destructiveAcknowledgements: Type.Optional( + Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }) + ), + }, + { additionalProperties: false } +); + +/** Creates a self-contained protocol-safe JSON-value schema with a unique reference id. */ +export function createJsonValueSchema(id: string) { + return Type.Recursive( + (JsonValue) => + Type.Union([ + Type.Null(), + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Array(JsonValue), + Type.Record(Type.String(), JsonValue), + ]), + { $id: id } + ); +} + +/** A standalone protocol-safe JSON value used for constant bindings. */ +export const JsonValueSchema = createJsonValueSchema( + 'https://constructive.dev/cli/v1/schemas/json-value' +); + +export const PositionalBindingSourceSchema = Type.Object( + { + kind: Type.Literal('positional'), + index: Type.Integer({ minimum: 0 }), + name: Type.Optional(Type.String()), + variadic: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } +); + +export const OptionBindingSourceSchema = Type.Object( + { + kind: Type.Literal('option'), + name: Type.String({ minLength: 1 }), + aliases: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + deprecatedAliases: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + short: Type.Optional(Type.String({ minLength: 1, maxLength: 1 })), + negatable: Type.Optional(Type.Boolean()), + sensitive: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } +); + +export const EnvironmentBindingSourceSchema = Type.Object( + { + kind: Type.Literal('environment'), + name: Type.String({ minLength: 1 }), + sensitive: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } +); + +export const ConstantBindingSourceSchema = Type.Object( + { + kind: Type.Literal('constant'), + value: JsonValueSchema, + }, + { additionalProperties: false } +); + +export const BindingSourceSchema = Type.Union([ + PositionalBindingSourceSchema, + OptionBindingSourceSchema, + EnvironmentBindingSourceSchema, + ConstantBindingSourceSchema, +]); + +export const InputBindingSchema = Type.Object( + { + property: Type.String({ minLength: 1 }), + sources: Type.Array(BindingSourceSchema), + valueType: Type.Optional( + Type.Union([ + Type.Literal('string'), + Type.Literal('number'), + Type.Literal('boolean'), + Type.Literal('json'), + ]) + ), + repeated: Type.Optional(Type.Boolean()), + conflict: Type.Optional( + Type.Union([Type.Literal('first'), Type.Literal('error')]) + ), + description: Type.Optional(Type.String()), + valueName: Type.Optional(Type.String()), + }, + { additionalProperties: false } +); + +export const CommandExampleSchema = Type.Object( + { + description: Type.Optional(Type.String()), + argv: Type.Array(Type.String()), + }, + { additionalProperties: false } +); + +export interface ApprovedCapabilities { + yes: boolean; + dryRun?: boolean; + idempotencyKey?: string; + acknowledgedRisks: readonly string[]; +} + +export interface CommandExample { + description?: string; + argv: readonly string[]; +} + +export interface EventReporter { + emit(event: TEvent): Promise; +} + +export interface OperationContext { + cwd: string; + mode: ExecutionMode; + /** Environment snapshot supplied by the outer adapter; operations never read process.env directly. */ + env: Readonly>; + signal: AbortSignal; + operationId: string; + now(): Date; + events: EventReporter; + capabilities: ApprovedCapabilities; + /** Register secrets discovered during execution before they can reach protocol output. */ + registerSensitiveValue(value: string): void; +} + +/** Terminal-only hooks live outside CommandDefinition so the operation contract stays reusable. */ +export interface CommandAdapterHooks { + collectInteractiveInput?: ( + input: Partial, + context: Readonly< + Pick + > + ) => Promise; + renderHuman?: (result: OperationResult) => string; +} + +export type CommandAdapterHookMap = Readonly< + Record +>; + +export interface CommandDefinition< + TInput extends TSchema = TSchema, + TOutput extends TSchema = TSchema, + TEvent extends TSchema = TSchema, +> { + id: string; + path: readonly string[]; + summary: string; + description?: string; + input: TInput; + output: TOutput; + events?: TEvent; + bindings: readonly InputBinding[]; + examples: readonly CommandExample[]; + lifecycle: CommandLifecycle; + effect: CommandEffect; + capabilities?: SafetyCapabilities; + execute( + input: Static, + context: OperationContext> + ): Promise>>; +} + +export function defineCommand< + TInput extends TSchema, + TOutput extends TSchema, + TEvent extends TSchema = TSchema, +>( + definition: CommandDefinition +): CommandDefinition { + return definition; +} + +export interface ExecutionSettings { + mode: ExecutionMode; + format: OutputFormat; + interactive: boolean; + terminalEffects: boolean; + mayOpenBrowser: boolean; + checkForUpdates: boolean; +} + +export interface OperationStartedEvent { + protocolVersion: typeof PROTOCOL_VERSION; + event: 'operation.started'; + operationId: string; + commandId: string; + timestamp: string; +} + +const ProtocolIdentityProperties = { + protocolVersion: Type.Literal(PROTOCOL_VERSION), + operationId: Type.String({ minLength: 1 }), + commandId: Type.String({ minLength: 1 }), + timestamp: Type.String({ minLength: 1 }), +}; + +export const OperationStartedEventSchema = Type.Object( + { + ...ProtocolIdentityProperties, + event: Type.Literal('operation.started'), + }, + { additionalProperties: false } +); + +export interface DomainProtocolEvent { + protocolVersion: typeof PROTOCOL_VERSION; + event: string; + operationId: string; + commandId: string; + timestamp: string; + [key: string]: unknown; +} + +export const DomainProtocolEventSchema = Type.Object( + { + ...ProtocolIdentityProperties, + event: Type.String({ + minLength: 1, + pattern: '^(?!operation\\.(?:started|completed|failed|cancelled)$).+', + }), + }, + { additionalProperties: true } +); + +export interface OperationCompletedEvent { + protocolVersion: typeof PROTOCOL_VERSION; + event: 'operation.completed'; + operationId: string; + commandId: string; + timestamp: string; + durationMs: number; + result: OperationResult; +} + +export const OperationCompletedEventSchema = Type.Object( + { + ...ProtocolIdentityProperties, + event: Type.Literal('operation.completed'), + durationMs: Type.Number({ minimum: 0 }), + result: OperationResultSchema, + }, + { additionalProperties: false } +); + +export interface OperationFailedEvent { + protocolVersion: typeof PROTOCOL_VERSION; + event: 'operation.failed'; + operationId: string; + commandId: string; + timestamp: string; + durationMs: number; + error: StructuredError; + warnings?: OperationWarning[]; +} + +export const OperationFailedEventSchema = Type.Object( + { + ...ProtocolIdentityProperties, + event: Type.Literal('operation.failed'), + durationMs: Type.Number({ minimum: 0 }), + error: StructuredErrorSchema, + warnings: Type.Optional(Type.Array(WarningSchema)), + }, + { additionalProperties: false } +); + +export interface OperationCancelledEvent { + protocolVersion: typeof PROTOCOL_VERSION; + event: 'operation.cancelled'; + operationId: string; + commandId: string; + timestamp: string; + durationMs: number; + error: StructuredError; + warnings?: OperationWarning[]; +} + +export const OperationCancelledEventSchema = Type.Object( + { + ...ProtocolIdentityProperties, + event: Type.Literal('operation.cancelled'), + durationMs: Type.Number({ minimum: 0 }), + error: StructuredErrorSchema, + warnings: Type.Optional(Type.Array(WarningSchema)), + }, + { additionalProperties: false } +); + +export type TerminalProtocolEvent = + | OperationCompletedEvent + | OperationFailedEvent + | OperationCancelledEvent; + +export type ProtocolEvent = + | OperationStartedEvent + | DomainProtocolEvent + | TerminalProtocolEvent; + +export const TerminalProtocolEventSchema = Type.Union([ + OperationCompletedEventSchema, + OperationFailedEventSchema, + OperationCancelledEventSchema, +]); + +export const ProtocolEventSchema = Type.Union([ + OperationStartedEventSchema, + DomainProtocolEventSchema, + TerminalProtocolEventSchema, +]); + +export type ExecutionStatus = 'completed' | 'failed' | 'cancelled'; + +export interface ExecutionOutcome { + status: ExecutionStatus; + commandId: string; + operationId: string; + startedAt: string; + completedAt: string; + durationMs: number; + result?: OperationResult; + error?: StructuredError; + warnings?: OperationWarning[]; + /** Terminal envelope retained even when event transcript capture is disabled. */ + terminalEvent: TerminalProtocolEvent; + /** Complete transcript for buffered adapters and tests. */ + protocolEvents: ProtocolEvent[]; +} + +export const ExecutionOutcomeSchema = Type.Object( + { + status: Type.Union([ + Type.Literal('completed'), + Type.Literal('failed'), + Type.Literal('cancelled'), + ]), + commandId: Type.String({ minLength: 1 }), + operationId: Type.String({ minLength: 1 }), + startedAt: Type.String({ minLength: 1 }), + completedAt: Type.String({ minLength: 1 }), + durationMs: Type.Number({ minimum: 0 }), + result: Type.Optional(OperationResultSchema), + error: Type.Optional(StructuredErrorSchema), + warnings: Type.Optional(Type.Array(WarningSchema)), + terminalEvent: TerminalProtocolEventSchema, + protocolEvents: Type.Array(ProtocolEventSchema), + }, + { additionalProperties: false } +); + +export type ProtocolEventSink = (event: ProtocolEvent) => void | Promise; + +export interface BindArgumentsOptions { + argv: readonly string[]; + env?: Readonly>; + strict?: boolean; + /** Optional adapter-owned warning accumulator retained even when binding fails. */ + warnings?: OperationWarning[]; + /** Defer schema validation until after an interactive adapter has collected missing input. */ + validate?: boolean; +} + +export interface BoundArguments> { + input: T; + warnings: OperationWarning[]; + sensitiveValues: string[]; +} diff --git a/packages/cli-runtime/src/discovery.ts b/packages/cli-runtime/src/discovery.ts new file mode 100644 index 0000000000..a05bf28b5b --- /dev/null +++ b/packages/cli-runtime/src/discovery.ts @@ -0,0 +1,590 @@ +import { Static, Type } from '@sinclair/typebox'; +import { + CommandDefinition, + InputBinding, + OptionBindingSource, +} from './contracts'; +import { + CommandCatalogEntrySchema, + CommandRegistry, + CommandSchemaDocumentSchema, +} from './registry'; + +export const GlobalOptionHelpSchema = Type.Object( + { + name: Type.String({ minLength: 1 }), + description: Type.String({ minLength: 1 }), + }, + { additionalProperties: false } +); + +/** Runtime schema for structured help generated from a command registry. */ +export const HelpDocumentSchema = Type.Object( + { + tool: Type.String({ minLength: 1 }), + usage: Type.String({ minLength: 1 }), + command: Type.Optional(CommandSchemaDocumentSchema), + commands: Type.Array(CommandCatalogEntrySchema), + globalOptions: Type.Array(GlobalOptionHelpSchema), + }, + { additionalProperties: false } +); + +export type HelpDocument = Static; + +interface GlobalOptionDefinition { + long: string; + short?: string; + valueName?: string; + values?: readonly string[]; + description: string; +} + +const GLOBAL_OPTION_DEFINITIONS: readonly GlobalOptionDefinition[] = [ + { + long: 'agent', + description: 'Use the strict noninteractive JSONL agent protocol.', + }, + { long: 'interactive', description: 'Allow prompts when stdin is a TTY.' }, + { long: 'non-interactive', description: 'Disable all prompts.' }, + { + long: 'format', + valueName: 'human|json|jsonl', + values: ['human', 'json', 'jsonl'], + description: 'Select the output protocol.', + }, + { + long: 'cwd', + valueName: 'path', + description: 'Resolve the command from this working directory.', + }, + { + long: 'yes', + description: 'Approve a command which declares confirmation support.', + }, + { long: 'no-color', description: 'Disable ANSI terminal styling.' }, + { long: 'debug', description: 'Include redacted internal diagnostics.' }, + { long: 'help', short: 'h', description: 'Show help.' }, + { long: 'version', short: 'v', description: 'Show the CLI version.' }, +]; + +const GLOBAL_OPTIONS = GLOBAL_OPTION_DEFINITIONS.map( + ({ long, valueName, description }) => ({ + name: `--${long}${valueName === undefined ? '' : ` <${valueName}>`}`, + description, + }) +); + +function optionLabel( + binding: InputBinding, + source: OptionBindingSource +): string { + const names = [`--${source.name}`]; + if (source.short !== undefined) names.unshift(`-${source.short}`); + if ((binding.valueType ?? 'string') !== 'boolean') + names.push(`<${binding.valueName ?? binding.property}>`); + return names.join(', '); +} + +function commandUsage(command: CommandDefinition, toolName: string): string { + const positionals = command.bindings + .flatMap((binding) => + binding.sources + .filter((source) => source.kind === 'positional') + .map( + (source) => + `<${source.name ?? binding.property}${source.variadic ? '...' : ''}>` + ) + ) + .join(' '); + const hasOptions = command.bindings.some((binding) => + binding.sources.some((source) => source.kind === 'option') + ); + return [toolName, ...command.path, positionals, hasOptions ? '[options]' : ''] + .filter(Boolean) + .join(' '); +} + +/** Quotes a single argv token for POSIX-compatible help and generated scripts. */ +export function quoteShellArgument(value: string): string { + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value) && value.length > 0) return value; + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +export function getHelpDocument( + registry: CommandRegistry, + path: readonly string[] = [], + toolName = 'cnc' +): HelpDocument { + const command = registry.getByPath(path); + return { + tool: toolName, + usage: + command === undefined + ? `${toolName} [options]` + : commandUsage(command, toolName), + ...(command === undefined ? {} : { command: registry.schema(command.id) }), + commands: command === undefined ? registry.catalog(path) : [], + globalOptions: GLOBAL_OPTIONS.map((option) => ({ ...option })), + }; +} + +export function renderHelp( + registry: CommandRegistry, + path: readonly string[] = [], + toolName = 'cnc' +): string { + const document = getHelpDocument(registry, path, toolName); + const command = path.length === 0 ? undefined : registry.getByPath(path); + const lines = [`Usage: ${document.usage}`, '']; + + if (command !== undefined) { + lines.push(command.summary); + if (command.description !== undefined) lines.push('', command.description); + + const positionals = command.bindings.flatMap((binding) => + binding.sources + .filter((source) => source.kind === 'positional') + .map((source) => ({ + label: source.name ?? binding.property, + description: binding.description ?? '', + })) + ); + if (positionals.length > 0) { + lines.push('', 'Arguments:'); + for (const positional of positionals) + lines.push( + ` ${positional.label.padEnd(24)} ${positional.description}`.trimEnd() + ); + } + + const options = command.bindings.flatMap((binding) => + binding.sources + .filter( + (source): source is OptionBindingSource => source.kind === 'option' + ) + .map((source) => ({ + label: optionLabel(binding, source), + description: binding.description ?? '', + })) + ); + if (options.length > 0) { + lines.push('', 'Command options:'); + for (const option of options) + lines.push( + ` ${option.label.padEnd(24)} ${option.description}`.trimEnd() + ); + } + + if (command.examples.length > 0) { + lines.push('', 'Examples:'); + for (const example of command.examples) { + if (example.description !== undefined) + lines.push(` # ${example.description}`); + lines.push( + ` ${[toolName, ...example.argv].map(quoteShellArgument).join(' ')}` + ); + } + } + } else if (document.commands.length > 0) { + lines.push('Commands:'); + for (const entry of document.commands) { + lines.push( + ` ${entry.path.join(' ').padEnd(28)} ${entry.summary}`.trimEnd() + ); + } + } else { + lines.push(`No commands found below "${path.join(' ')}".`); + } + + lines.push('', 'Global options:'); + for (const option of document.globalOptions) + lines.push(` ${option.name.padEnd(32)} ${option.description}`.trimEnd()); + return `${lines.join('\n').trimEnd()}\n`; +} + +export type CompletionShell = 'bash' | 'zsh' | 'fish'; + +function validateToolName(toolName: string): void { + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(toolName)) + throw new Error(`Invalid completion tool name "${toolName}".`); +} + +interface CompletionOption { + token: string; + requiresValue: boolean; + values?: readonly string[]; +} + +const globalCompletionOptions = (): CompletionOption[] => + GLOBAL_OPTION_DEFINITIONS.flatMap((definition) => [ + { + token: `--${definition.long}`, + requiresValue: definition.valueName !== undefined, + ...(definition.values === undefined ? {} : { values: definition.values }), + }, + ...(definition.short === undefined + ? [] + : [{ token: `-${definition.short}`, requiresValue: false }]), + ]); + +/** + * Return only supported, non-deprecated command spellings. Compatibility + * aliases still parse, but advertising deprecated spellings would keep them + * alive indefinitely in generated shell configuration. + */ +function commandCompletionOptions( + command: CommandDefinition +): CompletionOption[] { + const options = new Map(); + for (const binding of command.bindings) { + for (const source of binding.sources) { + if (source.kind !== 'option') continue; + const requiresValue = (binding.valueType ?? 'string') !== 'boolean'; + const longNames = [source.name, ...(source.aliases ?? [])]; + for (const name of longNames) { + options.set(`--${name}`, { token: `--${name}`, requiresValue }); + if (source.negatable) { + options.set(`--no-${name}`, { + token: `--no-${name}`, + requiresValue: false, + }); + } + } + if (source.short !== undefined) { + options.set(`-${source.short}`, { + token: `-${source.short}`, + requiresValue, + }); + } + } + } + return [...options.values()].sort((left, right) => + left.token.localeCompare(right.token) + ); +} + +function commandChildren( + registry: CommandRegistry, + prefix: readonly string[] +): string[] { + const candidates = new Set(); + for (const command of registry.list(prefix)) { + const next = command.path[prefix.length]; + if (next !== undefined) candidates.add(next); + } + return [...candidates].sort(); +} + +function completionPrefixes(registry: CommandRegistry): string[][] { + const prefixes = new Map(); + prefixes.set('', []); + for (const command of registry.list()) { + for (let length = 1; length <= command.path.length; length += 1) { + const prefix = command.path.slice(0, length); + prefixes.set(prefix.join(' '), prefix); + } + } + return [...prefixes.values()]; +} + +function casePatterns(values: readonly string[]): string { + return values + .map((value) => + value.endsWith('=*') + ? `${quoteShellArgument(value.slice(0, -1))}*` + : quoteShellArgument(value) + ) + .join('|'); +} + +function fishCasePatterns(values: readonly string[]): string { + return values.map(quoteShellArgument).join(' '); +} + +function bashCompletion( + registry: CommandRegistry, + shell: 'bash' | 'zsh', + toolName: string +): string { + const prefixes = completionPrefixes(registry); + const prefixKeys = prefixes.map((prefix) => prefix.join(' ')).filter(Boolean); + const globalOptions = globalCompletionOptions(); + const globalTokens = globalOptions.map(({ token }) => token); + const globalValueOptions = globalOptions + .filter(({ requiresValue }) => requiresValue) + .map(({ token }) => token); + const commandOptions = new Map( + registry + .list() + .map( + (command) => + [command.path.join(' '), commandCompletionOptions(command)] as const + ) + ); + const localValueCases = [...commandOptions.entries()].flatMap( + ([path, options]) => + options + .filter(({ requiresValue }) => requiresValue) + .map(({ token }) => `${path}|${token}`) + ); + const childCases = prefixes + .map((prefix) => { + const candidates = commandChildren(registry, prefix); + return ` ${quoteShellArgument(prefix.join(' '))}) children=${quoteShellArgument(candidates.join(' '))} ;;`; + }) + .join('\n'); + const optionCases = [...commandOptions.entries()] + .map( + ([path, options]) => + ` ${quoteShellArgument(path)}) command_options=${quoteShellArgument(options.map(({ token }) => token).join(' '))} ;;` + ) + .join('\n'); + const functionName = `_${toolName.replace(/-/g, '_')}_completion`; + const globalFlagPatterns = globalOptions + .filter(({ requiresValue }) => !requiresValue) + .map(({ token }) => token); + const globalInlinePatterns = globalValueOptions.map((token) => `${token}=*`); + const validPrefixCase = + prefixKeys.length === 0 + ? ' *) ;;' + : ` ${casePatterns(prefixKeys)}) path="$candidate" ;;\n *) ;;`; + const localValueCase = + localValueCases.length === 0 + ? ' *) ;;' + : ` ${casePatterns(localValueCases)}) expect_value=1; value_option="$word"; continue ;;\n *) ;;`; + const globalFlagCase = + [...globalFlagPatterns, ...globalInlinePatterns].length === 0 + ? '' + : ` ${casePatterns([...globalFlagPatterns, ...globalInlinePatterns])}) continue ;;\n`; + + const body = `${functionName}() { + local cur path word candidate children command_options candidates value_option + local expect_value=0 options_ended=0 index + cur="${'${COMP_WORDS[COMP_CWORD]}'}" + path="" + value_option="" + + for ((index=1; index prefix.join(' ')).filter(Boolean); + const globalOptions = globalCompletionOptions(); + const globalValueOptions = globalOptions + .filter(({ requiresValue }) => requiresValue) + .map(({ token }) => token); + const globalFlagPatterns = globalOptions + .filter(({ requiresValue }) => !requiresValue) + .map(({ token }) => token); + const globalInlinePatterns = globalValueOptions.map((token) => `${token}=*`); + const commandOptions = new Map( + registry + .list() + .map( + (command) => + [command.path.join(' '), commandCompletionOptions(command)] as const + ) + ); + const localValueCases = [...commandOptions.entries()].flatMap( + ([path, options]) => + options + .filter(({ requiresValue }) => requiresValue) + .map(({ token }) => `${path}|${token}`) + ); + const validPrefixCase = + prefixKeys.length === 0 + ? '' + : ` case ${fishCasePatterns(prefixKeys)}\n set path $path $word\n`; + const localValueCase = + localValueCases.length === 0 + ? '' + : ` case ${fishCasePatterns(localValueCases)}\n set expect_value 1\n continue\n`; + const globalFlagCase = + [...globalFlagPatterns, ...globalInlinePatterns].length === 0 + ? '' + : ` case ${fishCasePatterns([...globalFlagPatterns, ...globalInlinePatterns])}\n continue\n`; + const lines = [ + `function ${pathFunction}`, + ' set -l words (commandline -opc)', + ' set -e words[1]', + ' set -l path', + ' set -l expect_value 0', + ' set -l options_ended 0', + ' for word in $words', + ' if test $expect_value -eq 1', + ' set expect_value 0', + ' continue', + ' end', + ' if test $options_ended -eq 1', + ' continue', + ' end', + ' if test "$word" = --', + ' set options_ended 1', + ' continue', + ' end', + ' switch $word', + ` case ${fishCasePatterns(globalValueOptions)}`, + ' set expect_value 1', + ' continue', + globalFlagCase.trimEnd(), + ' end', + ' string match -q "*=*" -- $word; and continue', + ' switch (string join " " $path)"|"(string replace -r "=.*$" "" -- $word)', + localValueCase.trimEnd(), + ' end', + ' string match -qr "^-" -- $word; and continue', + ' set -l candidate (string join " " $path $word)', + ' switch $candidate', + validPrefixCase.trimEnd(), + ' end', + ' end', + ' if test $options_ended -eq 1', + ' echo __options_ended__', + ' else', + ' string join " " $path', + ' end', + 'end', + '', + `function ${atPathFunction} --argument-names expected`, + ` test "(${pathFunction})" = "$expected"`, + 'end', + '', + `function ${optionsActiveFunction}`, + ` test "(${pathFunction})" != __options_ended__`, + 'end', + '', + `complete -c ${quoteShellArgument(toolName)} -f`, + ].filter((line, index, all) => line !== '' || all[index - 1] !== ''); + + for (const definition of GLOBAL_OPTION_DEFINITIONS) { + const parameters = [ + `complete -c ${quoteShellArgument(toolName)}`, + `-n ${quoteShellArgument(optionsActiveFunction)}`, + `-l ${quoteShellArgument(definition.long)}`, + ...(definition.short === undefined + ? [] + : [`-s ${quoteShellArgument(definition.short)}`]), + ...(definition.valueName === undefined ? [] : ['-r']), + ...(definition.values === undefined + ? [] + : [`-a ${quoteShellArgument(definition.values.join(' '))}`]), + ]; + lines.push(parameters.join(' ')); + } + + for (const prefix of prefixes) { + const path = prefix.join(' '); + const condition = `${atPathFunction} "${path}"`; + for (const candidate of commandChildren(registry, prefix)) { + lines.push( + `complete -c ${quoteShellArgument(toolName)} -n ${quoteShellArgument(condition)} -a ${quoteShellArgument(candidate)}` + ); + } + } + for (const [path, options] of commandOptions) { + const condition = `${atPathFunction} "${path}"`; + for (const option of options) { + const spelling = option.token.startsWith('--') + ? `-l ${quoteShellArgument(option.token.slice(2))}` + : `-s ${quoteShellArgument(option.token.slice(1))}`; + lines.push( + [ + `complete -c ${quoteShellArgument(toolName)}`, + `-n ${quoteShellArgument(condition)}`, + spelling, + ...(option.requiresValue ? ['-r'] : []), + ].join(' ') + ); + } + } + return `${lines.join('\n')}\n`; +} + +export function generateCompletion( + registry: CommandRegistry, + shell: CompletionShell, + toolName = 'cnc' +): string { + validateToolName(toolName); + return shell === 'fish' + ? fishCompletion(registry, toolName) + : bashCompletion(registry, shell, toolName); +} diff --git a/packages/cli-runtime/src/docs.ts b/packages/cli-runtime/src/docs.ts new file mode 100644 index 0000000000..f7bf5ebedd --- /dev/null +++ b/packages/cli-runtime/src/docs.ts @@ -0,0 +1,365 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; + +import { renderHelp } from './discovery'; +import { CommandRegistry } from './registry'; + +const MANIFEST_NAME = '.constructive-cli-docs.json'; +const MANIFEST_FORMAT = 1; + +export interface GenerateDocumentationOptions { + toolName?: string; + toolVersion: string; + skillName?: string; +} + +export interface GeneratedDocumentation { + files: Readonly>; + generator: { + package: '@constructive-io/cli-runtime'; + tool: string; + toolVersion: string; + protocolVersion: 'constructive.dev/cli/v1'; + }; +} + +interface OwnershipManifest { + format: number; + generator: GeneratedDocumentation['generator']; + files: Record; +} + +export type DocumentationPlanAction = + | 'create' + | 'update' + | 'delete' + | 'unchanged' + | 'conflict'; + +export interface DocumentationPlanEntry { + path: string; + action: DocumentationPlanAction; + previousHash?: string; + desiredHash?: string; +} + +export interface DocumentationExportPlan { + target: string; + fingerprint: string; + entries: DocumentationPlanEntry[]; + conflicts: string[]; + documentation: GeneratedDocumentation; +} + +export interface DocumentationExportResult { + plan: DocumentationExportPlan; + applied: boolean; +} + +function hash(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function safeRelativePath(value: string): string { + if ( + value.length === 0 || + value === MANIFEST_NAME || + isAbsolute(value) || + value.split(/[\\/]/).includes('..') + ) { + throw new Error(`Unsafe generated path "${value}".`); + } + return value.replace(/\\/g, '/'); +} + +function commandPage( + registry: CommandRegistry, + commandId: string, + toolName: string +): string { + const command = registry.requireById(commandId); + const schemaPath = `../schemas/${command.id}.json`; + const lines = [ + `# ${toolName} ${command.path.join(' ')}`, + '', + command.summary, + ...(command.description === undefined ? [] : ['', command.description]), + '', + '```text', + renderHelp(registry, command.path, toolName).trimEnd(), + '```', + '', + `Machine contract: [${command.id}](${schemaPath})`, + ]; + return `${lines.join('\n')}\n`; +} + +export function generateDocumentation( + registry: CommandRegistry, + options: GenerateDocumentationOptions +): GeneratedDocumentation { + const toolName = options.toolName ?? 'cnc'; + const skillName = options.skillName ?? 'constructive-cli'; + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(toolName)) + throw new Error(`Invalid documentation tool name "${toolName}".`); + if (!/^[a-z][a-z0-9-]*$/.test(skillName)) + throw new Error(`Invalid skill name "${skillName}".`); + if (!/^[0-9A-Za-z.+_-]+$/.test(options.toolVersion)) + throw new Error('Tool version contains unsupported characters.'); + const files: Record = {}; + const catalog = registry.catalog(); + files['catalog.json'] = json({ + protocolVersion: 'constructive.dev/cli/v1', + tool: toolName, + commands: catalog, + }); + files['README.md'] = + `# ${toolName} ${options.toolVersion}\n\n${renderHelp(registry, [], toolName)}`; + + for (const command of registry.list()) { + files[safeRelativePath(`schemas/${command.id}.json`)] = json( + registry.schema(command.id) + ); + files[safeRelativePath(`commands/${command.id}.md`)] = commandPage( + registry, + command.id, + toolName + ); + } + + const skillCommands = catalog + .map( + (command) => + `- \`${toolName} ${command.path.join(' ')}\` — ${command.summary}` + ) + .join('\n'); + files[safeRelativePath(`${skillName}/SKILL.md`)] = + `---\nname: ${skillName}\ndescription: "Version-matched ${toolName} command reference."\nmetadata:\n version: "${options.toolVersion}"\n---\n\n# ${toolName}\n\nUse \`${toolName} commands --format json\` for compact discovery and \`${toolName} schema --format json\` for exact contracts. In agent mode, pass explicit inputs and consume JSONL events.\n\n## Commands\n\n${skillCommands}\n`; + + return { + files, + generator: { + package: '@constructive-io/cli-runtime', + tool: toolName, + toolVersion: options.toolVersion, + protocolVersion: 'constructive.dev/cli/v1', + }, + }; +} + +async function readOptional(path: string): Promise { + try { + return await readFile(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +async function readManifest( + target: string +): Promise<{ manifest?: OwnershipManifest; exists: boolean }> { + const content = await readOptional(join(target, MANIFEST_NAME)); + if (content === undefined) return { exists: false }; + try { + const parsed = JSON.parse(content.toString('utf8')) as OwnershipManifest; + if ( + parsed.format !== MANIFEST_FORMAT || + parsed.generator?.package !== '@constructive-io/cli-runtime' || + parsed.generator?.protocolVersion !== 'constructive.dev/cli/v1' || + parsed.files === null || + typeof parsed.files !== 'object' || + Object.entries(parsed.files).some( + ([path, digest]) => + typeof digest !== 'string' || safeRelativePath(path) !== path + ) + ) { + return { exists: true }; + } + return { exists: true, manifest: parsed }; + } catch { + return { exists: true }; + } +} + +async function pathContainsSymlink( + target: string, + relativePath: string +): Promise { + const parts = [target, ...relativePath.split('/')]; + let current = parts[0]; + for (let index = 0; index < parts.length; index += 1) { + if (index > 0) current = join(current, parts[index]); + try { + if ((await lstat(current)).isSymbolicLink()) return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + } + return false; +} + +export async function planDocumentationExport( + targetPath: string, + documentation: GeneratedDocumentation +): Promise { + const target = resolve(targetPath); + const manifestRead = await readManifest(target); + const previous = manifestRead.manifest; + const manifestOwnedByThisTool = + previous === undefined || + previous.generator.tool === documentation.generator.tool; + const desiredHashes = Object.fromEntries( + Object.entries(documentation.files).map(([path, content]) => [ + safeRelativePath(path), + hash(content), + ]) + ); + const allPaths = new Set([ + ...Object.keys(previous?.files ?? {}), + ...Object.keys(desiredHashes), + ]); + const entries: DocumentationPlanEntry[] = []; + + if ( + (manifestRead.exists && previous === undefined) || + !manifestOwnedByThisTool || + (await pathContainsSymlink(target, MANIFEST_NAME)) + ) { + entries.push({ path: MANIFEST_NAME, action: 'conflict' }); + } + + for (const relativePath of [...allPaths].sort()) { + safeRelativePath(relativePath); + if (await pathContainsSymlink(target, relativePath)) { + entries.push({ path: relativePath, action: 'conflict' }); + continue; + } + const actual = await readOptional(join(target, relativePath)); + const actualHash = actual === undefined ? undefined : hash(actual); + const previousHash = previous?.files[relativePath]; + const desiredHash = desiredHashes[relativePath]; + let action: DocumentationPlanAction; + + if (desiredHash === undefined) { + action = + actualHash === undefined + ? 'unchanged' + : previousHash === actualHash + ? 'delete' + : 'conflict'; + } else if (actualHash === undefined) { + action = 'create'; + } else if (previous === undefined) { + action = 'conflict'; + } else if (actualHash === desiredHash) { + action = 'unchanged'; + } else if (previousHash !== undefined && actualHash === previousHash) { + action = 'update'; + } else { + action = 'conflict'; + } + entries.push({ + path: relativePath, + action, + ...(actualHash === undefined ? {} : { previousHash: actualHash }), + ...(desiredHash === undefined ? {} : { desiredHash }), + }); + } + + const fingerprint = hash( + JSON.stringify({ + generator: documentation.generator, + files: Object.entries(desiredHashes).sort(([a], [b]) => + a.localeCompare(b) + ), + }) + ); + return { + target, + fingerprint, + entries, + conflicts: entries + .filter((entry) => entry.action === 'conflict') + .map((entry) => entry.path), + documentation, + }; +} + +async function atomicWrite(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = join(dirname(path), `.${randomUUID()}.tmp`); + try { + await writeFile(temporary, content, { encoding: 'utf8', mode: 0o644 }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +export async function applyDocumentationExport( + plan: DocumentationExportPlan +): Promise { + if (plan.conflicts.length > 0) + throw new Error( + `Documentation export has conflicts: ${plan.conflicts.join(', ')}` + ); + const fresh = await planDocumentationExport(plan.target, plan.documentation); + if ( + fresh.fingerprint !== plan.fingerprint || + JSON.stringify(fresh.entries) !== JSON.stringify(plan.entries) || + fresh.entries.some((entry) => entry.action === 'conflict') + ) { + throw new Error( + 'Documentation changed after the export was planned; create a new plan.' + ); + } + + if (fresh.entries.every((entry) => entry.action === 'unchanged')) return; + + for (const entry of fresh.entries) { + const destination = join(fresh.target, entry.path); + if (entry.action === 'create' || entry.action === 'update') { + await atomicWrite(destination, fresh.documentation.files[entry.path]); + } else if (entry.action === 'delete') { + await rm(destination); + } + } + + const manifest: OwnershipManifest = { + format: MANIFEST_FORMAT, + generator: fresh.documentation.generator, + files: Object.fromEntries( + Object.entries(fresh.documentation.files).map(([path, content]) => [ + path, + hash(content), + ]) + ), + }; + await atomicWrite(join(fresh.target, MANIFEST_NAME), json(manifest)); +} + +export async function exportDocumentation( + target: string, + documentation: GeneratedDocumentation, + options: { dryRun?: boolean } = {} +): Promise { + const plan = await planDocumentationExport(target, documentation); + if (options.dryRun || plan.conflicts.length > 0) + return { plan, applied: false }; + await applyDocumentationExport(plan); + return { plan, applied: true }; +} diff --git a/packages/cli-runtime/src/errors.ts b/packages/cli-runtime/src/errors.ts new file mode 100644 index 0000000000..5dc7e50d44 --- /dev/null +++ b/packages/cli-runtime/src/errors.ts @@ -0,0 +1,117 @@ +import { ErrorCategory, NextAction, StructuredError } from './contracts'; + +export interface CliErrorOptions { + code: string; + category: ErrorCategory; + message: string; + path?: string; + details?: unknown; + retryable?: boolean; + nextActions?: NextAction[]; + cause?: unknown; +} + +/** A safe, expected operation error which may cross the CLI protocol boundary. */ +export class CliError extends Error { + readonly code: string; + readonly category: ErrorCategory; + readonly path?: string; + readonly details?: unknown; + readonly retryable: boolean; + readonly nextActions?: NextAction[]; + override readonly cause?: unknown; + + constructor(options: CliErrorOptions) { + super(options.message); + this.name = 'CliError'; + this.code = options.code; + this.category = options.category; + this.path = options.path; + this.details = options.details; + this.retryable = options.retryable ?? false; + this.nextActions = options.nextActions; + this.cause = options.cause; + } + + toStructuredError(): StructuredError { + return { + code: this.code, + category: this.category, + message: this.message, + ...(this.path === undefined ? {} : { path: this.path }), + ...(this.details === undefined ? {} : { details: this.details }), + retryable: this.retryable, + ...(this.nextActions === undefined + ? {} + : { nextActions: this.nextActions }), + }; + } +} + +export class InvocationError extends CliError { + constructor( + code: string, + message: string, + options: Omit = {} + ) { + super({ code, category: 'invocation', message, ...options }); + this.name = 'InvocationError'; + } +} + +export class ContractError extends Error { + readonly code: string; + readonly details?: unknown; + + constructor(code: string, message: string, details?: unknown) { + super(message); + this.name = 'ContractError'; + this.code = code; + this.details = details; + } +} + +export function isCancellationError( + error: unknown, + signal?: AbortSignal +): boolean { + if (signal?.aborted) return true; + if ( + error === null || + (typeof error !== 'object' && typeof error !== 'function') + ) + return false; + const name = (error as { name?: unknown }).name; + return ( + name === 'AbortError' || + name === 'CanceledError' || + name === 'CancelledError' + ); +} + +export function normalizeKnownError(error: CliError): StructuredError { + return error.toStructuredError(); +} + +export function internalError(details?: unknown): StructuredError { + return { + code: 'CLI_INTERNAL_ERROR', + category: 'internal', + message: 'The command failed because of an internal error.', + ...(details === undefined ? {} : { details }), + retryable: false, + }; +} + +export function cancelledError(reason?: unknown): StructuredError { + const message = + typeof reason === 'string' && reason.length > 0 + ? reason + : 'The operation was cancelled.'; + return { + code: 'OPERATION_CANCELLED', + category: 'operation', + message, + retryable: true, + }; +} diff --git a/packages/cli-runtime/src/execution.ts b/packages/cli-runtime/src/execution.ts new file mode 100644 index 0000000000..2c4690b8ba --- /dev/null +++ b/packages/cli-runtime/src/execution.ts @@ -0,0 +1,1010 @@ +import { randomUUID } from 'node:crypto'; +import { isAbsolute } from 'node:path'; +import { Type } from '@sinclair/typebox'; + +import { + ApprovedCapabilities, + CommandDefinition, + ExecutionMode, + ExecutionOutcome, + ExecutionOutcomeSchema, + NextAction, + OperationContext, + OperationResult, + OperationWarning, + PROTOCOL_VERSION, + ProtocolEvent, + ProtocolEventSchema, + ProtocolEventSink, + StructuredError, + StructuredErrorSchema, + TerminalProtocolEvent, + WarningSchema, +} from './contracts'; +import { + cancelledError, + CliError, + ContractError, + internalError, + InvocationError, + isCancellationError, + normalizeKnownError, +} from './errors'; +import { + createRedactor, + isSensitiveKey, + RedactionOptions, + sensitiveEnvironmentValues, +} from './redaction'; +import { assertOperationResultMetadata, CommandRegistry } from './registry'; +import { assertJsonValue, compileSchema } from './schema'; + +const RESERVED_EVENTS = new Set([ + 'operation.started', + 'operation.completed', + 'operation.failed', + 'operation.cancelled', +]); + +const RESERVED_DOMAIN_FIELDS = new Set([ + 'protocolVersion', + 'operationId', + 'commandId', + 'timestamp', + 'durationMs', + 'result', + 'error', + 'warnings', +]); + +const protocolEventValidator = + compileSchema(ProtocolEventSchema); +const structuredErrorValidator = compileSchema( + StructuredErrorSchema +); +const warningsValidator = compileSchema( + Type.Array(WarningSchema) +); +const outcomeValidator = compileSchema( + ExecutionOutcomeSchema +); + +interface SafeClock { + read(): Date; + failure(): ContractError | undefined; +} + +function createSafeClock(clock: (() => Date) | undefined): SafeClock { + let clockFailure: ContractError | undefined; + return { + read(): Date { + try { + const value = (clock ?? (() => new Date()))(); + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) { + throw new Error('invalid clock value'); + } + return value; + } catch { + clockFailure ??= new ContractError( + 'CLI_CLOCK_CONTRACT_VIOLATION', + 'The operation clock did not return a valid Date.' + ); + return new Date(); + } + }, + failure: () => clockFailure, + }; +} + +function resolveOperationId(value: unknown): { + operationId: string; + failure?: InvocationError; +} { + if (value === undefined) return { operationId: randomUUID() }; + if (typeof value === 'string' && value.length > 0) { + return { operationId: value }; + } + return { + operationId: randomUUID(), + failure: new InvocationError( + 'CLI_OPERATION_ID_INVALID', + 'The operation ID must be a non-empty string.' + ), + }; +} + +class ProtocolSinkError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super('The protocol event sink failed.'); + this.name = 'ProtocolSinkError'; + this.cause = cause; + } +} + +export interface CreateOperationContextOptions { + cwd: string; + mode: ExecutionMode; + env: Readonly>; + signal: AbortSignal; + operationId?: string; + now?: () => Date; + events?: { emit(event: TEvent): Promise }; + capabilities?: Partial; + registerSensitiveValue?: (value: string) => void; +} + +export function createOperationContext( + options: CreateOperationContextOptions +): OperationContext { + if (!isAbsolute(options.cwd)) { + throw new ContractError( + 'CLI_CWD_NOT_ABSOLUTE', + 'OperationContext.cwd must be an absolute path.', + { + cwd: options.cwd, + } + ); + } + const env = Object.freeze({ ...options.env }); + const capabilities: ApprovedCapabilities = Object.freeze({ + yes: options.capabilities?.yes ?? false, + ...(options.capabilities?.dryRun === undefined + ? {} + : { dryRun: options.capabilities.dryRun }), + ...(options.capabilities?.idempotencyKey === undefined + ? {} + : { idempotencyKey: options.capabilities.idempotencyKey }), + acknowledgedRisks: Object.freeze([ + ...(options.capabilities?.acknowledgedRisks ?? []), + ]), + }); + return Object.freeze({ + cwd: options.cwd, + mode: options.mode, + env, + signal: options.signal, + operationId: options.operationId ?? randomUUID(), + now: options.now ?? (() => new Date()), + events: options.events ?? { emit: async () => undefined }, + capabilities, + registerSensitiveValue(value: string): void { + if (typeof value !== 'string') { + throw new ContractError( + 'CLI_SENSITIVE_VALUE_INVALID', + 'Sensitive values registered by an operation must be strings.' + ); + } + if (value.length > 0) options.registerSensitiveValue?.(value); + }, + }); +} + +export interface ExecuteCommandOptions { + cwd: string; + mode: ExecutionMode; + env?: Readonly>; + signal?: AbortSignal; + operationId?: string; + now?: () => Date; + capabilities?: Partial; + sink?: ProtocolEventSink; + redaction?: RedactionOptions; + initialWarnings?: OperationResult['warnings']; + debug?: boolean; + /** Set false for streaming adapters to avoid retaining long-running domain event transcripts. */ + captureEvents?: boolean; +} + +export interface CreateFailureOutcomeOptions { + commandId: string; + error: unknown; + operationId?: string; + now?: () => Date; + sink?: ProtocolEventSink; + redaction?: RedactionOptions; + debug?: boolean; + cancelled?: boolean; + warnings?: OperationWarning[]; + captureEvents?: boolean; +} + +function debugDetails(error: unknown): unknown { + if (!(error instanceof Error)) return { thrown: error }; + return { name: error.name, message: error.message, stack: error.stack }; +} + +function createExecutionRedactor( + options: RedactionOptions | undefined, + env: Readonly> = {}, + additionalSensitiveValues: readonly string[] = [] +) { + const sensitiveValues = new Set([ + ...(options?.sensitiveValues ?? []), + ...sensitiveEnvironmentValues(env, options?.sensitiveKeys), + ...additionalSensitiveValues, + ]); + const redact = ((value: T): T => + createRedactor({ + replacement: options?.replacement, + sensitiveKeys: options?.sensitiveKeys, + sensitiveValues: [...sensitiveValues], + })(value)) as ReturnType; + return { + redact, + registerSensitiveValue(value: string): void { + if (value.length > 0) sensitiveValues.add(value); + }, + }; +} + +function commandSensitiveValues( + command: CommandDefinition, + input: unknown, + env: Readonly> +): string[] { + const values: string[] = []; + const seen = new WeakSet(); + const collectStrings = (value: unknown): void => { + if (typeof value === 'string') { + if (value.length > 0) values.push(value); + return; + } + if (typeof value === 'number' || typeof value === 'boolean') { + values.push(String(value)); + return; + } + if (Array.isArray(value)) { + if (seen.has(value)) return; + seen.add(value); + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor !== undefined && 'value' in descriptor) + collectStrings(descriptor.value); + } + return; + } + if (value !== null && typeof value === 'object') { + if (seen.has(value)) return; + seen.add(value); + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor !== undefined && 'value' in descriptor) + collectStrings(descriptor.value); + } + } + }; + + const inputRecord = + input !== null && typeof input === 'object' + ? (input as Record) + : undefined; + const inputProperties = ( + command.input as { + properties?: Record; + } + ).properties; + for (const binding of command.bindings) { + const sensitiveInput = + isSensitiveKey(binding.property) || + inputProperties?.[binding.property]?.writeOnly === true || + binding.sources.some( + (source) => source.kind === 'option' && source.sensitive === true + ); + if (sensitiveInput && inputRecord !== undefined) { + const descriptor = Object.getOwnPropertyDescriptor( + inputRecord, + binding.property + ); + if (descriptor !== undefined && 'value' in descriptor) + collectStrings(descriptor.value); + } + for (const source of binding.sources) { + if (source.kind === 'environment' && source.sensitive === true) { + const value = env[source.name]; + if (value !== undefined && value.length > 0) values.push(value); + } + } + } + return [...new Set(values)]; +} + +function assertProtocolEvent(value: unknown): asserts value is ProtocolEvent { + assertJsonValue(value); + if (!protocolEventValidator.validate(value)) { + throw new ContractError( + 'CLI_PROTOCOL_CONTRACT_VIOLATION', + 'A protocol event did not match its wire schema.', + { + issues: protocolEventValidator.issues(), + } + ); + } +} + +function assertStructuredError( + value: unknown +): asserts value is StructuredError { + assertJsonValue(value); + if (!structuredErrorValidator.validate(value)) { + throw new ContractError( + 'CLI_ERROR_CONTRACT_VIOLATION', + 'A structured error did not match its wire schema.', + { + issues: structuredErrorValidator.issues(), + } + ); + } +} + +function createPublisher( + redact: ReturnType, + sink: ProtocolEventSink | undefined, + captureEvents: boolean +) { + const transcript: ProtocolEvent[] = []; + let sinkEnabled = sink !== undefined; + + const prepare = (event: ProtocolEvent): ProtocolEvent => { + const safe = redact(event); + assertProtocolEvent(safe); + return safe; + }; + + const retain = (event: ProtocolEvent): void => { + if (captureEvents) transcript.push(event); + }; + + const publish = async ( + event: ProtocolEvent, + terminal = false + ): Promise => { + const safe = prepare(event); + if (!terminal) retain(safe); + if (sinkEnabled) { + try { + await sink!(safe); + } catch (error) { + sinkEnabled = false; + throw new ProtocolSinkError(error); + } + } + if (terminal) retain(safe); + return safe; + }; + + return { transcript, prepare, publish, retain }; +} + +function safeWarnings( + warnings: OperationWarning[] | undefined, + redact: ReturnType +): OperationWarning[] { + if (warnings === undefined || warnings.length === 0) return []; + assertJsonValue(warnings); + if (!warningsValidator.validate(warnings)) { + throw new ContractError( + 'CLI_WARNING_CONTRACT_VIOLATION', + 'Operation warnings did not match their wire schema.', + { + issues: warningsValidator.issues(), + } + ); + } + const safe = redact(warnings); + assertJsonValue(safe); + if (!warningsValidator.validate(safe)) { + throw new ContractError( + 'CLI_WARNING_CONTRACT_VIOLATION', + 'Redacted operation warnings did not match their wire schema.', + { issues: warningsValidator.issues() } + ); + } + return safe; +} + +function safeStructuredError( + error: StructuredError, + redact: ReturnType +): StructuredError { + const safe = redact(error); + assertStructuredError(safe); + return safe; +} + +function sinkFailureError( + error: ProtocolSinkError, + debug: boolean | undefined +): StructuredError { + return { + code: 'CLI_PROTOCOL_SINK_FAILED', + category: 'internal', + message: 'The command could not deliver a protocol event.', + ...(debug ? { details: debugDetails(error.cause) } : {}), + retryable: false, + }; +} + +function normalizeError( + caught: unknown, + signal: AbortSignal | undefined, + debug: boolean | undefined, + redact: ReturnType +): { status: 'failed' | 'cancelled'; error: StructuredError } { + const cancelled = isCancellationError(caught, signal); + const candidate = cancelled + ? cancelledError(signal?.reason) + : caught instanceof ProtocolSinkError + ? sinkFailureError(caught, debug) + : caught instanceof CliError + ? normalizeKnownError(caught) + : internalError(debug ? debugDetails(caught) : undefined); + try { + return { + status: cancelled ? 'cancelled' : 'failed', + error: safeStructuredError(candidate, redact), + }; + } catch (contractFailure) { + return { + status: 'failed', + error: safeStructuredError( + internalError(debug ? debugDetails(contractFailure) : undefined), + redact + ), + }; + } +} + +function assertOutcome(outcome: ExecutionOutcome): void { + assertJsonValue(outcome); + if (!outcomeValidator.validate(outcome)) { + throw new ContractError( + 'CLI_OUTCOME_CONTRACT_VIOLATION', + 'Execution outcome did not match its public schema.', + { + issues: outcomeValidator.issues(), + } + ); + } +} + +function assertCapabilities( + command: CommandDefinition, + approved: Partial | undefined +): void { + const declared = command.capabilities ?? {}; + if (approved?.dryRun !== undefined && declared.dryRun !== true) { + throw new CliError({ + code: 'CLI_CAPABILITY_UNSUPPORTED', + category: 'invocation', + message: `Command "${command.path.join(' ')}" does not support dry runs.`, + }); + } + if ( + approved?.idempotencyKey !== undefined && + declared.idempotencyKey !== true + ) { + throw new CliError({ + code: 'CLI_CAPABILITY_UNSUPPORTED', + category: 'invocation', + message: `Command "${command.path.join(' ')}" does not support idempotency keys.`, + }); + } + if (command.effect === 'destructive' && approved?.yes !== true) { + throw new CliError({ + code: 'CLI_CONFIRMATION_REQUIRED', + category: 'invocation', + message: `Destructive command "${command.path.join(' ')}" requires explicit confirmation.`, + }); + } + const allowedRisks = new Set(declared.destructiveAcknowledgements ?? []); + const unsupportedRisks = (approved?.acknowledgedRisks ?? []).filter( + (risk) => !allowedRisks.has(risk) + ); + if (unsupportedRisks.length > 0) { + throw new CliError({ + code: 'CLI_ACKNOWLEDGEMENT_UNSUPPORTED', + category: 'invocation', + message: `Command "${command.path.join(' ')}" does not declare one or more risk acknowledgements.`, + details: { unsupportedRisks }, + }); + } +} + +function assertRegisteredNextActions( + registry: CommandRegistry, + actions: readonly NextAction[] | undefined, + source: string +): void { + for (const [index, action] of (actions ?? []).entries()) { + if (registry.getById(action.commandId) === undefined) { + throw new ContractError( + 'CLI_NEXT_ACTION_COMMAND_UNKNOWN', + `${source} references an unregistered next-action command.`, + { index, commandId: action.commandId } + ); + } + const issues = registry.validateInput(action.commandId, action.input); + if (issues.length > 0) { + throw new ContractError( + 'CLI_NEXT_ACTION_INPUT_INVALID', + `${source} contains input which does not satisfy its next-action command.`, + { index, commandId: action.commandId, issues } + ); + } + } +} + +async function finalizeTerminal( + publisher: ReturnType, + terminal: TerminalProtocolEvent, + fallback: (error: unknown) => TerminalProtocolEvent +): Promise { + try { + return (await publisher.publish(terminal, true)) as TerminalProtocolEvent; + } catch (error) { + const fallbackTerminal = fallback(error); + try { + return (await publisher.publish( + fallbackTerminal, + true + )) as TerminalProtocolEvent; + } catch (fallbackError) { + // A failed sink is disabled by publish. If validation itself unexpectedly + // fails here, prepare throws and exposing no outcome is safer than emitting + // an envelope which violates the public protocol. + const safe = publisher.prepare( + fallback(fallbackError) + ) as TerminalProtocolEvent; + publisher.retain(safe); + return safe; + } + } +} + +/** Converts adapter/parser failures into the same versioned terminal protocol. */ +export async function createFailureOutcome( + options: CreateFailureOutcomeOptions +): Promise { + const identity = resolveOperationId(options.operationId); + const operationId = identity.operationId; + const clock = createSafeClock(options.now); + const started = clock.read(); + const executionRedaction = createExecutionRedactor(options.redaction); + const { redact } = executionRedaction; + const publisher = createPublisher( + redact, + options.sink, + options.captureEvents ?? true + ); + const protocolFailure = clock.failure() ?? identity.failure; + let normalized = normalizeError( + protocolFailure ?? + (options.cancelled === true + ? new DOMException('The operation was cancelled.', 'AbortError') + : options.error), + protocolFailure === undefined && options.cancelled === true + ? AbortSignal.abort(options.error) + : undefined, + options.debug, + redact + ); + let warnings: OperationWarning[] = []; + + try { + await publisher.publish({ + protocolVersion: PROTOCOL_VERSION, + event: 'operation.started', + operationId, + commandId: options.commandId, + timestamp: started.toISOString(), + }); + warnings = safeWarnings(options.warnings, redact); + if (clock.failure() !== undefined) { + normalized = normalizeError( + clock.failure(), + undefined, + options.debug, + redact + ); + warnings = []; + } + } catch (error) { + normalized = normalizeError(error, undefined, options.debug, redact); + warnings = []; + } + + const completed = clock.read(); + if (clock.failure() !== undefined) { + normalized = normalizeError( + clock.failure(), + undefined, + options.debug, + redact + ); + warnings = []; + } + const durationMs = Math.max(0, completed.getTime() - started.getTime()); + const makeTerminal = ( + status: 'failed' | 'cancelled', + error: StructuredError + ): TerminalProtocolEvent => ({ + protocolVersion: PROTOCOL_VERSION, + event: status === 'cancelled' ? 'operation.cancelled' : 'operation.failed', + operationId, + commandId: options.commandId, + timestamp: completed.toISOString(), + durationMs, + error, + ...(warnings.length === 0 ? {} : { warnings }), + }); + let terminalStatus = normalized.status; + let terminalError = normalized.error; + const terminal = await finalizeTerminal( + publisher, + makeTerminal(terminalStatus, terminalError), + (error) => { + const fallback = normalizeError(error, undefined, options.debug, redact); + terminalStatus = 'failed'; + terminalError = fallback.error; + return makeTerminal('failed', fallback.error); + } + ); + + const outcome: ExecutionOutcome = { + status: terminalStatus, + commandId: options.commandId, + operationId, + startedAt: started.toISOString(), + completedAt: completed.toISOString(), + durationMs, + error: terminalError, + ...(warnings.length === 0 ? {} : { warnings }), + terminalEvent: terminal, + protocolEvents: publisher.transcript, + }; + assertOutcome(outcome); + return outcome; +} + +export async function executeCommand( + registry: CommandRegistry, + commandOrId: CommandDefinition | string, + input: unknown, + options: ExecuteCommandOptions +): Promise { + const command = registry.requireById( + typeof commandOrId === 'string' ? commandOrId : commandOrId.id + ); + const signal = options.signal ?? new AbortController().signal; + const identity = resolveOperationId(options.operationId); + const operationId = identity.operationId; + const clock = createSafeClock(options.now); + const now = clock.read; + const started = now(); + const env = options.env ?? {}; + const executionRedaction = createExecutionRedactor( + options.redaction, + env, + commandSensitiveValues(command, input, env) + ); + const { redact } = executionRedaction; + const publisher = createPublisher( + redact, + options.sink, + options.captureEvents ?? true + ); + let eventReporterSealed = false; + let eventTail: Promise = Promise.resolve(); + let eventFailure: unknown; + let hasEventFailure = false; + + const enqueueDomainEvent = (domainEvent: unknown): Promise => { + if (eventReporterSealed) { + const rejected: Promise = Promise.reject( + new ContractError( + 'CLI_EVENT_REPORTER_CLOSED', + `Command "${command.id}" emitted an event after its operation was sealed.` + ) + ); + // Detached work may ignore this promise. Attaching a handler prevents an + // unhandled rejection while callers which await it still observe failure. + void rejected.catch((): void => undefined); + return rejected; + } + + const queued = eventTail.then(async () => { + if (hasEventFailure) throw eventFailure; + assertJsonValue(domainEvent); + const issues = registry.validateEvent(command.id, domainEvent); + if (issues.length > 0) { + throw new ContractError( + 'CLI_EVENT_CONTRACT_VIOLATION', + `Command "${command.id}" emitted an invalid event.`, + { issues } + ); + } + const eventName = (domainEvent as { event?: unknown }).event; + if (typeof eventName !== 'string' || RESERVED_EVENTS.has(eventName)) { + throw new ContractError( + 'CLI_EVENT_NAME_RESERVED', + `Command "${command.id}" emitted a reserved or invalid event name.` + ); + } + const event = domainEvent as Record; + const reserved = Object.keys(event).filter( + (key) => key !== 'event' && RESERVED_DOMAIN_FIELDS.has(key) + ); + if (reserved.length > 0) { + throw new ContractError( + 'CLI_EVENT_FIELD_RESERVED', + `Command "${command.id}" emitted reserved protocol fields.`, + { fields: reserved } + ); + } + const safeEvent = redact(event); + assertJsonValue(safeEvent); + const redactedIssues = registry.validateEvent(command.id, safeEvent); + if (redactedIssues.length > 0) { + throw new ContractError( + 'CLI_EVENT_REDACTION_CONTRACT_VIOLATION', + `Redaction made an event from "${command.id}" invalid.`, + { issues: redactedIssues } + ); + } + await publisher.publish({ + ...safeEvent, + protocolVersion: PROTOCOL_VERSION, + event: eventName, + operationId, + commandId: command.id, + timestamp: now().toISOString(), + }); + }); + + // The tail itself always settles so calls retain invocation order. The + // first failure is drained into the operation even if emit was not awaited. + eventTail = queued.then( + (): void => undefined, + (failure) => { + if (!hasEventFailure) { + hasEventFailure = true; + eventFailure = failure; + } + } + ); + return queued; + }; + + let result: OperationResult | undefined; + let error: StructuredError | undefined; + let status: ExecutionOutcome['status'] = 'failed'; + let warnings: OperationWarning[] = []; + + try { + await publisher.publish({ + protocolVersion: PROTOCOL_VERSION, + event: 'operation.started', + operationId, + commandId: command.id, + timestamp: started.toISOString(), + }); + warnings = safeWarnings(options.initialWarnings, redact); + if (identity.failure !== undefined) throw identity.failure; + if (clock.failure() !== undefined) throw clock.failure(); + + const inputIssues = registry.validateInput(command.id, input); + if (inputIssues.length > 0) { + throw new CliError({ + code: 'CLI_INPUT_INVALID', + category: 'validation', + message: `Input for "${command.path.join(' ')}" is invalid.`, + path: inputIssues[0]?.path, + details: { issues: inputIssues }, + }); + } + assertJsonValue(input); + assertCapabilities(command, options.capabilities); + const context = createOperationContext({ + cwd: options.cwd, + mode: options.mode, + env, + signal, + operationId, + now, + capabilities: options.capabilities, + registerSensitiveValue: executionRedaction.registerSensitiveValue, + events: { + emit: enqueueDomainEvent, + }, + }); + if (signal.aborted) + throw ( + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + let rawResult: OperationResult | undefined; + let commandFailure: unknown; + let commandFailed = false; + try { + rawResult = await command.execute(input as never, context as never); + } catch (caught) { + commandFailed = true; + commandFailure = caught; + } + eventReporterSealed = true; + await eventTail; + if (hasEventFailure) throw eventFailure; + if (clock.failure() !== undefined) throw clock.failure(); + if (commandFailed) throw commandFailure; + + assertOperationResultMetadata(rawResult!); + assertRegisteredNextActions( + registry, + rawResult!.nextActions, + `Result from "${command.id}"` + ); + warnings = [...warnings, ...safeWarnings(rawResult!.warnings, redact)]; + const rawResultWithWarnings: OperationResult = { + ...rawResult!, + ...(warnings.length === 0 ? { warnings: undefined } : { warnings }), + }; + if (rawResultWithWarnings.warnings === undefined) + delete rawResultWithWarnings.warnings; + const rawIssues = registry.validateResult( + command.id, + rawResultWithWarnings + ); + if (rawIssues.length > 0) { + throw new ContractError( + 'CLI_OUTPUT_CONTRACT_VIOLATION', + `Command "${command.id}" returned a result which does not match its output schema.`, + { issues: rawIssues } + ); + } + assertJsonValue(rawResultWithWarnings); + const safeResult = redact(rawResultWithWarnings); + assertJsonValue(safeResult); + assertRegisteredNextActions( + registry, + safeResult.nextActions, + `Redacted result from "${command.id}"` + ); + const safeIssues = registry.validateResult(command.id, safeResult); + if (safeIssues.length > 0) { + throw new ContractError( + 'CLI_OUTPUT_REDACTION_CONTRACT_VIOLATION', + `Redaction made the result from "${command.id}" invalid.`, + { issues: safeIssues } + ); + } + result = safeResult; + status = 'completed'; + } catch (caught) { + eventReporterSealed = true; + let errorToNormalize = clock.failure() ?? caught; + try { + if (caught instanceof CliError) { + assertRegisteredNextActions( + registry, + caught.nextActions, + `Error from "${command.id}"` + ); + } + } catch (contractFailure) { + errorToNormalize = contractFailure; + } + let normalized = normalizeError( + errorToNormalize, + signal, + options.debug, + redact + ); + try { + assertRegisteredNextActions( + registry, + normalized.error.nextActions, + `Redacted error from "${command.id}"` + ); + } catch (contractFailure) { + normalized = normalizeError( + contractFailure, + undefined, + options.debug, + redact + ); + } + status = normalized.status; + error = normalized.error; + } + + const completed = now(); + if (clock.failure() !== undefined) { + const normalized = normalizeError( + clock.failure(), + undefined, + options.debug, + redact + ); + status = normalized.status; + error = normalized.error; + result = undefined; + } + const durationMs = Math.max(0, completed.getTime() - started.getTime()); + const terminalFor = ( + terminalStatus: ExecutionOutcome['status'], + terminalError: StructuredError | undefined, + terminalResult: OperationResult | undefined + ): TerminalProtocolEvent => + terminalStatus === 'completed' + ? { + protocolVersion: PROTOCOL_VERSION, + event: 'operation.completed', + operationId, + commandId: command.id, + timestamp: completed.toISOString(), + durationMs, + result: terminalResult!, + } + : { + protocolVersion: PROTOCOL_VERSION, + event: + terminalStatus === 'cancelled' + ? 'operation.cancelled' + : 'operation.failed', + operationId, + commandId: command.id, + timestamp: completed.toISOString(), + durationMs, + error: terminalError!, + ...(warnings.length === 0 ? {} : { warnings }), + }; + + const terminal = await finalizeTerminal( + publisher, + terminalFor(status, error, result), + (terminalFailure) => { + const fallback = normalizeError( + terminalFailure, + undefined, + options.debug, + redact + ); + status = 'failed'; + error = fallback.error; + result = undefined; + return terminalFor(status, error, result); + } + ); + + const outcome: ExecutionOutcome = { + status, + commandId: command.id, + operationId, + startedAt: started.toISOString(), + completedAt: completed.toISOString(), + durationMs, + ...(result === undefined ? {} : { result }), + ...(error === undefined ? {} : { error }), + ...(status === 'completed' || warnings.length === 0 ? {} : { warnings }), + terminalEvent: terminal, + protocolEvents: publisher.transcript, + }; + assertOutcome(outcome); + return outcome; +} + +export function exitCodeForOutcome( + outcome: ExecutionOutcome +): 0 | 1 | 2 | 70 | 130 { + if (outcome.status === 'completed') return 0; + if (outcome.status === 'cancelled') return 130; + if ( + outcome.error?.category === 'invocation' || + outcome.error?.category === 'validation' + ) + return 2; + if (outcome.error?.category === 'internal') return 70; + return 1; +} diff --git a/packages/cli-runtime/src/index.ts b/packages/cli-runtime/src/index.ts new file mode 100644 index 0000000000..97072817fb --- /dev/null +++ b/packages/cli-runtime/src/index.ts @@ -0,0 +1,14 @@ +export { Static, Type } from '@sinclair/typebox'; +export type { TSchema } from '@sinclair/typebox'; + +export * from './bindings'; +export * from './contracts'; +export * from './discovery'; +export * from './docs'; +export * from './errors'; +export * from './execution'; +export * from './redaction'; +export * from './registry'; +export * from './render'; +export * from './schema'; +export * from './settings'; diff --git a/packages/cli-runtime/src/redaction.ts b/packages/cli-runtime/src/redaction.ts new file mode 100644 index 0000000000..fb9ad4b8f1 --- /dev/null +++ b/packages/cli-runtime/src/redaction.ts @@ -0,0 +1,200 @@ +const DEFAULT_SENSITIVE_KEY = + /(?:^|[-_])(authorization|cookie|credentials?|password|passwd|secret|session|token|api[-_]?key|private[-_]?key|database[-_]?url|postgres(?:ql)?[-_]?url|pg[-_]?uri)(?:$|[-_])/i; + +// Environment variables are frequently written as delimiter-free uppercase +// names (for example PGPASSWORD). Match their compact form as well as the +// delimiter-aware names above so a secret cannot evade automatic redaction by +// changing only the spelling convention. +const COMPACT_SENSITIVE_KEY = + /(?:authorization|credentials?|password|passwd|secret|session|token|api(?:access)?key|privatekey|signingkey|servicerolekey|accesskey|connectionstring|webhookurl|pgpassfile|githubpat|jwt|dsn)$/i; + +export interface RedactionOptions { + replacement?: string; + sensitiveKeys?: readonly (string | RegExp)[]; + sensitiveValues?: readonly string[]; +} + +export type Redactor = (value: T) => T; + +export function isSensitiveKey( + key: string, + matchers: readonly (string | RegExp)[] = [] +): boolean { + const normalized = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2'); + if (DEFAULT_SENSITIVE_KEY.test(`-${normalized}-`)) return true; + const compact = normalized.replace(/[^a-z0-9]/gi, ''); + if (COMPACT_SENSITIVE_KEY.test(compact)) return true; + return matchers.some((matcher) => + typeof matcher === 'string' + ? matcher.toLowerCase() === key.toLowerCase() + : (() => { + matcher.lastIndex = 0; + return matcher.test(key); + })() + ); +} + +export function sensitiveEnvironmentValues( + env: Readonly>, + matchers: readonly (string | RegExp)[] = [] +): string[] { + return [ + ...new Set( + Object.entries(env) + .filter( + ([key, value]) => + value !== undefined && + value.length > 0 && + isSensitiveKey(key, matchers) + ) + .map(([, value]) => value!) + ), + ]; +} + +function redactString( + value: string, + secrets: readonly string[], + replacement: string +): string { + let redacted = value; + for (const secret of secrets) { + if (secret.length < 3) { + if (redacted === secret) return replacement; + continue; + } + redacted = redacted.split(secret).join(replacement); + } + return redacted; +} + +/** + * Creates a recursive, cycle-safe redactor. It redacts both sensitive fields and + * known secret values embedded in otherwise safe messages. + */ +export function createRedactor(options: RedactionOptions = {}): Redactor { + const replacement = options.replacement ?? '[REDACTED]'; + const matchers = options.sensitiveKeys ?? []; + const secrets = [ + ...new Set( + (options.sensitiveValues ?? []).filter((value) => value.length > 0) + ), + ].sort((left, right) => right.length - left.length); + + return (input: T): T => { + const ancestors = new WeakSet(); + + const visit = ( + value: unknown, + parentKey?: string, + inheritedSensitive = false + ): unknown => { + const sensitive = + inheritedSensitive || + (parentKey !== undefined && isSensitiveKey(parentKey, matchers)); + if (typeof value === 'string') { + return sensitive + ? replacement + : redactString(value, secrets, replacement); + } + // A sensitive field can be typed as a number, boolean, bigint, or symbol. + // Preserve no primitive value from that field: coercing it to a string + // first would still expose the secret, while returning it unchanged makes + // key-based redaction depend on the value's runtime type. If replacing the + // primitive violates the command's output/event schema, execution fails + // closed as a redaction contract violation instead of leaking the value. + if (sensitive && value !== null && typeof value !== 'object') { + return replacement; + } + // Sensitive inputs can be declared as numeric or boolean values and then + // echoed under an innocuous result key. Compare their canonical string + // representation with the registered secret set before returning them. + if ( + (typeof value === 'number' || typeof value === 'boolean') && + secrets.includes(String(value)) + ) { + return replacement; + } + if (value === null || typeof value !== 'object') return value; + + if (sensitive && (value instanceof Date || value instanceof Error)) { + return replacement; + } + + if (ancestors.has(value)) return '[Circular]'; + ancestors.add(value); + + if (value instanceof Date) { + ancestors.delete(value); + return value.toISOString(); + } + if (value instanceof Error) { + const output: Record = {}; + Object.defineProperties(output, { + name: { + value: value.name, + enumerable: true, + configurable: true, + writable: true, + }, + message: { + value: redactString(value.message, secrets, replacement), + enumerable: true, + configurable: true, + writable: true, + }, + }); + ancestors.delete(value); + return output; + } + if (Array.isArray(value)) { + // Preserve length, holes, and enumerable data properties. Sparse arrays are + // subsequently rejected by assertJsonValue instead of being silently + // compacted or serialized as nulls. + const output = new Array(value.length); + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + const item = + descriptor !== undefined && 'value' in descriptor + ? descriptor.value + : replacement; + Object.defineProperty(output, key, { + value: visit(item, key, sensitive), + enumerable: true, + configurable: true, + writable: true, + }); + } + ancestors.delete(value); + return output; + } + + const prototype = + Object.getPrototypeOf(value) === null ? null : Object.prototype; + const output = Object.create(prototype) as Record; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + const item = + descriptor !== undefined && 'value' in descriptor + ? descriptor.value + : replacement; + // defineProperty avoids the legacy __proto__ setter and cannot mutate the + // clone's prototype when an untrusted result contains a __proto__ key. + Object.defineProperty(output, key, { + value: visit(item, key, sensitive), + enumerable: true, + configurable: true, + writable: true, + }); + } + ancestors.delete(value); + return output; + }; + + return visit(input) as T; + }; +} + +export function redact(value: T, options?: RedactionOptions): T { + return createRedactor(options)(value); +} diff --git a/packages/cli-runtime/src/registry.ts b/packages/cli-runtime/src/registry.ts new file mode 100644 index 0000000000..501cf4435f --- /dev/null +++ b/packages/cli-runtime/src/registry.ts @@ -0,0 +1,920 @@ +import { Static, TSchema, Type } from '@sinclair/typebox'; +import { + CommandEffectSchema, + CommandDefinition, + CommandExampleSchema, + CommandLifecycleSchema, + createJsonValueSchema, + InputBinding, + InputBindingSchema, + OperationResult, + OperationResultSchema, + OperationWarning, + OptionBindingSource, + PROTOCOL_VERSION, + SafetyCapabilitiesSchema, + SafetyCapabilities, + operationResultSchema, +} from './contracts'; +import { bindArguments } from './bindings'; +import { ContractError, InvocationError } from './errors'; +import { + assertJsonValue, + cloneSchema, + compileSchema, + SchemaIssue, + SchemaValidator, +} from './schema'; +import { + GLOBAL_LONG_OPTION_NAMES, + GLOBAL_SHORT_OPTION_NAMES, + parseGlobalArguments, +} from './settings'; + +interface CompiledCommand { + input: SchemaValidator; + output: SchemaValidator; + result: SchemaValidator; + event?: SchemaValidator; +} + +export const CommandCatalogEntrySchema = Type.Object( + { + id: Type.String({ minLength: 1 }), + path: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), + summary: Type.String({ minLength: 1 }), + lifecycle: CommandLifecycleSchema, + effect: CommandEffectSchema, + capabilities: SafetyCapabilitiesSchema, + }, + { additionalProperties: false } +); + +export type CommandCatalogEntry = Static; + +/** Runtime schema for the registry's exported, versioned command contract. */ +export const CommandSchemaDocumentSchema = Type.Object( + { + protocolVersion: Type.Literal(PROTOCOL_VERSION), + id: Type.String({ minLength: 1 }), + path: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), + summary: Type.String({ minLength: 1 }), + description: Type.Optional(Type.String()), + lifecycle: CommandLifecycleSchema, + effect: CommandEffectSchema, + capabilities: SafetyCapabilitiesSchema, + input: Type.Record( + Type.String(), + createJsonValueSchema( + 'https://constructive.dev/cli/v1/schemas/command-schema/input-json-value' + ) + ), + output: Type.Record( + Type.String(), + createJsonValueSchema( + 'https://constructive.dev/cli/v1/schemas/command-schema/output-json-value' + ) + ), + events: Type.Optional( + Type.Record( + Type.String(), + createJsonValueSchema( + 'https://constructive.dev/cli/v1/schemas/command-schema/events-json-value' + ) + ) + ), + bindings: Type.Array(InputBindingSchema), + examples: Type.Array(CommandExampleSchema), + }, + { additionalProperties: false } +); + +export type CommandSchemaDocument = Static; + +const commandSchemaDocumentValidator = compileSchema( + CommandSchemaDocumentSchema +); + +function snapshotCapabilities( + capabilities: SafetyCapabilities | undefined +): Static { + if (capabilities === undefined) return {}; + return { + ...(capabilities.dryRun === undefined + ? {} + : { dryRun: capabilities.dryRun }), + ...(capabilities.idempotencyKey === undefined + ? {} + : { idempotencyKey: capabilities.idempotencyKey }), + ...(capabilities.confirmation === undefined + ? {} + : { confirmation: capabilities.confirmation }), + ...(capabilities.destructiveAcknowledgements === undefined + ? {} + : { + destructiveAcknowledgements: [ + ...capabilities.destructiveAcknowledgements, + ], + }), + }; +} + +function validateIdentifier(command: CommandDefinition): void { + if ( + typeof command.id !== 'string' || + !/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(command.id) + ) { + throw new ContractError( + 'CLI_COMMAND_ID_INVALID', + `Invalid command id "${command.id}".` + ); + } + if ( + !Array.isArray(command.path) || + command.path.length === 0 || + command.path.some( + (part) => + typeof part !== 'string' || + !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(part) + ) + ) { + throw new ContractError( + 'CLI_COMMAND_PATH_INVALID', + `Invalid command path for "${command.id}".`, + { + path: command.path, + } + ); + } + if ( + typeof command.summary !== 'string' || + command.summary.trim().length === 0 + ) { + throw new ContractError( + 'CLI_COMMAND_SUMMARY_REQUIRED', + `Command "${command.id}" needs a summary.` + ); + } + if ( + command.description !== undefined && + typeof command.description !== 'string' + ) { + throw new ContractError( + 'CLI_COMMAND_DESCRIPTION_INVALID', + `Description for "${command.id}" must be a string.` + ); + } + if (!['finite', 'long-running'].includes(command.lifecycle)) { + throw new ContractError( + 'CLI_COMMAND_LIFECYCLE_INVALID', + `Lifecycle for "${command.id}" is invalid.` + ); + } + if (!['read', 'write', 'destructive', 'service'].includes(command.effect)) { + throw new ContractError( + 'CLI_COMMAND_EFFECT_INVALID', + `Effect for "${command.id}" is invalid.` + ); + } + if (!Array.isArray(command.bindings) || !Array.isArray(command.examples)) { + throw new ContractError( + 'CLI_COMMAND_CONTRACT_INVALID', + `Bindings and examples for "${command.id}" must be arrays.` + ); + } + if (command.capabilities !== undefined) { + const capabilities = command.capabilities; + for (const key of ['dryRun', 'idempotencyKey', 'confirmation'] as const) { + if ( + capabilities[key] !== undefined && + typeof capabilities[key] !== 'boolean' + ) { + throw new ContractError( + 'CLI_COMMAND_CAPABILITY_INVALID', + `Capability "${key}" in "${command.id}" is invalid.` + ); + } + } + if ( + capabilities.destructiveAcknowledgements !== undefined && + (!Array.isArray(capabilities.destructiveAcknowledgements) || + capabilities.destructiveAcknowledgements.some( + (risk) => typeof risk !== 'string' || risk.length === 0 + ) || + new Set(capabilities.destructiveAcknowledgements).size !== + capabilities.destructiveAcknowledgements.length) + ) { + throw new ContractError( + 'CLI_COMMAND_CAPABILITY_INVALID', + `Destructive acknowledgements in "${command.id}" must be unique non-empty strings.` + ); + } + } + if ( + command.effect === 'destructive' && + command.capabilities?.confirmation !== true + ) { + throw new ContractError( + 'CLI_DESTRUCTIVE_CONFIRMATION_REQUIRED', + `Destructive command "${command.id}" must declare confirmation support.` + ); + } +} + +function deepFreeze(value: T, seen = new WeakSet()): T { + // Functions are retained as operation entry points but are not part of the + // serializable contract snapshot; freezing them would mutate caller-owned state. + if (typeof value !== 'object' || value === null) return value; + const object = value as object; + if (seen.has(object)) return value; + seen.add(object); + for (const key of Reflect.ownKeys(object)) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (descriptor !== undefined && 'value' in descriptor) + deepFreeze(descriptor.value, seen); + } + return Object.freeze(value); +} + +function jsonSnapshot(value: T, label: string): T { + try { + assertJsonValue(value); + return JSON.parse(JSON.stringify(value)) as T; + } catch (error) { + if (error instanceof ContractError) { + throw new ContractError( + 'CLI_COMMAND_CONTRACT_INVALID', + `${label} must be a JSON value.`, + { + cause: error.message, + } + ); + } + throw error; + } +} + +function snapshotCommand(command: CommandDefinition): CommandDefinition { + if (typeof command.execute !== 'function') { + throw new ContractError( + 'CLI_COMMAND_EXECUTE_REQUIRED', + `Command "${command.id}" needs an execute function.` + ); + } + const snapshot: CommandDefinition = { + id: command.id, + path: jsonSnapshot([...command.path], `Path for "${command.id}"`), + summary: command.summary, + ...(command.description === undefined + ? {} + : { description: command.description }), + input: cloneSchema(command.input), + output: cloneSchema(command.output), + ...(command.events === undefined + ? {} + : { events: cloneSchema(command.events) }), + bindings: jsonSnapshot(command.bindings, `Bindings for "${command.id}"`), + examples: jsonSnapshot(command.examples, `Examples for "${command.id}"`), + lifecycle: command.lifecycle, + effect: command.effect, + ...(command.capabilities === undefined + ? {} + : { + capabilities: jsonSnapshot( + command.capabilities, + `Capabilities for "${command.id}"` + ), + }), + execute: command.execute, + }; + return deepFreeze(snapshot); +} + +function validateEventSchema(command: CommandDefinition): void { + if (command.events === undefined) return; + const hasEventProperty = ( + schema: TSchema & { + type?: unknown; + properties?: Record; + anyOf?: TSchema[]; + oneOf?: TSchema[]; + } + ): boolean => { + if (schema.type === 'object' && schema.properties?.event !== undefined) + return true; + const branches = schema.anyOf ?? schema.oneOf; + return ( + branches !== undefined && + branches.length > 0 && + branches.every((branch) => hasEventProperty(branch)) + ); + }; + if (!hasEventProperty(command.events)) { + throw new ContractError( + 'CLI_EVENT_SCHEMA_INVALID', + `Event schema for "${command.id}" must describe an object with an event property.` + ); + } +} + +function validateBindings(command: CommandDefinition): void { + const input = command.input as TSchema & { + type?: unknown; + properties?: Record; + }; + if (input.type !== 'object' || input.properties === undefined) { + throw new ContractError( + 'CLI_INPUT_SCHEMA_INVALID', + `Input schema for "${command.id}" must be an object.` + ); + } + + const properties = new Set(); + const longOptionNames = new Map(); + const shortOptionNames = new Map(); + const positionalIndexes = new Map(); + let variadicIndex: number | undefined; + let variadicCount = 0; + + const registerLongOption = ( + name: string, + binding: InputBinding, + source: OptionBindingSource + ): void => { + const owner = longOptionNames.get(name); + if (owner !== undefined) { + throw new ContractError( + 'CLI_OPTION_DUPLICATE', + `Option "${name}" is bound to both "${owner}" and "${binding.property}" in "${command.id}".` + ); + } + if (GLOBAL_LONG_OPTION_NAMES.includes(name)) { + throw new ContractError( + 'CLI_OPTION_GLOBAL_COLLISION', + `Option "${name}" in "${command.id}" collides with a global option.` + ); + } + longOptionNames.set(name, binding.property); + + if (source.negatable) { + const negated = `no-${name}`; + const negatedOwner = longOptionNames.get(negated); + if (negatedOwner !== undefined) { + throw new ContractError( + 'CLI_OPTION_DUPLICATE', + `Generated negated option "${negated}" collides with "${negatedOwner}" in "${command.id}".` + ); + } + if (GLOBAL_LONG_OPTION_NAMES.includes(negated)) { + throw new ContractError( + 'CLI_OPTION_GLOBAL_COLLISION', + `Generated negated option "${negated}" in "${command.id}" collides with a global option.` + ); + } + longOptionNames.set(negated, binding.property); + } + }; + + for (const binding of command.bindings) { + if ( + binding === null || + typeof binding !== 'object' || + typeof binding.property !== 'string' + ) { + throw new ContractError( + 'CLI_BINDING_INVALID', + `Command "${command.id}" contains an invalid binding.` + ); + } + if (!Array.isArray(binding.sources)) { + throw new ContractError( + 'CLI_BINDING_SOURCES_INVALID', + `Binding "${binding.property}" in "${command.id}" must declare a sources array.` + ); + } + if ( + binding.valueType !== undefined && + !['string', 'number', 'boolean', 'json'].includes(binding.valueType) + ) { + throw new ContractError( + 'CLI_BINDING_VALUE_TYPE_INVALID', + `Binding "${binding.property}" in "${command.id}" has an invalid value type.` + ); + } + if ( + binding.repeated !== undefined && + typeof binding.repeated !== 'boolean' + ) { + throw new ContractError( + 'CLI_BINDING_INVALID', + `Binding "${binding.property}" has an invalid repeated value.` + ); + } + if ( + binding.conflict !== undefined && + !['first', 'error'].includes(binding.conflict) + ) { + throw new ContractError( + 'CLI_BINDING_INVALID', + `Binding "${binding.property}" has an invalid conflict policy.` + ); + } + if (['__proto__', 'prototype', 'constructor'].includes(binding.property)) { + throw new ContractError( + 'CLI_BINDING_PROPERTY_UNSAFE', + `Binding property "${binding.property}" is not allowed in "${command.id}".` + ); + } + if ( + !Object.prototype.hasOwnProperty.call(input.properties, binding.property) + ) { + throw new ContractError( + 'CLI_BINDING_PROPERTY_UNKNOWN', + `Binding references unknown input property "${binding.property}" in "${command.id}".` + ); + } + if (properties.has(binding.property)) { + throw new ContractError( + 'CLI_BINDING_PROPERTY_DUPLICATE', + `Input property "${binding.property}" has multiple bindings in "${command.id}".` + ); + } + properties.add(binding.property); + + for (const source of binding.sources) { + if ( + source === null || + typeof source !== 'object' || + !['option', 'positional', 'environment', 'constant'].includes( + source.kind + ) + ) { + throw new ContractError( + 'CLI_BINDING_SOURCE_INVALID', + `Binding "${binding.property}" in "${command.id}" contains an invalid source.` + ); + } + if (source.kind === 'option') { + if ( + typeof source.name !== 'string' || + !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(source.name) + ) { + throw new ContractError( + 'CLI_OPTION_NAME_INVALID', + `Canonical option "${source.name}" in "${command.id}" must be kebab-case without dashes.` + ); + } + if (source.negatable && binding.valueType !== 'boolean') { + throw new ContractError( + 'CLI_OPTION_NEGATABLE_INVALID', + `Negatable option "${source.name}" in "${command.id}" must bind a boolean value.` + ); + } + if ( + (source.aliases !== undefined && !Array.isArray(source.aliases)) || + (source.deprecatedAliases !== undefined && + !Array.isArray(source.deprecatedAliases)) || + (source.negatable !== undefined && + typeof source.negatable !== 'boolean') || + (source.sensitive !== undefined && + typeof source.sensitive !== 'boolean') + ) { + throw new ContractError( + 'CLI_OPTION_SOURCE_INVALID', + `Option source "${source.name}" in "${command.id}" is invalid.` + ); + } + const aliases = [ + ...(source.aliases ?? []), + ...(source.deprecatedAliases ?? []), + ]; + for (const alias of aliases) { + if ( + typeof alias !== 'string' || + !/^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/.test(alias) + ) { + throw new ContractError( + 'CLI_OPTION_ALIAS_INVALID', + `Alias "${alias}" for "${source.name}" in "${command.id}" is invalid.` + ); + } + } + registerLongOption(source.name, binding, source); + for (const alias of aliases) registerLongOption(alias, binding, source); + + if (source.short !== undefined) { + if (!/^[A-Za-z0-9]$/.test(source.short)) { + throw new ContractError( + 'CLI_OPTION_SHORT_INVALID', + `Short option "${source.short}" in "${command.id}" must be one alphanumeric character.` + ); + } + const owner = shortOptionNames.get(source.short); + if (owner !== undefined) { + throw new ContractError( + 'CLI_OPTION_DUPLICATE', + `Short option "${source.short}" is bound to both "${owner}" and "${binding.property}" in "${command.id}".` + ); + } + if (GLOBAL_SHORT_OPTION_NAMES.includes(source.short)) { + throw new ContractError( + 'CLI_OPTION_GLOBAL_COLLISION', + `Short option "${source.short}" in "${command.id}" collides with a global option.` + ); + } + shortOptionNames.set(source.short, binding.property); + } + } + if (source.kind === 'positional') { + if (!Number.isInteger(source.index) || source.index < 0) { + throw new ContractError( + 'CLI_POSITIONAL_INDEX_INVALID', + `Position ${source.index} for "${binding.property}" in "${command.id}" is invalid.` + ); + } + if ( + (source.name !== undefined && typeof source.name !== 'string') || + (source.variadic !== undefined && + typeof source.variadic !== 'boolean') + ) { + throw new ContractError( + 'CLI_POSITIONAL_SOURCE_INVALID', + `Position ${source.index} for "${binding.property}" in "${command.id}" is invalid.` + ); + } + const owner = positionalIndexes.get(source.index); + if (owner !== undefined) { + throw new ContractError( + 'CLI_POSITIONAL_DUPLICATE', + `Position ${source.index} is bound to both "${owner}" and "${binding.property}" in "${command.id}".` + ); + } + positionalIndexes.set(source.index, binding.property); + if (source.variadic) { + variadicCount += 1; + variadicIndex = source.index; + if (binding.repeated !== true) { + throw new ContractError( + 'CLI_VARIADIC_BINDING_INVALID', + `Variadic positional "${binding.property}" in "${command.id}" must be repeated.` + ); + } + } + } + if (source.kind === 'environment') { + if ( + typeof source.name !== 'string' || + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(source.name) + ) { + throw new ContractError( + 'CLI_ENVIRONMENT_NAME_INVALID', + `Environment binding "${source.name}" in "${command.id}" is invalid.` + ); + } + if ( + source.sensitive !== undefined && + typeof source.sensitive !== 'boolean' + ) { + throw new ContractError( + 'CLI_ENVIRONMENT_SOURCE_INVALID', + `Environment binding "${source.name}" in "${command.id}" has invalid sensitivity metadata.` + ); + } + } + if (source.kind === 'constant') { + jsonSnapshot( + source.value, + `Constant binding for "${binding.property}" in "${command.id}"` + ); + } + } + + const propertySchema = input.properties[binding.property] as TSchema & { + type?: unknown; + }; + if (binding.repeated === true && propertySchema.type !== 'array') { + throw new ContractError( + 'CLI_REPEATED_BINDING_INVALID', + `Repeated binding "${binding.property}" in "${command.id}" must target an array property.` + ); + } + } + + if (variadicCount > 1) { + throw new ContractError( + 'CLI_POSITIONAL_VARIADIC_DUPLICATE', + `Command "${command.id}" has multiple variadic positionals.` + ); + } + if (variadicIndex !== undefined) { + for (const index of positionalIndexes.keys()) { + if (index > variadicIndex) { + throw new ContractError( + 'CLI_POSITIONAL_AFTER_VARIADIC', + `Command "${command.id}" declares a positional after its variadic positional.` + ); + } + } + } + + const sortedIndexes = [...positionalIndexes.keys()].sort( + (left, right) => left - right + ); + for (let expected = 0; expected < sortedIndexes.length; expected += 1) { + if (sortedIndexes[expected] !== expected) { + throw new ContractError( + 'CLI_POSITIONAL_GAP', + `Command "${command.id}" must use contiguous positional indexes beginning at zero.` + ); + } + } + + for (const property of Object.keys(input.properties)) { + if (!properties.has(property)) { + throw new ContractError( + 'CLI_BINDING_PROPERTY_MISSING', + `Input property "${property}" does not have a binding in "${command.id}".` + ); + } + } +} + +function validateExamples(command: CommandDefinition): void { + for (const [index, example] of command.examples.entries()) { + if ( + example === null || + typeof example !== 'object' || + !Array.isArray(example.argv) || + example.argv.some((value) => typeof value !== 'string') || + (example.description !== undefined && + typeof example.description !== 'string') + ) { + throw new ContractError( + 'CLI_COMMAND_EXAMPLE_INVALID', + `Example ${index + 1} for "${command.id}" has invalid argv.` + ); + } + const commandPath = example.argv.slice(0, command.path.length); + if ( + commandPath.length !== command.path.length || + commandPath.some((part, partIndex) => part !== command.path[partIndex]) + ) { + throw new ContractError( + 'CLI_COMMAND_EXAMPLE_INVALID', + `Example ${index + 1} for "${command.id}" must begin with "${command.path.join(' ')}".` + ); + } + try { + // Parse the full invocation so command-local options occur after the + // command path. Parsing only the suffix makes the global parser mistake a + // valid command option for an unknown leading global option. + const parsed = parseGlobalArguments(example.argv); + const parsedCommandPath = parsed.argv.slice(0, command.path.length); + if ( + parsedCommandPath.length !== command.path.length || + parsedCommandPath.some( + (part, partIndex) => part !== command.path[partIndex] + ) + ) { + throw new InvocationError( + 'CLI_COMMAND_EXAMPLE_PATH_INVALID', + `The example does not resolve to "${command.path.join(' ')}" after global options are parsed.` + ); + } + bindArguments(command, { + argv: parsed.argv.slice(command.path.length), + env: {}, + strict: true, + validate: !command.bindings.some( + (binding) => binding.sources.length === 0 + ), + }); + } catch (error) { + throw new ContractError( + 'CLI_COMMAND_EXAMPLE_INVALID', + `Example ${index + 1} for "${command.id}" does not satisfy its command contract.`, + { cause: error instanceof Error ? error.message : String(error) } + ); + } + } +} + +export class CommandRegistry { + private readonly byId = new Map(); + private readonly byPath = new Map(); + private readonly compiled = new Map(); + + constructor(commands: readonly CommandDefinition[]) { + for (const sourceCommand of commands) { + const command = snapshotCommand(sourceCommand); + validateIdentifier(command); + validateBindings(command); + validateEventSchema(command); + const pathKey = command.path.join(' '); + if (this.byId.has(command.id)) { + throw new ContractError( + 'CLI_COMMAND_ID_DUPLICATE', + `Duplicate command id "${command.id}".` + ); + } + if (this.byPath.has(pathKey)) { + throw new ContractError( + 'CLI_COMMAND_PATH_DUPLICATE', + `Duplicate command path "${pathKey}".` + ); + } + + this.byId.set(command.id, command); + this.byPath.set(pathKey, command); + this.compiled.set(command.id, { + input: compileSchema(command.input), + output: compileSchema(command.output), + result: compileSchema(operationResultSchema(command.output)), + ...(command.events === undefined + ? {} + : { event: compileSchema(command.events) }), + }); + } + for (const command of this.byId.values()) validateExamples(command); + } + + list(prefix: readonly string[] = []): CommandDefinition[] { + return [...this.byId.values()] + .filter((command) => + prefix.every((part, index) => command.path[index] === part) + ) + .sort((left, right) => + left.path.join(' ').localeCompare(right.path.join(' ')) + ); + } + + getById(id: string): CommandDefinition | undefined { + return this.byId.get(id); + } + + requireById(id: string): CommandDefinition { + const command = this.getById(id); + if (command === undefined) { + throw new InvocationError( + 'CLI_COMMAND_NOT_FOUND', + 'No command is registered for the requested id.' + ); + } + return command; + } + + getByPath(path: readonly string[]): CommandDefinition | undefined { + return this.byPath.get(path.join(' ')); + } + + requireByPath(path: readonly string[]): CommandDefinition { + const command = this.getByPath(path); + if (command === undefined) { + throw new InvocationError( + 'CLI_COMMAND_NOT_FOUND', + 'No command is registered for the requested path.' + ); + } + return command; + } + + /** Resolves the longest command path at the beginning of argv. */ + resolve( + argv: readonly string[] + ): { command: CommandDefinition; consumed: number } | undefined { + for (let length = argv.length; length > 0; length -= 1) { + const command = this.getByPath(argv.slice(0, length)); + if (command !== undefined) return { command, consumed: length }; + } + return undefined; + } + + validateInput(commandId: string, value: unknown): SchemaIssue[] { + const validator = this.requireCompiled(commandId).input; + return validator.validate(value) ? [] : validator.issues(); + } + + validateOutput(commandId: string, value: unknown): SchemaIssue[] { + const validator = this.requireCompiled(commandId).output; + return validator.validate(value) ? [] : validator.issues(); + } + + validateResult(commandId: string, value: unknown): SchemaIssue[] { + const validator = this.requireCompiled(commandId).result; + return validator.validate(value) ? [] : validator.issues(); + } + + validateEvent(commandId: string, value: unknown): SchemaIssue[] { + const validator = this.requireCompiled(commandId).event; + if (validator === undefined) { + return [ + { + path: '/', + keyword: 'events', + message: 'command does not declare events', + params: {}, + }, + ]; + } + return validator.validate(value) ? [] : validator.issues(); + } + + catalog(prefix: readonly string[] = []): CommandCatalogEntry[] { + return this.list(prefix).map((command) => ({ + id: command.id, + path: [...command.path], + summary: command.summary, + lifecycle: command.lifecycle, + effect: command.effect, + capabilities: snapshotCapabilities(command.capabilities), + })); + } + + schema(commandId: string): CommandSchemaDocument { + const command = this.requireById(commandId); + const document: unknown = jsonSnapshot( + { + protocolVersion: 'constructive.dev/cli/v1', + id: command.id, + path: [...command.path], + summary: command.summary, + ...(command.description === undefined + ? {} + : { description: command.description }), + lifecycle: command.lifecycle, + effect: command.effect, + capabilities: snapshotCapabilities(command.capabilities), + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + ...cloneSchema(command.input), + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + ...cloneSchema(command.output), + }, + ...(command.events === undefined + ? {} + : { + events: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + ...cloneSchema(command.events), + }, + }), + bindings: command.bindings, + examples: command.examples.map((example) => ({ + ...(example.description === undefined + ? {} + : { description: example.description }), + argv: [...example.argv], + })), + }, + `Schema document for "${command.id}"` + ); + if (!commandSchemaDocumentValidator.validate(document)) { + throw new ContractError( + 'CLI_COMMAND_SCHEMA_DOCUMENT_INVALID', + 'The exported command schema is invalid.', + { + commandId: command.id, + issues: commandSchemaDocumentValidator.issues(), + } + ); + } + return document; + } + + private requireCompiled(commandId: string): CompiledCommand { + const compiled = this.compiled.get(commandId); + if (compiled === undefined) + throw new ContractError( + 'CLI_COMMAND_NOT_REGISTERED', + `Command "${commandId}" is not registered.` + ); + return compiled; + } +} + +export function createCommandRegistry( + commands: readonly CommandDefinition[] +): CommandRegistry { + return new CommandRegistry(commands); +} + +export function assertOperationResultMetadata( + result: OperationResult +): OperationWarning[] { + assertJsonValue(result); + const validator = compileSchema>( + OperationResultSchema + ); + if (!validator.validate(result)) { + throw new ContractError( + 'CLI_RESULT_INVALID', + 'Command returned invalid result metadata.', + { + issues: validator.issues(), + } + ); + } + return [...(result.warnings ?? [])]; +} diff --git a/packages/cli-runtime/src/render.ts b/packages/cli-runtime/src/render.ts new file mode 100644 index 0000000000..87ddaac9fd --- /dev/null +++ b/packages/cli-runtime/src/render.ts @@ -0,0 +1,127 @@ +import { + CommandDefinition, + ExecutionOutcome, + OutputFormat, + ProtocolEvent, + ProtocolEventSink, +} from './contracts'; +import { InvocationError } from './errors'; + +export interface RenderedExecution { + stdout: string; + stderr: string; +} + +function jsonStringify(value: unknown, indentation?: number): string { + const serialized = JSON.stringify( + value, + (_key, item) => (typeof item === 'bigint' ? item.toString() : item), + indentation + ); + if (serialized === undefined) + throw new Error('Value cannot be represented as JSON.'); + return serialized; +} + +export function serializeProtocolEvent(event: ProtocolEvent): string { + return jsonStringify(event); +} + +export function createJsonlSink( + write: (line: string) => void | Promise +): ProtocolEventSink { + return async (event) => { + await write(`${serializeProtocolEvent(event)}\n`); + }; +} + +export function assertFormatAllowed( + command: CommandDefinition, + format: OutputFormat +): void { + if (command.lifecycle === 'long-running' && format === 'json') { + throw new InvocationError( + 'CLI_FORMAT_UNSUPPORTED', + `Long-running command "${command.path.join(' ')}" requires --format jsonl or human.` + ); + } +} + +function terminalEvent(outcome: ExecutionOutcome): ProtocolEvent { + const event = outcome.terminalEvent; + if (outcome.protocolEvents.length === 0) return event; + const terminalEvents = outcome.protocolEvents.filter((item) => + ['operation.completed', 'operation.failed', 'operation.cancelled'].includes( + item.event + ) + ); + if ( + event === undefined || + terminalEvents.length !== 1 || + ![ + 'operation.completed', + 'operation.failed', + 'operation.cancelled', + ].includes(event.event) + ) { + throw new Error( + 'Execution outcome does not contain exactly one terminal event.' + ); + } + return event; +} + +export function renderExecution( + outcome: ExecutionOutcome, + format: OutputFormat, + renderHumanResult?: ( + result: NonNullable + ) => string +): RenderedExecution { + if (format === 'json') { + return { + stdout: `${serializeProtocolEvent(terminalEvent(outcome))}\n`, + stderr: '', + }; + } + if (format === 'jsonl') { + if (outcome.protocolEvents.length === 0) { + throw new Error( + 'JSONL rendering requires event transcript capture or a streaming sink.' + ); + } + return { + stdout: + outcome.protocolEvents + .map((event) => serializeProtocolEvent(event)) + .join('\n') + '\n', + stderr: '', + }; + } + + if (outcome.status === 'completed') { + const result = outcome.result!; + const output = + renderHumanResult === undefined + ? typeof result.data === 'string' + ? result.data + : jsonStringify(result.data, 2) + : renderHumanResult(result); + const stderr = (result.warnings ?? []) + .map((warning) => `Warning [${warning.code}]: ${warning.message}`) + .join('\n'); + return { + stdout: output.length === 0 ? '' : `${output}\n`, + stderr: stderr.length === 0 ? '' : `${stderr}\n`, + }; + } + + const error = outcome.error!; + const warnings = (outcome.warnings ?? []) + .map((warning) => `Warning [${warning.code}]: ${warning.message}`) + .join('\n'); + return { + stdout: '', + stderr: `${warnings.length === 0 ? '' : `${warnings}\n`}Error [${error.code}]: ${error.message}\n`, + }; +} diff --git a/packages/cli-runtime/src/schema.ts b/packages/cli-runtime/src/schema.ts new file mode 100644 index 0000000000..fc4e824e1d --- /dev/null +++ b/packages/cli-runtime/src/schema.ts @@ -0,0 +1,178 @@ +import { TSchema } from '@sinclair/typebox'; +import Ajv2020, { ErrorObject, ValidateFunction } from 'ajv/dist/2020.js'; + +import { ContractError } from './errors'; + +export interface SchemaIssue { + path: string; + keyword: string; + message: string; + params: Record; +} + +export interface SchemaValidator { + readonly schema: TSchema; + validate(value: unknown): value is T; + issues(): SchemaIssue[]; +} + +function toIssues(errors: ErrorObject[] | null | undefined): SchemaIssue[] { + return (errors ?? []).map((error) => ({ + path: error.instancePath || '/', + keyword: error.keyword, + message: error.message ?? 'is invalid', + params: error.params as Record, + })); +} + +export function createAjv(): Ajv2020 { + return new Ajv2020({ + allErrors: true, + strict: true, + strictSchema: true, + validateFormats: false, + allowUnionTypes: true, + }); +} + +export function compileSchema( + schema: TSchema, + ajv = createAjv() +): SchemaValidator { + let compiled: ValidateFunction; + try { + compiled = ajv.compile(schema); + } catch (error) { + throw new ContractError( + 'CLI_SCHEMA_INVALID', + error instanceof Error + ? error.message + : 'The JSON Schema could not be compiled.', + { schema } + ); + } + + return { + schema, + validate(value: unknown): value is T { + return Boolean(compiled(value)); + }, + issues(): SchemaIssue[] { + return toIssues(compiled.errors); + }, + }; +} + +export function cloneSchema(schema: T): T { + return JSON.parse(JSON.stringify(schema)) as T; +} + +/** Rejects values which JSON.stringify would silently drop or alter. */ +export function assertJsonValue( + value: unknown, + path = '/', + ancestors = new WeakSet() +): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return; + if (typeof value === 'number') { + if (Number.isFinite(value)) return; + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Non-finite number at ${path} cannot cross the CLI protocol.` + ); + } + if (typeof value !== 'object') { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Value at ${path} cannot cross the CLI protocol.`, + { + type: typeof value, + } + ); + } + if (ancestors.has(value)) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Circular value at ${path} cannot cross the CLI protocol.` + ); + } + ancestors.add(value); + if (Array.isArray(value)) { + const enumerableSymbols = Object.getOwnPropertySymbols(value).filter( + (key) => Object.getOwnPropertyDescriptor(value, key)?.enumerable === true + ); + if (enumerableSymbols.length > 0) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Symbol properties at ${path} cannot cross the CLI protocol.` + ); + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Sparse array slot at ${path === '/' ? '' : path}/${index} cannot cross the CLI protocol.` + ); + } + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor)) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Accessor array slot at ${path === '/' ? '' : path}/${index} cannot cross the CLI protocol.` + ); + } + assertJsonValue( + descriptor.value, + `${path === '/' ? '' : path}/${index}`, + ancestors + ); + } + const extraKeys = Object.keys(value).filter( + (key) => !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length + ); + if (extraKeys.length > 0) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Enumerable array properties at ${path} cannot cross the CLI protocol.`, + { keys: extraKeys } + ); + } + } else { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Non-plain object at ${path} cannot cross the CLI protocol.` + ); + } + const enumerableSymbols = Object.getOwnPropertySymbols(value).filter( + (key) => Object.getOwnPropertyDescriptor(value, key)?.enumerable === true + ); + if (enumerableSymbols.length > 0) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Symbol properties at ${path} cannot cross the CLI protocol.` + ); + } + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new ContractError( + 'CLI_JSON_VALUE_INVALID', + `Accessor property at ${path} cannot cross the CLI protocol.`, + { + key, + } + ); + } + const escaped = key.replace(/~/g, '~0').replace(/\//g, '~1'); + assertJsonValue( + descriptor.value, + `${path === '/' ? '' : path}/${escaped}`, + ancestors + ); + } + } + ancestors.delete(value); +} diff --git a/packages/cli-runtime/src/settings.ts b/packages/cli-runtime/src/settings.ts new file mode 100644 index 0000000000..f91962fd4f --- /dev/null +++ b/packages/cli-runtime/src/settings.ts @@ -0,0 +1,246 @@ +import { ExecutionSettings, OperationWarning, OutputFormat } from './contracts'; +import { InvocationError } from './errors'; + +/** Reserved by the process adapter and unavailable to command-local bindings. */ +export const GLOBAL_LONG_OPTION_NAMES: readonly string[] = Object.freeze([ + 'agent', + 'interactive', + 'non-interactive', + 'nonInteractive', + 'format', + 'cwd', + 'yes', + 'no-color', + 'noColor', + 'debug', + 'help', + 'version', +]); + +export const GLOBAL_SHORT_OPTION_NAMES: readonly string[] = Object.freeze([ + 'h', + 'v', +]); + +export interface ResolveExecutionSettingsOptions { + agent?: boolean; + interactive?: boolean; + nonInteractive?: boolean; + format?: OutputFormat; + noColor?: boolean; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; + ci?: boolean; +} + +export function resolveExecutionSettings( + options: ResolveExecutionSettingsOptions +): ExecutionSettings { + if (options.interactive && options.nonInteractive) { + throw new InvocationError( + 'CLI_MODE_CONFLICT', + '--interactive and --non-interactive cannot be used together.' + ); + } + if (options.interactive && !options.stdinIsTTY) { + throw new InvocationError( + 'CLI_INTERACTIVE_REQUIRES_TTY', + '--interactive requires a TTY on stdin.' + ); + } + if ( + options.interactive && + (options.agent === true || + options.format === 'json' || + options.format === 'jsonl') + ) { + throw new InvocationError( + 'CLI_MODE_CONFLICT', + '--interactive cannot be combined with agent mode or a structured output format.' + ); + } + if (options.agent && options.format === 'human') { + throw new InvocationError( + 'CLI_MODE_CONFLICT', + '--agent cannot be combined with --format human.' + ); + } + + const format = options.format ?? (options.agent ? 'jsonl' : 'human'); + const structured = format === 'json' || format === 'jsonl'; + const automaticallyNonInteractive = + options.ci === true || !options.stdinIsTTY || !options.stdoutIsTTY; + const interactive = + options.interactive === true + ? true + : options.agent === true || + options.nonInteractive === true || + structured || + automaticallyNonInteractive + ? false + : true; + const mode = + options.agent === true ? 'agent' : options.ci === true ? 'ci' : 'human'; + const terminalEffects = + interactive && + format === 'human' && + !automaticallyNonInteractive && + options.noColor !== true; + + return { + mode, + format, + interactive, + terminalEffects, + mayOpenBrowser: + interactive && format === 'human' && !automaticallyNonInteractive, + checkForUpdates: + interactive && format === 'human' && !automaticallyNonInteractive, + }; +} + +export interface ParsedGlobalArguments { + argv: string[]; + options: { + agent: boolean; + interactive: boolean; + nonInteractive: boolean; + format?: OutputFormat; + cwd?: string; + yes: boolean; + noColor: boolean; + debug: boolean; + help: boolean; + version: boolean; + }; + warnings: OperationWarning[]; +} + +/** Extracts only universal flags and leaves command-owned flags untouched. */ +export function parseGlobalArguments( + argv: readonly string[] +): ParsedGlobalArguments { + const remaining: string[] = []; + const warnings: OperationWarning[] = []; + let commandStarted = false; + const options: ParsedGlobalArguments['options'] = { + agent: false, + interactive: false, + nonInteractive: false, + yes: false, + noColor: false, + debug: false, + help: false, + version: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--') { + remaining.push(...argv.slice(index)); + break; + } + const [name, inline] = token.startsWith('--') + ? token.slice(2).split(/=(.*)/s, 2) + : [undefined, undefined]; + const requireFlag = (label: string): void => { + if (inline !== undefined) + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + `${label} does not accept a value.` + ); + }; + const takeValue = (label: string): string => { + if (inline !== undefined) return inline; + index += 1; + const value = argv[index]; + if (value === undefined) + throw new InvocationError( + 'CLI_OPTION_VALUE_MISSING', + `${label} requires a value.` + ); + return value; + }; + + switch (name) { + case 'agent': + requireFlag('--agent'); + options.agent = true; + break; + case 'interactive': + requireFlag('--interactive'); + options.interactive = true; + break; + case 'non-interactive': + requireFlag('--non-interactive'); + options.nonInteractive = true; + break; + case 'nonInteractive': + requireFlag('--nonInteractive'); + options.nonInteractive = true; + warnings.push({ + code: 'CLI_DEPRECATED', + message: + 'Option "--nonInteractive" is deprecated; use "--non-interactive".', + }); + break; + case 'format': { + const value = takeValue('--format'); + if (!['human', 'json', 'jsonl'].includes(value)) { + throw new InvocationError( + 'CLI_OPTION_VALUE_INVALID', + '--format must be human, json, or jsonl.' + ); + } + options.format = value as OutputFormat; + break; + } + case 'cwd': + options.cwd = takeValue('--cwd'); + break; + case 'yes': + requireFlag('--yes'); + options.yes = true; + break; + case 'no-color': + requireFlag('--no-color'); + options.noColor = true; + break; + case 'noColor': + requireFlag('--noColor'); + options.noColor = true; + warnings.push({ + code: 'CLI_DEPRECATED', + message: 'Option "--noColor" is deprecated; use "--no-color".', + }); + break; + case 'debug': + requireFlag('--debug'); + options.debug = true; + break; + case 'help': + requireFlag('--help'); + options.help = true; + break; + case 'version': + requireFlag('--version'); + options.version = true; + break; + default: + if (token === '-h') options.help = true; + else if (token === '-v') options.version = true; + else { + if (!commandStarted && token.startsWith('-')) { + throw new InvocationError( + 'CLI_OPTION_UNKNOWN', + `Unknown global option "${token.split('=')[0]}".` + ); + } + remaining.push(token); + if (!token.startsWith('-')) commandStarted = true; + } + } + } + + return { argv: remaining, options, warnings }; +} diff --git a/packages/cli-runtime/tsconfig.esm.json b/packages/cli-runtime/tsconfig.esm.json new file mode 100644 index 0000000000..800d7506d3 --- /dev/null +++ b/packages/cli-runtime/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false + } +} diff --git a/packages/cli-runtime/tsconfig.json b/packages/cli-runtime/tsconfig.json new file mode 100644 index 0000000000..1a9d5696cb --- /dev/null +++ b/packages/cli-runtime/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} From d40dbce75fd337307c0397f55975eff860cb9d0f Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:37:39 +0700 Subject: [PATCH 02/18] fix(cache): scope Graphile storage state per runtime Give embedded cache, schema, bucket, and presigned-upload services explicit instance ownership so concurrent CLI operations do not share or evict one another's state. Add ownership and configuration coverage for the new factories. --- .../__tests__/plugin.test.ts | 459 ++++---- .../src/plugin.ts | 382 ++++--- .../src/types.ts | 17 +- graphile/graphile-cache/package.json | 2 +- .../src/__tests__/ownership.test.ts | 114 ++ .../graphile-cache/src/create-instance.ts | 21 +- graphile/graphile-cache/src/graphile-cache.ts | 289 ++++-- graphile/graphile-cache/src/index.ts | 9 +- .../__tests__/cache-scope.test.ts | 69 ++ .../package.json | 2 +- .../src/download-url-field.ts | 188 ++-- .../src/index.ts | 21 +- .../src/plugin.ts | 981 ++++++++++++------ .../src/preset.ts | 8 +- .../src/storage-module-cache.ts | 230 ++-- .../__tests__/build-schema-config.test.ts | 82 ++ graphile/graphile-schema/package.json | 2 +- .../src/build-introspection.ts | 17 +- graphile/graphile-schema/src/build-schema.ts | 93 +- graphile/graphile-schema/src/index.ts | 8 +- packages/server-utils/package.json | 2 +- packages/server-utils/src/lru.ts | 21 +- postgres/pg-cache/package.json | 2 +- .../pg-cache/src/__tests__/driver.test.ts | 66 +- postgres/pg-cache/src/__tests__/lru.test.ts | 84 +- postgres/pg-cache/src/index.ts | 16 +- postgres/pg-cache/src/lru.ts | 138 ++- postgres/pg-cache/src/pg.ts | 88 +- 28 files changed, 2334 insertions(+), 1077 deletions(-) create mode 100644 graphile/graphile-cache/src/__tests__/ownership.test.ts create mode 100644 graphile/graphile-presigned-url-plugin/__tests__/cache-scope.test.ts create mode 100644 graphile/graphile-schema/__tests__/build-schema-config.test.ts diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts index 0ee54bc9d5..7bae24d36a 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts @@ -16,12 +16,17 @@ // Mock @constructive-io/bucket-provisioner before any imports const mockProvision = jest.fn(); const mockUpdateCors = jest.fn(); +const mockDestroy = jest.fn(); const mockBucketProvisionerConstructor = jest.fn(); jest.mock('@constructive-io/bucket-provisioner', () => ({ BucketProvisioner: jest.fn().mockImplementation((opts: any) => { mockBucketProvisionerConstructor(opts); - return { provision: mockProvision, updateCors: mockUpdateCors }; + return { + provision: mockProvision, + updateCors: mockUpdateCors, + getClient: () => ({ destroy: mockDestroy }), + }; }), })); @@ -57,10 +62,7 @@ jest.mock('graphile-utils', () => ({ // Invoke the provisionBucket plan to trigger the lambda mock, // which captures the callback into capturedLambdaCallback. if (schema.plans?.Mutation?.provisionBucket) { - schema.plans.Mutation.provisionBucket( - null, - { getRaw: mockGetRaw }, - ); + schema.plans.Mutation.provisionBucket(null, { getRaw: mockGetRaw }); } return { name: 'ExtendSchemaPlugin', @@ -80,7 +82,7 @@ import type { BucketProvisionerPluginOptions } from '../src/types'; // --- Test helpers --- function createDefaultOptions( - overrides: Partial = {}, + overrides: Partial = {} ): BucketProvisionerPluginOptions { return { connection: { @@ -101,30 +103,37 @@ function createMockPgClient(overrides: Record = {}) { rows: [{ id: 'db-uuid-123' }], }, 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: null, - }], + rows: [ + { + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: null, + provider: null, + allowed_origins: null, + }, + ], }, - 'app_public': { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: null, - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-789', + key: 'public', + type: 'public', + is_public: true, + allowed_origins: null, + }, + ], }, }; return { query: jest.fn((sql: string, _params?: any[]) => { - for (const [key, value] of Object.entries({ ...defaultQueries, ...overrides })) { + for (const [key, value] of Object.entries({ + ...defaultQueries, + ...overrides, + })) { if (sql.includes(key)) { return Promise.resolve(value); } @@ -141,6 +150,7 @@ describe('createBucketProvisionerPlugin', () => { jest.clearAllMocks(); mockProvision.mockReset(); mockBucketProvisionerConstructor.mockReset(); + mockDestroy.mockReset(); capturedLambdaCallback = null; mockProvision.mockResolvedValue({ @@ -171,13 +181,17 @@ describe('createBucketProvisionerPlugin', () => { it('includes GraphQLObjectType_fields_field hook when autoProvision is true', () => { const plugin = createBucketProvisionerPlugin(createDefaultOptions()); - expect(plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBeDefined(); - expect(typeof plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBe('function'); + expect( + plugin.schema!.hooks!.GraphQLObjectType_fields_field + ).toBeDefined(); + expect(typeof plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBe( + 'function' + ); }); it('does not include fields_field hook when autoProvision is false', () => { const plugin = createBucketProvisionerPlugin( - createDefaultOptions({ autoProvision: false }), + createDefaultOptions({ autoProvision: false }) ); // When autoProvision is false, the plugin is just the extendSchema result @@ -200,7 +214,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const result = await capturedLambdaCallback!({ @@ -218,13 +232,15 @@ describe('createBucketProvisionerPlugin', () => { it('provisions a private bucket', async () => { const privateBucketOverrides = { - 'app_public': { - rows: [{ - id: 'bucket-uuid-private', - key: 'private', - type: 'private', - is_public: false, - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-private', + key: 'private', + type: 'private', + is_public: false, + }, + ], }, }; @@ -245,7 +261,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(privateBucketOverrides); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const result = await capturedLambdaCallback!({ @@ -261,7 +277,7 @@ describe('createBucketProvisionerPlugin', () => { it('uses bucketNamePrefix when set', async () => { createBucketProvisionerPlugin( - createDefaultOptions({ bucketNamePrefix: 'myapp' }), + createDefaultOptions({ bucketNamePrefix: 'myapp' }) ); mockProvision.mockResolvedValue({ @@ -279,7 +295,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const result = await capturedLambdaCallback!({ @@ -290,18 +306,19 @@ describe('createBucketProvisionerPlugin', () => { // The provision call should have the prefixed name expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'myapp-public' }), + expect.objectContaining({ bucketName: 'myapp-public' }) ); expect(result.success).toBe(true); }); it('uses custom resolveBucketName when provided', async () => { const customResolver = jest.fn( - (bucketKey: string, databaseId: string) => `org-${databaseId}-${bucketKey}`, + (bucketKey: string, databaseId: string) => + `org-${databaseId}-${bucketKey}` ); createBucketProvisionerPlugin( - createDefaultOptions({ resolveBucketName: customResolver }), + createDefaultOptions({ resolveBucketName: customResolver }) ); mockProvision.mockResolvedValue({ @@ -319,7 +336,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -330,7 +347,7 @@ describe('createBucketProvisionerPlugin', () => { expect(customResolver).toHaveBeenCalledWith('public', 'db-uuid-123'); expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'org-db-uuid-123-public' }), + expect.objectContaining({ bucketName: 'org-db-uuid-123-public' }) ); }); @@ -342,7 +359,7 @@ describe('createBucketProvisionerPlugin', () => { input: { bucketKey: '' }, withPgClient: jest.fn(), pgSettings: {}, - }), + }) ).rejects.toThrow('INVALID_BUCKET_KEY'); }); @@ -353,7 +370,7 @@ describe('createBucketProvisionerPlugin', () => { 'jwt_private.current_database_id': { rows: [{ id: null }] }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await expect( @@ -361,7 +378,7 @@ describe('createBucketProvisionerPlugin', () => { input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, - }), + }) ).rejects.toThrow('DATABASE_NOT_FOUND'); }); @@ -372,7 +389,7 @@ describe('createBucketProvisionerPlugin', () => { 'metaschema_modules_public.storage_module': { rows: [] }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await expect( @@ -380,7 +397,7 @@ describe('createBucketProvisionerPlugin', () => { input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, - }), + }) ).rejects.toThrow('STORAGE_MODULE_NOT_PROVISIONED'); }); @@ -388,10 +405,10 @@ describe('createBucketProvisionerPlugin', () => { createBucketProvisionerPlugin(createDefaultOptions()); const pgClient = createMockPgClient({ - 'app_public': { rows: [] }, + app_public: { rows: [] }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await expect( @@ -399,7 +416,7 @@ describe('createBucketProvisionerPlugin', () => { input: { bucketKey: 'nonexistent' }, withPgClient: mockWithPgClient, pgSettings: {}, - }), + }) ).rejects.toThrow('BUCKET_NOT_FOUND'); }); @@ -410,7 +427,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const result = await capturedLambdaCallback!({ @@ -429,18 +446,20 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient({ 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: 'http://custom-minio:9000', - public_url_prefix: 'https://cdn.example.com', - provider: 'minio', - }], + rows: [ + { + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: 'http://custom-minio:9000', + public_url_prefix: 'https://cdn.example.com', + provider: 'minio', + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -456,18 +475,16 @@ describe('createBucketProvisionerPlugin', () => { endpoint: 'http://custom-minio:9000', provider: 'minio', }), - }), + }) ); }); it('passes versioning option to provision call', async () => { - createBucketProvisionerPlugin( - createDefaultOptions({ versioning: true }), - ); + createBucketProvisionerPlugin(createDefaultOptions({ versioning: true })); const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -477,7 +494,7 @@ describe('createBucketProvisionerPlugin', () => { }); expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ versioning: true }), + expect.objectContaining({ versioning: true }) ); }); @@ -486,18 +503,20 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient({ 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: 'https://cdn.example.com', - provider: null, - }], + rows: [ + { + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: 'https://cdn.example.com', + provider: null, + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -509,7 +528,7 @@ describe('createBucketProvisionerPlugin', () => { expect(mockProvision).toHaveBeenCalledWith( expect.objectContaining({ publicUrlPrefix: 'https://cdn.example.com', - }), + }) ); }); }); @@ -523,7 +542,7 @@ describe('createBucketProvisionerPlugin', () => { expect(typeof options.connection).toBe('object'); }); - it('resolves lazy getter connection config on first use', async () => { + it('resolves a lazy connection getter for each operation', async () => { const connectionConfig = { provider: 'minio' as const, region: 'us-east-1', @@ -538,7 +557,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -549,21 +568,25 @@ describe('createBucketProvisionerPlugin', () => { expect(getter).toHaveBeenCalledTimes(1); - // Second call should use cached value + // A later operation re-evaluates the getter so a runtime-aware provider + // cannot be replaced with the first operation's credentials. await capturedLambdaCallback!({ input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, }); - // Still only 1 call because it was cached - expect(getter).toHaveBeenCalledTimes(1); + expect(getter).toHaveBeenCalledTimes(2); }); }); describe('auto-provisioning hook (GraphQLObjectType_fields_field)', () => { - function getFieldsFieldHook(options?: Partial) { - const plugin = createBucketProvisionerPlugin(createDefaultOptions(options)); + function getFieldsFieldHook( + options?: Partial + ) { + const plugin = createBucketProvisionerPlugin( + createDefaultOptions(options) + ); return plugin.schema!.hooks!.GraphQLObjectType_fields_field as Function; } @@ -641,7 +664,9 @@ describe('createBucketProvisionerPlugin', () => { it('wraps update mutations on @storageBuckets-tagged tables', () => { const hook = getFieldsFieldHook(); - const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'updated' } }); + const originalResolve = jest + .fn() + .mockResolvedValue({ data: { id: 'updated' } }); const field = { resolve: originalResolve }; const build = {}; const context = { @@ -664,7 +689,9 @@ describe('createBucketProvisionerPlugin', () => { it('wraps create mutations on @storageBuckets-tagged tables', () => { const hook = getFieldsFieldHook(); - const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'new-bucket' } }); + const originalResolve = jest + .fn() + .mockResolvedValue({ data: { id: 'new-bucket' } }); const field = { resolve: originalResolve }; const build = {}; const context = { @@ -708,7 +735,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const graphqlContext = { @@ -720,7 +747,7 @@ describe('createBucketProvisionerPlugin', () => { null, { input: { bucket: { key: 'public', type: 'public' } } }, graphqlContext, - {}, + {} ); // Original resolver should be called @@ -755,7 +782,7 @@ describe('createBucketProvisionerPlugin', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const graphqlContext = { @@ -768,7 +795,7 @@ describe('createBucketProvisionerPlugin', () => { null, { input: { bucket: { key: 'public', type: 'public' } } }, graphqlContext, - {}, + {} ); expect(result).toBe(mutationResult); @@ -798,7 +825,7 @@ describe('createBucketProvisionerPlugin', () => { null, { input: { bucket: { name: 'test' } } }, // Missing key and type { withPgClient: jest.fn(), pgSettings: {} }, - {}, + {} ); // Should still return the mutation result @@ -831,7 +858,7 @@ describe('createBucketProvisionerPlugin', () => { null, { input: { bucket: { key: 'public', type: 'public' } } }, { pgSettings: {} }, // No withPgClient - {}, + {} ); expect(result).toBe(mutationResult); @@ -882,7 +909,7 @@ describe('createBucketProvisionerPlugin', () => { null, { input: { bucket: { key: 'public', type: 'public' } } }, // No allowed_origins { withPgClient: jest.fn(), pgSettings: {} }, - {}, + {} ); expect(result).toBe(mutationResult); @@ -911,25 +938,34 @@ describe('createBucketProvisionerPlugin', () => { const wrapped = hook(field, build, context); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: ['https://new-origin.example.com'], - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-789', + key: 'public', + type: 'public', + is_public: true, + allowed_origins: ['https://new-origin.example.com'], + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); const result = await wrapped.resolve( null, - { input: { bucket: { key: 'public', allowed_origins: ['https://new-origin.example.com'] } } }, + { + input: { + bucket: { + key: 'public', + allowed_origins: ['https://new-origin.example.com'], + }, + }, + }, { withPgClient: mockWithPgClient, pgSettings: {} }, - {}, + {} ); expect(result).toBe(mutationResult); @@ -938,7 +974,7 @@ describe('createBucketProvisionerPlugin', () => { bucketName: 'public', accessType: 'public', allowedOrigins: ['https://new-origin.example.com'], - }), + }) ); }); @@ -964,26 +1000,35 @@ describe('createBucketProvisionerPlugin', () => { const wrapped = hook(field, build, context); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: ['https://bad-origin.com'], - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-789', + key: 'public', + type: 'public', + is_public: true, + allowed_origins: ['https://bad-origin.com'], + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); // Should NOT throw const result = await wrapped.resolve( null, - { input: { bucket: { key: 'public', allowed_origins: ['https://bad-origin.com'] } } }, + { + input: { + bucket: { + key: 'public', + allowed_origins: ['https://bad-origin.com'], + }, + }, + }, { withPgClient: mockWithPgClient, pgSettings: {} }, - {}, + {} ); expect(result).toBe(mutationResult); @@ -1017,29 +1062,33 @@ describe('CORS resolution hierarchy', () => { createBucketProvisionerPlugin(createDefaultOptions()); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid-cdn', - key: 'cdn-assets', - type: 'public', - is_public: true, - allowed_origins: ['*'], - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-cdn', + key: 'cdn-assets', + type: 'public', + is_public: true, + allowed_origins: ['*'], + }, + ], }, 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: ['https://db-default.example.com'], - }], + rows: [ + { + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: null, + provider: null, + allowed_origins: ['https://db-default.example.com'], + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -1053,7 +1102,7 @@ describe('CORS resolution hierarchy', () => { expect.objectContaining({ bucketName: 'cdn-assets', allowedOrigins: ['*'], - }), + }) ); }); @@ -1074,29 +1123,33 @@ describe('CORS resolution hierarchy', () => { createBucketProvisionerPlugin(createDefaultOptions()); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid-uploads', - key: 'uploads', - type: 'public', - is_public: true, - allowed_origins: null, // No bucket-level override - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-uploads', + key: 'uploads', + type: 'public', + is_public: true, + allowed_origins: null, // No bucket-level override + }, + ], }, 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: ['https://db-default.example.com'], - }], + rows: [ + { + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: null, + provider: null, + allowed_origins: ['https://db-default.example.com'], + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -1110,7 +1163,7 @@ describe('CORS resolution hierarchy', () => { expect.objectContaining({ bucketName: 'uploads', allowedOrigins: ['https://db-default.example.com'], - }), + }) ); }); @@ -1128,34 +1181,40 @@ describe('CORS resolution hierarchy', () => { lifecycleRules: [], }); - createBucketProvisionerPlugin(createDefaultOptions({ - allowedOrigins: ['https://plugin-default.example.com'], - })); + createBucketProvisionerPlugin( + createDefaultOptions({ + allowedOrigins: ['https://plugin-default.example.com'], + }) + ); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid-docs', - key: 'docs', - type: 'private', - is_public: false, - allowed_origins: null, - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-docs', + key: 'docs', + type: 'private', + is_public: false, + allowed_origins: null, + }, + ], }, 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: null, - }], + rows: [ + { + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: null, + provider: null, + allowed_origins: null, + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -1169,7 +1228,7 @@ describe('CORS resolution hierarchy', () => { expect.objectContaining({ bucketName: 'docs', allowedOrigins: ['https://plugin-default.example.com'], - }), + }) ); }); @@ -1190,18 +1249,20 @@ describe('CORS resolution hierarchy', () => { createBucketProvisionerPlugin(createDefaultOptions()); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid-cdn', - key: 'cdn-public', - type: 'public', - is_public: true, - allowed_origins: ['*'], - }], + app_public: { + rows: [ + { + id: 'bucket-uuid-cdn', + key: 'cdn-public', + type: 'public', + is_public: true, + allowed_origins: ['*'], + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -1213,7 +1274,7 @@ describe('CORS resolution hierarchy', () => { expect(mockProvision).toHaveBeenCalledWith( expect.objectContaining({ allowedOrigins: ['*'], - }), + }) ); }); }); @@ -1253,17 +1314,19 @@ describe('bucket name resolution', () => { }); const pgClient = createMockPgClient({ - 'app_public': { - rows: [{ - id: 'bucket-uuid', - key: 'private', - type: 'private', - is_public: false, - }], + app_public: { + rows: [ + { + id: 'bucket-uuid', + key: 'private', + type: 'private', + is_public: false, + }, + ], }, }); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -1273,7 +1336,7 @@ describe('bucket name resolution', () => { }); expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'private' }), + expect.objectContaining({ bucketName: 'private' }) ); }); @@ -1292,7 +1355,7 @@ describe('bucket name resolution', () => { }); const customResolver = jest.fn( - (bucketKey: string) => `custom-${bucketKey}`, + (bucketKey: string) => `custom-${bucketKey}` ); createBucketProvisionerPlugin({ @@ -1310,7 +1373,7 @@ describe('bucket name resolution', () => { const pgClient = createMockPgClient(); const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), + callback(pgClient) ); await capturedLambdaCallback!({ @@ -1321,7 +1384,7 @@ describe('bucket name resolution', () => { expect(customResolver).toHaveBeenCalled(); expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'custom-public' }), + expect.objectContaining({ bucketName: 'custom-public' }) ); }); }); diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts index 74eeedff40..7d24bc24e5 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts @@ -36,10 +36,11 @@ import type { GraphileConfig } from 'graphile-config'; import { extendSchema, gql } from 'graphile-utils'; import { Logger } from '@pgpmjs/logger'; import { QuoteUtils } from '@pgsql/quotes'; -import { - BucketProvisioner, +import { BucketProvisioner } from '@constructive-io/bucket-provisioner'; +import type { + StorageConnectionConfig, + ProvisionResult, } from '@constructive-io/bucket-provisioner'; -import type { StorageConnectionConfig, ProvisionResult } from '@constructive-io/bucket-provisioner'; import type { BucketProvisionerPluginOptions, @@ -118,7 +119,7 @@ interface StorageModuleRow { async function resolveStorageModule( pgClient: any, databaseId: string, - ownerId?: string, + ownerId?: string ): Promise { if (!ownerId) { // App-level resolution @@ -129,13 +130,18 @@ async function resolveStorageModule( // Entity-scoped: load all modules and probe entity tables const result = await pgClient.query(ALL_STORAGE_MODULES_QUERY, [databaseId]); const modules = result.rows as StorageModuleRow[]; - const entityModules = modules.filter((m) => m.entity_schema && m.entity_table); + const entityModules = modules.filter( + (m) => m.entity_schema && m.entity_table + ); for (const mod of entityModules) { - const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema!, mod.entity_table!); + const entityTable = QuoteUtils.quoteQualifiedIdentifier( + mod.entity_schema!, + mod.entity_table! + ); const probe = await pgClient.query( `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, - [ownerId], + [ownerId] ); if (probe.rows.length > 0) { return mod; @@ -156,28 +162,35 @@ interface BucketRow { // --- Helpers --- /** - * Resolve the connection config from the options. If the option is a lazy - * getter function, call it (and cache the result). + * Resolve the connection config from the options. Runtime-aware getters own + * their scope-local caching; the plugin must not mutate a shared preset. */ function resolveConnection( - options: BucketProvisionerPluginOptions, + options: BucketProvisionerPluginOptions ): StorageConnectionConfig { if (typeof options.connection === 'function') { - const resolved = options.connection(); - // Cache so subsequent calls don't re-evaluate - options.connection = resolved; - return resolved; + return options.connection(); } return options.connection; } +function resolvePluginAllowedOrigins( + options: BucketProvisionerPluginOptions +): string[] { + const origins = + typeof options.allowedOrigins === 'function' + ? options.allowedOrigins() + : options.allowedOrigins; + return [...origins]; +} + /** * Resolve the S3 bucket name from a logical bucket key. */ function resolveBucketName( bucketKey: string, databaseId: string, - options: BucketProvisionerPluginOptions, + options: BucketProvisionerPluginOptions ): string { if (options.resolveBucketName) { return options.resolveBucketName(bucketKey, databaseId); @@ -193,7 +206,7 @@ function resolveBucketName( */ async function resolveDatabaseId(pgClient: any): Promise { const result = await pgClient.query( - `SELECT jwt_private.current_database_id() AS id`, + `SELECT jwt_private.current_database_id() AS id` ); return result.rows[0]?.id ?? null; } @@ -207,7 +220,7 @@ async function resolveDatabaseId(pgClient: any): Promise { function resolveAllowedOrigins( bucketOrigins: string[] | null | undefined, storageModuleOrigins: string[] | null | undefined, - pluginOrigins: string[], + pluginOrigins: string[] ): string[] { if (bucketOrigins && bucketOrigins.length > 0) { return bucketOrigins; @@ -224,14 +237,17 @@ function resolveAllowedOrigins( function buildProvisioner( options: BucketProvisionerPluginOptions, storageModule: StorageModuleRow | null, - effectiveOrigins: string[], + effectiveOrigins: string[] ): BucketProvisioner { const connection = resolveConnection(options); const effectiveConnection: StorageConnectionConfig = { ...connection, ...(storageModule?.endpoint ? { endpoint: storageModule.endpoint } : {}), ...(storageModule?.provider - ? { provider: storageModule.provider as StorageConnectionConfig['provider'] } + ? { + provider: + storageModule.provider as StorageConnectionConfig['provider'], + } : {}), }; @@ -251,7 +267,7 @@ async function provisionBucketForRow( bucketKey: string, bucketType: string, bucketAllowedOrigins: string[] | null | undefined, - options: BucketProvisionerPluginOptions, + options: BucketProvisionerPluginOptions ): Promise { const s3BucketName = resolveBucketName(bucketKey, databaseId, options); const accessType = bucketType as 'public' | 'private' | 'temp'; @@ -263,30 +279,38 @@ async function provisionBucketForRow( const effectiveOrigins = resolveAllowedOrigins( bucketAllowedOrigins, storageModule?.allowed_origins, - options.allowedOrigins, + resolvePluginAllowedOrigins(options) ); - const provisioner = buildProvisioner(options, storageModule, effectiveOrigins); + const provisioner = buildProvisioner( + options, + storageModule, + effectiveOrigins + ); log.info( `Provisioning S3 bucket "${s3BucketName}" (key="${bucketKey}", type="${accessType}", ` + - `origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`, + `origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}` ); - const result = await provisioner.provision({ - bucketName: s3BucketName, - accessType, - versioning: options.versioning ?? false, - publicUrlPrefix: storageModule?.public_url_prefix ?? undefined, - allowedOrigins: effectiveOrigins, - }); - - log.info( - `Successfully provisioned S3 bucket "${s3BucketName}" ` + - `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`, - ); + try { + const result = await provisioner.provision({ + bucketName: s3BucketName, + accessType, + versioning: options.versioning ?? false, + publicUrlPrefix: storageModule?.public_url_prefix ?? undefined, + allowedOrigins: effectiveOrigins, + }); + + log.info( + `Successfully provisioned S3 bucket "${s3BucketName}" ` + + `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})` + ); - return result; + return result; + } finally { + provisioner.getClient().destroy(); + } } /** @@ -298,7 +322,7 @@ async function updateBucketCors( bucketKey: string, bucketType: string, bucketAllowedOrigins: string[] | null | undefined, - options: BucketProvisionerPluginOptions, + options: BucketProvisionerPluginOptions ): Promise { const s3BucketName = resolveBucketName(bucketKey, databaseId, options); const accessType = bucketType as 'public' | 'private' | 'temp'; @@ -308,23 +332,31 @@ async function updateBucketCors( const effectiveOrigins = resolveAllowedOrigins( bucketAllowedOrigins, storageModule?.allowed_origins, - options.allowedOrigins, + resolvePluginAllowedOrigins(options) ); - const provisioner = buildProvisioner(options, storageModule, effectiveOrigins); + const provisioner = buildProvisioner( + options, + storageModule, + effectiveOrigins + ); log.info( `Updating CORS on S3 bucket "${s3BucketName}" ` + - `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`, + `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}` ); - await provisioner.updateCors({ - bucketName: s3BucketName, - accessType, - allowedOrigins: effectiveOrigins, - }); + try { + await provisioner.updateCors({ + bucketName: s3BucketName, + accessType, + allowedOrigins: effectiveOrigins, + }); - log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`); + log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`); + } finally { + provisioner.getClient().destroy(); + } } // --- Plugin factory --- @@ -345,7 +377,7 @@ async function updateBucketCors( * @param options - Plugin configuration (S3 credentials, CORS origins, naming) */ export function createBucketProvisionerPlugin( - options: BucketProvisionerPluginOptions, + options: BucketProvisionerPluginOptions ): GraphileConfig.Plugin { const autoProvision = options.autoProvision ?? true; @@ -353,7 +385,9 @@ export function createBucketProvisionerPlugin( const mutationPlugin = extendSchema(() => ({ typeDefs: gql` input ProvisionBucketInput { - """The logical bucket key (e.g., "public", "private")""" + """ + The logical bucket key (e.g., "public", "private") + """ bucketKey: String! """ Owner entity ID for entity-scoped bucket provisioning. @@ -363,17 +397,29 @@ export function createBucketProvisionerPlugin( } type ProvisionBucketPayload { - """Whether provisioning succeeded""" + """ + Whether provisioning succeeded + """ success: Boolean! - """The S3 bucket name that was provisioned""" + """ + The S3 bucket name that was provisioned + """ bucketName: String! - """The access type applied""" + """ + The access type applied + """ accessType: String! - """The storage provider used""" + """ + The storage provider used + """ provider: String! - """The S3 endpoint (null for AWS S3 default)""" + """ + The S3 endpoint (null for AWS S3 default) + """ endpoint: String - """Error message if provisioning failed""" + """ + Error message if provisioning failed + """ error: String } @@ -384,9 +430,7 @@ export function createBucketProvisionerPlugin( the S3 bucket with the appropriate privacy policies, CORS rules, and lifecycle settings. """ - provisionBucket( - input: ProvisionBucketInput! - ): ProvisionBucketPayload + provisionBucket(input: ProvisionBucketInput!): ProvisionBucketPayload } `, plans: { @@ -401,83 +445,99 @@ export function createBucketProvisionerPlugin( pgSettings: $pgSettings, }); - return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => { - const { bucketKey, ownerId } = input; + return lambda( + $combined, + async ({ input, withPgClient, pgSettings }: any) => { + const { bucketKey, ownerId } = input; - if (!bucketKey || typeof bucketKey !== 'string') { - throw new Error('INVALID_BUCKET_KEY'); - } - - return withPgClient(pgSettings, async (pgClient: any) => { - // Resolve database ID from JWT context - const databaseId = await resolveDatabaseId(pgClient); - if (!databaseId) { - throw new Error('DATABASE_NOT_FOUND'); + if (!bucketKey || typeof bucketKey !== 'string') { + throw new Error('INVALID_BUCKET_KEY'); } - // Resolve storage module (app-level or entity-scoped via ownerId) - const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId); - if (!storageModule) { - throw new Error( + return withPgClient(pgSettings, async (pgClient: any) => { + // Resolve database ID from JWT context + const databaseId = await resolveDatabaseId(pgClient); + if (!databaseId) { + throw new Error('DATABASE_NOT_FOUND'); + } + + // Resolve storage module (app-level or entity-scoped via ownerId) + const storageModule = await resolveStorageModule( + pgClient, + databaseId, ownerId - ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId' - : 'STORAGE_MODULE_NOT_PROVISIONED', ); - } + if (!storageModule) { + throw new Error( + ownerId + ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId' + : 'STORAGE_MODULE_NOT_PROVISIONED' + ); + } - // Look up the bucket row (RLS enforced via pgSettings) - const hasOwner = ownerId && storageModule.scope !== 'app'; - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); - const bucketResult = await pgClient.query( - hasOwner - ? `SELECT id, key, type, is_public, allowed_origins + // Look up the bucket row (RLS enforced via pgSettings) + const hasOwner = ownerId && storageModule.scope !== 'app'; + const bucketsTable = QuoteUtils.quoteQualifiedIdentifier( + storageModule.buckets_schema, + storageModule.buckets_table + ); + const bucketResult = await pgClient.query( + hasOwner + ? `SELECT id, key, type, is_public, allowed_origins FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 LIMIT 1` - : `SELECT id, key, type, is_public, allowed_origins + : `SELECT id, key, type, is_public, allowed_origins FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, - hasOwner ? [bucketKey, ownerId] : [bucketKey], - ); - - if (bucketResult.rows.length === 0) { - throw new Error('BUCKET_NOT_FOUND'); - } - - const bucket = bucketResult.rows[0] as BucketRow; - - try { - const result = await provisionBucketForRow( - pgClient, - databaseId, - bucket.key, - bucket.type, - bucket.allowed_origins, - options, + hasOwner ? [bucketKey, ownerId] : [bucketKey] ); - return { - success: true, - bucketName: result.bucketName, - accessType: result.accessType, - provider: result.provider, - endpoint: result.endpoint, - error: null, - }; - } catch (err: any) { - log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`); - return { - success: false, - bucketName: resolveBucketName(bucket.key, databaseId, options), - accessType: bucket.type, - provider: resolveConnection(options).provider, - endpoint: resolveConnection(options).endpoint ?? null, - error: err.message, - }; - } - }); - }); + if (bucketResult.rows.length === 0) { + throw new Error('BUCKET_NOT_FOUND'); + } + + const bucket = bucketResult.rows[0] as BucketRow; + + try { + const result = await provisionBucketForRow( + pgClient, + databaseId, + bucket.key, + bucket.type, + bucket.allowed_origins, + options + ); + + return { + success: true, + bucketName: result.bucketName, + accessType: result.accessType, + provider: result.provider, + endpoint: result.endpoint, + error: null, + }; + } catch (err: any) { + log.error( + `Failed to provision bucket "${bucketKey}": ${err.message}` + ); + return { + success: false, + bucketName: resolveBucketName( + bucket.key, + databaseId, + options + ), + accessType: bucket.type, + provider: resolveConnection(options).provider, + endpoint: resolveConnection(options).endpoint ?? null, + error: err.message, + }; + } + }); + } + ); }, }, }, @@ -497,7 +557,11 @@ export function createBucketProvisionerPlugin( 'Auto-provisions S3 buckets when bucket rows are created, ' + 'updates CORS when allowed_origins changes on update, ' + 'and provides a provisionBucket mutation for explicit provisioning', - after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'], + after: [ + 'PgAttributesPlugin', + 'PgMutationCreatePlugin', + 'PgMutationUpdateDeletePlugin', + ], schema: { ...mutationPlugin.schema, @@ -537,20 +601,32 @@ export function createBucketProvisionerPlugin( return field; } - log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`); + log.debug( + `Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})` + ); const defaultResolver = (obj: any) => obj[fieldName]; const { resolve: oldResolve = defaultResolver, ...rest } = field; return { ...rest, - async resolve(source: any, args: any, graphqlContext: any, info: any) { + async resolve( + source: any, + args: any, + graphqlContext: any, + info: any + ) { // Call the original resolver first (creates/updates the DB row) - const result = await oldResolve(source, args, graphqlContext, info); + const result = await oldResolve( + source, + args, + graphqlContext, + info + ); try { const inputKey = Object.keys(args.input || {}).find( - (k) => k !== 'clientMutationId', + (k) => k !== 'clientMutationId' ); const bucketInput = inputKey ? args.input[inputKey] : null; @@ -558,7 +634,9 @@ export function createBucketProvisionerPlugin( const pgSettings = graphqlContext.pgSettings; if (!withPgClient) { - log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`); + log.warn( + `${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context` + ); return result; } @@ -567,7 +645,7 @@ export function createBucketProvisionerPlugin( if (!bucketInput?.key || !bucketInput?.type) { log.warn( `Auto-provision skipped for "${fieldName}": ` + - `could not extract key/type from mutation input`, + `could not extract key/type from mutation input` ); return result; } @@ -575,7 +653,9 @@ export function createBucketProvisionerPlugin( await withPgClient(pgSettings, async (pgClient: any) => { const databaseId = await resolveDatabaseId(pgClient); if (!databaseId) { - log.warn('Auto-provision skipped: could not resolve database_id'); + log.warn( + 'Auto-provision skipped: could not resolve database_id' + ); return; } @@ -584,14 +664,18 @@ export function createBucketProvisionerPlugin( databaseId, bucketInput.key, bucketInput.type, - bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, - options, + bucketInput.allowedOrigins ?? + bucketInput.allowed_origins ?? + null, + options ); }); } else { // --- UPDATE: re-apply CORS if allowed_origins is in the patch --- - const hasOriginsUpdate = bucketInput && - ('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput); + const hasOriginsUpdate = + bucketInput && + ('allowedOrigins' in bucketInput || + 'allowed_origins' in bucketInput); if (!hasOriginsUpdate) { // allowed_origins not being changed, nothing to do @@ -601,14 +685,21 @@ export function createBucketProvisionerPlugin( await withPgClient(pgSettings, async (pgClient: any) => { const databaseId = await resolveDatabaseId(pgClient); if (!databaseId) { - log.warn('CORS update skipped: could not resolve database_id'); + log.warn( + 'CORS update skipped: could not resolve database_id' + ); return; } // Read the storage module config (app-level; auto-hook doesn't have ownerId context) - const storageModule = await resolveStorageModule(pgClient, databaseId); + const storageModule = await resolveStorageModule( + pgClient, + databaseId + ); if (!storageModule) { - log.warn('CORS update skipped: storage module not provisioned'); + log.warn( + 'CORS update skipped: storage module not provisioned' + ); return; } @@ -619,23 +710,28 @@ export function createBucketProvisionerPlugin( if (!patchKey) { log.warn( `CORS update skipped for "${fieldName}": ` + - `could not determine bucket key from mutation input`, + `could not determine bucket key from mutation input` ); return; } // Read the full bucket row (post-update) to get type + origins - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); + const bucketsTable = QuoteUtils.quoteQualifiedIdentifier( + storageModule.buckets_schema, + storageModule.buckets_table + ); const bucketResult = await pgClient.query( `SELECT id, key, type, is_public, allowed_origins FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, - [patchKey], + [patchKey] ); if (bucketResult.rows.length === 0) { - log.warn(`CORS update skipped: bucket "${patchKey}" not found`); + log.warn( + `CORS update skipped: bucket "${patchKey}" not found` + ); return; } @@ -647,16 +743,16 @@ export function createBucketProvisionerPlugin( bucket.key, bucket.type, bucket.allowed_origins, - options, + options ); }); } } catch (err: any) { log.error( `${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` + - (isCreate - ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.` - : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`), + (isCreate + ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.` + : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`) ); } diff --git a/graphile/graphile-bucket-provisioner-plugin/src/types.ts b/graphile/graphile-bucket-provisioner-plugin/src/types.ts index aa2f6a61a1..702ee8e77e 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/types.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/types.ts @@ -14,7 +14,12 @@ import type { } from '@constructive-io/bucket-provisioner'; // Re-export types that consumers will need -export type { StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult }; +export type { + StorageConnectionConfig, + StorageProvider, + BucketAccessType, + ProvisionResult, +}; /** * S3 connection configuration or a lazy getter that returns it on first use. @@ -27,6 +32,9 @@ export type ConnectionConfigOrGetter = | StorageConnectionConfig | (() => StorageConnectionConfig); +/** CORS origins or a runtime-scoped getter for them. */ +export type AllowedOriginsOrGetter = string[] | (() => string[]); + /** * Function to derive the actual S3 bucket name from a logical bucket key. * @@ -34,7 +42,10 @@ export type ConnectionConfigOrGetter = * @param databaseId - The metaschema database UUID * @returns The S3 bucket name to create/configure */ -export type BucketNameResolver = (bucketKey: string, databaseId: string) => string; +export type BucketNameResolver = ( + bucketKey: string, + databaseId: string +) => string; /** * Plugin options for the bucket provisioner plugin. @@ -51,7 +62,7 @@ export interface BucketProvisionerPluginOptions { * These are the domains where your app runs (e.g., ["https://app.example.com"]). * Required for browser-based presigned URL uploads. */ - allowedOrigins: string[]; + allowedOrigins: AllowedOriginsOrGetter; /** * Optional prefix for S3 bucket names. diff --git a/graphile/graphile-cache/package.json b/graphile/graphile-cache/package.json index 2c5bee3483..250741bb51 100644 --- a/graphile/graphile-cache/package.json +++ b/graphile/graphile-cache/package.json @@ -34,7 +34,7 @@ "express": "^5.2.1", "grafserv": "1.0.0", "graphile-realtime-subscriptions": "workspace:^", - "lru-cache": "^11.2.7", + "lru-cache": "^10.4.3", "pg-cache": "workspace:^", "postgraphile": "5.0.3" }, diff --git a/graphile/graphile-cache/src/__tests__/ownership.test.ts b/graphile/graphile-cache/src/__tests__/ownership.test.ts new file mode 100644 index 0000000000..60fcfbfa4f --- /dev/null +++ b/graphile/graphile-cache/src/__tests__/ownership.test.ts @@ -0,0 +1,114 @@ +import type { Express } from 'express'; +import type { GrafservBase } from 'grafserv'; +import type { Server as HttpServer } from 'node:http'; +import type { Pool } from 'pg'; +import type { PostGraphileInstance } from 'postgraphile'; +import { PgPoolCacheManager } from 'pg-cache'; + +import { + type GraphileCacheEntry, + GraphileCacheManager, +} from '../graphile-cache'; + +const createEntry = ( + cacheKey: string, + release: () => Promise = async () => {}, + pgPoolKey?: string +): GraphileCacheEntry => + ({ + cacheKey, + pgPoolKey, + createdAt: Date.now(), + pgl: { release } as unknown as PostGraphileInstance, + serv: {} as GrafservBase, + handler: {} as Express, + httpServer: { listening: false } as HttpServer, + realtimeManager: null, + }) as GraphileCacheEntry; + +const createPool = (): Pool => { + let ended = false; + return { + get ended() { + return ended; + }, + end: jest.fn(async () => { + ended = true; + }), + } as unknown as Pool; +}; + +describe('GraphileCacheManager ownership', () => { + it('closing one cache does not release entries owned by another cache', async () => { + const firstPg = new PgPoolCacheManager(undefined, {}); + const secondPg = new PgPoolCacheManager(undefined, {}); + const first = new GraphileCacheManager({ pgCache: firstPg }); + const second = new GraphileCacheManager({ pgCache: secondPg }); + const releaseFirst = jest.fn(async () => {}); + const releaseSecond = jest.fn(async () => {}); + + first.set('shared-key', createEntry('shared-key', releaseFirst)); + second.set('shared-key', createEntry('shared-key', releaseSecond)); + + await first.close(); + + expect(releaseFirst).toHaveBeenCalledTimes(1); + expect(releaseSecond).not.toHaveBeenCalled(); + expect(second.has('shared-key')).toBe(true); + + await second.close(); + await Promise.all([firstPg.close(), secondPg.close()]); + }); + + it('releases a Graphile instance before its backing pool ends', async () => { + const pgCache = new PgPoolCacheManager(undefined, {}); + const cache = new GraphileCacheManager({ pgCache }); + const pool = createPool(); + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + const release = jest.fn(() => releaseGate); + + pgCache.set('pool-a', pool); + cache.set('api-a', createEntry('api-a', release, 'pool-a')); + + const closePromise = pgCache.close(); + await Promise.resolve(); + await Promise.resolve(); + + expect(release).toHaveBeenCalledTimes(1); + expect(pool.end).not.toHaveBeenCalled(); + + finishRelease(); + await closePromise; + + expect(pool.end).toHaveBeenCalledTimes(1); + expect(cache.has('api-a')).toBe(false); + }); + + it('tracks asynchronous LRU disposal until release completes', async () => { + const pgCache = new PgPoolCacheManager(undefined, {}); + const cache = new GraphileCacheManager({ + pgCache, + config: { max: 1, ttl: 60_000 }, + }); + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + const release = jest.fn(() => releaseGate); + + cache.set('first', createEntry('first', release)); + cache.set('second', createEntry('second')); + + const disposalPromise = cache.waitForDisposals(); + await Promise.resolve(); + expect(release).toHaveBeenCalledTimes(1); + + finishRelease(); + await disposalPromise; + await cache.close(); + await pgCache.close(); + }); +}); diff --git a/graphile/graphile-cache/src/create-instance.ts b/graphile/graphile-cache/src/create-instance.ts index fc4c625ae2..0acce10569 100644 --- a/graphile/graphile-cache/src/create-instance.ts +++ b/graphile/graphile-cache/src/create-instance.ts @@ -10,6 +10,8 @@ const log = new Logger('graphile-cache:create'); interface GraphileInstanceOptions { preset: any; cacheKey: string; + /** Exact key used by the owning pg-cache manager. */ + pgPoolKey?: string; /** * When true, a RealtimeManager is created and started alongside the * PostGraphile instance. The pool is extracted from the preset's @@ -37,7 +39,7 @@ interface GraphileInstanceOptions { export const createGraphileInstance = async ( opts: GraphileInstanceOptions ): Promise => { - const { preset, cacheKey, enableRealtime = false } = opts; + const { preset, cacheKey, pgPoolKey, enableRealtime = false } = opts; const pgl = postgraphile(preset); const serv = pgl.createServ(grafserv); @@ -53,12 +55,14 @@ export const createGraphileInstance = async ( handler, httpServer, cacheKey, + ...(pgPoolKey ? { pgPoolKey } : {}), createdAt: Date.now(), }; if (enableRealtime) { try { - const { RealtimeManager } = await import('graphile-realtime-subscriptions'); + const { RealtimeManager } = + await import('graphile-realtime-subscriptions'); // Extract PgSubscriber and pool from the resolved preset's pgServices. // The pool is the same instance managed by pg-cache (via getPgPool) @@ -69,9 +73,13 @@ export const createGraphileInstance = async ( const pool = pgService?.adaptorSettings?.pool ?? null; if (!pgSubscriber) { - log.warn(`PostGraphile[${cacheKey}] has no pgSubscriber — RealtimeManager will not be started`); + log.warn( + `PostGraphile[${cacheKey}] has no pgSubscriber — RealtimeManager will not be started` + ); } else if (!pool) { - log.warn(`PostGraphile[${cacheKey}] has no pool in pgService — RealtimeManager will not be started`); + log.warn( + `PostGraphile[${cacheKey}] has no pool in pgService — RealtimeManager will not be started` + ); } else { const manager = new RealtimeManager({ pgSubscriber, @@ -85,7 +93,10 @@ export const createGraphileInstance = async ( log.info(`RealtimeManager started for PostGraphile[${cacheKey}]`); } } catch (err) { - log.error(`Failed to start RealtimeManager for PostGraphile[${cacheKey}]:`, err); + log.error( + `Failed to start RealtimeManager for PostGraphile[${cacheKey}]:`, + err + ); } } diff --git a/graphile/graphile-cache/src/graphile-cache.ts b/graphile/graphile-cache/src/graphile-cache.ts index ae016550fb..bc6c645e33 100644 --- a/graphile/graphile-cache/src/graphile-cache.ts +++ b/graphile/graphile-cache/src/graphile-cache.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'events'; import { parseEnvNumber } from '12factor-env'; import { Logger } from '@pgpmjs/logger'; import { LRUCache } from 'lru-cache'; -import { pgCache } from 'pg-cache'; +import { pgCache, PgPoolCacheManager } from 'pg-cache'; import type { Express } from 'express'; import type { Server as HttpServer } from 'http'; import type { PostGraphileInstance } from 'postgraphile'; @@ -56,13 +56,15 @@ export interface CacheConfig { * NOTE: This value should be <= PG_CACHE_MAX (also default: 50) so that * every cached PostGraphile instance has a live pool backing it. */ -export function getCacheConfig(): CacheConfig { - const isDevelopment = process.env.NODE_ENV === 'development'; +export function getCacheConfig( + environment: Readonly> = process.env +): CacheConfig { + const isDevelopment = environment.NODE_ENV === 'development'; - const max = parseEnvNumber(process.env.GRAPHILE_CACHE_MAX) ?? 50; + const max = parseEnvNumber(environment.GRAPHILE_CACHE_MAX) ?? 50; const ttl = - parseEnvNumber(process.env.GRAPHILE_CACHE_TTL_MS) ?? + parseEnvNumber(environment.GRAPHILE_CACHE_TTL_MS) ?? (isDevelopment ? FIVE_MINUTES_MS : ONE_YEAR); return { max, ttl }; @@ -85,34 +87,17 @@ export interface GraphileCacheEntry { handler: Express; httpServer: HttpServer; cacheKey: string; + /** Exact pg-cache key backing this instance. */ + pgPoolKey?: string; createdAt: number; /** Optional RealtimeManager for cursor-tracked subscription delivery */ realtimeManager?: { stop(): Promise } | null; } -// Track disposed entries to prevent double-disposal -const disposedKeys = new Set(); - -// Track keys that are being manually evicted for accurate eviction reason -const manualEvictionKeys = new Set(); - -/** - * Dispose a PostGraphile v5 cache entry - * - * Properly releases resources by: - * 1. Closing the HTTP server if listening - * 2. Releasing the PostGraphile instance (which internally releases grafserv) - * - * Uses disposedKeys set to prevent double-disposal when closeAllCaches() - * explicitly disposes entries and then clear() triggers the dispose callback. - */ -const disposeEntry = async (entry: GraphileCacheEntry, key: string): Promise => { - // Prevent double-disposal - if (disposedKeys.has(key)) { - return; - } - disposedKeys.add(key); - +const disposeEntry = async ( + entry: GraphileCacheEntry, + key: string +): Promise => { log.debug(`Disposing PostGraphile[${key}]`); try { // Close HTTP server if it's listening @@ -126,7 +111,10 @@ const disposeEntry = async (entry: GraphileCacheEntry, key: string): Promise { - if (manualEvictionKeys.has(key)) { - manualEvictionKeys.delete(key); - return 'manual'; +export interface GraphileCacheManagerOptions { + config?: Partial; + environment?: Readonly>; + pgCache?: PgPoolCacheManager; + events?: CacheEventEmitter; +} + +/** An owned PostGraphile cache whose teardown cannot affect another server. */ +export class GraphileCacheManager { + private readonly cache: LRUCache; + private readonly manualEvictionKeys = new Set(); + private readonly disposalPromises = new Map< + GraphileCacheEntry, + Promise + >(); + private closePromise: Promise | null = null; + private readonly pgPoolCache: PgPoolCacheManager; + readonly events: CacheEventEmitter; + readonly config: CacheConfig; + + constructor(options: GraphileCacheManagerOptions = {}) { + this.config = { + ...getCacheConfig(options.environment), + ...options.config, + }; + this.pgPoolCache = options.pgCache ?? pgCache; + this.events = options.events ?? new CacheEventEmitter(); + this.cache = new LRUCache({ + max: this.config.max, + ttl: this.config.ttl, + updateAgeOnGet: true, + dispose: (entry, key) => { + const reason = this.getEvictionReason(key, entry); + this.events.emitEviction({ key, reason, entry }); + log.debug(`Evicting PostGraphile[${key}] (reason: ${reason})`); + this.scheduleDisposal(entry, key); + }, + }); + + this.pgPoolCache.registerCleanupCallback(async (pgPoolKey) => { + const keys = [...this.cache.entries()] + .filter(([, entry]) => entry.pgPoolKey === pgPoolKey) + .map(([key]) => key); + for (const key of keys) this.delete(key); + await this.waitForDisposals(); + }); } - // Check if TTL expired - const age = Date.now() - entry.createdAt; - const config = getCacheConfig(); - if (age >= config.ttl) { - return 'ttl'; + get size(): number { + return this.cache.size; } - return 'lru'; -}; + get max(): number { + return this.cache.max; + } + + get(key: string): GraphileCacheEntry | undefined { + return this.cache.get(key); + } + + has(key: string): boolean { + return this.cache.has(key); + } -// Get initial cache configuration -const initialConfig = getCacheConfig(); + set(key: string, entry: GraphileCacheEntry): this { + this.cache.set(key, entry); + return this; + } -// --- Graphile Cache --- -export const graphileCache = new LRUCache({ - max: initialConfig.max, - ttl: initialConfig.ttl, - updateAgeOnGet: true, - dispose: (entry, key) => { - // Determine eviction reason before disposal - const reason = getEvictionReason(key, entry); + delete(key: string): boolean { + if (!this.cache.has(key)) return false; + this.manualEvictionKeys.add(key); + return this.cache.delete(key); + } - // Emit eviction event - cacheEvents.emitEviction({ key, reason, entry }); + clear(): void { + for (const key of this.cache.keys()) this.manualEvictionKeys.add(key); + this.cache.clear(); + } - log.debug(`Evicting PostGraphile[${key}] (reason: ${reason})`); + entries(): IterableIterator<[string, GraphileCacheEntry]> { + return this.cache.entries(); + } - // LRU dispose is synchronous, but v5 disposal is async - // Fire and forget the async cleanup - disposeEntry(entry, key).catch((err) => { - log.error(`Failed to dispose PostGraphile[${key}]:`, err); - }); + keys(): IterableIterator { + return this.cache.keys(); + } + + forEach(callback: (entry: GraphileCacheEntry, key: string) => void): void { + this.cache.forEach(callback); } + + getRemainingTTL(key: string): number { + return this.cache.getRemainingTTL(key); + } + + async close(options: { closePools?: boolean } = {}): Promise { + if (this.closePromise) return this.closePromise; + this.closePromise = (async () => { + try { + this.clear(); + await this.waitForDisposals(); + if (options.closePools) await this.pgPoolCache.close(); + } finally { + this.closePromise = null; + } + })(); + return this.closePromise; + } + + async closeMatching( + predicate: (key: string, entry: GraphileCacheEntry) => boolean + ): Promise { + const entries = [...this.cache.entries()].filter(([key, entry]) => + predicate(key, entry) + ); + for (const [key] of entries) this.delete(key); + await this.waitForDisposals(); + return entries.length; + } + + async waitForDisposals(): Promise { + while (this.disposalPromises.size > 0) { + await Promise.allSettled([...this.disposalPromises.values()]); + } + } + + private getEvictionReason( + key: string, + entry: GraphileCacheEntry + ): EvictionReason { + if (this.manualEvictionKeys.delete(key)) return 'manual'; + return Date.now() - entry.createdAt >= this.config.ttl ? 'ttl' : 'lru'; + } + + private scheduleDisposal(entry: GraphileCacheEntry, key: string): void { + if (this.disposalPromises.has(entry)) return; + const promise = disposeEntry(entry, key); + this.disposalPromises.set(entry, promise); + void promise.catch((): void => {}); + void promise.then( + (): void => { + this.disposalPromises.delete(entry); + }, + (): void => { + this.disposalPromises.delete(entry); + } + ); + } +} + +export const graphileCache = new GraphileCacheManager({ + pgCache, + events: cacheEvents, }); // --- Cache Stats --- @@ -195,13 +293,14 @@ export interface CacheStats { /** * Get current cache statistics */ -export function getCacheStats(): CacheStats { - const config = getCacheConfig(); +export function getCacheStats( + cache: GraphileCacheManager = graphileCache +): CacheStats { return { - size: graphileCache.size, - max: config.max, - ttl: config.ttl, - keys: [...graphileCache.keys()] + size: cache.size, + max: cache.max, + ttl: cache.config.ttl, + keys: [...cache.keys()], }; } @@ -212,14 +311,15 @@ export function getCacheStats(): CacheStats { * @param pattern - RegExp to match against cache keys * @returns Number of entries cleared */ -export function clearMatchingEntries(pattern: RegExp): number { +export function clearMatchingEntries( + pattern: RegExp, + cache: GraphileCacheManager = graphileCache +): number { let cleared = 0; - for (const key of graphileCache.keys()) { + for (const key of cache.keys()) { if (pattern.test(key)) { - // Mark as manual eviction before deleting - manualEvictionKeys.add(key); - graphileCache.delete(key); + cache.delete(key); cleared++; } } @@ -227,22 +327,6 @@ export function clearMatchingEntries(pattern: RegExp): number { return cleared; } -// Register cleanup callback with pgCache -// When a pg pool is disposed, clean up any graphile instances using it -const unregister = pgCache.registerCleanupCallback((pgPoolKey: string) => { - log.debug(`pgPool[${pgPoolKey}] disposed - checking graphile entries`); - - // Remove graphile entries that reference this pool key - graphileCache.forEach((entry, k) => { - if (entry.cacheKey.includes(pgPoolKey)) { - log.debug(`Removing graphileCache[${k}] due to pgPool[${pgPoolKey}] disposal`); - manualEvictionKeys.add(k); - graphileCache.delete(k); - } - }); -}); - -// Enhanced close function that handles all caches const closePromise: { promise: Promise | null } = { promise: null }; /** @@ -263,30 +347,7 @@ export const closeAllCaches = async (verbose = false): Promise => { try { if (verbose) log.info('Closing all server caches...'); - // Collect all entries and dispose them properly - const entries = [...graphileCache.entries()]; - - // Mark all as manual evictions - for (const [key] of entries) { - manualEvictionKeys.add(key); - } - - const disposePromises = entries.map(([key, entry]) => - disposeEntry(entry, key) - ); - - // Wait for all disposals to complete - await Promise.allSettled(disposePromises); - - // Clear the cache after disposal (dispose callback will no-op due to disposedKeys) - graphileCache.clear(); - - // Clear disposed keys tracking after full cleanup - disposedKeys.clear(); - manualEvictionKeys.clear(); - - // Close pg pools - await pgCache.close(); + await graphileCache.close({ closePools: true }); if (verbose) log.success('All caches disposed.'); } finally { diff --git a/graphile/graphile-cache/src/index.ts b/graphile/graphile-cache/src/index.ts index e64be532b9..3690424456 100644 --- a/graphile/graphile-cache/src/index.ts +++ b/graphile/graphile-cache/src/index.ts @@ -2,6 +2,8 @@ export { // Cache instance and entry type graphileCache, + GraphileCacheManager, + GraphileCacheManagerOptions, GraphileCacheEntry, closeAllCaches, @@ -26,11 +28,14 @@ export { getCacheStats, // Clear matching entries - clearMatchingEntries + clearMatchingEntries, } from './graphile-cache'; // Factory for creating PostGraphile v5 instances export { createGraphileInstance } from './create-instance'; // Generic module config cache for plugin lookups -export { ModuleConfigCache, ModuleConfigCacheOptions } from './module-config-cache'; +export { + ModuleConfigCache, + ModuleConfigCacheOptions, +} from './module-config-cache'; diff --git a/graphile/graphile-presigned-url-plugin/__tests__/cache-scope.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/cache-scope.test.ts new file mode 100644 index 0000000000..66190a3d8a --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/__tests__/cache-scope.test.ts @@ -0,0 +1,69 @@ +import { + getStorageModuleConfig, + isS3BucketProvisioned, + markS3BucketProvisioned, + StorageModuleCacheScope, +} from '../src/storage-module-cache'; + +const storageRow = (id: string, table: string): Record => ({ + id, + scope: 'app', + entity_table_id: null, + buckets_schema: 'storage_public', + buckets_table: `${table}_buckets`, + files_schema: 'storage_public', + files_table: `${table}_files`, + endpoint: null, + public_url_prefix: null, + provider: null, + allowed_origins: null, + upload_url_expiry_seconds: null, + download_url_expiry_seconds: null, + default_max_file_size: null, + max_filename_length: null, + cache_ttl_seconds: null, + max_bulk_files: null, + max_bulk_total_size: null, + has_path_shares: false, + entity_schema: null, + entity_table: null, +}); + +describe('StorageModuleCacheScope', () => { + it('does not collide when two Graphile instances use the same database id', async () => { + const first = new StorageModuleCacheScope(); + const second = new StorageModuleCacheScope(); + const firstClient = { + query: jest.fn(async () => ({ rows: [storageRow('first', 'first')] })), + }; + const secondClient = { + query: jest.fn(async () => ({ rows: [storageRow('second', 'second')] })), + }; + + const firstConfig = await getStorageModuleConfig( + firstClient, + 'shared-database-id', + first + ); + const secondConfig = await getStorageModuleConfig( + secondClient, + 'shared-database-id', + second + ); + + expect(firstConfig?.id).toBe('first'); + expect(secondConfig?.id).toBe('second'); + expect(firstClient.query).toHaveBeenCalledTimes(1); + expect(secondClient.query).toHaveBeenCalledTimes(1); + }); + + it('scopes lazy S3 provisioning state to its Graphile instance', () => { + const first = new StorageModuleCacheScope(); + const second = new StorageModuleCacheScope(); + + markS3BucketProvisioned('shared-bucket', first); + + expect(isS3BucketProvisioned('shared-bucket', first)).toBe(true); + expect(isS3BucketProvisioned('shared-bucket', second)).toBe(false); + }); +}); diff --git a/graphile/graphile-presigned-url-plugin/package.json b/graphile/graphile-presigned-url-plugin/package.json index 2900431a1a..0883f70c81 100644 --- a/graphile/graphile-presigned-url-plugin/package.json +++ b/graphile/graphile-presigned-url-plugin/package.json @@ -44,7 +44,7 @@ "@aws-sdk/s3-request-presigner": "^3.1052.0", "@pgpmjs/logger": "workspace:^", "@pgsql/quotes": "^17.1.0", - "lru-cache": "^11.2.7" + "lru-cache": "^10.4.3" }, "peerDependencies": { "grafast": "1.0.2", diff --git a/graphile/graphile-presigned-url-plugin/src/download-url-field.ts b/graphile/graphile-presigned-url-plugin/src/download-url-field.ts index e4aa6b76e6..a4d4ac46c4 100644 --- a/graphile/graphile-presigned-url-plugin/src/download-url-field.ts +++ b/graphile/graphile-presigned-url-plugin/src/download-url-field.ts @@ -24,9 +24,17 @@ import 'graphile-build'; import { context as grafastContext, lambda, object } from 'grafast'; import { Logger } from '@pgpmjs/logger'; -import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; +import type { + PresignedUrlPluginOptions, + S3Config, + StorageModuleConfig, +} from './types'; import { generatePresignedGetUrl } from './s3-signer'; -import { loadAllStorageModules, resolveStorageConfigFromCodec } from './storage-module-cache'; +import { + loadAllStorageModules, + resolveStorageConfigFromCodec, + StorageModuleCacheScope, +} from './storage-module-cache'; const log = new Logger('graphile-presigned-url:download-url'); @@ -40,14 +48,13 @@ const log = new Logger('graphile-presigned-url:download-url'); * via the `@storageFiles` smart tag. */ /** - * Resolve the S3 config from the options. If the option is a lazy getter - * function, call it (and cache the result). + * Resolve the S3 config from the options. Runtime-aware getters own their + * scope-local caching; mutating the shared preset would capture the first + * server's credentials for every later server. */ function resolveS3(options: PresignedUrlPluginOptions): S3Config { if (typeof options.s3 === 'function') { - const resolved = options.s3(); - options.s3 = resolved; - return resolved; + return options.s3(); } return options.s3; } @@ -60,15 +67,19 @@ function resolveS3ForDatabase( options: PresignedUrlPluginOptions, storageConfig: StorageModuleConfig, databaseId: string, - bucketKey: string, + bucketKey: string ): S3Config { const globalS3 = resolveS3(options); const bucket = options.resolveBucketName ? options.resolveBucketName(databaseId, bucketKey) : globalS3.bucket; - const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix; + const publicUrlPrefix = + storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix; - if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) { + if ( + bucket === globalS3.bucket && + publicUrlPrefix === globalS3.publicUrlPrefix + ) { return globalS3; } @@ -81,12 +92,13 @@ function resolveS3ForDatabase( export function createDownloadUrlPlugin( options: PresignedUrlPluginOptions, + cacheScope = new StorageModuleCacheScope() ): GraphileConfig.Plugin { - return { name: 'PresignedUrlDownloadPlugin', version: '0.2.0', - description: 'Adds downloadUrl computed field to File types tagged with @storageFiles', + description: + 'Adds downloadUrl computed field to File types tagged with @storageFiles', schema: { hooks: { @@ -106,7 +118,9 @@ export function createDownloadUrlPlugin( return fields; } - log.debug(`Adding downloadUrl field to type: ${pgCodec.name} (has @storageFiles tag)`); + log.debug( + `Adding downloadUrl field to type: ${pgCodec.name} (has @storageFiles tag)` + ); const { graphql: { GraphQLString }, @@ -130,8 +144,12 @@ export function createDownloadUrlPlugin( const $filename = $parent.get('filename'); const $bucketId = $parent.get('bucket_id'); - const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $withPgClient = (grafastContext() as any).get( + 'withPgClient' + ); + const $pgSettings = (grafastContext() as any).get( + 'pgSettings' + ); const $combined = object({ key: $key, @@ -142,69 +160,99 @@ export function createDownloadUrlPlugin( pgSettings: $pgSettings, }); - return lambda($combined, async ({ key, isPublic, filename, bucketId, withPgClient, pgSettings }: any) => { - if (!key) return null; - - let s3ForDb = resolveS3(options); - let downloadUrlExpirySeconds = 3600; - try { - if (withPgClient && pgSettings) { - const databaseId = await withPgClient(pgSettings, async (pgClient: any) => { - const dbResult = await pgClient.query({ - text: `SELECT jwt_private.current_database_id() AS id`, - }); - return dbResult.rows[0]?.id ?? null; - }); - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const config = databaseId - ? resolveStorageConfigFromCodec( - capturedCodec, - await withPgClient(null, (pgClient: any) => loadAllStorageModules(pgClient, databaseId)), - ) - : null; - const resolved = config - ? await withPgClient(pgSettings, async (pgClient: any) => { - // Look up the bucket key for scoped S3 resolution - let bucketKey = 'public'; - if (bucketId) { - const bucketResult = await pgClient.query({ - text: `SELECT key FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`, - values: [bucketId], - }); - if (bucketResult.rows[0]?.key) { - bucketKey = bucketResult.rows[0].key; + return lambda( + $combined, + async ({ + key, + isPublic, + filename, + bucketId, + withPgClient, + pgSettings, + }: any) => { + if (!key) return null; + + let s3ForDb = resolveS3(options); + let downloadUrlExpirySeconds = 3600; + try { + if (withPgClient && pgSettings) { + const databaseId = await withPgClient( + pgSettings, + async (pgClient: any) => { + const dbResult = await pgClient.query({ + text: `SELECT jwt_private.current_database_id() AS id`, + }); + return dbResult.rows[0]?.id ?? null; + } + ); + // Module registration is server config, not user data: + // resolve it without the request role's pgSettings. + const config = databaseId + ? resolveStorageConfigFromCodec( + capturedCodec, + await withPgClient(null, (pgClient: any) => + loadAllStorageModules( + pgClient, + databaseId, + cacheScope + ) + ) + ) + : null; + const resolved = config + ? await withPgClient( + pgSettings, + async (pgClient: any) => { + // Look up the bucket key for scoped S3 resolution + let bucketKey = 'public'; + if (bucketId) { + const bucketResult = await pgClient.query( + { + text: `SELECT key FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`, + values: [bucketId], + } + ); + if (bucketResult.rows[0]?.key) { + bucketKey = bucketResult.rows[0].key; + } + } + + return { config, databaseId, bucketKey }; } - } - - return { config, databaseId, bucketKey }; - }) - : null; - if (resolved) { - downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds; - s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId, resolved.bucketKey); + ) + : null; + if (resolved) { + downloadUrlExpirySeconds = + resolved.config.downloadUrlExpirySeconds; + s3ForDb = resolveS3ForDatabase( + options, + resolved.config, + resolved.databaseId, + resolved.bucketKey + ); + } } + } catch { + // Fall back to global config if lookup fails } - } catch { - // Fall back to global config if lookup fails - } - if (isPublic && s3ForDb.publicUrlPrefix) { - return `${s3ForDb.publicUrlPrefix}/${s3ForDb.bucket}/${key}`; - } + if (isPublic && s3ForDb.publicUrlPrefix) { + return `${s3ForDb.publicUrlPrefix}/${s3ForDb.bucket}/${key}`; + } - return generatePresignedGetUrl( - s3ForDb, - key, - downloadUrlExpirySeconds, - filename || undefined, - ); - }); + return generatePresignedGetUrl( + s3ForDb, + key, + downloadUrlExpirySeconds, + filename || undefined + ); + } + ); }, - }, + } ), }, - 'PresignedUrlDownloadPlugin adding downloadUrl field', + 'PresignedUrlDownloadPlugin adding downloadUrl field' ); }, }, diff --git a/graphile/graphile-presigned-url-plugin/src/index.ts b/graphile/graphile-presigned-url-plugin/src/index.ts index 94e3babc03..ed4230f22b 100644 --- a/graphile/graphile-presigned-url-plugin/src/index.ts +++ b/graphile/graphile-presigned-url-plugin/src/index.ts @@ -30,8 +30,25 @@ export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin'; export { createDownloadUrlPlugin } from './download-url-field'; export { PresignedUrlPreset } from './preset'; -export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, loadAllStorageModules, resolveStorageConfigFromCodec, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache'; -export { generatePresignedPutUrl, generatePresignedGetUrl, deleteS3Object, headObject } from './s3-signer'; +export { + getStorageModuleConfig, + getStorageModuleConfigForOwner, + getBucketConfig, + resolveStorageModuleByFileId, + loadAllStorageModules, + resolveStorageConfigFromCodec, + clearStorageModuleCache, + clearBucketCache, + isS3BucketProvisioned, + markS3BucketProvisioned, + StorageModuleCacheScope, +} from './storage-module-cache'; +export { + generatePresignedPutUrl, + generatePresignedGetUrl, + deleteS3Object, + headObject, +} from './s3-signer'; export type { BucketConfig, StorageModuleConfig, diff --git a/graphile/graphile-presigned-url-plugin/src/plugin.ts b/graphile/graphile-presigned-url-plugin/src/plugin.ts index 1fb64bd55e..5affb95e39 100644 --- a/graphile/graphile-presigned-url-plugin/src/plugin.ts +++ b/graphile/graphile-presigned-url-plugin/src/plugin.ts @@ -22,8 +22,20 @@ import type { GraphileConfig } from 'graphile-config'; import 'graphile-build'; import { Logger } from '@pgpmjs/logger'; -import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig, BucketConfig } from './types'; -import { loadAllStorageModules, resolveStorageConfigFromCodec, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache'; +import type { + PresignedUrlPluginOptions, + S3Config, + StorageModuleConfig, + BucketConfig, +} from './types'; +import { + getBucketConfig, + isS3BucketProvisioned, + loadAllStorageModules, + markS3BucketProvisioned, + resolveStorageConfigFromCodec, + StorageModuleCacheScope, +} from './storage-module-cache'; import { generatePresignedPutUrl, deleteS3Object } from './s3-signer'; const log = new Logger('graphile-presigned-url:plugin'); @@ -81,9 +93,7 @@ async function resolveDatabaseId(pgClient: any): Promise { function resolveS3(options: PresignedUrlPluginOptions): S3Config { if (typeof options.s3 === 'function') { - const resolved = options.s3(); - options.s3 = resolved; - return resolved; + return options.s3(); } return options.s3; } @@ -92,15 +102,19 @@ function resolveS3ForDatabase( options: PresignedUrlPluginOptions, storageConfig: StorageModuleConfig, databaseId: string, - bucketKey: string, + bucketKey: string ): S3Config { const globalS3 = resolveS3(options); const bucket = options.resolveBucketName ? options.resolveBucketName(databaseId, bucketKey) : globalS3.bucket; - const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix; + const publicUrlPrefix = + storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix; - if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) { + if ( + bucket === globalS3.bucket && + publicUrlPrefix === globalS3.publicUrlPrefix + ) { return globalS3; } @@ -117,13 +131,21 @@ async function ensureS3BucketExists( bucket: BucketConfig, databaseId: string, allowedOrigins: string[] | null, + cacheScope: StorageModuleCacheScope ): Promise { if (!options.ensureBucketProvisioned) return; - if (isS3BucketProvisioned(s3BucketName)) return; + if (isS3BucketProvisioned(s3BucketName, cacheScope)) return; - log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`); - await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins); - markS3BucketProvisioned(s3BucketName); + log.info( + `Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}` + ); + await options.ensureBucketProvisioned( + s3BucketName, + bucket.type, + databaseId, + allowedOrigins + ); + markS3BucketProvisioned(s3BucketName, cacheScope); log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`); } @@ -131,14 +153,19 @@ async function ensureS3BucketExists( export function createPresignedUrlPlugin( options: PresignedUrlPluginOptions, + cacheScope = new StorageModuleCacheScope() ): GraphileConfig.Plugin { - return { name: 'PresignedUrlPlugin', version: '1.0.0', - description: 'Per-table S3 storage middleware: upload fields on @storageBuckets, delete middleware on @storageFiles', + description: + 'Per-table S3 storage middleware: upload fields on @storageBuckets, delete middleware on @storageFiles', - after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'], + after: [ + 'PgAttributesPlugin', + 'PgMutationCreatePlugin', + 'PgMutationUpdateDeletePlugin', + ], schema: { hooks: { @@ -153,291 +180,490 @@ export function createPresignedUrlPlugin( if (!isRootMutation) return fields; const { - graphql: { - GraphQLString, - GraphQLNonNull, - GraphQLInt, - GraphQLBoolean, - GraphQLObjectType, - GraphQLInputObjectType, - GraphQLList, - }, - } = build; + graphql: { + GraphQLString, + GraphQLNonNull, + GraphQLInt, + GraphQLBoolean, + GraphQLObjectType, + GraphQLInputObjectType, + GraphQLList, + }, + } = build; + + const bucketCodecs = Object.values( + (build.input as any).pgRegistry.pgCodecs + ).filter( + (codec: any) => + codec.attributes && + (codec.extensions as any)?.tags?.storageBuckets + ); - const bucketCodecs = Object.values((build.input as any).pgRegistry.pgCodecs).filter( - (codec: any) => codec.attributes && (codec.extensions as any)?.tags?.storageBuckets, - ); + if (bucketCodecs.length === 0) return fields; - if (bucketCodecs.length === 0) return fields; + const newFields: Record = {}; - const newFields: Record = {}; + // --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) --- + const fileCodecs = Object.values( + (build.input as any).pgRegistry.pgCodecs + ).filter( + (codec: any) => + codec.attributes && (codec.extensions as any)?.tags?.storageFiles + ); - // --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) --- - const fileCodecs = Object.values((build.input as any).pgRegistry.pgCodecs).filter( - (codec: any) => codec.attributes && (codec.extensions as any)?.tags?.storageFiles, + for (const filesCodec of fileCodecs as any[]) { + const filesTypeName = (build.inflection as any).tableType( + filesCodec ); - for (const filesCodec of fileCodecs as any[]) { - const filesTypeName = (build.inflection as any).tableType(filesCodec); - - // Find the matching bucket codec by table name prefix. - // Schema-name matching is ambiguous when multiple storage modules share - // the same PG schema (e.g. app_files + data_room_files both in storage_public). - // Instead, derive the prefix from the raw SQL table name: - // "data_room_files" → prefix "data_room" → matches "data_room_buckets" - // "app_files" → prefix "app" → matches "app_buckets" - const filesRawName = filesCodec.extensions?.pg?.name as string | undefined; - const filesPrefix = filesRawName?.replace(/_files$/, ''); - const matchingBucketCodec = (bucketCodecs as any[]).find((bc: any) => { - const bucketRawName = bc.extensions?.pg?.name as string | undefined; + // Find the matching bucket codec by table name prefix. + // Schema-name matching is ambiguous when multiple storage modules share + // the same PG schema (e.g. app_files + data_room_files both in storage_public). + // Instead, derive the prefix from the raw SQL table name: + // "data_room_files" → prefix "data_room" → matches "data_room_buckets" + // "app_files" → prefix "app" → matches "app_buckets" + const filesRawName = filesCodec.extensions?.pg?.name as + | string + | undefined; + const filesPrefix = filesRawName?.replace(/_files$/, ''); + const matchingBucketCodec = (bucketCodecs as any[]).find( + (bc: any) => { + const bucketRawName = bc.extensions?.pg?.name as + | string + | undefined; const bucketPrefix = bucketRawName?.replace(/_buckets$/, ''); return bucketPrefix === filesPrefix; - }); - if (!matchingBucketCodec) { - log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`); - continue; } + ); + if (!matchingBucketCodec) { + log.debug( + `Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"` + ); + continue; + } - const hasOwnerId = !!matchingBucketCodec.attributes.owner_id; - const mutationName = `upload${filesTypeName}`; - - const ownerIdGqlType = hasOwnerId - ? (build as any).getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input') - : null; - - const InputType = new GraphQLInputObjectType({ - name: `Upload${filesTypeName}Input`, - fields: { - bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' }, - ...(hasOwnerId - ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } } - : {}), - contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' }, - contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' }, - size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' }, - filename: { type: GraphQLString, description: 'Original filename (optional)' }, - key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' }, + const hasOwnerId = !!matchingBucketCodec.attributes.owner_id; + const mutationName = `upload${filesTypeName}`; + + const ownerIdGqlType = hasOwnerId + ? (build as any).getGraphQLTypeByPgCodec( + matchingBucketCodec.attributes.owner_id.codec, + 'input' + ) + : null; + + const InputType = new GraphQLInputObjectType({ + name: `Upload${filesTypeName}Input`, + fields: { + bucketKey: { + type: new GraphQLNonNull(GraphQLString), + description: 'Bucket key (e.g., "public", "private")', }, - }); - - const PayloadType = new GraphQLObjectType({ - name: `Upload${filesTypeName}Payload`, - fields: { - uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' }, - fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' }, - key: { type: new GraphQLNonNull(GraphQLString), description: 'The S3 object key' }, - deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' }, - expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' }, - previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' }, + ...(hasOwnerId + ? { + ownerId: { + type: new GraphQLNonNull( + ownerIdGqlType || GraphQLString + ), + description: + 'Owner entity ID (required for entity-scoped buckets)', + }, + } + : {}), + contentHash: { + type: new GraphQLNonNull(GraphQLString), + description: 'SHA-256 content hash (hex-encoded, 64 chars)', }, - }); - - const capturedFilesCodec = filesCodec; - - log.debug(`Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})`); - - newFields[mutationName] = context.fieldWithHooks( - { fieldName: mutationName } as any, - { - description: `Upload a file: resolves the bucket by key, creates the file row, and returns a presigned PUT URL.`, - type: PayloadType, - args: { - input: { type: new GraphQLNonNull(InputType) }, - }, - plan(_$mutation: any, fieldArgs: any) { - const $input = fieldArgs.getRaw('input'); - const $bucketKey = access($input, 'bucketKey'); - const $contentHash = access($input, 'contentHash'); - const $contentType = access($input, 'contentType'); - const $size = access($input, 'size'); - const $filename = access($input, 'filename'); - const $customKey = access($input, 'key'); - const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, (): null => null); - const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $pgSettings = (grafastContext() as any).get('pgSettings'); - - const $combined = object({ - bucketKey: $bucketKey, - ownerId: $ownerId, - contentHash: $contentHash, - contentType: $contentType, - size: $size, - filename: $filename, - customKey: $customKey, - withPgClient: $withPgClient, - pgSettings: $pgSettings, - }); - - return lambda($combined, async (vals: any) => { - const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient: any) => - resolveDatabaseId(pgClient), - ); - if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); - - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const allConfigs = await vals.withPgClient(null, (pgClient: any) => - loadAllStorageModules(pgClient, databaseId), - ); - const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs); - if (!storageConfig) throw new Error('STORAGE_MODULE_NOT_FOUND'); - - return vals.withPgClient(vals.pgSettings, async (pgClient: any) => { - return pgClient.withTransaction(async (txClient: any) => { - const bucket = await getBucketConfig( - txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined, - ); - if (!bucket) throw new Error('BUCKET_NOT_FOUND'); - - const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucket.key); - await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins); - - return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, { - contentHash: vals.contentHash, - contentType: vals.contentType, - size: vals.size, - filename: vals.filename, - key: vals.customKey, - }); - }); - }); - }); - }, + contentType: { + type: new GraphQLNonNull(GraphQLString), + description: 'MIME type of the file', }, - ); - - // --- Bulk file upload mutation --- - const BulkFileInputType = new GraphQLInputObjectType({ - name: `Upload${filesTypeName}BulkFileInput`, - fields: { - contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' }, - contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' }, - size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' }, - filename: { type: GraphQLString, description: 'Original filename (optional)' }, - key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' }, + size: { + type: new GraphQLNonNull(GraphQLInt), + description: 'File size in bytes', + }, + filename: { + type: GraphQLString, + description: 'Original filename (optional)', + }, + key: { + type: GraphQLString, + description: + 'Custom S3 key (only when bucket has allow_custom_keys=true)', + }, + }, + }); + + const PayloadType = new GraphQLObjectType({ + name: `Upload${filesTypeName}Payload`, + fields: { + uploadUrl: { + type: GraphQLString, + description: 'Presigned PUT URL (null if deduplicated)', + }, + fileId: { + type: new GraphQLNonNull(GraphQLString), + description: 'The file ID (UUID)', }, - }); - - const BulkFilePayloadType = new GraphQLObjectType({ - name: `Upload${filesTypeName}BulkFilePayload`, - fields: { - uploadUrl: { type: GraphQLString }, - fileId: { type: new GraphQLNonNull(GraphQLString) }, - key: { type: new GraphQLNonNull(GraphQLString) }, - deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) }, - expiresAt: { type: GraphQLString }, - previousVersionId: { type: GraphQLString }, + key: { + type: new GraphQLNonNull(GraphQLString), + description: 'The S3 object key', }, - }); - - const BulkInputType = new GraphQLInputObjectType({ - name: `Upload${filesTypeName}BulkInput`, - fields: { - bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' }, - ...(hasOwnerId - ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } } - : {}), - files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFileInputType))), description: 'Array of files to upload' }, + deduplicated: { + type: new GraphQLNonNull(GraphQLBoolean), + description: + 'Whether this file was deduplicated (content already exists)', }, - }); + expiresAt: { + type: GraphQLString, + description: + 'Presigned URL expiry time (null if deduplicated)', + }, + previousVersionId: { + type: GraphQLString, + description: + 'ID of the previous version (when using custom keys)', + }, + }, + }); + + const capturedFilesCodec = filesCodec; + + log.debug( + `Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})` + ); - const BulkPayloadType = new GraphQLObjectType({ - name: `Upload${filesTypeName}BulkPayload`, - fields: { - files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) }, + newFields[mutationName] = context.fieldWithHooks( + { fieldName: mutationName } as any, + { + description: `Upload a file: resolves the bucket by key, creates the file row, and returns a presigned PUT URL.`, + type: PayloadType, + args: { + input: { type: new GraphQLNonNull(InputType) }, }, - }); - - const bulkMutationName = `upload${filesTypeName}s`; - log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`); - - newFields[bulkMutationName] = context.fieldWithHooks( - { fieldName: bulkMutationName } as any, - { - description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`, - type: BulkPayloadType, - args: { - input: { type: new GraphQLNonNull(BulkInputType) }, - }, - plan(_$mutation: any, fieldArgs: any) { - const $input = fieldArgs.getRaw('input'); - const $bucketKey = access($input, 'bucketKey'); - const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, (): null => null); - const $files = access($input, 'files'); - const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $pgSettings = (grafastContext() as any).get('pgSettings'); - - const $combined = object({ - bucketKey: $bucketKey, - ownerId: $ownerId, - files: $files, - withPgClient: $withPgClient, - pgSettings: $pgSettings, - }); - - return lambda($combined, async (vals: any) => { - const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient: any) => - resolveDatabaseId(pgClient), - ); - if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); - - // Module registration is server config, not user data: - // resolve it without the request role's pgSettings. - const allConfigs = await vals.withPgClient(null, (pgClient: any) => - loadAllStorageModules(pgClient, databaseId), - ); - const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs); - if (!storageConfig) throw new Error('STORAGE_MODULE_NOT_FOUND'); - - return vals.withPgClient(vals.pgSettings, async (pgClient: any) => { - return pgClient.withTransaction(async (txClient: any) => { - const bucket = await getBucketConfig( - txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined, - ); - if (!bucket) throw new Error('BUCKET_NOT_FOUND'); + plan(_$mutation: any, fieldArgs: any) { + const $input = fieldArgs.getRaw('input'); + const $bucketKey = access($input, 'bucketKey'); + const $contentHash = access($input, 'contentHash'); + const $contentType = access($input, 'contentType'); + const $size = access($input, 'size'); + const $filename = access($input, 'filename'); + const $customKey = access($input, 'key'); + const $ownerId = hasOwnerId + ? access($input, 'ownerId') + : lambda(null, (): null => null); + const $withPgClient = (grafastContext() as any).get( + 'withPgClient' + ); + const $pgSettings = (grafastContext() as any).get( + 'pgSettings' + ); + + const $combined = object({ + bucketKey: $bucketKey, + ownerId: $ownerId, + contentHash: $contentHash, + contentType: $contentType, + size: $size, + filename: $filename, + customKey: $customKey, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); + + return lambda($combined, async (vals: any) => { + const databaseId = await vals.withPgClient( + vals.pgSettings, + (pgClient: any) => resolveDatabaseId(pgClient) + ); + if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); - // Enforce bulk upload limits - const filesArray = vals.files as any[]; - if (filesArray.length > storageConfig.maxBulkFiles) { - throw new Error( - `BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`, + // Module registration is server config, not user data: + // resolve it without the request role's pgSettings. + const allConfigs = await vals.withPgClient( + null, + (pgClient: any) => + loadAllStorageModules(pgClient, databaseId, cacheScope) + ); + const storageConfig = resolveStorageConfigFromCodec( + capturedFilesCodec, + allConfigs + ); + if (!storageConfig) + throw new Error('STORAGE_MODULE_NOT_FOUND'); + + return vals.withPgClient( + vals.pgSettings, + async (pgClient: any) => { + return pgClient.withTransaction( + async (txClient: any) => { + const bucket = await getBucketConfig( + txClient, + storageConfig, + databaseId, + vals.bucketKey, + vals.ownerId || undefined, + cacheScope ); - } - const totalSize = filesArray.reduce((sum: number, f: any) => sum + (f.size || 0), 0); - if (totalSize > storageConfig.maxBulkTotalSize) { - throw new Error( - `BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`, + if (!bucket) throw new Error('BUCKET_NOT_FOUND'); + + const s3ForDb = resolveS3ForDatabase( + options, + storageConfig, + databaseId, + bucket.key + ); + await ensureS3BucketExists( + options, + s3ForDb.bucket, + bucket, + databaseId, + storageConfig.allowedOrigins, + cacheScope + ); + + return processSingleFile( + options, + txClient, + storageConfig, + databaseId, + bucket, + s3ForDb, + { + contentHash: vals.contentHash, + contentType: vals.contentType, + size: vals.size, + filename: vals.filename, + key: vals.customKey, + } ); } + ); + } + ); + }); + }, + } + ); - const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucket.key); - await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins); - - const results = []; - for (const file of filesArray) { - results.push( - await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, { - contentHash: file.contentHash, - contentType: file.contentType, - size: file.size, - filename: file.filename, - key: file.key, - }), + // --- Bulk file upload mutation --- + const BulkFileInputType = new GraphQLInputObjectType({ + name: `Upload${filesTypeName}BulkFileInput`, + fields: { + contentHash: { + type: new GraphQLNonNull(GraphQLString), + description: 'SHA-256 content hash (hex-encoded, 64 chars)', + }, + contentType: { + type: new GraphQLNonNull(GraphQLString), + description: 'MIME type of the file', + }, + size: { + type: new GraphQLNonNull(GraphQLInt), + description: 'File size in bytes', + }, + filename: { + type: GraphQLString, + description: 'Original filename (optional)', + }, + key: { + type: GraphQLString, + description: + 'Custom S3 key (only when bucket has allow_custom_keys=true)', + }, + }, + }); + + const BulkFilePayloadType = new GraphQLObjectType({ + name: `Upload${filesTypeName}BulkFilePayload`, + fields: { + uploadUrl: { type: GraphQLString }, + fileId: { type: new GraphQLNonNull(GraphQLString) }, + key: { type: new GraphQLNonNull(GraphQLString) }, + deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) }, + expiresAt: { type: GraphQLString }, + previousVersionId: { type: GraphQLString }, + }, + }); + + const BulkInputType = new GraphQLInputObjectType({ + name: `Upload${filesTypeName}BulkInput`, + fields: { + bucketKey: { + type: new GraphQLNonNull(GraphQLString), + description: 'Bucket key (e.g., "public", "private")', + }, + ...(hasOwnerId + ? { + ownerId: { + type: new GraphQLNonNull( + ownerIdGqlType || GraphQLString + ), + description: + 'Owner entity ID (required for entity-scoped buckets)', + }, + } + : {}), + files: { + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(BulkFileInputType)) + ), + description: 'Array of files to upload', + }, + }, + }); + + const BulkPayloadType = new GraphQLObjectType({ + name: `Upload${filesTypeName}BulkPayload`, + fields: { + files: { + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(BulkFilePayloadType)) + ), + }, + }, + }); + + const bulkMutationName = `upload${filesTypeName}s`; + log.debug( + `Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}` + ); + + newFields[bulkMutationName] = context.fieldWithHooks( + { fieldName: bulkMutationName } as any, + { + description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`, + type: BulkPayloadType, + args: { + input: { type: new GraphQLNonNull(BulkInputType) }, + }, + plan(_$mutation: any, fieldArgs: any) { + const $input = fieldArgs.getRaw('input'); + const $bucketKey = access($input, 'bucketKey'); + const $ownerId = hasOwnerId + ? access($input, 'ownerId') + : lambda(null, (): null => null); + const $files = access($input, 'files'); + const $withPgClient = (grafastContext() as any).get( + 'withPgClient' + ); + const $pgSettings = (grafastContext() as any).get( + 'pgSettings' + ); + + const $combined = object({ + bucketKey: $bucketKey, + ownerId: $ownerId, + files: $files, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); + + return lambda($combined, async (vals: any) => { + const databaseId = await vals.withPgClient( + vals.pgSettings, + (pgClient: any) => resolveDatabaseId(pgClient) + ); + if (!databaseId) throw new Error('DATABASE_NOT_FOUND'); + + // Module registration is server config, not user data: + // resolve it without the request role's pgSettings. + const allConfigs = await vals.withPgClient( + null, + (pgClient: any) => + loadAllStorageModules(pgClient, databaseId, cacheScope) + ); + const storageConfig = resolveStorageConfigFromCodec( + capturedFilesCodec, + allConfigs + ); + if (!storageConfig) + throw new Error('STORAGE_MODULE_NOT_FOUND'); + + return vals.withPgClient( + vals.pgSettings, + async (pgClient: any) => { + return pgClient.withTransaction( + async (txClient: any) => { + const bucket = await getBucketConfig( + txClient, + storageConfig, + databaseId, + vals.bucketKey, + vals.ownerId || undefined, + cacheScope + ); + if (!bucket) throw new Error('BUCKET_NOT_FOUND'); + + // Enforce bulk upload limits + const filesArray = vals.files as any[]; + if ( + filesArray.length > storageConfig.maxBulkFiles + ) { + throw new Error( + `BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch` + ); + } + const totalSize = filesArray.reduce( + (sum: number, f: any) => sum + (f.size || 0), + 0 ); + if (totalSize > storageConfig.maxBulkTotalSize) { + throw new Error( + `BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch` + ); + } + + const s3ForDb = resolveS3ForDatabase( + options, + storageConfig, + databaseId, + bucket.key + ); + await ensureS3BucketExists( + options, + s3ForDb.bucket, + bucket, + databaseId, + storageConfig.allowedOrigins, + cacheScope + ); + + const results = []; + for (const file of filesArray) { + results.push( + await processSingleFile( + options, + txClient, + storageConfig, + databaseId, + bucket, + s3ForDb, + { + contentHash: file.contentHash, + contentType: file.contentType, + size: file.size, + filename: file.filename, + key: file.key, + } + ) + ); + } + return { files: results }; } - return { files: results }; - }); - }); - }); - }, + ); + } + ); + }); }, - ); - } + } + ); + } return build.extend( fields, newFields, - 'PresignedUrlPlugin adding file upload mutations', + 'PresignedUrlPlugin adding file upload mutations' ); }, @@ -468,7 +694,9 @@ export function createPresignedUrlPlugin( return field; } - log.debug(`Wrapping delete mutation "${fieldName}" with S3 cleanup (codec: ${pgCodec.name})`); + log.debug( + `Wrapping delete mutation "${fieldName}" with S3 cleanup (codec: ${pgCodec.name})` + ); const defaultResolver = (obj: any) => obj[fieldName]; const { resolve: oldResolve = defaultResolver, ...rest } = field; @@ -476,10 +704,15 @@ export function createPresignedUrlPlugin( return { ...rest, - async resolve(source: any, args: any, graphqlContext: any, info: any) { + async resolve( + source: any, + args: any, + graphqlContext: any, + info: any + ) { // Extract the file ID from the mutation input const inputKey = Object.keys(args.input || {}).find( - (k) => k !== 'clientMutationId', + (k) => k !== 'clientMutationId' ); const fileInput = inputKey ? args.input[inputKey] : null; @@ -492,13 +725,25 @@ export function createPresignedUrlPlugin( if (withPgClient) { try { - const databaseId = await withPgClient(pgSettings, (pgClient: any) => resolveDatabaseId(pgClient)); + const databaseId = await withPgClient( + pgSettings, + (pgClient: any) => resolveDatabaseId(pgClient) + ); // Module registration is server config, not user data: // resolve it without the request role's pgSettings. const allConfigs = databaseId - ? await withPgClient(null, (pgClient: any) => loadAllStorageModules(pgClient, databaseId)) + ? await withPgClient(null, (pgClient: any) => + loadAllStorageModules( + pgClient, + databaseId, + cacheScope + ) + ) : []; - const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs); + const storageConfig = resolveStorageConfigFromCodec( + capturedCodec, + allConfigs + ); if (storageConfig) { await withPgClient(pgSettings, async (pgClient: any) => { @@ -508,7 +753,10 @@ export function createPresignedUrlPlugin( values: [fileInput], }); if (result.rows.length > 0) { - fileRow = result.rows[0] as { key: string; bucket_id: string }; + fileRow = result.rows[0] as { + key: string; + bucket_id: string; + }; } }); } @@ -519,7 +767,12 @@ export function createPresignedUrlPlugin( } // Call PostGraphile's generated delete (RLS enforced) - const result = await oldResolve(source, args, graphqlContext, info); + const result = await oldResolve( + source, + args, + graphqlContext, + info + ); // Attempt sync S3 cleanup if we have the file row if (fileRow) { @@ -528,45 +781,71 @@ export function createPresignedUrlPlugin( if (withPgClient) { try { - const databaseId = await withPgClient(pgSettings, (pgClient: any) => resolveDatabaseId(pgClient)); + const databaseId = await withPgClient( + pgSettings, + (pgClient: any) => resolveDatabaseId(pgClient) + ); // Module registration is server config, not user data: // resolve it without the request role's pgSettings. const allConfigs = databaseId - ? await withPgClient(null, (pgClient: any) => loadAllStorageModules(pgClient, databaseId)) + ? await withPgClient(null, (pgClient: any) => + loadAllStorageModules( + pgClient, + databaseId, + cacheScope + ) + ) : []; - const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs); + const storageConfig = resolveStorageConfigFromCodec( + capturedCodec, + allConfigs + ); - if (storageConfig) await withPgClient(pgSettings, async (pgClient: any) => { - // Check refcount: any other file with the same key in this bucket? - const refResult = await pgClient.query({ - text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`, - values: [fileRow!.key, fileRow!.bucket_id], - }); - const refCount = refResult.rows[0]?.ref_count ?? 0; + if (storageConfig) + await withPgClient(pgSettings, async (pgClient: any) => { + // Check refcount: any other file with the same key in this bucket? + const refResult = await pgClient.query({ + text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`, + values: [fileRow!.key, fileRow!.bucket_id], + }); + const refCount = refResult.rows[0]?.ref_count ?? 0; - if (refCount > 0) { - log.info(`File deleted from DB; S3 key ${fileRow!.key} still referenced by ${refCount} file(s)`); - return; - } + if (refCount > 0) { + log.info( + `File deleted from DB; S3 key ${fileRow!.key} still referenced by ${refCount} file(s)` + ); + return; + } - // No other references — attempt sync S3 delete - // Look up the bucket key for scoped S3 resolution - const bucketResult = await pgClient.query({ - text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`, - values: [fileRow!.bucket_id], + // No other references — attempt sync S3 delete + // Look up the bucket key for scoped S3 resolution + const bucketResult = await pgClient.query({ + text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`, + values: [fileRow!.bucket_id], + }); + const bucketKey = bucketResult.rows[0]?.key; + if (!bucketKey) { + log.warn( + `Bucket not found for bucket_id=${fileRow!.bucket_id}; skipping S3 delete` + ); + return; + } + const s3ForDb = resolveS3ForDatabase( + options, + storageConfig, + databaseId, + bucketKey + ); + await deleteS3Object(s3ForDb, fileRow!.key); + log.info( + `Sync S3 delete succeeded for key=${fileRow!.key}` + ); }); - const bucketKey = bucketResult.rows[0]?.key; - if (!bucketKey) { - log.warn(`Bucket not found for bucket_id=${fileRow!.bucket_id}; skipping S3 delete`); - return; - } - const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey); - await deleteS3Object(s3ForDb, fileRow!.key); - log.info(`Sync S3 delete succeeded for key=${fileRow!.key}`); - }); } catch (err: any) { // Sync S3 delete failed — the AFTER DELETE trigger has enqueued an async GC job - log.warn(`Sync S3 delete failed for key=${fileRow.key}; async GC job will retry: ${err.message}`); + log.warn( + `Sync S3 delete failed for key=${fileRow.key}; async GC job will retry: ${err.message}` + ); } } } @@ -589,24 +868,43 @@ async function processSingleFile( databaseId: string, bucket: BucketConfig, s3ForDb: S3Config, - input: any, + input: any ) { const { contentHash, contentType, size, filename, key: customKey } = input; - if (!contentHash || typeof contentHash !== 'string' || contentHash.length > MAX_CONTENT_HASH_LENGTH) { + if ( + !contentHash || + typeof contentHash !== 'string' || + contentHash.length > MAX_CONTENT_HASH_LENGTH + ) { throw new Error('INVALID_CONTENT_HASH'); } if (!isValidSha256(contentHash)) { - throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256'); + throw new Error( + 'INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256' + ); } - if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) { + if ( + !contentType || + typeof contentType !== 'string' || + contentType.length > MAX_CONTENT_TYPE_LENGTH + ) { throw new Error('INVALID_CONTENT_TYPE'); } - if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) { - throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`); + if ( + typeof size !== 'number' || + size <= 0 || + size > storageConfig.defaultMaxFileSize + ) { + throw new Error( + `INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes` + ); } if (filename !== undefined && filename !== null) { - if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) { + if ( + typeof filename !== 'string' || + filename.length > storageConfig.maxFilenameLength + ) { throw new Error('INVALID_FILENAME'); } } @@ -623,13 +921,17 @@ async function processSingleFile( return contentType === pattern; }); if (!isAllowed) { - throw new Error(`CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types`); + throw new Error( + `CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types` + ); } } // Validate size against bucket's max_file_size if (bucket.max_file_size && size > bucket.max_file_size) { - throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`); + throw new Error( + `FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes` + ); } // Determine S3 key @@ -637,7 +939,9 @@ async function processSingleFile( let isCustomKey = false; if (customKey) { if (!bucket.allow_custom_keys) { - throw new Error('CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys'); + throw new Error( + 'CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys' + ); } const keyError = validateCustomKey(customKey); if (keyError) { @@ -666,7 +970,9 @@ async function processSingleFile( if (existingResult.rows.length > 0) { const existing = existingResult.rows[0]; if (existing.content_hash === contentHash) { - log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`); + log.info( + `Dedup hit (custom key): file ${existing.id} for key ${s3Key}` + ); return { uploadUrl: null as string | null, fileId: existing.id as string, @@ -677,7 +983,9 @@ async function processSingleFile( }; } previousVersionId = existing.id; - log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`); + log.info( + `Versioning: new version of key ${s3Key}, previous=${previousVersionId}` + ); } } else { const dedupResult = await txClient.query({ @@ -705,12 +1013,31 @@ async function processSingleFile( } // Auto-derive ltree path from custom key directory (only when has_path_shares) - const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null; + const derivedPath = + isCustomKey && storageConfig.hasPathShares + ? derivePathFromKey(s3Key) + : null; // Create file record const hasOwnerColumn = storageConfig.scope !== 'app'; - const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public']; - const values: any[] = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public]; + const columns = [ + 'bucket_id', + 'key', + 'content_hash', + 'mime_type', + 'size', + 'filename', + 'is_public', + ]; + const values: any[] = [ + bucket.id, + s3Key, + contentHash, + contentType, + size, + filename || null, + bucket.is_public, + ]; if (hasOwnerColumn) { columns.push('owner_id'); @@ -725,7 +1052,9 @@ async function processSingleFile( values.push(derivedPath); } - const placeholders = values.map((_: any, i: number) => `$${i + 1}`).join(', '); + const placeholders = values + .map((_: any, i: number) => `$${i + 1}`) + .join(', '); const fileResult = await txClient.query({ text: `INSERT INTO ${storageConfig.filesQualifiedName} (${columns.join(', ')}) @@ -742,10 +1071,12 @@ async function processSingleFile( s3Key, contentType, size, - storageConfig.uploadUrlExpirySeconds, + storageConfig.uploadUrlExpirySeconds ); - const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString(); + const expiresAt = new Date( + Date.now() + storageConfig.uploadUrlExpirySeconds * 1000 + ).toISOString(); return { uploadUrl, diff --git a/graphile/graphile-presigned-url-plugin/src/preset.ts b/graphile/graphile-presigned-url-plugin/src/preset.ts index 1fb2146185..b9c3ab7563 100644 --- a/graphile/graphile-presigned-url-plugin/src/preset.ts +++ b/graphile/graphile-presigned-url-plugin/src/preset.ts @@ -10,6 +10,7 @@ import type { GraphileConfig } from 'graphile-config'; import type { PresignedUrlPluginOptions } from './types'; import { createPresignedUrlPlugin } from './plugin'; import { createDownloadUrlPlugin } from './download-url-field'; +import { StorageModuleCacheScope } from './storage-module-cache'; /** * Creates a preset that includes the presigned URL plugins with the given options. @@ -35,12 +36,13 @@ import { createDownloadUrlPlugin } from './download-url-field'; * ``` */ export function PresignedUrlPreset( - options: PresignedUrlPluginOptions, + options: PresignedUrlPluginOptions ): GraphileConfig.Preset { + const cacheScope = new StorageModuleCacheScope(); return { plugins: [ - createPresignedUrlPlugin(options), - createDownloadUrlPlugin(options), + createPresignedUrlPlugin(options, cacheScope), + createDownloadUrlPlugin(options, cacheScope), ], }; } diff --git a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts index 6ea403fbb5..b1500c93f5 100644 --- a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts +++ b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts @@ -10,11 +10,10 @@ const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes const DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS = 3600; // 1 hour const DEFAULT_MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB const DEFAULT_MAX_FILENAME_LENGTH = 1024; -const DEFAULT_CACHE_TTL_SECONDS = process.env.NODE_ENV === 'development' ? 300 : 3600; +const DEFAULT_CACHE_TTL_SECONDS = 3600; const DEFAULT_MAX_BULK_FILES = 100; const DEFAULT_MAX_BULK_TOTAL_SIZE = 1073741824; // 1GB -const FIVE_MINUTES_MS = 1000 * 60 * 5; const ONE_HOUR_MS = 1000 * 60 * 60; /** @@ -27,11 +26,12 @@ const ONE_HOUR_MS = 1000 * 60 * 60; * * Pattern: same as graphile-cache's LRU with TTL-based eviction. */ -const storageModuleCache = new LRUCache({ - max: 50, - ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS, - updateAgeOnGet: true, -}); +const createStorageModuleCache = () => + new LRUCache({ + max: 50, + ttl: ONE_HOUR_MS, + updateAgeOnGet: true, + }); /** * SQL query to resolve the app-level storage module config for a database. @@ -144,22 +144,34 @@ function buildConfig(row: StorageModuleRow): StorageModuleConfig { const cacheTtlSeconds = row.cache_ttl_seconds ?? DEFAULT_CACHE_TTL_SECONDS; return { id: row.id, - bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table), - filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table), + bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier( + row.buckets_schema, + row.buckets_table + ), + filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier( + row.files_schema, + row.files_table + ), schemaName: row.buckets_schema, bucketsTableName: row.buckets_table, filesTableName: row.files_table, scope: row.scope, entityTableId: row.entity_table_id, - entityQualifiedName: row.entity_schema && row.entity_table - ? QuoteUtils.quoteQualifiedIdentifier(row.entity_schema, row.entity_table) - : null, + entityQualifiedName: + row.entity_schema && row.entity_table + ? QuoteUtils.quoteQualifiedIdentifier( + row.entity_schema, + row.entity_table + ) + : null, endpoint: row.endpoint, publicUrlPrefix: row.public_url_prefix, provider: row.provider, allowedOrigins: row.allowed_origins, - uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS, - downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS, + uploadUrlExpirySeconds: + row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS, + downloadUrlExpirySeconds: + row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS, defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE, maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH, cacheTtlSeconds, @@ -180,18 +192,30 @@ function buildConfig(row: StorageModuleRow): StorageModuleConfig { * @returns StorageModuleConfig or null if no storage module is provisioned */ export async function getStorageModuleConfig( - pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, + pgClient: { + query: (opts: { + text: string; + values?: unknown[]; + }) => Promise<{ rows: unknown[] }>; + }, databaseId: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope ): Promise { + const { storageModuleCache } = cacheScope; const cacheKey = `storage:${databaseId}:app`; const cached = storageModuleCache.get(cacheKey); if (cached) { return cached; } - log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`); + log.debug( + `Cache miss for app-level storage in database ${databaseId}, querying metaschema...` + ); - const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] }); + const result = await pgClient.query({ + text: APP_STORAGE_MODULE_QUERY, + values: [databaseId], + }); if (result.rows.length === 0) { log.warn(`No app-level storage module found for database ${databaseId}`); @@ -200,7 +224,9 @@ export async function getStorageModuleConfig( const config = buildConfig(result.rows[0] as StorageModuleRow); storageModuleCache.set(cacheKey, config); - log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`); + log.debug( + `Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}` + ); return config; } @@ -221,10 +247,17 @@ export async function getStorageModuleConfig( * @returns StorageModuleConfig or null if no matching module found */ export async function getStorageModuleConfigForOwner( - pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, + pgClient: { + query: (opts: { + text: string; + values?: unknown[]; + }) => Promise<{ rows: unknown[] }>; + }, databaseId: string, ownerId: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope ): Promise { + const { storageModuleCache } = cacheScope; // Check if we already have a cached mapping for this ownerId const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`; const cachedOwner = storageModuleCache.get(ownerCacheKey); @@ -245,8 +278,13 @@ export async function getStorageModuleConfigForOwner( } if (allConfigs.length === 0) { - log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`); - const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] }); + log.debug( + `Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}` + ); + const result = await pgClient.query({ + text: ALL_STORAGE_MODULES_QUERY, + values: [databaseId], + }); allConfigs = (result.rows as StorageModuleRow[]).map(buildConfig); // Cache each individual config by its scope @@ -257,7 +295,9 @@ export async function getStorageModuleConfigForOwner( } // Find entity-scoped modules and probe their entity tables for the ownerId - const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null); + const entityModules = allConfigs.filter( + (c) => c.entityQualifiedName !== null + ); for (const mod of entityModules) { const probeResult = await pgClient.query({ @@ -269,13 +309,15 @@ export async function getStorageModuleConfigForOwner( storageModuleCache.set(ownerCacheKey, mod); log.debug( `Resolved ownerId ${ownerId} to storage module ${mod.id} ` + - `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})`, + `(scope=${mod.scope}, table=${mod.bucketsQualifiedName})` ); return mod; } } - log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`); + log.warn( + `No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}` + ); return null; } @@ -290,17 +332,30 @@ export async function getStorageModuleConfigForOwner( * @returns Object with the storage config and file row, or null if not found */ export async function resolveStorageModuleByFileId( - pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, + pgClient: { + query: (opts: { + text: string; + values?: unknown[]; + }) => Promise<{ rows: unknown[] }>; + }, databaseId: string, - fileId: string, -): Promise<{ storageConfig: StorageModuleConfig; file: { id: string; key: string; mime_type: string; bucket_id: string } } | null> { + fileId: string +): Promise<{ + storageConfig: StorageModuleConfig; + file: { id: string; key: string; mime_type: string; bucket_id: string }; +} | null> { // Load all storage modules for this database - log.debug(`Resolving file ${fileId} across all storage modules for database ${databaseId}`); - - const allConfigs = (await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] })).rows.map( - (row: unknown) => buildConfig(row as StorageModuleRow), + log.debug( + `Resolving file ${fileId} across all storage modules for database ${databaseId}` ); + const allConfigs = ( + await pgClient.query({ + text: ALL_STORAGE_MODULES_QUERY, + values: [databaseId], + }) + ).rows.map((row: unknown) => buildConfig(row as StorageModuleRow)); + // Probe each module's files table for the fileId for (const config of allConfigs) { const fileResult = await pgClient.query({ @@ -311,7 +366,12 @@ export async function resolveStorageModuleByFileId( values: [fileId], }); if (fileResult.rows.length > 0) { - const file = fileResult.rows[0] as { id: string; key: string; mime_type: string; bucket_id: string }; + const file = fileResult.rows[0] as { + id: string; + key: string; + mime_type: string; + bucket_id: string; + }; return { storageConfig: config, file }; } } @@ -326,9 +386,16 @@ export async function resolveStorageModuleByFileId( * The result is cached per-database so subsequent calls avoid the DB query. */ export async function loadAllStorageModules( - pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, + pgClient: { + query: (opts: { + text: string; + values?: unknown[]; + }) => Promise<{ rows: unknown[] }>; + }, databaseId: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope ): Promise { + const { storageModuleCache } = cacheScope; const cacheKey = `storage:${databaseId}:all-list`; const cached = storageModuleCache.get(cacheKey); if (cached) { @@ -336,7 +403,10 @@ export async function loadAllStorageModules( } log.debug(`Loading all storage modules for database ${databaseId}`); - const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] }); + const result = await pgClient.query({ + text: ALL_STORAGE_MODULES_QUERY, + values: [databaseId], + }); const configs = (result.rows as StorageModuleRow[]).map(buildConfig); // Cache each individual config by its scope @@ -365,18 +435,25 @@ export async function loadAllStorageModules( * @returns The matching StorageModuleConfig or null */ export function resolveStorageConfigFromCodec( - pgCodec: { name: string; extensions?: { pg?: { schemaName?: string; name?: string } }; sqlType?: string }, - allConfigs: StorageModuleConfig[], + pgCodec: { + name: string; + extensions?: { pg?: { schemaName?: string; name?: string } }; + sqlType?: string; + }, + allConfigs: StorageModuleConfig[] ): StorageModuleConfig | null { const schemaName = pgCodec.extensions?.pg?.schemaName; const tableName = pgCodec.extensions?.pg?.name ?? pgCodec.name; if (!schemaName || !tableName) return null; - return allConfigs.find((c) => - (c.filesTableName === tableName && c.schemaName === schemaName) || - (c.bucketsTableName === tableName && c.schemaName === schemaName), - ) || null; + return ( + allConfigs.find( + (c) => + (c.filesTableName === tableName && c.schemaName === schemaName) || + (c.bucketsTableName === tableName && c.schemaName === schemaName) + ) || null + ); } // --- Bucket metadata cache --- @@ -395,11 +472,27 @@ export function resolveStorageConfigFromCodec( * Keys: `bucket:${databaseId}:${storageModuleId}:${bucketKey}` * TTL: same as storage module cache (5min dev / 1hr prod) */ -const bucketCache = new LRUCache({ - max: 500, // many buckets across many databases - ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS, - updateAgeOnGet: true, -}); +const createBucketCache = () => + new LRUCache({ + max: 500, + ttl: ONE_HOUR_MS, + updateAgeOnGet: true, + }); + +/** Metadata and provisioning caches owned by one PostGraphile instance. */ +export class StorageModuleCacheScope { + readonly storageModuleCache = createStorageModuleCache(); + readonly bucketCache = createBucketCache(); + readonly provisionedBuckets = new Set(); + + clear(): void { + this.storageModuleCache.clear(); + this.bucketCache.clear(); + this.provisionedBuckets.clear(); + } +} + +const legacyStorageModuleCacheScope = new StorageModuleCacheScope(); /** * Resolve bucket metadata for a given database + bucket key, using the LRU cache. @@ -415,19 +508,28 @@ const bucketCache = new LRUCache({ * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible */ export async function getBucketConfig( - pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> }, + pgClient: { + query: (opts: { + text: string; + values?: unknown[]; + }) => Promise<{ rows: unknown[] }>; + }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string, ownerId?: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope ): Promise { + const { bucketCache } = cacheScope; const cacheKey = `bucket:${databaseId}:${storageConfig.id}:${bucketKey}${ownerId ? `:${ownerId}` : ''}`; const cached = bucketCache.get(cacheKey); if (cached) { return cached; } - log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`); + log.debug( + `Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...` + ); // Entity-scoped buckets use (owner_id, key) composite lookup; // app-level buckets just use key. @@ -473,7 +575,9 @@ export async function getBucketConfig( }; bucketCache.set(cacheKey, config); - log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`); + log.debug( + `Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})` + ); return config; } @@ -493,20 +597,24 @@ export async function getBucketConfig( * The set resets on server restart, which is fine because the * provisioner's createBucket is idempotent (handles "already exists"). */ -const provisionedBuckets = new Set(); - /** * Check whether an S3 bucket has already been provisioned (cached). */ -export function isS3BucketProvisioned(s3BucketName: string): boolean { - return provisionedBuckets.has(s3BucketName); +export function isS3BucketProvisioned( + s3BucketName: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope +): boolean { + return cacheScope.provisionedBuckets.has(s3BucketName); } /** * Mark an S3 bucket as provisioned in the in-memory cache. */ -export function markS3BucketProvisioned(s3BucketName: string): void { - provisionedBuckets.add(s3BucketName); +export function markS3BucketProvisioned( + s3BucketName: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope +): void { + cacheScope.provisionedBuckets.add(s3BucketName); log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`); } @@ -514,17 +622,21 @@ export function markS3BucketProvisioned(s3BucketName: string): void { * Clear the storage module cache AND bucket cache. * Useful for testing or schema changes. */ -export function clearStorageModuleCache(): void { - storageModuleCache.clear(); - bucketCache.clear(); - provisionedBuckets.clear(); +export function clearStorageModuleCache( + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope +): void { + cacheScope.clear(); } /** * Clear cached bucket entries for a specific database. * Useful when bucket config changes are detected. */ -export function clearBucketCache(databaseId?: string): void { +export function clearBucketCache( + databaseId?: string, + cacheScope: StorageModuleCacheScope = legacyStorageModuleCacheScope +): void { + const { bucketCache } = cacheScope; if (!databaseId) { bucketCache.clear(); return; diff --git a/graphile/graphile-schema/__tests__/build-schema-config.test.ts b/graphile/graphile-schema/__tests__/build-schema-config.test.ts new file mode 100644 index 0000000000..207a0601ca --- /dev/null +++ b/graphile/graphile-schema/__tests__/build-schema-config.test.ts @@ -0,0 +1,82 @@ +import { resolveBuildPgConfig } from '../src/build-schema'; + +const PG_ENV_KEYS = [ + 'PGHOST', + 'PGPORT', + 'PGUSER', + 'PGPASSWORD', + 'PGDATABASE', +] as const; + +describe('build schema PostgreSQL configuration', () => { + let previousPgEnv: Record; + + beforeEach(() => { + previousPgEnv = Object.fromEntries( + PG_ENV_KEYS.map((key) => [key, process.env[key]]) + ); + process.env.PGHOST = 'ambient-host'; + process.env.PGPORT = '1111'; + process.env.PGUSER = 'ambient-user'; + process.env.PGPASSWORD = 'ambient-password'; + process.env.PGDATABASE = 'ambient-database'; + }); + + afterEach(() => { + for (const key of PG_ENV_KEYS) { + const previous = previousPgEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); + + it('ignores ambient process env and resolves the supplied environment', () => { + expect( + resolveBuildPgConfig({ + schemas: ['public'], + database: 'explicit-database', + env: { + PGHOST: 'explicit-host', + PGPORT: '2222', + PGUSER: 'explicit-user', + PGPASSWORD: 'explicit-password', + PGDATABASE: 'ignored-env-database', + }, + }) + ).toEqual({ + host: 'explicit-host', + port: 2222, + user: 'explicit-user', + password: 'explicit-password', + database: 'explicit-database', + }); + }); + + it('gives explicit build config precedence over the supplied environment', () => { + expect( + resolveBuildPgConfig({ + schemas: ['public'], + env: { + PGHOST: 'env-host', + PGPORT: '2222', + PGUSER: 'env-user', + PGPASSWORD: 'env-password', + PGDATABASE: 'env-database', + }, + pg: { + host: 'config-host', + port: 3333, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }, + }) + ).toEqual({ + host: 'config-host', + port: 3333, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }); + }); +}); diff --git a/graphile/graphile-schema/package.json b/graphile/graphile-schema/package.json index 43348308e8..95a2459719 100644 --- a/graphile/graphile-schema/package.json +++ b/graphile/graphile-schema/package.json @@ -34,7 +34,7 @@ "graphile-config": "1.0.1", "graphile-settings": "workspace:^", "graphql": "16.13.0", - "pg-cache": "workspace:^", + "pg": "^8.21.0", "pg-env": "workspace:^" }, "devDependencies": { diff --git a/graphile/graphile-schema/src/build-introspection.ts b/graphile/graphile-schema/src/build-introspection.ts index bd9091c854..2edb2b354b 100644 --- a/graphile/graphile-schema/src/build-introspection.ts +++ b/graphile/graphile-schema/src/build-introspection.ts @@ -1,16 +1,14 @@ -import type { TableMeta } from 'graphile-settings' -import { _cachedTablesMeta } from 'graphile-settings' -import { buildSchemaSDL } from './build-schema' -import type { BuildSchemaOptions } from './build-schema' +import type { TableMeta } from 'graphile-settings'; +import { buildSchemaArtifacts } from './build-schema'; +import type { BuildSchemaOptions } from './build-schema'; -export type { BuildSchemaOptions as BuildIntrospectionOptions } +export type { BuildSchemaOptions as BuildIntrospectionOptions }; /** * Build introspection metadata for all tables visible in the given schemas. * - * Internally calls `buildSchemaSDL()` which triggers the MetaSchemaPlugin - * gather hook, populating `_cachedTablesMeta` as a side-effect. The cached - * metadata is then returned as a plain array of `TableMeta` objects. + * Metadata is captured inside the individual schema-build scope, so concurrent + * builds cannot read one another's tables. * * The result includes every table's fields, types, constraints, indexes, * relations, inflection names, and query entry-points — the same data @@ -31,6 +29,5 @@ export type { BuildSchemaOptions as BuildIntrospectionOptions } export async function buildIntrospectionJSON( opts: BuildSchemaOptions ): Promise { - await buildSchemaSDL(opts) - return [..._cachedTablesMeta] + return (await buildSchemaArtifacts(opts)).tablesMeta; } diff --git a/graphile/graphile-schema/src/build-schema.ts b/graphile/graphile-schema/src/build-schema.ts index ff5ed78417..94a0c5f031 100644 --- a/graphile/graphile-schema/src/build-schema.ts +++ b/graphile/graphile-schema/src/build-schema.ts @@ -1,53 +1,86 @@ -import deepmerge from 'deepmerge' -import { lexicographicSortSchema, printSchema } from 'graphql' -import { ConstructivePreset, makePgService } from 'graphile-settings' -import { makeSchema } from 'graphile-build' -import { getPgPool } from 'pg-cache' -import { getPgEnvOptions } from 'pg-env' -import type { GraphileConfig } from 'graphile-config' +import deepmerge from 'deepmerge'; +import { lexicographicSortSchema, printSchema } from 'graphql'; +import { makeSchema } from 'graphile-build'; +import type { GraphileConfig } from 'graphile-config'; +import { + captureTablesMeta, + ConstructivePreset, + makePgService, + type TableMeta, + withGraphileSettingsRuntime, +} from 'graphile-settings'; +import { Pool } from 'pg'; +import { getPgEnvOptions, type PgConfig } from 'pg-env'; export type BuildSchemaOptions = { database?: string; schemas: string[]; graphile?: Partial; + /** Explicit PostgreSQL settings. Values override `env`. */ + pg?: Partial; + /** Explicit environment used for PostgreSQL defaults. Never read ambient env. */ + env?: Readonly>; + /** Caller-owned pool. When omitted, this function creates and closes one. */ + pool?: Pool; }; -export async function buildSchemaSDL(opts: BuildSchemaOptions): Promise { - const database = opts.database ?? 'constructive' - const schemas = Array.isArray(opts.schemas) ? opts.schemas : [] +export interface BuildSchemaArtifacts { + sdl: string; + tablesMeta: TableMeta[]; +} - const config = getPgEnvOptions({ database }) +export const resolveBuildPgConfig = (opts: BuildSchemaOptions): PgConfig => + getPgEnvOptions( + { + database: 'constructive', + ...(opts.pg ?? {}), + ...(opts.database === undefined ? {} : { database: opts.database }), + }, + { ...(opts.env ?? {}) } + ); - // Create the pool through pg-cache so it is tracked and can be cleaned up - // by callers via pgCache.delete(database) before dropping ephemeral databases. - // Without this, makePgService creates its own internal pool that isn't released, - // causing "database has active sessions" errors during ephemeral DB teardown. - const pool = getPgPool(config) +export async function buildSchemaArtifacts( + opts: BuildSchemaOptions +): Promise { + const schemas = Array.isArray(opts.schemas) ? opts.schemas : []; + const ownedPool = opts.pool === undefined; + const pool = opts.pool ?? new Pool(resolveBuildPgConfig(opts)); - // Hybrid preset composition: use deepmerge for safe scalar/object keys - // (plugins, disablePlugins, schema, gather, etc.) but pluck out `extends` - // and `pgServices` to compose them via Graphile's native mechanism. - // deepmerge cannot deep-clone `extends` (contains the entire PostGraphile - // preset tree) or `pgServices` (contains pg Pool / EventEmitter internals) - // without overflowing the call stack. - const { extends: callerExtends, pgServices: _pgServices, ...callerRest } = opts.graphile ?? {} + const { + extends: callerExtends, + pgServices: _pgServices, + ...callerRest + } = opts.graphile ?? {}; - const baseRest: GraphileConfig.Preset = {} + const baseRest: GraphileConfig.Preset = {}; const preset: GraphileConfig.Preset = { ...deepmerge(baseRest, callerRest), - extends: [ - ConstructivePreset, - ...(callerExtends ?? []), - ], + extends: [ConstructivePreset, ...(callerExtends ?? [])], pgServices: [ makePgService({ pool, schemas, }), ], + }; + + try { + const captured = await withGraphileSettingsRuntime( + { env: opts.env ?? {} }, + () => captureTablesMeta(() => makeSchema(preset)) + ); + return { + sdl: printSchema(lexicographicSortSchema(captured.result.schema)), + tablesMeta: captured.tablesMeta, + }; + } finally { + if (ownedPool) await pool.end(); } +} - const { schema } = await makeSchema(preset) - return printSchema(lexicographicSortSchema(schema)) +export async function buildSchemaSDL( + opts: BuildSchemaOptions +): Promise { + return (await buildSchemaArtifacts(opts)).sdl; } diff --git a/graphile/graphile-schema/src/index.ts b/graphile/graphile-schema/src/index.ts index c05a296166..50e6675cca 100644 --- a/graphile/graphile-schema/src/index.ts +++ b/graphile/graphile-schema/src/index.ts @@ -1,5 +1,9 @@ -export { buildSchemaSDL } from './build-schema'; -export type { BuildSchemaOptions } from './build-schema'; +export { + buildSchemaArtifacts, + buildSchemaSDL, + resolveBuildPgConfig, +} from './build-schema'; +export type { BuildSchemaArtifacts, BuildSchemaOptions } from './build-schema'; export { buildIntrospectionJSON } from './build-introspection'; export { _cachedTablesMeta } from 'graphile-settings'; export type { diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 75c7793cd5..6bfc722aa4 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -33,7 +33,7 @@ "@pgpmjs/types": "workspace:^", "cors": "^2.8.6", "express": "^5.2.1", - "lru-cache": "^11.2.7" + "lru-cache": "^10.4.3" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/packages/server-utils/src/lru.ts b/packages/server-utils/src/lru.ts index 7f07b2ff53..57f5c0941b 100644 --- a/packages/server-utils/src/lru.ts +++ b/packages/server-utils/src/lru.ts @@ -9,13 +9,18 @@ const ONE_YEAR = ONE_DAY * 366; export const SVC_CACHE_TTL_MS = ONE_YEAR; +export type ServiceCache = LRUCache; + +export const createServiceCache = (): ServiceCache => + new LRUCache({ + max: 50, + ttl: SVC_CACHE_TTL_MS, + updateAgeOnGet: true, + dispose: (_, key) => { + log.debug(`Disposing service[${key}]`); + }, + }); + // --- Service Cache --- // Keep max aligned with PG_CACHE_MAX and GRAPHILE_CACHE_MAX (default: 50) -export const svcCache = new LRUCache({ - max: 50, - ttl: SVC_CACHE_TTL_MS, - updateAgeOnGet: true, - dispose: (_, key) => { - log.debug(`Disposing service[${key}]`); - } -}); \ No newline at end of file +export const svcCache = createServiceCache(); diff --git a/postgres/pg-cache/package.json b/postgres/pg-cache/package.json index c8c7b97ec4..8f0c3b6cd1 100644 --- a/postgres/pg-cache/package.json +++ b/postgres/pg-cache/package.json @@ -32,7 +32,7 @@ "12factor-env": "workspace:^", "@pgpmjs/logger": "workspace:^", "@pgpmjs/types": "workspace:^", - "lru-cache": "^11.2.7", + "lru-cache": "^10.4.3", "pg": "^8.21.0", "pg-env": "workspace:^" }, diff --git a/postgres/pg-cache/src/__tests__/driver.test.ts b/postgres/pg-cache/src/__tests__/driver.test.ts index dd155d538f..7d63d4fd74 100644 --- a/postgres/pg-cache/src/__tests__/driver.test.ts +++ b/postgres/pg-cache/src/__tests__/driver.test.ts @@ -10,23 +10,42 @@ import { defaultPgPoolFactory, getActivePgPoolFactory, getPgPool, + getPgPoolCacheKey, hasPgPoolFactory, + PgPoolCacheManager, PgPoolFactory, - registerPgPoolFactory + registerPgPoolFactory, } from '../index'; -import { pgCache } from '../lru'; const createMockPool = (): pg.Pool => - ({ query: jest.fn(), connect: jest.fn(), end: jest.fn(async () => {}) } as unknown as pg.Pool); + ({ + query: jest.fn(), + connect: jest.fn(), + end: jest.fn(async () => {}), + }) as unknown as pg.Pool; const freshConfig = () => { const database = `seam_${randomUUID()}`; - return { database, host: 'localhost', port: 5432, user: 'postgres', password: 'x' }; + return { + database, + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'x', + }; }; describe('pg-cache pool-factory seam', () => { - afterEach(() => { + const ownedCaches: PgPoolCacheManager[] = []; + const createCache = (): PgPoolCacheManager => { + const cache = new PgPoolCacheManager(undefined, {}); + ownedCaches.push(cache); + return cache; + }; + + afterEach(async () => { registerPgPoolFactory(undefined); + await Promise.all(ownedCaches.splice(0).map((cache) => cache.close())); }); it('defaults to no registered factory', () => { @@ -48,37 +67,56 @@ describe('pg-cache pool-factory seam', () => { const cfg = freshConfig(); const mock = createMockPool(); const factory = jest.fn(() => mock); + const cache = createCache(); registerPgPoolFactory(factory); - const pool = getPgPool(cfg); + const pool = getPgPool(cfg, { cache, environment: {} }); expect(factory).toHaveBeenCalledTimes(1); expect(pool).toBe(mock); - - pgCache.delete(cfg.database); }); - it('caches by database: a second call reuses the pool and does not re-invoke the factory', () => { + it('caches by complete connection identity', () => { const cfg = freshConfig(); const factory = jest.fn(() => createMockPool()); + const cache = createCache(); registerPgPoolFactory(factory); - const first = getPgPool(cfg); - const second = getPgPool(cfg); + const first = getPgPool(cfg, { cache, environment: {} }); + const second = getPgPool(cfg, { cache, environment: {} }); expect(first).toBe(second); expect(factory).toHaveBeenCalledTimes(1); + }); + + it('does not collide when database names match but credentials differ', () => { + const cfg = freshConfig(); + const factory = jest.fn(() => createMockPool()); + const cache = createCache(); + registerPgPoolFactory(factory); - pgCache.delete(cfg.database); + const first = getPgPool(cfg, { cache, environment: {} }); + const second = getPgPool( + { ...cfg, host: 'db.internal', password: 'different-secret' }, + { cache, environment: {} } + ); + + expect(first).not.toBe(second); + expect(factory).toHaveBeenCalledTimes(2); + expect([...cache.keys()]).toHaveLength(2); + expect([...cache.keys()].join(' ')).not.toContain('different-secret'); + expect(getPgPoolCacheKey(cfg, { environment: {} })).not.toContain( + cfg.password + ); }); it('falls back to defaultPgPoolFactory when nothing is registered', () => { const cfg = freshConfig(); + const cache = createCache(); // defaultPgPoolFactory builds a real pg.Pool but does NOT connect until a // query runs, so this is safe without a live server. - const pool = getPgPool(cfg); + const pool = getPgPool(cfg, { cache, environment: {} }); expect(pool).toBeInstanceOf(pg.Pool); - pgCache.delete(cfg.database); }); it('defaultPgPoolFactory returns a pg.Pool', () => { diff --git a/postgres/pg-cache/src/__tests__/lru.test.ts b/postgres/pg-cache/src/__tests__/lru.test.ts index cca6db9634..70590226d7 100644 --- a/postgres/pg-cache/src/__tests__/lru.test.ts +++ b/postgres/pg-cache/src/__tests__/lru.test.ts @@ -1,11 +1,6 @@ -// Guards against the pg-cache close() resource leak fixed in feat/observability. -// -// Previously, close() reset this.closed = false after shutdown, allowing -// set() to silently accept new pools that were never cleaned up. The module- -// level closePromise also reset to null, enabling double-shutdown. -// -// These tests lock the fix: close() is final, set() rejects, and repeated -// close() calls are idempotent. See pg-cache-close-leak.md for full details. +// Locks cache ownership and shutdown behavior: disposal is awaited, concurrent +// close callers share one cleanup, and a manager may be reused after a complete +// close for explicit restart/provisioning flows. import pg from 'pg'; import { PgPoolCacheManager } from '../lru'; @@ -14,8 +9,12 @@ import { PgPoolCacheManager } from '../lru'; const createMockPool = (): pg.Pool => { let ended = false; return { - get ended() { return ended; }, - end: jest.fn(async () => { ended = true; }), + get ended() { + return ended; + }, + end: jest.fn(async () => { + ended = true; + }), } as unknown as pg.Pool; }; @@ -28,7 +27,11 @@ describe('PgPoolCacheManager', () => { afterEach(async () => { // Ensure all pools are cleaned up even if a test fails mid-way - try { await cache.close(); } catch { /* already closed */ } + try { + await cache.close(); + } catch { + /* already closed */ + } }); it('stores and retrieves a pool', () => { @@ -121,6 +124,28 @@ describe('PgPoolCacheManager', () => { expect(pool.end).toHaveBeenCalledTimes(1); }); + it('concurrent close callers await the same cleanup', async () => { + const pool = createMockPool(); + let releaseCleanup!: () => void; + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + cache.set('key1', pool); + cache.registerCleanupCallback(() => cleanupGate); + + let secondResolved = false; + const first = cache.close(); + const second = cache.close().then(() => { + secondResolved = true; + }); + await Promise.resolve(); + + expect(secondResolved).toBe(false); + releaseCleanup(); + await Promise.all([first, second]); + expect(pool.end).toHaveBeenCalledTimes(1); + }); + it('close() disposes all pools', async () => { const pool1 = createMockPool(); const pool2 = createMockPool(); @@ -174,5 +199,42 @@ describe('PgPoolCacheManager', () => { await small.close(); }); + + it('awaits async cleanup before ending the backing pool', async () => { + const pool = createMockPool(); + let releaseCleanup!: () => void; + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + cache.set('key1', pool); + cache.registerCleanupCallback(() => cleanupGate); + + const closePromise = cache.close(); + await Promise.resolve(); + + expect(pool.end).not.toHaveBeenCalled(); + releaseCleanup(); + await closePromise; + expect(pool.end).toHaveBeenCalledTimes(1); + }); + }); + + describe('ownership isolation', () => { + it('closing one manager does not dispose another manager pools', async () => { + const first = new PgPoolCacheManager(undefined, {}); + const second = new PgPoolCacheManager(undefined, {}); + const firstPool = createMockPool(); + const secondPool = createMockPool(); + first.set('same-logical-key', firstPool); + second.set('same-logical-key', secondPool); + + await first.close(); + + expect(firstPool.end).toHaveBeenCalledTimes(1); + expect(secondPool.end).not.toHaveBeenCalled(); + expect(second.get('same-logical-key')).toBe(secondPool); + + await second.close(); + }); }); }); diff --git a/postgres/pg-cache/src/index.ts b/postgres/pg-cache/src/index.ts index 286748297a..400b9b75f4 100644 --- a/postgres/pg-cache/src/index.ts +++ b/postgres/pg-cache/src/index.ts @@ -2,22 +2,24 @@ export { getActivePgPoolFactory, hasPgPoolFactory, - registerPgPoolFactory + registerPgPoolFactory, } from './driver'; -export { +export { close, getPgCacheConfig, - pgCache, - PgPoolCacheManager, - teardownPgPools + pgCache, + PgPoolCacheManager, + teardownPgPools, } from './lru'; export { buildConnectionString, defaultPgPoolFactory, getPgPool, - getPgPoolConfig + getPgPoolCacheKey, + getPgPoolConfig, } from './pg'; // Re-export types export type { PgPoolFactory, QueryableClient, QueryablePool } from './driver'; -export type { PgCacheConfig, PoolCleanupCallback } from './lru'; \ No newline at end of file +export type { PgCacheConfig, PgPoolKey, PoolCleanupCallback } from './lru'; +export type { PgPoolRuntimeOptions } from './pg'; diff --git a/postgres/pg-cache/src/lru.ts b/postgres/pg-cache/src/lru.ts index 98612a862b..1604ec7d8b 100644 --- a/postgres/pg-cache/src/lru.ts +++ b/postgres/pg-cache/src/lru.ts @@ -12,10 +12,10 @@ const ONE_YEAR = ONE_DAY * 366; // Kubernetes sends only SIGTERM on pod shutdown const SYS_EVENTS = ['SIGTERM']; -type PgPoolKey = string; +export type PgPoolKey = string; // Cleanup callback type - called when a pg pool is disposed -export type PoolCleanupCallback = (pgPoolKey: string) => void; +export type PoolCleanupCallback = (pgPoolKey: string) => void | Promise; // --- Cache Configuration --- @@ -33,10 +33,12 @@ export interface PgCacheConfig { * - PG_CACHE_MAX: Maximum number of pools (default: 50) * - PG_CACHE_TTL_MS: TTL in milliseconds (default: ONE_YEAR) */ -export function getPgCacheConfig(): PgCacheConfig { +export function getPgCacheConfig( + environment: Readonly> = process.env +): PgCacheConfig { return { - max: parseEnvNumber(process.env.PG_CACHE_MAX) ?? 50, - ttl: parseEnvNumber(process.env.PG_CACHE_TTL_MS) ?? ONE_YEAR, + max: parseEnvNumber(environment.PG_CACHE_MAX) ?? 50, + ttl: parseEnvNumber(environment.PG_CACHE_TTL_MS) ?? ONE_YEAR, }; } @@ -44,7 +46,10 @@ class ManagedPgPool { public isDisposed = false; private disposePromise: Promise | null = null; - constructor(public readonly pool: pg.Pool, public readonly key: string) {} + constructor( + public readonly pool: pg.Pool, + public readonly key: string + ) {} async dispose(): Promise { if (this.isDisposed) return this.disposePromise; @@ -59,7 +64,9 @@ class ManagedPgPool { log.info(`pg.Pool ${this.key} already ended.`); } } catch (err) { - log.error(`Error ending pg.Pool ${this.key}: ${(err as Error).message}`); + log.error( + `Error ending pg.Pool ${this.key}: ${(err as Error).message}` + ); throw err; } })(); @@ -69,15 +76,20 @@ class ManagedPgPool { } export class PgPoolCacheManager { - private cleanupTasks: Promise[] = []; + private cleanupTasks = new Set>(); + private scheduledPools = new WeakSet(); private closed = false; + private closePromise: Promise | null = null; private cleanupCallbacks: Set = new Set(); readonly config: PgCacheConfig; private readonly pgCache: LRUCache; - constructor(config?: Partial) { - const defaults = getPgCacheConfig(); + constructor( + config?: Partial, + environment: Readonly> = process.env + ) { + const defaults = getPgCacheConfig(environment); this.config = { ...defaults, ...config }; this.pgCache = new LRUCache({ @@ -86,9 +98,8 @@ export class PgPoolCacheManager { updateAgeOnGet: true, dispose: (managedPool, key, reason) => { log.debug(`Disposing pg pool [${key}] (${reason})`); - this.notifyCleanup(key); - this.disposePool(managedPool); - } + this.scheduleDisposal(key, managedPool); + }, }); } @@ -114,59 +125,90 @@ export class PgPoolCacheManager { } set(key: PgPoolKey, pool: pg.Pool): void { - if (this.closed) throw new Error(`Cannot add to cache after it has been closed (key: ${key})`); + if (this.closed) + throw new Error( + `Cannot add to cache after it has been closed (key: ${key})` + ); this.pgCache.set(key, new ManagedPgPool(pool, key)); } delete(key: PgPoolKey): void { - const managedPool = this.pgCache.get(key); - const existed = this.pgCache.delete(key); - if (!existed && managedPool) { - this.notifyCleanup(key); - this.disposePool(managedPool); - } + this.pgCache.delete(key); } clear(): void { - const entries = [...this.pgCache.entries()]; this.pgCache.clear(); - for (const [key, managedPool] of entries) { - this.notifyCleanup(key); - this.disposePool(managedPool); - } } - async close(): Promise { - if (this.closed) return; - this.closed = true; - this.clear(); + keys(): IterableIterator { + return this.pgCache.keys(); + } + + async closeMatching(predicate: (key: PgPoolKey) => boolean): Promise { + const keys = [...this.pgCache.keys()].filter(predicate); + for (const key of keys) this.pgCache.delete(key); await this.waitForDisposals(); - // Re-open the cache so it can accept new entries if the process - // survives the shutdown signal (e.g. during provisioning or restart). - this.closed = false; + return keys.length; + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + this.closePromise = (async () => { + this.closed = true; + try { + this.clear(); + await this.waitForDisposals(); + } finally { + // Re-open the cache so it can accept new entries if the process + // survives shutdown (for example during provisioning or restart). + this.closed = false; + this.closePromise = null; + } + })(); + return this.closePromise; } async waitForDisposals(): Promise { - if (this.cleanupTasks.length === 0) return; - const tasks = [...this.cleanupTasks]; - this.cleanupTasks = []; - await Promise.allSettled(tasks); + while (this.cleanupTasks.size > 0) { + await Promise.allSettled([...this.cleanupTasks]); + } } - private notifyCleanup(pgPoolKey: string): void { - this.cleanupCallbacks.forEach(callback => { - try { - callback(pgPoolKey); - } catch (err) { - log.error(`Error in cleanup callback for pool ${pgPoolKey}: ${(err as Error).message}`); + private async notifyCleanup(pgPoolKey: string): Promise { + const results = await Promise.allSettled( + [...this.cleanupCallbacks].map((callback) => callback(pgPoolKey)) + ); + for (const result of results) { + if (result.status === 'rejected') { + log.error( + `Error in cleanup callback for pool ${pgPoolKey}: ${ + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + }` + ); } - }); + } } - private disposePool(managedPool: ManagedPgPool): void { - if (managedPool.isDisposed) return; - const task = managedPool.dispose(); - this.cleanupTasks.push(task); + private scheduleDisposal(key: PgPoolKey, managedPool: ManagedPgPool): void { + if (this.scheduledPools.has(managedPool)) return; + this.scheduledPools.add(managedPool); + + const task = (async () => { + await this.notifyCleanup(key); + await managedPool.dispose(); + })(); + this.cleanupTasks.add(task); + void task.catch((): void => {}); + void task.then( + (): void => { + this.cleanupTasks.delete(task); + }, + (): void => { + this.cleanupTasks.delete(task); + } + ); } } @@ -193,7 +235,7 @@ export const close = async (verbose = false): Promise => { return closePromise.promise; }; -SYS_EVENTS.forEach(event => { +SYS_EVENTS.forEach((event) => { process.on(event, () => { log.info(`Received ${event}`); close(); diff --git a/postgres/pg-cache/src/pg.ts b/postgres/pg-cache/src/pg.ts index ffb88babcc..824842a01a 100644 --- a/postgres/pg-cache/src/pg.ts +++ b/postgres/pg-cache/src/pg.ts @@ -1,10 +1,11 @@ +import { createHash } from 'node:crypto'; import { parseEnvNumber } from '12factor-env'; import pg from 'pg'; import { getPgEnvOptions, PgConfig, PgPoolConfig } from 'pg-env'; import { Logger } from '@pgpmjs/logger'; import { getActivePgPoolFactory, PgPoolFactory } from './driver'; -import { pgCache } from './lru'; +import { pgCache, PgPoolCacheManager } from './lru'; const log = new Logger('pg-cache'); @@ -14,8 +15,7 @@ export const buildConnectionString = ( host: string, port: string | number, database: string -): string => - `postgres://${user}:${password}@${host}:${port}/${database}`; +): string => `postgres://${user}:${password}@${host}:${port}/${database}`; /** * Read per-pool configuration from environment variables. @@ -25,12 +25,23 @@ export const buildConnectionString = ( * - PG_POOL_IDLE_TIMEOUT_MS: Close idle clients after ms (default: 30000) * - PG_POOL_CONNECTION_TIMEOUT_MS: Fail connect() after ms (default: 5000) */ -export function getPgPoolConfig(overrides?: PgPoolConfig): pg.PoolConfig { +export function getPgPoolConfig( + overrides?: PgPoolConfig, + environment: Readonly> = process.env +): pg.PoolConfig { return { - max: overrides?.max ?? parseEnvNumber(process.env.PG_POOL_MAX) ?? 5, - idleTimeoutMillis: overrides?.idleTimeoutMillis ?? parseEnvNumber(process.env.PG_POOL_IDLE_TIMEOUT_MS) ?? 30000, - connectionTimeoutMillis: overrides?.connectionTimeoutMillis ?? parseEnvNumber(process.env.PG_POOL_CONNECTION_TIMEOUT_MS) ?? 5000, - ...(overrides?.allowExitOnIdle !== undefined && { allowExitOnIdle: overrides.allowExitOnIdle }), + max: overrides?.max ?? parseEnvNumber(environment.PG_POOL_MAX) ?? 5, + idleTimeoutMillis: + overrides?.idleTimeoutMillis ?? + parseEnvNumber(environment.PG_POOL_IDLE_TIMEOUT_MS) ?? + 30000, + connectionTimeoutMillis: + overrides?.connectionTimeoutMillis ?? + parseEnvNumber(environment.PG_POOL_CONNECTION_TIMEOUT_MS) ?? + 5000, + ...(overrides?.allowExitOnIdle !== undefined && { + allowExitOnIdle: overrides.allowExitOnIdle, + }), }; } @@ -41,7 +52,13 @@ export function getPgPoolConfig(overrides?: PgPoolConfig): pg.PoolConfig { export const defaultPgPoolFactory: PgPoolFactory = (pgConfig): pg.Pool => { const config = getPgEnvOptions(pgConfig); const { user, password, host, port, database } = config; - const connectionString = buildConnectionString(user, password, host, port, database); + const connectionString = buildConnectionString( + user, + password, + host, + port, + database + ); const poolConfig = getPgPoolConfig(pgConfig.pool); const pgPool = new pg.Pool({ connectionString, ...poolConfig }); @@ -89,22 +106,57 @@ export const defaultPgPoolFactory: PgPoolFactory = (pgConfig): pg.Pool => { pgPool.on('error', (err: Error & { code?: string }) => { if (err.code === '57P01') { // Expected during database cleanup - log at debug level - log.debug(`Pool ${database} connection terminated (expected during cleanup): ${err.message}`); + log.debug( + `Pool ${database} connection terminated (expected during cleanup): ${err.message}` + ); } else { // Unexpected pool error - log at error level for visibility // Note: This does NOT swallow query errors - those still throw via Promise rejection - log.error(`Pool ${database} unexpected idle connection error [${err.code || 'unknown'}]: ${err.message}`); + log.error( + `Pool ${database} unexpected idle connection error [${err.code || 'unknown'}]: ${err.message}` + ); } }); return pgPool; }; -export const getPgPool = (pgConfig: Partial & { pool?: PgPoolConfig }): pg.Pool => { - const config = getPgEnvOptions(pgConfig); - const { database } = config; - if (pgCache.has(database)) { - const cached = pgCache.get(database); +export interface PgPoolRuntimeOptions { + cache?: PgPoolCacheManager; + environment?: Readonly>; + namespace?: string; +} + +const digest = (value: unknown): string => + createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 24); + +export const getPgPoolCacheKey = ( + pgConfig: Partial & { pool?: PgPoolConfig }, + options: Omit = {} +): string => { + const environment = { ...(options.environment ?? process.env) }; + const config = getPgEnvOptions(pgConfig, environment); + const pool = getPgPoolConfig(pgConfig.pool, environment); + const namespace = digest(options.namespace ?? 'default'); + const connection = digest({ ...config, pool }); + return `pg:${namespace}:${config.database}:${connection}`; +}; + +export const getPgPool = ( + pgConfig: Partial & { pool?: PgPoolConfig }, + options: PgPoolRuntimeOptions = {} +): pg.Pool => { + const environment = { ...(options.environment ?? process.env) }; + const config = getPgEnvOptions(pgConfig, environment); + const poolConfig = getPgPoolConfig(pgConfig.pool, environment); + const cache = options.cache ?? pgCache; + const cacheKey = getPgPoolCacheKey( + { ...config, pool: poolConfig }, + { environment: {}, namespace: options.namespace } + ); + + if (cache.has(cacheKey)) { + const cached = cache.get(cacheKey); if (cached) return cached; } @@ -112,8 +164,8 @@ export const getPgPool = (pgConfig: Partial & { pool?: PgPoolConfig }) // factory may return any QueryablePool (e.g. an in-process PGlite pool); it is // treated as a pg.Pool since that is the only surface consumers use. const factory = getActivePgPoolFactory() ?? defaultPgPoolFactory; - const pgPool = factory(pgConfig) as pg.Pool; + const pgPool = factory({ ...config, pool: poolConfig }) as pg.Pool; - pgCache.set(database, pgPool); + cache.set(cacheKey, pgPool); return pgPool; }; From 9ac81cbd3d510c5bff20fd2e591411bf51ec4e00 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:37:45 +0700 Subject: [PATCH 03/18] fix(graphql): inject per-instance runtime environments Thread explicit environment, cache, diagnostic, and lifecycle ownership through embedded GraphQL and Graphile services. Scope logger suppression to the current async operation and keep public-key signature authentication fail-closed. --- .../PublicKeySignature.fail-closed.test.ts | 59 +++ .../__tests__/meta-schema-concurrency.test.ts | 48 ++ .../__tests__/runtime-environment.test.ts | 116 +++++ .../__tests__/upload-resolver.test.ts | 40 +- graphile/graphile-settings/package.json | 2 +- .../src/bucket-provisioner-resolver.ts | 74 ++- graphile/graphile-settings/src/index.ts | 17 +- .../src/plugins/PublicKeySignature.ts | 114 +++-- .../graphile-settings/src/plugins/index.ts | 17 +- .../src/plugins/inflector-logger.ts | 15 +- .../src/plugins/meta-schema.ts | 1 + .../src/plugins/meta-schema/cache.ts | 28 +- .../src/plugins/meta-schema/plugin.ts | 13 +- .../src/presets/constructive-preset.ts | 50 +- .../src/presigned-url-resolver.ts | 163 +++--- .../src/runtime-environment.ts | 135 +++++ .../graphile-settings/src/runtime-options.ts | 17 + .../graphile-settings/src/upload-resolver.ts | 240 ++++----- .../src/__tests__/server-lifecycle.test.ts | 151 ++++++ graphql/explorer/src/server.ts | 349 +++++++++++-- graphql/explorer/src/settings.ts | 11 +- graphql/query/package.json | 2 +- graphql/server/package.json | 2 +- .../src/__tests__/cache-ownership.test.ts | 78 +++ .../src/__tests__/runtime-environment.test.ts | 59 +++ .../__tests__/debug-db-scope.test.ts | 28 ++ .../src/diagnostics/debug-db-snapshot.ts | 82 ++- .../src/diagnostics/debug-memory-snapshot.ts | 59 ++- .../server/src/diagnostics/debug-sampler.ts | 124 +++-- .../server/src/diagnostics/observability.ts | 46 +- graphql/server/src/index.ts | 7 +- .../src/middleware/__tests__/api.test.ts | 57 ++- .../src/middleware/__tests__/routing.test.ts | 164 ++++-- graphql/server/src/middleware/api.ts | 305 +++++++++--- graphql/server/src/middleware/auth.ts | 52 +- graphql/server/src/middleware/captcha.ts | 45 +- graphql/server/src/middleware/cookie.ts | 42 +- graphql/server/src/middleware/flush.ts | 69 ++- graphql/server/src/middleware/graphile.ts | 382 ++++++++------ .../__tests__/graphile-build-stats.test.ts | 30 +- .../src/middleware/observability/debug-db.ts | 12 +- .../middleware/observability/debug-memory.ts | 19 +- .../observability/graphile-build-stats.ts | 69 ++- graphql/server/src/runtime-environment.ts | 28 ++ graphql/server/src/server.ts | 465 +++++++++++++++--- packages/express-context/package.json | 2 +- packages/express-context/src/context.ts | 40 +- pgpm/env/src/env.ts | 183 ++++--- pgpm/logger/__tests__/suppression.test.ts | 22 + pgpm/logger/src/index.ts | 12 +- pgpm/logger/src/logger.ts | 67 ++- postgres/pg-env/src/env.ts | 25 +- 52 files changed, 3205 insertions(+), 1032 deletions(-) create mode 100644 graphile/graphile-settings/__tests__/PublicKeySignature.fail-closed.test.ts create mode 100644 graphile/graphile-settings/__tests__/meta-schema-concurrency.test.ts create mode 100644 graphile/graphile-settings/__tests__/runtime-environment.test.ts create mode 100644 graphile/graphile-settings/src/runtime-environment.ts create mode 100644 graphile/graphile-settings/src/runtime-options.ts create mode 100644 graphql/explorer/src/__tests__/server-lifecycle.test.ts create mode 100644 graphql/server/src/__tests__/cache-ownership.test.ts create mode 100644 graphql/server/src/__tests__/runtime-environment.test.ts create mode 100644 graphql/server/src/diagnostics/__tests__/debug-db-scope.test.ts create mode 100644 graphql/server/src/runtime-environment.ts create mode 100644 pgpm/logger/__tests__/suppression.test.ts diff --git a/graphile/graphile-settings/__tests__/PublicKeySignature.fail-closed.test.ts b/graphile/graphile-settings/__tests__/PublicKeySignature.fail-closed.test.ts new file mode 100644 index 0000000000..f4daba3fe9 --- /dev/null +++ b/graphile/graphile-settings/__tests__/PublicKeySignature.fail-closed.test.ts @@ -0,0 +1,59 @@ +let capturedVerifyCallback: + | ((value: { input: Record }) => Promise) + | undefined; + +jest.mock('grafast', () => ({ + context: jest.fn(() => ({ get: jest.fn() })), + lambda: jest.fn((_step: unknown, callback: typeof capturedVerifyCallback) => { + capturedVerifyCallback = callback; + return 'lambda-step'; + }), + object: jest.fn((value: unknown) => value), +})); + +jest.mock('graphile-utils', () => ({ + extendSchema: jest.fn((factory: () => any) => { + const schema = factory(); + schema.plans.Mutation.verifyMessageForSigning(null, { + getRaw: () => 'input-step', + }); + return { name: 'PublicKeySignatureTestPlugin', schema: { hooks: {} } }; + }), + gql: jest.fn((strings: TemplateStringsArray) => strings.join('')), +})); + +import { PublicKeySignature } from '../src/plugins/PublicKeySignature'; + +describe('PublicKeySignature verification', () => { + it('cannot be enabled through ambient environment state', async () => { + const previous = process.env.ENABLE_SIGNATURE_VERIFICATION; + process.env.ENABLE_SIGNATURE_VERIFICATION = 'true'; + try { + PublicKeySignature({ + schema: 'app_private', + crypto_network: 'btc', + sign_up_with_key: 'sign_up_with_key', + sign_in_request_challenge: 'sign_in_request_challenge', + sign_in_record_failure: 'sign_in_record_failure', + sign_in_with_challenge: 'sign_in_with_challenge', + }); + + expect(capturedVerifyCallback).toBeDefined(); + await expect( + capturedVerifyCallback!({ + input: { + publicKey: 'public-key', + message: 'challenge', + signature: 'unverified-signature', + }, + }) + ).rejects.toThrow('FEATURE_DISABLED'); + } finally { + if (previous === undefined) { + delete process.env.ENABLE_SIGNATURE_VERIFICATION; + } else { + process.env.ENABLE_SIGNATURE_VERIFICATION = previous; + } + } + }); +}); diff --git a/graphile/graphile-settings/__tests__/meta-schema-concurrency.test.ts b/graphile/graphile-settings/__tests__/meta-schema-concurrency.test.ts new file mode 100644 index 0000000000..47d97dd274 --- /dev/null +++ b/graphile/graphile-settings/__tests__/meta-schema-concurrency.test.ts @@ -0,0 +1,48 @@ +import { + captureTablesMeta, + getCachedTablesMeta, + setCachedTablesMeta, +} from '../src/plugins/meta-schema/cache'; +import type { TableMeta } from '../src/plugins/meta-schema/types'; + +const table = (name: string): TableMeta => ({ name }) as TableMeta; + +describe('meta schema build isolation', () => { + it('keeps interleaved build metadata in its originating async scope', async () => { + const global = [table('global')]; + const firstMeta = [table('first')]; + const secondMeta = [table('second')]; + setCachedTablesMeta(global); + + let notifyFirstSet!: () => void; + const firstSet = new Promise((resolve) => { + notifyFirstSet = resolve; + }); + let releaseFirst!: () => void; + const firstMayFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = captureTablesMeta(async () => { + setCachedTablesMeta(firstMeta); + notifyFirstSet(); + await firstMayFinish; + return getCachedTablesMeta(); + }); + + await firstSet; + const second = captureTablesMeta(async () => { + setCachedTablesMeta(secondMeta); + releaseFirst(); + await Promise.resolve(); + return getCachedTablesMeta(); + }); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult.result).toEqual(firstMeta); + expect(firstResult.tablesMeta).toEqual(firstMeta); + expect(secondResult.result).toEqual(secondMeta); + expect(secondResult.tablesMeta).toEqual(secondMeta); + expect(getCachedTablesMeta()).toEqual(global); + }); +}); diff --git a/graphile/graphile-settings/__tests__/runtime-environment.test.ts b/graphile/graphile-settings/__tests__/runtime-environment.test.ts new file mode 100644 index 0000000000..4cf56d32f3 --- /dev/null +++ b/graphile/graphile-settings/__tests__/runtime-environment.test.ts @@ -0,0 +1,116 @@ +import { getBucketProvisionerConnection } from '../src/bucket-provisioner-resolver'; +import { + createBucketNameResolver, + getAllowedOrigins, + getPresignedUrlS3Config, +} from '../src/presigned-url-resolver'; +import { + getGraphileSettingsRuntimeResource, + withGraphileSettingsRuntime, +} from '../src/runtime-environment'; +import { getGraphileSettingsRuntimeOptions } from '../src/runtime-options'; + +const environment = (name: string): NodeJS.ProcessEnv => ({ + NODE_ENV: 'test', + BUCKET_PROVIDER: 'minio', + BUCKET_NAME: `${name}-bucket`, + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY: `${name}-access`, + AWS_SECRET_KEY: `${name}-secret`, + CDN_ENDPOINT: `http://${name}.storage.test:9000`, + SERVER_ORIGIN: `https://${name}.app.test`, +}); + +describe('graphile-settings runtime isolation', () => { + it('refuses to create credential-bearing resources outside a scope', () => { + expect(() => getBucketProvisionerConnection()).toThrow( + 'GRAPHILE_SETTINGS_RUNTIME_REQUIRED' + ); + expect(() => getPresignedUrlS3Config()).toThrow( + 'GRAPHILE_SETTINGS_RUNTIME_REQUIRED' + ); + }); + + it('isolates concurrent environments and caches only within each scope', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const run = (name: string) => + withGraphileSettingsRuntime( + { cwd: process.cwd(), env: environment(name) }, + async () => { + const firstOptions = getGraphileSettingsRuntimeOptions(); + const firstConnection = getBucketProvisionerConnection(); + const firstS3 = getPresignedUrlS3Config(); + const resolveBucketName = createBucketNameResolver(); + + await gate; + + return { + firstOptions, + secondOptions: getGraphileSettingsRuntimeOptions(), + firstConnection, + secondConnection: getBucketProvisionerConnection(), + firstS3, + secondS3: getPresignedUrlS3Config(), + bucketName: resolveBucketName('database-id', 'private'), + origins: getAllowedOrigins(), + }; + } + ); + + const firstPromise = run('first'); + const secondPromise = run('second'); + release(); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + + expect(first.firstOptions).toBe(first.secondOptions); + expect(second.firstOptions).toBe(second.secondOptions); + expect(first.firstConnection).toBe(first.secondConnection); + expect(first.firstS3).toBe(first.secondS3); + expect(second.firstConnection).toBe(second.secondConnection); + expect(second.firstS3).toBe(second.secondS3); + + expect(first.firstConnection).not.toBe(second.firstConnection); + expect(first.firstS3).not.toBe(second.firstS3); + expect(first.firstS3.client).not.toBe(second.firstS3.client); + + expect(first.firstConnection).toMatchObject({ + accessKeyId: 'first-access', + secretAccessKey: 'first-secret', + endpoint: 'http://first.storage.test:9000', + }); + expect(second.firstConnection).toMatchObject({ + accessKeyId: 'second-access', + secretAccessKey: 'second-secret', + endpoint: 'http://second.storage.test:9000', + }); + expect(first.firstS3.bucket).toBe('first-bucket'); + expect(second.firstS3.bucket).toBe('second-bucket'); + expect(first.bucketName).toBe('first-bucket-private-database-id'); + expect(second.bucketName).toBe('second-bucket-private-database-id'); + expect(first.origins).toEqual(['https://first.app.test']); + expect(second.origins).toEqual(['https://second.app.test']); + }); + + it('disposes each scoped resource exactly once', async () => { + const key = Symbol('test-resource'); + const create = jest.fn(() => ({ id: 'resource' })); + const dispose = jest.fn(); + + await withGraphileSettingsRuntime( + { cwd: process.cwd(), env: environment('dispose') }, + async () => { + expect(getGraphileSettingsRuntimeResource(key, create, dispose)).toBe( + getGraphileSettingsRuntimeResource(key, create, dispose) + ); + } + ); + + expect(create).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphile/graphile-settings/__tests__/upload-resolver.test.ts b/graphile/graphile-settings/__tests__/upload-resolver.test.ts index 1b64f119d5..3af4dfc04e 100644 --- a/graphile/graphile-settings/__tests__/upload-resolver.test.ts +++ b/graphile/graphile-settings/__tests__/upload-resolver.test.ts @@ -45,6 +45,7 @@ async function loadUploadResolverModule(opts: { upload: mockUpload, uploadWithContentType: mockUploadWithContentType, detectContentType: mockDetectContentType, + destroy: jest.fn(), })); return { __esModule: true, @@ -53,9 +54,12 @@ async function loadUploadResolverModule(opts: { }); const mod = await import('../src/upload-resolver'); + const { withGraphileSettingsRuntime } = + await import('../src/runtime-environment'); return { ...mod, + withGraphileSettingsRuntime, mockDetectContentType, mockUploadWithContentType, mockUpload, @@ -75,12 +79,13 @@ describe('uploadResolver MIME validation', () => { constructiveUploadFieldDefinitions, mockDetectContentType, mockUploadWithContentType, + withGraphileSettingsRuntime, } = await loadUploadResolverModule({ detectedContentType: 'application/pdf', }); const imageDef = constructiveUploadFieldDefinitions.find( - (def) => 'name' in def && def.name === 'image', + (def) => 'name' in def && def.name === 'image' ); if (!imageDef) { throw new Error('Missing image upload field definition'); @@ -89,12 +94,14 @@ describe('uploadResolver MIME validation', () => { const fakeUpload = makeFakeUpload('document.pdf'); await expect( - imageDef.resolve( - fakeUpload as any, - {}, - {}, - { uploadPlugin: { tags: {}, type: 'image' } }, - ), + withGraphileSettingsRuntime({ env: {}, cwd: process.cwd() }, () => + imageDef.resolve( + fakeUpload as any, + {}, + {}, + { uploadPlugin: { tags: {}, type: 'image' } } + ) + ) ).rejects.toThrow('UPLOAD_MIMETYPE'); expect(mockDetectContentType).toHaveBeenCalledTimes(1); @@ -106,13 +113,14 @@ describe('uploadResolver MIME validation', () => { constructiveUploadFieldDefinitions, mockDetectContentType, mockUploadWithContentType, + withGraphileSettingsRuntime, } = await loadUploadResolverModule({ detectedContentType: 'image/png', uploadResultContentType: 'image/png', }); const imageDef = constructiveUploadFieldDefinitions.find( - (def) => 'name' in def && def.name === 'image', + (def) => 'name' in def && def.name === 'image' ); if (!imageDef) { throw new Error('Missing image upload field definition'); @@ -120,11 +128,15 @@ describe('uploadResolver MIME validation', () => { const fakeUpload = makeFakeUpload('photo.png'); - const result = await imageDef.resolve( - fakeUpload as any, - {}, - {}, - { uploadPlugin: { tags: {}, type: 'image' } }, + const result = await withGraphileSettingsRuntime( + { env: {}, cwd: process.cwd() }, + () => + imageDef.resolve( + fakeUpload as any, + {}, + {}, + { uploadPlugin: { tags: {}, type: 'image' } } + ) ); expect(result).toEqual({ @@ -137,7 +149,7 @@ describe('uploadResolver MIME validation', () => { expect(mockUploadWithContentType).toHaveBeenCalledWith( expect.objectContaining({ contentType: 'image/png', - }), + }) ); }); }); diff --git a/graphile/graphile-settings/package.json b/graphile/graphile-settings/package.json index 56aae4f925..a9004cef98 100644 --- a/graphile/graphile-settings/package.json +++ b/graphile/graphile-settings/package.json @@ -64,7 +64,7 @@ "graphile-utils": "5.0.1", "graphql": "16.13.0", "inflekt": "^0.7.1", - "lru-cache": "^11.2.7", + "lru-cache": "^10.4.3", "pg": "^8.21.0", "pg-query-context": "workspace:^", "pg-sql2": "5.0.1", diff --git a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts b/graphile/graphile-settings/src/bucket-provisioner-resolver.ts index 28107a0d6d..56728beeb5 100644 --- a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts +++ b/graphile/graphile-settings/src/bucket-provisioner-resolver.ts @@ -8,55 +8,53 @@ * Follows the same lazy-init pattern as presigned-url-resolver.ts. */ -import { getEnvOptions } from '@constructive-io/graphql-env'; import { Logger } from '@pgpmjs/logger'; import type { StorageConnectionConfig } from 'graphile-bucket-provisioner-plugin'; -const log = new Logger('bucket-provisioner-resolver'); +import { getGraphileSettingsRuntimeResource } from './runtime-environment'; +import { getGraphileSettingsRuntimeOptions } from './runtime-options'; -let connectionConfig: StorageConnectionConfig | null = null; +const log = new Logger('bucket-provisioner-resolver'); +const BUCKET_CONNECTION = Symbol('constructive.bucket-connection'); /** * Lazily initialize and return the StorageConnectionConfig for the * bucket provisioner plugin. * - * Reads CDN config on first call via getEnvOptions() (which already merges - * pgpmDefaults -> config file -> env vars) and caches the result. - * Same CDN config source as presigned-url-resolver.ts. + * Reads CDN config on first use in each operation/server runtime and caches it + * only for that scope. Concurrent runtimes cannot share credentials. */ export function getBucketProvisionerConnection(): StorageConnectionConfig { - if (connectionConfig) return connectionConfig; - - const { cdn } = getEnvOptions(); - - if (!cdn) { - throw new Error( - '[bucket-provisioner-resolver] CDN config not found. ' + - 'Ensure CDN environment variables (AWS_ACCESS_KEY, AWS_SECRET_KEY, etc.) ' + - 'are set or that pgpmDefaults provides CDN fields.', + return getGraphileSettingsRuntimeResource(BUCKET_CONNECTION, () => { + const { cdn } = getGraphileSettingsRuntimeOptions(); + + if (!cdn) { + throw new Error( + '[bucket-provisioner-resolver] CDN config not found. ' + + 'Ensure CDN environment variables (AWS_ACCESS_KEY, AWS_SECRET_KEY, etc.) ' + + 'are set or that pgpmDefaults provides CDN fields.' + ); + } + + const { provider, awsRegion, awsAccessKey, awsSecretKey, endpoint } = cdn; + + if (!awsAccessKey || !awsSecretKey) { + throw new Error( + '[bucket-provisioner-resolver] Missing S3 credentials. ' + + 'Set AWS_ACCESS_KEY and AWS_SECRET_KEY environment variables.' + ); + } + + log.info( + `[bucket-provisioner-resolver] Initializing: provider=${provider} endpoint=${endpoint}` ); - } - - const { provider, awsRegion, awsAccessKey, awsSecretKey, endpoint } = cdn; - - if (!awsAccessKey || !awsSecretKey) { - throw new Error( - '[bucket-provisioner-resolver] Missing S3 credentials. ' + - 'Set AWS_ACCESS_KEY and AWS_SECRET_KEY environment variables.', - ); - } - - log.info( - `[bucket-provisioner-resolver] Initializing: provider=${provider} endpoint=${endpoint}`, - ); - - connectionConfig = { - provider: (provider as StorageConnectionConfig['provider']) || 'minio', - region: awsRegion || 'us-east-1', - accessKeyId: awsAccessKey, - secretAccessKey: awsSecretKey, - ...(endpoint ? { endpoint, forcePathStyle: true } : {}), - }; - return connectionConfig; + return { + provider: (provider as StorageConnectionConfig['provider']) || 'minio', + region: awsRegion || 'us-east-1', + accessKeyId: awsAccessKey, + secretAccessKey: awsSecretKey, + ...(endpoint ? { endpoint, forcePathStyle: true } : {}), + }; + }); } diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index 0c5740aa4d..cd68ba3840 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -42,7 +42,10 @@ import 'graphile-build'; // ============================================================================ // Main preset + factory -export { ConstructivePreset, createConstructivePreset } from './presets/constructive-preset'; +export { + ConstructivePreset, + createConstructivePreset, +} from './presets/constructive-preset'; export type { ConstructivePresetOptions } from './presets/constructive-preset'; // Re-export all plugins for convenience @@ -58,6 +61,18 @@ export * from './presets/index'; // Re-export makePgService for convenience export { makePgService }; +export { + getGraphileSettingsRuntime, + getGraphileSettingsRuntimeResource, + hasGraphileSettingsRuntime, + withGraphileSettingsRuntime, +} from './runtime-environment'; +export type { + GraphileSettingsEnvironment, + GraphileSettingsRuntimeOptions, +} from './runtime-environment'; +export { getGraphileSettingsRuntimeOptions } from './runtime-options'; + // Presigned URL utilities export { getPresignedUrlS3Config } from './presigned-url-resolver'; diff --git a/graphile/graphile-settings/src/plugins/PublicKeySignature.ts b/graphile/graphile-settings/src/plugins/PublicKeySignature.ts index 95683f8281..733c837100 100644 --- a/graphile/graphile-settings/src/plugins/PublicKeySignature.ts +++ b/graphile/graphile-settings/src/plugins/PublicKeySignature.ts @@ -21,14 +21,16 @@ const SAFE_CRYPTO_NETWORK = /^[a-z0-9_-]{1,64}$/i; function validateIdentifier(name: string, label: string): void { if (!SAFE_IDENTIFIER.test(name)) { - throw new Error(`PublicKeySignature: invalid ${label} "${name}" — must match /^[a-z_][a-z0-9_]*$/`); + throw new Error( + `PublicKeySignature: invalid ${label} "${name}" — must match /^[a-z_][a-z0-9_]*$/` + ); } } function validateCryptoNetwork(name: string): void { if (!SAFE_CRYPTO_NETWORK.test(name)) { throw new Error( - 'PublicKeySignature: invalid crypto_network — must match /^[a-z0-9_-]{1,64}$/i', + 'PublicKeySignature: invalid crypto_network — must match /^[a-z0-9_-]{1,64}$/i' ); } } @@ -36,16 +38,17 @@ function validateCryptoNetwork(name: string): void { const MAX_PUBLIC_KEY_LENGTH = 256; const MAX_MESSAGE_LENGTH = 4096; const MAX_SIGNATURE_LENGTH = 1024; -const ENABLE_SIGNATURE_VERIFICATION = process.env.ENABLE_SIGNATURE_VERIFICATION === 'true'; -export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): GraphileConfig.Plugin => { +export const PublicKeySignature = ( + pubkey_challenge: PublicKeyChallengeConfig +): GraphileConfig.Plugin => { const { schema, crypto_network, sign_up_with_key, sign_in_request_challenge, sign_in_record_failure, - sign_in_with_challenge + sign_in_with_challenge, } = pubkey_challenge; validateIdentifier(schema, 'schema'); @@ -103,10 +106,17 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): createUserAccountWithPublicKey(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + }); return lambda($combined, async ({ input, withPgClient }: any) => { - if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) { + if ( + !input.publicKey || + typeof input.publicKey !== 'string' || + input.publicKey.length > MAX_PUBLIC_KEY_LENGTH + ) { throw new Error('INVALID_PUBLIC_KEY'); } @@ -118,17 +128,17 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): context: { role: 'anonymous' }, query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_up_with_key)}($1)`, variables: [input.publicKey], - skipTransaction: true + skipTransaction: true, }); const { - rows: [{ [sign_in_request_challenge]: message }] + rows: [{ [sign_in_request_challenge]: message }], } = await pgQueryWithContext({ client: pgClient, context: { role: 'anonymous' }, query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`, variables: [input.publicKey], - skipTransaction: true + skipTransaction: true, }); await pgClient.query('COMMIT'); @@ -144,21 +154,28 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): getMessageForSigning(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + }); return lambda($combined, async ({ input, withPgClient }: any) => { - if (!input.publicKey || typeof input.publicKey !== 'string' || input.publicKey.length > MAX_PUBLIC_KEY_LENGTH) { + if ( + !input.publicKey || + typeof input.publicKey !== 'string' || + input.publicKey.length > MAX_PUBLIC_KEY_LENGTH + ) { throw new Error('INVALID_PUBLIC_KEY'); } return withPgClient(null, async (pgClient: any) => { const { - rows: [{ [sign_in_request_challenge]: message }] + rows: [{ [sign_in_request_challenge]: message }], } = await pgQueryWithContext({ client: pgClient, context: { role: 'anonymous' }, query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_request_challenge)}($1)`, - variables: [input.publicKey] + variables: [input.publicKey], }); if (!message) throw new Error('NO_ACCOUNT_EXISTS'); @@ -168,62 +185,43 @@ export const PublicKeySignature = (pubkey_challenge: PublicKeyChallengeConfig): }); }, - // NOTE: Verification remains behind a feature flag until crypto - // verification is re-implemented. + // Signature verification must remain fail-closed until a cryptographic + // verifier is implemented. A runtime flag cannot safely enable the old + // path because that path never used the submitted signature. verifyMessageForSigning(_$mutation: any, fieldArgs: any) { const $input = fieldArgs.getRaw('input'); - const $withPgClient = (grafastContext() as any).get('withPgClient'); - const $combined = object({ input: $input, withPgClient: $withPgClient }); + const $combined = object({ input: $input }); - return lambda($combined, async ({ input, withPgClient }: any) => { - const { publicKey, message, signature: _signature } = input; + return lambda($combined, async ({ input }: any) => { + const { publicKey, message, signature } = input; - if (!publicKey || typeof publicKey !== 'string' || publicKey.length > MAX_PUBLIC_KEY_LENGTH) { + if ( + !publicKey || + typeof publicKey !== 'string' || + publicKey.length > MAX_PUBLIC_KEY_LENGTH + ) { throw new Error('INVALID_PUBLIC_KEY'); } - if (!message || typeof message !== 'string' || message.length > MAX_MESSAGE_LENGTH) { + if ( + !message || + typeof message !== 'string' || + message.length > MAX_MESSAGE_LENGTH + ) { throw new Error('INVALID_MESSAGE'); } - if (!_signature || typeof _signature !== 'string' || _signature.length > MAX_SIGNATURE_LENGTH) { + if ( + !signature || + typeof signature !== 'string' || + signature.length > MAX_SIGNATURE_LENGTH + ) { throw new Error('INVALID_SIGNATURE'); } - if (!ENABLE_SIGNATURE_VERIFICATION) { - // Fail closed without mutating lockout counters while verification - // is disabled. - throw new Error('FEATURE_DISABLED'); - } - - return withPgClient(null, async (pgClient: any) => { - // Only the success path needs a transaction (multi-step) - await pgClient.query('BEGIN'); - try { - const { - rows: [token] - } = await pgQueryWithContext({ - client: pgClient, - context: { role: 'anonymous' }, - query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(schema, sign_in_with_challenge)}($1, $2)`, - variables: [publicKey, message], - skipTransaction: true - }); - - if (!token?.access_token) throw new Error('BAD_SIGNIN'); - - await pgClient.query('COMMIT'); - return { - access_token: token.access_token, - access_token_expires_at: token.access_token_expires_at - }; - } catch (err) { - await pgClient.query('ROLLBACK'); - throw err; - } - }); + throw new Error('FEATURE_DISABLED'); }); - } - } - } + }, + }, + }, })); }; diff --git a/graphile/graphile-settings/src/plugins/index.ts b/graphile/graphile-settings/src/plugins/index.ts index f086b9af3b..2ddcf45508 100644 --- a/graphile/graphile-settings/src/plugins/index.ts +++ b/graphile/graphile-settings/src/plugins/index.ts @@ -8,10 +8,7 @@ export { MinimalPreset } from './minimal-preset'; // Custom inflector using inflekt library -export { - InflektPlugin, - InflektPreset, -} from './custom-inflector'; +export { InflektPlugin, InflektPreset } from './custom-inflector'; // Conflict detector for multi-schema setups export { @@ -49,6 +46,7 @@ export type { UniqueLookupOptions } from './primary-key-only'; // Meta schema plugin for introspection (tables, fields, indexes, constraints) export { + captureTablesMeta, MetaSchemaPlugin, MetaSchemaPreset, } from './meta-schema'; @@ -70,10 +68,7 @@ export type { } from './meta-schema/types'; // PG type mappings for custom PostgreSQL types (email, url, etc.) -export { - PgTypeMappingsPlugin, - PgTypeMappingsPreset, -} from './pg-type-mappings'; +export { PgTypeMappingsPlugin, PgTypeMappingsPreset } from './pg-type-mappings'; export type { TypeMapping } from './pg-type-mappings'; // Public key signature plugin for crypto authentication @@ -81,7 +76,11 @@ export { PublicKeySignature } from './PublicKeySignature'; export type { PublicKeyChallengeConfig } from './PublicKeySignature'; // Internal exports for testing -export { _pgTypeToGqlType, _buildFieldMeta, _cachedTablesMeta } from './meta-schema'; +export { + _pgTypeToGqlType, + _buildFieldMeta, + _cachedTablesMeta, +} from './meta-schema'; // Required input plugin - makes @requiredInput tagged fields non-nullable in mutation inputs export { diff --git a/graphile/graphile-settings/src/plugins/inflector-logger.ts b/graphile/graphile-settings/src/plugins/inflector-logger.ts index 5bfcfe18b3..f9840092a6 100644 --- a/graphile/graphile-settings/src/plugins/inflector-logger.ts +++ b/graphile/graphile-settings/src/plugins/inflector-logger.ts @@ -1,4 +1,7 @@ import type { GraphileConfig } from 'graphile-config'; +import { Logger } from '@pgpmjs/logger'; + +import { getGraphileSettingsRuntime } from '../runtime-environment'; /** * InflectorLoggerPlugin - Logs inflector calls during schema build for debugging. @@ -35,12 +38,16 @@ import type { GraphileConfig } from 'graphile-config'; * Set INFLECTOR_LOG=1 environment variable to enable logging. */ -const LOG_ENABLED = process.env.INFLECTOR_LOG === '1'; +const logger = new Logger('inflector'); -function log(category: string, message: string, details?: Record) { - if (!LOG_ENABLED) return; +function log( + category: string, + message: string, + details?: Record +) { + if (getGraphileSettingsRuntime().env.INFLECTOR_LOG !== '1') return; const detailsStr = details ? ` ${JSON.stringify(details)}` : ''; - console.log(`[Inflector:${category}]${detailsStr} => ${message}`); + logger.info(`[Inflector:${category}]${detailsStr} => ${message}`); } export const InflectorLoggerPlugin: GraphileConfig.Plugin = { diff --git a/graphile/graphile-settings/src/plugins/meta-schema.ts b/graphile/graphile-settings/src/plugins/meta-schema.ts index 283fb5af6f..ace05aca8c 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema.ts @@ -12,6 +12,7 @@ import { MetaSchemaPlugin } from './meta-schema/plugin'; import { buildFieldMeta, pgTypeToGqlType } from './meta-schema/type-mappings'; export { MetaSchemaPlugin }; +export { captureTablesMeta } from './meta-schema/cache'; export const MetaSchemaPreset: GraphileConfig.Preset = { plugins: [MetaSchemaPlugin], diff --git a/graphile/graphile-settings/src/plugins/meta-schema/cache.ts b/graphile/graphile-settings/src/plugins/meta-schema/cache.ts index 3e9f909b7a..91aed443f4 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/cache.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/cache.ts @@ -1,11 +1,35 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import type { TableMeta } from './types'; export let cachedTablesMeta: TableMeta[] = []; +interface TablesMetaScope { + tablesMeta: TableMeta[]; +} + +const tablesMetaStorage = new AsyncLocalStorage(); + export function getCachedTablesMeta(): TableMeta[] { - return cachedTablesMeta; + return tablesMetaStorage.getStore()?.tablesMeta ?? cachedTablesMeta; } export function setCachedTablesMeta(tablesMeta: TableMeta[]): void { - cachedTablesMeta = tablesMeta; + const scope = tablesMetaStorage.getStore(); + if (scope) { + scope.tablesMeta = tablesMeta; + } else { + // Compatibility fallback for consumers that invoke the plugin without a + // build scope. Graphile-schema always uses captureTablesMeta. + cachedTablesMeta = tablesMeta; + } +} + +/** Capture metadata produced by one asynchronous schema build in isolation. */ +export async function captureTablesMeta( + callback: () => Promise +): Promise<{ result: T; tablesMeta: TableMeta[] }> { + const scope: TablesMetaScope = { tablesMeta: [] }; + const result = await tablesMetaStorage.run(scope, callback); + return { result, tablesMeta: [...scope.tablesMeta] }; } diff --git a/graphile/graphile-settings/src/plugins/meta-schema/plugin.ts b/graphile/graphile-settings/src/plugins/meta-schema/plugin.ts index 2144c323fc..a6f8da23b9 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/plugin.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/plugin.ts @@ -10,6 +10,11 @@ interface QueryTypeContext { }; } +const tablesMetaByBuild = new WeakMap< + object, + ReturnType +>(); + export const MetaSchemaPlugin: GraphileConfig.Plugin = { name: 'MetaSchemaPlugin', version: '1.0.0', @@ -18,16 +23,18 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = { hooks: { init(input, rawBuild) { const build = rawBuild as unknown as MetaBuild; - setCachedTablesMeta(collectTablesMeta(build)); + const tablesMeta = collectTablesMeta(build); + tablesMetaByBuild.set(rawBuild as object, tablesMeta); + setCachedTablesMeta(tablesMeta); return input; }, - GraphQLObjectType_fields(rawFields, _rawBuild, rawContext) { + GraphQLObjectType_fields(rawFields, rawBuild, rawContext) { const context = rawContext as unknown as QueryTypeContext; if (context.Self?.name !== 'Query') return rawFields; return extendQueryWithMetaField( rawFields as unknown as Record, - getCachedTablesMeta(), + tablesMetaByBuild.get(rawBuild as object) ?? getCachedTablesMeta() ) as typeof rawFields; }, }, diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index f06a20939f..9329b3f0c9 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -3,12 +3,22 @@ import { BulkMutationPreset } from 'graphile-bulk-mutations'; import type { GraphileConfig } from 'graphile-config'; import { ConnectionFilterPreset } from 'graphile-connection-filter'; import { I18nPreset } from 'graphile-i18n'; -import { createFolderOperatorFactory, GraphileLtreePreset } from 'graphile-ltree'; +import { + createFolderOperatorFactory, + GraphileLtreePreset, +} from 'graphile-ltree'; import { PgAggregatesPreset } from 'graphile-pg-aggregates'; -import { createPostgisOperatorFactory,GraphilePostgisPreset } from 'graphile-postgis'; +import { + createPostgisOperatorFactory, + GraphilePostgisPreset, +} from 'graphile-postgis'; import { PresignedUrlPreset } from 'graphile-presigned-url-plugin'; import { RealtimeSubscriptionsPreset } from 'graphile-realtime-subscriptions'; -import { createMatchesOperatorFactory, createTrgmOperatorFactories,UnifiedSearchPreset } from 'graphile-search'; +import { + createMatchesOperatorFactory, + createTrgmOperatorFactories, + UnifiedSearchPreset, +} from 'graphile-search'; import { GraphileLlmPreset } from 'graphile-llm'; import { UploadPreset } from 'graphile-upload-plugin'; @@ -23,9 +33,14 @@ import { MinimalPreset, NoUniqueLookupPreset, PgTypeMappingsPreset, - RequiredInputPreset + RequiredInputPreset, } from '../plugins'; -import { createBucketNameResolver, createEnsureBucketProvisioned, getAllowedOrigins,getPresignedUrlS3Config } from '../presigned-url-resolver'; +import { + createBucketNameResolver, + createEnsureBucketProvisioned, + getAllowedOrigins, + getPresignedUrlS3Config, +} from '../presigned-url-resolver'; import { constructiveUploadFieldDefinitions } from '../upload-resolver'; /** @@ -64,7 +79,7 @@ const DEFAULTS: Required = { enableLlm: true, enableRealtime: false, enableBulk: false, - enableI18n: false + enableI18n: false, }; /** @@ -137,7 +152,7 @@ export function createConstructivePreset( NoUniqueLookupPreset, MetaSchemaPreset, PgTypeMappingsPreset, - RequiredInputPreset + RequiredInputPreset, ]; if (opts.enableConnectionFilter) { @@ -153,7 +168,10 @@ export function createConstructivePreset( if (opts.enableSearch) { presets.push( - UnifiedSearchPreset({ fullTextScalarName: 'FullText', tsConfig: 'english' }) + UnifiedSearchPreset({ + fullTextScalarName: 'FullText', + tsConfig: 'english', + }) ); } @@ -169,7 +187,7 @@ export function createConstructivePreset( presets.push( UploadPreset({ uploadFieldDefinitions: constructiveUploadFieldDefinitions, - maxFileSize: 10 * 1024 * 1024 // 10MB + maxFileSize: 10 * 1024 * 1024, // 10MB }) ); } @@ -179,11 +197,11 @@ export function createConstructivePreset( PresignedUrlPreset({ s3: getPresignedUrlS3Config, resolveBucketName: createBucketNameResolver(), - ensureBucketProvisioned: createEnsureBucketProvisioned() + ensureBucketProvisioned: createEnsureBucketProvisioned(), }), BucketProvisionerPreset({ connection: getBucketProvisionerConnection, - allowedOrigins: getAllowedOrigins() + allowedOrigins: getAllowedOrigins, }) ); } @@ -232,7 +250,10 @@ export function createConstructivePreset( // When connection filter is enabled it replaces the built-in condition arg. const disablePlugins: string[] = []; if (opts.enableConnectionFilter) { - disablePlugins.push('PgConditionArgumentPlugin', 'PgConditionCustomFieldsPlugin'); + disablePlugins.push( + 'PgConditionArgumentPlugin', + 'PgConditionCustomFieldsPlugin' + ); } // ----- schema options ----- @@ -248,7 +269,7 @@ export function createConstructivePreset( } const preset: GraphileConfig.Preset = { - extends: presets + extends: presets, }; if (disablePlugins.length > 0) { @@ -266,6 +287,7 @@ export function createConstructivePreset( * Default Constructive preset -- everything enabled except aggregates. * Backwards-compatible: identical to the previous static ConstructivePreset. */ -export const ConstructivePreset: GraphileConfig.Preset = createConstructivePreset(); +export const ConstructivePreset: GraphileConfig.Preset = + createConstructivePreset(); export default ConstructivePreset; diff --git a/graphile/graphile-settings/src/presigned-url-resolver.ts b/graphile/graphile-settings/src/presigned-url-resolver.ts index 75a44503c3..bb50af5ec2 100644 --- a/graphile/graphile-settings/src/presigned-url-resolver.ts +++ b/graphile/graphile-settings/src/presigned-url-resolver.ts @@ -12,15 +12,23 @@ */ import { createS3Client } from '@constructive-io/s3-utils'; -import { getEnvOptions } from '@constructive-io/graphql-env'; import { Logger } from '@pgpmjs/logger'; -import type { S3Config, BucketNameResolver, EnsureBucketProvisioned } from 'graphile-presigned-url-plugin'; import { BucketProvisioner } from '@constructive-io/bucket-provisioner'; +import type { + BucketNameResolver, + EnsureBucketProvisioned, + S3Config, +} from 'graphile-presigned-url-plugin'; + import { getBucketProvisionerConnection } from './bucket-provisioner-resolver'; +import { getGraphileSettingsRuntimeResource } from './runtime-environment'; +import { getGraphileSettingsRuntimeOptions } from './runtime-options'; const log = new Logger('presigned-url-resolver'); - -let s3Config: S3Config | null = null; +const PRESIGNED_S3_CONFIG = Symbol('constructive.presigned-s3-config'); +const ENSURE_BUCKET_PROVISIONER = Symbol( + 'constructive.ensure-bucket-provisioner' +); /** * Lazily initialize and return the S3Config for the presigned URL plugin. @@ -34,57 +42,70 @@ let s3Config: S3Config | null = null; * per-database bucket names take precedence for all S3 operations. */ export function getPresignedUrlS3Config(): S3Config { - if (s3Config) return s3Config; - - const { cdn } = getEnvOptions(); - - if (!cdn) { - throw new Error( - '[presigned-url-resolver] CDN config not found. ' + - 'Ensure CDN environment variables (AWS_ACCESS_KEY, AWS_SECRET_KEY, etc.) ' + - 'are set or that pgpmDefaults provides CDN fields.', - ); - } - - const { bucketName, awsRegion, awsAccessKey, awsSecretKey, endpoint, publicUrlPrefix } = cdn; - - if (!awsAccessKey || !awsSecretKey) { - throw new Error( - '[presigned-url-resolver] Missing S3 credentials. ' + - 'Set AWS_ACCESS_KEY and AWS_SECRET_KEY environment variables.', - ); - } - - if (!bucketName) { - throw new Error( - '[presigned-url-resolver] Missing CDN bucket name. ' + - 'Set CDN_BUCKET_NAME environment variable.', - ); - } + return getGraphileSettingsRuntimeResource( + PRESIGNED_S3_CONFIG, + () => { + const { cdn } = getGraphileSettingsRuntimeOptions(); + + if (!cdn) { + throw new Error( + '[presigned-url-resolver] CDN config not found. ' + + 'Ensure CDN environment variables (AWS_ACCESS_KEY, AWS_SECRET_KEY, etc.) ' + + 'are set or that pgpmDefaults provides CDN fields.' + ); + } + + const { + bucketName, + awsRegion, + awsAccessKey, + awsSecretKey, + endpoint, + publicUrlPrefix, + } = cdn; + + if (!awsAccessKey || !awsSecretKey) { + throw new Error( + '[presigned-url-resolver] Missing S3 credentials. ' + + 'Set AWS_ACCESS_KEY and AWS_SECRET_KEY environment variables.' + ); + } + + if (!bucketName) { + throw new Error( + '[presigned-url-resolver] Missing CDN bucket name. ' + + 'Set BUCKET_NAME environment variable.' + ); + } + + log.info( + `[presigned-url-resolver] Initializing: bucket=${bucketName} endpoint=${endpoint}` + ); + + const client = createS3Client({ + provider: (cdn.provider || 'minio') as any, + region: awsRegion, + accessKeyId: awsAccessKey, + secretAccessKey: awsSecretKey, + ...(endpoint ? { endpoint } : {}), + }); - log.info( - `[presigned-url-resolver] Initializing: bucket=${bucketName} endpoint=${endpoint}`, + return { + client, + bucket: bucketName, + region: awsRegion, + publicUrlPrefix, + ...(endpoint ? { endpoint, forcePathStyle: true } : {}), + }; + }, + ({ client }) => client.destroy() ); - - const client = createS3Client({ - provider: (cdn.provider || 'minio') as any, - region: awsRegion, - accessKeyId: awsAccessKey, - secretAccessKey: awsSecretKey, - ...(endpoint ? { endpoint } : {}), - }); - - s3Config = { - client, - bucket: bucketName, - region: awsRegion, - publicUrlPrefix, - ...(endpoint ? { endpoint, forcePathStyle: true } : {}), - }; - - return s3Config; } +const getRuntimeOptions = () => { + return getGraphileSettingsRuntimeOptions(); +}; + /** * Create a per-(database, bucketKey) bucket name resolver. * @@ -96,10 +117,8 @@ export function getPresignedUrlS3Config(): S3Config { * S3 buckets per logical bucket key. */ export function createBucketNameResolver(): BucketNameResolver { - const { cdn } = getEnvOptions(); - const prefix = cdn?.bucketName || 'test-bucket'; - return (databaseId: string, bucketKey: string): string => { + const prefix = getRuntimeOptions().cdn?.bucketName || 'test-bucket'; return `${prefix}-${bucketKey}-${databaseId}`; }; } @@ -112,7 +131,7 @@ export function createBucketNameResolver(): BucketNameResolver { * Falls back to ['http://localhost:3000'] for local development. */ export function getAllowedOrigins(): string[] { - const { server } = getEnvOptions(); + const { server } = getRuntimeOptions(); if (server?.origin) return [server.origin]; return ['*']; } @@ -129,29 +148,31 @@ export function getAllowedOrigins(): string[] { * SERVER_ORIGIN env var (falls back to localhost for local dev). */ export function createEnsureBucketProvisioned(): EnsureBucketProvisioned { - let provisioner: BucketProvisioner | null = null; - return async ( bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, - allowedOrigins: string[] | null, + allowedOrigins: string[] | null ): Promise => { // Per-database origins from storage_module, falling back to global SERVER_ORIGIN - const effectiveOrigins = (allowedOrigins && allowedOrigins.length > 0) - ? allowedOrigins - : getAllowedOrigins(); - - if (!provisioner) { - provisioner = new BucketProvisioner({ - connection: getBucketProvisionerConnection(), - allowedOrigins: effectiveOrigins, - }); - } + const effectiveOrigins = + allowedOrigins && allowedOrigins.length > 0 + ? allowedOrigins + : getAllowedOrigins(); + + const provisioner = getGraphileSettingsRuntimeResource( + ENSURE_BUCKET_PROVISIONER, + () => + new BucketProvisioner({ + connection: getBucketProvisionerConnection(), + allowedOrigins: effectiveOrigins, + }), + (resource) => resource.getClient().destroy() + ); log.info( `[lazy-provision] Provisioning S3 bucket "${bucketName}" ` + - `(type=${accessType}) for database ${databaseId}`, + `(type=${accessType}) for database ${databaseId}` ); await provisioner.provision({ @@ -161,6 +182,8 @@ export function createEnsureBucketProvisioned(): EnsureBucketProvisioned { allowedOrigins: effectiveOrigins, }); - log.info(`[lazy-provision] S3 bucket "${bucketName}" provisioned successfully`); + log.info( + `[lazy-provision] S3 bucket "${bucketName}" provisioned successfully` + ); }; } diff --git a/graphile/graphile-settings/src/runtime-environment.ts b/graphile/graphile-settings/src/runtime-environment.ts new file mode 100644 index 0000000000..876eb11ba7 --- /dev/null +++ b/graphile/graphile-settings/src/runtime-environment.ts @@ -0,0 +1,135 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { resolve } from 'node:path'; + +export type GraphileSettingsEnvironment = Readonly< + Record +>; + +export interface GraphileSettingsRuntimeOptions { + cwd?: string; + env?: GraphileSettingsEnvironment; +} + +interface RuntimeResource { + value: unknown; + dispose?: (value: unknown) => void | Promise; +} + +interface GraphileSettingsRuntimeState { + cwd: string; + env: GraphileSettingsEnvironment; + resources: Map; +} + +const runtimeStorage = new AsyncLocalStorage(); + +const ambientRuntime = (): GraphileSettingsRuntimeState => ({ + cwd: process.cwd(), + env: process.env, + resources: new Map(), +}); + +/** + * Read the runtime attached to the current server/request lifecycle. + * Ambient process state remains a compatibility fallback for consumers which + * have not adopted an explicit runtime scope. + */ +export const getGraphileSettingsRuntime = (): Readonly< + Pick +> => runtimeStorage.getStore() ?? ambientRuntime(); + +export const hasGraphileSettingsRuntime = (): boolean => + runtimeStorage.getStore() !== undefined; + +/** + * Return one lazily-created resource per runtime scope. Credential-bearing + * resources require an explicit scope: creating them from ambient process + * state would either leak a client per call or recreate the original + * first-operation-wins credential singleton. + */ +export const getGraphileSettingsRuntimeResource = ( + key: symbol, + create: () => T, + dispose?: (value: T) => void | Promise +): T => { + const runtime = runtimeStorage.getStore(); + if (!runtime) { + throw new Error( + 'GRAPHILE_SETTINGS_RUNTIME_REQUIRED: run the server or operation with ' + + 'withGraphileSettingsRuntime().' + ); + } + + const cached = runtime.resources.get(key); + if (cached) return cached.value as T; + + const value = create(); + runtime.resources.set(key, { + value, + ...(dispose === undefined + ? {} + : { + dispose: (resource) => dispose(resource as T), + }), + }); + return value; +}; + +const disposeRuntimeResources = async ( + runtime: GraphileSettingsRuntimeState +): Promise => { + const resources = [...runtime.resources.values()].reverse(); + runtime.resources.clear(); + const failures: unknown[] = []; + + for (const resource of resources) { + if (!resource.dispose) continue; + try { + await resource.dispose(resource.value); + } catch (error) { + failures.push(error); + } + } + + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError( + failures, + 'Failed to dispose graphile-settings runtime resources.' + ); + } +}; + +/** + * Run a complete server lifecycle with an immutable environment snapshot and + * isolated credential-bearing resources. Resources are disposed after the + * lifecycle settles, including on cancellation and startup failure. + */ +export const withGraphileSettingsRuntime = async ( + options: GraphileSettingsRuntimeOptions, + callback: () => Promise +): Promise => { + const parent = runtimeStorage.getStore(); + const runtime: GraphileSettingsRuntimeState = { + cwd: resolve(options.cwd ?? parent?.cwd ?? process.cwd()), + env: Object.freeze({ ...(options.env ?? parent?.env ?? process.env) }), + resources: new Map(), + }; + + let result: T | undefined; + let primaryError: unknown; + try { + result = await runtimeStorage.run(runtime, callback); + } catch (error) { + primaryError = error; + } + + try { + await disposeRuntimeResources(runtime); + } catch (cleanupError) { + if (primaryError === undefined) throw cleanupError; + } + + if (primaryError !== undefined) throw primaryError; + return result as T; +}; diff --git a/graphile/graphile-settings/src/runtime-options.ts b/graphile/graphile-settings/src/runtime-options.ts new file mode 100644 index 0000000000..41cc75b0a7 --- /dev/null +++ b/graphile/graphile-settings/src/runtime-options.ts @@ -0,0 +1,17 @@ +import { getEnvOptions } from '@constructive-io/graphql-env'; + +import { + getGraphileSettingsRuntime, + getGraphileSettingsRuntimeResource, +} from './runtime-environment'; + +const RESOLVED_OPTIONS = Symbol('constructive.resolved-options'); + +/** Resolve config files and environment exactly once per runtime scope. */ +export const getGraphileSettingsRuntimeOptions = (): ReturnType< + typeof getEnvOptions +> => + getGraphileSettingsRuntimeResource(RESOLVED_OPTIONS, () => { + const runtime = getGraphileSettingsRuntime(); + return getEnvOptions({}, runtime.cwd, { ...runtime.env }); + }); diff --git a/graphile/graphile-settings/src/upload-resolver.ts b/graphile/graphile-settings/src/upload-resolver.ts index 423020b749..b41bef508f 100644 --- a/graphile/graphile-settings/src/upload-resolver.ts +++ b/graphile/graphile-settings/src/upload-resolver.ts @@ -18,54 +18,70 @@ import Streamer from '@constructive-io/s3-streamer'; import uploadNames from '@constructive-io/upload-names'; -import { getEnvOptions } from '@constructive-io/graphql-env'; import { Logger } from '@pgpmjs/logger'; import { randomBytes } from 'crypto'; import type { - FileUpload, - UploadFieldDefinition, - UploadPluginInfo, + FileUpload, + UploadFieldDefinition, + UploadPluginInfo, } from 'graphile-upload-plugin'; +import { + getGraphileSettingsRuntime, + getGraphileSettingsRuntimeResource, +} from './runtime-environment'; +import { getGraphileSettingsRuntimeOptions } from './runtime-options'; + const log = new Logger('upload-resolver'); const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml']; -let streamer: Streamer | null = null; -let bucketName: string; - -function getStreamer(): Streamer { - if (streamer) return streamer; - - const opts = getEnvOptions(); - const cdn = opts.cdn || {}; - - const provider = cdn.provider || 'minio'; - bucketName = cdn.bucketName || 'test-bucket'; - const awsRegion = cdn.awsRegion || 'us-east-1'; - const awsAccessKey = cdn.awsAccessKey || 'minioadmin'; - const awsSecretKey = cdn.awsSecretKey || 'minioadmin'; - const endpoint = cdn.endpoint || 'http://localhost:9000'; - - if (process.env.NODE_ENV === 'production') { - if (!cdn.awsAccessKey || !cdn.awsSecretKey) { - log.warn('[upload-resolver] WARNING: Using default credentials in production.'); - } - } - - log.info( - `[upload-resolver] Initializing: provider=${provider} bucket=${bucketName}`, - ); - - streamer = new Streamer({ - defaultBucket: bucketName, - awsRegion, - awsSecretKey, - awsAccessKey, - endpoint, - provider, - }); - - return streamer; +const UPLOAD_RUNTIME = Symbol('constructive.upload-runtime'); + +interface UploadRuntime { + streamer: Streamer; + bucketName: string; +} + +function getUploadRuntime(): UploadRuntime { + return getGraphileSettingsRuntimeResource( + UPLOAD_RUNTIME, + () => { + const runtime = getGraphileSettingsRuntime(); + const opts = getGraphileSettingsRuntimeOptions(); + const cdn = opts.cdn || {}; + + const provider = cdn.provider || 'minio'; + const bucketName = cdn.bucketName || 'test-bucket'; + const awsRegion = cdn.awsRegion || 'us-east-1'; + const awsAccessKey = cdn.awsAccessKey || 'minioadmin'; + const awsSecretKey = cdn.awsSecretKey || 'minioadmin'; + const endpoint = cdn.endpoint || 'http://localhost:9000'; + + if (runtime.env.NODE_ENV === 'production') { + if (!cdn.awsAccessKey || !cdn.awsSecretKey) { + log.warn( + '[upload-resolver] WARNING: Using default credentials in production.' + ); + } + } + + log.info( + `[upload-resolver] Initializing: provider=${provider} bucket=${bucketName}` + ); + + const streamer = new Streamer({ + defaultBucket: bucketName, + awsRegion, + awsSecretKey, + awsAccessKey, + endpoint, + provider, + }); + + return { streamer, bucketName }; + }, + ({ streamer }) => streamer.destroy() + ); } /** @@ -73,8 +89,8 @@ function getStreamer(): Streamer { * Format: {random10chars}-{sanitized-filename} */ function generateKey(filename: string): string { - const rand = randomBytes(12).toString('hex'); - return `${rand}-${uploadNames(filename)}`; + const rand = randomBytes(12).toString('hex'); + return `${rand}-${uploadNames(filename)}`; } /** @@ -88,59 +104,59 @@ function generateKey(filename: string): string { * stream bytes, validated against smart-tag/type rules, and only then uploaded. */ async function uploadResolver( - upload: FileUpload, - _args: unknown, - _context: unknown, - info: { uploadPlugin: UploadPluginInfo }, + upload: FileUpload, + _args: unknown, + _context: unknown, + info: { uploadPlugin: UploadPluginInfo } ): Promise { - const { tags, type } = info.uploadPlugin; - const s3 = getStreamer(); - const { filename } = upload; - const key = generateKey(filename); - - // MIME type validation from smart tags - const typ = type || tags?.type; - const VALID_MIME = /^[a-z]+\/[a-z0-9][a-z0-9!#$&\-.^_+]*$/i; - const mim: string[] = tags?.mime - ? String(tags.mime) - .trim() - .split(',') - .map((a: string) => a.trim()) - .filter((m: string) => VALID_MIME.test(m)) - : typ === 'image' - ? DEFAULT_IMAGE_MIME_TYPES - : []; - - const detected = await s3.detectContentType({ - readStream: upload.createReadStream(), - filename, - }); - const detectedContentType = detected.contentType; - - if (mim.length && !mim.includes(detectedContentType)) { - detected.stream.destroy(); - throw new Error('UPLOAD_MIMETYPE'); - } - - const result = await s3.uploadWithContentType({ - readStream: detected.stream, - contentType: detectedContentType, - magic: detected.magic, - key, - bucket: bucketName, - }); - - const url = result.upload.Location; - const { contentType } = result; - - switch (typ) { - case 'image': - case 'upload': - return { filename, mime: contentType, url }; - case 'attachment': - default: - return url; - } + const { tags, type } = info.uploadPlugin; + const { streamer: s3, bucketName } = getUploadRuntime(); + const { filename } = upload; + const key = generateKey(filename); + + // MIME type validation from smart tags + const typ = type || tags?.type; + const VALID_MIME = /^[a-z]+\/[a-z0-9][a-z0-9!#$&\-.^_+]*$/i; + const mim: string[] = tags?.mime + ? String(tags.mime) + .trim() + .split(',') + .map((a: string) => a.trim()) + .filter((m: string) => VALID_MIME.test(m)) + : typ === 'image' + ? DEFAULT_IMAGE_MIME_TYPES + : []; + + const detected = await s3.detectContentType({ + readStream: upload.createReadStream(), + filename, + }); + const detectedContentType = detected.contentType; + + if (mim.length && !mim.includes(detectedContentType)) { + detected.stream.destroy(); + throw new Error('UPLOAD_MIMETYPE'); + } + + const result = await s3.uploadWithContentType({ + readStream: detected.stream, + contentType: detectedContentType, + magic: detected.magic, + key, + bucket: bucketName, + }); + + const url = result.upload.Location; + const { contentType } = result; + + switch (typ) { + case 'image': + case 'upload': + return { filename, mime: contentType, url }; + case 'attachment': + default: + return url; + } } /** @@ -157,22 +173,22 @@ async function uploadResolver( * to every application database. They rarely change, so this config is stable. */ export const constructiveUploadFieldDefinitions: UploadFieldDefinition[] = [ - { - name: 'image', - namespaceName: 'public', - type: 'image', - resolve: uploadResolver, - }, - { - name: 'upload', - namespaceName: 'public', - type: 'upload', - resolve: uploadResolver, - }, - { - name: 'attachment', - namespaceName: 'public', - type: 'attachment', - resolve: uploadResolver, - }, + { + name: 'image', + namespaceName: 'public', + type: 'image', + resolve: uploadResolver, + }, + { + name: 'upload', + namespaceName: 'public', + type: 'upload', + resolve: uploadResolver, + }, + { + name: 'attachment', + namespaceName: 'public', + type: 'attachment', + resolve: uploadResolver, + }, ]; diff --git a/graphql/explorer/src/__tests__/server-lifecycle.test.ts b/graphql/explorer/src/__tests__/server-lifecycle.test.ts new file mode 100644 index 0000000000..cc1ed7a18c --- /dev/null +++ b/graphql/explorer/src/__tests__/server-lifecycle.test.ts @@ -0,0 +1,151 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + type GraphQLExplorerCacheScope, + startGraphQLExplorer, +} from '../server'; + +const createCacheEntry = (key: string, release: jest.Mock) => + ({ + cacheKey: key, + createdAt: Date.now(), + pgl: { release }, + serv: {}, + handler: {}, + httpServer: { listening: false }, + realtimeManager: null, + }) as any; + +const listen = async (server: Server): Promise => { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing TCP address.'); + return address.port; +}; + +const close = async (server: Server): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}; + +describe('GraphQL explorer lifecycle', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'graphql-explorer-lifecycle-')); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it('resolves only after readiness and closes the listener', async () => { + const handle = await startGraphQLExplorer( + { server: { host: '127.0.0.1', port: 0 } }, + { cwd, env: { NODE_ENV: 'test' }, onError: jest.fn() } + ); + + expect(handle.httpServer.listening).toBe(true); + expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + const response = await fetch(`${handle.url}/healthz`); + expect(response.ok).toBe(true); + + await handle.close(); + expect(handle.httpServer.listening).toBe(false); + }); + + it('rejects a port conflict with the original system code', async () => { + const occupyingServer = createServer(); + const port = await listen(occupyingServer); + + try { + await expect( + startGraphQLExplorer( + { server: { host: '127.0.0.1', port } }, + { cwd, env: { NODE_ENV: 'test' }, onError: jest.fn() } + ) + ).rejects.toMatchObject({ code: 'EADDRINUSE' }); + } finally { + await close(occupyingServer); + } + }); + + it('refuses an already-cancelled startup without opening a listener', async () => { + const controller = new AbortController(); + const reason = new DOMException('cancelled by test', 'AbortError'); + controller.abort(reason); + + await expect( + startGraphQLExplorer( + { server: { host: '127.0.0.1', port: 0 } }, + { + cwd, + env: { NODE_ENV: 'test' }, + signal: controller.signal, + onError: jest.fn(), + } + ) + ).rejects.toBe(reason); + }); + + it('surfaces listener failures after readiness through the lifecycle handle', async () => { + const onError = jest.fn(); + const handle = await startGraphQLExplorer( + { server: { host: '127.0.0.1', port: 0 } }, + { cwd, env: { NODE_ENV: 'test' }, onError } + ); + const failure = Object.assign(new Error('listener failed'), { + code: 'EIO', + }); + + handle.httpServer.emit('error', failure); + + await expect(handle.waitForFailure()).rejects.toBe(failure); + expect(onError).toHaveBeenCalledWith(failure); + await handle.close(); + }); + + it('closes only the cache scope owned by that explorer', async () => { + const first = await startGraphQLExplorer( + { server: { host: '127.0.0.1', port: 0 } }, + { cwd, env: { NODE_ENV: 'test', PGHOST: 'first.internal' } } + ); + const second = await startGraphQLExplorer( + { server: { host: '127.0.0.1', port: 0 } }, + { cwd, env: { NODE_ENV: 'test', PGHOST: 'second.internal' } } + ); + const firstScope = first.app.locals + .graphqlExplorerCacheScope as GraphQLExplorerCacheScope; + const secondScope = second.app.locals + .graphqlExplorerCacheScope as GraphQLExplorerCacheScope; + const releaseFirst = jest.fn(async () => {}); + const releaseSecond = jest.fn(async () => {}); + firstScope.graphileCache.set( + 'same-logical-key', + createCacheEntry('same-logical-key', releaseFirst) + ); + secondScope.graphileCache.set( + 'same-logical-key', + createCacheEntry('same-logical-key', releaseSecond) + ); + + await first.close(); + + expect(releaseFirst).toHaveBeenCalledTimes(1); + expect(releaseSecond).not.toHaveBeenCalled(); + expect(secondScope.graphileCache.has('same-logical-key')).toBe(true); + + await second.close(); + }); +}); diff --git a/graphql/explorer/src/server.ts b/graphql/explorer/src/server.ts index 60a3b6fad0..20b6169792 100644 --- a/graphql/explorer/src/server.ts +++ b/graphql/explorer/src/server.ts @@ -3,17 +3,156 @@ import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { cors, healthz, poweredBy } from '@pgpmjs/server-utils'; import { middleware as parseDomains } from '@constructive-io/url-domains'; import express, { Express, NextFunction, Request, Response } from 'express'; -import { createGraphileInstance, graphileCache, GraphileCacheEntry } from 'graphile-cache'; -import { makePgService } from 'graphile-settings'; +import type { Server as HttpServer } from 'node:http'; +import { + createGraphileInstance, + GraphileCacheEntry, + GraphileCacheManager, +} from 'graphile-cache'; +import { makePgService, withGraphileSettingsRuntime } from 'graphile-settings'; import type { GraphileConfig } from 'graphile-config'; -import { getPgPool } from 'pg-cache'; +import { getPgPool, getPgPoolCacheKey, PgPoolCacheManager } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; import { printDatabases, printSchemas } from './render'; import { getGraphilePreset } from './settings'; -export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { - const opts = getEnvOptions(rawOpts); +export interface GraphQLExplorerRuntimeOptions { + onError?: (error: unknown) => void; + cwd?: string; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + /** @internal Owned resources supplied by startGraphQLExplorer. */ + cacheScope?: GraphQLExplorerCacheScope; +} + +/** Cache ownership boundary for one explorer lifecycle. */ +export class GraphQLExplorerCacheScope { + readonly pgCache: PgPoolCacheManager; + readonly graphileCache: GraphileCacheManager; + + constructor( + environment: Readonly> = process.env + ) { + this.pgCache = new PgPoolCacheManager(undefined, environment); + this.graphileCache = new GraphileCacheManager({ + pgCache: this.pgCache, + environment, + }); + } + + async close(): Promise { + let graphileFailure: unknown; + try { + await this.graphileCache.close(); + } catch (error) { + graphileFailure = error; + } + try { + await this.pgCache.close(); + } catch (error) { + if (graphileFailure !== undefined) { + throw new AggregateError( + [graphileFailure, error], + 'Multiple GraphQL explorer cache resources failed to close.' + ); + } + throw error; + } + if (graphileFailure !== undefined) throw graphileFailure; + } +} + +export interface GraphQLExplorerHandle { + app: Express; + httpServer: HttpServer; + url: string; + waitForFailure(): Promise; + close(): Promise; +} + +const closeHttpServer = async (server: HttpServer): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => { + if ( + error && + (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING' + ) { + reject(error); + return; + } + resolve(); + }); + }); +}; + +const abortReason = (signal: AbortSignal): unknown => + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError'); + +const formatServerUrl = (server: HttpServer, fallbackHost: string): string => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const rawHost = + typeof address === 'object' && address?.address + ? address.address + : fallbackHost; + const host = + rawHost === '::' || rawHost === '[::]' || rawHost === '0.0.0.0' + ? 'localhost' + : rawHost; + return `http://${host.includes(':') ? `[${host}]` : host}:${port}`; +}; + +const createRuntimeScopeMiddleware = + (runtime: GraphQLExplorerRuntimeOptions): express.RequestHandler => + (_req, res, next) => { + void withGraphileSettingsRuntime( + { cwd: runtime.cwd, env: runtime.env }, + () => + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + res.off('finish', handleComplete); + res.off('close', handleComplete); + }; + const handleComplete = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + + res.once('finish', handleComplete); + res.once('close', handleComplete); + try { + next(); + } catch (error) { + settled = true; + cleanup(); + reject(error); + } + }) + ).catch((error) => { + if (!res.headersSent) { + next(error); + return; + } + runtime.onError?.(error); + }); + }; + +export const createGraphQLExplorerApp = ( + rawOpts: ConstructiveOptions = {}, + runtime: GraphQLExplorerRuntimeOptions = {} +): Express => { + const opts = getEnvOptions(rawOpts, runtime.cwd, runtime.env); + const environment = Object.freeze({ ...(runtime.env ?? process.env) }); + const cacheScope = + runtime.cacheScope ?? new GraphQLExplorerCacheScope(environment); + const graphileCache = cacheScope.graphileCache; + const reportError = + runtime.onError ?? ((error: unknown) => console.error(error)); const { pg, server } = opts; @@ -28,21 +167,26 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { return cached; } - const pgConfig = getPgEnvOptions({ - ...pg, - database: dbname, - }); + const pgConfig = getPgEnvOptions( + { + ...pg, + database: dbname, + }, + runtime.env + ); // Route through pg-cache so the pool is tracked and can be cleaned up // properly, preventing leaked connections during database teardown. - const pool = getPgPool(pgConfig); + const pool = getPgPool(pgConfig, { + cache: cacheScope.pgCache, + environment, + }); + const pgPoolKey = getPgPoolCacheKey(pgConfig, { environment }); - const basePreset = getGraphilePreset(opts); + const basePreset = getGraphilePreset(opts, runtime); const preset: GraphileConfig.Preset = { ...basePreset, - pgServices: [ - makePgService({ pool, schemas: [schemaname] }), - ], + pgServices: [makePgService({ pool, schemas: [schemaname] })], grafserv: { graphqlPath: '/graphql', graphiqlPath: '/graphiql', @@ -50,12 +194,18 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { }, }; - const instance = await createGraphileInstance({ preset, cacheKey: key }); + const instance = await createGraphileInstance({ + preset, + cacheKey: key, + pgPoolKey, + }); graphileCache.set(key, instance); return instance; }; const app = express(); + app.locals.graphqlExplorerCacheScope = cacheScope; + app.use(createRuntimeScopeMiddleware(runtime)); healthz(app); cors(app, server.origin); @@ -67,10 +217,14 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { const [dbName] = req.urlDomains.subdomains; try { const pgPool = getPgPool( - getPgEnvOptions({ - ...opts.pg, - database: dbName, - }) + getPgEnvOptions( + { + ...opts.pg, + database: dbName, + }, + environment + ), + { cache: cacheScope.pgCache, environment } ); const results = await pgPool.query(` @@ -93,7 +247,7 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { res.status(404).send('DB Not found'); return; } - console.error(e); + reportError(e); res.status(500).send('Something happened...'); return; } @@ -106,10 +260,14 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { const [, dbName] = req.urlDomains.subdomains; try { const pgPool = getPgPool( - getPgEnvOptions({ - ...opts.pg, - database: dbName, - }) + getPgEnvOptions( + { + ...opts.pg, + database: dbName, + }, + environment + ), + { cache: cacheScope.pgCache, environment } ); await pgPool.query('SELECT 1;'); @@ -118,7 +276,7 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { res.status(404).send('DB Not found'); return; } - console.error(e); + reportError(e); res.status(500).send('Something happened...'); return; } @@ -134,7 +292,8 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { instance.handler(req, res, next); return; } catch (e: any) { - res.status(500).send(e.message); + reportError(e); + res.status(500).send('Something happened...'); return; } } @@ -156,10 +315,14 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { if (req.urlDomains?.subdomains.length === 0) { try { const rootPgPool = getPgPool( - getPgEnvOptions({ - ...opts.pg, - database: opts.pg.user, // is this to get postgres? - }) + getPgEnvOptions( + { + ...opts.pg, + database: opts.pg.user, // is this to get postgres? + }, + environment + ), + { cache: cacheScope.pgCache, environment } ); const results = await rootPgPool.query(` @@ -175,7 +338,7 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { res.status(404).send('DB Not found'); return; } - console.error(e); + reportError(e); res.status(500).send('Something happened...'); return; } @@ -183,9 +346,129 @@ export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { return next(); }); - app.listen(server.port, server.host, () => { - console.log(`app listening at http://${server.host}:${server.port}`); + return app; +}; + +export const startGraphQLExplorer = async ( + rawOpts: ConstructiveOptions = {}, + runtime: GraphQLExplorerRuntimeOptions = {} +): Promise => { + const opts = getEnvOptions(rawOpts, runtime.cwd, runtime.env); + const environment = Object.freeze({ ...(runtime.env ?? process.env) }); + const cacheScope = new GraphQLExplorerCacheScope(environment); + const app = createGraphQLExplorerApp(opts, { + ...runtime, + env: environment, + cacheScope, + }); + const signal = runtime.signal; + + if (signal?.aborted) { + await cacheScope.close(); + throw abortReason(signal); + } + + let httpServer: HttpServer; + try { + httpServer = await new Promise((resolve, reject) => { + const candidate = app.listen(opts.server.port, opts.server.host); + const cleanup = () => { + candidate.off('listening', handleListening); + candidate.off('error', handleError); + signal?.removeEventListener('abort', handleAbort); + }; + const handleListening = () => { + cleanup(); + resolve(candidate); + }; + const handleError = (error: Error) => { + cleanup(); + reject(error); + }; + const handleAbort = () => { + cleanup(); + void closeHttpServer(candidate).then( + () => reject(abortReason(signal!)), + reject + ); + }; + candidate.once('listening', handleListening); + candidate.once('error', handleError); + signal?.addEventListener('abort', handleAbort, { once: true }); + }); + } catch (error) { + await cacheScope.close(); + throw error; + } + + let resolveFailure!: (error: Error) => void; + const failure = new Promise((resolve) => { + resolveFailure = resolve; }); + const handleRuntimeError = (error: Error) => { + runtime.onError?.(error); + resolveFailure(error); + }; + httpServer.on('error', handleRuntimeError); + let closePromise: Promise | null = null; + + return { + app, + httpServer, + url: formatServerUrl(httpServer, opts.server.host || 'localhost'), + async waitForFailure(): Promise { + throw await failure; + }, + async close() { + if (closePromise) return closePromise; + closePromise = (async () => { + httpServer.off('error', handleRuntimeError); + let listenerFailure: unknown; + try { + await closeHttpServer(httpServer); + } catch (error) { + listenerFailure = error; + } + try { + await cacheScope.close(); + } catch (error) { + if (listenerFailure !== undefined) { + throw new AggregateError( + [listenerFailure, error], + 'Multiple GraphQL explorer resources failed to close.' + ); + } + throw error; + } + if (listenerFailure !== undefined) throw listenerFailure; + })(); + return closePromise; + }, + }; +}; + +/** Dispose caches owned by an app returned from createGraphQLExplorerApp. */ +export const closeGraphQLExplorerApp = async (app: Express): Promise => { + const cacheScope = app.locals.graphqlExplorerCacheScope as + | GraphQLExplorerCacheScope + | undefined; + await cacheScope?.close(); +}; +/** + * Backwards-compatible convenience API. New lifecycle-aware consumers should + * use startGraphQLExplorer so readiness and shutdown are observable. + */ +export const GraphQLExplorer = (rawOpts: ConstructiveOptions = {}): Express => { + const opts = getEnvOptions(rawOpts); + const app = createGraphQLExplorerApp(opts); + const httpServer = app.listen(opts.server.port, opts.server.host, () => { + console.log( + `app listening at http://${opts.server.host}:${opts.server.port}` + ); + }); + httpServer.once('close', () => { + void closeGraphQLExplorerApp(app).catch((error) => console.error(error)); + }); return app; }; diff --git a/graphql/explorer/src/settings.ts b/graphql/explorer/src/settings.ts index 5474bcf069..f7edc976d1 100644 --- a/graphql/explorer/src/settings.ts +++ b/graphql/explorer/src/settings.ts @@ -1,6 +1,6 @@ import { ConstructiveOptions } from '@constructive-io/graphql-types'; import { getEnvOptions } from '@constructive-io/graphql-env'; -import { ConstructivePreset } from 'graphile-settings'; +import { createConstructivePreset } from 'graphile-settings'; import type { GraphileConfig } from 'graphile-config'; /** @@ -8,11 +8,14 @@ import type { GraphileConfig } from 'graphile-config'; * * This returns a v5 preset that can be extended with pgServices. */ -export const getGraphilePreset = (rawOpts: ConstructiveOptions): GraphileConfig.Preset => { - const opts = getEnvOptions(rawOpts); +export const getGraphilePreset = ( + rawOpts: ConstructiveOptions, + runtime: { cwd?: string; env?: NodeJS.ProcessEnv } = {} +): GraphileConfig.Preset => { + const opts = getEnvOptions(rawOpts, runtime.cwd, runtime.env); return { - extends: [ConstructivePreset], + extends: [createConstructivePreset()], grafast: { context: () => ({ pgSettings: { role: opts.pg?.user ?? 'postgres' }, diff --git a/graphql/query/package.json b/graphql/query/package.json index 104e8c1523..b1a26a78e0 100644 --- a/graphql/query/package.json +++ b/graphql/query/package.json @@ -41,7 +41,7 @@ "graphql": "16.13.0", "inflection": "^3.0.0", "inflekt": "^0.7.1", - "lru-cache": "^11.2.7", + "lru-cache": "^10.4.3", "postgraphile": "5.0.3" }, "keywords": [ diff --git a/graphql/server/package.json b/graphql/server/package.json index 73058296ca..5506d3e34d 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -69,7 +69,7 @@ "graphile-utils": "5.0.1", "graphql": "16.13.0", "graphql-upload": "^13.0.0", - "lru-cache": "^11.2.7", + "lru-cache": "^10.4.3", "pg": "^8.21.0", "pg-cache": "workspace:^", "pg-env": "workspace:^", diff --git a/graphql/server/src/__tests__/cache-ownership.test.ts b/graphql/server/src/__tests__/cache-ownership.test.ts new file mode 100644 index 0000000000..ccf7d9a876 --- /dev/null +++ b/graphql/server/src/__tests__/cache-ownership.test.ts @@ -0,0 +1,78 @@ +import type { GraphileCacheEntry, GraphileCacheManager } from 'graphile-cache'; +import type { ServiceCache } from '@pgpmjs/server-utils'; + +import { Server } from '../server'; + +interface ServerInternals { + graphileInstanceCache: GraphileCacheManager; + serviceCache: ServiceCache; +} + +const internals = (server: Server): ServerInternals => + server as unknown as ServerInternals; + +const createEntry = ( + key: string, + release: jest.Mock, []> +): GraphileCacheEntry => + ({ + cacheKey: key, + createdAt: Date.now(), + pgl: { release }, + serv: {}, + handler: {}, + httpServer: { listening: false }, + realtimeManager: null, + }) as unknown as GraphileCacheEntry; + +describe('GraphQL Server cache ownership', () => { + it('keeps concurrent server caches isolated during scoped close', async () => { + const first = new Server( + { + pg: { + host: 'first.internal', + database: 'shared', + user: 'first', + password: 'first-secret', + }, + }, + { env: { NODE_ENV: 'test' } } + ); + const second = new Server( + { + pg: { + host: 'second.internal', + database: 'shared', + user: 'second', + password: 'second-secret', + }, + }, + { env: { NODE_ENV: 'test' } } + ); + const firstInternals = internals(first); + const secondInternals = internals(second); + const releaseFirst = jest.fn(async () => {}); + const releaseSecond = jest.fn(async () => {}); + firstInternals.graphileInstanceCache.set( + 'same-logical-key', + createEntry('same-logical-key', releaseFirst) + ); + secondInternals.graphileInstanceCache.set( + 'same-logical-key', + createEntry('same-logical-key', releaseSecond) + ); + firstInternals.serviceCache.set('same-logical-key', 'first'); + secondInternals.serviceCache.set('same-logical-key', 'second'); + + await first.close(); + + expect(releaseFirst).toHaveBeenCalledTimes(1); + expect(releaseSecond).not.toHaveBeenCalled(); + expect(secondInternals.graphileInstanceCache.has('same-logical-key')).toBe( + true + ); + expect(secondInternals.serviceCache.get('same-logical-key')).toBe('second'); + + await second.close(); + }); +}); diff --git a/graphql/server/src/__tests__/runtime-environment.test.ts b/graphql/server/src/__tests__/runtime-environment.test.ts new file mode 100644 index 0000000000..3e3076a9c8 --- /dev/null +++ b/graphql/server/src/__tests__/runtime-environment.test.ts @@ -0,0 +1,59 @@ +import { getGraphileSettingsRuntime } from 'graphile-settings'; + +import { + getServerEnvironment, + withServerEnvironment, +} from '../runtime-environment'; + +describe('server runtime environment isolation', () => { + it('uses the injected immutable snapshot instead of ambient process state', async () => { + const previous = process.env.CNC_SERVER_ENV_TEST; + process.env.CNC_SERVER_ENV_TEST = 'ambient-secret'; + + try { + await withServerEnvironment( + { CNC_SERVER_ENV_TEST: 'operation-value' }, + async () => { + expect(getServerEnvironment().CNC_SERVER_ENV_TEST).toBe( + 'operation-value' + ); + expect(() => { + (getServerEnvironment() as NodeJS.ProcessEnv).CNC_SERVER_ENV_TEST = + 'mutated'; + }).toThrow(); + await Promise.resolve(); + expect(getServerEnvironment().CNC_SERVER_ENV_TEST).toBe( + 'operation-value' + ); + } + ); + } finally { + if (previous === undefined) delete process.env.CNC_SERVER_ENV_TEST; + else process.env.CNC_SERVER_ENV_TEST = previous; + } + }); + + it('keeps concurrent service environments isolated', async () => { + const values = await Promise.all([ + withServerEnvironment({ SERVICE_ID: 'first' }, async () => { + await new Promise((resolve) => setImmediate(resolve)); + return [ + getServerEnvironment().SERVICE_ID, + getGraphileSettingsRuntime().env.SERVICE_ID, + ]; + }), + withServerEnvironment({ SERVICE_ID: 'second' }, async () => { + await Promise.resolve(); + return [ + getServerEnvironment().SERVICE_ID, + getGraphileSettingsRuntime().env.SERVICE_ID, + ]; + }), + ]); + + expect(values).toEqual([ + ['first', 'first'], + ['second', 'second'], + ]); + }); +}); diff --git a/graphql/server/src/diagnostics/__tests__/debug-db-scope.test.ts b/graphql/server/src/diagnostics/__tests__/debug-db-scope.test.ts new file mode 100644 index 0000000000..b1a2b7bdcc --- /dev/null +++ b/graphql/server/src/diagnostics/__tests__/debug-db-scope.test.ts @@ -0,0 +1,28 @@ +import type { Pool } from 'pg'; + +import { + closeDebugDatabasePools, + createDebugDatabasePoolScope, +} from '../debug-db-snapshot'; + +const createPool = () => + ({ end: jest.fn(async (): Promise => undefined) }) as unknown as Pool; + +describe('debug database pool ownership', () => { + it('closes only pools in the selected diagnostics scope', async () => { + const first = createDebugDatabasePoolScope(); + const second = createDebugDatabasePoolScope(); + const firstPool = createPool(); + const secondPool = createPool(); + first.set('same-safe-key', firstPool); + second.set('same-safe-key', secondPool); + + await closeDebugDatabasePools(first); + + expect(firstPool.end).toHaveBeenCalledTimes(1); + expect(secondPool.end).not.toHaveBeenCalled(); + expect(second.get('same-safe-key')).toBe(secondPool); + + await closeDebugDatabasePools(second); + }); +}); diff --git a/graphql/server/src/diagnostics/debug-db-snapshot.ts b/graphql/server/src/diagnostics/debug-db-snapshot.ts index 218868ef1a..a6113b9cdc 100644 --- a/graphql/server/src/diagnostics/debug-db-snapshot.ts +++ b/graphql/server/src/diagnostics/debug-db-snapshot.ts @@ -1,8 +1,15 @@ import type { ConstructiveOptions } from '@constructive-io/graphql-types'; -import { buildConnectionString, getPgPool } from 'pg-cache'; +import { + buildConnectionString, + getPgPool, + getPgPoolCacheKey, + type PgPoolCacheManager, +} from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; import { Pool, type PoolClient } from 'pg'; +import { getServerEnvironment } from '../runtime-environment'; + const ACTIVE_ACTIVITY_SQL = ` select pid, @@ -119,49 +126,77 @@ const NOTIFY_QUEUE_SQL = ` const DIAGNOSTICS_STATEMENT_TIMEOUT_MS = 3_000; const DIAGNOSTICS_LOCK_TIMEOUT_MS = 500; -const diagnosticsPools = new Map(); +export type DiagnosticsPoolScope = Map; + +/** Create an owned diagnostics pool scope for one server lifecycle. */ +export const createDebugDatabasePoolScope = (): DiagnosticsPoolScope => + new Map(); + +const diagnosticsPools = createDebugDatabasePoolScope(); + +export interface DebugDatabaseRuntimeOptions { + pgCache?: PgPoolCacheManager; + environment?: Readonly>; + diagnosticsPools?: DiagnosticsPoolScope; +} -const buildDiagnosticsConnectionString = (opts: ConstructiveOptions): string => { - const pgConfig = getPgEnvOptions(opts.pg); +const buildDiagnosticsConnectionString = ( + opts: ConstructiveOptions, + environment: Readonly> +): string => { + const pgConfig = getPgEnvOptions(opts.pg, environment); return buildConnectionString( pgConfig.user, pgConfig.password, pgConfig.host, pgConfig.port, - pgConfig.database, + pgConfig.database ); }; -const getDiagnosticsPool = (opts: ConstructiveOptions): Pool => { - const connectionString = buildDiagnosticsConnectionString(opts); - const existing = diagnosticsPools.get(connectionString); +const getDiagnosticsPool = ( + opts: ConstructiveOptions, + runtime: DebugDatabaseRuntimeOptions +): Pool => { + const environment = runtime.environment ?? getServerEnvironment(); + const pools = runtime.diagnosticsPools ?? diagnosticsPools; + const poolKey = getPgPoolCacheKey(opts.pg ?? {}, { + environment, + namespace: 'diagnostics', + }); + const existing = pools.get(poolKey); if (existing) { return existing; } const pool = new Pool({ - connectionString, + connectionString: buildDiagnosticsConnectionString(opts, environment), max: 1, idleTimeoutMillis: 10_000, connectionTimeoutMillis: 1_500, allowExitOnIdle: true, application_name: 'constructive-debug-snapshot', }); - diagnosticsPools.set(connectionString, pool); + pools.set(poolKey, pool); return pool; }; const withDiagnosticsClient = async ( opts: ConstructiveOptions, - fn: (client: PoolClient) => Promise, + runtime: DebugDatabaseRuntimeOptions, + fn: (client: PoolClient) => Promise ): Promise => { - const diagnosticsPool = getDiagnosticsPool(opts); + const diagnosticsPool = getDiagnosticsPool(opts, runtime); const client = await diagnosticsPool.connect(); try { await client.query('BEGIN'); - await client.query(`SET LOCAL statement_timeout = '${DIAGNOSTICS_STATEMENT_TIMEOUT_MS}ms'`); - await client.query(`SET LOCAL lock_timeout = '${DIAGNOSTICS_LOCK_TIMEOUT_MS}ms'`); + await client.query( + `SET LOCAL statement_timeout = '${DIAGNOSTICS_STATEMENT_TIMEOUT_MS}ms'` + ); + await client.query( + `SET LOCAL lock_timeout = '${DIAGNOSTICS_LOCK_TIMEOUT_MS}ms'` + ); const result = await fn(client); await client.query('COMMIT'); return result; @@ -177,9 +212,11 @@ const withDiagnosticsClient = async ( } }; -export const closeDebugDatabasePools = async (): Promise => { - const pools = [...diagnosticsPools.values()]; - diagnosticsPools.clear(); +export const closeDebugDatabasePools = async ( + poolScope: DiagnosticsPoolScope = diagnosticsPools +): Promise => { + const pools = [...poolScope.values()]; + poolScope.clear(); await Promise.allSettled(pools.map((pool) => pool.end())); }; @@ -202,8 +239,12 @@ export interface DebugDatabaseSnapshot { export const getDebugDatabaseSnapshot = async ( opts: ConstructiveOptions, + runtime: DebugDatabaseRuntimeOptions = {} ): Promise => { - const appPool = getPgPool(opts.pg); + const appPool = getPgPool(opts.pg, { + cache: runtime.pgCache, + environment: runtime.environment, + }); const { activity, blocked, @@ -211,7 +252,7 @@ export const getDebugDatabaseSnapshot = async ( databaseStats, settings, notifyQueue, - } = await withDiagnosticsClient(opts, async (client) => ({ + } = await withDiagnosticsClient(opts, runtime, async (client) => ({ activity: await client.query(ACTIVE_ACTIVITY_SQL), blocked: await client.query(BLOCKED_ACTIVITY_SQL), lockSummary: await client.query(LOCK_SUMMARY_SQL), @@ -233,7 +274,8 @@ export const getDebugDatabaseSnapshot = async ( lockSummary: lockSummary.rows, databaseStats: databaseStats.rows[0] ?? null, settings: settings.rows, - notificationQueueUsage: (notifyQueue.rows[0]?.queue_usage as number | null | undefined) ?? null, + notificationQueueUsage: + (notifyQueue.rows[0]?.queue_usage as number | null | undefined) ?? null, timestamp: new Date().toISOString(), }; }; diff --git a/graphql/server/src/diagnostics/debug-memory-snapshot.ts b/graphql/server/src/diagnostics/debug-memory-snapshot.ts index b3491ee133..748c1246c7 100644 --- a/graphql/server/src/diagnostics/debug-memory-snapshot.ts +++ b/graphql/server/src/diagnostics/debug-memory-snapshot.ts @@ -1,11 +1,24 @@ import os from 'node:os'; import v8 from 'node:v8'; -import { svcCache, SVC_CACHE_TTL_MS } from '@pgpmjs/server-utils'; -import { getCacheStats } from 'graphile-cache'; +import { getServerEnvironment } from '../runtime-environment'; +import { + svcCache, + SVC_CACHE_TTL_MS, + type ServiceCache, +} from '@pgpmjs/server-utils'; +import { + getCacheStats, + graphileCache, + type GraphileCacheManager, +} from 'graphile-cache'; import { getInFlightCount, getInFlightKeys } from '../middleware/graphile'; -import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats'; +import { + getGraphileBuildStats, + type GraphileBuildStatsManager, +} from '../middleware/observability/graphile-build-stats'; -const toMB = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(1)} MB`; +const toMB = (bytes: number): string => + `${(bytes / 1024 / 1024).toFixed(1)} MB`; export interface DebugMemorySnapshot { pid: number; @@ -57,7 +70,19 @@ export interface DebugMemorySnapshot { timestamp: string; } -export const getDebugMemorySnapshot = (): DebugMemorySnapshot => { +export interface DebugMemoryRuntimeOptions { + graphileCache?: GraphileCacheManager; + serviceCache?: ServiceCache; + inFlight?: ReadonlyMap; + environment?: Readonly>; + graphileBuildStats?: GraphileBuildStatsManager; +} + +export const getDebugMemorySnapshot = ( + runtime: DebugMemoryRuntimeOptions = {} +): DebugMemorySnapshot => { + const scopedGraphileCache = runtime.graphileCache ?? graphileCache; + const scopedServiceCache = runtime.serviceCache ?? svcCache; const mem = process.memoryUsage(); const heapSpaces = v8.getHeapSpaceStatistics().map((space) => ({ spaceName: space.space_name, @@ -69,7 +94,7 @@ export const getDebugMemorySnapshot = (): DebugMemorySnapshot => { return { pid: process.pid, - nodeEnv: process.env.NODE_ENV, + nodeEnv: (runtime.environment ?? getServerEnvironment()).NODE_ENV, memory: { heapUsedBytes: mem.heapUsed, heapTotalBytes: mem.heapTotal, @@ -94,29 +119,31 @@ export const getDebugMemorySnapshot = (): DebugMemorySnapshot => { heapStatistics: v8.getHeapStatistics(), heapSpaces, }, - graphileCache: getCacheStats(), + graphileCache: getCacheStats(scopedGraphileCache), svcCache: { - size: svcCache.size, - max: svcCache.max, + size: scopedServiceCache.size, + max: scopedServiceCache.max, ttlMs: SVC_CACHE_TTL_MS, // Note: with updateAgeOnGet: true, this is "time since last access" not "time since creation" oldestKeyAgeMs: (() => { let minRemaining = Infinity; - for (const key of svcCache.keys()) { - const remaining = svcCache.getRemainingTTL(key); + for (const key of scopedServiceCache.keys()) { + const remaining = scopedServiceCache.getRemainingTTL(key); if (remaining < minRemaining) { minRemaining = remaining; } } - return Number.isFinite(minRemaining) ? SVC_CACHE_TTL_MS - minRemaining : null; + return Number.isFinite(minRemaining) + ? SVC_CACHE_TTL_MS - minRemaining + : null; })(), - keys: [...svcCache.keys()].slice(0, 200), + keys: [...scopedServiceCache.keys()].slice(0, 200), }, inFlight: { - count: getInFlightCount(), - keys: getInFlightKeys(), + count: getInFlightCount(runtime.inFlight), + keys: getInFlightKeys(runtime.inFlight), }, - graphileBuilds: getGraphileBuildStats(), + graphileBuilds: getGraphileBuildStats(runtime.graphileBuildStats), uptimeMinutes: process.uptime() / 60, timestamp: new Date().toISOString(), }; diff --git a/graphql/server/src/diagnostics/debug-sampler.ts b/graphql/server/src/diagnostics/debug-sampler.ts index 76ae11de8e..59ffb04df3 100644 --- a/graphql/server/src/diagnostics/debug-sampler.ts +++ b/graphql/server/src/diagnostics/debug-sampler.ts @@ -2,37 +2,57 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { Logger } from '@pgpmjs/logger'; -import { getDebugDatabaseSnapshot } from './debug-db-snapshot'; -import { getDebugMemorySnapshot } from './debug-memory-snapshot'; +import { + getDebugDatabaseSnapshot, + type DebugDatabaseRuntimeOptions, +} from './debug-db-snapshot'; +import { + getDebugMemorySnapshot, + type DebugMemoryRuntimeOptions, +} from './debug-memory-snapshot'; import { isGraphqlDebugSamplerEnabled } from './observability'; +import { getServerEnvironment } from '../runtime-environment'; const log = new Logger('debug-sampler'); const MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GB -const getSamplerIntervalMs = (): number => { - const raw = process.env.GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS; +export interface DebugSamplerRuntimeOptions + extends DebugDatabaseRuntimeOptions, DebugMemoryRuntimeOptions {} + +const getSamplerEnvironment = ( + runtime: DebugSamplerRuntimeOptions +): Readonly> => + runtime.environment ?? getServerEnvironment(); + +const getSamplerIntervalMs = (runtime: DebugSamplerRuntimeOptions): number => { + const raw = getSamplerEnvironment(runtime).GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS; const parsed = raw ? Number.parseInt(raw, 10) : 10_000; return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : 10_000; }; -const getSamplerRootDir = (): string => { - if (process.env.GRAPHQL_DEBUG_SAMPLER_DIR) { - return path.resolve(process.env.GRAPHQL_DEBUG_SAMPLER_DIR); +const getSamplerRootDir = (runtime: DebugSamplerRuntimeOptions): string => { + const configured = getSamplerEnvironment(runtime).GRAPHQL_DEBUG_SAMPLER_DIR; + if (configured) { + return path.resolve(configured); } return path.resolve(__dirname, '../..', 'logs'); }; -const createSessionLogDir = (): string => { - const rootDir = getSamplerRootDir(); +const createSessionLogDir = (runtime: DebugSamplerRuntimeOptions): string => { + const environment = getSamplerEnvironment(runtime); + const rootDir = getSamplerRootDir(runtime); const sessionName = `run-${new Date().toISOString().replace(/[:.]/g, '-')}-pid${process.pid}`; - return process.env.GRAPHQL_DEBUG_SAMPLER_DIR + return environment.GRAPHQL_DEBUG_SAMPLER_DIR ? rootDir : path.join(rootDir, sessionName); }; -const appendJsonLine = async (filePath: string, payload: unknown): Promise => { +const appendJsonLine = async ( + filePath: string, + payload: unknown +): Promise => { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.appendFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8'); }; @@ -52,7 +72,10 @@ const getDirSize = async (dirPath: string): Promise => { return total; }; -const enforceMaxSize = async (rootDir: string, currentSessionDir: string): Promise => { +const enforceMaxSize = async ( + rootDir: string, + currentSessionDir: string +): Promise => { try { const totalSize = await getDirSize(rootDir); if (totalSize <= MAX_TOTAL_BYTES) { @@ -67,7 +90,7 @@ const enforceMaxSize = async (rootDir: string, currentSessionDir: string): Promi const fullPath = path.join(rootDir, e.name); const stat = await fs.stat(fullPath); return { path: fullPath, mtimeMs: stat.mtimeMs }; - }), + }) ); sessionDirs.sort((a, b) => a.mtimeMs - b.mtimeMs); @@ -92,13 +115,21 @@ export interface DebugSamplerHandle { stop(): Promise; } -export const startDebugSampler = (opts: ConstructiveOptions): DebugSamplerHandle | null => { - if (!isGraphqlDebugSamplerEnabled(opts.server?.host)) { +export const startDebugSampler = ( + opts: ConstructiveOptions, + runtime: DebugSamplerRuntimeOptions = {} +): DebugSamplerHandle | null => { + if ( + !isGraphqlDebugSamplerEnabled( + opts.server?.host, + getSamplerEnvironment(runtime) + ) + ) { return null; } - const intervalMs = getSamplerIntervalMs(); - const logDir = createSessionLogDir(); + const intervalMs = getSamplerIntervalMs(runtime); + const logDir = createSessionLogDir(runtime); const memoryLogPath = path.join(logDir, 'debug-memory.ndjson'); const dbLogPath = path.join(logDir, 'debug-db.ndjson'); const errorLogPath = path.join(logDir, 'debug-sampler-errors.ndjson'); @@ -108,7 +139,10 @@ export const startDebugSampler = (opts: ConstructiveOptions): DebugSamplerHandle let inFlight: Promise | null = null; let writeFailureLogged = false; - const runBackgroundWrite = (promise: Promise, scope: string): void => { + const runBackgroundWrite = ( + promise: Promise, + scope: string + ): void => { promise.catch((error) => { // Avoid recursive attempts to write additional error files when the // underlying storage path is broken or unavailable. @@ -119,18 +153,24 @@ export const startDebugSampler = (opts: ConstructiveOptions): DebugSamplerHandle }); }; - const recordError = async (scope: 'memory' | 'db' | 'sampler', error: unknown): Promise => { + const recordError = async ( + scope: 'memory' | 'db' | 'sampler', + error: unknown + ): Promise => { const payload = { scope, timestamp: new Date().toISOString(), pid: process.pid, - error: error instanceof Error ? { - name: error.name, - message: error.message, - stack: error.stack, - } : { - message: String(error), - }, + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : { + message: String(error), + }, }; await appendJsonLine(errorLogPath, payload); }; @@ -141,20 +181,23 @@ export const startDebugSampler = (opts: ConstructiveOptions): DebugSamplerHandle } try { - await appendJsonLine(memoryLogPath, getDebugMemorySnapshot()); + await appendJsonLine(memoryLogPath, getDebugMemorySnapshot(runtime)); } catch (error) { log.error('Failed to capture debug memory snapshot', error); await recordError('memory', error); } try { - await appendJsonLine(dbLogPath, await getDebugDatabaseSnapshot(opts)); + await appendJsonLine( + dbLogPath, + await getDebugDatabaseSnapshot(opts, runtime) + ); } catch (error) { log.error('Failed to capture debug DB snapshot', error); await recordError('db', error); } - await enforceMaxSize(getSamplerRootDir(), logDir); + await enforceMaxSize(getSamplerRootDir(runtime), logDir); }; const tick = (): void => { @@ -164,8 +207,13 @@ export const startDebugSampler = (opts: ConstructiveOptions): DebugSamplerHandle if (inFlight) { runBackgroundWrite( - recordError('sampler', new Error('Skipped debug sample because previous sample is still running')), - 'record-skip', + recordError( + 'sampler', + new Error( + 'Skipped debug sample because previous sample is still running' + ) + ), + 'record-skip' ); return; } @@ -187,10 +235,18 @@ export const startDebugSampler = (opts: ConstructiveOptions): DebugSamplerHandle pid: process.pid, timestamp: new Date().toISOString(), }; - runBackgroundWrite(appendJsonLine(memoryLogPath, lifecyclePayload), 'lifecycle-memory-start'); - runBackgroundWrite(appendJsonLine(dbLogPath, lifecyclePayload), 'lifecycle-db-start'); + runBackgroundWrite( + appendJsonLine(memoryLogPath, lifecyclePayload), + 'lifecycle-memory-start' + ); + runBackgroundWrite( + appendJsonLine(dbLogPath, lifecyclePayload), + 'lifecycle-db-start' + ); - log.info(`Debug sampler writing snapshots every ${intervalMs}ms to ${logDir}`); + log.info( + `Debug sampler writing snapshots every ${intervalMs}ms to ${logDir}` + ); tick(); timer = setInterval(tick, intervalMs); timer.unref(); diff --git a/graphql/server/src/diagnostics/observability.ts b/graphql/server/src/diagnostics/observability.ts index bf0e8d466a..f4e94463d7 100644 --- a/graphql/server/src/diagnostics/observability.ts +++ b/graphql/server/src/diagnostics/observability.ts @@ -1,7 +1,12 @@ +import { getServerEnvironment } from '../runtime-environment'; + const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1']); -const parseBooleanEnv = (value: string | undefined, fallback: boolean): boolean => { +const parseBooleanEnv = ( + value: string | undefined, + fallback: boolean +): boolean => { if (value == null) { return fallback; } @@ -44,26 +49,45 @@ const normalizeAddress = (value: string | null | undefined): string | null => { return normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized; }; -export const isDevelopmentObservabilityMode = (): boolean => process.env.NODE_ENV === 'development'; +export const isDevelopmentObservabilityMode = ( + environment: Readonly< + Record + > = getServerEnvironment() +): boolean => environment.NODE_ENV === 'development'; export const isLoopbackHost = (value: string | null | undefined): boolean => { const normalized = normalizeHost(value); return normalized != null && LOOPBACK_HOSTS.has(normalized); }; -export const isLoopbackAddress = (value: string | null | undefined): boolean => { +export const isLoopbackAddress = ( + value: string | null | undefined +): boolean => { const normalized = normalizeAddress(value); return normalized != null && LOOPBACK_ADDRESSES.has(normalized); }; -export const isGraphqlObservabilityRequested = (): boolean => - parseBooleanEnv(process.env.GRAPHQL_OBSERVABILITY_ENABLED, false); +export const isGraphqlObservabilityRequested = ( + environment: Readonly< + Record + > = getServerEnvironment() +): boolean => parseBooleanEnv(environment.GRAPHQL_OBSERVABILITY_ENABLED, false); -export const isGraphqlObservabilityEnabled = (serverHost?: string | null): boolean => - isDevelopmentObservabilityMode() && - isGraphqlObservabilityRequested() && +export const isGraphqlObservabilityEnabled = ( + serverHost?: string | null, + environment: Readonly< + Record + > = getServerEnvironment() +): boolean => + isDevelopmentObservabilityMode(environment) && + isGraphqlObservabilityRequested(environment) && isLoopbackHost(serverHost); -export const isGraphqlDebugSamplerEnabled = (serverHost?: string | null): boolean => - isGraphqlObservabilityEnabled(serverHost) && - parseBooleanEnv(process.env.GRAPHQL_DEBUG_SAMPLER_ENABLED, true); +export const isGraphqlDebugSamplerEnabled = ( + serverHost?: string | null, + environment: Readonly< + Record + > = getServerEnvironment() +): boolean => + isGraphqlObservabilityEnabled(serverHost, environment) && + parseBooleanEnv(environment.GRAPHQL_DEBUG_SAMPLER_ENABLED, true); diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index 53dd89cc1e..a597ea8bc6 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -1,7 +1,12 @@ export * from './server'; +export * from './runtime-environment'; // Export middleware for use in testing packages -export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api'; +export { + createApiMiddleware, + getSubdomain, + getApiConfig, +} from './middleware/api'; export { createAuthenticateMiddleware } from './middleware/auth'; export { cors } from './middleware/cors'; export { graphile } from './middleware/graphile'; diff --git a/graphql/server/src/middleware/__tests__/api.test.ts b/graphql/server/src/middleware/__tests__/api.test.ts index 3f00712c9b..74d48f2dee 100644 --- a/graphql/server/src/middleware/__tests__/api.test.ts +++ b/graphql/server/src/middleware/__tests__/api.test.ts @@ -1,4 +1,5 @@ jest.mock('pg-cache', () => ({ + ...jest.requireActual('pg-cache'), getPgPool: jest.fn(), })); @@ -20,7 +21,7 @@ const mockGetPgPool = getPgPool as jest.MockedFunction; const createRequest = (headers: Record): Request => { const normalized = new Map( - Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) ); return { @@ -30,15 +31,16 @@ const createRequest = (headers: Record): Request => { } as unknown as Request; }; -const createPrivateOptions = (): ApiOptions => ({ - pg: { - database: 'constructive', - }, - api: { - isPublic: false, - metaSchemas: ['metaschema_public'], - }, -} as unknown as ApiOptions); +const createPrivateOptions = (): ApiOptions => + ({ + pg: { + database: 'constructive', + }, + api: { + isPublic: false, + metaSchemas: ['metaschema_public'], + }, + }) as unknown as ApiOptions; describe('api middleware routing priority', () => { beforeEach(() => { @@ -58,7 +60,9 @@ describe('api middleware routing priority', () => { 'X-Schemata': 'services_public', }); - expect(getSvcKey(createPrivateOptions(), req)).toBe('api:db-123:customer-api'); + expect(getSvcKey(createPrivateOptions(), req)).toBe( + 'api:db-123:customer-api' + ); }); it('uses the same X-Api-Name priority when resolving and caching API config', async () => { @@ -73,15 +77,17 @@ describe('api middleware routing priority', () => { if (params[0] === 'db-123' && params[1] === 'customer-api') { return { - rows: [{ - api_id: 'api-123', - database_id: 'db-123', - dbname: 'tenant_db', - role_name: 'api_role', - anon_role: 'api_anon', - is_public: false, - schemas: ['api_public'], - }], + rows: [ + { + api_id: 'api-123', + database_id: 'db-123', + dbname: 'tenant_db', + role_name: 'api_role', + anon_role: 'api_anon', + is_public: false, + schemas: ['api_public'], + }, + ], }; } @@ -110,8 +116,13 @@ describe('api middleware routing priority', () => { isPublic: false, }); expect(svcCache.get('api:db-123:customer-api')).toBe(result); - expect(query.mock.calls).toEqual(expect.arrayContaining([ - [expect.stringContaining('FROM services_public.apis'), ['db-123', 'customer-api', false]], - ])); + expect(query.mock.calls).toEqual( + expect.arrayContaining([ + [ + expect.stringContaining('FROM services_public.apis'), + ['db-123', 'customer-api', false], + ], + ]) + ); }); }); diff --git a/graphql/server/src/middleware/__tests__/routing.test.ts b/graphql/server/src/middleware/__tests__/routing.test.ts index a99f38a6f1..6ff38efe4b 100644 --- a/graphql/server/src/middleware/__tests__/routing.test.ts +++ b/graphql/server/src/middleware/__tests__/routing.test.ts @@ -1,4 +1,5 @@ jest.mock('pg-cache', () => ({ + ...jest.requireActual('pg-cache'), getPgPool: jest.fn(), })); @@ -19,7 +20,9 @@ import { ResolvedRoute, resolveRoute, routeToApiStructure } from '../routing'; const mockGetPgPool = getPgPool as jest.MockedFunction; -const matchedRoute = (overrides: Partial = {}): ResolvedRoute => ({ +const matchedRoute = ( + overrides: Partial = {} +): ResolvedRoute => ({ route_binding_id: 'rb-1', hostname: 'api.example.com', matched_wildcard: false, @@ -48,56 +51,99 @@ const matchedRoute = (overrides: Partial = {}): ResolvedRoute => }); const noMatchRoute = (): ResolvedRoute => - matchedRoute({ route_binding_id: null, target_module: null, resolved_config: null }); + matchedRoute({ + route_binding_id: null, + target_module: null, + resolved_config: null, + }); -const createPool = (query: jest.Mock): Pool => ({ query } as unknown as Pool); +const createPool = (query: jest.Mock): Pool => ({ query }) as unknown as Pool; describe('resolveRoute', () => { it('returns the row when a route matches', async () => { const query = jest.fn().mockResolvedValue({ rows: [matchedRoute()] }); - const row = await resolveRoute(createPool(query), 'constructive_routing_public', 'api.example.com'); + const row = await resolveRoute( + createPool(query), + 'constructive_routing_public', + 'api.example.com' + ); expect(row?.route_binding_id).toBe('rb-1'); expect(query).toHaveBeenCalledWith( - expect.stringContaining(`"constructive_routing_public".resolve_route($1, '/', NULL)`), + expect.stringContaining( + `"constructive_routing_public".resolve_route($1, '/', NULL)` + ), ['api.example.com'] ); }); it('returns null on the contract no-match row (route_binding_id IS NULL)', async () => { const query = jest.fn().mockResolvedValue({ rows: [noMatchRoute()] }); - const row = await resolveRoute(createPool(query), 'constructive_routing_public', 'nope.example.com'); + const row = await resolveRoute( + createPool(query), + 'constructive_routing_public', + 'nope.example.com' + ); expect(row).toBeNull(); }); it('returns null when the resolver function is not installed', async () => { - const query = jest.fn().mockRejectedValue(Object.assign(new Error('undefined function'), { code: '42883' })); - const row = await resolveRoute(createPool(query), 'constructive_routing_public', 'api.example.com'); + const query = jest + .fn() + .mockRejectedValue( + Object.assign(new Error('undefined function'), { code: '42883' }) + ); + const row = await resolveRoute( + createPool(query), + 'constructive_routing_public', + 'api.example.com' + ); expect(row).toBeNull(); }); it('returns null when the routing schema does not exist', async () => { - const query = jest.fn().mockRejectedValue(Object.assign(new Error('invalid schema'), { code: '3F000' })); - const row = await resolveRoute(createPool(query), 'constructive_routing_public', 'api.example.com'); + const query = jest + .fn() + .mockRejectedValue( + Object.assign(new Error('invalid schema'), { code: '3F000' }) + ); + const row = await resolveRoute( + createPool(query), + 'constructive_routing_public', + 'api.example.com' + ); expect(row).toBeNull(); }); it('rethrows unexpected database errors', async () => { - const query = jest.fn().mockRejectedValue(Object.assign(new Error('boom'), { code: '57P01' })); + const query = jest + .fn() + .mockRejectedValue(Object.assign(new Error('boom'), { code: '57P01' })); await expect( - resolveRoute(createPool(query), 'constructive_routing_public', 'api.example.com') + resolveRoute( + createPool(query), + 'constructive_routing_public', + 'api.example.com' + ) ).rejects.toThrow('boom'); }); it('rejects unsafe schema names without querying', async () => { const query = jest.fn(); - const row = await resolveRoute(createPool(query), 'bad"; DROP TABLE x;--', 'api.example.com'); + const row = await resolveRoute( + createPool(query), + 'bad"; DROP TABLE x;--', + 'api.example.com' + ); expect(row).toBeNull(); expect(query).not.toHaveBeenCalled(); }); }); describe('routeToApiStructure', () => { - const opts = { pg: { database: 'constructive' }, api: { isPublic: true } } as unknown as ApiOptions; + const opts = { + pg: { database: 'constructive' }, + api: { isPublic: true }, + } as unknown as ApiOptions; it('maps an api-target route onto ApiStructure', () => { const structure = routeToApiStructure(matchedRoute(), opts); @@ -115,18 +161,22 @@ describe('routeToApiStructure', () => { }); it('returns null for non-api targets', () => { - expect(routeToApiStructure(matchedRoute({ target_module: 'sites' }), opts)).toBeNull(); + expect( + routeToApiStructure(matchedRoute({ target_module: 'sites' }), opts) + ).toBeNull(); }); it('returns null when resolved_config lacks api essentials', () => { - expect(routeToApiStructure(matchedRoute({ resolved_config: {} }), opts)).toBeNull(); + expect( + routeToApiStructure(matchedRoute({ resolved_config: {} }), opts) + ).toBeNull(); }); }); describe('getApiConfig with scoped routing enabled', () => { const createRequest = (headers: Record): Request => { const normalized = new Map( - Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) ); return { protocol: 'http', @@ -137,14 +187,15 @@ describe('getApiConfig with scoped routing enabled', () => { } as unknown as Request; }; - const createOptions = (enableScopedRouting: boolean): ApiOptions => ({ - pg: { database: 'constructive' }, - api: { - isPublic: true, - metaSchemas: ['metaschema_public'], - enableScopedRouting, - }, - } as unknown as ApiOptions); + const createOptions = (enableScopedRouting: boolean): ApiOptions => + ({ + pg: { database: 'constructive' }, + api: { + isPublic: true, + metaSchemas: ['metaschema_public'], + enableScopedRouting, + }, + }) as unknown as ApiOptions; beforeEach(() => { svcCache.clear(); @@ -156,20 +207,28 @@ describe('getApiConfig with scoped routing enabled', () => { }); const schemaValidationRows = (params: unknown[]) => ({ - rows: (params[0] as string[]).map((schemaName) => ({ schema_name: schemaName })), + rows: (params[0] as string[]).map((schemaName) => ({ + schema_name: schemaName, + })), }); it('resolves via resolve_route (host only) before the legacy domain lookup', async () => { const query = jest.fn(async (sql: string, params: unknown[]) => { - if (sql.includes('information_schema.schemata')) return schemaValidationRows(params); + if (sql.includes('information_schema.schemata')) + return schemaValidationRows(params); if (sql.includes('resolve_route')) return { rows: [matchedRoute()] }; throw new Error(`unexpected legacy query: ${sql}`); }); mockGetPgPool.mockReturnValue(createPool(query) as never); - const result = await getApiConfig(createOptions(true), createRequest({ host: 'api.example.com' })); + const result = await getApiConfig( + createOptions(true), + createRequest({ host: 'api.example.com' }) + ); - expect(result).toEqual(expect.objectContaining({ apiId: 'api-1', dbname: 'tenant_db' })); + expect(result).toEqual( + expect.objectContaining({ apiId: 'api-1', dbname: 'tenant_db' }) + ); expect(query).toHaveBeenCalledWith( expect.stringContaining('resolve_route'), ['api.example.com'] @@ -178,40 +237,55 @@ describe('getApiConfig with scoped routing enabled', () => { it('falls back to the legacy domain lookup when resolve_route has no match', async () => { const query = jest.fn(async (sql: string, params: unknown[]) => { - if (sql.includes('information_schema.schemata')) return schemaValidationRows(params); + if (sql.includes('information_schema.schemata')) + return schemaValidationRows(params); if (sql.includes('resolve_route')) return { rows: [noMatchRoute()] }; if (sql.includes('services_public.domains')) { return { - rows: [{ - api_id: 'legacy-api', - database_id: 'db-legacy', - dbname: 'legacy_db', - role_name: 'authenticated', - anon_role: 'anon', - is_public: true, - schemas: ['app_public'], - }], + rows: [ + { + api_id: 'legacy-api', + database_id: 'db-legacy', + dbname: 'legacy_db', + role_name: 'authenticated', + anon_role: 'anon', + is_public: true, + schemas: ['app_public'], + }, + ], }; } return { rows: [] }; }); mockGetPgPool.mockReturnValue(createPool(query) as never); - const result = await getApiConfig(createOptions(true), createRequest({ host: 'legacy.example.com' })); + const result = await getApiConfig( + createOptions(true), + createRequest({ host: 'legacy.example.com' }) + ); - expect(result).toEqual(expect.objectContaining({ apiId: 'legacy-api', dbname: 'legacy_db' })); + expect(result).toEqual( + expect.objectContaining({ apiId: 'legacy-api', dbname: 'legacy_db' }) + ); }); it('does not call resolve_route when scoped routing is disabled', async () => { const query = jest.fn(async (sql: string, params: unknown[]) => { - if (sql.includes('information_schema.schemata')) return schemaValidationRows(params); - if (sql.includes('resolve_route')) throw new Error('resolve_route should not be called'); + if (sql.includes('information_schema.schemata')) + return schemaValidationRows(params); + if (sql.includes('resolve_route')) + throw new Error('resolve_route should not be called'); return { rows: [] }; }); mockGetPgPool.mockReturnValue(createPool(query) as never); - await getApiConfig(createOptions(false), createRequest({ host: 'api.example.com' })); + await getApiConfig( + createOptions(false), + createRequest({ host: 'api.example.com' }) + ); - expect(query.mock.calls.some(([sql]) => String(sql).includes('resolve_route'))).toBe(false); + expect( + query.mock.calls.some(([sql]) => String(sql).includes('resolve_route')) + ).toBe(false); }); }); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 37a56445bc..8b2e1d0d2c 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -1,6 +1,5 @@ -import { getNodeEnv } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; -import { svcCache } from '@pgpmjs/server-utils'; +import { svcCache, type ServiceCache } from '@pgpmjs/server-utils'; import { parseUrl } from '@constructive-io/url-domains'; import { createDefaultRegistry, @@ -9,11 +8,22 @@ import { } from '@constructive-io/express-context'; import { NextFunction, Request, Response } from 'express'; import { Pool } from 'pg'; -import { getPgPool } from 'pg-cache'; +import { getPgPool, type PgPoolCacheManager } from 'pg-cache'; import errorPage50x from '../errors/50x'; import errorPage404Message from '../errors/404-message'; -import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, DatabaseSettings, PubkeyChallengeSettings, RlsModule, WebauthnSettings } from '../types'; +import { + ApiConfigResult, + ApiError, + ApiOptions, + ApiStructure, + AuthSettings, + DatabaseSettings, + PubkeyChallengeSettings, + RlsModule, + WebauthnSettings, +} from '../types'; +import { getServerEnvironment } from '../runtime-environment'; import { resolveRoute, routeToApiStructure } from './routing'; import './types'; @@ -25,6 +35,13 @@ const log = new Logger('api'); const defaultRegistry: LoaderRegistry = createDefaultRegistry(); +export interface ApiRuntimeOptions { + serviceCache?: ServiceCache; + pgCache?: PgPoolCacheManager; + loaders?: LoaderRegistry; + environment?: Readonly>; +} + // ============================================================================= // SQL Queries (API resolution only — module queries now live in loaders) // ============================================================================= @@ -123,16 +140,20 @@ interface ResolveContext { cacheKey: string; headers: RoutingHeaders; host: string; + runtime: ApiRuntimeOptions; } -type ResolutionMode = +type ResolutionMode = | 'services-disabled' | 'schemata-header' | 'api-name-header' | 'meta-schema-header' | 'domain-lookup'; -type PrivateHeaderMode = Exclude; +type PrivateHeaderMode = Exclude< + ResolutionMode, + 'services-disabled' | 'domain-lookup' +>; interface RoutingHeaders { schemata?: string; @@ -162,9 +183,13 @@ const buildLoaderContext = ( servicesPool: Pool, opts: ApiOptions, row: ApiRow, + runtime: ApiRuntimeOptions ): LoaderContext => ({ servicesPool, - tenantPool: getPgPool({ ...opts.pg, database: row.dbname }), + tenantPool: getPgPool( + { ...opts.pg, database: row.dbname }, + { cache: runtime.pgCache, environment: runtime.environment } + ), databaseId: row.database_id, apiId: row.api_id, dbname: row.dbname, @@ -176,7 +201,7 @@ const buildLoaderContext = ( */ const resolveModuleSettings = async ( registry: LoaderRegistry, - ctx: LoaderContext, + ctx: LoaderContext ): Promise => { const [ rlsModule, @@ -212,9 +237,14 @@ const isApiError = (result: ApiConfigResult): result is ApiError => !!result && typeof (result as ApiError).errorHtml === 'string'; const parseCommaSeparatedHeader = (value: string): string[] => - value.split(',').map((s) => s.trim()).filter(Boolean); - -const getPrivateHeaderMode = (headers: RoutingHeaders): PrivateHeaderMode | null => { + value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +const getPrivateHeaderMode = ( + headers: RoutingHeaders +): PrivateHeaderMode | null => { if (headers.apiName) return 'api-name-header'; if (headers.schemata) return 'schemata-header'; if (headers.metaSchema) return 'meta-schema-header'; @@ -228,7 +258,9 @@ const getRoutingHeaders = (req: Request): RoutingHeaders => ({ databaseId: req.get('X-Database-Id'), }); -const getUrlDomains = (req: Request): { domain: string; subdomains: string[] } => { +const getUrlDomains = ( + req: Request +): { domain: string; subdomains: string[] } => { const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; const parsed = parseUrl(fullUrl); return { @@ -244,7 +276,10 @@ export const getSubdomain = (subdomains: string[]): string | null => { export const getSvcKey = (opts: ApiOptions, req: Request): string => { const { domain, subdomains } = getUrlDomains(req); - const baseKey = subdomains.filter((n) => n !== 'www').concat(domain).join('.'); + const baseKey = subdomains + .filter((n) => n !== 'www') + .concat(domain) + .join('.'); if (opts.api?.isPublic === false) { const headers = getRoutingHeaders(req); @@ -262,7 +297,11 @@ export const getSvcKey = (opts: ApiOptions, req: Request): string => { return baseKey; }; -const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedModuleSettings = {}): ApiStructure => ({ +const toApiStructure = ( + row: ApiRow, + opts: ApiOptions, + settings: ResolvedModuleSettings = {} +): ApiStructure => ({ apiId: row.api_id, dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', @@ -299,7 +338,10 @@ const createAdminStructure = ( // Database Queries (API resolution only) // ============================================================================= -const validateSchemata = async (pool: Pool, schemas: string[]): Promise => { +const validateSchemata = async ( + pool: Pool, + schemas: string[] +): Promise => { const result = await pool.query( `SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas] @@ -313,7 +355,11 @@ const queryByDomain = async ( subdomain: string | null, isPublic: boolean ): Promise => { - const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]); + const result = await pool.query(DOMAIN_LOOKUP_SQL, [ + domain, + subdomain, + isPublic, + ]); return result.rows[0] ?? null; }; @@ -323,11 +369,18 @@ const queryByApiName = async ( name: string, isPublic: boolean ): Promise => { - const result = await pool.query(API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]); + const result = await pool.query(API_NAME_LOOKUP_SQL, [ + databaseId, + name, + isPublic, + ]); return result.rows[0] ?? null; }; -const queryApiList = async (pool: Pool, isPublic: boolean): Promise => { +const queryApiList = async ( + pool: Pool, + isPublic: boolean +): Promise => { const result = await pool.query(API_LIST_SQL, [isPublic]); return result.rows; }; @@ -338,7 +391,7 @@ const queryApiList = async (pool: Pool, isPublic: boolean): Promise { const { opts, headers } = ctx; - + if (opts.api?.enableServicesApi === false) return 'services-disabled'; if (opts.api?.isPublic === false) { return getPrivateHeaderMode(headers) ?? 'domain-lookup'; @@ -370,27 +423,43 @@ const resolveSchemataHeader = async ( const validHeaderSchemas = headerSchemas.filter((s) => validSet.has(s)); if (validHeaderSchemas.length === 0) { - return { errorHtml: 'No valid schemas found for the supplied X-Schemata header.' }; + return { + errorHtml: 'No valid schemas found for the supplied X-Schemata header.', + }; } return createAdminStructure(opts, validHeaderSchemas, headers.databaseId); }; -const resolveApiNameHeader = async (ctx: ResolveContext): Promise => { +const resolveApiNameHeader = async ( + ctx: ResolveContext +): Promise => { const { opts, pool, headers } = ctx; if (!headers.databaseId) return null; const isPublic = opts.api?.isPublic ?? false; - const row = await queryByApiName(pool, headers.databaseId, headers.apiName!, isPublic); - + const row = await queryByApiName( + pool, + headers.databaseId, + headers.apiName!, + isPublic + ); + if (!row) { - log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`); + log.debug( + `[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}` + ); return null; } - const loaderCtx = buildLoaderContext(pool, opts, row); - const settings = await resolveModuleSettings(defaultRegistry, loaderCtx); - log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`); + const loaderCtx = buildLoaderContext(pool, opts, row, ctx.runtime); + const settings = await resolveModuleSettings( + ctx.runtime.loaders ?? defaultRegistry, + loaderCtx + ); + log.debug( + `[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}` + ); return toApiStructure(row, opts, settings); }; @@ -398,7 +467,11 @@ const resolveMetaSchemaHeader = ( ctx: ResolveContext, validatedSchemas: string[] ): ApiStructure => { - return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId); + return createAdminStructure( + ctx.opts, + validatedSchemas, + ctx.headers.databaseId + ); }; /** @@ -409,7 +482,9 @@ const resolveMetaSchemaHeader = ( * services_public lookup) when disabled, unmatched, resolver not installed, * or the target is not an api surface. */ -const resolveScopedRoute = async (ctx: ResolveContext): Promise => { +const resolveScopedRoute = async ( + ctx: ResolveContext +): Promise => { const { opts, pool, host } = ctx; if (!opts.api?.enableScopedRouting) return null; @@ -420,20 +495,30 @@ const resolveScopedRoute = async (ctx: ResolveContext): Promise => { +const resolveDomainLookup = async ( + ctx: ResolveContext +): Promise => { const { opts, pool, domain, subdomain } = ctx; const isPublic = opts.api?.isPublic ?? false; - log.debug(`[domain-lookup] domain=${domain} subdomain=${subdomain} isPublic=${isPublic}`); - + log.debug( + `[domain-lookup] domain=${domain} subdomain=${subdomain} isPublic=${isPublic}` + ); + const row = await queryByDomain(pool, domain, subdomain, isPublic); - + if (!row) { - log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`); + log.debug( + `[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}` + ); return null; } - const loaderCtx = buildLoaderContext(pool, opts, row); - const settings = await resolveModuleSettings(defaultRegistry, loaderCtx); - log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`); + const loaderCtx = buildLoaderContext(pool, opts, row, ctx.runtime); + const settings = await resolveModuleSettings( + ctx.runtime.loaders ?? defaultRegistry, + loaderCtx + ); + log.debug( + `[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}` + ); return toApiStructure(row, opts, settings); }; @@ -468,7 +564,12 @@ const buildDevFallbackError = async ( ctx: ResolveContext, req: Request ): Promise => { - if (getNodeEnv() !== 'development') return null; + if ( + (ctx.runtime.environment ?? getServerEnvironment()).NODE_ENV !== + 'development' + ) { + return null; + } const isPublic = ctx.opts.api?.isPublic ?? false; const apis = await queryApiList(ctx.pool, isPublic); @@ -478,20 +579,27 @@ const buildDevFallbackError = async ( const portMatch = host.match(/:(\d+)$/); const port = portMatch ? portMatch[1] : ''; - const apiCards = apis.map((api) => { - const domains = api.domains.length - ? api.domains.map((d) => { - const hostname = d.subdomain ? `${d.subdomain}.${d.domain}` : d.domain; - const url = port ? `http://${hostname}:${port}/graphiql` : `http://${hostname}/graphiql`; - return `${hostname}`; - }).join('·') - : 'no domains'; - - const badge = api.is_public - ? 'public' - : 'private'; - - return ` + const apiCards = apis + .map((api) => { + const domains = api.domains.length + ? api.domains + .map((d) => { + const hostname = d.subdomain + ? `${d.subdomain}.${d.domain}` + : d.domain; + const url = port + ? `http://${hostname}:${port}/graphiql` + : `http://${hostname}/graphiql`; + return `${hostname}`; + }) + .join('·') + : 'no domains'; + + const badge = api.is_public + ? 'public' + : 'private'; + + return `
${api.name} @@ -503,7 +611,8 @@ const buildDevFallbackError = async ( ${badge}
`; - }).join(''); + }) + .join(''); return { errorHtml: ` @@ -520,9 +629,14 @@ const buildDevFallbackError = async ( export const getApiConfig = async ( opts: ApiOptions, - req: Request + req: Request, + runtime: ApiRuntimeOptions = {} ): Promise => { - const pool = getPgPool(opts.pg); + const serviceCache = runtime.serviceCache ?? svcCache; + const pool = getPgPool(opts.pg, { + cache: runtime.pgCache, + environment: runtime.environment, + }); const { domain, subdomains } = getUrlDomains(req); const subdomain = getSubdomain(subdomains); const cacheKey = getSvcKey(opts, req); @@ -530,9 +644,9 @@ export const getApiConfig = async ( req.svc_key = cacheKey; // Check cache first - if (svcCache.has(cacheKey)) { + if (serviceCache.has(cacheKey)) { log.debug(`Cache HIT for key=${cacheKey}`); - return svcCache.get(cacheKey) as ApiStructure; + return serviceCache.get(cacheKey) as ApiStructure; } log.debug(`Cache MISS for key=${cacheKey}, resolving API`); @@ -545,22 +659,29 @@ export const getApiConfig = async ( cacheKey, headers: getRoutingHeaders(req), host: req.get('host') || '', + runtime, }; // Validate schemas upfront for modes that need them const apiOpts = opts.api || {}; - const headerSchemas = ctx.headers.schemata ? parseCommaSeparatedHeader(ctx.headers.schemata) : []; + const headerSchemas = ctx.headers.schemata + ? parseCommaSeparatedHeader(ctx.headers.schemata) + : []; const candidateSchemas = apiOpts.isPublic === false && headerSchemas.length ? [...new Set([...(apiOpts.metaSchemas || []), ...headerSchemas])] : apiOpts.metaSchemas || []; - + const validatedSchemas = await validateSchemata(pool, candidateSchemas); if (validatedSchemas.length === 0) { - const source = headerSchemas.length ? headerSchemas : apiOpts.metaSchemas || []; + const source = headerSchemas.length + ? headerSchemas + : apiOpts.metaSchemas || []; const label = headerSchemas.length ? 'X-Schemata' : 'metaSchemas'; - const error = new Error(`No valid schemas found. Configured ${label}: [${source.join(', ')}]`) as Error & { code?: string }; + const error = new Error( + `No valid schemas found. Configured ${label}: [${source.join(', ')}]` + ) as Error & { code?: string }; error.code = 'NO_VALID_SCHEMAS'; throw error; } @@ -600,7 +721,7 @@ export const getApiConfig = async ( // Cache successful results if (result && !isApiError(result)) { - svcCache.set(cacheKey, result); + serviceCache.set(cacheKey, result); } return result; @@ -610,8 +731,15 @@ export const getApiConfig = async ( // Express Middleware // ============================================================================= -export const createApiMiddleware = (opts: ApiOptions) => { - return async (req: Request, res: Response, next: NextFunction): Promise => { +export const createApiMiddleware = ( + opts: ApiOptions, + runtime: ApiRuntimeOptions = {} +) => { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { log.debug(`[api-middleware] ${req.method} ${req.path}`); // Fast path: services disabled @@ -624,6 +752,7 @@ export const createApiMiddleware = (opts: ApiOptions) => { cacheKey: 'meta-api-off', headers: {}, host: '', + runtime, }); req.databaseId = req.api.databaseId; req.svc_key = 'meta-api-off'; @@ -631,21 +760,31 @@ export const createApiMiddleware = (opts: ApiOptions) => { } try { - const apiConfig = await getApiConfig(opts, req); + const apiConfig = await getApiConfig(opts, req, runtime); if (isApiError(apiConfig)) { - res.status(404).send(errorPage404Message('API not found', apiConfig.errorHtml)); + res + .status(404) + .send(errorPage404Message('API not found', apiConfig.errorHtml)); return; } if (!apiConfig) { - res.status(404).send(errorPage404Message('API service not found for the given domain/subdomain.')); + res + .status(404) + .send( + errorPage404Message( + 'API service not found for the given domain/subdomain.' + ) + ); return; } req.api = apiConfig; req.databaseId = apiConfig.databaseId; - log.debug(`Resolved API: db=${apiConfig.dbname}, schemas=[${apiConfig.schema?.join(', ')}]`); + log.debug( + `Resolved API: db=${apiConfig.dbname}, schemas=[${apiConfig.schema?.join(', ')}]` + ); next(); } catch (error: unknown) { const err = error as Error & { code?: string }; @@ -656,7 +795,13 @@ export const createApiMiddleware = (opts: ApiOptions) => { } if (err.message?.includes('does not exist')) { - res.status(404).send(errorPage404Message("The resource you're looking for does not exist.")); + res + .status(404) + .send( + errorPage404Message( + "The resource you're looking for does not exist." + ) + ); return; } diff --git a/graphql/server/src/middleware/auth.ts b/graphql/server/src/middleware/auth.ts index b86e765c89..39b52f24f4 100644 --- a/graphql/server/src/middleware/auth.ts +++ b/graphql/server/src/middleware/auth.ts @@ -1,13 +1,11 @@ -import { getNodeEnv } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; import { PgpmOptions } from '@pgpmjs/types'; import { NextFunction, Request, RequestHandler, Response } from 'express'; -import { getPgPool } from 'pg-cache'; +import { getPgPool, type PgPoolCacheManager } from 'pg-cache'; import pgQueryContext from 'pg-query-context'; import './types'; // for Request type const log = new Logger('auth'); -const isDev = () => getNodeEnv() === 'development'; /** Default cookie name for session tokens. */ const SESSION_COOKIE_NAME = 'constructive_session'; @@ -19,15 +17,24 @@ const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token'; * Extract a named cookie value from the raw Cookie header. * Avoids pulling in cookie-parser as a dependency. */ -const parseCookieToken = (req: Request, cookieName: string): string | undefined => { +const parseCookieToken = ( + req: Request, + cookieName: string +): string | undefined => { const header = req.headers.cookie; if (!header) return undefined; - const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`)); + const match = header + .split(';') + .find((c) => c.trim().startsWith(`${cookieName}=`)); return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; }; export const createAuthenticateMiddleware = ( - opts: PgpmOptions + opts: PgpmOptions, + runtime: { + pgCache?: PgPoolCacheManager; + environment?: Readonly>; + } = {} ): RequestHandler => { return async ( req: Request, @@ -41,10 +48,13 @@ export const createAuthenticateMiddleware = ( return; } - const pool = getPgPool({ - ...opts.pg, - database: api.dbname, - }); + const pool = getPgPool( + { + ...opts.pg, + database: api.dbname, + }, + { cache: runtime.pgCache, environment: runtime.environment } + ); const rlsModule = api.rlsModule; log.info( @@ -79,10 +89,16 @@ export const createAuthenticateMiddleware = ( // Resolve the credential: prefer Bearer header, fall back to session cookie const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME); - const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken) - ? authToken - : cookieToken; - const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none'); + const effectiveToken = + authType?.toLowerCase() === 'bearer' && authToken + ? authToken + : cookieToken; + const tokenSource = + authType?.toLowerCase() === 'bearer' && authToken + ? 'bearer' + : cookieToken + ? 'cookie' + : 'none'; if (effectiveToken) { log.info(`[auth] Processing ${tokenSource} authentication`); @@ -119,7 +135,9 @@ export const createAuthenticateMiddleware = ( } token = result.rows[0]; - log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`); + log.info( + `[auth] Auth success: role=${token.role}, user_id=${token.user_id}` + ); } catch (e: any) { log.error('[auth] Auth error:', e.message); res.status(200).json({ @@ -135,7 +153,9 @@ export const createAuthenticateMiddleware = ( return; } } else { - log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth'); + log.info( + '[auth] No credential provided (no bearer token or session cookie), using anonymous auth' + ); } req.token = token; diff --git a/graphql/server/src/middleware/captcha.ts b/graphql/server/src/middleware/captcha.ts index 60f7f90fd0..e57fc56aff 100644 --- a/graphql/server/src/middleware/captcha.ts +++ b/graphql/server/src/middleware/captcha.ts @@ -47,7 +47,10 @@ const getOperationName = (req: Request): string | undefined => { /** * Verify a reCAPTCHA token with Google's API. */ -const verifyToken = async (token: string, secretKey: string): Promise => { +const verifyToken = async ( + token: string, + secretKey: string +): Promise => { try { const params = new URLSearchParams({ secret: secretKey, response: token }); const res = await fetch(RECAPTCHA_VERIFY_URL, { @@ -56,7 +59,9 @@ const verifyToken = async (token: string, secretKey: string): Promise = }); const data = (await res.json()) as RecaptchaResponse; if (!data.success) { - log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`); + log.debug( + `[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}` + ); } return data.success; } catch (e: any) { @@ -78,8 +83,14 @@ const verifyToken = async (token: string, secretKey: string): Promise = * - The request is not a protected mutation * - No secret key is configured server-side */ -export const createCaptchaMiddleware = (): RequestHandler => { - return async (req: Request, res: Response, next: NextFunction): Promise => { +export const createCaptchaMiddleware = ( + environment: Readonly> = process.env +): RequestHandler => { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { const authSettings = req.api?.authSettings; // Skip if CAPTCHA is not enabled @@ -94,19 +105,23 @@ export const createCaptchaMiddleware = (): RequestHandler => { } // Secret key must be set server-side (env var, not stored in DB for security) - const secretKey = process.env.RECAPTCHA_SECRET_KEY; + const secretKey = environment.RECAPTCHA_SECRET_KEY; if (!secretKey) { - log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification'); + log.warn( + '[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification' + ); return next(); } const captchaToken = req.get(CAPTCHA_HEADER); if (!captchaToken) { res.status(200).json({ - errors: [{ - message: 'CAPTCHA verification required', - extensions: { code: 'CAPTCHA_REQUIRED' }, - }], + errors: [ + { + message: 'CAPTCHA verification required', + extensions: { code: 'CAPTCHA_REQUIRED' }, + }, + ], }); return; } @@ -114,10 +129,12 @@ export const createCaptchaMiddleware = (): RequestHandler => { const valid = await verifyToken(captchaToken, secretKey); if (!valid) { res.status(200).json({ - errors: [{ - message: 'CAPTCHA verification failed', - extensions: { code: 'CAPTCHA_FAILED' }, - }], + errors: [ + { + message: 'CAPTCHA verification failed', + extensions: { code: 'CAPTCHA_FAILED' }, + }, + ], }); return; } diff --git a/graphql/server/src/middleware/cookie.ts b/graphql/server/src/middleware/cookie.ts index bb92446396..8883768edc 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import type { AuthSettings } from '../types'; +import { getServerEnvironment } from '../runtime-environment'; export const SESSION_COOKIE_NAME = 'constructive_session'; export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token'; @@ -33,8 +34,11 @@ export const getSessionCookieConfig = ( } return { - secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production', - sameSite: (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + secure: + authSettings?.cookieSecure ?? + getServerEnvironment().NODE_ENV === 'production', + sameSite: + (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', domain: authSettings?.cookieDomain ?? undefined, httpOnly: authSettings?.cookieHttponly ?? true, maxAge, @@ -45,10 +49,15 @@ export const getSessionCookieConfig = ( /** * Build cookie config for device token (long-lived, 90 days). */ -export const getDeviceTokenCookieConfig = (authSettings?: AuthSettings): CookieConfig => { +export const getDeviceTokenCookieConfig = ( + authSettings?: AuthSettings +): CookieConfig => { return { - secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production', - sameSite: (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + secure: + authSettings?.cookieSecure ?? + getServerEnvironment().NODE_ENV === 'production', + sameSite: + (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', domain: authSettings?.cookieDomain ?? undefined, httpOnly: true, maxAge: DEVICE_TOKEN_MAX_AGE, @@ -77,7 +86,10 @@ export const setSessionCookie = ( /** * Clear the session cookie. */ -export const clearSessionCookie = (res: Response, config: CookieConfig): void => { +export const clearSessionCookie = ( + res: Response, + config: CookieConfig +): void => { res.clearCookie(SESSION_COOKIE_NAME, { secure: config.secure, sameSite: config.sameSite, @@ -108,7 +120,10 @@ export const setDeviceTokenCookie = ( /** * Clear the device token cookie. */ -export const clearDeviceTokenCookie = (res: Response, config: CookieConfig): void => { +export const clearDeviceTokenCookie = ( + res: Response, + config: CookieConfig +): void => { res.clearCookie(DEVICE_TOKEN_COOKIE_NAME, { secure: config.secure, sameSite: config.sameSite, @@ -122,10 +137,15 @@ export const clearDeviceTokenCookie = (res: Response, config: CookieConfig): voi * Parse a cookie value from the raw Cookie header. * Avoids pulling in cookie-parser as a dependency. */ -export const parseCookieValue = (req: Request, cookieName: string): string | undefined => { +export const parseCookieValue = ( + req: Request, + cookieName: string +): string | undefined => { const header = req.headers.cookie; if (!header) return undefined; - const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`)); + const match = header + .split(';') + .find((c) => c.trim().startsWith(`${cookieName}=`)); return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; }; @@ -139,6 +159,8 @@ export const getDeviceTokenFromRequest = (req: Request): string | undefined => { /** * Get the session token from the request cookie. */ -export const getSessionTokenFromRequest = (req: Request): string | undefined => { +export const getSessionTokenFromRequest = ( + req: Request +): string | undefined => { return parseCookieValue(req, SESSION_COOKIE_NAME); }; diff --git a/graphql/server/src/middleware/flush.ts b/graphql/server/src/middleware/flush.ts index 653d74631c..cff2269316 100644 --- a/graphql/server/src/middleware/flush.ts +++ b/graphql/server/src/middleware/flush.ts @@ -1,44 +1,67 @@ import { ConstructiveOptions } from '@constructive-io/graphql-types'; import { Logger } from '@pgpmjs/logger'; -import { svcCache } from '@pgpmjs/server-utils'; +import { svcCache, type ServiceCache } from '@pgpmjs/server-utils'; import { NextFunction, Request, Response } from 'express'; -import { graphileCache } from 'graphile-cache'; -import { getPgPool } from 'pg-cache'; +import { graphileCache, type GraphileCacheManager } from 'graphile-cache'; +import { getPgPool, type PgPoolCacheManager } from 'pg-cache'; +import type { LoaderRegistry } from '@constructive-io/express-context'; import './types'; // for Request type const log = new Logger('flush'); -export const flush = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - if (req.url === '/flush') { - // TODO: check bearer for a flush / special key - graphileCache.delete((req as any).svc_key); - svcCache.delete((req as any).svc_key); - res.status(200).send('OK'); - return; - } - return next(); +export interface FlushRuntimeOptions { + graphileCache?: GraphileCacheManager; + serviceCache?: ServiceCache; + pgCache?: PgPoolCacheManager; + environment?: Readonly>; + loaders?: LoaderRegistry; +} + +export const createFlushMiddleware = (runtime: FlushRuntimeOptions = {}) => { + const scopedGraphileCache = runtime.graphileCache ?? graphileCache; + const scopedServiceCache = runtime.serviceCache ?? svcCache; + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + if (req.url === '/flush') { + // TODO: check bearer for a flush / special key + scopedGraphileCache.delete((req as any).svc_key); + scopedServiceCache.delete((req as any).svc_key); + if (req.databaseId) runtime.loaders?.invalidate(req.databaseId); + res.status(200).send('OK'); + return; + } + return next(); + }; }; +export const flush = createFlushMiddleware(); + export const flushService = async ( opts: ConstructiveOptions, - databaseId: string + databaseId: string, + runtime: FlushRuntimeOptions = {} ): Promise => { - const pgPool = getPgPool(opts.pg); + const scopedGraphileCache = runtime.graphileCache ?? graphileCache; + const scopedServiceCache = runtime.serviceCache ?? svcCache; + const pgPool = getPgPool(opts.pg, { + cache: runtime.pgCache, + environment: runtime.environment, + }); log.info('flushing db ' + databaseId); + runtime.loaders?.invalidate(databaseId); const api = new RegExp(`^api:${databaseId}:.*`); const schemata = new RegExp(`^schemata:${databaseId}:.*`); const meta = new RegExp(`^metaschema:api:${databaseId}`); if (!opts.api.isPublic) { - graphileCache.forEach((_, k: string) => { + scopedGraphileCache.forEach((_, k: string) => { if (api.test(k) || schemata.test(k) || meta.test(k)) { - graphileCache.delete(k); - svcCache.delete(k); + scopedGraphileCache.delete(k); + scopedServiceCache.delete(k); } }); } @@ -60,8 +83,8 @@ export const flushService = async ( key = `${row.subdomain}.${row.domain}`; } if (key) { - graphileCache.delete(key); - svcCache.delete(key); + scopedGraphileCache.delete(key); + scopedServiceCache.delete(key); } } }; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 674fca1e59..3e1c7ac414 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -1,22 +1,34 @@ import crypto from 'node:crypto'; -import { getNodeEnv } from '@pgpmjs/env'; import type { ComputeConfig } from '@constructive-io/express-context'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { Logger } from '@pgpmjs/logger'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; -import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; -import { createGraphileInstance, type GraphileCacheEntry, graphileCache } from 'graphile-cache'; +import type { GraphQLError, GraphQLFormattedError } from 'graphql'; +import { + createGraphileInstance, + type GraphileCacheEntry, + graphileCache, + type GraphileCacheManager, +} from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; import { createFunctionBindingsPlugin } from 'graphile-function-bindings'; import { createConstructivePreset, makePgService } from 'graphile-settings'; -import { getPgPool } from 'pg-cache'; +import { + getPgPool, + getPgPoolCacheKey, + type PgPoolCacheManager, +} from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; import './types'; // for Request type import { isGraphqlObservabilityEnabled } from '../diagnostics/observability'; import { HandlerCreationError } from '../errors/api-errors'; -import { observeGraphileBuild } from './observability/graphile-build-stats'; +import { + observeGraphileBuild, + type GraphileBuildStatsManager, +} from './observability/graphile-build-stats'; import type { DatabaseSettings } from '../types'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; +import { getServerEnvironment } from '../runtime-environment'; const maskErrorLog = new Logger('graphile:maskError'); @@ -134,15 +146,20 @@ const SAFE_ERROR_CODES = new Set([ * allowlist as-is, but masks unexpected/database errors with a reference ID * and logs the original. */ -const maskError = (error: GraphQLError): GraphQLError | GraphQLFormattedError => { - if (getNodeEnv() === 'development') { +const maskError = ( + error: GraphQLError +): GraphQLError | GraphQLFormattedError => { + if (getServerEnvironment().NODE_ENV === 'development') { return error; } // Only expose errors with codes on the safe allowlist. // Note: grafserv strips originalError and internal extensions before // serializing to the client, so returning the full error object is safe here. - if (error.extensions?.code && SAFE_ERROR_CODES.has(error.extensions.code as string)) { + if ( + error.extensions?.code && + SAFE_ERROR_CODES.has(error.extensions.code as string) + ) { return error; } @@ -174,27 +191,34 @@ const creating = new Map>(); * Returns the number of currently in-flight handler creation operations. * Useful for monitoring and debugging. */ -export function getInFlightCount(): number { - return creating.size; +export function getInFlightCount( + inFlight: ReadonlyMap = creating +): number { + return inFlight.size; } /** * Returns the cache keys for all currently in-flight handler creation operations. * Useful for monitoring and debugging. */ -export function getInFlightKeys(): string[] { - return [...creating.keys()]; +export function getInFlightKeys( + inFlight: ReadonlyMap = creating +): string[] { + return [...inFlight.keys()]; } /** * Clears the in-flight map. Used for testing purposes. */ -export function clearInFlightMap(): void { - creating.clear(); +export function clearInFlightMap( + inFlight: Map = creating +): void { + inFlight.clear(); } const log = new Logger('graphile'); -const reqLabel = (req: Request): string => (req.requestId ? `[${req.requestId}]` : '[req]'); +const reqLabel = (req: Request): string => + req.requestId ? `[${req.requestId}]` : '[req]'; /** * Build a PostGraphile v5 preset for a tenant. @@ -211,130 +235,148 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, apiId?: string, - compute?: ComputeConfig, + compute?: ComputeConfig ): GraphileConfig.Preset => { return { - extends: [createConstructivePreset(databaseSettings)], - plugins: [ - AuthCookiePlugin, - // Only registered when the compute module is provisioned for this - // database — all schema/table names come from the constructive - // metaschema (express-context compute module loader); the plugin has - // no fallbacks or discovery of its own. - ...(apiId && compute?.modules.length - ? [ - createFunctionBindingsPlugin({ - apiId, - modules: compute.modules.map((m) => ({ - computeSchema: m.schemaName, - bindingsTable: m.bindingsTableName, - definitionsTable: m.definitionsTableName, - invocationsSchema: m.invocationsSchemaName, - invocationsTable: m.invocationsTableName, - invocationsEntityField: m.invocationsEntityField, - })), - }), - ] - : []), - ], - pgServices: [ - makePgService({ - pool, - schemas, - }), - ], - grafserv: { - graphqlPath: '/graphql', - graphiqlPath: '/graphiql', - graphiql: true, - graphiqlOnGraphQLGET: false, - maskError, - }, - grafast: { - explain: process.env.NODE_ENV === 'development', - context: (requestContext: Partial) => { - // In grafserv/express/v4, the request is available at requestContext.expressv4.req - const req = (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req; - const context: Record = {}; - - if (req) { - if (req.databaseId) { - context['jwt.claims.database_id'] = req.databaseId; - } - // API provenance — which API surface this request arrived through. - // Derived server-side from hostname -> services_public.domains -> api_id; - // never taken from client-supplied headers, body, or token payload. - if (req.api?.apiId) { - context['jwt.claims.api_id'] = req.api.apiId; - } - if (req.clientIp) { - context['jwt.claims.ip_address'] = req.clientIp; - } - if (req.get('origin')) { - context['jwt.claims.origin'] = req.get('origin') as string; - } - if (req.get('User-Agent')) { - context['jwt.claims.user_agent'] = req.get('User-Agent') as string; - } - if (req.deviceToken) { - context['jwt.claims.device_token'] = req.deviceToken; - } - - if (req.token?.user_id) { - const pgSettings: Record = { - role: roleName, - 'jwt.claims.token_id': req.token.id, - 'jwt.claims.user_id': req.token.user_id, - ...context, - }; - - if (req.token.session_id) { - pgSettings['jwt.claims.session_id'] = req.token.session_id; + extends: [createConstructivePreset(databaseSettings)], + plugins: [ + AuthCookiePlugin, + // Only registered when the compute module is provisioned for this + // database — all schema/table names come from the constructive + // metaschema (express-context compute module loader); the plugin has + // no fallbacks or discovery of its own. + ...(apiId && compute?.modules.length + ? [ + createFunctionBindingsPlugin({ + apiId, + modules: compute.modules.map((m) => ({ + computeSchema: m.schemaName, + bindingsTable: m.bindingsTableName, + definitionsTable: m.definitionsTableName, + invocationsSchema: m.invocationsSchemaName, + invocationsTable: m.invocationsTableName, + invocationsEntityField: m.invocationsEntityField, + })), + }), + ] + : []), + ], + pgServices: [ + makePgService({ + pool, + schemas, + }), + ], + grafserv: { + graphqlPath: '/graphql', + graphiqlPath: '/graphiql', + graphiql: true, + graphiqlOnGraphQLGET: false, + maskError, + }, + grafast: { + explain: getServerEnvironment().NODE_ENV === 'development', + context: (requestContext: Partial) => { + // In grafserv/express/v4, the request is available at requestContext.expressv4.req + const req = (requestContext as { expressv4?: { req?: Request } }) + ?.expressv4?.req; + const context: Record = {}; + + if (req) { + if (req.databaseId) { + context['jwt.claims.database_id'] = req.databaseId; } - - // Propagate credential metadata as JWT claims so PG functions - // can read them via current_setting('jwt.claims.access_level') etc. - if (req.token.access_level) { - pgSettings['jwt.claims.access_level'] = req.token.access_level; + // API provenance — which API surface this request arrived through. + // Derived server-side from hostname -> services_public.domains -> api_id; + // never taken from client-supplied headers, body, or token payload. + if (req.api?.apiId) { + context['jwt.claims.api_id'] = req.api.apiId; } - if (req.token.kind) { - pgSettings['jwt.claims.kind'] = req.token.kind; + if (req.clientIp) { + context['jwt.claims.ip_address'] = req.clientIp; } - - // Principal identity — always set; equals user_id for human sessions - pgSettings['jwt.claims.principal_id'] = req.token.principal_id || req.token.user_id; - - // Enforce read-only transactions for read_only credentials - if (req.token.access_level === 'read_only') { - pgSettings['default_transaction_read_only'] = 'on'; + if (req.get('origin')) { + context['jwt.claims.origin'] = req.get('origin') as string; } - - if (req.requestId) { - pgSettings['request.id'] = req.requestId; + if (req.get('User-Agent')) { + context['jwt.claims.user_agent'] = req.get('User-Agent') as string; + } + if (req.deviceToken) { + context['jwt.claims.device_token'] = req.deviceToken; } - return { pgSettings }; + if (req.token?.user_id) { + const pgSettings: Record = { + role: roleName, + 'jwt.claims.token_id': req.token.id, + 'jwt.claims.user_id': req.token.user_id, + ...context, + }; + + if (req.token.session_id) { + pgSettings['jwt.claims.session_id'] = req.token.session_id; + } + + // Propagate credential metadata as JWT claims so PG functions + // can read them via current_setting('jwt.claims.access_level') etc. + if (req.token.access_level) { + pgSettings['jwt.claims.access_level'] = req.token.access_level; + } + if (req.token.kind) { + pgSettings['jwt.claims.kind'] = req.token.kind; + } + + // Principal identity — always set; equals user_id for human sessions + pgSettings['jwt.claims.principal_id'] = + req.token.principal_id || req.token.user_id; + + // Enforce read-only transactions for read_only credentials + if (req.token.access_level === 'read_only') { + pgSettings['default_transaction_read_only'] = 'on'; + } + + if (req.requestId) { + pgSettings['request.id'] = req.requestId; + } + + return { pgSettings }; + } } - } - const anonSettings: Record = { - role: anonRole, - ...context, - }; - if (req?.requestId) { - anonSettings['request.id'] = req.requestId; - } + const anonSettings: Record = { + role: anonRole, + ...context, + }; + if (req?.requestId) { + anonSettings['request.id'] = req.requestId; + } - return { - pgSettings: anonSettings, - }; + return { + pgSettings: anonSettings, + }; + }, }, - }, -}; + }; }; -export const graphile = (opts: ConstructiveOptions): RequestHandler => { - const observabilityEnabled = isGraphqlObservabilityEnabled(opts.server?.host); +export interface GraphileRuntimeOptions { + cache?: GraphileCacheManager; + pgCache?: PgPoolCacheManager; + inFlight?: Map>; + environment?: Readonly>; + buildStats?: GraphileBuildStatsManager; +} + +export const graphile = ( + opts: ConstructiveOptions, + runtime: GraphileRuntimeOptions = {} +): RequestHandler => { + const observabilityEnabled = isGraphqlObservabilityEnabled( + opts.server?.host, + runtime.environment + ); + const cache = runtime.cache ?? graphileCache; + const inFlightMap = runtime.inFlight ?? creating; return async (req: Request, res: Response, next: NextFunction) => { const label = reqLabel(req); @@ -355,25 +397,33 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // ========================================================================= // Phase A: Cache Check (fast path) // ========================================================================= - const cached = graphileCache.get(key); + const cached = cache.get(key); if (cached) { - log.debug(`${label} PostGraphile cache hit key=${key} db=${dbname} schemas=${schemaLabel}`); + log.debug( + `${label} PostGraphile cache hit key=${key} db=${dbname} schemas=${schemaLabel}` + ); return cached.handler(req, res, next); } - log.debug(`${label} PostGraphile cache miss key=${key} db=${dbname} schemas=${schemaLabel}`); + log.debug( + `${label} PostGraphile cache miss key=${key} db=${dbname} schemas=${schemaLabel}` + ); // ========================================================================= // Phase B: In-Flight Check (single-flight coalescing) // ========================================================================= - const inFlight = creating.get(key); + const inFlight = inFlightMap.get(key); if (inFlight) { - log.debug(`${label} Coalescing request for PostGraphile[${key}] - waiting for in-flight creation`); + log.debug( + `${label} Coalescing request for PostGraphile[${key}] - waiting for in-flight creation` + ); try { const instance = await inFlight; return instance.handler(req, res, next); } catch (error) { - log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`); + log.warn( + `${label} Coalesced request failed for PostGraphile[${key}], retrying` + ); // Fall through to Phase C to retry creation } } @@ -383,14 +433,14 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // ========================================================================= // Re-check cache after coalesced request failure (another retry may have succeeded) - const recheckedCache = graphileCache.get(key); + const recheckedCache = cache.get(key); if (recheckedCache) { log.debug(`${label} PostGraphile cache hit on re-check key=${key}`); return recheckedCache.handler(req, res, next); } // Re-check in-flight map (another retry may have started creation) - const retryInFlight = creating.get(key); + const retryInFlight = inFlightMap.get(key); if (retryInFlight) { log.debug(`${label} Re-coalescing request for PostGraphile[${key}]`); const retryInstance = await retryInFlight; @@ -398,40 +448,63 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { } log.info( - `${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`, + `${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}` ); - const pgConfig = getPgEnvOptions({ - ...opts.pg, - database: dbname, - }); + const pgConfig = getPgEnvOptions( + { + ...opts.pg, + database: dbname, + }, + getServerEnvironment() + ); // Route through pg-cache so the pool is tracked and can be cleaned up // properly, preventing leaked connections during database teardown. - const pool = getPgPool(pgConfig); + const pool = getPgPool(pgConfig, { + cache: runtime.pgCache, + environment: runtime.environment, + }); + const pgPoolKey = getPgPoolCacheKey(pgConfig, { + environment: runtime.environment, + }); // Create promise and store in in-flight map BEFORE try block - const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const compute = api.apiId + ? await req.constructive?.useModule('compute') + : undefined; + const preset = buildPreset( + pool, + schema || [], + anonRole, + roleName, + api.databaseSettings, + api.apiId, + compute + ); const creationPromise = observeGraphileBuild( { cacheKey: key, serviceKey: key, databaseId: api.databaseId ?? null, }, - () => createGraphileInstance({ - preset, - cacheKey: key, - enableRealtime: api.databaseSettings?.enableRealtime, - }), - { enabled: observabilityEnabled }, + () => + createGraphileInstance({ + preset, + cacheKey: key, + pgPoolKey, + enableRealtime: api.databaseSettings?.enableRealtime, + }), + { enabled: observabilityEnabled, stats: runtime.buildStats } ); - creating.set(key, creationPromise); + inFlightMap.set(key, creationPromise); try { const instance = await creationPromise; - graphileCache.set(key, instance); - log.info(`${label} Cached PostGraphile v5 handler key=${key} db=${dbname}`); + cache.set(key, instance); + log.info( + `${label} Cached PostGraphile v5 handler key=${key} db=${dbname}` + ); return instance.handler(req, res, next); } catch (error) { log.error(`${label} Failed to create PostGraphile[${key}]:`, error); @@ -440,17 +513,20 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { { cacheKey: key, cause: error instanceof Error ? error.message : String(error), - }, + } ); } finally { // Always clean up in-flight tracker - creating.delete(key); + inFlightMap.delete(key); } } catch (e: any) { log.error(`${label} PostGraphile middleware error`, e); if (!res.headersSent) { return res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } + error: { + code: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + }, }); } next(e); diff --git a/graphql/server/src/middleware/observability/__tests__/graphile-build-stats.test.ts b/graphql/server/src/middleware/observability/__tests__/graphile-build-stats.test.ts index e1735f2218..3873a5a0d2 100644 --- a/graphql/server/src/middleware/observability/__tests__/graphile-build-stats.test.ts +++ b/graphql/server/src/middleware/observability/__tests__/graphile-build-stats.test.ts @@ -1,4 +1,9 @@ -import { getGraphileBuildStats, observeGraphileBuild, resetGraphileBuildStats } from '../graphile-build-stats'; +import { + getGraphileBuildStats, + GraphileBuildStatsManager, + observeGraphileBuild, + resetGraphileBuildStats, +} from '../graphile-build-stats'; describe('graphile build stats', () => { beforeEach(() => { @@ -13,7 +18,7 @@ describe('graphile build stats', () => { databaseId: 'db1', }, async () => 'ok', - { enabled: false }, + { enabled: false } ); const stats = getGraphileBuildStats(); @@ -31,7 +36,7 @@ describe('graphile build stats', () => { databaseId: `db:${i}`, }, async () => 'ok', - { enabled: true }, + { enabled: true } ); } @@ -40,4 +45,23 @@ describe('graphile build stats', () => { expect(stats.byServiceKey['svc:0']).toBeUndefined(); expect(stats.byServiceKey['svc:129']).toBeDefined(); }); + + it('keeps concurrent server diagnostics isolated', async () => { + const first = new GraphileBuildStatsManager(); + const second = new GraphileBuildStatsManager(); + + await observeGraphileBuild( + { + cacheKey: 'first-cache', + serviceKey: 'first-service', + databaseId: 'first-db', + }, + async () => 'ok', + { enabled: true, stats: first } + ); + + expect(getGraphileBuildStats(first).started).toBe(1); + expect(getGraphileBuildStats(second).started).toBe(0); + expect(getGraphileBuildStats(second).recentEvents).toEqual([]); + }); }); diff --git a/graphql/server/src/middleware/observability/debug-db.ts b/graphql/server/src/middleware/observability/debug-db.ts index f012ffe3b4..48e636f0cc 100644 --- a/graphql/server/src/middleware/observability/debug-db.ts +++ b/graphql/server/src/middleware/observability/debug-db.ts @@ -1,14 +1,20 @@ import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { Logger } from '@pgpmjs/logger'; import type { RequestHandler } from 'express'; -import { getDebugDatabaseSnapshot } from '../../diagnostics/debug-db-snapshot'; +import { + getDebugDatabaseSnapshot, + type DebugDatabaseRuntimeOptions, +} from '../../diagnostics/debug-db-snapshot'; const log = new Logger('debug-db'); -export const createDebugDatabaseMiddleware = (opts: ConstructiveOptions): RequestHandler => { +export const createDebugDatabaseMiddleware = ( + opts: ConstructiveOptions, + runtime: DebugDatabaseRuntimeOptions = {} +): RequestHandler => { return async (_req, res) => { try { - const response = await getDebugDatabaseSnapshot(opts); + const response = await getDebugDatabaseSnapshot(opts, runtime); log.debug('Database debug snapshot', { activeActivity: response.activeActivity.length, diff --git a/graphql/server/src/middleware/observability/debug-memory.ts b/graphql/server/src/middleware/observability/debug-memory.ts index 81372f0354..751d728ba5 100644 --- a/graphql/server/src/middleware/observability/debug-memory.ts +++ b/graphql/server/src/middleware/observability/debug-memory.ts @@ -1,12 +1,19 @@ import { Logger } from '@pgpmjs/logger'; import type { RequestHandler } from 'express'; -import { getDebugMemorySnapshot } from '../../diagnostics/debug-memory-snapshot'; +import { + getDebugMemorySnapshot, + type DebugMemoryRuntimeOptions, +} from '../../diagnostics/debug-memory-snapshot'; const log = new Logger('debug-memory'); -export const debugMemory: RequestHandler = (_req, res) => { - const response = getDebugMemorySnapshot(); +export const createDebugMemoryMiddleware = + (runtime: DebugMemoryRuntimeOptions = {}): RequestHandler => + (_req, res) => { + const response = getDebugMemorySnapshot(runtime); - log.debug('Memory snapshot:', response); - res.json(response); -}; + log.debug('Memory snapshot:', response); + res.json(response); + }; + +export const debugMemory: RequestHandler = createDebugMemoryMiddleware(); diff --git a/graphql/server/src/middleware/observability/graphile-build-stats.ts b/graphql/server/src/middleware/observability/graphile-build-stats.ts index 9c14b08933..68e6acc4bc 100644 --- a/graphql/server/src/middleware/observability/graphile-build-stats.ts +++ b/graphql/server/src/middleware/observability/graphile-build-stats.ts @@ -8,7 +8,7 @@ export interface GraphileBuildEvent { timestamp: string; } -interface GraphileBuildAggregate { +export interface GraphileBuildAggregate { count: number; totalMs: number; maxMs: number; @@ -25,7 +25,7 @@ interface GraphileBuildContext { const MAX_BUILD_EVENTS = 100; const MAX_AGGREGATE_KEYS = 100; -const buildStats = { +const createBuildStatsState = () => ({ started: 0, succeeded: 0, failed: 0, @@ -40,19 +40,34 @@ const buildStats = { lastDatabaseId: null as string | null, recentEvents: [] as GraphileBuildEvent[], byServiceKey: new Map(), -}; +}); + +export type GraphileBuildStatsState = ReturnType; + +/** Owned build diagnostics for one GraphQL server lifecycle. */ +export class GraphileBuildStatsManager { + readonly state: GraphileBuildStatsState = createBuildStatsState(); +} + +const graphileBuildStats = new GraphileBuildStatsManager(); -const pushBuildEvent = (event: GraphileBuildEvent): void => { +const pushBuildEvent = ( + buildStats: GraphileBuildStatsState, + event: GraphileBuildEvent +): void => { buildStats.recentEvents.push(event); if (buildStats.recentEvents.length > MAX_BUILD_EVENTS) { - buildStats.recentEvents.splice(0, buildStats.recentEvents.length - MAX_BUILD_EVENTS); + buildStats.recentEvents.splice( + 0, + buildStats.recentEvents.length - MAX_BUILD_EVENTS + ); } }; const updateAggregate = ( map: Map, key: string, - durationMs: number, + durationMs: number ): void => { const hasKey = map.has(key); if (!hasKey && map.size >= MAX_AGGREGATE_KEYS) { @@ -83,14 +98,18 @@ const updateAggregate = ( map.set(key, current); }; -const recordBuildStart = (context: GraphileBuildContext, startedAt: number): void => { +const recordBuildStart = ( + buildStats: GraphileBuildStatsState, + context: GraphileBuildContext, + startedAt: number +): void => { buildStats.started += 1; buildStats.lastKey = context.cacheKey; buildStats.lastStartedAt = new Date(startedAt).toISOString(); buildStats.lastServiceKey = context.serviceKey; buildStats.lastDatabaseId = context.databaseId; - pushBuildEvent({ + pushBuildEvent(buildStats, { type: 'start', cacheKey: context.cacheKey, serviceKey: context.serviceKey, @@ -99,7 +118,11 @@ const recordBuildStart = (context: GraphileBuildContext, startedAt: number): voi }); }; -const recordBuildSuccess = (context: GraphileBuildContext, startedAt: number): void => { +const recordBuildSuccess = ( + buildStats: GraphileBuildStatsState, + context: GraphileBuildContext, + startedAt: number +): void => { const durationMs = Date.now() - startedAt; buildStats.succeeded += 1; @@ -115,7 +138,7 @@ const recordBuildSuccess = (context: GraphileBuildContext, startedAt: number): v updateAggregate(buildStats.byServiceKey, context.serviceKey, durationMs); } - pushBuildEvent({ + pushBuildEvent(buildStats, { type: 'success', cacheKey: context.cacheKey, serviceKey: context.serviceKey, @@ -126,9 +149,10 @@ const recordBuildSuccess = (context: GraphileBuildContext, startedAt: number): v }; const recordBuildFailure = ( + buildStats: GraphileBuildStatsState, context: GraphileBuildContext, startedAt: number, - error: unknown, + error: unknown ): void => { const durationMs = Date.now() - startedAt; @@ -141,7 +165,7 @@ const recordBuildFailure = ( buildStats.lastServiceKey = context.serviceKey; buildStats.lastDatabaseId = context.databaseId; - pushBuildEvent({ + pushBuildEvent(buildStats, { type: 'failure', cacheKey: context.cacheKey, serviceKey: context.serviceKey, @@ -155,26 +179,30 @@ const recordBuildFailure = ( export const observeGraphileBuild = async ( context: GraphileBuildContext, fn: () => Promise, - opts: { enabled: boolean }, + opts: { enabled: boolean; stats?: GraphileBuildStatsManager } ): Promise => { if (!opts.enabled) { return fn(); } + const buildStats = (opts.stats ?? graphileBuildStats).state; const startedAt = Date.now(); - recordBuildStart(context, startedAt); + recordBuildStart(buildStats, context, startedAt); try { const result = await fn(); - recordBuildSuccess(context, startedAt); + recordBuildSuccess(buildStats, context, startedAt); return result; } catch (error) { - recordBuildFailure(context, startedAt, error); + recordBuildFailure(buildStats, context, startedAt, error); throw error; } }; -export const resetGraphileBuildStats = (): void => { +export const resetGraphileBuildStats = ( + manager: GraphileBuildStatsManager = graphileBuildStats +): void => { + const buildStats = manager.state; buildStats.started = 0; buildStats.succeeded = 0; buildStats.failed = 0; @@ -191,7 +219,9 @@ export const resetGraphileBuildStats = (): void => { buildStats.byServiceKey.clear(); }; -export function getGraphileBuildStats(): { +export function getGraphileBuildStats( + manager: GraphileBuildStatsManager = graphileBuildStats +): { started: number; succeeded: number; failed: number; @@ -208,6 +238,7 @@ export function getGraphileBuildStats(): { recentEvents: GraphileBuildEvent[]; byServiceKey: Record; } { + const buildStats = manager.state; const completed = buildStats.succeeded + buildStats.failed; const withAverages = (map: Map) => Object.fromEntries( @@ -217,7 +248,7 @@ export function getGraphileBuildStats(): { ...value, averageMs: value.count > 0 ? value.totalMs / value.count : 0, }, - ]), + ]) ); return { diff --git a/graphql/server/src/runtime-environment.ts b/graphql/server/src/runtime-environment.ts new file mode 100644 index 0000000000..9e4c78f60f --- /dev/null +++ b/graphql/server/src/runtime-environment.ts @@ -0,0 +1,28 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { + withGraphileSettingsRuntime, + type GraphileSettingsRuntimeOptions, +} from 'graphile-settings'; + +export type ServerEnvironment = Readonly>; + +const environmentStorage = new AsyncLocalStorage(); + +/** + * Return the environment explicitly attached to the current server lifecycle. + * Legacy programmatic consumers retain process.env as a compatibility fallback. + */ +export const getServerEnvironment = (): ServerEnvironment => + environmentStorage.getStore() ?? process.env; + +/** Run a complete server lifecycle with an immutable environment snapshot. */ +export const withServerEnvironment = async ( + environment: ServerEnvironment, + callback: () => Promise, + runtime: Pick = {} +): Promise => { + const snapshot = Object.freeze({ ...environment }); + return withGraphileSettingsRuntime({ ...runtime, env: snapshot }, () => + environmentStorage.run(snapshot, callback) + ); +}; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 6f08fd7025..cb1b260dfd 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -2,20 +2,42 @@ import { createCsrfMiddleware } from '@constructive-io/csrf'; import { getEnvOptions } from '@constructive-io/graphql-env'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { Logger } from '@pgpmjs/logger'; -import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils'; +import { + createServiceCache, + healthz, + poweredBy, + svcCache, + trustProxy, + type ServiceCache, +} from '@pgpmjs/server-utils'; import { PgpmOptions } from '@pgpmjs/types'; import { middleware as parseDomains } from '@constructive-io/url-domains'; import cookieParser from 'cookie-parser'; -import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express'; +import express, { + Express, + NextFunction, + Request, + RequestHandler, + Response, +} from 'express'; import type { Server as HttpServer } from 'http'; import graphqlUpload from 'graphql-upload'; import { Pool, PoolClient } from 'pg'; -import { graphileCache, closeAllCaches } from 'graphile-cache'; -import { getPgPool } from 'pg-cache'; +import { + graphileCache, + closeAllCaches, + GraphileCacheManager, + type GraphileCacheEntry, +} from 'graphile-cache'; +import { getPgPool, PgPoolCacheManager } from 'pg-cache'; import requestIp from 'request-ip'; import type { DebugSamplerHandle } from './diagnostics/debug-sampler'; -import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot'; +import { + closeDebugDatabasePools, + createDebugDatabasePoolScope, + type DiagnosticsPoolScope, +} from './diagnostics/debug-db-snapshot'; import { isDevelopmentObservabilityMode, isGraphqlObservabilityEnabled, @@ -28,22 +50,98 @@ import { cors } from './middleware/cors'; import { errorHandler, notFoundHandler } from './middleware/error-handler'; import { favicon } from './middleware/favicon'; import { createFnRouter } from './middleware/fn'; -import { flush, flushService } from './middleware/flush'; +import { createFlushMiddleware, flushService } from './middleware/flush'; import { graphile } from './middleware/graphile'; import { multipartBridge } from './middleware/multipart-bridge'; import { createDebugDatabaseMiddleware } from './middleware/observability/debug-db'; -import { debugMemory } from './middleware/observability/debug-memory'; +import { createDebugMemoryMiddleware } from './middleware/observability/debug-memory'; import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; // Auth cookie handling is done via AuthCookiePlugin in grafserv import { createCaptchaMiddleware } from './middleware/captcha'; import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createAgenticRouter } from 'agentic-server'; -import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; +import { + createContextMiddleware, + createDefaultRegistry, + requestIdMiddleware, + type LoaderRegistry, +} from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; +import { + getServerEnvironment, + withServerEnvironment, +} from './runtime-environment'; +import { GraphileBuildStatsManager } from './middleware/observability/graphile-build-stats'; const log = new Logger('server'); +const abortReason = (signal: AbortSignal): unknown => + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError'); + +const closeHttpServer = async (server: HttpServer): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => { + if ( + error && + (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING' + ) { + reject(error); + return; + } + resolve(); + }); + }); +}; + +const createRuntimeScopeMiddleware = + ( + environment: Readonly>, + cwd?: string + ): RequestHandler => + (_req, res, next) => { + void withServerEnvironment( + environment, + () => + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + res.off('finish', handleComplete); + res.off('close', handleComplete); + }; + const handleComplete = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + + res.once('finish', handleComplete); + res.once('close', handleComplete); + try { + next(); + } catch (error) { + settled = true; + cleanup(); + reject(error); + } + }), + { cwd } + ).catch((error) => { + if (!res.headersSent) { + next(error); + return; + } + log.error('Failed to dispose request-scoped Graphile resources', error); + }); + }; + +export interface GraphQLServerRuntimeOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; +} + /** * Creates and starts a GraphQL server instance * @@ -65,11 +163,12 @@ const log = new Logger('server'); * GraphQLServer(pgpmOptions); * ``` */ -export const GraphQLServer = (rawOpts: ConstructiveOptions | PgpmOptions = {}) => { - const opts = getEnvOptions(rawOpts); - const app = new Server(opts); - app.addEventListener(); - app.listen(); +export const GraphQLServer = ( + rawOpts: ConstructiveOptions | PgpmOptions = {} +): Server => { + const app = new Server(rawOpts); + void app.start().catch((error) => app.error('Server failed to start', error)); + return app; }; class Server { @@ -79,18 +178,77 @@ class Server { private listenRelease: (() => void) | null = null; private shuttingDown = false; private closed = false; + private closePromise: Promise | null = null; private httpServer: HttpServer | null = null; private debugSampler: DebugSamplerHandle | null = null; - - constructor(opts: ConstructiveOptions) { - this.opts = getEnvOptions(opts); + private reconnectTimer: NodeJS.Timeout | null = null; + private readonly failure: Promise; + private resolveFailure!: (error: Error) => void; + private readonly runtimeEnvironment: Readonly< + Record + >; + private readonly pgPoolCache: PgPoolCacheManager; + private readonly graphileInstanceCache: GraphileCacheManager; + private readonly serviceCache: ServiceCache; + private readonly inFlight = new Map>(); + private readonly loaderRegistry: LoaderRegistry; + private readonly diagnosticsPools: DiagnosticsPoolScope; + private readonly graphileBuildStats = new GraphileBuildStatsManager(); + + constructor( + opts: ConstructiveOptions | PgpmOptions, + runtime: GraphQLServerRuntimeOptions = {} + ) { + this.runtimeEnvironment = Object.freeze({ + ...(runtime.env ?? getServerEnvironment()), + }); + this.pgPoolCache = new PgPoolCacheManager( + undefined, + this.runtimeEnvironment + ); + this.graphileInstanceCache = new GraphileCacheManager({ + pgCache: this.pgPoolCache, + environment: this.runtimeEnvironment, + }); + this.serviceCache = createServiceCache(); + this.loaderRegistry = createDefaultRegistry(); + this.diagnosticsPools = createDebugDatabasePoolScope(); + this.opts = getEnvOptions(opts, runtime.cwd, { + ...this.runtimeEnvironment, + }); + this.failure = new Promise((resolve) => { + this.resolveFailure = resolve; + }); const effectiveOpts = this.opts; - const observabilityRequested = isGraphqlObservabilityRequested(); - const observabilityEnabled = isGraphqlObservabilityEnabled(effectiveOpts.server?.host); + const observabilityRequested = isGraphqlObservabilityRequested( + this.runtimeEnvironment + ); + const observabilityEnabled = isGraphqlObservabilityEnabled( + effectiveOpts.server?.host, + this.runtimeEnvironment + ); + + const cacheRuntime = { + graphileCache: this.graphileInstanceCache, + serviceCache: this.serviceCache, + pgCache: this.pgPoolCache, + inFlight: this.inFlight, + environment: this.runtimeEnvironment, + graphileBuildStats: this.graphileBuildStats, + }; const app = express(); - const api = createApiMiddleware(effectiveOpts); - const authenticate = createAuthenticateMiddleware(effectiveOpts); + app.use(createRuntimeScopeMiddleware(this.runtimeEnvironment, runtime.cwd)); + const api = createApiMiddleware(effectiveOpts, { + serviceCache: this.serviceCache, + pgCache: this.pgPoolCache, + loaders: this.loaderRegistry, + environment: this.runtimeEnvironment, + }); + const authenticate = createAuthenticateMiddleware(effectiveOpts, { + pgCache: this.pgPoolCache, + environment: this.runtimeEnvironment, + }); const requestLogger = createRequestLogger({ observabilityEnabled }); // Log startup configuration (non-sensitive values only) @@ -112,7 +270,7 @@ class Server { if (observabilityRequested && !observabilityEnabled) { const reasons = []; - if (!isDevelopmentObservabilityMode()) { + if (!isDevelopmentObservabilityMode(this.runtimeEnvironment)) { reasons.push('NODE_ENV must be development'); } if (!isLoopbackHost(effectiveOpts.server?.host)) { @@ -122,14 +280,26 @@ class Server { log.warn( `GRAPHQL_OBSERVABILITY_ENABLED was requested but observability remains disabled${ reasons.length > 0 ? `: ${reasons.join('; ')}` : '' - }`, + }` ); } healthz(app); if (observabilityEnabled) { - app.get('/debug/memory', localObservabilityOnly, debugMemory); - app.get('/debug/db', localObservabilityOnly, createDebugDatabaseMiddleware(effectiveOpts)); + app.get( + '/debug/memory', + localObservabilityOnly, + createDebugMemoryMiddleware(cacheRuntime) + ); + app.get( + '/debug/db', + localObservabilityOnly, + createDebugDatabaseMiddleware(effectiveOpts, { + pgCache: this.pgPoolCache, + environment: this.runtimeEnvironment, + diagnosticsPools: this.diagnosticsPools, + }) + ); } else { app.use('/debug', (_req, res) => { res.status(404).send('Not found'); @@ -139,23 +309,28 @@ class Server { trustProxy(app, effectiveOpts.server.trustProxy); // Warn if a global CORS override is set in production const fallbackOrigin = effectiveOpts.server?.origin?.trim(); - if (fallbackOrigin && process.env.NODE_ENV === 'production') { + if (fallbackOrigin && this.runtimeEnvironment.NODE_ENV === 'production') { if (fallbackOrigin === '*') { log.warn( - 'CORS wildcard ("*") is enabled in production; this effectively disables CORS and is not recommended. Prefer per-API CORS via meta schema.', + 'CORS wildcard ("*") is enabled in production; this effectively disables CORS and is not recommended. Prefer per-API CORS via meta schema.' ); } else { - log.warn(`CORS override origin set to ${fallbackOrigin} in production. Prefer per-API CORS via meta schema.`); + log.warn( + `CORS override origin set to ${fallbackOrigin} in production. Prefer per-API CORS via meta schema.` + ); } } app.use(poweredBy('constructive')); app.use(cookieParser()); app.use(cors(fallbackOrigin)); - app.use('/graphql', graphqlUpload.graphqlUploadExpress({ - maxFileSize: 10 * 1024 * 1024, // 10 MB - maxFiles: 10, - })); + app.use( + '/graphql', + graphqlUpload.graphqlUploadExpress({ + maxFileSize: 10 * 1024 * 1024, // 10 MB + maxFiles: 10, + }) + ); // Rewrite Content-Type after graphql-upload so grafserv accepts the request app.use('/graphql', multipartBridge); @@ -165,19 +340,30 @@ class Server { app.use(requestLogger); app.use(api); app.use(authenticate); - app.use(createContextMiddleware({ pg: effectiveOpts.pg, loaders: createDefaultRegistry() })); - app.use(createCaptchaMiddleware()); + app.use( + createContextMiddleware({ + pg: effectiveOpts.pg, + loaders: this.loaderRegistry, + pgCache: this.pgPoolCache, + environment: this.runtimeEnvironment, + }) + ); + app.use(createCaptchaMiddleware(this.runtimeEnvironment)); // CSRF protection for cookie-authenticated requests // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests const csrf = createCsrfMiddleware({ cookieOptions: { httpOnly: false, // SPA clients need to read this via document.cookie - secure: process.env.NODE_ENV === 'production', + secure: this.runtimeEnvironment.NODE_ENV === 'production', sameSite: 'lax', }, }); - const csrfProtect: RequestHandler = (req: Request, res: Response, next: NextFunction) => { + const csrfProtect: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction + ) => { // Skip CSRF for Bearer token auth const auth = req.headers.authorization; if (auth?.toLowerCase().startsWith('bearer ')) { @@ -191,7 +377,11 @@ class Server { // Apply CSRF protection for cookie-authenticated requests csrf.protect(req as any, res as any, next); }; - const csrfSetToken: RequestHandler = (req: Request, res: Response, next: NextFunction) => { + const csrfSetToken: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction + ) => { csrf.setToken(req as any, res as any, next); }; app.use(csrfSetToken); // Set CSRF token cookie on all requests @@ -204,21 +394,42 @@ class Server { // REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id) app.use(createFnRouter()); - app.use(graphile(effectiveOpts)); - app.use(flush); + app.use( + graphile(effectiveOpts, { + cache: this.graphileInstanceCache, + pgCache: this.pgPoolCache, + inFlight: this.inFlight, + environment: this.runtimeEnvironment, + buildStats: this.graphileBuildStats, + }) + ); + app.use( + createFlushMiddleware({ + graphileCache: this.graphileInstanceCache, + serviceCache: this.serviceCache, + loaders: this.loaderRegistry, + }) + ); // Error handling - MUST be LAST app.use(notFoundHandler); // Catches unmatched routes (404) app.use(errorHandler); // Catches all thrown errors this.app = app; - this.debugSampler = observabilityEnabled ? startDebugSampler(effectiveOpts) : null; + this.debugSampler = observabilityEnabled + ? startDebugSampler(effectiveOpts, { + ...cacheRuntime, + diagnosticsPools: this.diagnosticsPools, + }) + : null; } listen(): HttpServer { + if (this.httpServer) return this.httpServer; + const { server } = this.opts; const httpServer = this.app.listen(server?.port, server?.host, () => - log.info(`listening at http://${server?.host}:${server?.port}`), + log.info(`listening at http://${server?.host}:${server?.port}`) ); httpServer.on('error', (err: NodeJS.ErrnoException) => { @@ -227,19 +438,80 @@ class Server { } else { this.error('Server failed to start', err); } - throw err; + this.resolveFailure(err); }); this.httpServer = httpServer; return httpServer; } + /** Start the server and resolve only after the HTTP listener is ready. */ + async start(signal?: AbortSignal): Promise { + if (this.closed) + throw new Error('The GraphQL server has already been closed.'); + if (this.httpServer?.listening) return this.httpServer; + if (signal?.aborted) throw abortReason(signal); + + this.addEventListener(); + const httpServer = this.listen(); + if (httpServer.listening) return httpServer; + + try { + await new Promise((resolve, reject) => { + const cleanup = () => { + httpServer.off('listening', handleListening); + httpServer.off('error', handleError); + signal?.removeEventListener('abort', handleAbort); + }; + const handleListening = () => { + cleanup(); + resolve(); + }; + const handleError = (error: Error) => { + cleanup(); + reject(error); + }; + const handleAbort = () => { + cleanup(); + reject(abortReason(signal!)); + }; + httpServer.once('listening', handleListening); + httpServer.once('error', handleError); + signal?.addEventListener('abort', handleAbort, { once: true }); + }); + return httpServer; + } catch (error) { + try { + await this.close(); + } catch (cleanupError) { + this.error( + 'Failed to clean up after server startup failure', + cleanupError + ); + } + throw error; + } + } + + async waitForFailure(): Promise { + throw await this.failure; + } + async flush(databaseId: string): Promise { - await flushService(this.opts, databaseId); + await flushService(this.opts, databaseId, { + graphileCache: this.graphileInstanceCache, + serviceCache: this.serviceCache, + pgCache: this.pgPoolCache, + environment: this.runtimeEnvironment, + loaders: this.loaderRegistry, + }); } getPool(): Pool { - return getPgPool(this.opts.pg); + return getPgPool(this.opts.pg, { + cache: this.pgPoolCache, + environment: this.runtimeEnvironment, + }); } addEventListener(): void { @@ -248,12 +520,14 @@ class Server { pgPool.connect(this.listenForChanges.bind(this)); } - listenForChanges(err: Error | null, client: PoolClient, release: () => void): void { + listenForChanges( + err: Error | null, + client: PoolClient, + release: () => void + ): void { if (err) { this.error('Error connecting with notify listener', err); - if (!this.shuttingDown) { - setTimeout(() => this.addEventListener(), 5000); - } + this.scheduleReconnect(); return; } @@ -268,11 +542,25 @@ class Server { client.on('notification', ({ channel, payload }) => { if (channel === 'schema:update' && payload) { log.info('schema:update', payload); - this.flush(payload); + void this.flush(payload).catch((error) => + this.error('Failed to flush the GraphQL service cache', error) + ); } }); - client.query('LISTEN "schema:update"'); + void client.query('LISTEN "schema:update"').catch((error) => { + if (this.listenClient !== client) return; + this.listenClient = null; + this.listenRelease = null; + client.removeAllListeners('notification'); + client.removeAllListeners('error'); + release(); + this.error( + 'Failed to initialize the schema notification listener', + error + ); + this.scheduleReconnect(); + }); client.on('error', (e) => { if (this.shuttingDown) { @@ -280,13 +568,26 @@ class Server { return; } this.error('Error with database notify listener', e); + if (this.listenClient === client) { + this.listenClient = null; + this.listenRelease = null; + } release(); - this.addEventListener(); + this.scheduleReconnect(); }); this.log('connected and listening for changes...'); } + private scheduleReconnect(): void { + if (this.shuttingDown || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.addEventListener(); + }, 5000); + this.reconnectTimer.unref?.(); + } + async removeEventListener(): Promise { if (!this.listenClient || !this.listenRelease) { return; @@ -309,28 +610,50 @@ class Server { release(); } - async close(opts: { closeCaches?: boolean } = {}): Promise { - const { closeCaches = false } = opts; - if (this.closed) { - if (closeCaches) { - await Server.closeCaches({ closePools: true }); - } - return; - } + async close(_opts: { closeCaches?: boolean } = {}): Promise { + if (this.closePromise) return this.closePromise; this.closed = true; this.shuttingDown = true; - await this.removeEventListener(); - if (this.debugSampler) { - await this.debugSampler.stop(); - this.debugSampler = null; - } - if (this.httpServer?.listening) { - await new Promise((resolve) => this.httpServer!.close(() => resolve())); - } - await closeDebugDatabasePools(); - if (closeCaches) { - await Server.closeCaches({ closePools: true }); - } + this.closePromise = (async () => { + const failures: unknown[] = []; + const attempt = async (cleanup: () => void | Promise) => { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + }; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.httpServer) { + await attempt(() => closeHttpServer(this.httpServer!)); + } + await attempt(() => this.removeEventListener()); + if (this.debugSampler) { + const sampler = this.debugSampler; + this.debugSampler = null; + await attempt(() => sampler.stop()); + } + await Promise.allSettled([...this.inFlight.values()]); + await attempt(() => closeDebugDatabasePools(this.diagnosticsPools)); + await attempt(() => this.graphileInstanceCache.close()); + await attempt(() => this.serviceCache.clear()); + await attempt(() => this.loaderRegistry.invalidate()); + this.inFlight.clear(); + await attempt(() => this.pgPoolCache.close()); + + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError( + failures, + 'Multiple GraphQL server resources failed to close.' + ); + } + })(); + return this.closePromise; } static async closeCaches(opts: { closePools?: boolean } = {}): Promise { @@ -341,7 +664,7 @@ class Server { if (closePools) { await closeAllCaches(); } else { - graphileCache.clear(); + await graphileCache.close(); } } diff --git a/packages/express-context/package.json b/packages/express-context/package.json index 14032875c5..fcb2d9c7c2 100644 --- a/packages/express-context/package.json +++ b/packages/express-context/package.json @@ -34,7 +34,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", - "lru-cache": "^11.2.7", + "lru-cache": "^10.4.3", "pg": "^8.21.0", "pg-cache": "workspace:^", "pg-query-context": "workspace:^" diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 89639fa078..0373a9445e 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -17,7 +17,7 @@ import type { PgpmOptions } from '@pgpmjs/types'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { Pool } from 'pg'; -import { getPgPool } from 'pg-cache'; +import { getPgPool, type PgPoolCacheManager } from 'pg-cache'; import type { BillingClient } from './billing-client'; import { createBillingClient } from './billing-client'; @@ -25,13 +25,23 @@ import type { LoaderRegistry } from './loaders/registry'; import type { LoaderContext } from './loaders/types'; import { withPgClient as withPgClientFn } from './pg-client'; import { buildPgSettings } from './pg-settings'; -import type { BillingConfig, BuiltinModuleMap, ConstructiveContext, InferenceLogConfig, LlmConfig } from './types'; +import type { + BillingConfig, + BuiltinModuleMap, + ConstructiveContext, + InferenceLogConfig, + LlmConfig, +} from './types'; export interface ContextMiddlewareOptions { /** Base PG options for pool creation (host, port, user, password) */ pg?: PgpmOptions['pg']; /** Module loader registry for per-database cached lookups */ loaders?: LoaderRegistry; + /** Server-owned pool cache. Defaults to the legacy process-wide cache. */ + pgCache?: PgPoolCacheManager; + /** Explicit environment for pool sizing and fallback PG settings. */ + environment?: Readonly>; } /** @@ -75,24 +85,30 @@ export function buildContext( api, token, requestId, - clientIp: req.clientIp + clientIp: req.clientIp, }); - const tenantPool: Pool = getPgPool({ - ...opts.pg, - database: api.dbname - }); + const tenantPool: Pool = getPgPool( + { + ...opts.pg, + database: api.dbname, + }, + { cache: opts.pgCache, environment: opts.environment } + ); // Build loader context (if registry provided and databaseId known) let loaderCtx: LoaderContext | null = null; if (opts.loaders && api.databaseId) { - const servicesPool: Pool = getPgPool(opts.pg); + const servicesPool: Pool = getPgPool(opts.pg, { + cache: opts.pgCache, + environment: opts.environment, + }); loaderCtx = { servicesPool, tenantPool, databaseId: api.databaseId, apiId: api.apiId, - dbname: api.dbname + dbname: api.dbname, }; } @@ -126,7 +142,7 @@ export function buildContext( const [billing, inferenceLog] = await Promise.all([ useModule('billing') as Promise, - useModule('inferenceLog') as Promise + useModule('inferenceLog') as Promise, ]); if (!billing) { @@ -144,10 +160,10 @@ export function buildContext( }, async useLlm() { if (llmConfig !== undefined) return llmConfig; - const resolved = await useModule('llm') as LlmConfig | undefined; + const resolved = (await useModule('llm')) as LlmConfig | undefined; llmConfig = resolved ?? null; return llmConfig; - } + }, }; } diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index a10f7487f5..7d5b0534a1 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -6,26 +6,28 @@ export { parseEnvBoolean, parseEnvList, parseEnvNumber }; /** * Parse core PGPM environment variables. * GraphQL-related env vars (GRAPHILE_*, FEATURES_*, API_*) are handled by @constructive-io/graphql-env. - * + * * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ -export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => { - const { - PGROOTDATABASE, - PGTEMPLATE, - DB_PREFIX, - DB_EXTENSIONS, - DB_CWD, - DB_CONNECTION_USER, - DB_CONNECTION_PASSWORD, - DB_CONNECTION_ROLE, - // New connections.app and connections.admin env vars - DB_CONNECTIONS_APP_USER, - DB_CONNECTIONS_APP_PASSWORD, - DB_CONNECTIONS_ADMIN_USER, - DB_CONNECTIONS_ADMIN_PASSWORD, +export const getEnvVars = ( + env: NodeJS.ProcessEnv = process.env +): PgpmOptions => { + const { + PGROOTDATABASE, + PGTEMPLATE, + DB_PREFIX, + DB_EXTENSIONS, + DB_CWD, + DB_CONNECTION_USER, + DB_CONNECTION_PASSWORD, + DB_CONNECTION_ROLE, + // New connections.app and connections.admin env vars + DB_CONNECTIONS_APP_USER, + DB_CONNECTIONS_APP_PASSWORD, + DB_CONNECTIONS_ADMIN_USER, + DB_CONNECTIONS_ADMIN_PASSWORD, - PORT, + PORT, SERVER_HOST, SERVER_TRUST_PROXY, SERVER_ORIGIN, @@ -83,7 +85,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => SMTP_MAX_MESSAGES, SMTP_NAME, SMTP_LOGGER, - SMTP_DEBUG + SMTP_DEBUG, } = env; return { @@ -91,38 +93,55 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(PGROOTDATABASE && { rootDb: PGROOTDATABASE }), ...(PGTEMPLATE && { template: PGTEMPLATE }), ...(DB_PREFIX && { prefix: DB_PREFIX }), - ...(DB_EXTENSIONS && { extensions: DB_EXTENSIONS.split(',').map(ext => ext.trim()) }), + ...(DB_EXTENSIONS && { + extensions: DB_EXTENSIONS.split(',').map((ext) => ext.trim()), + }), ...(DB_CWD && { cwd: DB_CWD }), - ...((DB_CONNECTION_USER || DB_CONNECTION_PASSWORD || DB_CONNECTION_ROLE) && { - connection: { - ...(DB_CONNECTION_USER && { user: DB_CONNECTION_USER }), - ...(DB_CONNECTION_PASSWORD && { password: DB_CONNECTION_PASSWORD }), - ...(DB_CONNECTION_ROLE && { role: DB_CONNECTION_ROLE }), - } + ...((DB_CONNECTION_USER || + DB_CONNECTION_PASSWORD || + DB_CONNECTION_ROLE) && { + connection: { + ...(DB_CONNECTION_USER && { user: DB_CONNECTION_USER }), + ...(DB_CONNECTION_PASSWORD && { password: DB_CONNECTION_PASSWORD }), + ...(DB_CONNECTION_ROLE && { role: DB_CONNECTION_ROLE }), + }, + }), + ...((DB_CONNECTIONS_APP_USER || + DB_CONNECTIONS_APP_PASSWORD || + DB_CONNECTIONS_ADMIN_USER || + DB_CONNECTIONS_ADMIN_PASSWORD) && { + connections: { + ...((DB_CONNECTIONS_APP_USER || DB_CONNECTIONS_APP_PASSWORD) && { + app: { + ...(DB_CONNECTIONS_APP_USER && { user: DB_CONNECTIONS_APP_USER }), + ...(DB_CONNECTIONS_APP_PASSWORD && { + password: DB_CONNECTIONS_APP_PASSWORD, + }), + }, }), - ...((DB_CONNECTIONS_APP_USER || DB_CONNECTIONS_APP_PASSWORD || DB_CONNECTIONS_ADMIN_USER || DB_CONNECTIONS_ADMIN_PASSWORD) && { - connections: { - ...((DB_CONNECTIONS_APP_USER || DB_CONNECTIONS_APP_PASSWORD) && { - app: { - ...(DB_CONNECTIONS_APP_USER && { user: DB_CONNECTIONS_APP_USER }), - ...(DB_CONNECTIONS_APP_PASSWORD && { password: DB_CONNECTIONS_APP_PASSWORD }), - } + ...((DB_CONNECTIONS_ADMIN_USER || DB_CONNECTIONS_ADMIN_PASSWORD) && { + admin: { + ...(DB_CONNECTIONS_ADMIN_USER && { + user: DB_CONNECTIONS_ADMIN_USER, }), - ...((DB_CONNECTIONS_ADMIN_USER || DB_CONNECTIONS_ADMIN_PASSWORD) && { - admin: { - ...(DB_CONNECTIONS_ADMIN_USER && { user: DB_CONNECTIONS_ADMIN_USER }), - ...(DB_CONNECTIONS_ADMIN_PASSWORD && { password: DB_CONNECTIONS_ADMIN_PASSWORD }), - } + ...(DB_CONNECTIONS_ADMIN_PASSWORD && { + password: DB_CONNECTIONS_ADMIN_PASSWORD, }), - } + }, }), }, + }), + }, server: { ...(PORT && { port: parseEnvNumber(PORT) }), ...(SERVER_HOST && { host: SERVER_HOST }), - ...(SERVER_TRUST_PROXY && { trustProxy: parseEnvBoolean(SERVER_TRUST_PROXY) }), + ...(SERVER_TRUST_PROXY && { + trustProxy: parseEnvBoolean(SERVER_TRUST_PROXY), + }), ...(SERVER_ORIGIN && { origin: SERVER_ORIGIN }), - ...(SERVER_STRICT_AUTH && { strictAuth: parseEnvBoolean(SERVER_STRICT_AUTH) }), + ...(SERVER_STRICT_AUTH && { + strictAuth: parseEnvBoolean(SERVER_STRICT_AUTH), + }), }, pg: { ...(PGHOST && { host: PGHOST }), @@ -135,69 +154,81 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(BUCKET_PROVIDER && { provider: BUCKET_PROVIDER as BucketProvider }), ...(BUCKET_NAME && { bucketName: BUCKET_NAME }), ...(AWS_REGION && { awsRegion: AWS_REGION }), - ...((AWS_ACCESS_KEY || AWS_ACCESS_KEY_ID) && { awsAccessKey: AWS_ACCESS_KEY || AWS_ACCESS_KEY_ID }), - ...((AWS_SECRET_KEY || AWS_SECRET_ACCESS_KEY) && { awsSecretKey: AWS_SECRET_KEY || AWS_SECRET_ACCESS_KEY }), + ...((AWS_ACCESS_KEY || AWS_ACCESS_KEY_ID) && { + awsAccessKey: AWS_ACCESS_KEY || AWS_ACCESS_KEY_ID, + }), + ...((AWS_SECRET_KEY || AWS_SECRET_ACCESS_KEY) && { + awsSecretKey: AWS_SECRET_KEY || AWS_SECRET_ACCESS_KEY, + }), ...(CDN_ENDPOINT && { endpoint: CDN_ENDPOINT }), ...(CDN_PUBLIC_URL_PREFIX && { publicUrlPrefix: CDN_PUBLIC_URL_PREFIX }), }, deployment: { ...(DEPLOYMENT_USE_TX && { useTx: parseEnvBoolean(DEPLOYMENT_USE_TX) }), ...(DEPLOYMENT_FAST && { fast: parseEnvBoolean(DEPLOYMENT_FAST) }), - ...(DEPLOYMENT_USE_PLAN && { usePlan: parseEnvBoolean(DEPLOYMENT_USE_PLAN) }), + ...(DEPLOYMENT_USE_PLAN && { + usePlan: parseEnvBoolean(DEPLOYMENT_USE_PLAN), + }), ...(DEPLOYMENT_CACHE && { cache: parseEnvBoolean(DEPLOYMENT_CACHE) }), ...(DEPLOYMENT_TO_CHANGE && { toChange: DEPLOYMENT_TO_CHANGE }), }, migrations: { ...(MIGRATIONS_CODEGEN_USE_TX && { codegen: { - useTx: parseEnvBoolean(MIGRATIONS_CODEGEN_USE_TX) - } + useTx: parseEnvBoolean(MIGRATIONS_CODEGEN_USE_TX), + }, }), }, jobs: { ...(JOBS_SCHEMA && { schema: { - schema: JOBS_SCHEMA - } + schema: JOBS_SCHEMA, + }, }), ...((JOBS_SUPPORT_ANY || JOBS_SUPPORTED) && { worker: { ...(JOBS_SUPPORT_ANY && { - supportAny: parseEnvBoolean(JOBS_SUPPORT_ANY) + supportAny: parseEnvBoolean(JOBS_SUPPORT_ANY), }), ...(JOBS_SUPPORTED && { - supported: parseEnvList(JOBS_SUPPORTED) - }) + supported: parseEnvList(JOBS_SUPPORTED), + }), }, scheduler: { ...(JOBS_SUPPORT_ANY && { - supportAny: parseEnvBoolean(JOBS_SUPPORT_ANY) + supportAny: parseEnvBoolean(JOBS_SUPPORT_ANY), }), ...(JOBS_SUPPORTED && { - supported: parseEnvList(JOBS_SUPPORTED) - }) - } + supported: parseEnvList(JOBS_SUPPORTED), + }), + }, }), ...((INTERNAL_GATEWAY_URL || INTERNAL_JOBS_CALLBACK_URL || INTERNAL_JOBS_CALLBACK_PORT) && { gateway: { ...(INTERNAL_GATEWAY_URL && { - gatewayUrl: INTERNAL_GATEWAY_URL + gatewayUrl: INTERNAL_GATEWAY_URL, }), ...(INTERNAL_JOBS_CALLBACK_URL && { - callbackUrl: INTERNAL_JOBS_CALLBACK_URL + callbackUrl: INTERNAL_JOBS_CALLBACK_URL, }), ...(INTERNAL_JOBS_CALLBACK_PORT && { - callbackPort: parseEnvNumber(INTERNAL_JOBS_CALLBACK_PORT) - }) - } - }) + callbackPort: parseEnvNumber(INTERNAL_JOBS_CALLBACK_PORT), + }), + }, + }), }, errorOutput: { - ...(PGPM_ERROR_QUERY_HISTORY_LIMIT && { queryHistoryLimit: parseEnvNumber(PGPM_ERROR_QUERY_HISTORY_LIMIT) }), - ...(PGPM_ERROR_MAX_LENGTH && { maxLength: parseEnvNumber(PGPM_ERROR_MAX_LENGTH) }), - ...(PGPM_ERROR_VERBOSE && { verbose: parseEnvBoolean(PGPM_ERROR_VERBOSE) }), + ...(PGPM_ERROR_QUERY_HISTORY_LIMIT && { + queryHistoryLimit: parseEnvNumber(PGPM_ERROR_QUERY_HISTORY_LIMIT), + }), + ...(PGPM_ERROR_MAX_LENGTH && { + maxLength: parseEnvNumber(PGPM_ERROR_MAX_LENGTH), + }), + ...(PGPM_ERROR_VERBOSE && { + verbose: parseEnvBoolean(PGPM_ERROR_VERBOSE), + }), }, smtp: { ...(SMTP_HOST && { host: SMTP_HOST }), @@ -207,22 +238,32 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(SMTP_PASS && { pass: SMTP_PASS }), ...(SMTP_FROM && { from: SMTP_FROM }), ...(SMTP_REPLY_TO && { replyTo: SMTP_REPLY_TO }), - ...(SMTP_REQUIRE_TLS && { requireTLS: parseEnvBoolean(SMTP_REQUIRE_TLS) }), - ...(SMTP_TLS_REJECT_UNAUTHORIZED && { tlsRejectUnauthorized: parseEnvBoolean(SMTP_TLS_REJECT_UNAUTHORIZED) }), + ...(SMTP_REQUIRE_TLS && { + requireTLS: parseEnvBoolean(SMTP_REQUIRE_TLS), + }), + ...(SMTP_TLS_REJECT_UNAUTHORIZED && { + tlsRejectUnauthorized: parseEnvBoolean(SMTP_TLS_REJECT_UNAUTHORIZED), + }), ...(SMTP_POOL && { pool: parseEnvBoolean(SMTP_POOL) }), - ...(SMTP_MAX_CONNECTIONS && { maxConnections: parseEnvNumber(SMTP_MAX_CONNECTIONS) }), - ...(SMTP_MAX_MESSAGES && { maxMessages: parseEnvNumber(SMTP_MAX_MESSAGES) }), + ...(SMTP_MAX_CONNECTIONS && { + maxConnections: parseEnvNumber(SMTP_MAX_CONNECTIONS), + }), + ...(SMTP_MAX_MESSAGES && { + maxMessages: parseEnvNumber(SMTP_MAX_MESSAGES), + }), ...(SMTP_NAME && { name: SMTP_NAME }), ...(SMTP_LOGGER && { logger: parseEnvBoolean(SMTP_LOGGER) }), ...(SMTP_DEBUG && { debug: parseEnvBoolean(SMTP_DEBUG) }), - } + }, }; }; type NodeEnv = 'development' | 'production' | 'test'; -export const getNodeEnv = (): NodeEnv => { - const env = process.env.NODE_ENV?.toLowerCase(); - if (env === 'production' || env === 'test') return env; +export const getNodeEnv = ( + environment: Readonly> = process.env +): NodeEnv => { + const nodeEnv = environment.NODE_ENV?.toLowerCase(); + if (nodeEnv === 'production' || nodeEnv === 'test') return nodeEnv; return 'development'; }; diff --git a/pgpm/logger/__tests__/suppression.test.ts b/pgpm/logger/__tests__/suppression.test.ts new file mode 100644 index 0000000000..a9ca4a11b3 --- /dev/null +++ b/pgpm/logger/__tests__/suppression.test.ts @@ -0,0 +1,22 @@ +import { Logger, withLogsSuppressed } from '../src'; + +describe('withLogsSuppressed', () => { + it('suppresses only the scoped async execution chain', async () => { + const write = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const logger = new Logger('test'); + + await withLogsSuppressed(async () => { + logger.info('hidden'); + await Promise.resolve(); + logger.info('also hidden'); + }); + + logger.info('visible'); + + expect(write).toHaveBeenCalledTimes(1); + expect(String(write.mock.calls[0][0])).toContain('visible'); + write.mockRestore(); + }); +}); diff --git a/pgpm/logger/src/index.ts b/pgpm/logger/src/index.ts index 7819f85040..61ad0a72c4 100644 --- a/pgpm/logger/src/index.ts +++ b/pgpm/logger/src/index.ts @@ -1,2 +1,10 @@ -export { Logger, createLogger, setLogLevel, setShowTimestamp, setLogFormat, setLogScopes } from './logger'; -export type { LogLevel, LogFormat } from './logger'; \ No newline at end of file +export { + Logger, + createLogger, + setLogLevel, + setShowTimestamp, + setLogFormat, + setLogScopes, + withLogsSuppressed, +} from './logger'; +export type { LogLevel, LogFormat } from './logger'; diff --git a/pgpm/logger/src/logger.ts b/pgpm/logger/src/logger.ts index c4486349dd..f8ba5ae79b 100644 --- a/pgpm/logger/src/logger.ts +++ b/pgpm/logger/src/logger.ts @@ -1,14 +1,31 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import yanse from 'yanse'; export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success'; export type LogFormat = 'pretty' | 'json'; +interface LogExecutionContext { + suppressed: boolean; +} + +const logExecutionContext = new AsyncLocalStorage(); + +/** + * Run work with PGPM logger output suppressed for the current async execution + * chain. This is intentionally async-context scoped rather than global so a + * machine-readable CLI operation cannot silence an unrelated human request in + * the same process. + */ +export const withLogsSuppressed = (callback: () => T): T => + logExecutionContext.run({ suppressed: true }, callback); + const levelPriority: Record = { debug: 0, info: 1, success: 2, warn: 3, - error: 4 + error: 4, }; const levelColors: Record = { @@ -16,7 +33,7 @@ const levelColors: Record = { warn: yanse.yellow, error: yanse.red, debug: yanse.gray, - success: yanse.green + success: yanse.green, }; const hasAnsi = (text: string): boolean => { @@ -81,21 +98,26 @@ const parseScopeFilter = (env: string | undefined): ScopeFilter => { const include = new Set(); const exclude = new Set(); - (env ?? '').split(',').map(s => s.trim()).forEach(scope => { - if (!scope) return; - if (scope === '*') { - include.add('*'); - } else if (scope.startsWith('^')) { - exclude.add(scope.slice(1)); - } else { - include.add(scope); - } - }); + (env ?? '') + .split(',') + .map((s) => s.trim()) + .forEach((scope) => { + if (!scope) return; + if (scope === '*') { + include.add('*'); + } else if (scope.startsWith('^')) { + exclude.add(scope.slice(1)); + } else { + include.add(scope); + } + }); return { include, exclude }; }; -let { include: allowedScopes, exclude: blockedScopes } = parseScopeFilter(process.env.LOG_SCOPE); +let { include: allowedScopes, exclude: blockedScopes } = parseScopeFilter( + process.env.LOG_SCOPE +); // Update scopes at runtime export const setLogScopes = (scopes: string[]) => { @@ -113,6 +135,8 @@ export class Logger { } private log(level: LogLevel, ...args: any[]) { + if (logExecutionContext.getStore()?.suppressed) return; + // Respect log level if (levelPriority[level] < levelPriority[globalLogLevel]) return; @@ -133,7 +157,7 @@ export class Logger { const entry: Record = { timestamp: new Date().toISOString(), level, - scope: this.scope + scope: this.scope, }; // Extract message and data from args @@ -146,7 +170,7 @@ export class Logger { entry.error = { name: arg.name, message: arg.message, - stack: arg.stack + stack: arg.stack, }; } else { // Preserve additional errors in the message string @@ -172,7 +196,7 @@ export class Logger { const color = levelColors[level]; const prefix = color(`${level.toUpperCase()}:`); - const formattedArgs = args.map(arg => { + const formattedArgs = args.map((arg) => { const normalized = formatArg(arg); return typeof normalized === 'string' && !hasAnsi(normalized) ? color(normalized) @@ -180,11 +204,14 @@ export class Logger { }); const outputParts = showTimestamp - ? [yanse.dim(`[${new Date().toISOString()}]`), tag, prefix, ...formattedArgs] + ? [ + yanse.dim(`[${new Date().toISOString()}]`), + tag, + prefix, + ...formattedArgs, + ] : [tag, prefix, ...formattedArgs]; - const output = outputParts - .map(arg => String(arg)) - .join(' ') + '\n'; + const output = outputParts.map((arg) => String(arg)).join(' ') + '\n'; stream.write(output); } diff --git a/postgres/pg-env/src/env.ts b/postgres/pg-env/src/env.ts index 8a8e15aa72..5b7aa30f09 100644 --- a/postgres/pg-env/src/env.ts +++ b/postgres/pg-env/src/env.ts @@ -1,15 +1,11 @@ import { parseEnvNumber } from '12factor-env'; -import { defaultPgConfig,PgConfig } from './pg-config'; +import { defaultPgConfig, PgConfig } from './pg-config'; -export const getPgEnvVars = (): Partial => { - const { - PGHOST, - PGPORT, - PGUSER, - PGPASSWORD, - PGDATABASE - } = process.env; +export const getPgEnvVars = ( + env: NodeJS.ProcessEnv = process.env +): Partial => { + const { PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE } = env; return { ...(PGHOST && { host: PGHOST }), @@ -20,8 +16,11 @@ export const getPgEnvVars = (): Partial => { }; }; -export const getPgEnvOptions = (overrides: Partial = {}): PgConfig => { - const envOpts = getPgEnvVars(); +export const getPgEnvOptions = ( + overrides: Partial = {}, + env: NodeJS.ProcessEnv = process.env +): PgConfig => { + const envOpts = getPgEnvVars(env); const merged = { ...defaultPgConfig, ...envOpts, ...overrides }; return merged; }; @@ -43,6 +42,6 @@ export function getSpawnEnvWithPg( ): NodeJS.ProcessEnv { return { ...baseEnv, - ...toPgEnvVars(config) + ...toPgEnvVars(config), }; -} \ No newline at end of file +} From 21466cca52573a652ae2cc2ce94bbfba7a05cbb4 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:37:52 +0700 Subject: [PATCH 04/18] feat(jobs): add observable isolated lifecycles Give workers, schedulers, services, and function runtimes explicit environment and ownership boundaries. Return lifecycle handles, await startup and shutdown, and cover concurrent instances and request cleanup. --- functions/send-email/src/index.ts | 23 +- functions/send-email/tsconfig.esm.json | 7 +- functions/send-verification-link/src/index.ts | 86 +++-- jobs/job-pg/__tests__/pool-manager.test.ts | 45 +++ jobs/job-pg/src/index.ts | 101 +++++- .../job-scheduler/__tests__/lifecycle.test.ts | 40 +++ jobs/job-scheduler/src/index.ts | 162 ++++++--- jobs/job-utils/src/index.ts | 73 ++-- jobs/job-utils/src/runtime.ts | 148 ++++---- jobs/knative-job-fn/src/index.ts | 57 ++- .../knative-job-fn/src/runtime-environment.ts | 16 + .../__tests__/lifecycle.test.ts | 177 ++++++++++ jobs/knative-job-service/src/index.ts | 331 ++++++++++++------ jobs/knative-job-service/src/types.ts | 4 + .../test-fixtures/concurrent-functions.cjs | 150 ++++++++ .../__tests__/lifecycle.test.ts | 37 ++ jobs/knative-job-worker/__tests__/req.test.ts | 288 +++++++++------ jobs/knative-job-worker/src/index.ts | 150 +++++--- jobs/knative-job-worker/src/req.ts | 57 ++- 19 files changed, 1463 insertions(+), 489 deletions(-) create mode 100644 jobs/job-pg/__tests__/pool-manager.test.ts create mode 100644 jobs/job-scheduler/__tests__/lifecycle.test.ts create mode 100644 jobs/knative-job-fn/src/runtime-environment.ts create mode 100644 jobs/knative-job-service/__tests__/lifecycle.test.ts create mode 100644 jobs/knative-job-service/test-fixtures/concurrent-functions.cjs create mode 100644 jobs/knative-job-worker/__tests__/lifecycle.test.ts diff --git a/functions/send-email/src/index.ts b/functions/send-email/src/index.ts index 197afd2b6e..8ce22301c2 100644 --- a/functions/send-email/src/index.ts +++ b/functions/send-email/src/index.ts @@ -1,4 +1,7 @@ -import { createJobApp } from '@constructive-io/knative-job-fn'; +import { + createJobApp, + getJobFunctionEnvironment, +} from '@constructive-io/knative-job-fn'; import { send as sendSmtp } from 'simple-smtp-server'; import { send as sendPostmaster } from '@constructive-io/postmaster'; import { parseEnvBoolean } from '@pgpmjs/env'; @@ -27,8 +30,12 @@ const getRequiredField = ( return value; }; -const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_DRY_RUN ?? process.env.SIMPLE_EMAIL_DRY_RUN) ?? false; -const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; +const runtimeEnv = getJobFunctionEnvironment(); +const isDryRun = + parseEnvBoolean( + runtimeEnv.SEND_EMAIL_DRY_RUN ?? runtimeEnv.SIMPLE_EMAIL_DRY_RUN + ) ?? false; +const useSmtp = parseEnvBoolean(runtimeEnv.EMAIL_SEND_USE_SMTP) ?? false; const logger = createLogger('send-email'); const app = createJobApp(); @@ -46,7 +53,7 @@ app.post('/', async (req: any, res: any, next: any) => { throw new Error("Either 'html' or 'text' must be provided"); } - const fromEnv = useSmtp ? process.env.SMTP_FROM : process.env.MAILGUN_FROM; + const fromEnv = useSmtp ? runtimeEnv.SMTP_FROM : runtimeEnv.MAILGUN_FROM; const from = isNonEmptyString(payload.from) ? payload.from : isNonEmptyString(fromEnv) @@ -63,7 +70,7 @@ app.post('/', async (req: any, res: any, next: any) => { from, replyTo, hasHtml: Boolean(html), - hasText: Boolean(text) + hasText: Boolean(text), }; if (isDryRun) { @@ -77,7 +84,7 @@ app.post('/', async (req: any, res: any, next: any) => { ...(html && { html }), ...(text && { text }), ...(from && { from }), - ...(replyTo && { replyTo }) + ...(replyTo && { replyTo }), }); logger.info('Sent email', logContext); @@ -93,8 +100,8 @@ export default app; // When executed directly (e.g. `node dist/index.js` in Knative), // start an HTTP server on the provided PORT (default 8080). -if (require.main === module) { - const port = Number(process.env.PORT ?? 8080); +if (typeof require !== 'undefined' && require.main === module) { + const port = Number(runtimeEnv.PORT ?? 8080); // @constructive-io/knative-job-fn exposes a .listen method that delegates to the underlying Express app (app as any).listen(port, () => { logger.info(`listening on port ${port}`); diff --git a/functions/send-email/tsconfig.esm.json b/functions/send-email/tsconfig.esm.json index 809510a46b..800d7506d3 100644 --- a/functions/send-email/tsconfig.esm.json +++ b/functions/send-email/tsconfig.esm.json @@ -1,8 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "module": "esnext", - "moduleResolution": "bundler" + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false } } - diff --git a/functions/send-verification-link/src/index.ts b/functions/send-verification-link/src/index.ts index b7046b472a..0ba09e98e2 100644 --- a/functions/send-verification-link/src/index.ts +++ b/functions/send-verification-link/src/index.ts @@ -1,4 +1,7 @@ -import { createJobApp } from '@constructive-io/knative-job-fn'; +import { + createJobApp, + getJobFunctionEnvironment, +} from '@constructive-io/knative-job-fn'; import { GraphQLClient } from 'graphql-request'; import gql from 'graphql-tag'; import { generate } from '@launchql/mjml'; @@ -7,8 +10,13 @@ import { send as sendSmtp } from 'simple-smtp-server'; import { parseEnvBoolean } from '@pgpmjs/env'; import { createLogger } from '@pgpmjs/logger'; -const isDryRun = parseEnvBoolean(process.env.SEND_VERIFICATION_LINK_DRY_RUN ?? process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false; -const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; +const runtimeEnv = getJobFunctionEnvironment(); +const isDryRun = + parseEnvBoolean( + runtimeEnv.SEND_VERIFICATION_LINK_DRY_RUN ?? + runtimeEnv.SEND_EMAIL_LINK_DRY_RUN + ) ?? false; +const useSmtp = parseEnvBoolean(runtimeEnv.EMAIL_SEND_USE_SMTP) ?? false; const logger = createLogger('send-verification-link'); const app = createJobApp(); @@ -74,7 +82,7 @@ type GraphQLContext = { }; const getRequiredEnv = (name: string): string => { - const value = process.env[name]; + const value = runtimeEnv[name]; if (!value) { throw new Error(`Missing required environment variable ${name}`); } @@ -98,12 +106,12 @@ const createGraphQLClient = ( ): GraphQLClient => { const headers: Record = {}; - if (process.env.GRAPHQL_AUTH_TOKEN) { - headers.Authorization = `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}`; + if (runtimeEnv.GRAPHQL_AUTH_TOKEN) { + headers.Authorization = `Bearer ${runtimeEnv.GRAPHQL_AUTH_TOKEN}`; } const envName = options.hostHeaderEnvVar || 'GRAPHQL_HOST_HEADER'; - const hostHeader = process.env[envName]; + const hostHeader = runtimeEnv[envName]; if (hostHeader) { headers.host = hostHeader; } @@ -166,7 +174,7 @@ export const sendEmailLink = async ( } const databaseInfo = await meta.request(GetDatabaseInfo, { - databaseId + databaseId, }); const database = databaseInfo?.databases?.nodes?.[0]; @@ -203,9 +211,8 @@ export const sendEmailLink = async ( domain === '0.0.0.0'; // For localhost, skip subdomain to generate cleaner URLs (http://localhost:3000) - const hostname = subdomain && !isLocalDomain - ? [subdomain, domain].join('.') - : domain; + const hostname = + subdomain && !isLocalDomain ? [subdomain, domain].join('.') : domain; // Treat localhost-style hosts specially so we can generate // http://localhost[:port]/... links for local dev without @@ -217,8 +224,8 @@ export const sendEmailLink = async ( // When localhost + LOCAL_APP_PORT: use http://localhost:PORT (local dev) // Otherwise: https (production) - const useLocalUrl = isLocalHost && process.env.LOCAL_APP_PORT; - const localPort = useLocalUrl ? `:${process.env.LOCAL_APP_PORT}` : ''; + const useLocalUrl = isLocalHost && runtimeEnv.LOCAL_APP_PORT; + const localPort = useLocalUrl ? `:${runtimeEnv.LOCAL_APP_PORT}` : ''; const protocol = useLocalUrl ? 'http' : 'https'; const url = new URL(`${protocol}://${hostname}${localPort}`); @@ -241,7 +248,7 @@ export const sendEmailLink = async ( url.searchParams.append('type', scope); const inviter = await client.request(GetUser, { - userId: params.sender_id + userId: params.sender_id, }); inviterName = inviter?.users?.nodes?.[0]?.displayName; @@ -309,8 +316,8 @@ export const sendEmailLink = async ( paddingLeft: '0px', paddingRight: '0px', paddingBottom: '0px', - paddingTop: '0' - } + paddingTop: '0', + }, }); if (isDryRun) { @@ -318,20 +325,20 @@ export const sendEmailLink = async ( email_type: params.email_type, email: params.email, subject, - link + link, }); } else { const sendEmail = useSmtp ? sendSmtp : sendPostmaster; await sendEmail({ to: params.email, subject, - html + html, }); } return { complete: true, - ...(isDryRun ? { dryRun: true } : null) + ...(isDryRun ? { dryRun: true } : null), }; }; @@ -341,17 +348,21 @@ app.post('/', async (req: any, res: any, next: any) => { const params = (req.body || {}) as SendEmailParams; const databaseId = - req.get('X-Database-Id') || req.get('x-database-id') || process.env.DEFAULT_DATABASE_ID; + req.get('X-Database-Id') || + req.get('x-database-id') || + runtimeEnv.DEFAULT_DATABASE_ID; if (!databaseId) { - return res.status(400).json({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' }); + return res + .status(400) + .json({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' }); } const graphqlUrl = getRequiredEnv('GRAPHQL_URL'); - const metaGraphqlUrl = process.env.META_GRAPHQL_URL || graphqlUrl; + const metaGraphqlUrl = runtimeEnv.META_GRAPHQL_URL || graphqlUrl; // Get API name or schemata from env (for tenant queries like GetUser) - const apiName = process.env.GRAPHQL_API_NAME; - const schemata = process.env.GRAPHQL_SCHEMATA; + const apiName = runtimeEnv.GRAPHQL_API_NAME; + const schemata = runtimeEnv.GRAPHQL_SCHEMATA; // For GetUser query - needs tenant API access via X-Api-Name or X-Schemata const client = createGraphQLClient(graphqlUrl, { @@ -371,12 +382,14 @@ app.post('/', async (req: any, res: any, next: any) => { const result = await sendEmailLink(params, { client, meta, - databaseId + databaseId, }); // Validation failures return { missing: '...' } - treat as client error if (result && typeof result === 'object' && 'missing' in result) { - return res.status(400).json({ error: `Missing required field: ${result.missing}` }); + return res + .status(400) + .json({ error: `Missing required field: ${result.missing}` }); } res.status(200).json(result); @@ -388,22 +401,23 @@ app.post('/', async (req: any, res: any, next: any) => { export default app; // When executed directly (e.g. via `node dist/index.js`), start an HTTP server. -if (require.main === module) { - const port = Number(process.env.PORT ?? 8080); +if (typeof require !== 'undefined' && require.main === module) { + const port = Number(runtimeEnv.PORT ?? 8080); // Log startup configuration (non-sensitive values only - no API keys or tokens) logger.info('[send-verification-link] Starting with config:', { port, - graphqlUrl: process.env.GRAPHQL_URL || 'not set', - metaGraphqlUrl: process.env.META_GRAPHQL_URL || process.env.GRAPHQL_URL || 'not set', - apiName: process.env.GRAPHQL_API_NAME || 'not set', - defaultDatabaseId: process.env.DEFAULT_DATABASE_ID || 'not set', + graphqlUrl: runtimeEnv.GRAPHQL_URL || 'not set', + metaGraphqlUrl: + runtimeEnv.META_GRAPHQL_URL || runtimeEnv.GRAPHQL_URL || 'not set', + apiName: runtimeEnv.GRAPHQL_API_NAME || 'not set', + defaultDatabaseId: runtimeEnv.DEFAULT_DATABASE_ID || 'not set', dryRun: isDryRun, useSmtp, - mailgunDomain: process.env.MAILGUN_DOMAIN || 'not set', - mailgunFrom: process.env.MAILGUN_FROM || 'not set', - localAppPort: process.env.LOCAL_APP_PORT || 'not set', - hasAuthToken: !!process.env.GRAPHQL_AUTH_TOKEN + mailgunDomain: runtimeEnv.MAILGUN_DOMAIN || 'not set', + mailgunFrom: runtimeEnv.MAILGUN_FROM || 'not set', + localAppPort: runtimeEnv.LOCAL_APP_PORT || 'not set', + hasAuthToken: !!runtimeEnv.GRAPHQL_AUTH_TOKEN, }); // @constructive-io/knative-job-fn exposes a .listen method that delegates to the Express app diff --git a/jobs/job-pg/__tests__/pool-manager.test.ts b/jobs/job-pg/__tests__/pool-manager.test.ts new file mode 100644 index 0000000000..ca9814e818 --- /dev/null +++ b/jobs/job-pg/__tests__/pool-manager.test.ts @@ -0,0 +1,45 @@ +import type { Pool } from 'pg'; + +import { PoolManager } from '../src'; + +describe('PoolManager lifecycle', () => { + it('coalesces concurrent close requests into one cleanup', async () => { + let releaseCleanup!: () => void; + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const end = jest.fn(async (): Promise => undefined); + const manager = new PoolManager({ + pgPool: { end } as unknown as Pool, + registerSignalHandlers: false, + }); + const cleanup = jest.fn(async (): Promise => cleanupGate); + manager.onClose(cleanup); + + const first = manager.close(); + const second = manager.close(); + await Promise.resolve(); + + expect(cleanup).toHaveBeenCalledTimes(1); + releaseCleanup(); + await Promise.all([first, second]); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(end).toHaveBeenCalledTimes(1); + }); + + it('still closes the pool when a cleanup callback fails', async () => { + const end = jest.fn(async (): Promise => undefined); + const manager = new PoolManager({ + pgPool: { end } as unknown as Pool, + registerSignalHandlers: false, + }); + manager.onClose(async (): Promise => { + throw new Error('release failed'); + }); + + await expect(manager.close()).rejects.toBeInstanceOf(AggregateError); + expect(end).toHaveBeenCalledTimes(1); + await expect(manager.close()).resolves.toBeUndefined(); + }); +}); diff --git a/jobs/job-pg/src/index.ts b/jobs/job-pg/src/index.ts index 10e5530c39..d1f8886539 100644 --- a/jobs/job-pg/src/index.ts +++ b/jobs/job-pg/src/index.ts @@ -6,7 +6,7 @@ import { createLogger } from '@pgpmjs/logger'; // other events are bad for babel-watch const SYS_EVENTS = [ // 'SIGUSR2', - 'SIGINT' + 'SIGINT', // 'SIGTERM', // 'SIGPIPE', // 'SIGHUP', @@ -30,11 +30,9 @@ function once unknown>( }; } -const pgPoolConfig: PoolConfig = getJobPgConfig() as PoolConfig; - const logger = createLogger('job-pg'); -const end = (pool: Pool): void => { +const end = async (pool: Pool): Promise => { try { // Pool has internal state flags, but they are not part of the public type const state = pool as unknown as { @@ -47,10 +45,11 @@ const end = (pool: Pool): void => { ); return; } - void pool.end(); + await pool.end(); logger.info('successfully closed pool.'); } catch (e) { - process.stderr.write(String(e)); + logger.error('failed to close pool', e); + throw e; } }; @@ -58,13 +57,26 @@ const end = (pool: Pool): void => { // (we forward whatever was passed to `onClose`). type PoolCloseCallback = (...args: any[]) => Promise | void; -class PoolManager { +export class PoolManager { private pgPool: Pool; private callbacks: Array<[PoolCloseCallback, any, any[]]>; private _closed: boolean; - - constructor({ pgPool = new Pool(pgPoolConfig) }: { pgPool?: Pool } = {}) { - this.pgPool = pgPool; + private closePromise?: Promise; + + private signalHandler?: () => void; + + constructor({ + pgPool, + pgConfig, + registerSignalHandlers = true, + }: { + pgPool?: Pool; + pgConfig?: PoolConfig; + registerSignalHandlers?: boolean; + } = {}) { + const resolvedPool = + pgPool ?? new Pool(pgConfig ?? (getJobPgConfig() as PoolConfig)); + this.pgPool = resolvedPool; this.callbacks = []; this._closed = false; @@ -73,9 +85,10 @@ class PoolManager { await this.close(); }, this); - SYS_EVENTS.forEach((event) => { - process.on(event, closeOnce); - }); + if (registerSignalHandlers) { + this.signalHandler = closeOnce; + SYS_EVENTS.forEach((event) => process.on(event, closeOnce)); + } } onClose(fn: PoolCloseCallback, context?: any, args: any[] = []): void { @@ -88,17 +101,69 @@ class PoolManager { async close(): Promise { if (this._closed) return; + if (this.closePromise) return this.closePromise; + + const closePromise = this.performClose(); + this.closePromise = closePromise; + try { + await closePromise; + } catch (error) { + if (this.closePromise === closePromise) this.closePromise = undefined; + throw error; + } + } + private async performClose(): Promise { + const failures: unknown[] = []; for (const [fn, context, args] of this.callbacks) { logger.info('closing fn', fn.name); - await fn.apply(context, args); + try { + await fn.apply(context, args); + } catch (error) { + failures.push(error); + } + } + + try { + await end(this.pgPool); + this._closed = true; + } catch (error) { + failures.push(error); + } finally { + if (this.signalHandler) { + SYS_EVENTS.forEach((event) => process.off(event, this.signalHandler!)); + this.signalHandler = undefined; + } } - end(this.pgPool); - this._closed = true; + if (failures.length > 0) { + throw new AggregateError( + failures, + 'Failed to close the job pool manager.' + ); + } } } -const mngr = new PoolManager(); +let defaultManager: PoolManager | undefined; +const getDefaultManager = (): PoolManager => { + if (!defaultManager) defaultManager = new PoolManager(); + return defaultManager; +}; + +const managerFacade = { + onClose(fn: PoolCloseCallback, context?: any, args: any[] = []): void { + getDefaultManager().onClose(fn, context, args); + }, + getPool(): Pool { + return getDefaultManager().getPool(); + }, + async close(): Promise { + if (!defaultManager) return; + const manager = defaultManager; + defaultManager = undefined; + await manager.close(); + }, +}; -export default mngr; +export default managerFacade; diff --git a/jobs/job-scheduler/__tests__/lifecycle.test.ts b/jobs/job-scheduler/__tests__/lifecycle.test.ts new file mode 100644 index 0000000000..3d1d875f83 --- /dev/null +++ b/jobs/job-scheduler/__tests__/lifecycle.test.ts @@ -0,0 +1,40 @@ +import type { Pool } from 'pg'; +import { withLogsSuppressed } from '@pgpmjs/logger'; + +import Scheduler from '../src'; + +describe('job scheduler lifecycle', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('cancels pending reconnects and scheduled jobs during shutdown', async () => { + const connect = jest.fn(async (): Promise => { + throw new Error('database unavailable'); + }); + const lifecycle = { + close: jest.fn(async (): Promise => undefined), + onClose: jest.fn(), + }; + const scheduler = new Scheduler({ + tasks: [], + pgPool: { connect } as unknown as Pool, + lifecycle, + }); + const cancel = jest.fn(); + scheduler.jobs.example = { cancel }; + + await withLogsSuppressed(() => scheduler.listen()); + expect(jest.getTimerCount()).toBe(1); + + await scheduler.stop(); + expect(cancel).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + jest.advanceTimersByTime(5000); + expect(connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/jobs/job-scheduler/src/index.ts b/jobs/job-scheduler/src/index.ts index 9d258feea4..08b04d2cd5 100644 --- a/jobs/job-scheduler/src/index.ts +++ b/jobs/job-scheduler/src/index.ts @@ -1,7 +1,10 @@ import * as jobs from '@constructive-io/job-utils'; -import type { PgClientLike } from '@constructive-io/job-utils'; +import type { + JobRuntimeContext, + PgClientLike, +} from '@constructive-io/job-utils'; import schedule from 'node-schedule'; -import poolManager from '@constructive-io/job-pg'; +import poolManager, { type PoolManager } from '@constructive-io/job-pg'; import type { Pool, PoolClient } from 'pg'; import { Logger } from '@pgpmjs/logger'; @@ -27,18 +30,31 @@ export default class Scheduler { _initialized?: boolean; listenClient?: PoolClient; listenRelease?: () => void; + reconnectTimer?: NodeJS.Timeout; stopped?: boolean; + runtime?: JobRuntimeContext; + private readonly lifecycle: Pick; + private readonly onFatal?: (error: Error) => void; + private readonly failFastOnInitialConnect: boolean; constructor({ tasks, idleDelay = 15000, pgPool = poolManager.getPool(), - workerId = 'scheduler-0' + workerId = 'scheduler-0', + runtime, + lifecycle = poolManager, + onFatal, + failFastOnInitialConnect = false, }: { tasks: string[]; idleDelay?: number; pgPool?: Pool; workerId?: string; + runtime?: JobRuntimeContext; + lifecycle?: Pick; + onFatal?: (error: Error) => void; + failFastOnInitialConnect?: boolean; }) { /* * idleDelay: This is how long to wait between polling for jobs. @@ -53,21 +69,33 @@ export default class Scheduler { this.doNextTimer = undefined; this.pgPool = pgPool; this.jobs = {}; - poolManager.onClose(async () => { - await jobs.releaseScheduledJobs(pgPool, { - workerId: this.workerId, - // When ids is omitted the DB function releases all scheduled jobs - ids: undefined as unknown as Array - }); + this.runtime = runtime; + this.lifecycle = lifecycle; + this.onFatal = onFatal; + this.failFastOnInitialConnect = failFastOnInitialConnect; + lifecycle.onClose(async () => { + await jobs.releaseScheduledJobs( + pgPool, + { + workerId: this.workerId, + // When ids is omitted the DB function releases all scheduled jobs + ids: undefined as unknown as Array, + }, + runtime + ); }); } async initialize(client: PgClientLike) { if (this._initialized === true) return; - await jobs.releaseScheduledJobs(client, { - workerId: this.workerId, - // When ids is omitted the DB function releases all scheduled jobs - ids: undefined as unknown as Array - }); + await jobs.releaseScheduledJobs( + client, + { + workerId: this.workerId, + // When ids is omitted the DB function releases all scheduled jobs + ids: undefined as unknown as Array, + }, + this.runtime + ); this._initialized = true; await this.doNext(client); } @@ -76,21 +104,24 @@ export default class Scheduler { { err, fatalError, - jobId + jobId, }: { err?: Error; fatalError: unknown; jobId: ScheduledJobRow['id'] } ) { const when = err ? `after failure '${err.message}'` : 'after success'; log.error(`Failed to release job '${jobId}' ${when}; committing seppuku`); log.error(String(fatalError)); - await poolManager.close(); - process.exit(1); + const fatal = + fatalError instanceof Error ? fatalError : new Error(String(fatalError)); + await this.stop(); + await this.lifecycle.close(); + this.onFatal?.(fatal); } async handleError( client: PgClientLike, { err, job, - duration + duration, }: { err: Error; job: ScheduledJobRow; duration: string } ) { log.error( @@ -98,10 +129,14 @@ export default class Scheduler { ); const j = this.jobs[job.id]; if (j) j.cancel(); - await jobs.releaseScheduledJobs(client, { - workerId: this.workerId, - ids: [job.id] - }); + await jobs.releaseScheduledJobs( + client, + { + workerId: this.workerId, + ids: [job.id], + }, + this.runtime + ); } async handleSuccess( client: PgClientLike, @@ -114,9 +149,13 @@ export default class Scheduler { async scheduleJob(client: PgClientLike, job: ScheduledJobRow) { const { id, task_identifier, schedule_info } = job; const j = schedule.scheduleJob(schedule_info as never, async () => { - const newjob = (await jobs.runScheduledJob(client, { - jobId: id - })) as ScheduledJobRow | null; + const newjob = (await jobs.runScheduledJob( + client, + { + jobId: id, + }, + this.runtime + )) as ScheduledJobRow | null; if (newjob) { if (newjob.id) { @@ -148,12 +187,16 @@ export default class Scheduler { this.doNextTimer = undefined; } try { - const job = await jobs.getScheduledJob(client, { - workerId: this.workerId, - supportedTaskNames: jobs.getJobSupportAny() - ? null - : this.supportedTaskNames - }); + const job = await jobs.getScheduledJob( + client, + { + workerId: this.workerId, + supportedTaskNames: jobs.getJobSupportAny(this.runtime) + ? null + : this.supportedTaskNames, + }, + this.runtime + ); if (!job || !job.id) { if (!this.stopped) { this.doNextTimer = setTimeout( @@ -205,15 +248,21 @@ export default class Scheduler { let release: () => void; try { client = await this.pgPool.connect(); - release = () => client.release(); + let released = false; + release = () => { + if (released) return; + released = true; + client.release(); + }; } catch (err) { log.error('Error connecting with notify listener', err); if (err instanceof Error && err.stack) { log.debug(err.stack); } - if (!this.stopped) { - setTimeout(() => this.listen(), 5000); + if (this.failFastOnInitialConnect && !this._initialized) { + throw err; } + this.scheduleReconnect(); return; } if (this.stopped) { @@ -226,10 +275,20 @@ export default class Scheduler { log.info('a NEW scheduled JOB!'); if (this.doNextTimer) { // Must be idle, do something! - this.doNext(client); + void this.doNext(client); } }); - client.query('LISTEN "scheduled_jobs:insert"'); + try { + await client.query('LISTEN "scheduled_jobs:insert"'); + } catch (error) { + this.listenClient = undefined; + this.listenRelease = undefined; + client.removeAllListeners('notification'); + release(); + if (this.failFastOnInitialConnect && !this._initialized) throw error; + this.scheduleReconnect(); + return; + } client.on('error', (e: unknown) => { if (this.stopped) { release(); @@ -239,19 +298,36 @@ export default class Scheduler { if (e instanceof Error && e.stack) { log.debug(e.stack); } - release(); - if (!this.stopped) { - this.listen(); + if (this.doNextTimer) { + clearTimeout(this.doNextTimer); + this.doNextTimer = undefined; } + if (this.listenClient === client) { + this.listenClient = undefined; + this.listenRelease = undefined; + } + release(); + this.scheduleReconnect(); }); - log.info( - `${this.workerId} connected and looking for scheduled jobs...` - ); - this.doNext(client); + log.info(`${this.workerId} connected and looking for scheduled jobs...`); + void this.doNext(client); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + void this.listen(); + }, 5000); + this.reconnectTimer.unref?.(); } async stop(): Promise { this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } if (this.doNextTimer) { clearTimeout(this.doNextTimer); this.doNextTimer = undefined; diff --git a/jobs/job-utils/src/index.ts b/jobs/job-utils/src/index.ts index ab8b70f04a..59581b4b17 100644 --- a/jobs/job-utils/src/index.ts +++ b/jobs/job-utils/src/index.ts @@ -5,7 +5,7 @@ import type { GetScheduledJobParams, RunScheduledJobParams, ReleaseScheduledJobsParams, - ReleaseJobsParams + ReleaseJobsParams, } from '@pgpmjs/types'; import { @@ -21,8 +21,9 @@ import { getJobGatewayDevMap, getJobsCallbackPort, getCallbackBaseUrl, - getNodeEnvironment + getNodeEnvironment, } from './runtime'; +export type { JobRuntimeContext } from './runtime'; import { Logger } from '@pgpmjs/logger'; @@ -45,42 +46,47 @@ export { getJobGatewayDevMap, getJobsCallbackPort, getCallbackBaseUrl, - getNodeEnvironment + getNodeEnvironment, }; -const JOBS_SCHEMA = getJobSchema(); - export const failJob = async ( client: PgClientLike, - { workerId, jobId, message }: FailJobParams + { workerId, jobId, message }: FailJobParams, + runtime?: import('./runtime').JobRuntimeContext ) => { + const jobsSchema = getJobSchema(runtime); log.warn(`failJob worker[${workerId}] job[${jobId}] ${message}`); - await client.query( - `SELECT * FROM "${JOBS_SCHEMA}".fail_job($1, $2, $3);`, - [workerId, jobId, message] - ); + await client.query(`SELECT * FROM "${jobsSchema}".fail_job($1, $2, $3);`, [ + workerId, + jobId, + message, + ]); }; export const completeJob = async ( client: PgClientLike, - { workerId, jobId }: CompleteJobParams + { workerId, jobId }: CompleteJobParams, + runtime?: import('./runtime').JobRuntimeContext ) => { + const jobsSchema = getJobSchema(runtime); log.info(`completeJob worker[${workerId}] job[${jobId}]`); - await client.query( - `SELECT * FROM "${JOBS_SCHEMA}".complete_job($1, $2);`, - [workerId, jobId] - ); + await client.query(`SELECT * FROM "${jobsSchema}".complete_job($1, $2);`, [ + workerId, + jobId, + ]); }; export const getJob = async ( client: PgClientLike, - { workerId, supportedTaskNames }: GetJobParams + { workerId, supportedTaskNames }: GetJobParams, + runtime?: import('./runtime').JobRuntimeContext ): Promise => { + const jobsSchema = getJobSchema(runtime); log.debug(`getJob worker[${workerId}]`); const { - rows: [job] + rows: [job], } = await client.query( - `SELECT * FROM "${JOBS_SCHEMA}".get_job($1, $2::text[]);`, + `SELECT * FROM "${jobsSchema}".get_job($1, $2::text[]);`, [workerId, supportedTaskNames] ); return (job as T) ?? null; @@ -88,13 +94,15 @@ export const getJob = async ( export const getScheduledJob = async ( client: PgClientLike, - { workerId, supportedTaskNames }: GetScheduledJobParams + { workerId, supportedTaskNames }: GetScheduledJobParams, + runtime?: import('./runtime').JobRuntimeContext ): Promise => { + const jobsSchema = getJobSchema(runtime); log.debug(`getScheduledJob worker[${workerId}]`); const { - rows: [job] + rows: [job], } = await client.query( - `SELECT * FROM "${JOBS_SCHEMA}".get_scheduled_job($1, $2::text[]);`, + `SELECT * FROM "${jobsSchema}".get_scheduled_job($1, $2::text[]);`, [workerId, supportedTaskNames] ); return (job as T) ?? null; @@ -102,14 +110,16 @@ export const getScheduledJob = async ( export const runScheduledJob = async ( client: PgClientLike, - { jobId }: RunScheduledJobParams + { jobId }: RunScheduledJobParams, + runtime?: import('./runtime').JobRuntimeContext ): Promise => { + const jobsSchema = getJobSchema(runtime); log.info(`runScheduledJob job[${jobId}]`); try { const { - rows: [job] + rows: [job], } = await client.query( - `SELECT * FROM "${JOBS_SCHEMA}".run_scheduled_job($1);`, + `SELECT * FROM "${jobsSchema}".run_scheduled_job($1);`, [jobId] ); return job ?? null; @@ -123,22 +133,23 @@ export const runScheduledJob = async ( export const releaseScheduledJobs = async ( client: PgClientLike, - { workerId, ids }: ReleaseScheduledJobsParams + { workerId, ids }: ReleaseScheduledJobsParams, + runtime?: import('./runtime').JobRuntimeContext ) => { + const jobsSchema = getJobSchema(runtime); log.info(`releaseScheduledJobs worker[${workerId}]`); return client.query( - `SELECT "${JOBS_SCHEMA}".release_scheduled_jobs($1, $2::bigint[]);`, + `SELECT "${jobsSchema}".release_scheduled_jobs($1, $2::bigint[]);`, [workerId, ids ?? null] ); }; export const releaseJobs = async ( client: PgClientLike, - { workerId }: ReleaseJobsParams + { workerId }: ReleaseJobsParams, + runtime?: import('./runtime').JobRuntimeContext ) => { + const jobsSchema = getJobSchema(runtime); log.info(`releaseJobs worker[${workerId}]`); - return client.query( - `SELECT "${JOBS_SCHEMA}".release_jobs($1);`, - [workerId] - ); + return client.query(`SELECT "${jobsSchema}".release_jobs($1);`, [workerId]); }; diff --git a/jobs/job-utils/src/runtime.ts b/jobs/job-utils/src/runtime.ts index 3d91b70daf..8c75339622 100644 --- a/jobs/job-utils/src/runtime.ts +++ b/jobs/job-utils/src/runtime.ts @@ -7,85 +7,99 @@ import { jobsDefaults } from '@pgpmjs/types'; type Maybe = T | null | undefined; +export interface JobRuntimeContext { + cwd?: string; + env?: NodeJS.ProcessEnv; + options?: PgpmOptions; + signal?: AbortSignal; +} + +const getRuntimeEnv = (context?: JobRuntimeContext): NodeJS.ProcessEnv => + context?.env ?? process.env; + +const getRuntimeOptions = (context?: JobRuntimeContext): PgpmOptions => + getEnvOptions( + context?.options ?? {}, + context?.cwd ?? process.cwd(), + getRuntimeEnv(context) + ); + const toStrArray = (v: Maybe): string[] | undefined => - v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined; + v + ? v + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; // ---- PG config ---- -export const getJobPgConfig = (): PgConfig => { - const opts: PgpmOptions = getEnvOptions(); - const envOnly = getPgEnvVars(); +export const getJobPgConfig = (context?: JobRuntimeContext): PgConfig => { + const opts = getRuntimeOptions(context); + const envOnly = getPgEnvVars(getRuntimeEnv(context)); return { ...defaultPgConfig, ...(opts.pg ?? {}), - ...envOnly + ...envOnly, }; }; -export const getJobPool = (): Pool => - getPgPool(getJobPgConfig()); - -export const getJobConnectionString = (): string => { - const cfg = getJobPgConfig(); - return buildConnectionString(cfg.user, cfg.password, cfg.host, cfg.port, cfg.database); +export const getJobPool = (context?: JobRuntimeContext): Pool => + getPgPool(getJobPgConfig(context)); + +export const getJobConnectionString = (context?: JobRuntimeContext): string => { + const cfg = getJobPgConfig(context); + return buildConnectionString( + cfg.user, + cfg.password, + cfg.host, + cfg.port, + cfg.database + ); }; // ---- Schema ---- -export const getJobSchema = (): string => { - const opts: PgpmOptions = getEnvOptions(); +export const getJobSchema = (context?: JobRuntimeContext): string => { + const opts = getRuntimeOptions(context); const fromOpts: string | undefined = opts.jobs?.schema?.schema; - return ( - fromOpts || - jobsDefaults.schema?.schema || - 'app_jobs' - ); + return fromOpts || jobsDefaults.schema?.schema || 'app_jobs'; }; // ---- SupportAny / Supported ---- -export const getJobSupportAny = (): boolean => { - const opts: PgpmOptions = getEnvOptions(); - const envVal = parseEnvBoolean(process.env.JOBS_SUPPORT_ANY); +export const getJobSupportAny = (context?: JobRuntimeContext): boolean => { + const opts = getRuntimeOptions(context); + const envVal = parseEnvBoolean(getRuntimeEnv(context).JOBS_SUPPORT_ANY); if (typeof envVal === 'boolean') return envVal; const worker: boolean | undefined = opts.jobs?.worker?.supportAny; const scheduler: boolean | undefined = opts.jobs?.scheduler?.supportAny; - return ( - worker ?? - scheduler ?? - jobsDefaults.worker?.supportAny ?? - true - ); + return worker ?? scheduler ?? jobsDefaults.worker?.supportAny ?? true; }; -export const getJobSupported = (): string[] => { - const opts: PgpmOptions = getEnvOptions(); +export const getJobSupported = (context?: JobRuntimeContext): string[] => { + const opts = getRuntimeOptions(context); const worker: string[] | undefined = opts.jobs?.worker?.supported; const scheduler: string[] | undefined = opts.jobs?.scheduler?.supported; - return ( - worker ?? - scheduler ?? - jobsDefaults.worker?.supported ?? - [] - ); + return worker ?? scheduler ?? jobsDefaults.worker?.supported ?? []; }; // ---- Hostnames ---- -export const getWorkerHostname = (): string => { - const opts: PgpmOptions = getEnvOptions(); +export const getWorkerHostname = (context?: JobRuntimeContext): string => { + const opts = getRuntimeOptions(context); return ( - process.env.HOSTNAME || + getRuntimeEnv(context).HOSTNAME || opts.jobs?.worker?.hostname || jobsDefaults.worker?.hostname || 'worker-0' ); }; -export const getSchedulerHostname = (): string => { - const opts: PgpmOptions = getEnvOptions(); +export const getSchedulerHostname = (context?: JobRuntimeContext): string => { + const opts = getRuntimeOptions(context); return ( - process.env.HOSTNAME || + getRuntimeEnv(context).HOSTNAME || opts.jobs?.scheduler?.hostname || jobsDefaults.scheduler?.hostname || 'scheduler-0' @@ -93,49 +107,57 @@ export const getSchedulerHostname = (): string => { }; // ---- Job gateway config (generic HTTP gateway) ---- -export const getJobGatewayConfig = () => { - const opts: PgpmOptions = getEnvOptions(); +export const getJobGatewayConfig = (context?: JobRuntimeContext) => { + const opts = getRuntimeOptions(context); const gateway = opts.jobs?.gateway ?? {}; const defaults = jobsDefaults.gateway ?? {}; return { gatewayUrl: gateway.gatewayUrl || defaults.gatewayUrl, callbackUrl: gateway.callbackUrl || defaults.callbackUrl, - callbackPort: gateway.callbackPort ?? defaults.callbackPort + callbackPort: gateway.callbackPort ?? defaults.callbackPort, }; }; -export const getJobGatewayDevMap = (): - | Record - | null => { - const map = process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP; +export const getJobGatewayDevMap = ( + context?: JobRuntimeContext +): Record | null => { + const map = getRuntimeEnv(context).INTERNAL_GATEWAY_DEVELOPMENT_MAP; if (!map) return null; try { - return JSON.parse(map); + const parsed = JSON.parse(map) as unknown; + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string') + ) { + throw new TypeError('Expected an object containing string URLs.'); + } + return parsed as Record; } catch (err) { - console.warn( - '[getJobGatewayDevMap] Failed to parse INTERNAL_GATEWAY_DEVELOPMENT_MAP as JSON:', - err, - 'Value:', - map + throw new Error( + 'INTERNAL_GATEWAY_DEVELOPMENT_MAP must contain a JSON object.', + { cause: err } ); - return null; } }; -export const getNodeEnvironment = getNodeEnv; +export const getNodeEnvironment = (context?: JobRuntimeContext) => + getNodeEnv(getRuntimeEnv(context)); // Neutral callback helpers (generic HTTP callback) -export const getJobsCallbackPort = (): number => { - const { callbackPort } = getJobGatewayConfig(); +export const getJobsCallbackPort = (context?: JobRuntimeContext): number => { + const { callbackPort } = getJobGatewayConfig(context); return callbackPort; }; -export const getCallbackBaseUrl = (): string => { - if (process.env.INTERNAL_JOBS_CALLBACK_URL) { - return process.env.INTERNAL_JOBS_CALLBACK_URL; +export const getCallbackBaseUrl = (context?: JobRuntimeContext): string => { + const env = getRuntimeEnv(context); + if (env.INTERNAL_JOBS_CALLBACK_URL) { + return env.INTERNAL_JOBS_CALLBACK_URL; } - const host = process.env.JOBS_CALLBACK_HOST || 'jobs-callback'; - const port = getJobsCallbackPort(); + const host = env.JOBS_CALLBACK_HOST || 'jobs-callback'; + const port = getJobsCallbackPort(context); return `http://${host}:${port}/callback`; }; diff --git a/jobs/knative-job-fn/src/index.ts b/jobs/knative-job-fn/src/index.ts index 466822cd6f..6635879d85 100644 --- a/jobs/knative-job-fn/src/index.ts +++ b/jobs/knative-job-fn/src/index.ts @@ -5,6 +5,8 @@ import { URL } from 'node:url'; import type { Server as HttpServer } from 'http'; import { createLogger } from '@pgpmjs/logger'; +export * from './runtime-environment'; + type JobCallbackStatus = 'success' | 'error'; type JobContext = { @@ -15,11 +17,12 @@ type JobContext = { }; function getHeaders(req: any) { + const callbackUrl = req.get('X-Callback-Url'); return { 'x-worker-id': req.get('X-Worker-Id'), 'x-job-id': req.get('X-Job-Id'), 'x-database-id': req.get('X-Database-Id'), - 'x-callback-url': req.get('X-Callback-Url') + 'x-callback-url': callbackUrl ? safeLogUrl(callbackUrl) : undefined, }; } @@ -36,6 +39,24 @@ const normalizeCallbackUrl = (rawUrl: string): string => { } }; +const SENSITIVE_QUERY_KEY = + /(auth|credential|key|password|secret|signature|token)/i; + +const safeLogUrl = (rawUrl: string): string => { + try { + const url = new URL(normalizeCallbackUrl(rawUrl)); + url.username = ''; + url.password = ''; + for (const key of url.searchParams.keys()) { + if (SENSITIVE_QUERY_KEY.test(key)) + url.searchParams.set(key, '[REDACTED]'); + } + return url.toString(); + } catch { + return '[invalid-url]'; + } +}; + const postJson = ( urlStr: string, headers: Record, @@ -60,8 +81,8 @@ const postJson = ( method: 'POST', headers: { 'Content-Type': 'application/json', - ...headers - } + ...headers, + }, }, (res) => { // Drain response data but ignore contents; callback server @@ -91,7 +112,7 @@ const sendJobCallback = async ( const headers: Record = { 'X-Worker-Id': workerId, - 'X-Job-Id': jobId + 'X-Job-Id': jobId, }; if (databaseId) { @@ -99,7 +120,7 @@ const sendJobCallback = async ( } const body: Record = { - status + status, }; if (status === 'error') { @@ -110,17 +131,17 @@ const sendJobCallback = async ( try { logger.info('Sending job callback', { status, - target: normalizeCallbackUrl(callbackUrl), + target: safeLogUrl(callbackUrl), workerId, jobId, - databaseId + databaseId, }); await postJson(target, headers, body); } catch (err) { logger.error('Failed to POST job callback', { - target, + target: safeLogUrl(target), status, - err + err, }); } }; @@ -153,7 +174,7 @@ const createJobApp = () => { method: req.method, path: req.originalUrl || req.url, headers, - body + body, }); } catch { // best-effort logging; never block the request @@ -167,7 +188,7 @@ const createJobApp = () => { 'Content-Type': 'application/json', 'X-Worker-Id': req.get('X-Worker-Id'), 'X-Database-Id': req.get('X-Database-Id'), - 'X-Job-Id': req.get('X-Job-Id') + 'X-Job-Id': req.get('X-Job-Id'), }); next(); }); @@ -178,7 +199,7 @@ const createJobApp = () => { callbackUrl: req.get('X-Callback-Url'), workerId: req.get('X-Worker-Id'), jobId: req.get('X-Job-Id'), - databaseId: req.get('X-Database-Id') + databaseId: req.get('X-Database-Id'), }; // Store on res.locals so the error middleware can also mark callbacks as sent. @@ -199,7 +220,7 @@ const createJobApp = () => { workerId: ctx.workerId, jobId: ctx.jobId, databaseId: ctx.databaseId, - statusCode: res.statusCode + statusCode: res.statusCode, }); if (isError) { @@ -229,7 +250,7 @@ const createJobApp = () => { app.use(async (error: any, req: any, res: any, next: any) => { res.set({ 'Content-Type': 'application/json', - 'X-Job-Error': true + 'X-Job-Error': true, }); // Mark job as having errored via callback, if available. @@ -251,7 +272,7 @@ const createJobApp = () => { const errorDetails: any = { message: error?.message, name: error?.name, - stack: error?.stack + stack: error?.stack, }; if (error?.response) { @@ -259,14 +280,14 @@ const createJobApp = () => { status: error.response.status, statusText: error.response.statusText, errors: error.response.errors, - data: error.response.data + data: error.response.data, }; } logger.error('Function error', { headers, path: req.originalUrl || req.url, - error: errorDetails + error: errorDetails, }); } catch { // never throw from the error logger @@ -285,7 +306,7 @@ const createJobApp = () => { : app.listen(port, onListen); return server; - } + }, }; }; diff --git a/jobs/knative-job-fn/src/runtime-environment.ts b/jobs/knative-job-fn/src/runtime-environment.ts new file mode 100644 index 0000000000..12671a155a --- /dev/null +++ b/jobs/knative-job-fn/src/runtime-environment.ts @@ -0,0 +1,16 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export type JobFunctionEnvironment = Readonly< + Record +>; + +const environmentStorage = new AsyncLocalStorage(); + +export const getJobFunctionEnvironment = (): JobFunctionEnvironment => + environmentStorage.getStore() ?? process.env; + +export const withJobFunctionEnvironment = async ( + environment: JobFunctionEnvironment, + callback: () => Promise +): Promise => + environmentStorage.run(Object.freeze({ ...environment }), callback); diff --git a/jobs/knative-job-service/__tests__/lifecycle.test.ts b/jobs/knative-job-service/__tests__/lifecycle.test.ts new file mode 100644 index 0000000000..28a7a6e7e2 --- /dev/null +++ b/jobs/knative-job-service/__tests__/lifecycle.test.ts @@ -0,0 +1,177 @@ +import { spawnSync } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import { join } from 'node:path'; +import { withLogsSuppressed } from '@pgpmjs/logger'; + +import { KnativeJobsSvc } from '../src'; + +const listen = async ( + server: Server, + host: string | null = '127.0.0.1' +): Promise => { + await new Promise((resolve, reject) => { + server.once('error', reject); + const ready = () => { + server.off('error', reject); + resolve(); + }; + if (host) server.listen(0, host, ready); + else server.listen(0, ready); + }); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing TCP address.'); + return address.port; +}; + +const close = async (server: Server): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}; + +const findFreePort = async (): Promise => { + const server = createServer(); + const port = await listen(server); + await close(server); + return port; +}; + +describe('KnativeJobsSvc lifecycle', () => { + it('starts a function with the injected environment and releases its port', async () => { + const previousDryRun = process.env.SEND_EMAIL_DRY_RUN; + process.env.SEND_EMAIL_DRY_RUN = 'false'; + const port = await findFreePort(); + const service = new KnativeJobsSvc({ + functions: { + enabled: true, + services: [{ name: 'send-email', port }], + }, + runtime: { + cwd: process.cwd(), + env: { NODE_ENV: 'test', SEND_EMAIL_DRY_RUN: 'true' }, + }, + }); + + try { + const result = await withLogsSuppressed(() => service.start()); + expect(result).toEqual({ + jobs: false, + functions: [{ name: 'send-email', port }], + }); + + const response = await fetch(`http://127.0.0.1:${port}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + to: 'agent@example.test', + subject: 'Lifecycle test', + text: 'This must use dry-run mode.', + }), + }); + expect(response.status).toBe(200); + + const failure = new Error('function listener failed'); + const functionServers = ( + service as unknown as { functionServers: Map } + ).functionServers; + functionServers.get('send-email')?.emit('error', failure); + await expect(service.waitForFailure()).rejects.toBe(failure); + } finally { + await service.stop(); + if (previousDryRun === undefined) delete process.env.SEND_EMAIL_DRY_RUN; + else process.env.SEND_EMAIL_DRY_RUN = previousDryRun; + } + + const replacement = createServer(); + await new Promise((resolve, reject) => { + replacement.once('error', reject); + replacement.listen(port, '127.0.0.1', () => resolve()); + }); + await close(replacement); + }); + + it('rejects a function port conflict and still performs cleanup', async () => { + const occupyingServer = createServer(); + const port = await listen(occupyingServer, null); + const service = new KnativeJobsSvc({ + functions: { + enabled: true, + services: [{ name: 'send-email', port }], + }, + runtime: { + cwd: process.cwd(), + env: { NODE_ENV: 'test', SEND_EMAIL_DRY_RUN: 'true' }, + }, + }); + + try { + await expect( + withLogsSuppressed(() => service.start()) + ).rejects.toMatchObject({ code: 'EADDRINUSE' }); + } finally { + await service.stop(); + await close(occupyingServer); + } + }); + + it('keeps concurrent function environments, ports, and teardown instance-scoped', async () => { + const child = spawnSync( + process.execPath, + [join(__dirname, '../test-fixtures/concurrent-functions.cjs')], + { + cwd: join(__dirname, '..'), + encoding: 'utf8', + timeout: 20_000, + env: { + ...process.env, + LOG_FORMAT: 'json', + LOG_TIMESTAMP: 'false', + SEND_EMAIL_DRY_RUN: 'false', + SIMPLE_EMAIL_DRY_RUN: 'false', + MAILGUN_FROM: 'ambient@example.test', + }, + } + ); + expect({ + status: child.status, + signal: child.signal, + stderr: child.stderr, + }).toEqual({ status: 0, signal: null, stderr: '' }); + + const lines = child.stdout.split('\n').filter(Boolean); + const marker = 'CNC_CONCURRENT_FUNCTIONS_RESULT '; + const summaryLine = lines.find((line) => line.startsWith(marker)); + expect(summaryLine).toBeDefined(); + const summary = JSON.parse(summaryLine!.slice(marker.length)); + expect(summary).toMatchObject({ + firstStatus: 200, + secondStatus: 200, + survivingStatus: 200, + firstPortRebound: true, + bothPortsReleased: true, + }); + expect(summary.firstPort).not.toBe(summary.secondPort); + expect(summary.dryRuns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: 'First instance', + from: 'first@example.test', + }), + expect.objectContaining({ + subject: 'Second instance', + from: 'second@example.test', + }), + expect.objectContaining({ + subject: 'Second instance after first stopped', + from: 'second@example.test', + }), + ]) + ); + expect(summary.dryRuns).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: 'ambient@example.test' }), + ]) + ); + }); +}); diff --git a/jobs/knative-job-service/src/index.ts b/jobs/knative-job-service/src/index.ts index 5aece6b1a4..952052804a 100644 --- a/jobs/knative-job-service/src/index.ts +++ b/jobs/knative-job-service/src/index.ts @@ -1,14 +1,16 @@ import jobServerFactory from '@constructive-io/knative-job-server'; +import { withJobFunctionEnvironment } from '@constructive-io/knative-job-fn'; import Worker from '@constructive-io/knative-job-worker'; import Scheduler from '@constructive-io/job-scheduler'; -import poolManager from '@constructive-io/job-pg'; +import defaultPoolManager, { PoolManager } from '@constructive-io/job-pg'; import { getJobPgConfig, getJobSchema, getJobSupported, getJobsCallbackPort, getSchedulerHostname, - getWorkerHostname + getWorkerHostname, + type JobRuntimeContext, } from '@constructive-io/job-utils'; import { parseEnvBoolean, parseEnvList } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; @@ -23,7 +25,7 @@ import { FunctionName, FunctionServiceConfig, FunctionsOptions, - StartedFunction + StartedFunction, } from './types'; type FunctionRegistryEntry = { @@ -34,12 +36,12 @@ type FunctionRegistryEntry = { const functionRegistry: Record = { 'send-email': { moduleName: '@constructive-io/send-email-fn', - defaultPort: 8081 + defaultPort: 8081, }, 'send-verification-link': { moduleName: '@constructive-io/send-verification-link-fn', - defaultPort: 8082 - } + defaultPort: 8082, + }, }; const log = new Logger('knative-job-service'); @@ -54,17 +56,18 @@ const resolveFunctionEntry = (name: FunctionName): FunctionRegistryEntry => { }; const loadFunctionApp = (moduleName: string) => { - const knativeModuleId = requireFn.resolve('@constructive-io/knative-job-fn'); - delete requireFn.cache[knativeModuleId]; - const moduleId = requireFn.resolve(moduleName); delete requireFn.cache[moduleId]; - const mod = requireFn(moduleName) as { default?: { listen: (port: number, cb?: () => void) => unknown } }; + const mod = requireFn(moduleName) as { + default?: { listen: (port: number, cb?: () => void) => unknown }; + }; const app = mod.default ?? mod; if (!app || typeof (app as { listen?: unknown }).listen !== 'function') { - throw new Error(`Function module "${moduleName}" does not export a listenable app.`); + throw new Error( + `Function module "${moduleName}" does not export a listenable app.` + ); } return app as { listen: (port: number, cb?: () => void) => unknown }; @@ -83,7 +86,7 @@ const normalizeFunctionServices = ( if (!options?.services?.length) { return Object.keys(functionRegistry).map((name) => ({ - name: name as FunctionName + name: name as FunctionName, })); } @@ -106,36 +109,64 @@ const ensureUniquePorts = (services: FunctionServiceConfig[]) => { } }; +const abortReason = (signal: AbortSignal): unknown => + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError'); + +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) throw abortReason(signal); +}; + const startFunction = async ( service: FunctionServiceConfig, - functionServers: Map + functionServers: Map, + onFatal: (error: Error) => void, + signal?: AbortSignal ): Promise => { + throwIfAborted(signal); const entry = resolveFunctionEntry(service.name); const port = resolveFunctionPort(service); const app = loadFunctionApp(entry.moduleName); await new Promise((resolve, reject) => { - const server = app.listen(port, () => { + const server = app.listen(port) as HttpServer; + functionServers.set(service.name, server); + + const cleanup = () => { + server.off('listening', handleListening); + server.off('error', handleStartError); + signal?.removeEventListener('abort', handleAbort); + }; + const handleListening = () => { + cleanup(); log.info(`function:${service.name} listening on ${port}`); + server.on('error', onFatal); resolve(); - }) as HttpServer & { on?: (event: string, cb: (err: Error) => void) => void }; - - if (server?.on) { - server.on('error', (err) => { - log.error(`function:${service.name} failed to start`, err); - reject(err); - }); - } + }; + const handleStartError = (error: Error) => { + cleanup(); + log.error(`function:${service.name} failed to start`, error); + reject(error); + }; + const handleAbort = () => { + cleanup(); + void closeServer(server).then(() => reject(abortReason(signal!)), reject); + }; - functionServers.set(service.name, server); + server.once('listening', handleListening); + server.once('error', handleStartError); + signal?.addEventListener('abort', handleAbort, { once: true }); }); + throwIfAborted(signal); return { name: service.name, port }; }; const startFunctions = async ( options: FunctionsOptions | undefined, - functionServers: Map + functionServers: Map, + onFatal: (error: Error) => void, + signal?: AbortSignal ): Promise => { const services = normalizeFunctionServices(options); if (!services.length) return []; @@ -144,32 +175,40 @@ const startFunctions = async ( const started: StartedFunction[] = []; for (const service of services) { - started.push(await startFunction(service, functionServers)); + throwIfAborted(signal); + started.push( + await startFunction(service, functionServers, onFatal, signal) + ); } return started; }; type JobRunner = { - listen: () => void; + listen: () => Promise | void; stop?: () => Promise | void; }; const listenApp = async ( app: { listen: (port: number, host?: string) => HttpServer }, port: number, - host?: string -): Promise => - new Promise((resolveListen, rejectListen) => { + host: string | undefined, + onFatal: (error: Error) => void, + signal?: AbortSignal +): Promise => { + throwIfAborted(signal); + return new Promise((resolveListen, rejectListen) => { const server = host ? app.listen(port, host) : app.listen(port); const cleanup = () => { server.off('listening', handleListen); server.off('error', handleError); + signal?.removeEventListener('abort', handleAbort); }; const handleListen = () => { cleanup(); + server.on('error', onFatal); resolveListen(server); }; @@ -178,15 +217,28 @@ const listenApp = async ( rejectListen(err); }; + const handleAbort = () => { + cleanup(); + void closeServer(server).then( + () => rejectListen(abortReason(signal!)), + rejectListen + ); + }; + server.once('listening', handleListen); server.once('error', handleError); + signal?.addEventListener('abort', handleAbort, { once: true }); }); +}; const closeServer = async (server?: HttpServer | null): Promise => { - if (!server || !server.listening) return; + if (!server) return; await new Promise((resolveClose, rejectClose) => { server.close((err) => { - if (err) { + if ( + err && + (err as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING' + ) { rejectClose(err); return; } @@ -200,92 +252,151 @@ export class KnativeJobsSvc { private started = false; private result: KnativeJobsSvcResult = { functions: [], - jobs: false + jobs: false, }; private functionServers = new Map(); private jobsHttpServer?: HttpServer; private worker?: JobRunner; private scheduler?: JobRunner; - private jobsPoolManager?: { close: () => Promise }; + private jobsPoolManager?: PoolManager; + private readonly failure: Promise; + private resolveFailure!: (error: Error) => void; + private readonly reportFailure = (error: Error): void => { + this.resolveFailure(error); + }; constructor(options: KnativeJobsSvcOptions = {}) { this.options = options; + this.failure = new Promise((resolveFailure) => { + this.resolveFailure = resolveFailure; + }); } - async start(): Promise { - if (this.started) return this.result; - this.started = true; - this.result = { - functions: [], - jobs: false - }; + async waitForFailure(): Promise { + throw await this.failure; + } - if (shouldEnableFunctions(this.options.functions)) { - log.info('starting functions'); - this.result.functions = await startFunctions( - this.options.functions, - this.functionServers - ); - } + async start(): Promise { + return withJobFunctionEnvironment( + this.options.runtime?.env ?? process.env, + async () => { + if (this.started) return this.result; + throwIfAborted(this.options.runtime?.signal); + this.started = true; + this.result = { + functions: [], + jobs: false, + }; + + if (shouldEnableFunctions(this.options.functions)) { + log.info('starting functions'); + this.result.functions = await startFunctions( + this.options.functions, + this.functionServers, + this.reportFailure, + this.options.runtime?.signal + ); + } - if (this.options.jobs?.enabled) { - log.info('starting jobs service'); - await this.startJobs(); - this.result.jobs = true; - } + throwIfAborted(this.options.runtime?.signal); + if (this.options.jobs?.enabled) { + log.info('starting jobs service'); + await this.startJobs(); + this.result.jobs = true; + } - return this.result; + throwIfAborted(this.options.runtime?.signal); + return this.result; + } + ); } async stop(): Promise { if (!this.started) return; this.started = false; - if (this.worker?.stop) { - await this.worker.stop(); - } - if (this.scheduler?.stop) { - await this.scheduler.stop(); - } + const failures: unknown[] = []; + const settle = async (operations: Promise[]): Promise => { + const results = await Promise.allSettled(operations); + for (const result of results) { + if (result.status === 'rejected') failures.push(result.reason); + } + }; + + await settle([ + Promise.resolve(this.worker?.stop?.()), + Promise.resolve(this.scheduler?.stop?.()), + ]); this.worker = undefined; this.scheduler = undefined; - await closeServer(this.jobsHttpServer); + this.jobsHttpServer?.off('error', this.reportFailure); + await settle([closeServer(this.jobsHttpServer)]); this.jobsHttpServer = undefined; + for (const server of this.functionServers.values()) { + server.off('error', this.reportFailure); + } + await settle( + [...this.functionServers.values()].map((server) => closeServer(server)) + ); + this.functionServers.clear(); + if (this.jobsPoolManager) { - await this.jobsPoolManager.close(); + await settle([this.jobsPoolManager.close()]); this.jobsPoolManager = undefined; } - for (const server of this.functionServers.values()) { - await closeServer(server); + if (failures.length > 0) { + throw new AggregateError( + failures, + 'One or more jobs services failed to stop.' + ); } - this.functionServers.clear(); } private async startJobs(): Promise { - const pgPool = poolManager.getPool(); + const runtime = this.options.runtime; + const jobsPoolManager = new PoolManager({ + pgConfig: getJobPgConfig(runtime), + registerSignalHandlers: false, + }); + this.jobsPoolManager = jobsPoolManager; + const pgPool = jobsPoolManager.getPool(); const jobsApp = jobServerFactory(pgPool); - const callbackPort = getJobsCallbackPort(); - this.jobsHttpServer = await listenApp(jobsApp, callbackPort); - - const tasks = getJobSupported(); + const callbackPort = getJobsCallbackPort(runtime); + this.jobsHttpServer = await listenApp( + jobsApp, + callbackPort, + undefined, + this.reportFailure, + runtime?.signal + ); + const address = this.jobsHttpServer.address(); + this.result.jobsPort = + typeof address === 'object' && address ? address.port : callbackPort; + + const tasks = getJobSupported(runtime); this.worker = new Worker({ pgPool, tasks, - workerId: getWorkerHostname() + workerId: getWorkerHostname(runtime), + runtime, + lifecycle: jobsPoolManager, + failFastOnInitialConnect: true, + onFatal: this.reportFailure, }); this.scheduler = new Scheduler({ pgPool, tasks, - workerId: getSchedulerHostname() + workerId: getSchedulerHostname(runtime), + runtime, + lifecycle: jobsPoolManager, + failFastOnInitialConnect: true, + onFatal: this.reportFailure, }); - this.jobsPoolManager = poolManager; - - this.worker.listen(); - this.scheduler.listen(); + await Promise.all([this.worker.listen(), this.scheduler.listen()]); } } @@ -298,13 +409,16 @@ const parsePortMap = (value?: string): Record => { if (trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed) as Record; - return Object.entries(parsed).reduce>((acc, [key, port]) => { - const portNumber = Number(port); - if (Number.isFinite(portNumber)) { - acc[key] = portNumber; - } - return acc; - }, {}); + return Object.entries(parsed).reduce>( + (acc, [key, port]) => { + const portNumber = Number(port); + if (Number.isFinite(portNumber)) { + acc[key] = portNumber; + } + return acc; + }, + {} + ); } catch { return {}; } @@ -320,11 +434,14 @@ const parsePortMap = (value?: string): Record => { }, {}); }; -const buildFunctionsOptionsFromEnv = (): KnativeJobsSvcOptions['functions'] => { - const rawFunctions = (process.env.CONSTRUCTIVE_FUNCTIONS || '').trim(); +const buildFunctionsOptionsFromEnv = ( + runtime?: JobRuntimeContext +): KnativeJobsSvcOptions['functions'] => { + const env = runtime?.env ?? process.env; + const rawFunctions = (env.CONSTRUCTIVE_FUNCTIONS || '').trim(); if (!rawFunctions) return undefined; - const portMap = parsePortMap(process.env.CONSTRUCTIVE_FUNCTION_PORTS); + const portMap = parsePortMap(env.CONSTRUCTIVE_FUNCTION_PORTS); const normalized = rawFunctions.toLowerCase(); if (normalized === 'all' || normalized === '*') { @@ -336,30 +453,38 @@ const buildFunctionsOptionsFromEnv = (): KnativeJobsSvcOptions['functions'] => { const services: FunctionServiceConfig[] = names.map((name) => ({ name, - port: portMap[name] + port: portMap[name], })); return { enabled: true, - services + services, }; }; -export const buildKnativeJobsSvcOptionsFromEnv = (): KnativeJobsSvcOptions => ({ +export const buildKnativeJobsSvcOptionsFromEnv = ( + runtime?: JobRuntimeContext +): KnativeJobsSvcOptions => ({ jobs: { - enabled: parseEnvBoolean(process.env.CONSTRUCTIVE_JOBS_ENABLED) ?? true + enabled: + parseEnvBoolean( + (runtime?.env ?? process.env).CONSTRUCTIVE_JOBS_ENABLED + ) ?? true, }, - functions: buildFunctionsOptionsFromEnv() + functions: buildFunctionsOptionsFromEnv(runtime), + runtime, }); -export const startKnativeJobsSvcFromEnv = async (): Promise => { - const server = new KnativeJobsSvc(buildKnativeJobsSvcOptionsFromEnv()); +export const startKnativeJobsSvcFromEnv = async ( + runtime?: JobRuntimeContext +): Promise => { + const server = new KnativeJobsSvc(buildKnativeJobsSvcOptionsFromEnv(runtime)); return server.start(); }; export const startJobsServices = () => { log.info('starting jobs services...'); - const pgPool = poolManager.getPool(); + const pgPool = defaultPoolManager.getPool(); const app = jobServerFactory(pgPool); const callbackPort = getJobsCallbackPort(); @@ -371,13 +496,13 @@ export const startJobsServices = () => { const worker = new Worker({ pgPool, workerId: getWorkerHostname(), - tasks + tasks, }); const scheduler = new Scheduler({ pgPool, workerId: getSchedulerHostname(), - tasks + tasks, }); worker.listen(); @@ -387,27 +512,29 @@ export const startJobsServices = () => { return { pgPool, httpServer }; }; -export const waitForJobsPrereqs = async (): Promise => { +export const waitForJobsPrereqs = async ( + runtime?: JobRuntimeContext +): Promise => { log.info('waiting for jobs prereqs'); let client: Client | null = null; try { - const cfg = getJobPgConfig(); + const cfg = getJobPgConfig(runtime); client = new Client({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, - database: cfg.database + database: cfg.database, }); await client.connect(); - const schema = getJobSchema(); + const schema = getJobSchema(runtime); await client.query(`SELECT * FROM "${schema}".jobs LIMIT 1;`); } catch (error) { log.error(error); throw new Error('jobs server boot failed...'); } finally { if (client) { - void client.end(); + await client.end(); } } }; @@ -420,7 +547,7 @@ export const bootJobs = async (): Promise => { }, { retries: 10, - factor: 2 + factor: 2, } ); @@ -439,7 +566,7 @@ export const bootJobs = async (): Promise => { supportedTasks: getJobSupported(), jobsEnabled: options.jobs?.enabled ?? true, functionsEnabled: shouldEnableFunctions(options.functions), - functions: normalizeFunctionServices(options.functions).map(s => s.name) + functions: normalizeFunctionServices(options.functions).map((s) => s.name), }); if (options.jobs?.enabled === false) { diff --git a/jobs/knative-job-service/src/types.ts b/jobs/knative-job-service/src/types.ts index e73e50dee4..d295a9a65f 100644 --- a/jobs/knative-job-service/src/types.ts +++ b/jobs/knative-job-service/src/types.ts @@ -17,6 +17,8 @@ export type JobsOptions = { export type KnativeJobsSvcOptions = { functions?: FunctionsOptions; jobs?: JobsOptions; + /** Explicit config/environment resolution for embedders such as CNC. */ + runtime?: JobRuntimeContext; }; export type StartedFunction = { @@ -27,4 +29,6 @@ export type StartedFunction = { export type KnativeJobsSvcResult = { functions: StartedFunction[]; jobs: boolean; + jobsPort?: number; }; +import type { JobRuntimeContext } from '@constructive-io/job-utils'; diff --git a/jobs/knative-job-service/test-fixtures/concurrent-functions.cjs b/jobs/knative-job-service/test-fixtures/concurrent-functions.cjs new file mode 100644 index 0000000000..84482395c4 --- /dev/null +++ b/jobs/knative-job-service/test-fixtures/concurrent-functions.cjs @@ -0,0 +1,150 @@ +'use strict'; + +const { createServer } = require('node:http'); +const { Logger } = require('@pgpmjs/logger'); + +const infoCalls = []; +Logger.prototype.info = function (...args) { + infoCalls.push(args); +}; + +require('ts-node/register/transpile-only'); +const { KnativeJobsSvc } = require('../src'); + +const listen = (server, port = 0) => + new Promise((resolve, reject) => { + const onError = (error) => { + server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Missing TCP address.')); + return; + } + resolve(address.port); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, '127.0.0.1'); + }); + +const close = (server) => + !server?.listening + ? Promise.resolve() + : new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + +const findDistinctFreePorts = async () => { + const reservations = [createServer(), createServer()]; + try { + const ports = await Promise.all(reservations.map((server) => listen(server))); + if (ports[0] === ports[1]) throw new Error('Expected distinct ephemeral ports.'); + return ports; + } finally { + await Promise.allSettled(reservations.map(close)); + } +}; + +const invoke = (port, subject) => + fetch(`http://127.0.0.1:${port}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + to: 'agent@example.test', + subject, + text: 'This must use the instance-scoped dry-run environment.', + }), + }); + +const bindBoth = async (ports) => { + const probes = ports.map(() => createServer()); + try { + await Promise.all(probes.map((server, index) => listen(server, ports[index]))); + return true; + } finally { + await Promise.allSettled(probes.map(close)); + } +}; + +const main = async () => { + const [firstPort, secondPort] = await findDistinctFreePorts(); + const first = new KnativeJobsSvc({ + functions: { + enabled: true, + services: [{ name: 'send-email', port: firstPort }], + }, + runtime: { + cwd: process.cwd(), + env: { + NODE_ENV: 'test', + SEND_EMAIL_DRY_RUN: 'true', + MAILGUN_FROM: 'first@example.test', + }, + }, + }); + const second = new KnativeJobsSvc({ + functions: { + enabled: true, + services: [{ name: 'send-email', port: secondPort }], + }, + runtime: { + cwd: process.cwd(), + env: { + NODE_ENV: 'test', + SEND_EMAIL_DRY_RUN: 'true', + MAILGUN_FROM: 'second@example.test', + }, + }, + }); + let replacement; + + try { + await Promise.all([first.start(), second.start()]); + const [firstResponse, secondResponse] = await Promise.all([ + invoke(firstPort, 'First instance'), + invoke(secondPort, 'Second instance'), + ]); + + await first.stop(); + replacement = createServer((_request, response) => { + response.writeHead(204).end(); + }); + await listen(replacement, firstPort); + const survivingResponse = await invoke( + secondPort, + 'Second instance after first stopped', + ); + + await second.stop(); + await close(replacement); + replacement = undefined; + const bothPortsReleased = await bindBoth([firstPort, secondPort]); + const dryRuns = infoCalls + .filter(([message]) => message === 'DRY RUN email (no send)') + .map(([, context]) => context); + + process.stdout.write( + `CNC_CONCURRENT_FUNCTIONS_RESULT ${JSON.stringify({ + firstPort, + secondPort, + firstStatus: firstResponse.status, + secondStatus: secondResponse.status, + survivingStatus: survivingResponse.status, + firstPortRebound: true, + bothPortsReleased, + dryRuns, + })}\n`, + ); + } finally { + await Promise.allSettled([first.stop(), second.stop(), close(replacement)]); + } +}; + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/jobs/knative-job-worker/__tests__/lifecycle.test.ts b/jobs/knative-job-worker/__tests__/lifecycle.test.ts new file mode 100644 index 0000000000..8505647bdb --- /dev/null +++ b/jobs/knative-job-worker/__tests__/lifecycle.test.ts @@ -0,0 +1,37 @@ +import type { Pool } from 'pg'; +import { withLogsSuppressed } from '@pgpmjs/logger'; + +import Worker from '../src'; + +describe('job worker lifecycle', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('cancels a pending reconnect during awaited shutdown', async () => { + const connect = jest.fn(async (): Promise => { + throw new Error('database unavailable'); + }); + const lifecycle = { + close: jest.fn(async (): Promise => undefined), + onClose: jest.fn(), + }; + const worker = new Worker({ + tasks: [], + pgPool: { connect } as unknown as Pool, + lifecycle, + }); + + await withLogsSuppressed(() => worker.listen()); + expect(jest.getTimerCount()).toBe(1); + + await worker.stop(); + expect(jest.getTimerCount()).toBe(0); + jest.advanceTimersByTime(5000); + expect(connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/jobs/knative-job-worker/__tests__/req.test.ts b/jobs/knative-job-worker/__tests__/req.test.ts index a15cfbbf36..0be2078d63 100644 --- a/jobs/knative-job-worker/__tests__/req.test.ts +++ b/jobs/knative-job-worker/__tests__/req.test.ts @@ -1,125 +1,197 @@ -const postMock = jest.fn(); - -jest.mock('request', () => ({ - __esModule: true, - default: { post: postMock }, - post: postMock -})); - -describe('knative request wrapper', () => { - beforeEach(() => { - jest.resetModules(); - postMock.mockReset(); - - process.env.PGUSER = 'postgres'; - process.env.PGHOST = 'localhost'; - process.env.PGPASSWORD = 'password'; - process.env.PGPORT = '5432'; - process.env.PGDATABASE = 'jobs'; - process.env.JOBS_SCHEMA = 'app_jobs'; - process.env.INTERNAL_JOBS_CALLBACK_URL = - 'http://callback.internal/jobs-complete'; - process.env.NODE_ENV = 'test'; - delete process.env.INTERNAL_GATEWAY_URL; - delete process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP; - }); - - it('uses INTERNAL_GATEWAY_URL as base and preserves headers and body', async () => { - process.env.INTERNAL_GATEWAY_URL = - 'http://gateway.internal/async-function'; - - postMock.mockImplementation( - (options: any, callback: (err: any) => void) => callback(null) +import { createServer, type IncomingMessage, type Server } from 'node:http'; + +import type { JobRuntimeContext } from '@constructive-io/job-utils'; +import { withLogsSuppressed } from '@pgpmjs/logger'; + +import { request } from '../src/req'; + +interface CapturedRequest { + body: unknown; + headers: IncomingMessage['headers']; + url: string; +} + +const listen = async ( + handler: (request: IncomingMessage) => Promise +): Promise<{ server: Server; url: string }> => { + const server = createServer((incoming, response) => { + void handler(incoming).then( + () => { + response.statusCode = 200; + response.end('ok'); + }, + (error) => { + response.statusCode = 500; + response.end(String(error)); + } ); - - const { request } = await import('../src/req'); - - await request('example-fn', { - body: { value: 1 }, - databaseId: 'db-123', - workerId: 'worker-1', - jobId: 42 + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); }); - - expect(postMock).toHaveBeenCalledTimes(1); - const [options] = postMock.mock.calls[0]; - - expect(options.url).toBe( - 'http://gateway.internal/async-function/example-fn' - ); - expect(options.headers['Content-Type']).toBe('application/json'); - expect(options.headers['X-Worker-Id']).toBe('worker-1'); - expect(options.headers['X-Job-Id']).toBe(42); - expect(options.headers['X-Database-Id']).toBe('db-123'); - expect(options.headers['X-Callback-Url']).toBe( - 'http://callback.internal/jobs-complete' - ); - expect(options.body).toEqual({ value: 1 }); }); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing address.'); + return { server, url: `http://127.0.0.1:${address.port}` }; +}; + +const close = async (server: Server): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}; - it('uses default gateway URL when INTERNAL_GATEWAY_URL is not set', async () => { - - postMock.mockImplementation( - (options: any, callback: (err: any) => void) => callback(null) - ); - - const { request } = await import('../src/req'); +const readBody = async (request: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +}; - await request('example-fn', { - body: { value: 2 }, - databaseId: 'db-456', - workerId: 'worker-2', - jobId: 43 +describe('knative request wrapper', () => { + it('uses the explicit gateway and preserves headers and body', async () => { + let captured: CapturedRequest | undefined; + const { server, url } = await listen(async (incoming) => { + captured = { + body: await readBody(incoming), + headers: incoming.headers, + url: incoming.url ?? '', + }; }); - - const [options] = postMock.mock.calls[0]; - expect(options.url).toBe('http://gateway:8080/example-fn'); - }); - - it('uses development map override when provided', async () => { - process.env.INTERNAL_GATEWAY_URL = - 'http://gateway.internal/async-function'; - process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP = JSON.stringify({ - 'example-fn': 'http://localhost:3000/dev-fn' + const runtime: JobRuntimeContext = { + cwd: process.cwd(), + env: { + NODE_ENV: 'test', + INTERNAL_GATEWAY_URL: `${url}/async-function`, + INTERNAL_JOBS_CALLBACK_URL: 'http://callback.internal/jobs-complete', + }, + }; + + try { + await withLogsSuppressed(() => + request( + 'example-fn', + { + body: { value: 1 }, + databaseId: 'db-123', + workerId: 'worker-1', + jobId: 42, + }, + runtime + ) + ); + } finally { + await close(server); + } + + expect(captured).toMatchObject({ + body: { value: 1 }, + url: '/async-function/example-fn', + headers: { + 'content-type': 'application/json', + 'x-worker-id': 'worker-1', + 'x-job-id': '42', + 'x-database-id': 'db-123', + 'x-callback-url': 'http://callback.internal/jobs-complete', + }, }); - process.env.NODE_ENV = 'development'; - - postMock.mockImplementation( - (options: any, callback: (err: any) => void) => callback(null) - ); - - const { request } = await import('../src/req'); + }); - await request('example-fn', { - body: {}, - databaseId: 'db-789', - workerId: 'worker-3', - jobId: 44 + it('uses the explicit development map override', async () => { + let path: string | undefined; + const { server, url } = await listen(async (incoming) => { + path = incoming.url; + await readBody(incoming); }); - const [options] = postMock.mock.calls[0]; - expect(options.url).toBe('http://localhost:3000/dev-fn'); + try { + await withLogsSuppressed(() => + request( + 'example-fn', + { + body: {}, + databaseId: 'db-789', + workerId: 'worker-3', + jobId: 44, + }, + { + cwd: process.cwd(), + env: { + NODE_ENV: 'development', + INTERNAL_GATEWAY_URL: 'http://unused.invalid', + INTERNAL_GATEWAY_DEVELOPMENT_MAP: JSON.stringify({ + 'example-fn': `${url}/dev-fn`, + }), + }, + } + ) + ); + } finally { + await close(server); + } + + expect(path).toBe('/dev-fn'); }); - it('rejects when HTTP request errors', async () => { - process.env.INTERNAL_GATEWAY_URL = - 'http://gateway.internal/async-function'; - - postMock.mockImplementation( - (options: any, callback: (err: any) => void) => - callback(new Error('network failure')) - ); + it('rejects malformed development maps without logging their contents', async () => { + expect(() => + withLogsSuppressed(() => + request( + 'example-fn', + { + body: {}, + databaseId: 'db-000', + workerId: 'worker-4', + jobId: 45, + }, + { + cwd: process.cwd(), + env: { + NODE_ENV: 'development', + INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{secret-invalid-json', + }, + } + ) + ) + ).toThrow('INTERNAL_GATEWAY_DEVELOPMENT_MAP must contain a JSON object.'); + }); - const { request } = await import('../src/req'); + it('rejects network failures', async () => { + const probe = createServer(); + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => resolve()); + }); + const probeAddress = probe.address(); + if (!probeAddress || typeof probeAddress === 'string') { + throw new Error('Missing probe address.'); + } + await close(probe); await expect( - request('example-fn', { - body: {}, - databaseId: 'db-000', - workerId: 'worker-4', - jobId: 45 - }) - ).rejects.toThrow('network failure'); + withLogsSuppressed(() => + request( + 'example-fn', + { + body: {}, + databaseId: 'db-000', + workerId: 'worker-4', + jobId: 45, + }, + { + cwd: process.cwd(), + env: { + NODE_ENV: 'test', + INTERNAL_GATEWAY_URL: `http://127.0.0.1:${probeAddress.port}`, + }, + } + ) + ) + ).rejects.toMatchObject({ code: 'ECONNREFUSED' }); }); - }); diff --git a/jobs/knative-job-worker/src/index.ts b/jobs/knative-job-worker/src/index.ts index 0089ea087c..687a9135f5 100644 --- a/jobs/knative-job-worker/src/index.ts +++ b/jobs/knative-job-worker/src/index.ts @@ -1,6 +1,9 @@ -import poolManager from '@constructive-io/job-pg'; +import poolManager, { type PoolManager } from '@constructive-io/job-pg'; import * as jobs from '@constructive-io/job-utils'; -import type { PgClientLike } from '@constructive-io/job-utils'; +import type { + JobRuntimeContext, + PgClientLike, +} from '@constructive-io/job-utils'; import type { Pool, PoolClient } from 'pg'; import { Logger } from '@pgpmjs/logger'; import { request as req } from './req'; @@ -23,18 +26,31 @@ export default class Worker { _initialized?: boolean; listenClient?: PoolClient; listenRelease?: () => void; + reconnectTimer?: NodeJS.Timeout; stopped?: boolean; + runtime?: JobRuntimeContext; + private readonly lifecycle: Pick; + private readonly onFatal?: (error: Error) => void; + private readonly failFastOnInitialConnect: boolean; constructor({ tasks, idleDelay = 15000, pgPool = poolManager.getPool(), - workerId = 'worker-0' + workerId = 'worker-0', + runtime, + lifecycle = poolManager, + onFatal, + failFastOnInitialConnect = false, }: { tasks: string[]; idleDelay?: number; pgPool?: Pool; workerId?: string; + runtime?: JobRuntimeContext; + lifecycle?: Pick; + onFatal?: (error: Error) => void; + failFastOnInitialConnect?: boolean; }) { /* * idleDelay: This is how long to wait between polling for jobs. @@ -49,15 +65,19 @@ export default class Worker { this.workerId = workerId; this.doNextTimer = undefined; this.pgPool = pgPool; - poolManager.onClose(async () => { - await jobs.releaseJobs(pgPool, { workerId: this.workerId }); + this.runtime = runtime; + this.lifecycle = lifecycle; + this.onFatal = onFatal; + this.failFastOnInitialConnect = failFastOnInitialConnect; + lifecycle.onClose(async () => { + await jobs.releaseJobs(pgPool, { workerId: this.workerId }, runtime); }); } async initialize(client: PgClientLike) { if (this._initialized === true) return; // release any jobs not finished from before if fatal error prevented cleanup - await jobs.releaseJobs(client, { workerId: this.workerId }); + await jobs.releaseJobs(client, { workerId: this.workerId }, this.runtime); this._initialized = true; await this.doNext(client); @@ -67,14 +87,17 @@ export default class Worker { { err, fatalError, - jobId + jobId, }: { err?: Error; fatalError: unknown; jobId: JobRow['id'] } ) { const when = err ? `after failure '${err.message}'` : 'after success'; log.error(`Failed to release job '${jobId}' ${when}; committing seppuku`); - await poolManager.close(); - log.error(String(fatalError)); - process.exit(1); + const fatal = + fatalError instanceof Error ? fatalError : new Error(String(fatalError)); + await this.stop(); + await this.lifecycle.close(); + log.error(String(fatal)); + this.onFatal?.(fatal); } async handleError( client: PgClientLike, @@ -86,39 +109,45 @@ export default class Worker { if (err.stack) { log.debug(err.stack); } - await jobs.failJob(client, { - workerId: this.workerId, - jobId: job.id, - message: err.message - }); + await jobs.failJob( + client, + { + workerId: this.workerId, + jobId: job.id, + message: err.message, + }, + this.runtime + ); } async handleSuccess( client: PgClientLike, { job, duration }: { job: JobRow; duration: string } ) { - log.info( - `Async task ${job.id} (${job.task_identifier}) to be processed` - ); + log.info(`Async task ${job.id} (${job.task_identifier}) to be processed`); } async doWork(job: JobRow) { const { payload, task_identifier } = job; log.debug('starting work on job', { id: job.id, task: task_identifier, - databaseId: job.database_id + databaseId: job.database_id, }); if ( - !jobs.getJobSupportAny() && + !jobs.getJobSupportAny(this.runtime) && !this.supportedTaskNames.includes(task_identifier) ) { throw new Error('Unsupported task'); } - await req(task_identifier, { - body: payload, - databaseId: job.database_id, - workerId: this.workerId, - jobId: job.id - }); + await req( + task_identifier, + { + body: payload, + databaseId: job.database_id, + workerId: this.workerId, + jobId: job.id, + }, + this.runtime + ); } async doNext(client: PgClientLike): Promise { if (this.stopped) return; @@ -132,12 +161,16 @@ export default class Worker { this.doNextTimer = undefined; } try { - const job = (await jobs.getJob(client, { - workerId: this.workerId, - supportedTaskNames: jobs.getJobSupportAny() - ? null - : this.supportedTaskNames - })) as JobRow | undefined; + const job = (await jobs.getJob( + client, + { + workerId: this.workerId, + supportedTaskNames: jobs.getJobSupportAny(this.runtime) + ? null + : this.supportedTaskNames, + }, + this.runtime + )) as JobRow | undefined; if (!job || !job.id) { if (!this.stopped) { @@ -189,15 +222,21 @@ export default class Worker { let release: () => void; try { client = await this.pgPool.connect(); - release = () => client.release(); + let released = false; + release = () => { + if (released) return; + released = true; + client.release(); + }; } catch (err) { log.error('Error connecting with notify listener', err); if (err instanceof Error && err.stack) { log.debug(err.stack); } - if (!this.stopped) { - setTimeout(() => this.listen(), 5000); + if (this.failFastOnInitialConnect && !this._initialized) { + throw err; } + this.scheduleReconnect(); return; } if (this.stopped) { @@ -209,10 +248,20 @@ export default class Worker { client.on('notification', () => { if (this.doNextTimer) { // Must be idle, do something! - this.doNext(client); + void this.doNext(client); } }); - client.query('LISTEN "jobs:insert"'); + try { + await client.query('LISTEN "jobs:insert"'); + } catch (error) { + this.listenClient = undefined; + this.listenRelease = undefined; + client.removeAllListeners('notification'); + release(); + if (this.failFastOnInitialConnect && !this._initialized) throw error; + this.scheduleReconnect(); + return; + } client.on('error', (e: unknown) => { if (this.stopped) { release(); @@ -222,17 +271,36 @@ export default class Worker { if (e instanceof Error && e.stack) { log.debug(e.stack); } - release(); - if (!this.stopped) { - this.listen(); + if (this.doNextTimer) { + clearTimeout(this.doNextTimer); + this.doNextTimer = undefined; + } + if (this.listenClient === client) { + this.listenClient = undefined; + this.listenRelease = undefined; } + release(); + this.scheduleReconnect(); }); log.info(`${this.workerId} connected and looking for jobs...`); - this.doNext(client); + void this.doNext(client); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + void this.listen(); + }, 5000); + this.reconnectTimer.unref?.(); } async stop(): Promise { this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } if (this.doNextTimer) { clearTimeout(this.doNextTimer); this.doNextTimer = undefined; diff --git a/jobs/knative-job-worker/src/req.ts b/jobs/knative-job-worker/src/req.ts index 7d66ec5a0e..a79a65dc2b 100644 --- a/jobs/knative-job-worker/src/req.ts +++ b/jobs/knative-job-worker/src/req.ts @@ -5,25 +5,44 @@ import { getCallbackBaseUrl, getJobGatewayConfig, getJobGatewayDevMap, - getNodeEnvironment + getNodeEnvironment, } from '@constructive-io/job-utils'; +import type { JobRuntimeContext } from '@constructive-io/job-utils'; import { Logger } from '@pgpmjs/logger'; const log = new Logger('jobs:req'); +const SENSITIVE_QUERY_KEY = + /(auth|credential|key|password|secret|signature|token)/i; -// callback URL for job completion -const completeUrl = getCallbackBaseUrl(); - -// Development override map (e.g. point a function name at localhost) -const nodeEnv = getNodeEnvironment(); -const DEV_MAP = nodeEnv !== 'production' ? getJobGatewayDevMap() : null; +const safeLogUrl = (value: string): string => { + try { + const url = new URL(value); + url.username = ''; + url.password = ''; + for (const key of url.searchParams.keys()) { + if (SENSITIVE_QUERY_KEY.test(key)) + url.searchParams.set(key, '[REDACTED]'); + } + return url.toString(); + } catch { + return '[invalid-url]'; + } +}; -const getFunctionUrl = (fn: string): string => { - if (DEV_MAP && DEV_MAP[fn]) { - return DEV_MAP[fn] || completeUrl; +const getFunctionUrl = ( + fn: string, + completeUrl: string, + runtime?: JobRuntimeContext +): string => { + const devMap = + getNodeEnvironment(runtime) !== 'production' + ? getJobGatewayDevMap(runtime) + : null; + if (devMap && devMap[fn]) { + return devMap[fn] || completeUrl; } - const { gatewayUrl } = getJobGatewayConfig(); + const { gatewayUrl } = getJobGatewayConfig(runtime); const base = gatewayUrl.replace(/\/$/, ''); return `${base}/${fn}`; }; @@ -37,16 +56,18 @@ interface RequestOptions { const request = ( fn: string, - { body, databaseId, workerId, jobId }: RequestOptions + { body, databaseId, workerId, jobId }: RequestOptions, + runtime?: JobRuntimeContext ) => { - const url = getFunctionUrl(fn); + const completeUrl = getCallbackBaseUrl(runtime); + const url = getFunctionUrl(fn, completeUrl, runtime); log.info(`dispatching job`, { fn, - url, - callbackUrl: completeUrl, + url: safeLogUrl(url), + callbackUrl: safeLogUrl(completeUrl), workerId, jobId, - databaseId + databaseId, }); return new Promise((resolve, reject) => { let parsed: URL; @@ -76,8 +97,8 @@ const request = ( 'X-Database-Id': databaseId, // async HTTP completion callback - 'X-Callback-Url': completeUrl - } + 'X-Callback-Url': completeUrl, + }, }, (res) => { res.on('data', () => {}); From 85c723ad14c54bf5fdb747027dca5c8032126f9d Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:37:57 +0700 Subject: [PATCH 05/18] feat(codegen): add transactional generation operations Make source resolution cancellable and explicit, enforce declarative config safety, plan generated ownership before writes, preserve sensitive-value boundaries, and return structured artifacts suitable for registry-backed CLI execution. --- graphql/codegen/package.json | 3 +- .../__tests__/codegen/cli-generator.test.ts | 147 +- .../codegen/database-isolation.test.ts | 347 +++++ .../__tests__/codegen/expand-targets.test.ts | 81 +- .../codegen/generate-cancellation.test.ts | 109 ++ .../__tests__/codegen/generate-safety.test.ts | 337 +++++ .../src/__tests__/codegen/schema-only.test.ts | 99 +- .../codegen/write-generated-files.test.ts | 485 +++++- .../__tests__/config/resolve-config.test.ts | 27 +- .../__tests__/config/security-policy.test.ts | 177 +++ .../fetch-schema-cancellation.test.ts | 68 + graphql/codegen/src/cli/handler.ts | 322 +++- graphql/codegen/src/core/cancellation.ts | 22 + .../src/core/codegen/cli/docs-generator.ts | 363 +++-- .../src/core/codegen/target-docs-generator.ts | 35 +- graphql/codegen/src/core/config/index.ts | 4 + graphql/codegen/src/core/config/loader.ts | 72 +- graphql/codegen/src/core/config/resolver.ts | 21 +- graphql/codegen/src/core/database/index.ts | 18 +- graphql/codegen/src/core/generate.ts | 1314 +++++++++++++---- .../src/core/introspect/fetch-schema.ts | 82 +- graphql/codegen/src/core/introspect/index.ts | 1 + .../src/core/introspect/source/api-schemas.ts | 63 +- .../src/core/introspect/source/database.ts | 155 +- .../src/core/introspect/source/endpoint.ts | 10 +- .../src/core/introspect/source/index.ts | 29 +- .../src/core/introspect/source/pgpm-module.ts | 97 +- .../src/core/introspect/source/types.ts | 4 +- graphql/codegen/src/core/output/index.ts | 10 + graphql/codegen/src/core/output/writer.ts | 1200 +++++++++++++-- graphql/codegen/src/core/pipeline/index.ts | 42 +- graphql/codegen/src/core/sensitive-values.ts | 229 +++ graphql/codegen/src/index.ts | 27 +- graphql/codegen/src/types/config.ts | 8 +- 34 files changed, 5125 insertions(+), 883 deletions(-) create mode 100644 graphql/codegen/src/__tests__/codegen/database-isolation.test.ts create mode 100644 graphql/codegen/src/__tests__/codegen/generate-cancellation.test.ts create mode 100644 graphql/codegen/src/__tests__/codegen/generate-safety.test.ts create mode 100644 graphql/codegen/src/__tests__/config/security-policy.test.ts create mode 100644 graphql/codegen/src/__tests__/introspect/fetch-schema-cancellation.test.ts create mode 100644 graphql/codegen/src/core/cancellation.ts create mode 100644 graphql/codegen/src/core/sensitive-values.ts diff --git a/graphql/codegen/package.json b/graphql/codegen/package.json index 68faf869b3..f0f77b7c2b 100644 --- a/graphql/codegen/package.json +++ b/graphql/codegen/package.json @@ -27,7 +27,7 @@ "directory": "dist" }, "main": "index.js", - "module": "index.mjs", + "module": "esm/index.js", "types": "index.d.ts", "bin": { "graphql-codegen": "cli/index.js" @@ -70,6 +70,7 @@ "inquirerer": "^4.9.1", "jiti": "^2.7.0", "oxfmt": "^0.51.0", + "pg": "^8.21.0", "pg-cache": "workspace:^", "pg-env": "workspace:^", "pgsql-client": "workspace:^", diff --git a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts index 941df8317d..b50bb049c8 100644 --- a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts @@ -1,4 +1,8 @@ -import { generateCli, generateMultiTargetCli, resolveBuiltinNames } from '../../core/codegen/cli'; +import { + generateCli, + generateMultiTargetCli, + resolveBuiltinNames, +} from '../../core/codegen/cli'; import { generateReadme as generateCliReadme, generateSkills as generateCliSkills, @@ -42,9 +46,7 @@ const emptyRelations: Relations = { manyToMany: [], }; -function createTable( - partial: Partial & { name: string }, -): Table { +function createTable(partial: Partial
& { name: string }): Table { return { name: partial.name, fields: partial.fields ?? [], @@ -58,7 +60,7 @@ function createTable( function createTypeRef( kind: TypeRef['kind'], name: string | null, - ofType?: TypeRef, + ofType?: TypeRef ): TypeRef { return { kind, name, ofType }; } @@ -104,19 +106,11 @@ const loginMutation: Operation = { args: [ { name: 'email', - type: createTypeRef( - 'NON_NULL', - null, - createTypeRef('SCALAR', 'String'), - ), + type: createTypeRef('NON_NULL', null, createTypeRef('SCALAR', 'String')), }, { name: 'password', - type: createTypeRef( - 'NON_NULL', - null, - createTypeRef('SCALAR', 'String'), - ), + type: createTypeRef('NON_NULL', null, createTypeRef('SCALAR', 'String')), }, ], returnType: createTypeRef('OBJECT', 'LoginPayload'), @@ -160,9 +154,7 @@ describe('cli-generator', () => { }); it('generates commands/context.ts', () => { - const file = result.files.find( - (f) => f.fileName === 'commands/context.ts', - ); + const file = result.files.find((f) => f.fileName === 'commands/context.ts'); expect(file).toBeDefined(); expect(file!.content).toMatchSnapshot(); }); @@ -180,9 +172,7 @@ describe('cli-generator', () => { }); it('generates commands/driver.ts', () => { - const file = result.files.find( - (f) => f.fileName === 'commands/driver.ts', - ); + const file = result.files.find((f) => f.fileName === 'commands/driver.ts'); expect(file).toBeDefined(); expect(file!.content).toMatchSnapshot(); }); @@ -195,7 +185,7 @@ describe('cli-generator', () => { it('generates commands/current-user.ts (custom query)', () => { const file = result.files.find( - (f) => f.fileName === 'commands/current-user.ts', + (f) => f.fileName === 'commands/current-user.ts' ); expect(file).toBeDefined(); expect(file!.content).toMatchSnapshot(); @@ -207,11 +197,8 @@ describe('cli-generator', () => { expect(file!.content).toMatchSnapshot(); }); - it('uses ORM methods in table commands', () => { - const carFile = result.files.find( - (f) => f.fileName === 'commands/car.ts', - ); + const carFile = result.files.find((f) => f.fileName === 'commands/car.ts'); expect(carFile!.content).toContain('getClient'); expect(carFile!.content).toContain('.findMany('); expect(carFile!.content).toContain('.execute()'); @@ -220,7 +207,7 @@ describe('cli-generator', () => { it('uses ORM methods in custom commands', () => { const loginFile = result.files.find( - (f) => f.fileName === 'commands/login.ts', + (f) => f.fileName === 'commands/login.ts' ); expect(loginFile!.content).toContain('getClient'); expect(loginFile!.content).toContain('.mutation.'); @@ -254,13 +241,22 @@ const allCustomOps: Operation[] = [currentUserQuery, loginMutation]; describe('cli docs generator', () => { it('generates CLI README', () => { - const readme = generateCliReadme([carTable, driverTable], allCustomOps, 'myapp'); + const readme = generateCliReadme( + [carTable, driverTable], + allCustomOps, + 'myapp' + ); expect(readme.fileName).toBe('README.md'); expect(readme.content).toMatchSnapshot(); }); it('generates CLI skill files', () => { - const skills = generateCliSkills([carTable, driverTable], allCustomOps, 'myapp', 'default'); + const skills = generateCliSkills( + [carTable, driverTable], + allCustomOps, + 'myapp', + 'default' + ); expect(skills.length).toBeGreaterThan(0); for (const sf of skills) { expect(sf.content).toMatchSnapshot(); @@ -276,7 +272,11 @@ describe('orm docs generator', () => { }); it('generates ORM skill files', () => { - const skills = generateOrmSkills([carTable, driverTable], allCustomOps, 'default'); + const skills = generateOrmSkills( + [carTable, driverTable], + allCustomOps, + 'default' + ); expect(skills.length).toBeGreaterThan(0); for (const sf of skills) { expect(sf.content).toMatchSnapshot(); @@ -292,7 +292,11 @@ describe('hooks docs generator', () => { }); it('generates hooks skill files', () => { - const skills = generateHooksSkills([carTable, driverTable], allCustomOps, 'default'); + const skills = generateHooksSkills( + [carTable, driverTable], + allCustomOps, + 'default' + ); expect(skills.length).toBeGreaterThan(0); for (const sf of skills) { expect(sf.content).toMatchSnapshot(); @@ -317,12 +321,49 @@ describe('target docs generator', () => { it('generates root-root README for multi-target', () => { const readme = generateRootRootReadme([ - { name: 'auth', output: './generated/auth', endpoint: 'http://auth.localhost/graphql', generators: ['ORM'] }, - { name: 'app', output: './generated/app', endpoint: 'http://app.localhost/graphql', generators: ['ORM', 'React Query', 'CLI'] }, + { + name: 'auth', + output: './generated/auth', + endpoint: 'http://auth.localhost/graphql', + generators: ['ORM'], + }, + { + name: 'app', + output: './generated/app', + endpoint: 'http://app.localhost/graphql', + generators: ['ORM', 'React Query', 'CLI'], + }, ]); expect(readme.fileName).toBe('README.md'); expect(readme.content).toMatchSnapshot(); }); + + it('projects private endpoint components out of target docs', () => { + const secrets = ['endpoint-password', 'query-secret', 'fragment-secret']; + const endpoint = `https://user:${secrets[0]}@api.example.com/graphql?tenant=${secrets[1]}#${secrets[2]}`; + const targetReadme = generateTargetReadme({ + hasOrm: true, + hasHooks: false, + hasCli: false, + tableCount: 0, + customQueryCount: 0, + customMutationCount: 0, + config: { endpoint }, + }); + const rootReadme = generateRootRootReadme([ + { + name: 'private', + output: './generated/private', + endpoint, + generators: ['ORM'], + }, + ]); + + for (const doc of [targetReadme, rootReadme]) { + expect(doc.content).toContain('https://api.example.com/graphql'); + for (const secret of secrets) expect(doc.content).not.toContain(secret); + } + }); }); describe('resolveDocsConfig', () => { @@ -381,9 +422,7 @@ describe('resolveBuiltinNames', () => { }); it('respects user overrides even with collisions', () => { - expect( - resolveBuiltinNames(['auth', 'app'], { auth: 'auth' }), - ).toEqual({ + expect(resolveBuiltinNames(['auth', 'app'], { auth: 'auth' })).toEqual({ auth: 'auth', context: 'context', config: 'config', @@ -392,7 +431,7 @@ describe('resolveBuiltinNames', () => { it('uses user override names', () => { expect( - resolveBuiltinNames(['app'], { auth: 'creds', context: 'profile' }), + resolveBuiltinNames(['app'], { auth: 'creds', context: 'profile' }) ).toEqual({ auth: 'creds', context: 'profile', @@ -418,7 +457,7 @@ describe('resolveBuiltinNames', () => { it('respects user config override even with collision', () => { expect( - resolveBuiltinNames(['config', 'app'], { config: 'config' }), + resolveBuiltinNames(['config', 'app'], { config: 'config' }) ).toEqual({ auth: 'auth', context: 'context', @@ -538,7 +577,7 @@ describe('multi-target cli generator', () => { it('generates multi-target context command', () => { const file = multiResult.files.find( - (f) => f.fileName === 'commands/context.ts', + (f) => f.fileName === 'commands/context.ts' ); expect(file).toBeDefined(); expect(file!.content).toMatchSnapshot(); @@ -546,7 +585,7 @@ describe('multi-target cli generator', () => { it('generates credentials command (renamed from auth)', () => { const file = multiResult.files.find( - (f) => f.fileName === 'commands/credentials.ts', + (f) => f.fileName === 'commands/credentials.ts' ); expect(file).toBeDefined(); expect(file!.content).toMatchSnapshot(); @@ -554,19 +593,19 @@ describe('multi-target cli generator', () => { it('generates target-prefixed table commands', () => { const userFile = multiResult.files.find( - (f) => f.fileName === 'commands/auth/user.ts', + (f) => f.fileName === 'commands/auth/user.ts' ); expect(userFile).toBeDefined(); expect(userFile!.content).toMatchSnapshot(); const memberFile = multiResult.files.find( - (f) => f.fileName === 'commands/members/member.ts', + (f) => f.fileName === 'commands/members/member.ts' ); expect(memberFile).toBeDefined(); expect(memberFile!.content).toMatchSnapshot(); const carFile = multiResult.files.find( - (f) => f.fileName === 'commands/app/car.ts', + (f) => f.fileName === 'commands/app/car.ts' ); expect(carFile).toBeDefined(); expect(carFile!.content).toMatchSnapshot(); @@ -574,7 +613,7 @@ describe('multi-target cli generator', () => { it('generates target-prefixed custom commands with save-token', () => { const loginFile = multiResult.files.find( - (f) => f.fileName === 'commands/auth/login.ts', + (f) => f.fileName === 'commands/auth/login.ts' ); expect(loginFile).toBeDefined(); expect(loginFile!.content).toContain('saveToken'); @@ -596,7 +635,7 @@ describe('multi-target cli generator', () => { it('generates config command', () => { const file = multiResult.files.find( - (f) => f.fileName === 'commands/config.ts', + (f) => f.fileName === 'commands/config.ts' ); expect(file).toBeDefined(); expect(file!.content).toContain('getStore'); @@ -621,7 +660,7 @@ describe('multi-target cli generator', () => { it('uses correct executor import path in target commands', () => { const userFile = multiResult.files.find( - (f) => f.fileName === 'commands/auth/user.ts', + (f) => f.fileName === 'commands/auth/user.ts' ); expect(userFile!.content).toContain('../../executor'); }); @@ -687,6 +726,22 @@ describe('multi-target cli docs', () => { expect(readme.content).toContain('credentials'); expect(readme.content).toContain('env'); }); + + it('projects private endpoint components out of multi-target docs', () => { + const secrets = ['endpoint-password', 'query-secret', 'fragment-secret']; + const readme = generateMultiTargetReadme({ + ...docsInput, + targets: [ + { + ...docsInput.targets[0], + endpoint: `https://user:${secrets[0]}@api.example.com/graphql?tenant=${secrets[1]}#${secrets[2]}`, + }, + ], + }); + + expect(readme.content).toContain('https://api.example.com/graphql'); + for (const secret of secrets) expect(readme.content).not.toContain(secret); + }); }); describe('multi-target cli with custom builtinNames', () => { diff --git a/graphql/codegen/src/__tests__/codegen/database-isolation.test.ts b/graphql/codegen/src/__tests__/codegen/database-isolation.test.ts new file mode 100644 index 0000000000..5ee2233824 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/database-isolation.test.ts @@ -0,0 +1,347 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import * as graphileSchema from 'graphile-schema'; +import type { Pool } from 'pg'; +import type { PgConfig } from 'pg-env'; +import * as pgsqlClient from 'pgsql-client'; +import * as pgsqlSeed from 'pgsql-seed'; + +import { runCodegenOperation } from '../../cli/handler'; +import { generateMulti } from '../../core/generate'; +import { resolvePgConfig } from '../../core/introspect'; +import { DatabaseSchemaSource } from '../../core/introspect/source/database'; +import { PgpmModuleSchemaSource } from '../../core/introspect/source/pgpm-module'; + +jest.mock('pgsql-client', () => ({ + ...jest.requireActual('pgsql-client'), + createEphemeralDb: jest.fn(), +})); +jest.mock('pgsql-seed', () => ({ + ...jest.requireActual('pgsql-seed'), + deployPgpm: jest.fn(), +})); + +const PG_ENV_KEYS = [ + 'PGHOST', + 'PGPORT', + 'PGUSER', + 'PGPASSWORD', + 'PGDATABASE', +] as const; + +describe('database operation isolation', () => { + let tempDir: string; + let previousPgEnv: Record; + + beforeEach(() => { + jest.clearAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-db-isolation-')); + previousPgEnv = Object.fromEntries( + PG_ENV_KEYS.map((key) => [key, process.env[key]]) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + for (const key of PG_ENV_KEYS) { + const previous = previousPgEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); + + it('resolves only the supplied environment and lets config override it', () => { + process.env.PGHOST = 'ambient-host'; + process.env.PGPORT = '1111'; + process.env.PGUSER = 'ambient-user'; + process.env.PGPASSWORD = 'ambient-password'; + process.env.PGDATABASE = 'ambient-database'; + + const explicitEnv = { + PGHOST: 'explicit-host', + PGPORT: '2222', + PGUSER: 'explicit-user', + PGPASSWORD: 'explicit-password', + PGDATABASE: 'explicit-database', + }; + + expect(resolvePgConfig({}, explicitEnv)).toEqual({ + host: 'explicit-host', + port: 2222, + user: 'explicit-user', + password: 'explicit-password', + database: 'explicit-database', + }); + + expect( + resolvePgConfig( + { + host: 'config-host', + port: 3333, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }, + explicitEnv + ) + ).toEqual({ + host: 'config-host', + port: 3333, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }); + }); + + it('threads explicit env through runCodegenOperation to a direct source', async () => { + process.env.PGHOST = 'ambient-host'; + const seen: PgConfig[] = []; + jest + .spyOn(DatabaseSchemaSource.prototype, 'fetch') + .mockImplementation(async function (this: DatabaseSchemaSource) { + seen.push( + (this as unknown as { options: { pgConfig: PgConfig } }).options + .pgConfig + ); + throw new Error('stop after capturing database config'); + }); + + await runCodegenOperation( + { + schemas: ['public'], + orm: true, + output: path.join(tempDir, 'generated'), + dryRun: true, + }, + { + cwd: tempDir, + env: { + PGHOST: 'operation-host', + PGPORT: '6543', + PGUSER: 'operation-user', + PGPASSWORD: 'operation-password', + PGDATABASE: 'operation-database', + }, + onProgress: () => undefined, + } + ); + + expect(seen).toEqual([ + { + host: 'operation-host', + port: 6543, + user: 'operation-user', + password: 'operation-password', + database: 'operation-database', + }, + ]); + }); + + it('preserves file config precedence through a single PGPM source', async () => { + const configPath = path.join(tempDir, 'graphql-codegen.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + db: { + config: { + host: 'config-host', + port: 7654, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }, + pgpm: { modulePath: './module' }, + schemas: ['public'], + }, + output: './generated', + orm: true, + docs: false, + }) + ); + + const seen: PgConfig[] = []; + jest + .spyOn(PgpmModuleSchemaSource.prototype, 'fetch') + .mockImplementation(async function (this: PgpmModuleSchemaSource) { + seen.push( + (this as unknown as { options: { pgConfig: PgConfig } }).options + .pgConfig + ); + throw new Error('stop after capturing PGPM config'); + }); + + await runCodegenOperation( + { config: configPath, dryRun: true }, + { + cwd: tempDir, + env: { + PGHOST: 'env-host', + PGPORT: '1111', + PGUSER: 'env-user', + PGPASSWORD: 'env-password', + PGDATABASE: 'env-database', + }, + onProgress: () => undefined, + } + ); + + expect(seen).toEqual([ + { + host: 'config-host', + port: 7654, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }, + ]); + }); + + it('uses one explicit base connection for a shared PGPM deployment', async () => { + const teardown = jest.fn(); + const ephemeralConfig: PgConfig = { + host: 'shared-host', + port: 8765, + user: 'shared-user', + password: 'shared-password', + database: 'ephemeral-shared', + }; + const createEphemeral = jest + .mocked(pgsqlClient.createEphemeralDb) + .mockImplementation((options = {}) => ({ + name: ephemeralConfig.database, + config: ephemeralConfig, + admin: {} as never, + teardown, + })); + const deploy = jest + .mocked(pgsqlSeed.deployPgpm) + .mockImplementation(async () => undefined as never); + const seen: PgConfig[] = []; + jest + .spyOn(DatabaseSchemaSource.prototype, 'fetch') + .mockImplementation(async function (this: DatabaseSchemaSource) { + seen.push( + (this as unknown as { options: { pgConfig: PgConfig } }).options + .pgConfig + ); + throw new Error('stop after capturing shared config'); + }); + + const baseDb = { + config: { + host: 'config-host', + port: 8765, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }, + pgpm: { modulePath: './module' }, + schemas: ['public'], + }; + const result = await generateMulti({ + configs: { + first: { + db: baseDb, + output: path.join(tempDir, 'first'), + orm: true, + docs: false, + }, + second: { + db: baseDb, + output: path.join(tempDir, 'second'), + orm: true, + docs: false, + }, + }, + cwd: tempDir, + env: { + PGHOST: 'env-host', + PGPORT: '1111', + PGUSER: 'env-user', + PGPASSWORD: 'env-password', + PGDATABASE: 'env-database', + }, + dryRun: true, + onProgress: () => undefined, + }); + + expect(result.hasError).toBe(true); + expect(createEphemeral).toHaveBeenCalledTimes(1); + expect(createEphemeral).toHaveBeenCalledWith( + expect.objectContaining({ + baseConfig: { + host: 'config-host', + port: 8765, + user: 'config-user', + password: 'config-password', + database: 'config-database', + }, + }) + ); + expect(deploy).toHaveBeenCalledTimes(1); + expect(seen).toEqual([ephemeralConfig, ephemeralConfig]); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('injects distinct pools into concurrent builds and closes both', async () => { + const pools: Array = []; + const poolFactory = jest.fn((config: PgConfig) => { + const pool = { + marker: config.database, + end: jest.fn().mockResolvedValue(undefined), + } as unknown as Pool & { marker: string; end: jest.Mock }; + pools.push(pool); + return pool; + }); + const build = jest + .spyOn(graphileSchema, 'buildSchemaArtifacts') + .mockImplementation(async ({ pool }) => { + const marker = (pool as Pool & { marker: string }).marker; + await Promise.resolve(); + return { + sdl: 'type Query { hello: String }', + tablesMeta: [ + { + name: marker, + schemaName: 'public', + relations: { manyToMany: [] }, + }, + ] as never, + }; + }); + + const first = new DatabaseSchemaSource({ + pgConfig: resolvePgConfig({ database: 'first' }, {}), + schemas: ['public'], + poolFactory, + }); + const second = new DatabaseSchemaSource({ + pgConfig: resolvePgConfig({ database: 'second' }, {}), + schemas: ['public'], + poolFactory, + }); + + const [firstResult, secondResult] = await Promise.all([ + first.fetch(), + second.fetch(), + ]); + + expect(pools).toHaveLength(2); + expect(pools[0]).not.toBe(pools[1]); + expect(build).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ pool: pools[0], schemas: ['public'] }) + ); + expect(build).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ pool: pools[1], schemas: ['public'] }) + ); + expect(firstResult.tablesMeta?.[0].name).toBe('first'); + expect(secondResult.tablesMeta?.[0].name).toBe('second'); + expect(pools[0].end).toHaveBeenCalledTimes(1); + expect(pools[1].end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts index 60a5526310..79a1098609 100644 --- a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts +++ b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts @@ -2,7 +2,12 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, TARGETS_MANIFEST } from '../../core/generate'; +import { + expandApiNamesToMultiTarget, + expandSchemaDirToMultiTarget, + removeStaleTargetDirs, + TARGETS_MANIFEST, +} from '../../core/generate'; describe('expandApiNamesToMultiTarget', () => { it('returns null for no apiNames', () => { @@ -15,13 +20,16 @@ describe('expandApiNamesToMultiTarget', () => { it('returns null for single apiName', () => { expect( - expandApiNamesToMultiTarget({ db: { apiNames: ['app'] } }), + expandApiNamesToMultiTarget({ db: { apiNames: ['app'] } }) ).toBeNull(); }); it('expands multiple apiNames into separate targets', () => { const result = expandApiNamesToMultiTarget({ - db: { apiNames: ['app', 'admin'], pgpm: { workspacePath: '/ws', moduleName: 'mod' } }, + db: { + apiNames: ['app', 'admin'], + pgpm: { workspacePath: '/ws', moduleName: 'mod' }, + }, output: './generated', orm: true, reactQuery: true, @@ -34,7 +42,10 @@ describe('expandApiNamesToMultiTarget', () => { expect(result!.app.output).toBe('./generated/app'); expect(result!.app.orm).toBe(true); expect(result!.app.reactQuery).toBe(true); - expect(result!.app.db?.pgpm).toEqual({ workspacePath: '/ws', moduleName: 'mod' }); + expect(result!.app.db?.pgpm).toEqual({ + workspacePath: '/ws', + moduleName: 'mod', + }); expect(result!.admin.db?.apiNames).toEqual(['admin']); expect(result!.admin.output).toBe('./generated/admin'); @@ -80,7 +91,7 @@ describe('expandSchemaDirToMultiTarget', () => { it('returns null when directory does not exist', () => { expect( - expandSchemaDirToMultiTarget({ schemaDir: '/nonexistent/path' }), + expandSchemaDirToMultiTarget({ schemaDir: '/nonexistent/path' }) ).toBeNull(); }); @@ -90,8 +101,14 @@ describe('expandSchemaDirToMultiTarget', () => { }); it('expands .graphql files into separate targets named by filename', () => { - fs.writeFileSync(path.join(tempDir, 'app.graphql'), 'type Query { hello: String }'); - fs.writeFileSync(path.join(tempDir, 'admin.graphql'), 'type Query { users: [User] }'); + fs.writeFileSync( + path.join(tempDir, 'app.graphql'), + 'type Query { hello: String }' + ); + fs.writeFileSync( + path.join(tempDir, 'admin.graphql'), + 'type Query { users: [User] }' + ); const result = expandSchemaDirToMultiTarget({ schemaDir: tempDir, @@ -112,7 +129,10 @@ describe('expandSchemaDirToMultiTarget', () => { }); it('uses default output path when output is not specified', () => { - fs.writeFileSync(path.join(tempDir, 'api.graphql'), 'type Query { ok: Boolean }'); + fs.writeFileSync( + path.join(tempDir, 'api.graphql'), + 'type Query { ok: Boolean }' + ); const result = expandSchemaDirToMultiTarget({ schemaDir: tempDir }); @@ -120,7 +140,10 @@ describe('expandSchemaDirToMultiTarget', () => { }); it('ignores non-.graphql files', () => { - fs.writeFileSync(path.join(tempDir, 'app.graphql'), 'type Query { a: String }'); + fs.writeFileSync( + path.join(tempDir, 'app.graphql'), + 'type Query { a: String }' + ); fs.writeFileSync(path.join(tempDir, 'notes.txt'), 'not a schema'); fs.writeFileSync(path.join(tempDir, 'data.json'), '{}'); @@ -130,9 +153,18 @@ describe('expandSchemaDirToMultiTarget', () => { }); it('sorts targets alphabetically', () => { - fs.writeFileSync(path.join(tempDir, 'zebra.graphql'), 'type Query { z: String }'); - fs.writeFileSync(path.join(tempDir, 'alpha.graphql'), 'type Query { a: String }'); - fs.writeFileSync(path.join(tempDir, 'mid.graphql'), 'type Query { m: String }'); + fs.writeFileSync( + path.join(tempDir, 'zebra.graphql'), + 'type Query { z: String }' + ); + fs.writeFileSync( + path.join(tempDir, 'alpha.graphql'), + 'type Query { a: String }' + ); + fs.writeFileSync( + path.join(tempDir, 'mid.graphql'), + 'type Query { m: String }' + ); const result = expandSchemaDirToMultiTarget({ schemaDir: tempDir }); @@ -153,7 +185,10 @@ describe('removeStaleTargetDirs', () => { /** Write a .targets manifest listing the given names. */ function writeManifest(names: string[]) { - fs.writeFileSync(path.join(tempDir, TARGETS_MANIFEST), JSON.stringify(names)); + fs.writeFileSync( + path.join(tempDir, TARGETS_MANIFEST), + JSON.stringify(names) + ); } it('removes previous targets that are no longer current', () => { @@ -229,4 +264,24 @@ describe('removeStaleTargetDirs', () => { const removed = removeStaleTargetDirs(tempDir, []); expect(removed).toEqual([]); }); + + it('refuses target-manifest path traversal', () => { + const victim = path.join( + path.dirname(tempDir), + `${path.basename(tempDir)}-victim` + ); + fs.mkdirSync(victim); + fs.writeFileSync( + path.join(tempDir, TARGETS_MANIFEST), + JSON.stringify([`../${path.basename(victim)}`]) + ); + + try { + const removed = removeStaleTargetDirs(tempDir, []); + expect(removed).toEqual([]); + expect(fs.existsSync(victim)).toBe(true); + } finally { + fs.rmSync(victim, { recursive: true, force: true }); + } + }); }); diff --git a/graphql/codegen/src/__tests__/codegen/generate-cancellation.test.ts b/graphql/codegen/src/__tests__/codegen/generate-cancellation.test.ts new file mode 100644 index 0000000000..d2010c562d --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/generate-cancellation.test.ts @@ -0,0 +1,109 @@ +import * as fs from 'node:fs'; +import http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runCodegenOperation } from '../../cli/handler'; +import { generate, generateMulti } from '../../core/generate'; + +describe('codegen cancellation', () => { + let tempDir: string; + let server: http.Server | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-cancel-')); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('preserves cancellation through generate instead of returning a failure result', async () => { + let requestStarted!: () => void; + const started = new Promise((resolve) => { + requestStarted = resolve; + }); + server = http.createServer(() => { + requestStarted(); + // Deliberately never respond. + }); + await new Promise((resolve) => + server!.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture server did not expose a TCP port.'); + } + + const controller = new AbortController(); + const reason = new Error('cancel generation'); + reason.name = 'AbortError'; + const output = path.join(tempDir, 'generated'); + const pending = generate({ + endpoint: `http://127.0.0.1:${address.port}/graphql`, + output, + orm: true, + docs: false, + signal: controller.signal, + onProgress: () => undefined, + }); + + await started; + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + expect(fs.existsSync(output)).toBe(false); + }); + + it('propagates the operation signal before config or filesystem work', async () => { + const controller = new AbortController(); + const reason = new Error('cancel operation'); + reason.name = 'AbortError'; + controller.abort(reason); + + await expect( + runCodegenOperation( + { + schemaFile: 'missing.graphql', + output: 'generated', + orm: true, + }, + { cwd: tempDir, signal: controller.signal } + ) + ).rejects.toBe(reason); + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + + it('checks cancellation before multi-target cleanup or generation', async () => { + const outputRoot = path.join(tempDir, 'generated'); + const staleTarget = path.join(outputRoot, 'stale'); + fs.mkdirSync(staleTarget, { recursive: true }); + fs.writeFileSync(path.join(outputRoot, '.targets'), '["stale"]\n'); + + const controller = new AbortController(); + const reason = new Error('cancel multi-target operation'); + reason.name = 'AbortError'; + controller.abort(reason); + + await expect( + generateMulti({ + configs: { + current: { + schemaFile: path.join(tempDir, 'missing.graphql'), + output: path.join(outputRoot, 'current'), + orm: true, + }, + }, + cleanStaleTargets: true, + signal: controller.signal, + }) + ).rejects.toBe(reason); + expect(fs.existsSync(staleTarget)).toBe(true); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts b/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts new file mode 100644 index 0000000000..5856b02114 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts @@ -0,0 +1,337 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runCodegenOperation } from '../../cli/handler'; +import { generate, generateMulti, TARGETS_MANIFEST } from '../../core/generate'; +import { writeGeneratedFiles } from '../../core/output'; + +const EXAMPLE_SCHEMA = path.resolve( + __dirname, + '../../../examples/example.schema.graphql' +); + +describe('generate() filesystem safety', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'generate-safety-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('keeps the reusable operation silent when verbose progress is requested', async () => { + const output = path.join(tempDir, 'generated'); + const onProgress = jest.fn(); + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const result = await runCodegenOperation( + { + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + verbose: true, + dryRun: true, + }, + { cwd: tempDir, onProgress } + ); + + expect(result.hasError).toBe(false); + expect(onProgress).toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + } + }); + + it('plans every generated file without creating the output on dry run', async () => { + const output = path.join(tempDir, 'generated'); + + const result = await generate({ + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + onProgress: () => undefined, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.plans).toHaveLength(1); + expect(result.planFingerprint).toMatch(/^[a-f0-9]{64}$/); + expect( + result.fileChanges?.some((change) => change.action === 'create') + ).toBe(true); + expect(result.filesWritten).toEqual([]); + expect(fs.existsSync(output)).toBe(false); + }); + + it('prunes stale owned files while preserving unknown handwritten files', async () => { + const output = path.join(tempDir, 'generated'); + const initial = await generate({ + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + reactQuery: true, + docs: false, + onProgress: () => undefined, + }); + expect(initial.success).toBe(true); + const handwritten = path.join(output, 'notes.ts'); + fs.writeFileSync(handwritten, 'export const handwritten = true;\n'); + + const result = await generate({ + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + reactQuery: false, + docs: false, + onProgress: () => undefined, + }); + + expect(result.success).toBe(true); + expect(fs.existsSync(handwritten)).toBe(true); + expect(fs.existsSync(path.join(output, 'hooks'))).toBe(false); + expect( + result.fileChanges?.some((change) => change.action === 'delete') + ).toBe(true); + }); + + it('rejects modified generated files unless overwrite is confirmed', async () => { + const output = path.join(tempDir, 'generated'); + const initial = await generate({ + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + onProgress: () => undefined, + }); + expect(initial.success).toBe(true); + + const barrel = path.join(output, 'index.ts'); + fs.writeFileSync(barrel, 'export const manuallyEdited = true;\n'); + + const conflict = await generate({ + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + onProgress: () => undefined, + }); + expect(conflict.success).toBe(false); + expect(conflict.fileChanges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'index.ts', + action: 'conflict', + reason: 'modified-generated-file', + }), + ]) + ); + expect(fs.readFileSync(barrel, 'utf8')).toContain('manuallyEdited'); + + const overwritten = await generate({ + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + onProgress: () => undefined, + overwriteModifiedGenerated: true, + yes: true, + }); + expect(overwritten.success).toBe(true); + expect(fs.readFileSync(barrel, 'utf8')).toContain( + '@generated by @constructive-io/graphql-codegen' + ); + }); + + it('does not commit an earlier target when a later target cannot be planned', async () => { + const firstOutput = path.join(tempDir, 'generated', 'first'); + const secondOutput = path.join(tempDir, 'generated', 'second'); + + const result = await generateMulti({ + configs: { + first: { + schemaFile: EXAMPLE_SCHEMA, + output: firstOutput, + orm: true, + docs: false, + }, + second: { + schemaFile: path.join(tempDir, 'missing.graphql'), + output: secondOutput, + orm: true, + docs: false, + }, + }, + cwd: tempDir, + onProgress: () => undefined, + }); + + expect(result.hasError).toBe(true); + expect(fs.existsSync(firstOutput)).toBe(false); + expect(fs.existsSync(secondOutput)).toBe(false); + }); + + it('preserves retained-transaction warnings on multi-target results', async () => { + const output = path.join(tempDir, 'generated', 'first'); + const nativeFs = require('node:fs') as typeof fs; + const originalRemove = nativeFs.rmSync; + let cleanupFailureInjected = false; + const removeSpy = jest + .spyOn(nativeFs, 'rmSync') + .mockImplementation((target, options) => { + if ( + !cleanupFailureInjected && + String(target).includes('.codegen-transaction-') + ) { + cleanupFailureInjected = true; + throw new Error('injected transaction cleanup failure'); + } + return originalRemove(target, options); + }); + + let result: Awaited>; + try { + result = await generateMulti({ + configs: { + first: { + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + }, + }, + cwd: tempDir, + onProgress: () => undefined, + }); + } finally { + removeSpy.mockRestore(); + } + + expect(result.hasError).toBe(false); + expect(result.warnings?.join('\n')).toContain( + 'injected transaction cleanup failure' + ); + expect(result.recoveryPath).toBeDefined(); + expect(fs.existsSync(result.recoveryPath!)).toBe(true); + }); + + it('plans stale owned targets during dry run and removes them only on apply', async () => { + const outputRoot = path.join(tempDir, 'generated'); + const staleOutput = path.join(outputRoot, 'stale'); + const currentOutput = path.join(outputRoot, 'current'); + const seeded = await writeGeneratedFiles( + [{ path: 'owned.ts', content: 'export const stale = true;\n' }], + staleOutput, + [], + { formatFiles: false, showProgress: false } + ); + expect(seeded.success).toBe(true); + fs.writeFileSync( + path.join(outputRoot, TARGETS_MANIFEST), + `${JSON.stringify(['stale'])}\n` + ); + + const dryRun = await generateMulti({ + configs: { + current: { + schemaFile: EXAMPLE_SCHEMA, + output: currentOutput, + orm: true, + docs: false, + }, + }, + cwd: tempDir, + cleanStaleTargets: true, + dryRun: true, + onProgress: () => undefined, + }); + + expect(dryRun.hasError).toBe(false); + expect(fs.existsSync(path.join(staleOutput, 'owned.ts'))).toBe(true); + expect(dryRun.fileChanges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'owned.ts', + action: 'delete', + }), + ]) + ); + + const applied = await generateMulti({ + configs: { + current: { + schemaFile: EXAMPLE_SCHEMA, + output: currentOutput, + orm: true, + docs: false, + }, + }, + cwd: tempDir, + cleanStaleTargets: true, + onProgress: () => undefined, + }); + + expect(applied.hasError).toBe(false); + expect(fs.existsSync(staleOutput)).toBe(false); + expect(fs.existsSync(path.join(currentOutput, 'index.ts'))).toBe(true); + expect( + JSON.parse( + fs.readFileSync(path.join(outputRoot, TARGETS_MANIFEST), 'utf8') + ) + ).toEqual(['current']); + }); + + it('rejects traversal entries in the target manifest without touching siblings', async () => { + const outputRoot = path.join(tempDir, 'generated'); + const victim = path.join(tempDir, 'victim'); + fs.mkdirSync(outputRoot, { recursive: true }); + const seeded = await writeGeneratedFiles( + [{ path: 'owned.ts', content: 'export const victim = true;\n' }], + victim, + [], + { formatFiles: false, showProgress: false } + ); + expect(seeded.success).toBe(true); + fs.writeFileSync( + path.join(outputRoot, TARGETS_MANIFEST), + `${JSON.stringify(['../victim'])}\n` + ); + + const result = await generateMulti({ + configs: { + current: { + schemaFile: EXAMPLE_SCHEMA, + output: path.join(outputRoot, 'current'), + orm: true, + docs: false, + }, + }, + cwd: tempDir, + cleanStaleTargets: true, + onProgress: () => undefined, + }); + + expect(result.hasError).toBe(true); + expect(result.results[0].result.message).toContain( + 'Invalid target ownership manifest' + ); + expect(fs.existsSync(path.join(victim, 'owned.ts'))).toBe(true); + expect(fs.existsSync(path.join(outputRoot, 'current'))).toBe(false); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/schema-only.test.ts b/graphql/codegen/src/__tests__/codegen/schema-only.test.ts index 87cd7191f2..d435429d14 100644 --- a/graphql/codegen/src/__tests__/codegen/schema-only.test.ts +++ b/graphql/codegen/src/__tests__/codegen/schema-only.test.ts @@ -2,11 +2,12 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { runCodegenOperation } from '../../cli/handler'; import { generate } from '../../core/generate'; const EXAMPLE_SCHEMA = path.resolve( __dirname, - '../../../examples/example.schema.graphql', + '../../../examples/example.schema.graphql' ); describe('generate() with schema.enabled', () => { @@ -78,4 +79,100 @@ describe('generate() with schema.enabled', () => { expect(result.success).toBe(true); expect(fs.existsSync(path.join(nestedDir, 'schema.graphql'))).toBe(true); }); + + it('does not create the schema output directory during a dry run', async () => { + const outputDir = path.join(tempDir, 'dry-run-output'); + + const result = await generate({ + schemaFile: EXAMPLE_SCHEMA, + schema: { enabled: true, output: outputDir }, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.filesWritten).toEqual([]); + expect(result.planFingerprint).toMatch(/^[a-f0-9]{64}$/); + expect(result.fileChanges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'schema.graphql', action: 'create' }), + ]) + ); + expect(fs.existsSync(outputDir)).toBe(false); + }); + + it('resolves relative source and output paths against an explicit cwd', async () => { + const cwd = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(cwd, 'schemas'), { recursive: true }); + fs.copyFileSync(EXAMPLE_SCHEMA, path.join(cwd, 'schemas', 'app.graphql')); + const processCwd = process.cwd(); + + const result = await generate({ + cwd, + schemaFile: 'schemas/app.graphql', + schema: { enabled: true, output: 'generated' }, + }); + + expect(result.success).toBe(true); + expect(result.output).toBe(path.join(cwd, 'generated')); + expect(fs.existsSync(path.join(cwd, 'generated', 'schema.graphql'))).toBe( + true + ); + expect(process.cwd()).toBe(processCwd); + }); + + it('preserves rollback recovery evidence through the operation boundary', async () => { + const source = path.join(tempDir, 'schema.graphql'); + const output = path.join(tempDir, 'generated'); + fs.copyFileSync(EXAMPLE_SCHEMA, source); + + const initial = await runCodegenOperation( + { + schemaFile: source, + schemaEnabled: true, + schemaOutput: output, + }, + { cwd: tempDir } + ); + expect(initial.hasError).toBe(false); + fs.appendFileSync( + source, + '\nextend type Query { recoveryProbe: String }\n' + ); + + const nativeFs = require('node:fs') as typeof fs; + const originalRename = nativeFs.renameSync; + let renameCall = 0; + const renameSpy = jest + .spyOn(nativeFs, 'renameSync') + .mockImplementation((from, to) => { + renameCall += 1; + if (renameCall === 2) throw new Error('injected commit failure'); + if (renameCall === 3) throw new Error('injected restore failure'); + return originalRename(from, to); + }); + + let operation: Awaited>; + try { + operation = await runCodegenOperation( + { + schemaFile: source, + schemaEnabled: true, + schemaOutput: output, + }, + { cwd: tempDir } + ); + } finally { + renameSpy.mockRestore(); + } + + expect(operation.hasError).toBe(true); + expect(operation.recoveryPath).toBeDefined(); + expect(fs.existsSync(operation.recoveryPath!)).toBe(true); + expect(operation.rollbackErrors?.join('\n')).toContain( + 'injected restore failure' + ); + expect(operation.results[0].result.recoveryPath).toBe( + operation.recoveryPath + ); + }); }); diff --git a/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts b/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts index cf39779a8c..2f1a3565fd 100644 --- a/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts +++ b/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts @@ -2,7 +2,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { writeGeneratedFiles } from '../../core/output'; +import { + GENERATED_FILES_MANIFEST, + writeGeneratedFileJobs, + writeGeneratedFiles, +} from '../../core/output'; describe('writeGeneratedFiles', () => { let tempDir: string; @@ -15,12 +19,23 @@ describe('writeGeneratedFiles', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('removes stale TypeScript files when pruneStaleFiles is enabled', async () => { + it('removes only previously owned files when pruning is enabled', async () => { const staleRoot = path.join(tempDir, 'stale.ts'); const staleNested = path.join(tempDir, 'nested', 'old.ts'); - fs.mkdirSync(path.dirname(staleNested), { recursive: true }); - fs.writeFileSync(staleRoot, 'export const stale = true;\n'); - fs.writeFileSync(staleNested, 'export const old = true;\n'); + const initial = await writeGeneratedFiles( + [ + { path: 'stale.ts', content: 'export const stale = true;\n' }, + { path: 'nested/old.ts', content: 'export const old = true;\n' }, + ], + tempDir, + [], + { + showProgress: false, + formatFiles: false, + pruneStaleFiles: true, + } + ); + expect(initial.success).toBe(true); const result = await writeGeneratedFiles( [{ path: 'nested/new.ts', content: 'export const fresh = true;\n' }], @@ -30,18 +45,21 @@ describe('writeGeneratedFiles', () => { showProgress: false, formatFiles: false, pruneStaleFiles: true, - }, + } ); expect(result.success).toBe(true); expect(fs.existsSync(staleRoot)).toBe(false); expect(fs.existsSync(staleNested)).toBe(false); expect(fs.existsSync(path.join(tempDir, 'nested', 'new.ts'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, GENERATED_FILES_MANIFEST))).toBe( + true + ); }); - it('keeps existing files when pruneStaleFiles is disabled', async () => { - const staleRoot = path.join(tempDir, 'stale.ts'); - fs.writeFileSync(staleRoot, 'export const stale = true;\n'); + it('keeps unowned files even when pruning is enabled', async () => { + const handwritten = path.join(tempDir, 'handwritten.ts'); + fs.writeFileSync(handwritten, 'export const userOwned = true;\n'); const result = await writeGeneratedFiles( [{ path: 'fresh.ts', content: 'export const fresh = true;\n' }], @@ -50,12 +68,455 @@ describe('writeGeneratedFiles', () => { { showProgress: false, formatFiles: false, - pruneStaleFiles: false, - }, + pruneStaleFiles: true, + } ); expect(result.success).toBe(true); - expect(fs.existsSync(staleRoot)).toBe(true); + expect(fs.existsSync(handwritten)).toBe(true); expect(fs.existsSync(path.join(tempDir, 'fresh.ts'))).toBe(true); }); + + it('reports a conflict instead of overwriting an unowned destination', async () => { + const destination = path.join(tempDir, 'model.ts'); + fs.writeFileSync(destination, 'export const handwritten = true;\n'); + + const result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const generated = true;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + + expect(result.success).toBe(false); + expect(result.plan?.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'model.ts', + action: 'conflict', + reason: 'unowned-existing-file', + }), + ]) + ); + expect(fs.readFileSync(destination, 'utf8')).toContain('handwritten'); + }); + + it('does not claim an identical unowned file or prune it later', async () => { + const destination = path.join(tempDir, 'model.ts'); + fs.writeFileSync(destination, 'export const same = true;\n'); + + const result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const same = true;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + + expect(result.success).toBe(false); + expect(result.plan?.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'model.ts', + action: 'conflict', + reason: 'unowned-existing-file', + }), + ]) + ); + expect(fs.existsSync(path.join(tempDir, GENERATED_FILES_MANIFEST))).toBe( + false + ); + + const prune = await writeGeneratedFiles([], tempDir, [], { + showProgress: false, + formatFiles: false, + pruneStaleFiles: true, + }); + expect(prune.success).toBe(true); + expect(fs.readFileSync(destination, 'utf8')).toContain('same = true'); + }); + + it('never lets the generated-file overwrite flag replace an unowned file', async () => { + const destination = path.join(tempDir, 'model.ts'); + fs.writeFileSync(destination, 'export const handwritten = true;\n'); + + const result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const generated = true;\n' }], + tempDir, + [], + { + showProgress: false, + formatFiles: false, + overwriteModifiedGenerated: true, + confirmOverwrite: true, + } + ); + + expect(result.success).toBe(false); + expect(result.plan?.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'model.ts', + action: 'conflict', + reason: 'unowned-existing-file', + }), + ]) + ); + expect(fs.readFileSync(destination, 'utf8')).toContain('handwritten'); + }); + + it('refuses to traverse a symbolic-link ancestor below the output root', async () => { + const output = path.join(tempDir, 'output'); + const external = path.join(tempDir, 'external'); + fs.mkdirSync(output); + fs.mkdirSync(external); + fs.symlinkSync(external, path.join(output, 'nested')); + + const result = await writeGeneratedFiles( + [ + { + path: 'nested/escaped.ts', + content: 'export const escaped = true;\n', + }, + ], + output, + [], + { showProgress: false, formatFiles: false } + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0]).toContain('traverses a symlink'); + expect(fs.existsSync(path.join(external, 'escaped.ts'))).toBe(false); + }); + + it('rejects ownership-manifest paths outside the output root', async () => { + const victim = path.join( + path.dirname(tempDir), + `${path.basename(tempDir)}-victim.ts` + ); + fs.writeFileSync(victim, 'export const victim = true;\n'); + fs.writeFileSync( + path.join(tempDir, GENERATED_FILES_MANIFEST), + `${JSON.stringify({ + version: 1, + generator: '@constructive-io/graphql-codegen', + files: { + [`../${path.basename(victim)}`]: { sha256: 'a'.repeat(64) }, + }, + })}\n` + ); + + try { + const result = await writeGeneratedFiles( + [{ path: 'safe.ts', content: 'export const safe = true;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0]).toContain('invalid shape'); + expect(fs.readFileSync(victim, 'utf8')).toContain('victim = true'); + } finally { + fs.rmSync(victim, { force: true }); + } + }); + + it('resolves an explicitly symlinked output root before staging', async () => { + const actualOutput = path.join(tempDir, 'actual-output'); + const linkedOutput = path.join(tempDir, 'linked-output'); + fs.mkdirSync(actualOutput); + fs.symlinkSync(actualOutput, linkedOutput); + + const result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const model = true;\n' }], + linkedOutput, + [], + { showProgress: false, formatFiles: false } + ); + + expect(result.success).toBe(true); + expect(result.plan?.outputDir).toBe(fs.realpathSync(actualOutput)); + expect(fs.existsSync(path.join(actualOutput, 'model.ts'))).toBe(true); + }); + + it('requires explicit overwrite and confirmation for modified generated files', async () => { + const generated = path.join(tempDir, 'model.ts'); + const initial = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 1;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + expect(initial.success).toBe(true); + fs.writeFileSync(generated, 'export const manuallyEdited = true;\n'); + + const withoutConfirmation = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 2;\n' }], + tempDir, + [], + { + showProgress: false, + formatFiles: false, + overwriteModifiedGenerated: true, + } + ); + expect(withoutConfirmation.success).toBe(false); + expect(withoutConfirmation.errors?.[0]).toContain('confirmOverwrite'); + + const confirmed = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 2;\n' }], + tempDir, + [], + { + showProgress: false, + formatFiles: false, + overwriteModifiedGenerated: true, + confirmOverwrite: true, + } + ); + expect(confirmed.success).toBe(true); + expect(fs.readFileSync(generated, 'utf8')).toContain('version = 2'); + }); + + it('returns a complete plan without creating the output directory on dry run', async () => { + const outputDir = path.join(tempDir, 'does-not-exist'); + + const result = await writeGeneratedFiles( + [{ path: 'nested/model.ts', content: 'export const model = true;\n' }], + outputDir, + [], + { + showProgress: false, + formatFiles: false, + pruneStaleFiles: true, + dryRun: true, + } + ); + + expect(result.success).toBe(true); + expect(result.planFingerprint).toMatch(/^[a-f0-9]{64}$/); + expect(result.plan?.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'nested/model.ts', + action: 'create', + }), + expect.objectContaining({ + path: GENERATED_FILES_MANIFEST, + action: 'create', + }), + ]) + ); + expect(fs.existsSync(outputDir)).toBe(false); + }); + + it('adopts and updates legacy generated files with a recognized marker', async () => { + const legacy = path.join(tempDir, 'legacy.ts'); + fs.writeFileSync( + legacy, + '/** @generated by @constructive-io/graphql-codegen */\nexport const old = true;\n' + ); + + const result = await writeGeneratedFiles( + [ + { + path: 'legacy.ts', + content: + '/** @generated by @constructive-io/graphql-codegen */\nexport const current = true;\n', + }, + ], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + + expect(result.success).toBe(true); + expect(result.plan?.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'legacy.ts', + action: 'update', + reason: 'legacy-generated-file', + }), + ]) + ); + }); + + it('retains backups when rollback restoration fails', async () => { + const generated = path.join(tempDir, 'model.ts'); + const initial = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 1;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + expect(initial.success).toBe(true); + + const nativeFs = require('node:fs') as typeof fs; + const originalRename = nativeFs.renameSync; + let renameCall = 0; + const renameSpy = jest + .spyOn(nativeFs, 'renameSync') + .mockImplementation((source, destination) => { + renameCall += 1; + if (renameCall === 2) throw new Error('injected commit failure'); + if (renameCall === 3) throw new Error('injected restore failure'); + return originalRename(source, destination); + }); + + let result: Awaited>; + try { + result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 2;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + } finally { + renameSpy.mockRestore(); + } + + expect(result.success).toBe(false); + expect(result.rollbackErrors?.join('\n')).toContain( + 'injected restore failure' + ); + expect(result.recoveryPath).toBeDefined(); + expect(fs.existsSync(result.recoveryPath!)).toBe(true); + expect( + fs.existsSync(path.join(result.recoveryPath!, 'backup', 'model.ts')) + ).toBe(true); + }); + + it('does not roll back committed files when transaction cleanup fails', async () => { + const generated = path.join(tempDir, 'model.ts'); + const initial = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 1;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + expect(initial.success).toBe(true); + + const nativeFs = require('node:fs') as typeof fs; + const originalRemove = nativeFs.rmSync; + let cleanupFailureInjected = false; + const removeSpy = jest + .spyOn(nativeFs, 'rmSync') + .mockImplementation((target, options) => { + if ( + !cleanupFailureInjected && + String(target).includes('.codegen-transaction-') + ) { + cleanupFailureInjected = true; + throw new Error('injected transaction cleanup failure'); + } + return originalRemove(target, options); + }); + + let result: Awaited>; + try { + result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 2;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + } finally { + removeSpy.mockRestore(); + } + + try { + expect(result.success).toBe(true); + expect(result.warnings?.join('\n')).toContain( + 'injected transaction cleanup failure' + ); + expect(result.recoveryPath).toBeDefined(); + expect(fs.existsSync(result.recoveryPath!)).toBe(true); + expect(fs.readFileSync(generated, 'utf8')).toContain('version = 2'); + } finally { + if (result.recoveryPath) { + fs.rmSync(result.recoveryPath, { recursive: true, force: true }); + } + } + }); + + it('preflights every output root before committing the first root', async () => { + const firstOutput = path.join(tempDir, 'first'); + const secondOutput = path.join(tempDir, 'second'); + fs.mkdirSync(secondOutput); + fs.writeFileSync( + path.join(secondOutput, 'model.ts'), + 'export const handwritten = true;\n' + ); + + const result = await writeGeneratedFileJobs( + [ + { + files: [ + { path: 'model.ts', content: 'export const first = true;\n' }, + ], + outputDir: firstOutput, + options: { formatFiles: false, showProgress: false }, + }, + { + files: [ + { path: 'model.ts', content: 'export const second = true;\n' }, + ], + outputDir: secondOutput, + options: { formatFiles: false, showProgress: false }, + }, + ], + { showProgress: false } + ); + + expect(result.success).toBe(false); + expect(fs.existsSync(path.join(firstOutput, 'model.ts'))).toBe(false); + expect( + fs.readFileSync(path.join(secondOutput, 'model.ts'), 'utf8') + ).toContain('handwritten'); + }); + + it('rolls back earlier output roots when a later commit fails', async () => { + const firstOutput = path.join(tempDir, 'first'); + const secondOutput = path.join(tempDir, 'second'); + const nativeFs = require('node:fs') as typeof fs; + const originalRename = nativeFs.renameSync; + let generatedRename = 0; + const renameSpy = jest + .spyOn(nativeFs, 'renameSync') + .mockImplementation((source, destination) => { + if (String(source).includes(`${path.sep}staged${path.sep}`)) { + generatedRename += 1; + if (generatedRename === 2) { + throw new Error('injected second-root failure'); + } + } + return originalRename(source, destination); + }); + + let result: Awaited>; + try { + result = await writeGeneratedFileJobs( + [ + { + files: [{ path: 'model.ts', content: 'first\n' }], + outputDir: firstOutput, + options: { formatFiles: false, showProgress: false }, + }, + { + files: [{ path: 'model.ts', content: 'second\n' }], + outputDir: secondOutput, + options: { formatFiles: false, showProgress: false }, + }, + ], + { showProgress: false } + ); + } finally { + renameSpy.mockRestore(); + } + + expect(result.success).toBe(false); + expect(fs.existsSync(path.join(firstOutput, 'model.ts'))).toBe(false); + expect(fs.existsSync(path.join(secondOutput, 'model.ts'))).toBe(false); + }); }); diff --git a/graphql/codegen/src/__tests__/config/resolve-config.test.ts b/graphql/codegen/src/__tests__/config/resolve-config.test.ts index 72a346bd08..0df0e349b3 100644 --- a/graphql/codegen/src/__tests__/config/resolve-config.test.ts +++ b/graphql/codegen/src/__tests__/config/resolve-config.test.ts @@ -1,3 +1,8 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { loadAndResolveConfig } from '../../core/config'; import type { GraphQLSDKConfigTarget } from '../../types/config'; import { DEFAULT_CONFIG, @@ -19,7 +24,7 @@ describe('config resolution', () => { expect(resolved.tables.include).toEqual(DEFAULT_CONFIG.tables.include); expect(resolved.queries.exclude).toEqual(DEFAULT_CONFIG.queries.exclude); expect(resolved.queries.systemExclude).toEqual( - DEFAULT_CONFIG.queries.systemExclude, + DEFAULT_CONFIG.queries.systemExclude ); }); @@ -61,4 +66,24 @@ describe('config resolution', () => { table: { parent: 'database', foreignKey: 'databaseId' }, }); }); + + it('discovers a config relative to an explicit cwd', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'codegen-config-cwd-') + ); + try { + fs.writeFileSync( + path.join(tempDir, 'graphql-codegen.config.ts'), + "export default { schemaFile: './schema.graphql', output: './generated', orm: true };\n" + ); + + const result = await loadAndResolveConfig({ cwd: tempDir }); + + expect(result.success).toBe(true); + expect(result.config?.schemaFile).toBe('./schema.graphql'); + expect(result.config?.output).toBe('./generated'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/graphql/codegen/src/__tests__/config/security-policy.test.ts b/graphql/codegen/src/__tests__/config/security-policy.test.ts new file mode 100644 index 0000000000..67960b8d3f --- /dev/null +++ b/graphql/codegen/src/__tests__/config/security-policy.test.ts @@ -0,0 +1,177 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { findConfigFile, loadConfigFile } from '../../core/config'; +import { + databaseForDisplay, + endpointForDisplay, + reportConfigSensitiveValues, +} from '../../core/sensitive-values'; + +describe('codegen configuration security policy', () => { + let cwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-config-policy-')); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it('loads declarative JSON without permitting executable configuration', async () => { + const configPath = path.join(cwd, 'graphql-codegen.config.JSON'); + fs.writeFileSync( + configPath, + JSON.stringify({ schemaFile: './schema.graphql', orm: true }) + ); + + await expect( + loadConfigFile(configPath, cwd, {}, { allowExecutableConfig: false }) + ).resolves.toMatchObject({ + success: true, + config: { schemaFile: './schema.graphql', orm: true }, + }); + }); + + it('discovers declarative JSON before a colocated executable config', () => { + const jsonPath = path.join(cwd, 'graphql-codegen.config.json'); + fs.writeFileSync(jsonPath, '{}\n'); + fs.writeFileSync( + path.join(cwd, 'graphql-codegen.config.ts'), + 'export default {};\n' + ); + + expect(findConfigFile(cwd)).toBe(jsonPath); + }); + + it('rejects executable configuration before evaluating it', async () => { + const marker = path.join(cwd, 'executed'); + const configPath = path.join(cwd, 'graphql-codegen.config.ts'); + fs.writeFileSync( + configPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'executed');\nexport default {};\n` + ); + + await expect( + loadConfigFile(configPath, cwd, {}, { allowExecutableConfig: false }) + ).resolves.toMatchObject({ + success: false, + code: 'CODEGEN_CONFIG_EXECUTABLE_UNSUPPORTED', + }); + expect(fs.existsSync(marker)).toBe(false); + }); + + it('rejects arrays as declarative config roots', async () => { + const configPath = path.join(cwd, 'graphql-codegen.config.json'); + fs.writeFileSync(configPath, '[]\n'); + + await expect( + loadConfigFile(configPath, cwd, {}, { allowExecutableConfig: false }) + ).resolves.toMatchObject({ + success: false, + code: 'CODEGEN_CONFIG_INVALID', + }); + }); + + it('retains explicit executable-config compatibility for the legacy adapter', async () => { + const configPath = path.join(cwd, 'graphql-codegen.config.ts'); + fs.writeFileSync( + configPath, + "export default { schemaFile: './schema.graphql', orm: true };\n" + ); + + await expect( + loadConfigFile(configPath, cwd, {}, { allowExecutableConfig: true }) + ).resolves.toMatchObject({ + success: true, + config: { schemaFile: './schema.graphql', orm: true }, + }); + }); + + it('recursively reports transport and database secrets', () => { + const endpointPassword = 'endpoint-password'; + const querySecret = 'endpoint-query-secret'; + const fragmentSecret = 'endpoint-fragment-secret'; + const authorizationSecret = 'authorization-secret'; + const customHeaderSecret = 'custom-header-secret'; + const cookieSecret = 'cookie-secret'; + const databasePassword = 'database-password'; + const connectionString = + 'postgresql://db-user:connection-password@db.example.com/app?sslkey=private-key'; + const reported: string[] = []; + + reportConfigSensitiveValues( + { + endpoint: `https://user:${endpointPassword}@api.example.com/graphql?api_key=${querySecret}#${fragmentSecret}`, + authorization: `Bearer ${authorizationSecret}`, + headers: { + 'X-Custom': ` ${customHeaderSecret} `, + Cookie: `session=${cookieSecret}; theme=dark`, + }, + db: { + config: { + password: databasePassword, + database: connectionString, + }, + }, + }, + (value) => reported.push(value) + ); + + expect(reported).toEqual( + expect.arrayContaining([ + endpointPassword, + querySecret, + fragmentSecret, + `Bearer ${authorizationSecret}`, + authorizationSecret, + customHeaderSecret, + cookieSecret, + databasePassword, + connectionString, + 'connection-password', + 'private-key', + ]) + ); + }); + + it('reports encoded URL values and the Basic authorization sent by Node', () => { + const reported: string[] = []; + + reportConfigSensitiveValues( + { + endpoint: + 'http://us%65r:p%40ss@api.example.com/graphql?tenant=s3cr%65t', + }, + (value) => reported.push(value) + ); + + expect(reported).toEqual( + expect.arrayContaining([ + 'us%65r', + 'p%40ss', + 'user', + 'p@ss', + 'user:p@ss', + 'Basic dXNlcjpwQHNz', + 's3cr%65t', + 's3cret', + ]) + ); + }); + + it('projects endpoint and database URLs without private components', () => { + expect( + endpointForDisplay( + 'https://user:password@api.example.com/graphql?token=secret#private' + ) + ).toBe('https://api.example.com/graphql'); + expect( + databaseForDisplay( + 'postgresql://user:password@db.example.com:5432/app?sslkey=secret#private' + ) + ).toBe('postgresql://db.example.com:5432/app'); + }); +}); diff --git a/graphql/codegen/src/__tests__/introspect/fetch-schema-cancellation.test.ts b/graphql/codegen/src/__tests__/introspect/fetch-schema-cancellation.test.ts new file mode 100644 index 0000000000..79cde1f87c --- /dev/null +++ b/graphql/codegen/src/__tests__/introspect/fetch-schema-cancellation.test.ts @@ -0,0 +1,68 @@ +import http from 'node:http'; + +import { fetchSchema } from '../../core/introspect/fetch-schema'; + +describe('fetchSchema cancellation', () => { + let server: http.Server | undefined; + + afterEach(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; + }); + + it('destroys an in-flight request and rejects with the caller abort reason', async () => { + let requestStarted!: () => void; + const started = new Promise((resolve) => { + requestStarted = resolve; + }); + let requestClosed!: () => void; + const closed = new Promise((resolve) => { + requestClosed = resolve; + }); + + server = http.createServer((request) => { + requestStarted(); + request.once('close', requestClosed); + // Deliberately leave the response open. Cancellation must close it. + }); + await new Promise((resolve) => + server!.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture server did not expose a TCP port.'); + } + + const controller = new AbortController(); + const reason = new Error('caller cancelled introspection'); + reason.name = 'AbortError'; + const pending = fetchSchema({ + endpoint: `http://127.0.0.1:${address.port}/graphql`, + signal: controller.signal, + timeout: 10_000, + }); + + await started; + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + await closed; + }); + + it('rejects a pre-aborted request without opening a connection', async () => { + const controller = new AbortController(); + const reason = new Error('already cancelled'); + reason.name = 'AbortError'; + controller.abort(reason); + + await expect( + fetchSchema({ + endpoint: 'http://127.0.0.1:1/graphql', + signal: controller.signal, + }) + ).rejects.toBe(reason); + }); +}); diff --git a/graphql/codegen/src/cli/handler.ts b/graphql/codegen/src/cli/handler.ts index 3a8d3573f4..358db057d1 100644 --- a/graphql/codegen/src/cli/handler.ts +++ b/graphql/codegen/src/cli/handler.ts @@ -1,15 +1,32 @@ /** - * Shared codegen CLI handler + * Shared codegen operation and legacy terminal adapter. * - * Contains the core logic used by both `graphql-codegen` and `cnc codegen`. - * Both CLIs delegate to runCodegenHandler() after handling their own - * help/version flags. + * `runCodegenOperation` is the reusable boundary: it never prints, prompts, + * exits, or changes process-global state. The legacy CLI adapter below keeps + * the existing human presentation while CNC migrates to the command runtime. */ +import path from 'node:path'; + import type { Question } from 'inquirerer'; import { findConfigFile, loadConfigFile } from '../core/config'; -import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, generate, generateMulti } from '../core/generate'; -import type { GraphQLSDKConfigTarget } from '../types/config'; +import { throwIfAborted } from '../core/cancellation'; +import { + expandApiNamesToMultiTarget, + expandSchemaDirToMultiTarget, + generate, + generateMulti, + type GenerateProgressEvent, + type GenerateResult, +} from '../core/generate'; +import type { FileChange, GenerationPlan } from '../core/output'; +import { + isSafeCodegenEndpoint, + reportConfigSensitiveValues, + type SensitiveValueReporter, +} from '../core/sensitive-values'; +import type { GraphQLSDKConfigTarget, SchemaConfig } from '../types/config'; +import { mergeConfig } from '../types/config'; import { buildDbConfig, buildGenerateOptions, @@ -22,41 +39,142 @@ import { } from './shared'; interface Prompter { - prompt(argv: Record, questions: Question[]): Promise>; + prompt( + argv: Record, + questions: Question[] + ): Promise>; } -export async function runCodegenHandler( - argv: Record, - prompter: Prompter, -): Promise { - const args = camelizeArgv(argv as Record); +export class CodegenOperationError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'CodegenOperationError'; + this.code = code; + } +} + +export interface RunCodegenOperationOptions { + cwd: string; + env?: Readonly>; + prompter?: Prompter; + overwriteModifiedGenerated?: boolean; + yes?: boolean; + onProgress?: (event: GenerateProgressEvent) => void; + /** Receives secrets discovered while resolving direct or file configuration. */ + onSensitiveValue?: SensitiveValueReporter; + /** + * Permit executable TypeScript/JavaScript configuration imports. + * Disabled by default; only the legacy standalone CLI enables this. + */ + allowExecutableConfig?: boolean; + /** Reject invalid or credential-bearing endpoint URLs before progress. */ + requireSafeEndpoints?: boolean; + signal?: AbortSignal; +} - const schemaConfig = args.schemaEnabled +export interface CodegenOperationResult { + results: Array<{ name?: string; result: GenerateResult }>; + hasError: boolean; + plans: GenerationPlan[]; + planFingerprint?: string; + fileChanges: FileChange[]; + warnings?: string[]; + recoveryPath?: string; + rollbackErrors?: string[]; +} + +const collectResult = ( + result: GenerateResult, + name?: string +): CodegenOperationResult => ({ + results: [{ ...(name === undefined ? {} : { name }), result }], + hasError: !result.success, + plans: result.plans ?? [], + ...(result.planFingerprint === undefined + ? {} + : { planFingerprint: result.planFingerprint }), + fileChanges: result.fileChanges ?? [], + ...(result.warnings === undefined ? {} : { warnings: result.warnings }), + ...(result.recoveryPath === undefined + ? {} + : { recoveryPath: result.recoveryPath }), + ...(result.rollbackErrors === undefined + ? {} + : { rollbackErrors: result.rollbackErrors }), +}); + +const schemaOptions = ( + args: Record +): SchemaConfig | undefined => + args.schemaEnabled ? { enabled: true, ...(args.schemaOutput ? { output: String(args.schemaOutput) } : {}), - ...(args.schemaFilename ? { filename: String(args.schemaFilename) } : {}), + ...(args.schemaFilename + ? { filename: String(args.schemaFilename) } + : {}), } : undefined; +const assertSafeResolvedEndpoints = ( + targets: readonly GraphQLSDKConfigTarget[], + options: RunCodegenOperationOptions +): void => { + if (options.requireSafeEndpoints !== true) return; + for (const target of targets) { + if ( + target.endpoint !== undefined && + !isSafeCodegenEndpoint(target.endpoint) + ) { + throw new CodegenOperationError( + 'CODEGEN_ENDPOINT_INVALID', + 'The codegen endpoint must be an absolute HTTP or HTTPS URL without embedded credentials, credential-like query parameters, or a fragment.' + ); + } + } +}; + +/** Execute codegen without terminal or process-global side effects. */ +export async function runCodegenOperation( + argv: Record, + options: RunCodegenOperationOptions +): Promise { + throwIfAborted(options.signal); + const cwd = path.resolve(options.cwd); + const args = camelizeArgv(argv as Record); + reportConfigSensitiveValues(args, options.onSensitiveValue); + const onProgress = options.onProgress ?? (() => undefined); + const schema = schemaOptions(args); + const dryRun = args.dryRun === true; const hasSourceFlags = Boolean( - args.endpoint || args.schemaFile || args.schemaDir || args.schemas || args.apiNames + args.endpoint || + args.schemaFile || + args.schemaDir || + args.schemas || + args.apiNames ); const configPath = (args.config as string | undefined) || - (!hasSourceFlags ? findConfigFile() : undefined); + (!hasSourceFlags ? (findConfigFile(cwd) ?? undefined) : undefined); const targetName = args.target as string | undefined; - let fileConfig: GraphQLSDKConfigTarget = {}; if (configPath) { - const loaded = await loadConfigFile(configPath); + const loaded = await loadConfigFile(configPath, cwd, options.env ?? {}, { + allowExecutableConfig: options.allowExecutableConfig === true, + }); + throwIfAborted(options.signal); if (!loaded.success) { - console.error('x', loaded.error); - process.exit(1); + throw new CodegenOperationError( + loaded.code ?? 'CODEGEN_CONFIG_INVALID', + loaded.error ?? 'Unable to load the codegen configuration.' + ); } const config = loaded.config as Record; + reportConfigSensitiveValues(config, options.onSensitiveValue); const isMulti = !( 'endpoint' in config || 'schemaFile' in config || @@ -66,66 +184,150 @@ export async function runCodegenHandler( if (isMulti) { const targets = config as Record; - if (targetName && !targets[targetName]) { - console.error( - 'x', - `Target "${targetName}" not found. Available: ${Object.keys(targets).join(', ')}`, + throw new CodegenOperationError( + 'CODEGEN_TARGET_NOT_FOUND', + `Target "${targetName}" not found. Available: ${Object.keys(targets).join(', ')}` ); - process.exit(1); } - const cliOptions = buildDbConfig( - normalizeCodegenListOptions(args), - ); - + const cliOverrides = buildDbConfig( + normalizeCodegenListOptions(args) + ) as Partial; + reportConfigSensitiveValues(cliOverrides, options.onSensitiveValue); const selectedTargets = targetName ? { [targetName]: targets[targetName] } : targets; - - const { results, hasError } = await generateMulti({ + const resolvedTargets = Object.values(selectedTargets).map((target) => + mergeConfig(target, cliOverrides) + ); + reportConfigSensitiveValues(resolvedTargets, options.onSensitiveValue); + assertSafeResolvedEndpoints(resolvedTargets, options); + const generated = await generateMulti({ configs: selectedTargets, - cliOverrides: cliOptions as Partial, - schema: schemaConfig, + cliOverrides, + schema, + cwd, + dryRun, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + yes: options.yes, + onProgress, + signal: options.signal, + env: options.env ?? {}, }); - - for (const { name, result } of results) { - console.log(`\n[${name}]`); - printResult(result); - } - - if (hasError) process.exit(1); - return; + return { + results: generated.results, + hasError: generated.hasError, + plans: generated.plans ?? [], + ...(generated.planFingerprint === undefined + ? {} + : { planFingerprint: generated.planFingerprint }), + fileChanges: generated.fileChanges ?? [], + ...(generated.warnings === undefined + ? {} + : { warnings: generated.warnings }), + ...(generated.recoveryPath === undefined + ? {} + : { recoveryPath: generated.recoveryPath }), + ...(generated.rollbackErrors === undefined + ? {} + : { rollbackErrors: generated.rollbackErrors }), + }; } fileConfig = config as GraphQLSDKConfigTarget; } const seeded = seedArgvFromConfig(args, fileConfig); - const answers = hasResolvedCodegenSource(seeded) - ? seeded - : await prompter.prompt(seeded, codegenQuestions); - const options = buildGenerateOptions(answers, fileConfig); - - const expandedApi = expandApiNamesToMultiTarget(options); - const expandedDir = expandSchemaDirToMultiTarget(options); - const expanded = expandedApi || expandedDir; + let answers = seeded; + if (!hasResolvedCodegenSource(seeded)) { + if (!options.prompter) { + throw new CodegenOperationError( + 'CODEGEN_SOURCE_REQUIRED', + 'No codegen source was supplied. Use --endpoint, --schema-file, --schema-dir, --schemas, --api-names, or --config.' + ); + } + answers = await options.prompter.prompt(seeded, codegenQuestions); + throwIfAborted(options.signal); + } + + const generatedOptions = buildGenerateOptions(answers, fileConfig); + reportConfigSensitiveValues(generatedOptions, options.onSensitiveValue); + assertSafeResolvedEndpoints([generatedOptions], options); + const expanded = + expandApiNamesToMultiTarget(generatedOptions) || + expandSchemaDirToMultiTarget(generatedOptions, cwd); if (expanded) { - const { results, hasError } = await generateMulti({ + reportConfigSensitiveValues(expanded, options.onSensitiveValue); + assertSafeResolvedEndpoints(Object.values(expanded), options); + const generated = await generateMulti({ configs: expanded, - schema: schemaConfig, + schema, + cwd, + dryRun, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + yes: options.yes, + onProgress, + signal: options.signal, + env: options.env ?? {}, }); - for (const { name, result } of results) { - console.log(`\n[${name}]`); - printResult(result); - } - if (hasError) process.exit(1); - return; + return { + results: generated.results, + hasError: generated.hasError, + plans: generated.plans ?? [], + ...(generated.planFingerprint === undefined + ? {} + : { planFingerprint: generated.planFingerprint }), + fileChanges: generated.fileChanges ?? [], + ...(generated.warnings === undefined + ? {} + : { warnings: generated.warnings }), + ...(generated.recoveryPath === undefined + ? {} + : { recoveryPath: generated.recoveryPath }), + ...(generated.rollbackErrors === undefined + ? {} + : { rollbackErrors: generated.rollbackErrors }), + }; } const result = await generate({ - ...options, - ...(schemaConfig ? { schema: schemaConfig } : {}), + ...generatedOptions, + cwd, + dryRun, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + yes: options.yes, + onProgress, + signal: options.signal, + env: options.env ?? {}, + ...(schema ? { schema } : {}), }); - printResult(result); + return collectResult(result); +} + +/** Legacy human adapter retained for the standalone graphql-codegen CLI. */ +export async function runCodegenHandler( + argv: Record, + prompter: Prompter +): Promise { + try { + const operation = await runCodegenOperation(argv, { + cwd: process.cwd(), + env: { ...process.env }, + prompter, + allowExecutableConfig: true, + onProgress: ({ message }) => console.log(message), + }); + for (const { name, result } of operation.results) { + if (name) console.log(`\n[${name}]`); + printResult(result); + } + if (operation.hasError) process.exitCode = 1; + } catch (error) { + console.error( + 'x', + error instanceof Error ? error.message : 'Code generation failed.' + ); + process.exitCode = 1; + } } diff --git a/graphql/codegen/src/core/cancellation.ts b/graphql/codegen/src/core/cancellation.ts new file mode 100644 index 0000000000..af2c56f9a5 --- /dev/null +++ b/graphql/codegen/src/core/cancellation.ts @@ -0,0 +1,22 @@ +/** Return the caller-supplied abort reason, with a stable fallback for runtimes + * that do not populate AbortSignal.reason. */ +export function getAbortReason(signal: AbortSignal): unknown { + if (signal.reason !== undefined) return signal.reason; + const error = new Error('Operation cancelled.'); + error.name = 'AbortError'; + return error; +} + +/** Throw at explicit cancellation boundaries. */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw getAbortReason(signal); +} + +/** Cancellation must cross result-oriented APIs instead of becoming a + * GenerateResult failure. */ +export function rethrowIfCancelled(error: unknown, signal?: AbortSignal): void { + if (signal?.aborted) throw getAbortReason(signal); + if (error instanceof Error && error.name === 'AbortError') { + throw error; + } +} diff --git a/graphql/codegen/src/core/codegen/cli/docs-generator.ts b/graphql/codegen/src/core/codegen/cli/docs-generator.ts index 18b3ce4ef7..aee74d1c76 100644 --- a/graphql/codegen/src/core/codegen/cli/docs-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/docs-generator.ts @@ -1,5 +1,6 @@ import { toKebabCase } from 'inflekt'; +import { endpointForDisplay } from '../../sensitive-values'; import type { Table, Operation, TypeRegistry } from '../../../types/schema'; import { flattenArgs, @@ -19,11 +20,7 @@ import { buildSkillReference, } from '../docs-utils'; import type { GeneratedDocFile } from '../docs-utils'; -import { - getScalarFields, - getTableNames, - getPrimaryKeyInfo, -} from '../utils'; +import { getScalarFields, getTableNames, getPrimaryKeyInfo } from '../utils'; import { getFieldsWithDefaults } from './table-command-generator'; export { resolveDocsConfig } from '../docs-utils'; @@ -33,7 +30,7 @@ export function generateReadme( tables: Table[], customOperations: Operation[], toolName: string, - registry?: TypeRegistry, + registry?: TypeRegistry ): GeneratedDocFile { const lines: string[] = []; @@ -42,7 +39,9 @@ export function generateReadme( lines.push(''); lines.push('```bash'); lines.push(`# Create a context pointing at your GraphQL endpoint`); - lines.push(`${toolName} context create production --endpoint https://api.example.com/graphql`); + lines.push( + `${toolName} context create production --endpoint https://api.example.com/graphql` + ); lines.push(''); lines.push(`# Set the active context`); lines.push(`${toolName} context use production`); @@ -92,7 +91,9 @@ export function generateReadme( lines.push(''); lines.push('| Subcommand | Description |'); lines.push('|------------|-------------|'); - lines.push('| `set-token ` | Store bearer token for current context |'); + lines.push( + '| `set-token ` | Store bearer token for current context |' + ); lines.push('| `status` | Show auth status across all contexts |'); lines.push('| `logout` | Remove credentials for current context |'); lines.push(''); @@ -107,7 +108,9 @@ export function generateReadme( lines.push('| `list` | List all config values |'); lines.push('| `delete ` | Delete a config value |'); lines.push(''); - lines.push(`Variables are scoped to the active context and stored at \`~/.${toolName}/config/\`.`); + lines.push( + `Variables are scoped to the active context and stored at \`~/.${toolName}/config/\`.` + ); lines.push(''); if (tables.length > 0) { @@ -127,10 +130,12 @@ export function generateReadme( lines.push('| Subcommand | Description |'); lines.push('|------------|-------------|'); lines.push(`| \`list\` | List all ${singularName} records |`); - lines.push(`| \`find-first\` | Find first matching ${singularName} record |`); + lines.push( + `| \`find-first\` | Find first matching ${singularName} record |` + ); const readmeSpecialGroups = categorizeSpecialFields(table, registry); const readmeHasSearch = readmeSpecialGroups.some( - (g) => g.category === 'search' || g.category === 'embedding', + (g) => g.category === 'search' || g.category === 'embedding' ); if (readmeHasSearch) { lines.push(`| \`search \` | Search ${singularName} records |`); @@ -161,20 +166,32 @@ export function generateReadme( } lines.push(''); const defaultFields = getFieldsWithDefaults(table, registry); - const requiredCreate = editableFields.filter((f) => !defaultFields.has(f.name)); - const optionalCreate = editableFields.filter((f) => defaultFields.has(f.name)); + const requiredCreate = editableFields.filter( + (f) => !defaultFields.has(f.name) + ); + const optionalCreate = editableFields.filter((f) => + defaultFields.has(f.name) + ); if (requiredCreate.length > 0) { - lines.push(`**Required create fields:** ${requiredCreate.map((f) => `\`${f.name}\``).join(', ')}`); + lines.push( + `**Required create fields:** ${requiredCreate.map((f) => `\`${f.name}\``).join(', ')}` + ); } if (optionalCreate.length > 0) { - lines.push(`**Optional create fields (backend defaults):** ${optionalCreate.map((f) => `\`${f.name}\``).join(', ')}`); + lines.push( + `**Optional create fields (backend defaults):** ${optionalCreate.map((f) => `\`${f.name}\``).join(', ')}` + ); } if (requiredCreate.length === 0 && optionalCreate.length === 0) { - lines.push(`**Create fields:** ${editableFields.map((f) => `\`${f.name}\``).join(', ')}`); + lines.push( + `**Create fields:** ${editableFields.map((f) => `\`${f.name}\``).join(', ')}` + ); } const specialGroups = categorizeSpecialFields(table, registry); lines.push(...buildSpecialFieldsMarkdown(specialGroups)); - lines.push(...buildSearchExamplesMarkdown(specialGroups, toolName, kebab)); + lines.push( + ...buildSearchExamplesMarkdown(specialGroups, toolName, kebab) + ); lines.push(''); } } @@ -208,7 +225,9 @@ export function generateReadme( lines.push('## Output'); lines.push(''); - lines.push('All commands output JSON to stdout. Pipe to `jq` for formatting:'); + lines.push( + 'All commands output JSON to stdout. Pipe to `jq` for formatting:' + ); lines.push(''); lines.push('```bash'); lines.push(`${toolName} car list | jq '.[]'`); @@ -217,7 +236,9 @@ export function generateReadme( lines.push(''); lines.push('## Non-Interactive Mode'); lines.push(''); - lines.push('Use `--no-tty` to skip all interactive prompts (useful for scripts and CI):'); + lines.push( + 'Use `--no-tty` to skip all interactive prompts (useful for scripts and CI):' + ); lines.push(''); lines.push('```bash'); lines.push(`${toolName} --no-tty car create --name "Sedan" --year 2024`); @@ -236,7 +257,7 @@ export function generateAgentsDocs( tables: Table[], customOperations: Operation[], toolName: string, - _registry?: TypeRegistry, + _registry?: TypeRegistry ): GeneratedDocFile { const lines: string[] = []; const tableCount = tables.length; @@ -250,7 +271,9 @@ export function generateAgentsDocs( lines.push('## Stack'); lines.push(''); lines.push(`- Generated CLI for a GraphQL API (TypeScript)`); - lines.push(`- ${tableCount} table${tableCount !== 1 ? 's' : ''}${customOpCount > 0 ? `, ${customOpCount} custom operation${customOpCount !== 1 ? 's' : ''}` : ''}`); + lines.push( + `- ${tableCount} table${tableCount !== 1 ? 's' : ''}${customOpCount > 0 ? `, ${customOpCount} custom operation${customOpCount !== 1 ? 's' : ''}` : ''}` + ); lines.push(`- Config stored at \`~/.${toolName}/config/\` via appstash`); lines.push(''); @@ -265,7 +288,9 @@ export function generateAgentsDocs( lines.push('## Resources'); lines.push(''); - lines.push(`- **Full API reference:** [README.md](./README.md) — CRUD docs for all ${tableCount} tables`); + lines.push( + `- **Full API reference:** [README.md](./README.md) — CRUD docs for all ${tableCount} tables` + ); lines.push('- **Schema types:** [types.ts](./types.ts)'); lines.push(''); @@ -278,7 +303,9 @@ export function generateAgentsDocs( lines.push('## Boundaries'); lines.push(''); - lines.push('All files in this directory are generated. Do not edit manually.'); + lines.push( + 'All files in this directory are generated. Do not edit manually.' + ); lines.push(''); return { @@ -292,7 +319,7 @@ export function generateSkills( customOperations: Operation[], toolName: string, targetName: string, - registry?: TypeRegistry, + registry?: TypeRegistry ): GeneratedDocFile[] { const files: GeneratedDocFile[] = []; const skillName = `cli-${targetName}`; @@ -384,7 +411,7 @@ export function generateSkills( // Embedder reference (generated once when any table has Vector fields) const hasEmbeddings = tables.some((t) => - categorizeSpecialFields(t, registry).some((g) => g.category === 'embedding'), + categorizeSpecialFields(t, registry).some((g) => g.category === 'embedding') ); if (hasEmbeddings) { referenceNames.push('embedder'); @@ -448,17 +475,27 @@ export function generateSkills( const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); const createFlags = [ - ...editableFields.filter((f) => !defaultFields.has(f.name)).map((f) => `--${f.name} <${cleanTypeName(f.type.gqlType)}>`), - ...editableFields.filter((f) => defaultFields.has(f.name)).map((f) => `[--${f.name} <${cleanTypeName(f.type.gqlType)}>]`), + ...editableFields + .filter((f) => !defaultFields.has(f.name)) + .map((f) => `--${f.name} <${cleanTypeName(f.type.gqlType)}>`), + ...editableFields + .filter((f) => defaultFields.has(f.name)) + .map((f) => `[--${f.name} <${cleanTypeName(f.type.gqlType)}>]`), ].join(' '); referenceNames.push(kebab); const skillSpecialGroups = categorizeSpecialFields(table, registry); - const skillSpecialDesc = skillSpecialGroups.length > 0 - ? `CRUD operations for ${table.name} records via ${toolName} CLI\n\n` + - skillSpecialGroups.map((g) => `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}`).join('\n\n') - : `CRUD operations for ${table.name} records via ${toolName} CLI`; + const skillSpecialDesc = + skillSpecialGroups.length > 0 + ? `CRUD operations for ${table.name} records via ${toolName} CLI\n\n` + + skillSpecialGroups + .map( + (g) => + `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}` + ) + .join('\n\n') + : `CRUD operations for ${table.name} records via ${toolName} CLI`; files.push({ fileName: `${skillName}/references/${kebab}.md`, @@ -470,17 +507,29 @@ export function generateSkills( `${toolName} ${kebab} list --where.. --orderBy `, `${toolName} ${kebab} list --limit 10 --after `, `${toolName} ${kebab} find-first --where.. `, - ...(skillSpecialGroups.some((g) => g.category === 'search' || g.category === 'embedding') + ...(skillSpecialGroups.some( + (g) => g.category === 'search' || g.category === 'embedding' + ) ? [`${toolName} ${kebab} search `] : []), `${toolName} ${kebab} get --${pk.name} <${cleanTypeName(pk.gqlType)}>`, `${toolName} ${kebab} create ${createFlags}`, `${toolName} ${kebab} update --${pk.name} <${cleanTypeName(pk.gqlType)}> ${editableFields.map((f) => `[--${f.name} <${cleanTypeName(f.type.gqlType)}>]`).join(' ')}`, `${toolName} ${kebab} delete --${pk.name} <${cleanTypeName(pk.gqlType)}>`, - ...(table.query?.bulkInsert ? [`${toolName} ${kebab} bulk-create --data ''`] : []), - ...(table.query?.bulkUpsert ? [`${toolName} ${kebab} bulk-upsert --data ''`] : []), - ...(table.query?.bulkUpdate ? [`${toolName} ${kebab} bulk-update --where '' --data ''`] : []), - ...(table.query?.bulkDelete ? [`${toolName} ${kebab} bulk-delete --where ''`] : []), + ...(table.query?.bulkInsert + ? [`${toolName} ${kebab} bulk-create --data ''`] + : []), + ...(table.query?.bulkUpsert + ? [`${toolName} ${kebab} bulk-upsert --data ''`] + : []), + ...(table.query?.bulkUpdate + ? [ + `${toolName} ${kebab} bulk-update --where '' --data ''`, + ] + : []), + ...(table.query?.bulkDelete + ? [`${toolName} ${kebab} bulk-delete --where ''`] + : []), ], examples: [ { @@ -497,7 +546,9 @@ export function generateSkills( }, { description: `Find first matching ${singularName}`, - code: [`${toolName} ${kebab} find-first --where.${pk.name}.equalTo `], + code: [ + `${toolName} ${kebab} find-first --where.${pk.name}.equalTo `, + ], }, { description: `List ${singularName} records with field selection`, @@ -505,14 +556,14 @@ export function generateSkills( }, { description: `List ${singularName} records with filtering and ordering`, - code: [`${toolName} ${kebab} list --where.${pk.name}.equalTo --orderBy ${pk.name.replace(/([A-Z])/g, '_$1').toUpperCase()}_ASC`], + code: [ + `${toolName} ${kebab} list --where.${pk.name}.equalTo --orderBy ${pk.name.replace(/([A-Z])/g, '_$1').toUpperCase()}_ASC`, + ], }, ...buildSearchExamples(skillSpecialGroups, toolName, kebab), { description: `Create a ${singularName}`, - code: [ - `${toolName} ${kebab} create ${createFlags}`, - ], + code: [`${toolName} ${kebab} create ${createFlags}`], }, { description: `Get a ${singularName} by ${pk.name}`, @@ -551,7 +602,9 @@ export function generateSkills( } // Overview SKILL.md - const tableKebabs = tables.slice(0, 5).map((t) => toKebabCase(getTableNames(t).singularName)); + const tableKebabs = tables + .slice(0, 5) + .map((t) => toKebabCase(getTableNames(t).singularName)); files.push({ fileName: `${skillName}/SKILL.md`, content: buildSkillFile( @@ -596,7 +649,7 @@ export function generateSkills( }, ], }, - referenceNames, + referenceNames ), }); @@ -617,7 +670,7 @@ export interface MultiTargetDocsInput { } export function generateMultiTargetReadme( - input: MultiTargetDocsInput, + input: MultiTargetDocsInput ): GeneratedDocFile { const { toolName, builtinNames, targets, registry } = input; const lines: string[] = []; @@ -628,11 +681,17 @@ export function generateMultiTargetReadme( lines.push(''); lines.push('### Create a context'); lines.push(''); - lines.push('A context stores per-target endpoint overrides for an environment (dev, staging, production).'); - lines.push('Default endpoints are baked in from the codegen config, so local development works with zero configuration.'); + lines.push( + 'A context stores per-target endpoint overrides for an environment (dev, staging, production).' + ); + lines.push( + 'Default endpoints are baked in from the codegen config, so local development works with zero configuration.' + ); lines.push(''); lines.push('```bash'); - lines.push(`# Interactive - prompts for each target endpoint (defaults shown)`); + lines.push( + `# Interactive - prompts for each target endpoint (defaults shown)` + ); lines.push(`${toolName} ${builtinNames.context} create local`); lines.push(''); lines.push(`# Non-interactive`); @@ -640,7 +699,9 @@ export function generateMultiTargetReadme( for (let i = 0; i < targets.length; i++) { const tgt = targets[i]; const continuation = i < targets.length - 1 ? ' \\' : ''; - lines.push(` --${tgt.name}-endpoint https://${tgt.name}.prod.example.com/graphql${continuation}`); + lines.push( + ` --${tgt.name}-endpoint https://${tgt.name}.prod.example.com/graphql${continuation}` + ); } lines.push('```'); lines.push(''); @@ -660,15 +721,19 @@ export function generateMultiTargetReadme( const authTarget = targets.find((t) => t.isAuthTarget); if (authTarget) { const authMutation = authTarget.customOperations.find( - (op) => op.kind === 'mutation' && /login|sign.?in|auth/i.test(op.name), + (op) => op.kind === 'mutation' && /login|sign.?in|auth/i.test(op.name) ); if (authMutation) { const kebab = toKebabCase(authMutation.name); lines.push('Or authenticate via a login mutation (auto-saves token):'); lines.push(''); lines.push('```bash'); - const flags = authMutation.args.map((a) => `--${a.name} `).join(' '); - lines.push(`${toolName} ${authTarget.name}:${kebab} ${flags} --save-token`); + const flags = authMutation.args + .map((a) => `--${a.name} `) + .join(' '); + lines.push( + `${toolName} ${authTarget.name}:${kebab} ${flags} --save-token` + ); lines.push('```'); lines.push(''); } @@ -679,7 +744,9 @@ export function generateMultiTargetReadme( lines.push('| Target | Default Endpoint | Tables | Custom Operations |'); lines.push('|--------|-----------------|--------|-------------------|'); for (const tgt of targets) { - lines.push(`| \`${tgt.name}\` | ${tgt.endpoint} | ${tgt.tables.length} | ${tgt.customOperations.length} |`); + lines.push( + `| \`${tgt.name}\` | ${endpointForDisplay(tgt.endpoint)} | ${tgt.tables.length} | ${tgt.customOperations.length} |` + ); } lines.push(''); @@ -689,9 +756,13 @@ export function generateMultiTargetReadme( lines.push(''); lines.push('| Command | Description |'); lines.push('|---------|-------------|'); - lines.push(`| \`${builtinNames.context}\` | Manage API contexts (per-target endpoints) |`); + lines.push( + `| \`${builtinNames.context}\` | Manage API contexts (per-target endpoints) |` + ); lines.push(`| \`${builtinNames.auth}\` | Manage authentication tokens |`); - lines.push(`| \`${builtinNames.config}\` | Manage config key-value store (per-context) |`); + lines.push( + `| \`${builtinNames.config}\` | Manage config key-value store (per-context) |` + ); lines.push(''); for (const tgt of targets) { @@ -702,7 +773,9 @@ export function generateMultiTargetReadme( for (const table of tgt.tables) { const { singularName } = getTableNames(table); const kebab = toKebabCase(singularName); - lines.push(`| \`${tgt.name}:${kebab}\` | ${singularName} CRUD operations |`); + lines.push( + `| \`${tgt.name}:${kebab}\` | ${singularName} CRUD operations |` + ); } for (const op of tgt.customOperations) { const kebab = toKebabCase(op.name); @@ -715,11 +788,15 @@ export function generateMultiTargetReadme( lines.push(''); lines.push(`### \`${builtinNames.context}\``); lines.push(''); - lines.push('Manage named API contexts (kubectl-style). Each context stores per-target endpoint overrides.'); + lines.push( + 'Manage named API contexts (kubectl-style). Each context stores per-target endpoint overrides.' + ); lines.push(''); lines.push('| Subcommand | Description |'); lines.push('|------------|-------------|'); - lines.push('| `create ` | Create a new context (prompts for per-target endpoints) |'); + lines.push( + '| `create ` | Create a new context (prompts for per-target endpoints) |' + ); lines.push('| `list` | List all contexts |'); lines.push('| `use ` | Set the active context |'); lines.push('| `current` | Show current context |'); @@ -728,7 +805,9 @@ export function generateMultiTargetReadme( lines.push('Create options:'); lines.push(''); for (const tgt of targets) { - lines.push(`- \`--${tgt.name}-endpoint \` (default: ${tgt.endpoint})`); + lines.push( + `- \`--${tgt.name}-endpoint \` (default: ${endpointForDisplay(tgt.endpoint)})` + ); } lines.push(''); lines.push(`Configuration is stored at \`~/.${toolName}/config/\`.`); @@ -736,11 +815,15 @@ export function generateMultiTargetReadme( lines.push(`### \`${builtinNames.auth}\``); lines.push(''); - lines.push('Manage authentication tokens per context. One shared token is used across all targets.'); + lines.push( + 'Manage authentication tokens per context. One shared token is used across all targets.' + ); lines.push(''); lines.push('| Subcommand | Description |'); lines.push('|------------|-------------|'); - lines.push('| `set-token ` | Store bearer token for current context |'); + lines.push( + '| `set-token ` | Store bearer token for current context |' + ); lines.push('| `status` | Show auth status across all contexts |'); lines.push('| `logout` | Remove credentials for current context |'); lines.push(''); @@ -756,12 +839,16 @@ export function generateMultiTargetReadme( lines.push('| `list` | List all config values |'); lines.push('| `delete ` | Delete a config value |'); lines.push(''); - lines.push(`Variables are scoped to the active context and stored at \`~/.${toolName}/config/\`.`); + lines.push( + `Variables are scoped to the active context and stored at \`~/.${toolName}/config/\`.` + ); lines.push(''); lines.push('## SDK Helpers'); lines.push(''); - lines.push('The generated `helpers.ts` provides typed client factories for use in scripts and services:'); + lines.push( + 'The generated `helpers.ts` provides typed client factories for use in scripts and services:' + ); lines.push(''); lines.push('```typescript'); for (const tgt of targets) { @@ -777,7 +864,9 @@ export function generateMultiTargetReadme( lines.push(''); lines.push('Credential resolution order:'); lines.push(`1. appstash store (\`~/.${toolName}/config/\`)`); - lines.push(`2. Environment variables (\`${toolName.toUpperCase().replace(/-/g, '_')}_TOKEN\`, \`${toolName.toUpperCase().replace(/-/g, '_')}__ENDPOINT\`)`); + lines.push( + `2. Environment variables (\`${toolName.toUpperCase().replace(/-/g, '_')}_TOKEN\`, \`${toolName.toUpperCase().replace(/-/g, '_')}__ENDPOINT\`)` + ); lines.push('3. Throws with actionable error message'); lines.push(''); @@ -801,10 +890,12 @@ export function generateMultiTargetReadme( lines.push('| Subcommand | Description |'); lines.push('|------------|-------------|'); lines.push(`| \`list\` | List all ${singularName} records |`); - lines.push(`| \`find-first\` | Find first matching ${singularName} record |`); + lines.push( + `| \`find-first\` | Find first matching ${singularName} record |` + ); const mtReadmeSpecialGroups = categorizeSpecialFields(table, registry); const mtReadmeHasSearch = mtReadmeSpecialGroups.some( - (g) => g.category === 'search' || g.category === 'embedding', + (g) => g.category === 'search' || g.category === 'embedding' ); if (mtReadmeHasSearch) { lines.push(`| \`search \` | Search ${singularName} records |`); @@ -834,20 +925,36 @@ export function generateMultiTargetReadme( lines.push(`| \`${f.name}\` | ${cleanTypeName(f.type.gqlType)} |`); } lines.push(''); - const requiredCreate = editableFields.filter((f) => !defaultFields.has(f.name)); - const optionalCreate = editableFields.filter((f) => defaultFields.has(f.name)); + const requiredCreate = editableFields.filter( + (f) => !defaultFields.has(f.name) + ); + const optionalCreate = editableFields.filter((f) => + defaultFields.has(f.name) + ); if (requiredCreate.length > 0) { - lines.push(`**Required create fields:** ${requiredCreate.map((f) => `\`${f.name}\``).join(', ')}`); + lines.push( + `**Required create fields:** ${requiredCreate.map((f) => `\`${f.name}\``).join(', ')}` + ); } if (optionalCreate.length > 0) { - lines.push(`**Optional create fields (backend defaults):** ${optionalCreate.map((f) => `\`${f.name}\``).join(', ')}`); + lines.push( + `**Optional create fields (backend defaults):** ${optionalCreate.map((f) => `\`${f.name}\``).join(', ')}` + ); } if (requiredCreate.length === 0 && optionalCreate.length === 0) { - lines.push(`**Create fields:** ${editableFields.map((f) => `\`${f.name}\``).join(', ')}`); + lines.push( + `**Create fields:** ${editableFields.map((f) => `\`${f.name}\``).join(', ')}` + ); } const mtSpecialGroups = categorizeSpecialFields(table, registry); lines.push(...buildSpecialFieldsMarkdown(mtSpecialGroups)); - lines.push(...buildSearchExamplesMarkdown(mtSpecialGroups, toolName, `${tgt.name}:${kebab}`)); + lines.push( + ...buildSearchExamplesMarkdown( + mtSpecialGroups, + toolName, + `${tgt.name}:${kebab}` + ) + ); lines.push(''); } @@ -872,7 +979,9 @@ export function generateMultiTargetReadme( lines.push('- **Arguments:** none'); } if (tgt.isAuthTarget && op.kind === 'mutation') { - lines.push(`- **Flags:** \`--save-token\` auto-saves returned token to credentials`); + lines.push( + `- **Flags:** \`--save-token\` auto-saves returned token to credentials` + ); } lines.push(''); } @@ -880,7 +989,9 @@ export function generateMultiTargetReadme( lines.push('## Output'); lines.push(''); - lines.push('All commands output JSON to stdout. Pipe to `jq` for formatting:'); + lines.push( + 'All commands output JSON to stdout. Pipe to `jq` for formatting:' + ); lines.push(''); lines.push('```bash'); if (targets.length > 0 && targets[0].tables.length > 0) { @@ -893,13 +1004,17 @@ export function generateMultiTargetReadme( lines.push(''); lines.push('## Non-Interactive Mode'); lines.push(''); - lines.push('Use `--no-tty` to skip all interactive prompts (useful for scripts and CI):'); + lines.push( + 'Use `--no-tty` to skip all interactive prompts (useful for scripts and CI):' + ); lines.push(''); lines.push('```bash'); if (targets.length > 0 && targets[0].tables.length > 0) { const tgt = targets[0]; const kebab = toKebabCase(getTableNames(tgt.tables[0]).singularName); - lines.push(`${toolName} --no-tty ${tgt.name}:${kebab} create --name "Example"`); + lines.push( + `${toolName} --no-tty ${tgt.name}:${kebab} create --name "Example"` + ); } lines.push('```'); lines.push(''); @@ -913,12 +1028,15 @@ export function generateMultiTargetReadme( } export function generateMultiTargetAgentsDocs( - input: MultiTargetDocsInput, + input: MultiTargetDocsInput ): GeneratedDocFile { const { toolName, builtinNames, targets } = input; const lines: string[] = []; const totalTables = targets.reduce((sum, t) => sum + t.tables.length, 0); - const totalCustomOps = targets.reduce((sum, t) => sum + t.customOperations.length, 0); + const totalCustomOps = targets.reduce( + (sum, t) => sum + t.customOperations.length, + 0 + ); lines.push(`# ${toolName} CLI`); lines.push(''); @@ -928,8 +1046,12 @@ export function generateMultiTargetAgentsDocs( lines.push('## Stack'); lines.push(''); lines.push(`- Unified multi-target CLI for GraphQL APIs (TypeScript)`); - lines.push(`- ${targets.length} target${targets.length !== 1 ? 's' : ''}: ${targets.map((t) => t.name).join(', ')}`); - lines.push(`- ${totalTables} table${totalTables !== 1 ? 's' : ''}${totalCustomOps > 0 ? `, ${totalCustomOps} custom operation${totalCustomOps !== 1 ? 's' : ''}` : ''}`); + lines.push( + `- ${targets.length} target${targets.length !== 1 ? 's' : ''}: ${targets.map((t) => t.name).join(', ')}` + ); + lines.push( + `- ${totalTables} table${totalTables !== 1 ? 's' : ''}${totalCustomOps > 0 ? `, ${totalCustomOps} custom operation${totalCustomOps !== 1 ? 's' : ''}` : ''}` + ); lines.push(`- Config stored at \`~/.${toolName}/config/\` via appstash`); lines.push(''); @@ -951,9 +1073,13 @@ export function generateMultiTargetAgentsDocs( lines.push('## Resources'); lines.push(''); - lines.push(`- **Full API reference:** [README.md](./README.md) — CRUD docs for all ${totalTables} tables across ${targets.length} targets`); + lines.push( + `- **Full API reference:** [README.md](./README.md) — CRUD docs for all ${totalTables} tables across ${targets.length} targets` + ); lines.push('- **Schema types:** [types.ts](./types.ts)'); - lines.push('- **SDK helpers:** [helpers.ts](./helpers.ts) — typed client factories'); + lines.push( + '- **SDK helpers:** [helpers.ts](./helpers.ts) — typed client factories' + ); lines.push(''); lines.push('## Conventions'); @@ -965,7 +1091,9 @@ export function generateMultiTargetAgentsDocs( lines.push('## Boundaries'); lines.push(''); - lines.push('All files in this directory are generated. Do not edit manually.'); + lines.push( + 'All files in this directory are generated. Do not edit manually.' + ); lines.push(''); return { @@ -975,7 +1103,7 @@ export function generateMultiTargetAgentsDocs( } export function generateMultiTargetSkills( - input: MultiTargetDocsInput, + input: MultiTargetDocsInput ): GeneratedDocFile[] { const { toolName, builtinNames, targets, registry } = input; const files: GeneratedDocFile[] = []; @@ -1004,7 +1132,8 @@ export function generateMultiTargetSkills( ], examples: [ { - description: 'Create a context for local development (accept all defaults)', + description: + 'Create a context for local development (accept all defaults)', code: [ `${toolName} ${builtinNames.context} create local`, `${toolName} ${builtinNames.context} use local`, @@ -1036,7 +1165,9 @@ export function generateMultiTargetSkills( examples: [ { description: 'Authenticate with a token', - code: [`${toolName} ${builtinNames.auth} set-token eyJhbGciOiJIUzI1NiIs...`], + code: [ + `${toolName} ${builtinNames.auth} set-token eyJhbGciOiJIUzI1NiIs...`, + ], }, { description: 'Check auth status', @@ -1078,12 +1209,13 @@ export function generateMultiTargetSkills( // Embedder reference (generated once when any target has tables with Vector fields) const allTargetTables = targets.flatMap((t) => t.tables); const hasEmbeddings = allTargetTables.some((t) => - categorizeSpecialFields(t, registry).some((g) => g.category === 'embedding'), + categorizeSpecialFields(t, registry).some((g) => g.category === 'embedding') ); if (hasEmbeddings) { - const firstTableName = allTargetTables.length > 0 - ? toKebabCase(getTableNames(allTargetTables[0]).singularName) - : 'model'; + const firstTableName = + allTargetTables.length > 0 + ? toKebabCase(getTableNames(allTargetTables[0]).singularName) + : 'model'; commonReferenceNames.push('embedder'); files.push({ fileName: `${commonSkillName}/references/embedder.md`, @@ -1169,13 +1301,11 @@ export function generateMultiTargetSkills( }, { description: 'Store a config variable', - code: [ - `${toolName} ${builtinNames.config} set orgId abc-123`, - ], + code: [`${toolName} ${builtinNames.config} set orgId abc-123`], }, ], }, - commonReferenceNames, + commonReferenceNames ), }); @@ -1191,18 +1321,28 @@ export function generateMultiTargetSkills( const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); const createFlags = [ - ...editableFields.filter((f) => !defaultFields.has(f.name)).map((f) => `--${f.name} <${cleanTypeName(f.type.gqlType)}>`), - ...editableFields.filter((f) => defaultFields.has(f.name)).map((f) => `[--${f.name} <${cleanTypeName(f.type.gqlType)}>]`), + ...editableFields + .filter((f) => !defaultFields.has(f.name)) + .map((f) => `--${f.name} <${cleanTypeName(f.type.gqlType)}>`), + ...editableFields + .filter((f) => defaultFields.has(f.name)) + .map((f) => `[--${f.name} <${cleanTypeName(f.type.gqlType)}>]`), ].join(' '); const cmd = `${tgt.name}:${kebab}`; tgtReferenceNames.push(kebab); const mtSkillSpecialGroups = categorizeSpecialFields(table, registry); - const mtSkillSpecialDesc = mtSkillSpecialGroups.length > 0 - ? `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)\n\n` + - mtSkillSpecialGroups.map((g) => `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}`).join('\n\n') - : `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)`; + const mtSkillSpecialDesc = + mtSkillSpecialGroups.length > 0 + ? `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)\n\n` + + mtSkillSpecialGroups + .map( + (g) => + `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}` + ) + .join('\n\n') + : `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)`; files.push({ fileName: `${tgtSkillName}/references/${kebab}.md`, @@ -1214,7 +1354,9 @@ export function generateMultiTargetSkills( `${toolName} ${cmd} list --where.. --orderBy `, `${toolName} ${cmd} list --limit 10 --after `, `${toolName} ${cmd} find-first --where.. `, - ...(mtSkillSpecialGroups.some((g) => g.category === 'search' || g.category === 'embedding') + ...(mtSkillSpecialGroups.some( + (g) => g.category === 'search' || g.category === 'embedding' + ) ? [`${toolName} ${cmd} search `] : []), `${toolName} ${cmd} get --${pk.name} <${cleanTypeName(pk.gqlType)}>`, @@ -1237,18 +1379,20 @@ export function generateMultiTargetSkills( }, { description: `Find first matching ${singularName}`, - code: [`${toolName} ${cmd} find-first --where.${pk.name}.equalTo `], + code: [ + `${toolName} ${cmd} find-first --where.${pk.name}.equalTo `, + ], }, { description: `List ${singularName} records with filtering and ordering`, - code: [`${toolName} ${cmd} list --where.${pk.name}.equalTo --orderBy ${pk.name.replace(/([A-Z])/g, '_$1').toUpperCase()}_ASC`], + code: [ + `${toolName} ${cmd} list --where.${pk.name}.equalTo --orderBy ${pk.name.replace(/([A-Z])/g, '_$1').toUpperCase()}_ASC`, + ], }, ...buildSearchExamples(mtSkillSpecialGroups, toolName, cmd), { description: `Create a ${singularName}`, - code: [ - `${toolName} ${cmd} create ${createFlags}`, - ], + code: [`${toolName} ${cmd} create ${createFlags}`], }, ], }), @@ -1287,9 +1431,10 @@ export function generateMultiTargetSkills( } // Target SKILL.md - const firstKebab = tgt.tables.length > 0 - ? toKebabCase(getTableNames(tgt.tables[0]).singularName) - : 'model'; + const firstKebab = + tgt.tables.length > 0 + ? toKebabCase(getTableNames(tgt.tables[0]).singularName) + : 'model'; files.push({ fileName: `${tgtSkillName}/SKILL.md`, content: buildSkillFile( @@ -1318,7 +1463,7 @@ export function generateMultiTargetSkills( }, ], }, - tgtReferenceNames, + tgtReferenceNames ), }); } diff --git a/graphql/codegen/src/core/codegen/target-docs-generator.ts b/graphql/codegen/src/core/codegen/target-docs-generator.ts index ceacf5ef38..19fc9f9f82 100644 --- a/graphql/codegen/src/core/codegen/target-docs-generator.ts +++ b/graphql/codegen/src/core/codegen/target-docs-generator.ts @@ -1,4 +1,5 @@ import type { GraphQLSDKConfigTarget } from '../../types/config'; +import { endpointForDisplay } from '../sensitive-values'; import { getReadmeHeader, getReadmeFooter } from './docs-utils'; import type { GeneratedDocFile } from './docs-utils'; @@ -13,7 +14,7 @@ export interface TargetReadmeOptions { } export function generateTargetReadme( - options: TargetReadmeOptions, + options: TargetReadmeOptions ): GeneratedDocFile { const { hasOrm, @@ -43,7 +44,7 @@ export function generateTargetReadme( lines.push(''); if (config.endpoint) { - lines.push(`**Endpoint:** \`${config.endpoint}\``); + lines.push(`**Endpoint:** \`${endpointForDisplay(config.endpoint)}\``); lines.push(''); } @@ -53,9 +54,7 @@ export function generateTargetReadme( if (hasOrm) { lines.push('### ORM Client (`./orm`)'); lines.push(''); - lines.push( - 'Prisma-like ORM client for programmatic GraphQL access.', - ); + lines.push('Prisma-like ORM client for programmatic GraphQL access.'); lines.push(''); lines.push('```typescript'); lines.push("import { createClient } from './orm';"); @@ -65,18 +64,14 @@ export function generateTargetReadme( lines.push('});'); lines.push('```'); lines.push(''); - lines.push( - 'See [orm/README.md](./orm/README.md) for full API reference.', - ); + lines.push('See [orm/README.md](./orm/README.md) for full API reference.'); lines.push(''); } if (hasHooks) { lines.push('### React Query Hooks (`./hooks`)'); lines.push(''); - lines.push( - 'Type-safe React Query hooks for data fetching and mutations.', - ); + lines.push('Type-safe React Query hooks for data fetching and mutations.'); lines.push(''); lines.push('```typescript'); lines.push("import { configure } from './hooks';"); @@ -86,7 +81,7 @@ export function generateTargetReadme( lines.push('```'); lines.push(''); lines.push( - 'See [hooks/README.md](./hooks/README.md) for full hook reference.', + 'See [hooks/README.md](./hooks/README.md) for full hook reference.' ); lines.push(''); } @@ -98,13 +93,9 @@ export function generateTargetReadme( : 'app'; lines.push(`### CLI Commands (\`./cli\`)`); lines.push(''); - lines.push( - `inquirerer-based CLI commands for \`${toolName}\`.`, - ); + lines.push(`inquirerer-based CLI commands for \`${toolName}\`.`); lines.push(''); - lines.push( - 'See [cli/README.md](./cli/README.md) for command reference.', - ); + lines.push('See [cli/README.md](./cli/README.md) for command reference.'); lines.push(''); } @@ -124,7 +115,7 @@ export interface RootRootReadmeTarget { } export function generateRootRootReadme( - targets: RootRootReadmeTarget[], + targets: RootRootReadmeTarget[] ): GeneratedDocFile { const lines: string[] = []; @@ -135,10 +126,12 @@ export function generateRootRootReadme( lines.push('| API | Endpoint | Generators | Docs |'); lines.push('|-----|----------|------------|------|'); for (const target of targets) { - const endpoint = target.endpoint || '-'; + const endpoint = target.endpoint + ? endpointForDisplay(target.endpoint) + : '-'; const gens = target.generators.join(', '); lines.push( - `| ${target.name} | ${endpoint} | ${gens} | [${target.output}/README.md](${target.output}/README.md) |`, + `| ${target.name} | ${endpoint} | ${gens} | [${target.output}/README.md](${target.output}/README.md) |` ); } lines.push(''); diff --git a/graphql/codegen/src/core/config/index.ts b/graphql/codegen/src/core/config/index.ts index a79e719879..5bc71180e7 100644 --- a/graphql/codegen/src/core/config/index.ts +++ b/graphql/codegen/src/core/config/index.ts @@ -3,9 +3,13 @@ */ export { + CONFIG_FILENAMES, CONFIG_FILENAME, + JSON_CONFIG_FILENAME, findConfigFile, loadConfigFile, + type LoadConfigFileErrorCode, + type LoadConfigFileOptions, type LoadConfigFileResult, } from './loader'; export { diff --git a/graphql/codegen/src/core/config/loader.ts b/graphql/codegen/src/core/config/loader.ts index 87afc3b618..714d1c6f92 100644 --- a/graphql/codegen/src/core/config/loader.ts +++ b/graphql/codegen/src/core/config/loader.ts @@ -10,19 +10,35 @@ import * as path from 'node:path'; import { createJiti } from 'jiti'; export const CONFIG_FILENAME = 'graphql-codegen.config.ts'; +export const JSON_CONFIG_FILENAME = 'graphql-codegen.config.json'; +export const CONFIG_FILENAMES = [ + JSON_CONFIG_FILENAME, + CONFIG_FILENAME, +] as const; + +export interface LoadConfigFileOptions { + /** Permit importing executable TypeScript or JavaScript configuration. */ + allowExecutableConfig?: boolean; +} + +export type LoadConfigFileErrorCode = + | 'CODEGEN_CONFIG_INVALID' + | 'CODEGEN_CONFIG_EXECUTABLE_UNSUPPORTED'; /** * Find the nearest config file by walking up directories */ export function findConfigFile( - startDir: string = process.cwd(), + startDir: string = process.cwd() ): string | null { let currentDir = startDir; while (true) { - const configPath = path.join(currentDir, CONFIG_FILENAME); - if (fs.existsSync(configPath)) { - return configPath; + for (const filename of CONFIG_FILENAMES) { + const configPath = path.join(currentDir, filename); + if (fs.existsSync(configPath)) { + return configPath; + } } const parentDir = path.dirname(currentDir); @@ -39,6 +55,7 @@ export interface LoadConfigFileResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any config?: any; error?: string; + code?: LoadConfigFileErrorCode; } /** @@ -50,30 +67,47 @@ export interface LoadConfigFileResult { */ export async function loadConfigFile( configPath: string, + cwd: string = process.cwd(), + env: Readonly> = {}, + options: LoadConfigFileOptions = {} ): Promise { - const resolvedPath = path.resolve(configPath); + const resolvedPath = path.isAbsolute(configPath) + ? path.normalize(configPath) + : path.resolve(cwd, configPath); if (!fs.existsSync(resolvedPath)) { return { success: false, + code: 'CODEGEN_CONFIG_INVALID', error: `Config file not found: ${resolvedPath}`, }; } - try { - // Use jiti to load TypeScript/ESM config files seamlessly - // jiti handles .ts, .js, .mjs, .cjs and ESM/CJS interop - const jiti = createJiti(__filename, { - interopDefault: true, - debug: process.env.JITI_DEBUG === '1', - }); + const declarativeJson = path.extname(resolvedPath).toLowerCase() === '.json'; + if (!declarativeJson && options.allowExecutableConfig === false) { + return { + success: false, + code: 'CODEGEN_CONFIG_EXECUTABLE_UNSUPPORTED', + error: + 'Executable codegen configuration is disabled. Use a declarative JSON config file.', + }; + } - // jiti.import() with { default: true } returns mod?.default ?? mod - const config = await jiti.import(resolvedPath, { default: true }); + try { + const config = declarativeJson + ? JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) + : await createJiti(__filename, { + interopDefault: true, + debug: env.JITI_DEBUG === '1', + // Config discovery is part of dry-run planning, so loading a config + // must not create node_modules/.cache/jiti or temporary artifacts. + fsCache: false, + }).import(resolvedPath, { default: true }); - if (!config || typeof config !== 'object') { + if (!config || typeof config !== 'object' || Array.isArray(config)) { return { success: false, + code: 'CODEGEN_CONFIG_INVALID', error: 'Config file must export a configuration object', }; } @@ -83,9 +117,17 @@ export async function loadConfigFile( config, }; } catch (err) { + if (declarativeJson) { + return { + success: false, + code: 'CODEGEN_CONFIG_INVALID', + error: 'Failed to parse declarative JSON config file.', + }; + } const message = err instanceof Error ? err.message : 'Unknown error'; return { success: false, + code: 'CODEGEN_CONFIG_INVALID', error: `Failed to load config file: ${message}`, }; } diff --git a/graphql/codegen/src/core/config/resolver.ts b/graphql/codegen/src/core/config/resolver.ts index 8f81b71108..a6d485c3e5 100644 --- a/graphql/codegen/src/core/config/resolver.ts +++ b/graphql/codegen/src/core/config/resolver.ts @@ -18,6 +18,8 @@ import { findConfigFile, loadConfigFile } from './loader'; export interface ConfigOverrideOptions extends GraphQLSDKConfigTarget { /** Path to config file (CLI-only) */ config?: string; + /** Base directory for config discovery and relative config paths. */ + cwd?: string; } /** @@ -38,10 +40,11 @@ export interface LoadConfigResult { * 3. Returns fully resolved configuration ready for use */ export async function loadAndResolveConfig( - options: ConfigOverrideOptions, + options: ConfigOverrideOptions ): Promise { // Destructure CLI-only fields, rest is config overrides - const { config: configPath, ...overrides } = options; + const { config: configPath, cwd: cwdOption, ...overrides } = options; + const cwd = cwdOption ?? process.cwd(); // Validate that at most one source is specified const sources = [ @@ -60,13 +63,13 @@ export async function loadAndResolveConfig( // Find config file let resolvedConfigPath = configPath; if (!resolvedConfigPath) { - resolvedConfigPath = findConfigFile() ?? undefined; + resolvedConfigPath = findConfigFile(cwd) ?? undefined; } let baseConfig: GraphQLSDKConfig = {}; if (resolvedConfigPath) { - const loadResult = await loadConfigFile(resolvedConfigPath); + const loadResult = await loadConfigFile(resolvedConfigPath, cwd); if (!loadResult.success) { return { success: false, error: loadResult.error }; } @@ -100,6 +103,7 @@ export async function loadAndResolveConfig( */ export async function loadWatchConfig(options: { config?: string; + cwd?: string; endpoint?: string; output?: string; pollInterval?: number; @@ -107,15 +111,16 @@ export async function loadWatchConfig(options: { touch?: string; clear?: boolean; }): Promise { + const cwd = options.cwd ?? process.cwd(); let configPath = options.config; if (!configPath) { - configPath = findConfigFile() ?? undefined; + configPath = findConfigFile(cwd) ?? undefined; } let baseConfig: GraphQLSDKConfig = {}; if (configPath) { - const loadResult = await loadConfigFile(configPath); + const loadResult = await loadConfigFile(configPath, cwd); if (!loadResult.success) { console.error('x', loadResult.error); return null; @@ -145,14 +150,14 @@ export async function loadWatchConfig(options: { if (!mergedConfig.endpoint) { console.error( - 'x No endpoint specified. Watch mode only supports live endpoints.', + 'x No endpoint specified. Watch mode only supports live endpoints.' ); return null; } if (mergedConfig.schemaFile) { console.error( - 'x Watch mode is only supported with an endpoint, not schemaFile.', + 'x Watch mode is only supported with an endpoint, not schemaFile.' ); return null; } diff --git a/graphql/codegen/src/core/database/index.ts b/graphql/codegen/src/core/database/index.ts index b078602f8b..b214aec923 100644 --- a/graphql/codegen/src/core/database/index.ts +++ b/graphql/codegen/src/core/database/index.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { buildSchemaSDL } from 'graphile-schema'; +import type { PgConfig } from 'pg-env'; export interface BuildSchemaFromDatabaseOptions { /** Database name */ @@ -18,6 +19,10 @@ export interface BuildSchemaFromDatabaseOptions { outDir: string; /** Optional filename (default: schema.graphql) */ filename?: string; + /** Explicit PostgreSQL values overriding `env`. */ + pg?: Partial; + /** Explicit environment used for omitted PostgreSQL values. */ + env?: Readonly>; } export interface BuildSchemaFromDatabaseResult { @@ -37,9 +42,16 @@ export interface BuildSchemaFromDatabaseResult { * @returns The path to the generated schema file and the SDL content */ export async function buildSchemaFromDatabase( - options: BuildSchemaFromDatabaseOptions, + options: BuildSchemaFromDatabaseOptions ): Promise { - const { database, schemas, outDir, filename = 'schema.graphql' } = options; + const { + database, + schemas, + outDir, + filename = 'schema.graphql', + pg, + env, + } = options; // Ensure output directory exists await fs.promises.mkdir(outDir, { recursive: true }); @@ -48,6 +60,8 @@ export async function buildSchemaFromDatabase( const sdl = await buildSchemaSDL({ database, schemas, + pg, + env, }); // Write schema to file diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 7e2ace908e..5b9d60f037 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -4,6 +4,7 @@ * This is the primary entry point for programmatic usage. * The CLI is a thin wrapper around this function. */ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import path from 'node:path'; @@ -14,12 +15,25 @@ import { pgCache } from 'pg-cache'; import { createEphemeralDb, type EphemeralDbResult } from 'pgsql-client'; import { deployPgpm } from 'pgsql-seed'; -import type { CliConfig, DbConfig, GraphQLSDKConfigTarget, PgpmConfig, SchemaConfig } from '../types/config'; -import { getConfigOptions } from '../types/config'; +import type { + CliConfig, + DbConfig, + GraphQLSDKConfigTarget, + PgpmConfig, + SchemaConfig, +} from '../types/config'; +import { getConfigOptions, mergeConfig } from '../types/config'; +import type { PgConfig } from 'pg-env'; import type { Operation, Table, TypeRegistry } from '../types/schema'; import { generate as generateReactQueryFiles } from './codegen'; -import { generateRootBarrel, generateMultiTargetBarrel } from './codegen/barrel'; -import { generateCli as generateCliFiles, generateMultiTargetCli } from './codegen/cli'; +import { + generateRootBarrel, + generateMultiTargetBarrel, +} from './codegen/barrel'; +import { + generateCli as generateCliFiles, + generateMultiTargetCli, +} from './codegen/cli'; import type { MultiTargetCliTarget } from './codegen/cli'; import { generateReadme as generateCliReadme, @@ -48,8 +62,19 @@ import { generateRootRootReadme, } from './codegen/target-docs-generator'; import type { RootRootReadmeTarget } from './codegen/target-docs-generator'; -import { createSchemaSource, validateSourceOptions } from './introspect'; -import { writeGeneratedFiles } from './output'; +import { + createSchemaSource, + resolvePgConfig, + validateSourceOptions, +} from './introspect'; +import { rethrowIfCancelled, throwIfAborted } from './cancellation'; +import type { + FileChange, + GeneratedFileWriteJob, + GenerationPlan, + WriteResult, +} from './output'; +import { GENERATED_FILES_MANIFEST, writeGeneratedFileJobs } from './output'; import { runCodegenPipeline, validateTablesFound } from './pipeline'; import { findWorkspaceRoot } from './workspace'; @@ -58,6 +83,29 @@ export interface GenerateOptions extends GraphQLSDKConfigTarget { verbose?: boolean; dryRun?: boolean; skipCustomOperations?: boolean; + /** Base directory for all relative source and output paths. */ + cwd?: string; + /** Allow replacing or deleting generated files modified since the last run. */ + overwriteModifiedGenerated?: boolean; + /** Explicit confirmation required with overwriteModifiedGenerated. */ + yes?: boolean; + /** Receives progress without coupling the core generator to terminal output. */ + onProgress?: (event: GenerateProgressEvent) => void; + /** Cancels fetch and generation before the filesystem commit begins. */ + signal?: AbortSignal; + /** Explicit environment used to resolve omitted PostgreSQL settings. */ + env?: Readonly>; +} + +export interface GenerateProgressEvent { + phase: + | 'schema.fetch' + | 'types.generate' + | 'hooks.generate' + | 'orm.generate' + | 'cli.generate' + | 'pgpm.prepare'; + message: string; } export interface GenerateResult { @@ -66,7 +114,20 @@ export interface GenerateResult { output?: string; tables?: string[]; filesWritten?: string[]; + filesRemoved?: string[]; + /** Complete filesystem plans, including unchanged files and conflicts. */ + plans?: GenerationPlan[]; + /** Stable hash of the ordered plan fingerprints. */ + planFingerprint?: string; + /** Flattened file changes for agent-oriented consumers. */ + fileChanges?: FileChange[]; errors?: string[]; + /** Non-fatal writer diagnostics, including retained transaction data. */ + warnings?: string[]; + /** Transaction directory retained because cleanup or rollback was incomplete. */ + recoveryPath?: string; + /** Filesystem restoration failures requiring manual recovery. */ + rollbackErrors?: string[]; pipelineData?: { tables: Table[]; customOperations: { @@ -85,6 +146,8 @@ export interface GenerateResult { */ export interface GenerateInternalOptions { skipCli?: boolean; + /** Collect fully generated write jobs without applying them. */ + writeJobs?: GeneratedFileWriteJob[]; /** * Internal-only name for the target when generating skills. * Used by generateMulti() so skill names are stable and composable. @@ -95,11 +158,11 @@ export interface GenerateInternalOptions { function resolveSkillsOutputDir( config: GraphQLSDKConfigTarget, outputRoot: string, + cwd: string ): string { + const resolvedOutputRoot = resolvePathFrom(cwd, outputRoot)!; const workspaceRoot = - findWorkspaceRoot(path.resolve(outputRoot)) ?? - findWorkspaceRoot(process.cwd()) ?? - process.cwd(); + findWorkspaceRoot(resolvedOutputRoot) ?? findWorkspaceRoot(cwd) ?? cwd; if (config.skillsPath) { return path.isAbsolute(config.skillsPath) @@ -110,13 +173,137 @@ function resolveSkillsOutputDir( return path.resolve(workspaceRoot, '.agents/skills'); } +function resolvePathFrom( + cwd: string, + value: string | undefined +): string | undefined { + if (!value) return value; + return path.isAbsolute(value) + ? path.normalize(value) + : path.resolve(cwd, value); +} + +function resolveTargetPaths( + target: GraphQLSDKConfigTarget, + cwd: string +): GraphQLSDKConfigTarget { + return { + ...target, + output: resolvePathFrom(cwd, target.output), + schemaFile: resolvePathFrom(cwd, target.schemaFile), + schemaDir: resolvePathFrom(cwd, target.schemaDir), + schema: target.schema + ? { + ...target.schema, + output: resolvePathFrom(cwd, target.schema.output), + } + : undefined, + db: target.db + ? { + ...target.db, + pgpm: target.db.pgpm + ? { + ...target.db.pgpm, + modulePath: resolvePathFrom(cwd, target.db.pgpm.modulePath), + workspacePath: resolvePathFrom( + cwd, + target.db.pgpm.workspacePath + ), + } + : undefined, + } + : undefined, + }; +} + +function combinePlanFingerprint(plans: GenerationPlan[]): string | undefined { + if (plans.length === 0) return undefined; + return createHash('sha256') + .update( + JSON.stringify( + plans.map((plan) => ({ + outputDir: plan.outputDir, + fingerprint: plan.fingerprint, + })) + ) + ) + .digest('hex'); +} + +function planFields( + results: WriteResult[] +): Pick { + const plans = results.flatMap((result) => (result.plan ? [result.plan] : [])); + return { + plans, + planFingerprint: combinePlanFingerprint(plans), + fileChanges: plans.flatMap((plan) => plan.changes), + }; +} + +function recoveryFields( + results: WriteResult[] +): Pick { + const warnings = results.flatMap((result) => result.warnings ?? []); + const rollbackErrors = results.flatMap( + (result) => result.rollbackErrors ?? [] + ); + const recoveryPath = results.find( + (result) => result.recoveryPath !== undefined + )?.recoveryPath; + return { + ...(warnings.length === 0 ? {} : { warnings }), + ...(recoveryPath === undefined ? {} : { recoveryPath }), + ...(rollbackErrors.length === 0 ? {} : { rollbackErrors }), + }; +} + +async function executeGeneratedWriteJobs( + jobs: GeneratedFileWriteJob[], + options: GenerateOptions, + internalOptions?: GenerateInternalOptions +): Promise { + const collecting = internalOptions?.writeJobs !== undefined; + const batch = await writeGeneratedFileJobs(jobs, { + dryRun: collecting || options.dryRun, + showProgress: false, + signal: options.signal, + }); + if (batch.success && collecting) { + internalOptions.writeJobs!.push(...jobs); + } + if (batch.results.length > 0) return batch.results; + return [ + { + success: false, + errors: batch.errors ?? ['Failed to prepare generated files.'], + }, + ]; +} + export async function generate( options: GenerateOptions = {}, - internalOptions?: GenerateInternalOptions, + internalOptions?: GenerateInternalOptions ): Promise { - // Apply defaults to get resolved config - const config = getConfigOptions(options); - const outputRoot = config.output; + throwIfAborted(options.signal); + const cwd = path.resolve(options.cwd ?? process.cwd()); + const { + cwd: _cwd, + overwriteModifiedGenerated: _overwriteModifiedGenerated, + yes: _yes, + onProgress: _onProgress, + signal: _signal, + env: _env, + ...configOverrides + } = options; + const report = (event: GenerateProgressEvent): void => { + if (options.onProgress) options.onProgress(event); + else console.log(event.message); + }; + // Resolve every relative path against the operation cwd once. Downstream + // generation never needs to mutate or rediscover the process cwd. + const config = resolveTargetPaths(getConfigOptions(configOverrides), cwd); + const outputRoot = config.output!; // Determine which generators to run // ORM is always required when React Query is enabled (hooks delegate to ORM) @@ -124,7 +311,9 @@ export async function generate( const runReactQuery = config.reactQuery ?? false; const runCli = internalOptions?.skipCli ? false : !!config.cli; const runOrm = - runReactQuery || !!config.cli || (options.orm !== undefined ? !!options.orm : false); + runReactQuery || + !!config.cli || + (options.orm !== undefined ? !!options.orm : false); const schemaEnabled = !!options.schema?.enabled; @@ -157,14 +346,22 @@ export async function generate( db: config.db, authorization: options.authorization || config.headers?.Authorization, headers: config.headers, + env: options.env ?? {}, }); if (schemaEnabled && !runReactQuery && !runOrm && !runCli) { try { - console.log(`Fetching schema from ${source.describe()}...`); - const { introspection } = await source.fetch(); + throwIfAborted(options.signal); + report({ + phase: 'schema.fetch', + message: `Fetching schema from ${source.describe()}...`, + }); + throwIfAborted(options.signal); + const { introspection } = await source.fetch(options.signal); + throwIfAborted(options.signal); const schema = buildClientSchema(introspection as any); const sdl = printSchema(schema); + throwIfAborted(options.signal); if (!sdl.trim()) { return { @@ -174,19 +371,54 @@ export async function generate( }; } - const outDir = path.resolve(options.schema?.output || outputRoot || '.'); - await fs.promises.mkdir(outDir, { recursive: true }); + const outDir = config.schema?.output || outputRoot; const filename = options.schema?.filename || 'schema.graphql'; const filePath = path.join(outDir, filename); - await fs.promises.writeFile(filePath, sdl, 'utf-8'); + // Cancellation is deliberately checked before, not after, the atomic + // writer. An abort that races with a completed commit reports success. + throwIfAborted(options.signal); + const [writeResult] = await executeGeneratedWriteJobs( + [ + { + files: [{ path: filename, content: sdl }], + outputDir: outDir, + options: { + pruneStaleFiles: false, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + confirmOverwrite: options.yes, + showProgress: false, + }, + }, + ], + options, + internalOptions + ); + const structuredPlan = planFields([writeResult]); + const recovery = recoveryFields([writeResult]); + if (!writeResult.success) { + return { + success: false, + message: `Failed to export schema: ${writeResult.errors?.join(', ')}`, + output: outDir, + errors: writeResult.errors, + ...structuredPlan, + ...recovery, + }; + } return { success: true, - message: `Schema exported to ${filePath}`, + message: options.dryRun + ? `Dry run complete. Would export schema to ${filePath}` + : `Schema exported to ${filePath}`, output: outDir, - filesWritten: [filePath], + filesWritten: writeResult.filesWritten, + filesRemoved: writeResult.filesRemoved, + ...structuredPlan, + ...recovery, }; } catch (err) { + rethrowIfCancelled(err, options.signal); return { success: false, message: `Failed to export schema: ${err instanceof Error ? err.message : 'Unknown error'}`, @@ -198,14 +430,27 @@ export async function generate( // Run pipeline let pipelineResult: Awaited>; try { - console.log(`Fetching schema from ${source.describe()}...`); + throwIfAborted(options.signal); + report({ + phase: 'schema.fetch', + message: `Fetching schema from ${source.describe()}...`, + }); + throwIfAborted(options.signal); pipelineResult = await runCodegenPipeline({ source, config, verbose: options.verbose, + onLog: (message) => + report({ + phase: 'schema.fetch', + message, + }), skipCustomOperations: options.skipCustomOperations, + signal: options.signal, }); + throwIfAborted(options.signal); } catch (err) { + rethrowIfCancelled(err, options.signal); return { success: false, message: `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}`, @@ -226,12 +471,15 @@ export async function generate( } const allFilesWritten: string[] = []; + const allFilesRemoved: string[] = []; const bothEnabled = runReactQuery && runOrm; const filesToWrite: Array<{ path: string; content: string }> = []; // Generate shared types when both are enabled if (bothEnabled) { - console.log('Generating shared types...'); + throwIfAborted(options.signal); + report({ phase: 'types.generate', message: 'Generating shared types...' }); + throwIfAborted(options.signal); const sharedResult = generateSharedTypes({ tables, customOperations: { @@ -241,12 +489,23 @@ export async function generate( }, config, }); - filesToWrite.push(...sharedResult.files); + throwIfAborted(options.signal); + // The root barrel below is the final owner of index.ts and already exports + // shared ./types alongside the enabled generators. The old writer silently + // overwrote this shared-only barrel; keep the plan unambiguous instead. + filesToWrite.push( + ...sharedResult.files.filter((file) => file.path !== 'index.ts') + ); } // Generate React Query hooks if (runReactQuery) { - console.log('Generating React Query hooks...'); + throwIfAborted(options.signal); + report({ + phase: 'hooks.generate', + message: 'Generating React Query hooks...', + }); + throwIfAborted(options.signal); const { files } = generateReactQueryFiles({ tables, customOperations: { @@ -257,17 +516,20 @@ export async function generate( config, sharedTypesPath: bothEnabled ? '..' : undefined, }); + throwIfAborted(options.signal); filesToWrite.push( ...files.map((file) => ({ ...file, path: path.posix.join('hooks', file.path), - })), + })) ); } // Generate ORM client if (runOrm) { - console.log('Generating ORM client...'); + throwIfAborted(options.signal); + report({ phase: 'orm.generate', message: 'Generating ORM client...' }); + throwIfAborted(options.signal); const { files } = generateOrmFiles({ tables, customOperations: { @@ -278,17 +540,20 @@ export async function generate( config, sharedTypesPath: bothEnabled ? '..' : undefined, }); + throwIfAborted(options.signal); filesToWrite.push( ...files.map((file) => ({ ...file, path: path.posix.join('orm', file.path), - })), + })) ); } // Generate CLI commands if (runCli) { - console.log('Generating CLI commands...'); + throwIfAborted(options.signal); + report({ phase: 'cli.generate', message: 'Generating CLI commands...' }); + throwIfAborted(options.signal); const { files } = generateCliFiles({ tables, customOperations: { @@ -298,11 +563,12 @@ export async function generate( config, typeRegistry: customOperations.typeRegistry, }); + throwIfAborted(options.signal); filesToWrite.push( ...files.map((file) => ({ path: path.posix.join('cli', file.fileName), content: file.content, - })), + })) ); } @@ -326,15 +592,30 @@ export async function generate( if (runOrm) { if (docsConfig.readme) { - const readme = generateOrmReadme(tables, allCustomOps, customOperations.typeRegistry); - filesToWrite.push({ path: path.posix.join('orm', readme.fileName), content: readme.content }); + const readme = generateOrmReadme( + tables, + allCustomOps, + customOperations.typeRegistry + ); + filesToWrite.push({ + path: path.posix.join('orm', readme.fileName), + content: readme.content, + }); } if (docsConfig.agents) { const agents = generateOrmAgentsDocs(tables, allCustomOps); - filesToWrite.push({ path: path.posix.join('orm', agents.fileName), content: agents.content }); + filesToWrite.push({ + path: path.posix.join('orm', agents.fileName), + content: agents.content, + }); } if (docsConfig.skills) { - for (const skill of generateOrmSkills(tables, allCustomOps, targetName, customOperations.typeRegistry)) { + for (const skill of generateOrmSkills( + tables, + allCustomOps, + targetName, + customOperations.typeRegistry + )) { skillsToWrite.push({ path: skill.fileName, content: skill.content }); } } @@ -342,15 +623,30 @@ export async function generate( if (runReactQuery) { if (docsConfig.readme) { - const readme = generateHooksReadme(tables, allCustomOps, customOperations.typeRegistry); - filesToWrite.push({ path: path.posix.join('hooks', readme.fileName), content: readme.content }); + const readme = generateHooksReadme( + tables, + allCustomOps, + customOperations.typeRegistry + ); + filesToWrite.push({ + path: path.posix.join('hooks', readme.fileName), + content: readme.content, + }); } if (docsConfig.agents) { const agents = generateHooksAgentsDocs(tables, allCustomOps); - filesToWrite.push({ path: path.posix.join('hooks', agents.fileName), content: agents.content }); + filesToWrite.push({ + path: path.posix.join('hooks', agents.fileName), + content: agents.content, + }); } if (docsConfig.skills) { - for (const skill of generateHooksSkills(tables, allCustomOps, targetName, customOperations.typeRegistry)) { + for (const skill of generateHooksSkills( + tables, + allCustomOps, + targetName, + customOperations.typeRegistry + )) { skillsToWrite.push({ path: skill.fileName, content: skill.content }); } } @@ -362,15 +658,37 @@ export async function generate( ? config.cli.toolName : 'app'; if (docsConfig.readme) { - const readme = generateCliReadme(tables, allCustomOps, toolName, customOperations.typeRegistry); - filesToWrite.push({ path: path.posix.join('cli', readme.fileName), content: readme.content }); + const readme = generateCliReadme( + tables, + allCustomOps, + toolName, + customOperations.typeRegistry + ); + filesToWrite.push({ + path: path.posix.join('cli', readme.fileName), + content: readme.content, + }); } if (docsConfig.agents) { - const agents = generateCliAgentsDocs(tables, allCustomOps, toolName, customOperations.typeRegistry); - filesToWrite.push({ path: path.posix.join('cli', agents.fileName), content: agents.content }); + const agents = generateCliAgentsDocs( + tables, + allCustomOps, + toolName, + customOperations.typeRegistry + ); + filesToWrite.push({ + path: path.posix.join('cli', agents.fileName), + content: agents.content, + }); } if (docsConfig.skills) { - for (const skill of generateCliSkills(tables, allCustomOps, toolName, targetName, customOperations.typeRegistry)) { + for (const skill of generateCliSkills( + tables, + allCustomOps, + toolName, + targetName, + customOperations.typeRegistry + )) { skillsToWrite.push({ path: skill.fileName, content: skill.content }); } } @@ -387,39 +705,67 @@ export async function generate( customMutationCount: customOperations.mutations.length, config, }); - filesToWrite.push({ path: targetReadme.fileName, content: targetReadme.content }); + filesToWrite.push({ + path: targetReadme.fileName, + content: targetReadme.content, + }); } - if (!options.dryRun) { - const writeResult = await writeGeneratedFiles(filesToWrite, outputRoot, [], { + const writeJobs: Array<{ + files: Array<{ path: string; content: string }>; + outputDir: string; + pruneStaleFiles: boolean; + }> = [ + { + files: filesToWrite, + outputDir: outputRoot, pruneStaleFiles: true, + }, + ]; + throwIfAborted(options.signal); + if (skillsToWrite.length > 0) { + writeJobs.push({ + files: skillsToWrite, + outputDir: resolveSkillsOutputDir(config, outputRoot, cwd), + pruneStaleFiles: false, }); - if (!writeResult.success) { - return { - success: false, - message: `Failed to write generated files: ${writeResult.errors?.join(', ')}`, - output: outputRoot, - errors: writeResult.errors, - }; - } - allFilesWritten.push(...(writeResult.filesWritten ?? [])); + } - if (skillsToWrite.length > 0) { - const skillsOutputDir = resolveSkillsOutputDir(config, outputRoot); - const skillsWriteResult = await writeGeneratedFiles(skillsToWrite, skillsOutputDir, [], { - pruneStaleFiles: false, - }); - if (!skillsWriteResult.success) { - return { - success: false, - message: `Failed to write generated skill files: ${skillsWriteResult.errors?.join(', ')}`, - output: skillsOutputDir, - errors: skillsWriteResult.errors, - }; - } - allFilesWritten.push(...(skillsWriteResult.filesWritten ?? [])); + throwIfAborted(options.signal); + const coordinatedJobs: GeneratedFileWriteJob[] = writeJobs.map((job) => ({ + files: job.files, + outputDir: job.outputDir, + options: { + pruneStaleFiles: job.pruneStaleFiles, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + confirmOverwrite: options.yes, + showProgress: false, + }, + })); + const writeResults = await executeGeneratedWriteJobs( + coordinatedJobs, + options, + internalOptions + ); + const recovery = recoveryFields(writeResults); + const failedWrite = writeResults.find((result) => !result.success); + if (failedWrite) { + return { + success: false, + message: `Failed to ${internalOptions?.writeJobs ? 'plan' : 'write'} generated files: ${failedWrite.errors?.join(', ')}`, + output: outputRoot, + errors: failedWrite.errors, + filesWritten: allFilesWritten, + filesRemoved: allFilesRemoved, + ...planFields(writeResults), + ...recovery, + }; + } + if (!internalOptions?.writeJobs && !options.dryRun) { + for (const writeResult of writeResults) { + allFilesWritten.push(...(writeResult.filesWritten ?? [])); + allFilesRemoved.push(...(writeResult.filesRemoved ?? [])); } - } const generators = [ @@ -438,6 +784,9 @@ export async function generate( output: outputRoot, tables: tables.map((t) => t.name), filesWritten: allFilesWritten, + filesRemoved: allFilesRemoved, + ...planFields(writeResults), + ...recovery, pipelineData: { tables, customOperations: { @@ -450,7 +799,7 @@ export async function generate( } export function expandApiNamesToMultiTarget( - config: GraphQLSDKConfigTarget, + config: GraphQLSDKConfigTarget ): Record | null { const apiNames = config.db?.apiNames; if (!apiNames || apiNames.length <= 1) return null; @@ -473,16 +822,18 @@ export function expandApiNamesToMultiTarget( export function expandSchemaDirToMultiTarget( config: GraphQLSDKConfigTarget, + cwd: string = process.cwd() ): Record | null { const schemaDir = config.schemaDir; if (!schemaDir) return null; - const resolvedDir = path.resolve(schemaDir); + const resolvedDir = resolvePathFrom(path.resolve(cwd), schemaDir)!; if (!fs.existsSync(resolvedDir) || !fs.statSync(resolvedDir).isDirectory()) { return null; } - const graphqlFiles = fs.readdirSync(resolvedDir) + const graphqlFiles = fs + .readdirSync(resolvedDir) .filter((f) => f.endsWith('.graphql')) .sort(); @@ -512,32 +863,68 @@ export interface GenerateMultiOptions { unifiedCli?: CliConfig | boolean; /** Remove subdirectories in the output root that don't match any current target name. */ cleanStaleTargets?: boolean; + /** Base directory for relative config, source, and output paths. */ + cwd?: string; + overwriteModifiedGenerated?: boolean; + yes?: boolean; + onProgress?: (event: GenerateProgressEvent) => void; + signal?: AbortSignal; + /** Explicit environment used to resolve omitted PostgreSQL settings. */ + env?: Readonly>; } export interface GenerateMultiResult { results: Array<{ name: string; result: GenerateResult }>; hasError: boolean; + plans?: GenerationPlan[]; + planFingerprint?: string; + fileChanges?: FileChange[]; + warnings?: string[]; + recoveryPath?: string; + rollbackErrors?: string[]; } interface SharedPgpmSource { key: string; + description: string; ephemeralDb: EphemeralDbResult; deployed: boolean; } -function getPgpmSourceKey(pgpm: PgpmConfig): string | null { - if (pgpm.modulePath) return `module:${path.resolve(pgpm.modulePath)}`; +function getPgpmSourceKey(pgpm: PgpmConfig, cwd: string): string | null { + if (pgpm.modulePath) return `module:${resolvePathFrom(cwd, pgpm.modulePath)}`; if (pgpm.workspacePath && pgpm.moduleName) - return `workspace:${path.resolve(pgpm.workspacePath)}:${pgpm.moduleName}`; + return `workspace:${resolvePathFrom(cwd, pgpm.workspacePath)}:${pgpm.moduleName}`; return null; } -function getModulePathFromPgpm( +function getSharedPgpmSourceKey( pgpm: PgpmConfig, -): string { - if (pgpm.modulePath) return pgpm.modulePath; + cwd: string, + pgConfig: PgConfig +): string | null { + const sourceKey = getPgpmSourceKey(pgpm, cwd); + if (!sourceKey) return null; + const connectionFingerprint = createHash('sha256') + .update( + JSON.stringify([ + pgConfig.host, + pgConfig.port, + pgConfig.user, + pgConfig.password, + pgConfig.database, + ]) + ) + .digest('hex'); + return `${sourceKey}:connection:${connectionFingerprint}`; +} + +function getModulePathFromPgpm(pgpm: PgpmConfig, cwd: string): string { + if (pgpm.modulePath) return resolvePathFrom(cwd, pgpm.modulePath)!; if (pgpm.workspacePath && pgpm.moduleName) { - const workspace = new PgpmPackage(pgpm.workspacePath); + const workspace = new PgpmPackage( + resolvePathFrom(cwd, pgpm.workspacePath)! + ); const moduleProject = workspace.getModuleProject(pgpm.moduleName); const modulePath = moduleProject.getModulePath(); if (!modulePath) { @@ -545,56 +932,93 @@ function getModulePathFromPgpm( } return modulePath; } - throw new Error('Invalid PGPM config: requires modulePath or workspacePath+moduleName'); + throw new Error( + 'Invalid PGPM config: requires modulePath or workspacePath+moduleName' + ); } async function prepareSharedPgpmSources( configs: Record, - cliOverrides?: Partial, + cliOverrides: Partial | undefined, + cwd: string, + env: Readonly>, + onProgress?: (event: GenerateProgressEvent) => void, + signal?: AbortSignal ): Promise> { const sharedSources = new Map(); - const pgpmTargetCount = new Map(); + const pgpmTargets = new Map< + string, + { + count: number; + pgpm: PgpmConfig; + baseConfig: PgConfig; + description: string; + } + >(); + + throwIfAborted(signal); for (const name of Object.keys(configs)) { - const merged = { ...configs[name], ...(cliOverrides ?? {}) }; + throwIfAborted(signal); + const merged = mergeConfig(configs[name], cliOverrides ?? {}); const pgpm = merged.db?.pgpm; if (!pgpm) continue; - const key = getPgpmSourceKey(pgpm); + const baseConfig = resolvePgConfig(merged.db?.config, env); + const key = getSharedPgpmSourceKey(pgpm, cwd, baseConfig); if (!key) continue; - pgpmTargetCount.set(key, (pgpmTargetCount.get(key) ?? 0) + 1); + const existing = pgpmTargets.get(key); + pgpmTargets.set(key, { + count: (existing?.count ?? 0) + 1, + pgpm, + baseConfig, + description: getPgpmSourceKey(pgpm, cwd)!, + }); } - for (const [key, count] of pgpmTargetCount) { - if (count < 2) continue; + try { + for (const [key, target] of pgpmTargets) { + throwIfAborted(signal); + if (target.count < 2) continue; + + throwIfAborted(signal); + const ephemeralDb = createEphemeralDb({ + prefix: 'codegen_pgpm_shared_', + verbose: false, + baseConfig: target.baseConfig, + }); + const shared: SharedPgpmSource = { + key, + description: target.description, + ephemeralDb, + deployed: false, + }; + sharedSources.set(key, shared); - let pgpmConfig: PgpmConfig | undefined; - for (const name of Object.keys(configs)) { - const merged = { ...configs[name], ...(cliOverrides ?? {}) }; - const pgpm = merged.db?.pgpm; - if (pgpm && getPgpmSourceKey(pgpm) === key) { - pgpmConfig = pgpm; - break; + throwIfAborted(signal); + const modulePath = getModulePathFromPgpm(target.pgpm, cwd); + await deployPgpm(ephemeralDb.config, modulePath, false); + shared.deployed = true; + throwIfAborted(signal); + + const event: GenerateProgressEvent = { + phase: 'pgpm.prepare', + message: `[multi-target] Shared PGPM source deployed once for ${target.count} targets: ${target.description}`, + }; + if (onProgress) onProgress(event); + else console.log(event.message); + } + } catch (error) { + try { + for (const shared of sharedSources.values()) { + pgCache.delete(shared.ephemeralDb.config.database); + } + await pgCache.waitForDisposals(); + } finally { + for (const shared of sharedSources.values()) { + shared.ephemeralDb.teardown({ keepDb: false }); } } - if (!pgpmConfig) continue; - - const ephemeralDb = createEphemeralDb({ - prefix: 'codegen_pgpm_shared_', - verbose: false, - }); - - const modulePath = getModulePathFromPgpm(pgpmConfig); - await deployPgpm(ephemeralDb.config, modulePath, false); - - sharedSources.set(key, { - key, - ephemeralDb, - deployed: true, - }); - - console.log( - `[multi-target] Shared PGPM source deployed once for ${count} targets: ${key}`, - ); + throw error; } return sharedSources; @@ -603,11 +1027,17 @@ async function prepareSharedPgpmSources( function applySharedPgpmDb( config: GraphQLSDKConfigTarget, sharedSources: Map, + cwd: string, + env: Readonly> ): GraphQLSDKConfigTarget { const pgpm = config.db?.pgpm; if (!pgpm) return config; - const key = getPgpmSourceKey(pgpm); + const key = getSharedPgpmSourceKey( + pgpm, + cwd, + resolvePgConfig(config.db?.config, env) + ); if (!key) return config; const shared = sharedSources.get(key); @@ -629,17 +1059,98 @@ function applySharedPgpmDb( /** Manifest file listing generated target names, written to the output root. */ export const TARGETS_MANIFEST = '.targets'; +function isSafeTargetName(target: unknown): target is string { + return ( + typeof target === 'string' && + target.length > 0 && + target !== '.' && + target !== '..' && + !path.isAbsolute(target) && + !target.includes('/') && + !target.includes('\\') && + !target.includes('\0') + ); +} + +function readTargetNamesManifest(outputRoot: string): string[] | null { + const manifestPath = path.join(outputRoot, TARGETS_MANIFEST); + if (!fs.existsSync(manifestPath)) return null; + const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if ( + !Array.isArray(parsed) || + !parsed.every(isSafeTargetName) || + new Set(parsed).size !== parsed.length + ) { + throw new Error(`Invalid target ownership manifest: ${manifestPath}`); + } + return parsed; +} + +function getStaleTargetWriteJobs( + outputRoot: string, + currentTargetNames: string[] +): GeneratedFileWriteJob[] { + if (!currentTargetNames.every(isSafeTargetName)) { + throw new Error('Target names must be single safe path segments.'); + } + const previousTargets = readTargetNamesManifest(outputRoot); + if (!previousTargets) return []; + const current = new Set(currentTargetNames); + const jobs: GeneratedFileWriteJob[] = []; + + for (const target of previousTargets) { + if (current.has(target)) continue; + const targetDir = path.join(outputRoot, target); + if (!fs.existsSync(targetDir)) continue; + if (fs.lstatSync(targetDir).isSymbolicLink()) { + throw new Error( + `Refusing to clean a symbolic-link target directory: ${targetDir}` + ); + } + const generatedManifest = path.join(targetDir, GENERATED_FILES_MANIFEST); + // Legacy empty/unmanaged directories are preserved. Only a writer-owned + // target can participate in the transactional cleanup plan. + if (!fs.existsSync(generatedManifest)) continue; + jobs.push({ + files: [], + outputDir: targetDir, + options: { + pruneStaleFiles: true, + removeManifestWhenEmpty: true, + showProgress: false, + }, + }); + } + return jobs; +} + +function removeEmptyDirectoryTree(directory: string): void { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + removeEmptyDirectoryTree(path.join(directory, entry.name)); + } + } + try { + fs.rmdirSync(directory); + } catch { + // Files or non-empty child directories are unowned and must survive. + } +} + /** * Remove stale generated target directories from `outputRoot`. * Reads the `.targets` manifest (written by `generateMulti`) to know which * directories were previously generated. Only those are eligible for removal; * hand-written directories (e.g. `config/`, `utils/`) are never touched. * Returns the list of directory names that were removed. + * + * @deprecated Use generateMulti({ cleanStaleTargets: true }), which plans this + * cleanup and commits it with the rest of the generation operation. */ export function removeStaleTargetDirs( outputRoot: string, currentTargetNames: string[], - verbose?: boolean, + verbose?: boolean ): string[] { const removed: string[] = []; if (!fs.existsSync(outputRoot)) return removed; @@ -649,18 +1160,89 @@ export function removeStaleTargetDirs( let previousTargets: string[]; try { - previousTargets = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + previousTargets = readTargetNamesManifest(outputRoot) ?? []; } catch { return removed; } + if (!currentTargetNames.every(isSafeTargetName)) return removed; + const currentTargets = new Set(currentTargetNames); const staleTargets = previousTargets.filter((t) => !currentTargets.has(t)); for (const target of staleTargets) { const dirPath = path.join(outputRoot, target); - if (fs.existsSync(dirPath)) { - fs.rmSync(dirPath, { recursive: true, force: true }); + if (!fs.existsSync(dirPath)) continue; + if (fs.lstatSync(dirPath).isSymbolicLink()) continue; + + const entries = fs.readdirSync(dirPath); + if (entries.length === 0) { + fs.rmdirSync(dirPath); + removed.push(target); + if (verbose) { + console.log(`Removed stale target directory: ${target}`); + } + continue; + } + + const generatedManifestPath = path.join(dirPath, GENERATED_FILES_MANIFEST); + if (!fs.existsSync(generatedManifestPath)) continue; + + let ownedFiles: Record; + try { + const generatedManifest = JSON.parse( + fs.readFileSync(generatedManifestPath, 'utf8') + ) as { files?: Record }; + if ( + !generatedManifest.files || + typeof generatedManifest.files !== 'object' + ) { + continue; + } + ownedFiles = Object.fromEntries( + Object.entries(generatedManifest.files).map(([filePath, entry]) => { + if ( + !entry || + typeof entry.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(entry.sha256) || + path.isAbsolute(filePath) || + filePath.includes('\\') || + filePath + .split('/') + .some( + (segment) => + segment === '' || segment === '.' || segment === '..' + ) + ) { + throw new Error('Invalid generated manifest'); + } + return [filePath, { sha256: entry.sha256 }]; + }) + ); + } catch { + continue; + } + + const ownedPaths = Object.entries(ownedFiles).map(([filePath, entry]) => ({ + absolutePath: path.join(dirPath, ...filePath.split('/')), + sha256: entry.sha256, + })); + const modifiedOwnedFile = ownedPaths.some(({ absolutePath, sha256 }) => { + if (!fs.existsSync(absolutePath)) return false; + return ( + createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex') !== sha256 + ); + }); + if (modifiedOwnedFile) continue; + + for (const { absolutePath } of ownedPaths) { + fs.rmSync(absolutePath, { force: true }); + } + fs.rmSync(generatedManifestPath, { force: true }); + removeEmptyDirectoryTree(dirPath); + if (!fs.existsSync(dirPath)) { removed.push(target); if (verbose) { console.log(`Removed stale target directory: ${target}`); @@ -671,9 +1253,22 @@ export function removeStaleTargetDirs( } export async function generateMulti( - options: GenerateMultiOptions, + options: GenerateMultiOptions ): Promise { - const { configs, cliOverrides, verbose, dryRun, schema, unifiedCli, cleanStaleTargets } = options; + throwIfAborted(options.signal); + const { + configs, + cliOverrides, + verbose, + dryRun, + schema, + unifiedCli, + cleanStaleTargets, + overwriteModifiedGenerated, + yes, + } = options; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const env = options.env ?? {}; const names = Object.keys(configs); const results: Array<{ name: string; result: GenerateResult }> = []; let hasError = false; @@ -683,192 +1278,353 @@ export async function generateMulti( const useUnifiedCli = !schemaEnabled && !!unifiedCli && names.length > 1; const cliTargets: MultiTargetCliTarget[] = []; - - // Remove stale target directories before generating - if (cleanStaleTargets && names.length > 0 && !dryRun) { - const firstOutput = getConfigOptions(configs[names[0]]).output; - const outputRoot = path.dirname(firstOutput); - removeStaleTargetDirs(outputRoot, names, verbose); + const additionalWriteResults: WriteResult[] = []; + const collectedWriteJobs: GeneratedFileWriteJob[] = []; + let staleTargetWriteJobs: GeneratedFileWriteJob[] = []; + const additionalWriteJobs: Array<{ + files: Array<{ path: string; content: string }>; + outputDir: string; + adoptUnownedPaths?: string[]; + }> = []; + + // Stale cleanup is a read-only planning input. It is committed with every + // other generated root only after all targets have generated successfully. + if (cleanStaleTargets && names.length > 0) { + try { + throwIfAborted(options.signal); + const firstOutput = resolveTargetPaths( + getConfigOptions(configs[names[0]]), + cwd + ).output!; + const outputRoot = path.dirname(firstOutput); + staleTargetWriteJobs = getStaleTargetWriteJobs(outputRoot, names); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to plan stale target cleanup.'; + return { + results: [ + { + name: 'targets', + result: { + success: false, + message, + errors: [message], + }, + }, + ], + hasError: true, + plans: [], + fileChanges: [], + }; + } } - const sharedSources = await prepareSharedPgpmSources(configs, cliOverrides); + const sharedSources = await prepareSharedPgpmSources( + configs, + cliOverrides, + cwd, + env, + options.onProgress, + options.signal + ); try { - for (const name of names) { - const baseConfig: GraphQLSDKConfigTarget = { - ...configs[name], - ...(cliOverrides ?? {}), - }; - const targetConfig = applySharedPgpmDb(baseConfig, sharedSources); - const result = await generate( - { - ...targetConfig, - verbose, - dryRun, - schema: schemaEnabled - ? { ...schema, filename: schema?.filename ?? `${name}.graphql` } - : targetConfig.schema, - }, - useUnifiedCli ? { skipCli: true, targetName: name } : { targetName: name }, - ); - results.push({ name, result }); - if (!result.success) { - hasError = true; - } else { - const resolvedConfig = getConfigOptions(targetConfig); - const gens: string[] = []; - if (resolvedConfig.reactQuery) gens.push('React Query'); - if (resolvedConfig.orm || resolvedConfig.reactQuery || !!resolvedConfig.cli) gens.push('ORM'); - if (resolvedConfig.cli) gens.push('CLI'); - targetInfos.push({ - name, - output: resolvedConfig.output, - endpoint: resolvedConfig.endpoint || undefined, - generators: gens, - }); - - if (useUnifiedCli && result.pipelineData) { - const isAuthTarget = name === 'auth'; - cliTargets.push({ + for (const name of names) { + throwIfAborted(options.signal); + const baseConfig = mergeConfig(configs[name], cliOverrides ?? {}); + const targetConfig = applySharedPgpmDb( + baseConfig, + sharedSources, + cwd, + env + ); + const result = await generate( + { + ...targetConfig, + verbose, + dryRun, + cwd, + overwriteModifiedGenerated, + yes, + onProgress: options.onProgress, + signal: options.signal, + env, + schema: schemaEnabled + ? { ...schema, filename: schema?.filename ?? `${name}.graphql` } + : targetConfig.schema, + }, + useUnifiedCli + ? { + skipCli: true, + targetName: name, + writeJobs: collectedWriteJobs, + } + : { targetName: name, writeJobs: collectedWriteJobs } + ); + results.push({ name, result }); + if (!result.success) { + hasError = true; + } else { + const displayConfig = getConfigOptions(targetConfig); + const resolvedConfig = resolveTargetPaths(displayConfig, cwd); + const gens: string[] = []; + if (resolvedConfig.reactQuery) gens.push('React Query'); + if ( + resolvedConfig.orm || + resolvedConfig.reactQuery || + !!resolvedConfig.cli + ) + gens.push('ORM'); + if (resolvedConfig.cli) gens.push('CLI'); + targetInfos.push({ name, - endpoint: resolvedConfig.endpoint || '', - ormImportPath: `../${resolvedConfig.output.replace(/^\.\//, '')}/orm`, - tables: result.pipelineData.tables, - customOperations: result.pipelineData.customOperations, - isAuthTarget, - typeRegistry: result.pipelineData.customOperations.typeRegistry, + output: displayConfig.output!, + endpoint: resolvedConfig.endpoint || undefined, + generators: gens, }); + + if (useUnifiedCli && result.pipelineData) { + const isAuthTarget = name === 'auth'; + cliTargets.push({ + name, + endpoint: resolvedConfig.endpoint || '', + ormImportPath: `../${displayConfig.output!.replace(/^\.\//, '')}/orm`, + tables: result.pipelineData.tables, + customOperations: result.pipelineData.customOperations, + isAuthTarget, + typeRegistry: result.pipelineData.customOperations.typeRegistry, + }); + } } } - } - if (useUnifiedCli && cliTargets.length > 0 && !dryRun) { - const cliConfig = typeof unifiedCli === 'object' ? unifiedCli : {}; - const toolName = cliConfig.toolName ?? 'app'; - const firstTargetConfig = configs[names[0]]; - const { files } = generateMultiTargetCli({ - toolName, - builtinNames: cliConfig.builtinNames, - targets: cliTargets, - entryPoint: cliConfig.entryPoint, - }); + if (useUnifiedCli && cliTargets.length > 0) { + throwIfAborted(options.signal); + const cliConfig = typeof unifiedCli === 'object' ? unifiedCli : {}; + const toolName = cliConfig.toolName ?? 'app'; + const firstTargetConfig = configs[names[0]]; + const { files } = generateMultiTargetCli({ + toolName, + builtinNames: cliConfig.builtinNames, + targets: cliTargets, + entryPoint: cliConfig.entryPoint, + }); + throwIfAborted(options.signal); - const cliFilesToWrite = files.map((file) => ({ - path: path.posix.join('cli', file.fileName), - content: file.content, - })); + const cliFilesToWrite = files.map((file) => ({ + path: path.posix.join('cli', file.fileName), + content: file.content, + })); - const firstTargetDocsConfig = names.length > 0 && configs[names[0]]?.docs; - const docsConfig = resolveDocsConfig(firstTargetDocsConfig); - const { resolveBuiltinNames } = await import('./codegen/cli'); - const builtinNames = resolveBuiltinNames( - cliTargets.map((t) => t.name), - cliConfig.builtinNames, - ); + const firstTargetDocsConfig = names.length > 0 && configs[names[0]]?.docs; + const docsConfig = resolveDocsConfig(firstTargetDocsConfig); + const { resolveBuiltinNames } = await import('./codegen/cli'); + const builtinNames = resolveBuiltinNames( + cliTargets.map((t) => t.name), + cliConfig.builtinNames + ); - // Merge all target type registries into a combined registry for docs generation - const combinedRegistry = new Map(); - for (const t of cliTargets) { - if (t.typeRegistry) { - for (const [key, value] of t.typeRegistry) { - combinedRegistry.set(key, value); + // Merge all target type registries into a combined registry for docs generation + const combinedRegistry = new Map< + string, + import('../types/schema').ResolvedType + >(); + for (const t of cliTargets) { + if (t.typeRegistry) { + for (const [key, value] of t.typeRegistry) { + combinedRegistry.set(key, value); + } } } - } - - const docsInput: MultiTargetDocsInput = { - toolName, - builtinNames, - registry: combinedRegistry.size > 0 ? combinedRegistry : undefined, - targets: cliTargets.map((t) => ({ - name: t.name, - endpoint: t.endpoint, - tables: t.tables, - customOperations: [ - ...(t.customOperations?.queries ?? []), - ...(t.customOperations?.mutations ?? []), - ], - isAuthTarget: t.isAuthTarget, - })), - }; - if (docsConfig.readme) { - const readme = generateMultiTargetReadme(docsInput); - cliFilesToWrite.push({ path: path.posix.join('cli', readme.fileName), content: readme.content }); - } - if (docsConfig.agents) { - const agents = generateMultiTargetAgentsDocs(docsInput); - cliFilesToWrite.push({ path: path.posix.join('cli', agents.fileName), content: agents.content }); - } - const { writeGeneratedFiles: writeFiles } = await import('./output'); - await writeFiles(cliFilesToWrite, '.', [], { pruneStaleFiles: false }); - - if (docsConfig.skills) { - const cliSkillsToWrite = generateMultiTargetSkills(docsInput).map((skill) => ({ - path: skill.fileName, - content: skill.content, - })); + const docsInput: MultiTargetDocsInput = { + toolName, + builtinNames, + registry: combinedRegistry.size > 0 ? combinedRegistry : undefined, + targets: cliTargets.map((t) => ({ + name: t.name, + endpoint: t.endpoint, + tables: t.tables, + customOperations: [ + ...(t.customOperations?.queries ?? []), + ...(t.customOperations?.mutations ?? []), + ], + isAuthTarget: t.isAuthTarget, + })), + }; - const firstTargetResolved = getConfigOptions({ - ...(firstTargetConfig ?? {}), - ...(cliOverrides ?? {}), + if (docsConfig.readme) { + const readme = generateMultiTargetReadme(docsInput); + cliFilesToWrite.push({ + path: path.posix.join('cli', readme.fileName), + content: readme.content, + }); + } + if (docsConfig.agents) { + const agents = generateMultiTargetAgentsDocs(docsInput); + cliFilesToWrite.push({ + path: path.posix.join('cli', agents.fileName), + content: agents.content, + }); + } + additionalWriteJobs.push({ + files: cliFilesToWrite, + outputDir: cwd, }); - const skillsOutputDir = resolveSkillsOutputDir( - firstTargetResolved, - firstTargetResolved.output, - ); - await writeFiles(cliSkillsToWrite, skillsOutputDir, [], { pruneStaleFiles: false }); + if (docsConfig.skills) { + const cliSkillsToWrite = generateMultiTargetSkills(docsInput).map( + (skill) => ({ + path: skill.fileName, + content: skill.content, + }) + ); + + const firstTargetResolved = getConfigOptions({ + ...(firstTargetConfig ?? {}), + ...(cliOverrides ?? {}), + }); + const skillsOutputDir = resolveSkillsOutputDir( + firstTargetResolved, + firstTargetResolved.output, + cwd + ); + additionalWriteJobs.push({ + files: cliSkillsToWrite, + outputDir: skillsOutputDir, + }); + } } - } - - // Generate root-root README and barrel if multi-target - if (names.length > 1 && targetInfos.length > 0 && !dryRun) { - const { writeGeneratedFiles: writeFiles } = await import('./output'); - const rootReadme = generateRootRootReadme(targetInfos); - await writeFiles( - [{ path: rootReadme.fileName, content: rootReadme.content }], - '.', - [], - { pruneStaleFiles: false }, - ); + // Generate root-root README and barrel if multi-target + if (names.length > 1 && targetInfos.length > 0) { + throwIfAborted(options.signal); + const rootReadme = generateRootRootReadme(targetInfos); + additionalWriteJobs.push({ + files: [{ path: rootReadme.fileName, content: rootReadme.content }], + outputDir: cwd, + }); - // Write a root barrel (index.ts) that re-exports each target as a - // namespace so the package has a single entry-point. Derive the - // common output root from the first target's output path. - const successfulNames = results - .filter((r) => r.result.success) - .map((r) => r.name); - if (successfulNames.length > 0) { - const firstOutput = getConfigOptions(configs[successfulNames[0]]).output; - const outputRoot = path.dirname(firstOutput); - const barrelContent = generateMultiTargetBarrel(successfulNames); - await writeFiles( - [{ path: 'index.ts', content: barrelContent }], - outputRoot, - [], - { pruneStaleFiles: false }, - ); + // Write a root barrel (index.ts) that re-exports each target as a + // namespace so the package has a single entry-point. Derive the + // common output root from the first target's output path. + const successfulNames = results + .filter((r) => r.result.success) + .map((r) => r.name); + if (successfulNames.length > 0) { + const firstOutput = resolveTargetPaths( + getConfigOptions(configs[successfulNames[0]]), + cwd + ).output!; + const outputRoot = path.dirname(firstOutput); + const barrelContent = generateMultiTargetBarrel(successfulNames); + additionalWriteJobs.push({ + files: [ + { path: 'index.ts', content: barrelContent }, + { + path: TARGETS_MANIFEST, + content: `${JSON.stringify(successfulNames.sort())}\n`, + }, + ], + outputDir: outputRoot, + adoptUnownedPaths: [TARGETS_MANIFEST], + }); + } + } - // Write manifest so removeStaleTargetDirs knows which dirs are generated - fs.writeFileSync( - path.join(outputRoot, TARGETS_MANIFEST), - JSON.stringify(successfulNames.sort()) + '\n', - ); + if (cleanStaleTargets && names.length === 1 && targetInfos.length === 1) { + const firstOutput = resolveTargetPaths( + getConfigOptions({ + ...configs[names[0]], + ...(cliOverrides ?? {}), + }), + cwd + ).output!; + additionalWriteJobs.push({ + files: [ + { + path: TARGETS_MANIFEST, + content: `${JSON.stringify(names)}\n`, + }, + ], + outputDir: path.dirname(firstOutput), + adoptUnownedPaths: [TARGETS_MANIFEST], + }); } - } + if (!hasError) { + throwIfAborted(options.signal); + const finalJobs: GeneratedFileWriteJob[] = [ + ...collectedWriteJobs, + ...staleTargetWriteJobs.map((job) => ({ + ...job, + options: { + ...(job.options ?? {}), + overwriteModifiedGenerated, + confirmOverwrite: yes, + }, + })), + ...additionalWriteJobs.map((job) => ({ + files: job.files, + outputDir: job.outputDir, + options: { + pruneStaleFiles: false, + overwriteModifiedGenerated, + confirmOverwrite: yes, + adoptUnownedPaths: job.adoptUnownedPaths, + showProgress: false, + }, + })), + ]; + const batch = await writeGeneratedFileJobs(finalJobs, { + dryRun, + showProgress: false, + signal: options.signal, + }); + additionalWriteResults.push(...batch.results); + if (!batch.success) { + hasError = true; + const errors = batch.errors ?? ['Failed to apply generated files.']; + const recovery = recoveryFields(batch.results); + results.push({ + name: 'write', + result: { + success: false, + message: errors.join(', '), + errors, + ...planFields(batch.results), + ...recovery, + }, + }); + } + } } finally { for (const shared of sharedSources.values()) { const keepDb = Object.values(configs).some((c) => c.db?.keepDb); - // Release pg-cache pool for this ephemeral database before dropping - // deployPgpm() caches connections that must be closed first - pgCache.delete(shared.ephemeralDb.config.database); - await pgCache.waitForDisposals(); - shared.ephemeralDb.teardown({ keepDb }); + try { + // deployPgpm() caches connections that must be closed before dropping. + pgCache.delete(shared.ephemeralDb.config.database); + await pgCache.waitForDisposals(); + } finally { + shared.ephemeralDb.teardown({ keepDb }); + } } } - return { results, hasError }; + const targetPlans = results.flatMap(({ result }) => result.plans ?? []); + const coordinatedPlans = additionalWriteResults.flatMap((result) => + result.plan ? [result.plan] : [] + ); + const plans = coordinatedPlans.length > 0 ? coordinatedPlans : targetPlans; + const recovery = recoveryFields(additionalWriteResults); + return { + results, + hasError, + plans, + planFingerprint: combinePlanFingerprint(plans), + fileChanges: plans.flatMap((plan) => plan.changes), + ...recovery, + }; } diff --git a/graphql/codegen/src/core/introspect/fetch-schema.ts b/graphql/codegen/src/core/introspect/fetch-schema.ts index 269fd54685..b345278cda 100644 --- a/graphql/codegen/src/core/introspect/fetch-schema.ts +++ b/graphql/codegen/src/core/introspect/fetch-schema.ts @@ -6,6 +6,12 @@ import http from 'node:http'; import https from 'node:https'; import type { IntrospectionQueryResponse } from '../../types/introspection'; +import { + getAbortReason, + rethrowIfCancelled, + throwIfAborted, +} from '../cancellation'; +import { endpointForDisplay } from '../sensitive-values'; import { SCHEMA_INTROSPECTION_QUERY } from './schema-query'; interface HttpResponse { @@ -22,18 +28,47 @@ function makeRequest( options: http.RequestOptions, body: string, timeout: number, + signal?: AbortSignal ): Promise { + throwIfAborted(signal); + return new Promise((resolve, reject) => { const protocol = url.protocol === 'https:' ? https : http; + let settled = false; + let req!: http.ClientRequest; + + const cleanup = (): void => { + signal?.removeEventListener('abort', onAbort); + }; + const succeed = (value: HttpResponse): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const fail = (reason: unknown): void => { + if (settled) return; + settled = true; + cleanup(); + reject(reason); + }; - const req = protocol.request(url, options, (res) => { + const onAbort = (): void => { + const reason = getAbortReason(signal!); + // Destroy the request/socket immediately, while rejecting with the exact + // caller-owned reason (which is not necessarily an Error instance). + req.destroy(reason instanceof Error ? reason : undefined); + fail(reason); + }; + + req = protocol.request(url, options, (res) => { let data = ''; res.setEncoding('utf8'); res.on('data', (chunk: string) => { data += chunk; }); res.on('end', () => { - resolve({ + succeed({ statusCode: res.statusCode || 0, statusMessage: res.statusMessage || '', data, @@ -41,11 +76,20 @@ function makeRequest( }); }); - req.on('error', reject); + req.on('error', (error) => { + fail(signal?.aborted ? getAbortReason(signal) : error); + }); + + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } req.setTimeout(timeout, () => { - req.destroy(); - reject(new Error(`Request timeout after ${timeout}ms`)); + const error = new Error(`Request timeout after ${timeout}ms`); + req.destroy(error); + fail(error); }); req.write(body); @@ -62,6 +106,8 @@ export interface FetchSchemaOptions { headers?: Record; /** Request timeout in milliseconds (default: 30000) */ timeout?: number; + /** Cancels an in-flight introspection request and destroys its socket. */ + signal?: AbortSignal; } export interface FetchSchemaResult { @@ -75,9 +121,18 @@ export interface FetchSchemaResult { * Fetch the full schema introspection from a GraphQL endpoint */ export async function fetchSchema( - options: FetchSchemaOptions, + options: FetchSchemaOptions ): Promise { - const { endpoint, authorization, headers = {}, timeout = 30000 } = options; + const { + endpoint, + authorization, + headers = {}, + timeout = 30000, + signal, + } = options; + const displayEndpoint = endpointForDisplay(endpoint); + + throwIfAborted(signal); const url = new URL(endpoint); @@ -102,7 +157,13 @@ export async function fetchSchema( }; try { - const response = await makeRequest(url, requestOptions, body, timeout); + const response = await makeRequest( + url, + requestOptions, + body, + timeout, + signal + ); if (response.statusCode < 200 || response.statusCode >= 300) { return { @@ -141,6 +202,7 @@ export async function fetchSchema( statusCode: response.statusCode, }; } catch (err) { + rethrowIfCancelled(err, signal); if (err instanceof Error) { if (err.message.includes('timeout')) { return { @@ -153,7 +215,7 @@ export async function fetchSchema( if (errorCode === 'ECONNREFUSED') { return { success: false, - error: `Connection refused - is the server running at ${endpoint}?`, + error: `Connection refused - is the server running at ${displayEndpoint}?`, }; } if (errorCode === 'ENOTFOUND') { @@ -165,7 +227,7 @@ export async function fetchSchema( if (errorCode === 'ECONNRESET') { return { success: false, - error: `Connection reset by server at ${endpoint}`, + error: `Connection reset by server at ${displayEndpoint}`, }; } diff --git a/graphql/codegen/src/core/introspect/index.ts b/graphql/codegen/src/core/introspect/index.ts index e0f4f6cfcf..471aa54d3c 100644 --- a/graphql/codegen/src/core/introspect/index.ts +++ b/graphql/codegen/src/core/introspect/index.ts @@ -20,6 +20,7 @@ export { createSchemaSource, EndpointSchemaSource, FileSchemaSource, + resolvePgConfig, SchemaSourceError, validateSourceOptions, } from './source'; diff --git a/graphql/codegen/src/core/introspect/source/api-schemas.ts b/graphql/codegen/src/core/introspect/source/api-schemas.ts index d4a17b62b5..752c16a83e 100644 --- a/graphql/codegen/src/core/introspect/source/api-schemas.ts +++ b/graphql/codegen/src/core/introspect/source/api-schemas.ts @@ -5,8 +5,11 @@ * by querying the services_public.api_schemas table. */ import { Pool } from 'pg'; -import { getPgPool } from 'pg-cache'; -import { getPgEnvOptions } from 'pg-env'; +import { getPgEnvOptions, type PgConfig } from 'pg-env'; + +export type DatabasePoolFactory = (config: PgConfig) => Pool; + +export type ExplicitEnvironment = Readonly>; /** * Result of validating services schema requirements @@ -27,7 +30,7 @@ export interface ServicesSchemaValidation { * @returns Validation result */ export async function validateServicesSchemas( - pool: Pool, + pool: Pool ): Promise { try { // Check for services_public.apis table @@ -94,7 +97,7 @@ export async function validateServicesSchemas( */ export async function resolveApiSchemas( pool: Pool, - apiNames: string[], + apiNames: string[] ): Promise { // First validate that the required schemas exist const validation = await validateServicesSchemas(pool); @@ -112,13 +115,13 @@ export async function resolveApiSchemas( WHERE api.name = ANY($1) ORDER BY ms.schema_name `, - [apiNames], + [apiNames] ); if (result.rows.length === 0) { throw new Error( `No schemas found for API names: ${apiNames.join(', ')}. ` + - 'Ensure the APIs exist and have schemas assigned in services_public.api_schemas.', + 'Ensure the APIs exist and have schemas assigned in services_public.api_schemas.' ); } @@ -126,31 +129,37 @@ export async function resolveApiSchemas( } /** - * Create a database pool for the given database name or connection string + * Resolve a complete PostgreSQL configuration from explicit inputs. * - * @param database - Database name or connection string - * @returns Database connection pool + * Configuration values override the supplied environment. Ambient process + * environment is deliberately never consulted by this reusable boundary. */ -export function createDatabasePool(database: string): Pool { - // Check if it's a connection string or just a database name +export function resolvePgConfig( + overrides: Partial = {}, + env: ExplicitEnvironment = {} +): PgConfig { + const config = getPgEnvOptions(overrides, { ...env }); + const database = config.database; const isConnectionString = database.startsWith('postgres://') || database.startsWith('postgresql://'); - if (isConnectionString) { - // Parse connection string and extract database name - // Format: postgres://user:password@host:port/database - const url = new URL(database); - const dbName = url.pathname.slice(1); // Remove leading slash - return getPgPool({ - host: url.hostname, - port: parseInt(url.port || '5432', 10), - user: url.username, - password: url.password, - database: dbName, - }); - } + if (!isConnectionString) return config; + + const url = new URL(database); + const dbName = decodeURIComponent(url.pathname.slice(1)); + return { + host: url.hostname, + port: Number.parseInt(url.port || '5432', 10), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: dbName, + }; +} - // Use environment variables for connection, just override database name - const config = getPgEnvOptions({ database }); - return getPgPool(config); +/** Create a caller-owned, operation-scoped PostgreSQL pool. */ +export function createDatabasePool( + config: PgConfig, + factory: DatabasePoolFactory = (poolConfig) => new Pool(poolConfig) +): Pool { + return factory(config); } diff --git a/graphql/codegen/src/core/introspect/source/database.ts b/graphql/codegen/src/core/introspect/source/database.ts index 15668fce47..a3eb906bbf 100644 --- a/graphql/codegen/src/core/introspect/source/database.ts +++ b/graphql/codegen/src/core/introspect/source/database.ts @@ -3,28 +3,28 @@ * * Loads GraphQL schema directly from a PostgreSQL database using PostGraphile * introspection and converts it to introspection format. - * Also returns _meta table metadata when available (via MetaSchemaPlugin cache). + * Also returns metadata captured from the same isolated schema build. */ import { buildSchema, introspectionFromSchema } from 'graphql'; -import { buildSchemaSDL, _cachedTablesMeta } from 'graphile-schema'; +import { buildSchemaArtifacts } from 'graphile-schema'; +import type { PgConfig } from 'pg-env'; import type { IntrospectionQueryResponse } from '../../../types/introspection'; +import { databaseForDisplay } from '../../sensitive-values'; import { createDatabasePool, + type DatabasePoolFactory, resolveApiSchemas, - validateServicesSchemas, } from './api-schemas'; import type { MetaTableInfo, SchemaSource, SchemaSourceResult } from './types'; import { SchemaSourceError } from './types'; export interface DatabaseSchemaSourceOptions { /** - * Database name or connection string - * Can be a simple database name (uses PGHOST, PGPORT, PGUSER, PGPASSWORD env vars) - * or a full connection string (postgres://user:pass@host:port/dbname) + * Complete, explicitly resolved PostgreSQL configuration. */ - database: string; + pgConfig: PgConfig; /** * PostgreSQL schemas to include in introspection @@ -38,6 +38,9 @@ export interface DatabaseSchemaSourceOptions { * Mutually exclusive with schemas */ apiNames?: string[]; + + /** @internal Test seam for creating operation-scoped pools. */ + poolFactory?: DatabasePoolFactory; } /** @@ -54,94 +57,88 @@ export class DatabaseSchemaSource implements SchemaSource { } async fetch(): Promise { - const { database, apiNames } = this.options; + const { pgConfig, apiNames, poolFactory } = this.options; + const pool = createDatabasePool(pgConfig, poolFactory); - // Resolve schemas - either from explicit schemas option or from apiNames - let schemas: string[]; - if (apiNames && apiNames.length > 0) { - // Validate services schemas exist at the beginning for database mode - const pool = createDatabasePool(database); - try { - const validation = await validateServicesSchemas(pool); - if (!validation.valid) { - throw new SchemaSourceError(validation.error!, this.describe()); + try { + // Resolve schemas - either from explicit schemas option or from apiNames + let schemas: string[]; + if (apiNames && apiNames.length > 0) { + try { + schemas = await resolveApiSchemas(pool, apiNames); + } catch (err) { + if (err instanceof SchemaSourceError) throw err; + throw new SchemaSourceError( + `Failed to resolve API schemas: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); } - schemas = await resolveApiSchemas(pool, apiNames); + } else { + schemas = this.options.schemas ?? ['public']; + } + + // The same operation-owned pool backs API resolution and schema building. + let artifacts; + try { + artifacts = await buildSchemaArtifacts({ pool, schemas }); } catch (err) { - if (err instanceof SchemaSourceError) throw err; throw new SchemaSourceError( - `Failed to resolve API schemas: ${err instanceof Error ? err.message : 'Unknown error'}`, + `Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } - } else { - schemas = this.options.schemas ?? ['public']; - } - // Build SDL from database (MetaSchemaPlugin populates _cachedTablesMeta as a side-effect) - let sdl: string; - try { - sdl = await buildSchemaSDL({ - database, - schemas, - }); - } catch (err) { - throw new SchemaSourceError( - `Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`, - this.describe(), - err instanceof Error ? err : undefined, - ); - } - - // Validate non-empty - if (!sdl.trim()) { - throw new SchemaSourceError( - 'Database introspection returned empty schema', - this.describe(), - ); - } + const { sdl, tablesMeta } = artifacts; + if (!sdl.trim()) { + throw new SchemaSourceError( + 'Database introspection returned empty schema', + this.describe() + ); + } - // Parse SDL to GraphQL schema - let schema; - try { - schema = buildSchema(sdl); - } catch (err) { - throw new SchemaSourceError( - `Invalid GraphQL SDL from database: ${err instanceof Error ? err.message : 'Unknown error'}`, - this.describe(), - err instanceof Error ? err : undefined, - ); - } + let schema; + try { + schema = buildSchema(sdl); + } catch (err) { + throw new SchemaSourceError( + `Invalid GraphQL SDL from database: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } - // Convert to introspection format - let introspectionResult; - try { - introspectionResult = introspectionFromSchema(schema); - } catch (err) { - throw new SchemaSourceError( - `Failed to generate introspection: ${err instanceof Error ? err.message : 'Unknown error'}`, - this.describe(), - err instanceof Error ? err : undefined, - ); - } + let introspectionResult; + try { + introspectionResult = introspectionFromSchema(schema); + } catch (err) { + throw new SchemaSourceError( + `Failed to generate introspection: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } - // Convert graphql-js introspection result to our mutable type - const introspection: IntrospectionQueryResponse = JSON.parse( - JSON.stringify(introspectionResult), - ) as IntrospectionQueryResponse; + const introspection: IntrospectionQueryResponse = JSON.parse( + JSON.stringify(introspectionResult) + ) as IntrospectionQueryResponse; - return { - introspection, - tablesMeta: [..._cachedTablesMeta] as MetaTableInfo[], - }; + return { + introspection, + tablesMeta: tablesMeta as MetaTableInfo[], + }; + } finally { + await pool.end(); + } } describe(): string { - const { database, schemas, apiNames } = this.options; + const { pgConfig, schemas, apiNames } = this.options; + const displayDatabase = databaseForDisplay(pgConfig.database); if (apiNames && apiNames.length > 0) { - return `database: ${database} (apiNames: ${apiNames.join(', ')})`; + return `database: ${displayDatabase} (apiNames: ${apiNames.join(', ')})`; } - return `database: ${database} (schemas: ${(schemas ?? ['public']).join(', ')})`; + return `database: ${displayDatabase} (schemas: ${(schemas ?? ['public']).join(', ')})`; } } diff --git a/graphql/codegen/src/core/introspect/source/endpoint.ts b/graphql/codegen/src/core/introspect/source/endpoint.ts index bc952290e9..5c79f49773 100644 --- a/graphql/codegen/src/core/introspect/source/endpoint.ts +++ b/graphql/codegen/src/core/introspect/source/endpoint.ts @@ -5,6 +5,7 @@ * Wraps the existing fetchSchema() function with the SchemaSource interface. */ import { fetchSchema } from '../fetch-schema'; +import { endpointForDisplay } from '../../sensitive-values'; import type { SchemaSource, SchemaSourceResult } from './types'; import { SchemaSourceError } from './types'; @@ -40,25 +41,26 @@ export class EndpointSchemaSource implements SchemaSource { this.options = options; } - async fetch(): Promise { + async fetch(signal?: AbortSignal): Promise { const result = await fetchSchema({ endpoint: this.options.endpoint, authorization: this.options.authorization, headers: this.options.headers, timeout: this.options.timeout, + signal, }); if (!result.success) { throw new SchemaSourceError( result.error ?? 'Unknown error fetching schema', - this.describe(), + this.describe() ); } if (!result.data) { throw new SchemaSourceError( 'No introspection data returned', - this.describe(), + this.describe() ); } @@ -68,6 +70,6 @@ export class EndpointSchemaSource implements SchemaSource { } describe(): string { - return `endpoint: ${this.options.endpoint}`; + return `endpoint: ${endpointForDisplay(this.options.endpoint)}`; } } diff --git a/graphql/codegen/src/core/introspect/source/index.ts b/graphql/codegen/src/core/introspect/source/index.ts index 8587d0bf19..beaefabfe8 100644 --- a/graphql/codegen/src/core/introspect/source/index.ts +++ b/graphql/codegen/src/core/introspect/source/index.ts @@ -15,6 +15,7 @@ export * from './pgpm-module'; export * from './types'; import type { DbConfig } from '../../../types/config'; +import { resolvePgConfig, type DatabasePoolFactory } from './api-schemas'; import { DatabaseSchemaSource } from './database'; import { EndpointSchemaSource } from './endpoint'; import { FileSchemaSource } from './file'; @@ -98,6 +99,12 @@ export interface CreateSchemaSourceOptions { * Request timeout in milliseconds (for endpoint requests) */ timeout?: number; + + /** Explicit environment used to fill omitted PostgreSQL settings. */ + env?: Readonly>; + + /** @internal Test seam for creating operation-scoped pools. */ + poolFactory?: DatabasePoolFactory; } /** @@ -111,7 +118,7 @@ export type SourceMode = | 'pgpm-workspace'; export function detectSourceMode( - options: CreateSchemaSourceOptions, + options: CreateSchemaSourceOptions ): SourceMode | null { if (options.endpoint) return 'endpoint'; if (options.schemaFile) return 'schemaFile'; @@ -141,9 +148,12 @@ export function detectSourceMode( * @throws Error if no valid source is provided */ export function createSchemaSource( - options: CreateSchemaSourceOptions, + options: CreateSchemaSourceOptions ): SchemaSource { const mode = detectSourceMode(options); + const pgConfig = options.db + ? resolvePgConfig(options.db.config, options.env ?? {}) + : undefined; switch (mode) { case 'schemaFile': @@ -160,12 +170,13 @@ export function createSchemaSource( }); case 'database': - // Database mode uses db.config for connection (falls back to env vars) - // and db.schemas or db.apiNames for schema selection + // Database mode resolves db.config over the explicitly supplied env and + // passes one complete configuration into the operation-owned source. return new DatabaseSchemaSource({ - database: options.db?.config?.database ?? '', + pgConfig: pgConfig!, schemas: options.db?.schemas, apiNames: options.db?.apiNames, + poolFactory: options.poolFactory, }); case 'pgpm-module': @@ -174,6 +185,8 @@ export function createSchemaSource( schemas: options.db?.schemas, apiNames: options.db?.apiNames, keepDb: options.db?.keepDb, + pgConfig: pgConfig!, + poolFactory: options.poolFactory, }); case 'pgpm-workspace': @@ -183,11 +196,13 @@ export function createSchemaSource( schemas: options.db?.schemas, apiNames: options.db?.apiNames, keepDb: options.db?.keepDb, + pgConfig: pgConfig!, + poolFactory: options.poolFactory, }); default: throw new Error( - 'No source specified. Use one of: endpoint, schemaFile, or db (with optional pgpm for module deployment).', + 'No source specified. Use one of: endpoint, schemaFile, or db (with optional pgpm for module deployment).' ); } } @@ -201,7 +216,7 @@ export function validateSourceOptions(options: CreateSchemaSourceOptions): { } { // Count primary sources const sources = [options.endpoint, options.schemaFile, options.db].filter( - Boolean, + Boolean ); if (sources.length === 0) { diff --git a/graphql/codegen/src/core/introspect/source/pgpm-module.ts b/graphql/codegen/src/core/introspect/source/pgpm-module.ts index 48becdb6ca..d2d19b03da 100644 --- a/graphql/codegen/src/core/introspect/source/pgpm-module.ts +++ b/graphql/codegen/src/core/introspect/source/pgpm-module.ts @@ -9,15 +9,21 @@ */ import { PgpmPackage } from '@pgpmjs/core'; import { buildSchema, introspectionFromSchema } from 'graphql'; -import { getPgPool, pgCache } from 'pg-cache'; +import { pgCache } from 'pg-cache'; +import type { Pool } from 'pg'; +import type { PgConfig } from 'pg-env'; import { createEphemeralDb, type EphemeralDbResult } from 'pgsql-client'; import { deployPgpm } from 'pgsql-seed'; -import { buildSchemaSDL } from 'graphile-schema'; +import { buildSchemaArtifacts } from 'graphile-schema'; import type { IntrospectionQueryResponse } from '../../../types/introspection'; -import { resolveApiSchemas, validateServicesSchemas } from './api-schemas'; -import type { SchemaSource, SchemaSourceResult } from './types'; +import { + createDatabasePool, + type DatabasePoolFactory, + resolveApiSchemas, +} from './api-schemas'; +import type { MetaTableInfo, SchemaSource, SchemaSourceResult } from './types'; import { SchemaSourceError } from './types'; /** @@ -48,6 +54,12 @@ export interface PgpmModulePathOptions { * @default false */ keepDb?: boolean; + + /** Complete base connection used to create the ephemeral database. */ + pgConfig: PgConfig; + + /** @internal Test seam for creating operation-scoped pools. */ + poolFactory?: DatabasePoolFactory; } /** @@ -83,6 +95,12 @@ export interface PgpmWorkspaceOptions { * @default false */ keepDb?: boolean; + + /** Complete base connection used to create the ephemeral database. */ + pgConfig: PgConfig; + + /** @internal Test seam for creating operation-scoped pools. */ + poolFactory?: DatabasePoolFactory; } export type PgpmModuleSchemaSourceOptions = @@ -93,7 +111,7 @@ export type PgpmModuleSchemaSourceOptions = * Type guard to check if options use direct module path */ export function isPgpmModulePathOptions( - options: PgpmModuleSchemaSourceOptions, + options: PgpmModuleSchemaSourceOptions ): options is PgpmModulePathOptions { return 'pgpmModulePath' in options; } @@ -102,7 +120,7 @@ export function isPgpmModulePathOptions( * Type guard to check if options use workspace + module name */ export function isPgpmWorkspaceOptions( - options: PgpmModuleSchemaSourceOptions, + options: PgpmModuleSchemaSourceOptions ): options is PgpmWorkspaceOptions { return 'pgpmWorkspacePath' in options && 'pgpmModuleName' in options; } @@ -133,7 +151,7 @@ export class PgpmModuleSchemaSource implements SchemaSource { throw new SchemaSourceError( `Failed to resolve module path: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } @@ -142,7 +160,7 @@ export class PgpmModuleSchemaSource implements SchemaSource { if (!pkg.isInModule()) { throw new SchemaSourceError( `Not a valid PGPM module: ${modulePath}. Directory must contain pgpm.plan and .control files.`, - this.describe(), + this.describe() ); } @@ -151,18 +169,22 @@ export class PgpmModuleSchemaSource implements SchemaSource { this.ephemeralDb = createEphemeralDb({ prefix: 'codegen_pgpm_', verbose: false, + baseConfig: this.options.pgConfig, }); } catch (err) { throw new SchemaSourceError( `Failed to create ephemeral database: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } const { config: dbConfig, teardown } = this.ephemeralDb; + let pool: Pool | undefined; try { + pool = createDatabasePool(dbConfig, this.options.poolFactory); + // Deploy the module to the ephemeral database try { await deployPgpm(dbConfig, modulePath, false); @@ -170,27 +192,21 @@ export class PgpmModuleSchemaSource implements SchemaSource { throw new SchemaSourceError( `Failed to deploy PGPM module: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } // Resolve schemas - either from explicit schemas option or from apiNames (after deployment) let schemas: string[]; if (apiNames && apiNames.length > 0) { - // For PGPM mode, validate services schemas AFTER migration - const pool = getPgPool(dbConfig); try { - const validation = await validateServicesSchemas(pool); - if (!validation.valid) { - throw new SchemaSourceError(validation.error!, this.describe()); - } schemas = await resolveApiSchemas(pool, apiNames); } catch (err) { if (err instanceof SchemaSourceError) throw err; throw new SchemaSourceError( `Failed to resolve API schemas: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } } else { @@ -198,25 +214,22 @@ export class PgpmModuleSchemaSource implements SchemaSource { } // Build SDL from the deployed database - let sdl: string; + let artifacts; try { - sdl = await buildSchemaSDL({ - database: dbConfig.database, - schemas, - }); + artifacts = await buildSchemaArtifacts({ pool, schemas }); } catch (err) { throw new SchemaSourceError( `Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } - // Validate non-empty + const { sdl, tablesMeta } = artifacts; if (!sdl.trim()) { throw new SchemaSourceError( 'Database introspection returned empty schema', - this.describe(), + this.describe() ); } @@ -228,7 +241,7 @@ export class PgpmModuleSchemaSource implements SchemaSource { throw new SchemaSourceError( `Invalid GraphQL SDL from database: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } @@ -240,29 +253,31 @@ export class PgpmModuleSchemaSource implements SchemaSource { throw new SchemaSourceError( `Failed to generate introspection: ${err instanceof Error ? err.message : 'Unknown error'}`, this.describe(), - err instanceof Error ? err : undefined, + err instanceof Error ? err : undefined ); } // Convert graphql-js introspection result to our mutable type const introspection: IntrospectionQueryResponse = JSON.parse( - JSON.stringify(introspectionResult), + JSON.stringify(introspectionResult) ) as IntrospectionQueryResponse; - return { introspection }; + return { + introspection, + tablesMeta: tablesMeta as MetaTableInfo[], + }; } finally { - // Release pg-cache pool for this ephemeral database before dropping - // deployPgpm() and getPgPool() cache connections that must be closed first - pgCache.delete(dbConfig.database); - await pgCache.waitForDisposals(); - - // Clean up the ephemeral database - teardown({ keepDb }); - - if (keepDb) { - console.log( - `[pgpm-module] Kept ephemeral database: ${dbConfig.database}`, - ); + try { + if (pool) await pool.end(); + } finally { + try { + // deployPgpm() may retain a pg-cache connection that must be closed + // before the ephemeral database can be dropped. Introspection never uses it. + pgCache.delete(dbConfig.database); + await pgCache.waitForDisposals(); + } finally { + teardown({ keepDb }); + } } } } diff --git a/graphql/codegen/src/core/introspect/source/types.ts b/graphql/codegen/src/core/introspect/source/types.ts index 949d5e70a6..70bff32d86 100644 --- a/graphql/codegen/src/core/introspect/source/types.ts +++ b/graphql/codegen/src/core/introspect/source/types.ts @@ -55,7 +55,7 @@ export interface SchemaSource { * Fetch or load the GraphQL introspection data * @throws SchemaSourceError if fetching fails */ - fetch(): Promise; + fetch(signal?: AbortSignal): Promise; /** * Human-readable description of the source (for logging) @@ -78,7 +78,7 @@ export class SchemaSourceError extends Error { /** * Original error that caused the failure */ - public readonly cause?: Error, + public readonly cause?: Error ) { super(`${message} (source: ${source})`); this.name = 'SchemaSourceError'; diff --git a/graphql/codegen/src/core/output/index.ts b/graphql/codegen/src/core/output/index.ts index e4cbae29b0..cb0a826788 100644 --- a/graphql/codegen/src/core/output/index.ts +++ b/graphql/codegen/src/core/output/index.ts @@ -3,7 +3,17 @@ */ export { + GENERATED_FILES_MANIFEST, type GeneratedFile, + type GeneratedFilesManifest, + type GenerationPlan, + type FileChange, + type FileChangeAction, + type GeneratedFileWriteJob, + type WriteBatchOptions, + type WriteBatchResult, + planGeneratedFiles, + writeGeneratedFileJobs, writeGeneratedFiles, type WriteOptions, type WriteResult, diff --git a/graphql/codegen/src/core/output/writer.ts b/graphql/codegen/src/core/output/writer.ts index 2f6de1a139..0b20eb8e6f 100644 --- a/graphql/codegen/src/core/output/writer.ts +++ b/graphql/codegen/src/core/output/writer.ts @@ -1,9 +1,12 @@ /** - * File writing utilities + * Safe output planning and application for generated files. * - * Pure functions for writing generated files to disk and formatting them. - * These are core utilities that can be used programmatically or by the CLI. + * Generation is deliberately split into a read-only planning phase and a + * transactional apply phase. The ownership manifest is the authority for + * pruning and modified-file detection; directory scans are never used to + * decide what may be deleted. */ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -11,38 +14,298 @@ import type { GeneratedFile } from '../codegen'; export type { GeneratedFile }; -/** - * Result of writing files - */ +export const GENERATED_FILES_MANIFEST = '.constructive-codegen-manifest.json'; + +const MANIFEST_VERSION = 1; +const GENERATOR_NAME = '@constructive-io/graphql-codegen'; +const LEGACY_GENERATED_MARKERS = [ + '@generated by @constructive-io/graphql-codegen', + '@constructive-io/graphql-codegen - DO NOT EDIT', +]; + +interface ManifestFileEntry { + sha256: string; +} + +export interface GeneratedFilesManifest { + version: 1; + generator: typeof GENERATOR_NAME; + files: Record; +} + +export type FileChangeAction = + | 'create' + | 'update' + | 'delete' + | 'unchanged' + | 'conflict'; + +export interface FileChange { + /** POSIX path relative to the output directory. */ + path: string; + absolutePath: string; + action: FileChangeAction; + previousHash?: string; + generatedHash?: string; + reason?: + | 'new-generated-file' + | 'generated-content-changed' + | 'generated-file-removed' + | 'generated-content-unchanged' + | 'modified-generated-file' + | 'unowned-existing-file' + | 'legacy-generated-file' + | 'retained-owned-file' + | 'ownership-manifest'; +} + +export interface GenerationPlan { + version: 1; + outputDir: string; + manifestPath: string; + fingerprint: string; + changes: FileChange[]; +} + +/** Result of planning or writing files. */ export interface WriteResult { success: boolean; filesWritten?: string[]; filesRemoved?: string[]; errors?: string[]; + /** Non-fatal cleanup or recovery information. */ + warnings?: string[]; + /** Retained transaction directory when rollback or cleanup was incomplete. */ + recoveryPath?: string; + rollbackErrors?: string[]; + plan?: GenerationPlan; + planFingerprint?: string; } -/** - * Options for writing files - */ export interface WriteOptions { - /** Show progress output (default: true) */ + /** Show progress output (default: true). */ showProgress?: boolean; - /** Format files with oxfmt after writing (default: true) */ + /** Format TypeScript files with oxfmt before planning (default: true). */ formatFiles?: boolean; - /** Remove stale .ts files in outputDir that are not in current file list (default: false) */ + /** Remove previously owned files absent from the current file set. */ pruneStaleFiles?: boolean; + /** Compute and return the complete plan without mutating the filesystem. */ + dryRun?: boolean; + /** Permit replacing or deleting owned files changed since generation. */ + overwriteModifiedGenerated?: boolean; + /** Required confirmation paired with overwriteModifiedGenerated. */ + confirmOverwrite?: boolean; + /** Known legacy generated paths that predate the ownership manifest. */ + adoptUnownedPaths?: string[]; + /** Delete the ownership manifest when the desired owned file set is empty. */ + removeManifestWhenEmpty?: boolean; +} + +export interface GeneratedFileWriteJob { + files: GeneratedFile[]; + outputDir: string; + options?: Omit; +} + +export interface WriteBatchResult { + success: boolean; + results: WriteResult[]; + errors?: string[]; +} + +export interface WriteBatchOptions extends Pick< + WriteOptions, + 'dryRun' | 'showProgress' +> { + /** Cancellation is honored until the coordinated commit begins. */ + signal?: AbortSignal; +} + +interface PreparedFile { + relativePath: string; + absolutePath: string; + content: string; + hash: string; +} + +interface PreparedPlan { + publicPlan: GenerationPlan; + desiredFiles: Map; + manifestContent?: string; + manifestChanged: boolean; + removeOutputDirWhenEmpty: boolean; } type OxfmtFormatFn = ( fileName: string, sourceText: string, - options?: Record, + options?: Record ) => Promise<{ code: string; errors: unknown[] }>; +function hashContent(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} + +function isPathInside(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ); +} + /** - * Dynamically import oxfmt's format function - * Returns null if oxfmt is not available + * Resolve an output directory through the deepest existing ancestor. This lets + * an explicitly symlinked cwd/output root work while giving all descendant + * containment checks one canonical boundary. */ +function canonicalizeOutputDir(outputDir: string): string { + const absolute = path.resolve(outputDir); + const missingSegments: string[] = []; + let existing = absolute; + + while (true) { + try { + fs.lstatSync(existing); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + const parent = path.dirname(existing); + if (parent === existing) throw error; + missingSegments.unshift(path.basename(existing)); + existing = parent; + } + } + + const canonicalAncestor = fs.realpathSync.native(existing); + const existingStat = fs.statSync(canonicalAncestor); + if (missingSegments.length === 0 && !existingStat.isDirectory()) { + throw new Error(`Generated output path is not a directory: ${absolute}`); + } + return path.join(canonicalAncestor, ...missingSegments); +} + +/** Refuse any symlink below the canonical output root. */ +function assertContainedPath(outputDir: string, candidate: string): void { + if (!isPathInside(outputDir, candidate)) { + throw new Error(`Generated file escapes output directory: ${candidate}`); + } + + let current = candidate; + while (current !== outputDir) { + try { + if (fs.lstatSync(current).isSymbolicLink()) { + throw new Error( + `Generated file path traverses a symlink below the output directory: ${current}` + ); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Generated file escapes output directory: ${candidate}`); + } + current = parent; + } +} + +function normalizeRelativePath(filePath: string): string { + if (!filePath || path.isAbsolute(filePath)) { + throw new Error(`Generated file path must be relative: ${filePath}`); + } + + const normalized = filePath.replace(/\\/g, '/'); + const segments = normalized.split('/'); + if ( + segments.some( + (segment) => segment === '' || segment === '.' || segment === '..' + ) + ) { + throw new Error(`Generated file path is unsafe: ${filePath}`); + } + + const relativePath = path.posix.normalize(normalized); + if ( + relativePath === GENERATED_FILES_MANIFEST || + relativePath.startsWith('../') + ) { + throw new Error(`Generated file path is reserved or unsafe: ${filePath}`); + } + + return relativePath; +} + +function isManifest(value: unknown): value is GeneratedFilesManifest { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + if ( + candidate.version !== MANIFEST_VERSION || + candidate.generator !== GENERATOR_NAME || + !candidate.files || + typeof candidate.files !== 'object' || + Array.isArray(candidate.files) + ) { + return false; + } + + return Object.entries(candidate.files).every(([filePath, entry]) => { + try { + if (normalizeRelativePath(filePath) !== filePath) return false; + } catch { + return false; + } + return ( + !!entry && + typeof entry === 'object' && + typeof entry.sha256 === 'string' && + /^[a-f0-9]{64}$/.test(entry.sha256) + ); + }); +} + +function readManifest(manifestPath: string): { + manifest: GeneratedFilesManifest; + content?: string; +} { + if (!fs.existsSync(manifestPath)) { + return { + manifest: { + version: MANIFEST_VERSION, + generator: GENERATOR_NAME, + files: {}, + }, + }; + } + + const content = fs.readFileSync(manifestPath, 'utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error( + `Generated ownership manifest is not valid JSON: ${manifestPath}` + ); + } + + if (!isManifest(parsed)) { + throw new Error( + `Generated ownership manifest has an unsupported or invalid shape: ${manifestPath}` + ); + } + + return { manifest: parsed, content }; +} + +function isLegacyGenerated(content: Buffer): boolean { + const prefix = content.subarray(0, 4096).toString('utf8'); + return LEGACY_GENERATED_MARKERS.some((marker) => prefix.includes(marker)); +} + async function getOxfmtFormat(): Promise { try { const oxfmt = await import('oxfmt'); @@ -52,13 +315,10 @@ async function getOxfmtFormat(): Promise { } } -/** - * Format a single file's content using oxfmt programmatically - */ async function formatFileContent( fileName: string, content: string, - formatFn: OxfmtFormatFn, + formatFn: OxfmtFormatFn ): Promise { try { const result = await formatFn(fileName, content, { @@ -69,159 +329,851 @@ async function formatFileContent( }); return result.code; } catch { - // If formatting fails, return original content return content; } } -/** - * Write generated files to disk - * - * @param files - Array of files to write - * @param outputDir - Base output directory - * @param subdirs - Subdirectories to create - * @param options - Write options - */ -export async function writeGeneratedFiles( +function createManifestContent( + files: Record +): string { + const sortedFiles = Object.fromEntries( + Object.entries(files).sort(([left], [right]) => left.localeCompare(right)) + ); + return `${JSON.stringify( + { + version: MANIFEST_VERSION, + generator: GENERATOR_NAME, + files: sortedFiles, + } satisfies GeneratedFilesManifest, + null, + 2 + )}\n`; +} + +function fingerprintChanges( + changes: FileChange[], + manifestContent: string | undefined +): string { + const canonical = changes + .map((change) => ({ + path: change.path, + action: change.action, + previousHash: change.previousHash, + generatedHash: change.generatedHash, + reason: change.reason, + })) + .sort((left, right) => left.path.localeCompare(right.path)); + return hashContent( + JSON.stringify({ + changes: canonical, + manifestHash: + manifestContent === undefined ? null : hashContent(manifestContent), + }) + ); +} + +async function prepareGenerationPlan( files: GeneratedFile[], outputDir: string, - subdirs: string[], - options: WriteOptions = {}, -): Promise { + options: WriteOptions +): Promise { const { - showProgress = true, formatFiles = true, pruneStaleFiles = false, + overwriteModifiedGenerated = false, + confirmOverwrite = false, + adoptUnownedPaths = [], + removeManifestWhenEmpty = false, } = options; - const errors: string[] = []; - const written: string[] = []; - const removed: string[] = []; - const total = files.length; - const isTTY = process.stdout.isTTY; - // Ensure output directory exists - try { - fs.mkdirSync(outputDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { - success: false, - errors: [`Failed to create output directory: ${message}`], - }; + if (overwriteModifiedGenerated && !confirmOverwrite) { + throw new Error( + 'overwriteModifiedGenerated requires explicit confirmOverwrite confirmation' + ); } - // Create subdirectories - for (const subdir of subdirs) { - const subdirPath = path.join(outputDir, subdir); + const resolvedOutputDir = canonicalizeOutputDir(outputDir); + const manifestPath = path.join(resolvedOutputDir, GENERATED_FILES_MANIFEST); + assertContainedPath(resolvedOutputDir, manifestPath); + const { manifest, content: previousManifestContent } = + readManifest(manifestPath); + const desiredFiles = new Map(); + const explicitlyAdoptedPaths = new Set( + adoptUnownedPaths.map(normalizeRelativePath) + ); + const formatFn = formatFiles ? await getOxfmtFormat() : null; + + for (const file of files) { + const relativePath = normalizeRelativePath(file.path); + if (desiredFiles.has(relativePath)) { + throw new Error(`Duplicate generated file path: ${relativePath}`); + } + + const content = + formatFn && relativePath.endsWith('.ts') + ? await formatFileContent(relativePath, file.content, formatFn) + : file.content; + desiredFiles.set(relativePath, { + relativePath, + absolutePath: path.join(resolvedOutputDir, ...relativePath.split('/')), + content, + hash: hashContent(content), + }); + assertContainedPath( + resolvedOutputDir, + path.join(resolvedOutputDir, ...relativePath.split('/')) + ); + } + + const changes: FileChange[] = []; + const nextManifestFiles: Record = {}; + + for (const desired of desiredFiles.values()) { + const previousEntry = manifest.files[desired.relativePath]; + let existing: Buffer | undefined; try { - fs.mkdirSync(subdirPath, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - errors.push(`Failed to create directory ${subdirPath}: ${message}`); + existing = fs.readFileSync(desired.absolutePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + if (!existing) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'create', + generatedHash: desired.hash, + reason: 'new-generated-file', + }); + nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; + continue; + } + + const existingHash = hashContent(existing); + const isExplicitlyAdopted = explicitlyAdoptedPaths.has( + desired.relativePath + ); + const isRecognizedLegacyFile = isLegacyGenerated(existing); + if (!previousEntry && !isRecognizedLegacyFile && !isExplicitlyAdopted) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'conflict', + previousHash: existingHash, + generatedHash: desired.hash, + reason: 'unowned-existing-file', + }); + continue; + } + + if (existingHash === desired.hash) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'unchanged', + previousHash: existingHash, + generatedHash: desired.hash, + reason: 'generated-content-unchanged', + }); + nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; + continue; } + + const modifiedOwnedFile = + !!previousEntry && existingHash !== previousEntry.sha256; + const conflict = modifiedOwnedFile; + + if (conflict && !overwriteModifiedGenerated) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'conflict', + previousHash: existingHash, + generatedHash: desired.hash, + reason: modifiedOwnedFile + ? 'modified-generated-file' + : 'modified-generated-file', + }); + } else { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'update', + previousHash: existingHash, + generatedHash: desired.hash, + reason: modifiedOwnedFile + ? 'modified-generated-file' + : !previousEntry + ? 'legacy-generated-file' + : 'generated-content-changed', + }); + } + nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; } - if (errors.length > 0) { - return { success: false, errors }; + for (const [relativePath, previousEntry] of Object.entries(manifest.files)) { + if (desiredFiles.has(relativePath)) continue; + const absolutePath = path.join( + resolvedOutputDir, + ...relativePath.split('/') + ); + assertContainedPath(resolvedOutputDir, absolutePath); + let existing: Buffer | undefined; + try { + existing = fs.readFileSync(absolutePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (!existing) continue; + + const existingHash = hashContent(existing); + if (!pruneStaleFiles) { + changes.push({ + path: relativePath, + absolutePath, + action: 'unchanged', + previousHash: existingHash, + reason: 'retained-owned-file', + }); + nextManifestFiles[relativePath] = previousEntry; + continue; + } + + const modified = existingHash !== previousEntry.sha256; + if (modified && !overwriteModifiedGenerated) { + changes.push({ + path: relativePath, + absolutePath, + action: 'conflict', + previousHash: existingHash, + reason: 'modified-generated-file', + }); + nextManifestFiles[relativePath] = previousEntry; + } else { + changes.push({ + path: relativePath, + absolutePath, + action: 'delete', + previousHash: existingHash, + reason: modified ? 'modified-generated-file' : 'generated-file-removed', + }); + } } - if (pruneStaleFiles) { - const expectedFiles = new Set( - files.map((file) => path.resolve(outputDir, file.path)), + changes.sort((left, right) => left.path.localeCompare(right.path)); + const manifestContent = + removeManifestWhenEmpty && Object.keys(nextManifestFiles).length === 0 + ? undefined + : createManifestContent(nextManifestFiles); + const manifestChanged = previousManifestContent !== manifestContent; + changes.push({ + path: GENERATED_FILES_MANIFEST, + absolutePath: manifestPath, + action: manifestChanged + ? manifestContent === undefined + ? 'delete' + : previousManifestContent === undefined + ? 'create' + : 'update' + : 'unchanged', + previousHash: + previousManifestContent === undefined + ? undefined + : hashContent(previousManifestContent), + generatedHash: + manifestContent === undefined ? undefined : hashContent(manifestContent), + reason: 'ownership-manifest', + }); + + const publicPlan: GenerationPlan = { + version: 1, + outputDir: resolvedOutputDir, + manifestPath, + fingerprint: fingerprintChanges(changes, manifestContent), + changes, + }; + + return { + publicPlan, + desiredFiles, + manifestContent, + manifestChanged, + removeOutputDirWhenEmpty: removeManifestWhenEmpty, + }; +} + +/** + * Compute a complete generation plan without mutating the target filesystem. + */ +export async function planGeneratedFiles( + files: GeneratedFile[], + outputDir: string, + options: Omit = {} +): Promise { + return (await prepareGenerationPlan(files, outputDir, options)).publicPlan; +} + +function removeEmptyParents(startDir: string, stopDir: string): void { + let current = startDir; + while (current !== stopDir && current.startsWith(`${stopDir}${path.sep}`)) { + try { + fs.rmdirSync(current); + } catch { + return; + } + current = path.dirname(current); + } +} + +interface CommittedChange { + change: FileChange; + backupCreated: boolean; + targetWritten: boolean; +} + +interface PlanTransaction { + prepared: PreparedPlan; + applicableChanges: FileChange[]; + transactionRoot: string; + stagedRoot: string; + backupRoot: string; + committed: CommittedChange[]; + written: string[]; + removed: string[]; + manifestBackupCreated: boolean; + manifestPublished: boolean; +} + +function conflictErrors(plan: GenerationPlan): string[] { + return plan.changes + .filter((change) => change.action === 'conflict') + .map( + (conflict) => + `${conflict.reason === 'unowned-existing-file' ? 'Refusing to overwrite unowned file' : 'Generated file was modified'}: ${conflict.absolutePath}` ); - const existingTsFiles = findTsFiles(outputDir); +} - for (const existingFile of existingTsFiles) { - const absolutePath = path.resolve(existingFile); - if (expectedFiles.has(absolutePath)) continue; - try { - fs.rmSync(absolutePath, { force: true }); - removed.push(absolutePath); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - errors.push(`Failed to remove stale file ${absolutePath}: ${message}`); +function createPlanTransaction(prepared: PreparedPlan): PlanTransaction { + const outputParent = path.dirname(prepared.publicPlan.outputDir); + fs.mkdirSync(outputParent, { recursive: true }); + const transactionRoot = fs.mkdtempSync( + path.join( + outputParent, + `.${path.basename(prepared.publicPlan.outputDir)}.codegen-transaction-` + ) + ); + return { + prepared, + applicableChanges: prepared.publicPlan.changes.filter( + (change) => + change.path !== GENERATED_FILES_MANIFEST && + (change.action === 'create' || + change.action === 'update' || + change.action === 'delete') + ), + transactionRoot, + stagedRoot: path.join(transactionRoot, 'staged'), + backupRoot: path.join(transactionRoot, 'backup'), + committed: [], + written: [], + removed: [], + manifestBackupCreated: false, + manifestPublished: false, + }; +} + +function stagePlan(transaction: PlanTransaction): void { + const { prepared, applicableChanges, stagedRoot } = transaction; + for (const change of applicableChanges) { + if (change.action === 'delete') continue; + const desired = prepared.desiredFiles.get(change.path); + if (!desired) { + throw new Error(`Missing prepared content for ${change.path}`); + } + const stagedPath = path.join(stagedRoot, ...change.path.split('/')); + fs.mkdirSync(path.dirname(stagedPath), { recursive: true }); + fs.writeFileSync(stagedPath, desired.content, 'utf8'); + } +} + +function readOptionalHash(filePath: string): string | undefined { + try { + return hashContent(fs.readFileSync(filePath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function assertPlanStillCurrent(transaction: PlanTransaction): void { + const { publicPlan } = transaction.prepared; + const currentOutputDir = canonicalizeOutputDir(publicPlan.outputDir); + if (currentOutputDir !== publicPlan.outputDir) { + throw new Error( + `Generated output path changed after planning: ${publicPlan.outputDir}` + ); + } + + for (const change of publicPlan.changes) { + assertContainedPath(publicPlan.outputDir, change.absolutePath); + const observedHash = readOptionalHash(change.absolutePath); + if (observedHash !== change.previousHash) { + throw new Error( + `Generated output changed after planning: ${change.absolutePath}` + ); + } + } +} + +function commitPlanFiles( + transaction: PlanTransaction, + showProgress: boolean, + progress: { current: number; total: number } +): void { + const { prepared, applicableChanges, backupRoot, stagedRoot } = transaction; + fs.mkdirSync(prepared.publicPlan.outputDir, { recursive: true }); + + for (const change of applicableChanges) { + progress.current += 1; + if ( + showProgress && + (progress.current === 1 || + progress.current === progress.total || + progress.current % 100 === 0) + ) { + console.log( + `Applying generated files: ${progress.current}/${progress.total}` + ); + } + + const targetPath = change.absolutePath; + let backupCreated = false; + if (fs.existsSync(targetPath)) { + const backupPath = path.join(backupRoot, ...change.path.split('/')); + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.renameSync(targetPath, backupPath); + backupCreated = true; + } + const committedChange: CommittedChange = { + change, + backupCreated, + targetWritten: false, + }; + transaction.committed.push(committedChange); + + if (change.action !== 'delete') { + const stagedPath = path.join(stagedRoot, ...change.path.split('/')); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.renameSync(stagedPath, targetPath); + committedChange.targetWritten = true; + transaction.written.push(targetPath); + } else { + transaction.removed.push(targetPath); + } + } +} + +function publishPlanManifest(transaction: PlanTransaction): void { + const { prepared, backupRoot } = transaction; + if (!prepared.manifestChanged) return; + + const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); + if (fs.existsSync(prepared.publicPlan.manifestPath)) { + fs.mkdirSync(path.dirname(manifestBackupPath), { recursive: true }); + fs.renameSync(prepared.publicPlan.manifestPath, manifestBackupPath); + transaction.manifestBackupCreated = true; + } + + if (prepared.manifestContent === undefined) return; + const manifestTempPath = path.join( + prepared.publicPlan.outputDir, + `.${GENERATED_FILES_MANIFEST}.${process.pid}.${Date.now()}.tmp` + ); + fs.writeFileSync(manifestTempPath, prepared.manifestContent, { + encoding: 'utf8', + mode: 0o600, + }); + fs.renameSync(manifestTempPath, prepared.publicPlan.manifestPath); + transaction.manifestPublished = true; +} + +function rollbackTransaction(transaction: PlanTransaction): string[] { + const errors: string[] = []; + const attempt = (description: string, operation: () => void): void => { + try { + operation(); + } catch (error) { + errors.push( + `${description}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + const { prepared, backupRoot } = transaction; + + if (transaction.manifestPublished) { + attempt('remove partially published ownership manifest', () => { + if (fs.existsSync(prepared.publicPlan.manifestPath)) { + fs.rmSync(prepared.publicPlan.manifestPath, { force: true }); + } + }); + } + const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); + if (transaction.manifestBackupCreated) { + attempt('restore previous ownership manifest', () => { + if (!fs.existsSync(manifestBackupPath)) { + throw new Error(`Backup is missing: ${manifestBackupPath}`); } + fs.mkdirSync(path.dirname(prepared.publicPlan.manifestPath), { + recursive: true, + }); + fs.renameSync(manifestBackupPath, prepared.publicPlan.manifestPath); + }); + } + + for (const committedChange of [...transaction.committed].reverse()) { + const { change, backupCreated, targetWritten } = committedChange; + if (targetWritten) { + attempt(`remove partially written file ${change.absolutePath}`, () => { + if (fs.existsSync(change.absolutePath)) { + fs.rmSync(change.absolutePath, { force: true }); + } + }); + } + const backupPath = path.join(backupRoot, ...change.path.split('/')); + if (backupCreated) { + attempt(`restore previous file ${change.absolutePath}`, () => { + if (!fs.existsSync(backupPath)) { + throw new Error(`Backup is missing: ${backupPath}`); + } + fs.mkdirSync(path.dirname(change.absolutePath), { recursive: true }); + fs.renameSync(backupPath, change.absolutePath); + }); + } else { + attempt(`remove empty directories for ${change.absolutePath}`, () => { + removeEmptyParents( + path.dirname(change.absolutePath), + prepared.publicPlan.outputDir + ); + }); } } - // Get oxfmt format function if formatting is enabled - const formatFn = formatFiles ? await getOxfmtFormat() : null; - if (formatFiles && !formatFn && showProgress) { - console.warn('Warning: oxfmt not available, files will not be formatted'); + return errors; +} + +function resultForPrepared( + prepared: PreparedPlan, + overrides: Partial +): WriteResult { + return { + success: true, + filesWritten: [], + filesRemoved: [], + plan: prepared.publicPlan, + planFingerprint: prepared.publicPlan.fingerprint, + ...overrides, + }; +} + +async function applyPreparedPlans( + preparedPlans: PreparedPlan[], + showProgress: boolean +): Promise { + const allConflictErrors = preparedPlans.flatMap(({ publicPlan }) => + conflictErrors(publicPlan) + ); + if (allConflictErrors.length > 0) { + return { + success: false, + errors: allConflictErrors, + results: preparedPlans.map((prepared) => { + const errors = conflictErrors(prepared.publicPlan); + return resultForPrepared(prepared, { + success: errors.length === 0, + ...(errors.length === 0 ? {} : { errors }), + }); + }), + }; } - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const filePath = path.join(outputDir, file.path); + const transactions: PlanTransaction[] = []; + const total = preparedPlans.reduce( + (count, prepared) => + count + + prepared.publicPlan.changes.filter( + (change) => + change.path !== GENERATED_FILES_MANIFEST && + (change.action === 'create' || + change.action === 'update' || + change.action === 'delete') + ).length, + 0 + ); + const progress = { current: 0, total }; - // Show progress - if (showProgress) { - const progress = Math.round(((i + 1) / total) * 100); - if (isTTY) { - process.stdout.write( - `\rWriting files: ${i + 1}/${total} (${progress}%)`, + try { + for (const prepared of preparedPlans) { + const hasWork = + prepared.manifestChanged || + prepared.publicPlan.changes.some( + (change) => + change.path !== GENERATED_FILES_MANIFEST && + (change.action === 'create' || + change.action === 'update' || + change.action === 'delete') ); - } else if (i % 100 === 0 || i === total - 1) { - // Non-TTY: periodic updates for CI/CD - console.log(`Writing files: ${i + 1}/${total}`); - } + if (!hasWork) continue; + const transaction = createPlanTransaction(prepared); + transactions.push(transaction); + stagePlan(transaction); } - // Ensure parent directory exists - const parentDir = path.dirname(filePath); - try { - fs.mkdirSync(parentDir, { recursive: true }); - } catch { - // Ignore if already exists + for (const transaction of transactions) { + assertPlanStillCurrent(transaction); + } + for (const transaction of transactions) { + commitPlanFiles(transaction, showProgress, progress); } + // Every ownership manifest is published after every content mutation. + for (const transaction of transactions) { + publishPlanManifest(transaction); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const rollbackByPlan = new Map(); + for (const transaction of [...transactions].reverse()) { + rollbackByPlan.set( + transaction.prepared.publicPlan, + rollbackTransaction(transaction) + ); + } + + for (const transaction of transactions) { + const rollbackErrors = rollbackByPlan.get( + transaction.prepared.publicPlan + )!; + if (rollbackErrors.length === 0) { + try { + fs.rmSync(transaction.transactionRoot, { + recursive: true, + force: true, + }); + } catch (cleanupError) { + rollbackErrors.push( + `remove transaction directory: ${cleanupError instanceof Error ? cleanupError.message : 'Unknown error'}` + ); + } + } + } + + const errors = [`Failed to apply generated file plans: ${message}`]; + return { + success: false, + errors, + results: preparedPlans.map((prepared) => { + const transaction = transactions.find( + (candidate) => candidate.prepared === prepared + ); + const rollbackErrors = rollbackByPlan.get(prepared.publicPlan) ?? []; + return resultForPrepared(prepared, { + success: false, + errors, + ...(rollbackErrors.length === 0 + ? {} + : { + rollbackErrors, + recoveryPath: transaction?.transactionRoot, + }), + }); + }), + }; + } - // Format content if oxfmt is available and file is TypeScript - let content = file.content; - if (formatFn && file.path.endsWith('.ts')) { - content = await formatFileContent(file.path, content, formatFn); + // At this point every content change and ownership manifest is committed. + // Cleanup cannot safely change that outcome: deleting one transaction's + // backups and then rolling every root back would make earlier roots + // unrestorable. Retain any transaction directory that cannot be removed and + // report it as a warning instead. + const cleanupWarningsByPlan = new Map(); + for (const transaction of transactions) { + for (const { change } of transaction.committed) { + if (change.action === 'delete') { + removeEmptyParents( + path.dirname(change.absolutePath), + transaction.prepared.publicPlan.outputDir + ); + } } try { - fs.writeFileSync(filePath, content, 'utf-8'); - written.push(filePath); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - errors.push(`Failed to write ${filePath}: ${message}`); + fs.rmSync(transaction.transactionRoot, { + recursive: true, + force: true, + }); + } catch (cleanupError) { + cleanupWarningsByPlan.set(transaction.prepared.publicPlan, [ + `Generated files were committed, but the transaction directory could not be removed: ${ + cleanupError instanceof Error ? cleanupError.message : 'Unknown error' + }`, + ]); } - } - // Clear progress line - if (showProgress && isTTY) { - process.stdout.write('\r' + ' '.repeat(40) + '\r'); + if (transaction.prepared.removeOutputDirWhenEmpty) { + try { + fs.rmdirSync(transaction.prepared.publicPlan.outputDir); + } catch { + // Unowned files or directories keep the output root alive. + } + } } + const byPlan = new Map( + transactions.map((transaction) => [ + transaction.prepared.publicPlan, + transaction, + ]) + ); return { - success: errors.length === 0, - filesWritten: written, - filesRemoved: removed, - errors: errors.length > 0 ? errors : undefined, + success: true, + results: preparedPlans.map((prepared) => { + const transaction = byPlan.get(prepared.publicPlan); + const warnings = cleanupWarningsByPlan.get(prepared.publicPlan) ?? []; + return resultForPrepared(prepared, { + filesWritten: transaction?.written ?? [], + filesRemoved: transaction?.removed ?? [], + ...(warnings.length === 0 + ? {} + : { + warnings, + recoveryPath: transaction?.transactionRoot, + }), + }); + }), }; } /** - * Recursively find all .ts files in a directory + * Plan and optionally apply generated files. + * + * Dry runs execute the same formatting, ownership, conflict, and pruning + * decisions as a real write, but never create the output or transaction dirs. */ -function findTsFiles(dir: string): string[] { - const files: string[] = []; +export async function writeGeneratedFiles( + files: GeneratedFile[], + outputDir: string, + _subdirs: string[], + options: WriteOptions = {} +): Promise { + const { dryRun, ...jobOptions } = options; + const batch = await writeGeneratedFileJobs( + [ + { + files, + outputDir, + options: jobOptions, + }, + ], + { + dryRun, + showProgress: options.showProgress, + } + ); + return ( + batch.results[0] ?? { + success: false, + errors: batch.errors ?? ['Failed to prepare generated files.'], + } + ); +} - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...findTsFiles(fullPath)); - } else if (entry.isFile() && entry.name.endsWith('.ts')) { - files.push(fullPath); - } +function mergeWriteJobs( + jobs: GeneratedFileWriteJob[] +): GeneratedFileWriteJob[] { + const grouped = new Map(); + for (const job of jobs) { + const outputDir = canonicalizeOutputDir(job.outputDir); + const current = grouped.get(outputDir); + if (!current) { + grouped.set(outputDir, { + files: [...job.files], + outputDir, + options: { + ...(job.options ?? {}), + adoptUnownedPaths: [...(job.options?.adoptUnownedPaths ?? [])], + }, + }); + continue; } - } catch { - // Ignore errors reading directories + + current.files.push(...job.files); + current.options = { + ...current.options, + ...job.options, + pruneStaleFiles: + current.options?.pruneStaleFiles === true || + job.options?.pruneStaleFiles === true, + removeManifestWhenEmpty: + current.options?.removeManifestWhenEmpty === true || + job.options?.removeManifestWhenEmpty === true, + adoptUnownedPaths: [ + ...(current.options?.adoptUnownedPaths ?? []), + ...(job.options?.adoptUnownedPaths ?? []), + ], + }; + } + return [...grouped.values()]; +} + +/** + * Plan all output roots before mutating any of them, then stage and commit them + * as one recoverable operation. Output roots may live on different filesystems; + * each root stages locally and every committed root is rolled back if a later + * root fails. + */ +export async function writeGeneratedFileJobs( + jobs: GeneratedFileWriteJob[], + options: WriteBatchOptions = {} +): Promise { + if (options.signal?.aborted) { + throw ( + options.signal.reason ?? + Object.assign(new Error('Operation cancelled.'), { name: 'AbortError' }) + ); + } + let preparedPlans: PreparedPlan[]; + try { + preparedPlans = await Promise.all( + mergeWriteJobs(jobs).map((job) => + prepareGenerationPlan(job.files, job.outputDir, job.options ?? {}) + ) + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, results: [], errors: [message] }; + } + + if (options.signal?.aborted) { + throw ( + options.signal.reason ?? + Object.assign(new Error('Operation cancelled.'), { name: 'AbortError' }) + ); + } + + if (options.dryRun) { + const results = preparedPlans.map((prepared) => { + const errors = conflictErrors(prepared.publicPlan); + return resultForPrepared(prepared, { + success: errors.length === 0, + ...(errors.length === 0 ? {} : { errors }), + }); + }); + const errors = results.flatMap((result) => result.errors ?? []); + return { + success: errors.length === 0, + results, + ...(errors.length === 0 ? {} : { errors }), + }; } - return files; + return applyPreparedPlans(preparedPlans, options.showProgress ?? true); } diff --git a/graphql/codegen/src/core/pipeline/index.ts b/graphql/codegen/src/core/pipeline/index.ts index 16be011b63..c99219f18e 100644 --- a/graphql/codegen/src/core/pipeline/index.ts +++ b/graphql/codegen/src/core/pipeline/index.ts @@ -9,11 +9,7 @@ * - Filtering */ import type { GraphQLSDKConfigTarget } from '../../types/config'; -import type { - Operation, - Table, - TypeRegistry, -} from '../../types/schema'; +import type { Operation, Table, TypeRegistry } from '../../types/schema'; import { enrichManyToManyRelations } from '../introspect/enrich-relations'; import { inferTablesFromIntrospection } from '../introspect/infer-tables'; import type { SchemaSource } from '../introspect/source'; @@ -24,6 +20,7 @@ import { getTableOperationNames, transformSchemaToOperations, } from '../introspect/transform-schema'; +import { throwIfAborted } from '../cancellation'; // Re-export for convenience export type { SchemaSource } from '../introspect/source'; @@ -52,10 +49,19 @@ export interface CodegenPipelineOptions { */ verbose?: boolean; + /** + * Receive verbose pipeline messages. The reusable pipeline never writes to + * the terminal; human adapters decide how these messages are rendered. + */ + onLog?: (message: string) => void; + /** * Skip custom operations (only generate table CRUD) */ skipCustomOperations?: boolean; + + /** Cancels at source and CPU-stage boundaries. */ + signal?: AbortSignal; } export interface CodegenPipelineResult { @@ -101,24 +107,31 @@ export interface CodegenPipelineResult { * 5. Separate table operations from custom operations */ export async function runCodegenPipeline( - options: CodegenPipelineOptions, + options: CodegenPipelineOptions ): Promise { const { source, config, verbose = false, + onLog, skipCustomOperations = false, + signal, } = options; - const log = verbose ? console.log : () => {}; + const log = verbose && onLog ? onLog : () => {}; // 1. Fetch introspection from source log(`Fetching schema from ${source.describe()}...`); - const { introspection, tablesMeta } = await source.fetch(); + throwIfAborted(signal); + const { introspection, tablesMeta } = await source.fetch(signal); + throwIfAborted(signal); // 2. Infer tables from introspection (replaces _meta) log('Inferring table metadata from schema...'); const commentsEnabled = config.codegen?.comments !== false; - let tables = inferTablesFromIntrospection(introspection, { comments: commentsEnabled }); + let tables = inferTablesFromIntrospection(introspection, { + comments: commentsEnabled, + }); + throwIfAborted(signal); const totalTables = tables.length; log(` Found ${totalTables} tables`); @@ -143,6 +156,7 @@ export async function runCodegenPipeline( mutations: allMutations, typeRegistry, } = transformSchemaToOperations(introspection); + throwIfAborted(signal); const totalQueries = allQueries.length; const totalMutations = allMutations.length; @@ -160,27 +174,27 @@ export async function runCodegenPipeline( const filteredQueries = filterOperations( allQueries, config.queries.include, - [...config.queries.exclude, ...config.queries.systemExclude], + [...config.queries.exclude, ...config.queries.systemExclude] ); const filteredMutations = filterOperations( allMutations, config.mutations.include, - [...config.mutations.exclude, ...config.mutations.systemExclude], + [...config.mutations.exclude, ...config.mutations.systemExclude] ); log( - ` After config filtering: ${filteredQueries.length} queries, ${filteredMutations.length} mutations`, + ` After config filtering: ${filteredQueries.length} queries, ${filteredMutations.length} mutations` ); // Remove table operations (already handled by table generators) customQueries = getCustomOperations(filteredQueries, tableOperationNames); customMutations = getCustomOperations( filteredMutations, - tableOperationNames, + tableOperationNames ); log( - ` Custom operations: ${customQueries.length} queries, ${customMutations.length} mutations`, + ` Custom operations: ${customQueries.length} queries, ${customMutations.length} mutations` ); } diff --git a/graphql/codegen/src/core/sensitive-values.ts b/graphql/codegen/src/core/sensitive-values.ts new file mode 100644 index 0000000000..f2b4f9a5f0 --- /dev/null +++ b/graphql/codegen/src/core/sensitive-values.ts @@ -0,0 +1,229 @@ +import { Buffer } from 'node:buffer'; + +/** Receives a sensitive value discovered while resolving codegen input. */ +export type SensitiveValueReporter = (value: string) => void; + +const SENSITIVE_KEY = + /(?:authorization|cookie|credentials?|password|passwd|secret|session|token|api(?:access)?key|privatekey|signingkey|accesskey|connectionstring|dsn)$/i; + +const SENSITIVE_ENDPOINT_QUERY_PARTS = new Set([ + 'auth', + 'authorization', + 'bearer', + 'cookie', + 'credential', + 'credentials', + 'jwt', + 'passwd', + 'password', + 'secret', + 'session', + 'signature', + 'token', +]); + +const normalizedKey = (key: string): string => + key.replace(/[^a-z0-9]/gi, '').toLowerCase(); + +const isSensitiveKey = (key: string): boolean => + SENSITIVE_KEY.test(normalizedKey(key)); + +const isSensitiveEndpointQueryKey = (key: string): boolean => { + const separated = key + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + const compact = separated.join(''); + return ( + separated.some((part) => SENSITIVE_ENDPOINT_QUERY_PARTS.has(part)) || + compact.includes('apikey') || + compact.includes('privatekey') || + compact.includes('signingkey') || + compact === 'key' || + compact === 'sig' + ); +}; + +const parseUrl = (value: string): URL | undefined => { + try { + return new URL(value); + } catch { + return undefined; + } +}; + +const decodeUrlComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const looksLikePostgresUrl = (value: string): boolean => + /^postgres(?:ql)?:\/\//i.test(value); + +/** Whether an endpoint satisfies CNC's strict executable-transport policy. */ +export const isSafeCodegenEndpoint = (endpoint: string): boolean => { + const parsed = parseUrl(endpoint); + return ( + parsed !== undefined && + endpoint.trim() === endpoint && + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.username === '' && + parsed.password === '' && + parsed.hash === '' && + ![...parsed.searchParams.keys()].some(isSensitiveEndpointQueryKey) + ); +}; + +const reportUrlSecrets = ( + value: string, + report: (value: string) => void, + options: { alwaysSensitive?: boolean } = {} +): void => { + const parsed = parseUrl(value); + if (parsed === undefined) { + if (options.alwaysSensitive) report(value); + return; + } + + const hasPrivateProjection = + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== ''; + if (options.alwaysSensitive || hasPrivateProjection) report(value); + + const decodedUsername = decodeUrlComponent(parsed.username); + const decodedPassword = decodeUrlComponent(parsed.password); + report(parsed.username); + report(parsed.password); + report(decodedUsername); + report(decodedPassword); + if (parsed.username !== '' || parsed.password !== '') { + report(`${parsed.username}:${parsed.password}`); + const decodedCredentials = `${decodedUsername}:${decodedPassword}`; + report(decodedCredentials); + report(`Basic ${Buffer.from(decodedCredentials).toString('base64')}`); + } + report(parsed.search); + report(parsed.search.slice(1)); + for (const [, queryValue] of parsed.searchParams) report(queryValue); + for (const parameter of parsed.search.slice(1).split('&')) { + const separator = parameter.indexOf('='); + if (separator >= 0) report(parameter.slice(separator + 1)); + } + report(parsed.hash); + report(parsed.hash.slice(1)); +}; + +/** + * Discover secrets after codegen has resolved CLI and file configuration. + * + * Header values are sensitive regardless of their names. Other nested values + * are collected when their key is credential-bearing, and PostgreSQL URLs are + * always treated as credentials because they commonly embed a password. + */ +export function reportConfigSensitiveValues( + config: unknown, + reporter: SensitiveValueReporter | undefined +): void { + if (reporter === undefined || config === null) return; + + const reported = new Set(); + const seen = new WeakSet(); + const report = (value: string): void => { + if (value.length === 0 || reported.has(value)) return; + reported.add(value); + reporter(value); + }; + + const visit = ( + value: unknown, + parentKey: string | undefined, + inheritedSensitive: boolean + ): void => { + const key = parentKey === undefined ? '' : normalizedKey(parentKey); + const sensitive = + inheritedSensitive || + (parentKey !== undefined && + (isSensitiveKey(parentKey) || key === 'headers')); + + if (typeof value === 'string') { + if (sensitive) { + report(value); + report(value.trim()); + } + if (key === 'authorization') { + const separator = value.indexOf(' '); + if (separator > 0 && separator < value.length - 1) { + report(value.slice(separator + 1)); + } + } + if (key === 'cookie') { + for (const pair of value.split(';')) { + const trimmed = pair.trim(); + report(trimmed); + const separator = trimmed.indexOf('='); + if (separator >= 0) report(trimmed.slice(separator + 1)); + } + } + if (key === 'endpoint') reportUrlSecrets(value, report); + if (looksLikePostgresUrl(value)) { + reportUrlSecrets(value, report, { alwaysSensitive: true }); + } + return; + } + if (typeof value === 'number') { + if (sensitive && Number.isFinite(value)) report(String(value)); + return; + } + if (value === null || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + + for (const childKey of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, childKey); + if (descriptor !== undefined && 'value' in descriptor) { + visit(descriptor.value, childKey, sensitive); + } + } + }; + + visit(config, undefined, false); +} + +const projectUrlForDisplay = ( + value: string, + protocols: readonly string[], + fallback: string +): string => { + const parsed = parseUrl(value); + if (parsed === undefined || !protocols.includes(parsed.protocol)) { + return fallback; + } + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); +}; + +/** Return an endpoint URL that is safe to include in events and errors. */ +export const endpointForDisplay = (endpoint: string): string => + projectUrlForDisplay( + endpoint, + ['http:', 'https:'], + '' + ); + +/** Return a database name or sanitized PostgreSQL URL for diagnostics. */ +export const databaseForDisplay = (database: string): string => + looksLikePostgresUrl(database) + ? projectUrlForDisplay( + database, + ['postgres:', 'postgresql:'], + '' + ) + : database; diff --git a/graphql/codegen/src/index.ts b/graphql/codegen/src/index.ts index 4d54cb5bee..b70d5c4710 100644 --- a/graphql/codegen/src/index.ts +++ b/graphql/codegen/src/index.ts @@ -22,14 +22,35 @@ export * from './client'; export { defineConfig } from './types/config'; // Main generate function (orchestrates the entire pipeline) -export type { GenerateOptions, GenerateResult, GenerateMultiOptions, GenerateMultiResult } from './core/generate'; -export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, TARGETS_MANIFEST } from './core/generate'; +export type { + GenerateOptions, + GenerateResult, + GenerateMultiOptions, + GenerateMultiResult, +} from './core/generate'; +export { + generate, + generateMulti, + expandApiNamesToMultiTarget, + expandSchemaDirToMultiTarget, + removeStaleTargetDirs, + TARGETS_MANIFEST, +} from './core/generate'; // Config utilities export { findConfigFile, loadConfigFile } from './core/config'; // CLI shared utilities (for packages/cli to import) -export { runCodegenHandler } from './cli/handler'; +export { + runCodegenHandler, + runCodegenOperation, + CodegenOperationError, +} from './cli/handler'; +export type { + CodegenOperationResult, + RunCodegenOperationOptions, +} from './cli/handler'; +export type { SensitiveValueReporter } from './core/sensitive-values'; export type { CodegenAnswers } from './cli/shared'; export { buildDbConfig, diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 962bf78da9..6d7393e66b 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -102,8 +102,8 @@ export interface PgpmConfig { export interface DbConfig { /** * PostgreSQL connection configuration - * Falls back to environment variables (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE) - * via @pgpmjs/env when not specified + * Values omitted here may be filled from the explicit environment supplied + * to generate()/generateMulti(). Reusable APIs never read ambient process.env. */ config?: Partial; @@ -561,7 +561,7 @@ export function defineConfig(config: GraphQLSDKConfig): GraphQLSDKConfig { */ export function mergeConfig( base: GraphQLSDKConfigTarget, - overrides: GraphQLSDKConfigTarget, + overrides: GraphQLSDKConfigTarget ): GraphQLSDKConfigTarget { return deepmerge(base, overrides, { arrayMerge: replaceArrays }); } @@ -571,7 +571,7 @@ export function mergeConfig( * Similar to getEnvOptions pattern from @pgpmjs/env. */ export function getConfigOptions( - overrides: GraphQLSDKConfigTarget = {}, + overrides: GraphQLSDKConfigTarget = {} ): GraphQLSDKConfigTarget { return deepmerge(DEFAULT_CONFIG, overrides, { arrayMerge: replaceArrays }); } From 397d32455abcfadfe4ab62b20f739d0349f534aa Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:38:04 +0700 Subject: [PATCH 06/18] feat(cli): migrate command families to agent protocol v1 Route context, authentication, GraphQL execution, codegen, server, explorer, and jobs through typed operations. Add deterministic discovery, strict agent-mode bindings, atomic state handling, service adapters, structured output, and compatibility-preserving human rendering. --- packages/cli/__tests__/config-store.test.ts | 430 +++++++ .../__tests__/operation-boundaries.test.ts | 20 + .../cli/__tests__/optional-capability.test.ts | 50 + .../__tests__/packaged-entrypoints.test.ts | 74 ++ .../cli/__tests__/protocol-contract.test.ts | 161 +++ packages/cli/__tests__/runtime-cli.test.ts | 479 ++++++++ .../__tests__/runtime-codegen-command.test.ts | 586 +++++++++ .../__tests__/runtime-execute-command.test.ts | 407 +++++++ .../__tests__/runtime-registry-purity.test.ts | 215 ++++ .../runtime-service-commands.test.ts | 638 ++++++++++ .../__tests__/runtime-state-commands.test.ts | 366 ++++++ packages/cli/__tests__/sdk.test.ts | 206 ++++ packages/cli/package.json | 70 +- packages/cli/scripts/finalize-esm.js | 42 + packages/cli/src/commands.ts | 726 +++++++++-- packages/cli/src/config/config-manager.ts | 1061 ++++++++++++++--- packages/cli/src/config/index.ts | 2 + packages/cli/src/config/resolution.ts | 74 ++ packages/cli/src/config/secrets.ts | 77 ++ packages/cli/src/config/types.ts | 21 + packages/cli/src/console-isolation.ts | 43 + packages/cli/src/index.ts | 91 +- packages/cli/src/runtime/codegen-command.ts | 527 ++++++++ .../cli/src/runtime/discovery-commands.ts | 335 ++++++ packages/cli/src/runtime/execute-command.ts | 496 ++++++++ packages/cli/src/runtime/index.ts | 14 + .../cli/src/runtime/optional-capability.ts | 33 + packages/cli/src/runtime/registry.ts | 229 ++++ packages/cli/src/runtime/service-commands.ts | 911 ++++++++++++++ packages/cli/src/runtime/service-hooks.ts | 226 ++++ packages/cli/src/runtime/state-commands.ts | 655 ++++++++++ packages/cli/src/sdk/client.ts | 286 ++++- packages/cli/src/sdk/executor.ts | 303 ++++- pnpm-lock.yaml | 145 ++- 34 files changed, 9538 insertions(+), 461 deletions(-) create mode 100644 packages/cli/__tests__/config-store.test.ts create mode 100644 packages/cli/__tests__/operation-boundaries.test.ts create mode 100644 packages/cli/__tests__/optional-capability.test.ts create mode 100644 packages/cli/__tests__/packaged-entrypoints.test.ts create mode 100644 packages/cli/__tests__/protocol-contract.test.ts create mode 100644 packages/cli/__tests__/runtime-cli.test.ts create mode 100644 packages/cli/__tests__/runtime-codegen-command.test.ts create mode 100644 packages/cli/__tests__/runtime-execute-command.test.ts create mode 100644 packages/cli/__tests__/runtime-registry-purity.test.ts create mode 100644 packages/cli/__tests__/runtime-service-commands.test.ts create mode 100644 packages/cli/__tests__/runtime-state-commands.test.ts create mode 100644 packages/cli/__tests__/sdk.test.ts create mode 100644 packages/cli/scripts/finalize-esm.js create mode 100644 packages/cli/src/config/resolution.ts create mode 100644 packages/cli/src/config/secrets.ts create mode 100644 packages/cli/src/console-isolation.ts mode change 100755 => 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/runtime/codegen-command.ts create mode 100644 packages/cli/src/runtime/discovery-commands.ts create mode 100644 packages/cli/src/runtime/execute-command.ts create mode 100644 packages/cli/src/runtime/index.ts create mode 100644 packages/cli/src/runtime/optional-capability.ts create mode 100644 packages/cli/src/runtime/registry.ts create mode 100644 packages/cli/src/runtime/service-commands.ts create mode 100644 packages/cli/src/runtime/service-hooks.ts create mode 100644 packages/cli/src/runtime/state-commands.ts diff --git a/packages/cli/__tests__/config-store.test.ts b/packages/cli/__tests__/config-store.test.ts new file mode 100644 index 0000000000..d93a30a4ff --- /dev/null +++ b/packages/cli/__tests__/config-store.test.ts @@ -0,0 +1,430 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'node:child_process'; +import { + ConfigStore, + ConfigStoreError, + createContext, + createContextAndMaybeActivate, + deleteContext, + getConfigDirForEnvironment, + resolveContext, + resolveToken, + setContextCredentials, + setCurrentContext, + redactSecrets, + validateContextName, + validateEndpoint, +} from '../src/config'; + +const NOW = new Date('2026-07-20T00:00:00.000Z'); +const REAL_TMP_DIR = fs.realpathSync(os.tmpdir()); + +describe('ConfigStore', () => { + let root: string; + let store: ConfigStore; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(REAL_TMP_DIR, 'cnc-config-test-')); + store = new ConfigStore({ configDir: root, lockTimeoutMs: 50 }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('validates context names and endpoints before touching disk', () => { + expect(validateContextName('production.us-1')).toBe('production.us-1'); + expect(() => validateContextName('../production')).toThrow( + expect.objectContaining({ code: 'CONTEXT_NAME_INVALID' }) + ); + expect(validateEndpoint('https://api.example.com/graphql?tenant=1')).toBe( + 'https://api.example.com/graphql?tenant=1' + ); + expect(() => validateEndpoint('file:///tmp/graphql')).toThrow( + expect.objectContaining({ code: 'CONTEXT_ENDPOINT_INVALID' }) + ); + expect(() => + validateEndpoint('https://secret@example.com/graphql') + ).toThrow(expect.objectContaining({ code: 'CONTEXT_ENDPOINT_INVALID' })); + expect(() => validateEndpoint(' https://api.example.com/graphql')).toThrow( + expect.objectContaining({ code: 'CONTEXT_ENDPOINT_INVALID' }) + ); + }); + + it('resolves state only from an explicit environment snapshot', () => { + expect(() => getConfigDirForEnvironment({})).toThrow( + expect.objectContaining({ code: 'CONFIG_BASE_DIRECTORY_REQUIRED' }) + ); + expect(getConfigDirForEnvironment({ HOME: root })).toBe( + path.join(root, '.cnc', 'config') + ); + }); + + it('rejects secret-bearing endpoint query keys without echoing their values', () => { + const secret = 'must-never-appear'; + const sensitiveKeys = [ + 'token', + 'access_token', + 'refreshToken', + 'api_key', + 'api%5Fkey', + 'x-api-key', + 'client-secret', + 'password', + 'authorization', + 'credential', + 'X-Amz-Signature', + ]; + + for (const key of sensitiveKeys) { + const endpoint = `https://api.example.com/graphql?${key}=${secret}`; + let failure: unknown; + try { + validateEndpoint(endpoint); + } catch (error) { + failure = error; + } + expect(failure).toEqual( + expect.objectContaining({ code: 'CONTEXT_ENDPOINT_INVALID' }) + ); + expect(JSON.stringify(failure)).not.toContain(secret); + expect((failure as Error).message).not.toContain(endpoint); + } + + expect( + validateEndpoint( + 'https://api.example.com/graphql?tenant=acme®ion=us-east-1' + ) + ).toBe('https://api.example.com/graphql?tenant=acme®ion=us-east-1'); + }); + + it('writes versioned state with restrictive permissions', () => { + createContext('production', 'https://api.example.com/graphql', store, NOW); + const state = store.read(); + + expect(state).toMatchObject({ + stateVersion: 1, + contexts: { + production: { + name: 'production', + endpoint: 'https://api.example.com/graphql', + createdAt: NOW.toISOString(), + }, + }, + }); + expect(fs.statSync(store.statePath).mode & 0o777).toBe(0o600); + expect(fs.statSync(root).mode & 0o777).toBe(0o700); + }); + + it('migrates legacy files on first mutation and keeps a backup', () => { + fs.mkdirSync(path.join(root, 'contexts')); + fs.writeFileSync( + path.join(root, 'settings.json'), + JSON.stringify({ currentContext: 'legacy' }) + ); + fs.writeFileSync( + path.join(root, 'credentials.json'), + JSON.stringify({ tokens: { legacy: { token: 'secret-token' } } }) + ); + fs.writeFileSync( + path.join(root, 'contexts', 'legacy.json'), + JSON.stringify({ + name: 'legacy', + endpoint: 'http://localhost:3000/graphql', + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }) + ); + + expect(store.read().settings.currentContext).toBe('legacy'); + createContext('next', 'https://next.example.com/graphql', store, NOW); + + expect(fs.existsSync(store.statePath)).toBe(true); + const backups = fs + .readdirSync(root) + .filter((entry) => entry.startsWith('legacy-backup-')); + expect(backups).toHaveLength(1); + expect( + fs.existsSync(path.join(root, backups[0], 'contexts', 'legacy.json')) + ).toBe(true); + }); + + it('atomically removes context credentials and active selection', () => { + createContext('production', 'https://api.example.com/graphql', store, NOW); + setCurrentContext('production', store); + setContextCredentials('production', 'secret-token', undefined, store); + + expect(deleteContext('production', store)).toBe(true); + expect(store.read()).toEqual({ + stateVersion: 1, + settings: {}, + contexts: {}, + credentials: { tokens: {} }, + }); + }); + + it('creates and activates the first context in one state commit', () => { + expect( + createContextAndMaybeActivate( + 'first', + 'https://first.example.com/graphql', + store, + NOW + ) + ).toMatchObject({ context: { name: 'first' }, activated: true }); + expect( + createContextAndMaybeActivate( + 'second', + 'https://second.example.com/graphql', + store, + NOW + ) + ).toMatchObject({ context: { name: 'second' }, activated: false }); + expect(store.read().settings.currentContext).toBe('first'); + }); + + it('refuses corrupt and newer state instead of silently resetting it', () => { + fs.writeFileSync(store.statePath, '{bad json'); + expect(() => store.read()).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }) + ); + + fs.writeFileSync(store.statePath, JSON.stringify({ stateVersion: 99 })); + expect(() => store.read()).toThrow( + expect.objectContaining({ code: 'CONFIG_VERSION_UNSUPPORTED' }) + ); + + fs.writeFileSync( + store.statePath, + JSON.stringify({ + stateVersion: 1, + settings: {}, + contexts: {}, + credentials: { tokens: { missing: { token: 'orphaned' } } }, + }) + ); + expect(() => store.read()).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }) + ); + }); + + it('refuses symlinked state and bounded lock contention', () => { + const target = path.join(root, 'target.json'); + fs.writeFileSync(target, JSON.stringify({})); + fs.symlinkSync(target, store.statePath); + expect(() => store.read()).toThrow( + expect.objectContaining({ code: 'CONFIG_SYMLINK_REJECTED' }) + ); + fs.unlinkSync(store.statePath); + + fs.writeFileSync(path.join(root, 'state.lock'), 'locked'); + expect(() => + store.mutate((state) => { + state.settings = {}; + }) + ).toThrow(expect.objectContaining({ code: 'CONFIG_LOCK_TIMEOUT' })); + }); + + it('recovers a stale lock but refuses a symlinked config directory', () => { + const staleStore = new ConfigStore({ + configDir: root, + lockTimeoutMs: 50, + staleLockMs: 10, + }); + const lock = path.join(root, 'state.lock'); + fs.writeFileSync(lock, 'stale'); + const old = new Date(Date.now() - 1_000); + fs.utimesSync(lock, old, old); + createContext( + 'recovered', + 'https://api.example.com/graphql', + staleStore, + NOW + ); + expect(staleStore.read().contexts.recovered).toBeDefined(); + + const target = fs.mkdtempSync( + path.join(REAL_TMP_DIR, 'cnc-config-target-') + ); + const link = `${target}-link`; + fs.symlinkSync(target, link, 'dir'); + try { + expect(() => new ConfigStore({ configDir: link }).read()).toThrow( + expect.objectContaining({ code: 'CONFIG_SYMLINK_REJECTED' }) + ); + } finally { + fs.unlinkSync(link); + fs.rmSync(target, { recursive: true, force: true }); + } + }); + + it('refuses a symlink in an existing config directory ancestor', () => { + const boundary = fs.mkdtempSync( + path.join(REAL_TMP_DIR, 'cnc-config-ancestor-') + ); + const target = path.join(boundary, 'real-parent'); + const linkedParent = path.join(boundary, 'linked-parent'); + fs.mkdirSync(target); + fs.symlinkSync(target, linkedParent, 'dir'); + + try { + const nestedStore = new ConfigStore({ + configDir: path.join(linkedParent, 'nested', 'config'), + }); + expect(() => nestedStore.read()).toThrow( + expect.objectContaining({ code: 'CONFIG_SYMLINK_REJECTED' }) + ); + expect(fs.existsSync(path.join(target, 'nested'))).toBe(false); + } finally { + fs.rmSync(boundary, { recursive: true, force: true }); + } + }); + + it('supports ordinary nested config directory ancestors', () => { + const boundary = fs.mkdtempSync( + path.join(REAL_TMP_DIR, 'cnc-config-nested-') + ); + const nestedConfigDir = path.join(boundary, 'one', 'two', 'config'); + const nestedStore = new ConfigStore({ configDir: nestedConfigDir }); + + try { + expect(nestedStore.read()).toEqual({ + stateVersion: 1, + settings: {}, + contexts: {}, + credentials: { tokens: {} }, + }); + createContext( + 'nested', + 'https://api.example.com/graphql?tenant=acme', + nestedStore, + NOW + ); + expect(nestedStore.read().contexts.nested.endpoint).toBe( + 'https://api.example.com/graphql?tenant=acme' + ); + } finally { + fs.rmSync(boundary, { recursive: true, force: true }); + } + }); + + it('serializes context and credential writes across processes', async () => { + const configModule = require.resolve('../dist/config'); + const workers = Array.from({ length: 6 }, (_, index) => { + const contextName = `worker-${index}`; + const script = ` + const { + ConfigStore, + createContext, + setContextCredentials, + } = require(${JSON.stringify(configModule)}); + const store = new ConfigStore({ + configDir: ${JSON.stringify(root)}, + lockTimeoutMs: 5000, + }); + createContext( + ${JSON.stringify(contextName)}, + ${JSON.stringify(`https://api.example.com/graphql?worker=${index}`)}, + store, + new Date(${JSON.stringify(NOW.toISOString())}), + ); + setContextCredentials( + ${JSON.stringify(contextName)}, + ${JSON.stringify(`secret-${index}`)}, + undefined, + store, + ); + `; + + return new Promise((resolveWorker, rejectWorker) => { + const child = spawn(process.execPath, ['-e', script], { + cwd: path.resolve(__dirname, '..'), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.once('error', rejectWorker); + child.once('exit', (code, signal) => { + if (code === 0) { + resolveWorker(); + return; + } + rejectWorker( + new Error( + `State worker exited with ${code ?? signal}: ${stderr.trim()}` + ) + ); + }); + }); + }); + + await Promise.all(workers); + const state = store.read(); + expect(Object.keys(state.contexts).sort()).toEqual( + Array.from({ length: 6 }, (_, index) => `worker-${index}`) + ); + expect(Object.keys(state.credentials.tokens).sort()).toEqual( + Array.from({ length: 6 }, (_, index) => `worker-${index}`) + ); + }); +}); + +describe('configuration resolution and redaction', () => { + let root: string; + let store: ConfigStore; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(REAL_TMP_DIR, 'cnc-resolution-test-')); + store = new ConfigStore({ configDir: root }); + createContext('current', 'https://current.example.com/graphql', store, NOW); + createContext('environment', 'https://env.example.com/graphql', store, NOW); + createContext('argument', 'https://arg.example.com/graphql', store, NOW); + setCurrentContext('current', store); + }); + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('resolves argument before CNC_CONTEXT and disables global fallback for agents', () => { + expect( + resolveContext({ + contextName: 'argument', + env: { CNC_CONTEXT: 'environment' }, + allowCurrentContext: false, + store, + }) + ).toMatchObject({ source: 'argument', context: { name: 'argument' } }); + expect( + resolveContext({ env: { CNC_CONTEXT: 'environment' }, store }) + ).toMatchObject({ + source: 'environment', + context: { name: 'environment' }, + }); + expect(() => resolveContext({ allowCurrentContext: false, store })).toThrow( + expect.objectContaining({ code: 'CONTEXT_REQUIRED' }) + ); + }); + + it('rejects ambiguous token sources and recursively redacts secret fields', () => { + expect(resolveToken({ env: { CNC_TOKEN: 'from-env' } })).toEqual({ + token: 'from-env', + source: 'environment', + }); + expect(() => + resolveToken({ stdinToken: 'stdin', env: { CNC_TOKEN: 'env' } }) + ).toThrow(expect.objectContaining({ code: 'TOKEN_SOURCE_AMBIGUOUS' })); + expect( + redactSecrets({ + token: 'one', + nested: { Authorization: 'Bearer two', safe: 'visible' }, + }) + ).toEqual({ + token: '[REDACTED]', + nested: { Authorization: '[REDACTED]', safe: 'visible' }, + }); + }); +}); diff --git a/packages/cli/__tests__/operation-boundaries.test.ts b/packages/cli/__tests__/operation-boundaries.test.ts new file mode 100644 index 0000000000..01a1083aca --- /dev/null +++ b/packages/cli/__tests__/operation-boundaries.test.ts @@ -0,0 +1,20 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('registry operation boundaries', () => { + it('does not access mutable process state from command modules', () => { + const runtimeDirectory = join(__dirname, '..', 'src', 'runtime'); + const violations = readdirSync(runtimeDirectory) + .filter((file) => file.endsWith('.ts')) + .flatMap((file) => { + const source = readFileSync(join(runtimeDirectory, file), 'utf8'); + return [ + ...source.matchAll( + /(?:process\.(?:chdir|cwd|env|exit|exitCode)|console\.(?:debug|error|info|log|warn))\b/g + ), + ].map((match) => `${file}:${match.index}:${match[0]}`); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/packages/cli/__tests__/optional-capability.test.ts b/packages/cli/__tests__/optional-capability.test.ts new file mode 100644 index 0000000000..84dbfded28 --- /dev/null +++ b/packages/cli/__tests__/optional-capability.test.ts @@ -0,0 +1,50 @@ +import { CliError } from '@constructive-io/cli-runtime'; + +import { importOptionalCapability } from '../src/runtime/optional-capability'; + +describe('optional capability imports', () => { + it.each(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'])( + 'maps %s for the optional package to a stable capability error', + async (code) => { + const cause = Object.assign( + new Error("Cannot find package '@constructive-io/optional-feature'"), + { code } + ); + + await expect( + importOptionalCapability( + 'optional feature', + '@constructive-io/optional-feature', + async () => { + throw cause; + } + ) + ).rejects.toMatchObject>({ + code: 'CAPABILITY_UNAVAILABLE', + category: 'configuration', + retryable: false, + details: { + capability: 'optional feature', + packageName: '@constructive-io/optional-feature', + }, + cause, + }); + } + ); + + it('does not mask failures from an installed capability', async () => { + const cause = Object.assign(new Error('missing transitive dependency'), { + code: 'MODULE_NOT_FOUND', + }); + + await expect( + importOptionalCapability( + 'optional feature', + '@constructive-io/optional-feature', + async () => { + throw cause; + } + ) + ).rejects.toBe(cause); + }); +}); diff --git a/packages/cli/__tests__/packaged-entrypoints.test.ts b/packages/cli/__tests__/packaged-entrypoints.test.ts new file mode 100644 index 0000000000..c3ce6a41bd --- /dev/null +++ b/packages/cli/__tests__/packaged-entrypoints.test.ts @@ -0,0 +1,74 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const packageRoot = resolve(__dirname, '..'); + +describe('built CNC entrypoints', () => { + const cjsRuntime = resolve(packageRoot, 'dist/runtime/index.js'); + const esmRuntime = pathToFileURL( + resolve(packageRoot, 'dist/esm/runtime/index.js') + ).href; + it.each([ + ['CommonJS', resolve(packageRoot, 'dist/index.js')], + ['ESM', resolve(packageRoot, 'dist/esm/index.js')], + ])('%s emits the same clean agent protocol', (_kind, entrypoint) => { + expect(existsSync(entrypoint)).toBe(true); + const child = spawnSync( + process.execPath, + [entrypoint, 'version', '--agent'], + { + cwd: packageRoot, + env: { ...process.env, CI: 'true' }, + encoding: 'utf8', + } + ); + + expect(child.status).toBe(0); + expect(child.stderr).toBe(''); + const events = child.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events.map(({ event }) => event)).toEqual([ + 'operation.started', + 'operation.completed', + ]); + expect(events.at(-1)).toMatchObject({ + protocolVersion: 'constructive.dev/cli/v1', + commandId: 'discovery.version', + result: { + data: { protocolVersion: 'constructive.dev/cli/v1' }, + }, + }); + }); + + it.each([ + [ + 'CommonJS runtime export', + [ + '-e', + `const runtime = require(${JSON.stringify(cjsRuntime)}); if (typeof runtime.createCncRegistryForEnvironment !== 'function' || typeof runtime.ConfigStore !== 'function') process.exit(1); runtime.createCncRegistryForEnvironment({ version: 'test', env: {}, configDir: process.cwd() });`, + ], + ], + [ + 'ESM runtime export', + [ + '--input-type=module', + '-e', + `const runtime = await import(${JSON.stringify(esmRuntime)}); if (typeof runtime.createCncRegistryForEnvironment !== 'function' || typeof runtime.ConfigStore !== 'function') process.exit(1); runtime.createCncRegistryForEnvironment({ version: 'test', env: {}, configDir: process.cwd() });`, + ], + ], + ])('%s is a silent reusable package API', (_kind, args) => { + const child = spawnSync(process.execPath, args, { + cwd: packageRoot, + env: { ...process.env, CI: 'true' }, + encoding: 'utf8', + }); + + expect(child.status).toBe(0); + expect(child.stdout).toBe(''); + expect(child.stderr).toBe(''); + }); +}); diff --git a/packages/cli/__tests__/protocol-contract.test.ts b/packages/cli/__tests__/protocol-contract.test.ts new file mode 100644 index 0000000000..14f51f4166 --- /dev/null +++ b/packages/cli/__tests__/protocol-contract.test.ts @@ -0,0 +1,161 @@ +import { createHash } from 'node:crypto'; + +import { + CommandCatalogEntrySchema, + CommandSchemaDocumentSchema, + DomainProtocolEventSchema, + ExecutionOutcomeSchema, + HelpDocumentSchema, + OperationCancelledEventSchema, + OperationCompletedEventSchema, + OperationFailedEventSchema, + OperationResultSchema, + OperationStartedEventSchema, + PROTOCOL_VERSION, + ProtocolEventSchema, + SafetyCapabilitiesSchema, + StructuredErrorSchema, + TerminalProtocolEventSchema, + exitCodeForOutcome, +} from '@constructive-io/cli-runtime'; + +import cliPackage from '../package.json'; +import runtimePackage from '../../cli-runtime/package.json'; +import { createCncRegistry } from '../src/runtime/registry'; +import { ConfigStore } from '../src/config'; + +const BASELINE = { + cliMajor: 7, + runtimeMajor: 0, + protocolVersion: 'constructive.dev/cli/v1', + protocolHash: + 'e8db669f49990542560beb4505ab42b4c57c78efc1af1e1678f536c1e955901f', + exitCodes: { + completed: 0, + knownFailure: 1, + invalidInvocation: 2, + internalFailure: 70, + cancellation: 130, + }, + commandHashes: { + 'auth.logout': + '69b445d37b72cd6dbd636e2babb345409391baa33686c3de05a5f896506c7f23', + 'auth.set-token': + '21dfc8df3d854a9dd3da739b4a6904c43c96c73d6b194abe598c244ddf9b23f8', + 'auth.status': + 'c5e0b8ffecb5c44e87f4e2392067d66c131ddfb6cc86c37f693ba49bf03666d7', + 'codegen.generate': + '00b23b8ad9e92d90bbe41c17657364fd60bf17a3ff36b9ecde3622642506c423', + 'context.create': + 'e52ed662d5e81533272f5bdb5ea7b1f0ea7b9d89810544e54d5d9673833c9233', + 'context.current': + '99bdb595ce6af4a297e29f31e3a5263b24702d18f938591cff17152229d30d4d', + 'context.delete': + '5f5e66d67d1b64fb6b577522089f4d36c703b2a21bc9484af048730b8a309d4f', + 'context.list': + '6ef59b47b7bae7f4594a355eb69c0757162fc4d66c3c80b48af6052fc52423e8', + 'context.use': + '54d40cfb4f76db466d4f03ab26402988585c2b893481012c20e444f462e2c808', + 'discovery.commands': + '7c0f8e375cb7e82ba3ec7abf205ac2b962019b1462fee9878fb803ecb38a8edb', + 'discovery.completion': + 'e553b4002c402272ae1a387f3e784e81ecc27529606b1479e8f34667b786050c', + 'discovery.docs-export': + '22c6a788fceab502ff74408b679555735f660a4cfbe0ce9579d7027af6db3575', + 'discovery.help': + '50cea8aa90dc6d29d5b9d9813e428e6a0f88f574f14b0935128c91f8cf8099de', + 'discovery.schema': + 'a352530f5b31265975961b75aa237c1b4b7af89e34e312102ee3dacefbeca1bc', + 'discovery.version': + '810e85542ae045f5cfc491ea2a52390c9e6ae9653f9112cde3c28f71ae0ab3a5', + execute: 'c2f14d732c26a9010ce8cc29a338e0be06c92f923d5e56e3404d22c2b9ac7fac', + 'explorer.start': + 'dcceb7ea5a663ce38fffaf258718c19c6d3b2fa6fe988ecf0ac05d4a563fb58f', + 'jobs.up': + 'e62ae1024860528a54f0c7b9722aae1e60a908abd9a25bd37aafe44e876c70b6', + 'server.start': + '11dfbd5913db5f2c226e0e68a2283392dacf3ad2a4b4847f4fd9a4e5d0b0758f', + }, +} as const; + +const hash = (value: unknown): string => + createHash('sha256').update(JSON.stringify(value)).digest('hex'); + +const major = (version: string): number => Number(version.split('.')[0]); + +const protocolHash = (): string => + hash({ + safetyCapabilities: SafetyCapabilitiesSchema, + commandCatalogEntry: CommandCatalogEntrySchema, + commandSchemaDocument: CommandSchemaDocumentSchema, + helpDocument: HelpDocumentSchema, + operationResult: OperationResultSchema, + structuredError: StructuredErrorSchema, + started: OperationStartedEventSchema, + domain: DomainProtocolEventSchema, + completed: OperationCompletedEventSchema, + failed: OperationFailedEventSchema, + cancelled: OperationCancelledEventSchema, + terminal: TerminalProtocolEventSchema, + protocolEvent: ProtocolEventSchema, + outcome: ExecutionOutcomeSchema, + }); + +const exitCodes = () => ({ + completed: exitCodeForOutcome({ status: 'completed' } as never), + knownFailure: exitCodeForOutcome({ + status: 'failed', + error: { category: 'operation' }, + } as never), + invalidInvocation: exitCodeForOutcome({ + status: 'failed', + error: { category: 'invocation' }, + } as never), + internalFailure: exitCodeForOutcome({ + status: 'failed', + error: { category: 'internal' }, + } as never), + cancellation: exitCodeForOutcome({ status: 'cancelled' } as never), +}); + +describe('constructive.dev/cli/v1 compatibility gate', () => { + it('requires a protocol or package major bump for breaking wire changes', () => { + const { registry } = createCncRegistry({ + version: cliPackage.version, + store: new ConfigStore({ + configDir: `${__dirname}/unused-contract-state`, + }), + }); + const currentCommands = new Map( + registry + .list() + .map((command) => [command.id, hash(registry.schema(command.id))]) + ); + const breakingChanges: string[] = []; + + for (const [commandId, baselineHash] of Object.entries( + BASELINE.commandHashes + )) { + const currentHash = currentCommands.get(commandId); + if (currentHash === undefined) + breakingChanges.push(`removed command ${commandId}`); + else if (currentHash !== baselineHash) + breakingChanges.push(`changed command schema ${commandId}`); + } + if (protocolHash() !== BASELINE.protocolHash) + breakingChanges.push('changed protocol envelope schema'); + if (JSON.stringify(exitCodes()) !== JSON.stringify(BASELINE.exitCodes)) + breakingChanges.push('changed exit-code semantics'); + + if (breakingChanges.length === 0) return; + + const compatibilityVersionAdvanced = + PROTOCOL_VERSION !== BASELINE.protocolVersion || + major(cliPackage.version) > BASELINE.cliMajor || + major(runtimePackage.version) > BASELINE.runtimeMajor; + expect({ breakingChanges, compatibilityVersionAdvanced }).toEqual({ + breakingChanges, + compatibilityVersionAdvanced: true, + }); + }); +}); diff --git a/packages/cli/__tests__/runtime-cli.test.ts b/packages/cli/__tests__/runtime-cli.test.ts new file mode 100644 index 0000000000..fde32b6a0c --- /dev/null +++ b/packages/cli/__tests__/runtime-cli.test.ts @@ -0,0 +1,479 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable, Writable } from 'node:stream'; + +import { + renderHumanLifecycleEvent, + runCli, + type CliWritable, +} from '../src/commands'; + +const REAL_TMP_DIR = realpathSync(tmpdir()); +const DEFAULT_TEST_HOME = join(REAL_TMP_DIR, 'cnc-runtime-cli-default-home'); + +class CaptureStream extends Writable implements CliWritable { + readonly chunks: Buffer[] = []; + isTTY?: boolean; + + constructor(isTTY = false) { + super(); + this.isTTY = isTTY; + } + + _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + } + + text(): string { + return Buffer.concat(this.chunks).toString('utf8'); + } +} + +const invoke = async ( + argv: string[], + options: { + stdinTTY?: boolean; + stdoutTTY?: boolean; + env?: Record; + cwd?: string; + } = {} +) => { + const stdin = Readable.from([]) as Readable & { isTTY?: boolean }; + stdin.isTTY = options.stdinTTY ?? false; + const stdout = new CaptureStream(options.stdoutTTY ?? false); + const stderr = new CaptureStream(false); + const exitCode = await runCli(argv, { + cwd: options.cwd ?? process.cwd(), + env: { HOME: DEFAULT_TEST_HOME, ...(options.env ?? {}) }, + stdin, + stdout, + stderr, + version: '7.30.4-test', + now: (() => { + let milliseconds = 0; + return () => new Date(milliseconds++); + })(), + }); + return { exitCode, stdout: stdout.text(), stderr: stderr.text() }; +}; + +describe('CNC agent protocol adapter', () => { + it('renders validated service lifecycle events for human terminals', () => { + expect( + renderHumanLifecycleEvent({ + protocolVersion: 'constructive.dev/cli/v1', + event: 'service.ready', + operationId: 'op_1', + commandId: 'server.start', + timestamp: '2026-07-20T00:00:00.000Z', + service: 'graphql', + url: 'http://localhost:5555', + port: 5555, + }) + ).toBe('graphql: ready at http://localhost:5555'); + }); + + it('emits protocol-only JSONL in agent mode', async () => { + const result = await invoke(['version', '--agent']); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const events = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events.map(({ event }) => event)).toEqual([ + 'operation.started', + 'operation.completed', + ]); + expect(events[1].protocolVersion).toBe('constructive.dev/cli/v1'); + expect(events[1].result.data.version).toBe('7.30.4-test'); + }); + + it('emits exactly one JSON terminal envelope', async () => { + const result = await invoke(['commands', '--format', 'json']); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const lines = result.stdout.trim().split('\n'); + expect(lines).toHaveLength(1); + const terminal = JSON.parse(lines[0]); + expect(terminal.event).toBe('operation.completed'); + expect(terminal.result.data.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'execute', path: ['execute'] }), + expect.objectContaining({ id: 'codegen.generate', path: ['codegen'] }), + ]) + ); + }); + + it('discovers the token-stdin adapter contract through schema and help', async () => { + const schemaResult = await invoke([ + 'schema', + 'auth', + 'set-token', + '--format', + 'json', + ]); + expect(schemaResult.exitCode).toBe(0); + const schema = JSON.parse(schemaResult.stdout).result.data.schema; + expect(schema.input.properties.readFromStdin).toEqual( + expect.objectContaining({ type: 'boolean' }) + ); + expect(schema.bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: 'readFromStdin', + sources: [ + expect.objectContaining({ kind: 'option', name: 'token-stdin' }), + ], + }), + ]) + ); + + const helpResult = await invoke(['help', 'auth', 'set-token']); + expect(helpResult.exitCode).toBe(0); + expect(helpResult.stdout).toContain('--token-stdin'); + }); + + it('maps invalid mode combinations to exit 2 without prose leakage', async () => { + const result = await invoke(['version', '--agent', '--format', 'human']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe(''); + const events = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events.at(-1)).toMatchObject({ + event: 'operation.failed', + error: { code: 'CLI_MODE_CONFLICT', category: 'invocation' }, + }); + }); + + it('honors explicit JSON format for agent invocation failures', async () => { + const result = await invoke([ + 'version', + '--unknown', + '--agent', + '--format', + 'json', + ]); + + expect(result).toEqual( + expect.objectContaining({ exitCode: 2, stderr: '' }) + ); + expect(result.stdout.trim().split('\n')).toHaveLength(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + event: 'operation.failed', + error: { code: 'CLI_OPTION_UNKNOWN' }, + }); + }); + + it('rejects JSON buffering for long-running commands before startup', async () => { + const result = await invoke(['server', '--format', 'json']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe(''); + const terminal = JSON.parse(result.stdout); + expect(terminal).toMatchObject({ + event: 'operation.failed', + error: { code: 'CLI_FORMAT_UNSUPPORTED' }, + }); + }); + + it('rejects unknown agent flags and surplus positionals', async () => { + const unknown = await invoke(['version', '--agent', '--wat']); + const unknownInline = await invoke([ + 'version', + '--wat=secret-value', + '--agent', + ]); + const unknownGlobal = await invoke([ + '--wat=secret-value', + 'version', + '--agent', + ]); + const surplus = await invoke(['version', 'extra', '--agent']); + const helpBypass = await invoke(['server', '--bogus', '--help', '--agent']); + const versionOptionBypass = await invoke([ + 'execute', + '--bogus', + '--version', + '--agent', + ]); + const versionCommandBypass = await invoke([ + 'not-a-command', + '--version', + '--agent', + ]); + + expect(unknown.exitCode).toBe(2); + expect(unknownInline.exitCode).toBe(2); + expect(unknownGlobal.exitCode).toBe(2); + expect(surplus.exitCode).toBe(2); + expect(helpBypass.exitCode).toBe(2); + expect(versionOptionBypass.exitCode).toBe(2); + expect(versionCommandBypass.exitCode).toBe(2); + expect( + JSON.parse(unknown.stdout.trim().split('\n').at(-1)!).error.code + ).toBe('CLI_OPTION_UNKNOWN'); + expect(unknownGlobal.stdout).not.toContain('secret-value'); + expect(unknownInline.stdout).not.toContain('secret-value'); + expect( + JSON.parse(surplus.stdout.trim().split('\n').at(-1)!).error.code + ).toBe('CLI_ARGUMENT_SURPLUS'); + expect( + JSON.parse(helpBypass.stdout.trim().split('\n').at(-1)!).error.code + ).toBe('CLI_OPTION_UNKNOWN'); + expect( + JSON.parse(versionOptionBypass.stdout.trim().split('\n').at(-1)!).error + .code + ).toBe('CLI_OPTION_UNKNOWN'); + expect( + JSON.parse(versionCommandBypass.stdout.trim().split('\n').at(-1)!).error + .code + ).toBe('CLI_COMMAND_NOT_FOUND'); + }); + + it('retains deprecation warnings when strict command binding fails', async () => { + const result = await invoke([ + 'execute', + '--unknown', + '--nonInteractive', + '--format', + 'json', + ]); + + expect(result).toEqual( + expect.objectContaining({ exitCode: 2, stderr: '' }) + ); + expect(JSON.parse(result.stdout)).toMatchObject({ + event: 'operation.failed', + error: { code: 'CLI_OPTION_UNKNOWN' }, + warnings: [ + { + code: 'CLI_DEPRECATED', + message: + 'Option "--nonInteractive" is deprecated; use "--non-interactive".', + }, + ], + }); + }); + + it('keeps structured stderr empty in CI and non-TTY execution', async () => { + const result = await invoke(['help', '--format', 'json'], { + env: { CI: 'true' }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).not.toMatch(/\u001b\[/); + expect(JSON.parse(result.stdout).event).toBe('operation.completed'); + }); + + it('strips terminal styling from non-TTY human output', async () => { + const result = await invoke(['help', '--\u001b[31munknown\u001b[0m']); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('CLI_OPTION_UNKNOWN'); + expect(result.stderr).not.toMatch(/\u001b\[/); + }); + + it('resolves relative, absolute, symlinked, and concurrent cwd values independently', async () => { + const root = mkdtempSync(join(REAL_TMP_DIR, 'cnc cwd matrix-')); + const first = join(root, 'first workspace'); + const second = join(root, 'second workspace'); + const linked = join(root, 'linked workspace'); + mkdirSync(first); + mkdirSync(second); + symlinkSync(first, linked, 'dir'); + + try { + const [relativeResult, absoluteResult, symlinkResult] = await Promise.all( + [ + invoke( + [ + 'docs', + 'export', + '--target', + 'agent docs', + '--dry-run', + '--cwd', + 'first workspace', + '--format', + 'json', + ], + { cwd: root } + ), + invoke( + [ + 'docs', + 'export', + '--target', + 'agent docs', + '--dry-run', + '--cwd', + second, + '--format', + 'json', + ], + { cwd: root } + ), + invoke( + [ + 'docs', + 'export', + '--target', + 'agent docs', + '--dry-run', + '--cwd', + linked, + '--format', + 'json', + ], + { cwd: root } + ), + ] + ); + + for (const result of [relativeResult, absoluteResult, symlinkResult]) { + expect(result).toEqual( + expect.objectContaining({ exitCode: 0, stderr: '' }) + ); + expect(JSON.parse(result.stdout).result.artifacts).toEqual([]); + } + const targets = [relativeResult, absoluteResult, symlinkResult].map( + ({ stdout }) => JSON.parse(stdout).result.data.target + ); + expect(targets).toEqual([ + join(first, 'agent docs'), + join(second, 'agent docs'), + join(linked, 'agent docs'), + ]); + expect(process.cwd()).not.toBe(first); + expect(process.cwd()).not.toBe(second); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('uses the adapter environment for state and never emits token material', async () => { + const home = mkdtempSync(join(REAL_TMP_DIR, 'cnc-agent-home-')); + const token = 'agent-token-that-must-not-leak'; + try { + const created = await invoke( + [ + 'context', + 'create', + 'preview', + '--endpoint', + 'https://preview.example.com/graphql', + '--agent', + ], + { env: { HOME: home } } + ); + const authenticated = await invoke( + ['auth', 'set-token', '--context', 'preview', '--agent'], + { env: { HOME: home, CNC_TOKEN: token } } + ); + + expect(created).toEqual( + expect.objectContaining({ exitCode: 0, stderr: '' }) + ); + expect(authenticated).toEqual( + expect.objectContaining({ exitCode: 0, stderr: '' }) + ); + expect(created.stdout).not.toContain(token); + expect(authenticated.stdout).not.toContain(token); + + const statePath = join(home, '.cnc', 'config', 'state.json'); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + const state = JSON.parse(readFileSync(statePath, 'utf8')); + expect(state).toMatchObject({ + settings: { currentContext: 'preview' }, + credentials: { tokens: { preview: { token } } }, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('returns a safe JSON error for secret-bearing context endpoints', async () => { + const home = mkdtempSync(join(REAL_TMP_DIR, 'cnc-endpoint-home-')); + const endpointSecret = 'query-secret-that-must-not-leak'; + try { + const result = await invoke( + [ + 'context', + 'create', + 'unsafe', + '--endpoint', + `https://api.example.com/graphql?authorization=${endpointSecret}`, + '--format', + 'json', + ], + { env: { HOME: home } } + ); + + expect(result).toEqual( + expect.objectContaining({ exitCode: 2, stderr: '' }) + ); + expect(result.stdout).not.toContain(endpointSecret); + expect(JSON.parse(result.stdout)).toMatchObject({ + event: 'operation.failed', + error: { code: 'CONTEXT_ENDPOINT_INVALID' }, + }); + expect(existsSync(join(home, '.cnc'))).toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('cancels a token-stdin read without waiting for EOF', async () => { + const stdin = new Readable({ read: () => undefined }) as Readable & { + isTTY?: boolean; + }; + stdin.isTTY = false; + const stdout = new CaptureStream(false); + const stderr = new CaptureStream(false); + const controller = new AbortController(); + const pending = runCli( + ['auth', 'set-token', '--context', 'preview', '--token-stdin', '--agent'], + { + cwd: process.cwd(), + env: { HOME: DEFAULT_TEST_HOME }, + stdin, + stdout, + stderr, + signal: controller.signal, + version: '7.30.4-test', + } + ); + setImmediate(() => + controller.abort(new DOMException('Test cancellation.', 'AbortError')) + ); + + const exitCode = await pending; + const terminal = JSON.parse(stdout.text().trim().split('\n').at(-1)!); + expect(exitCode).toBe(130); + expect(terminal).toMatchObject({ event: 'operation.cancelled' }); + expect(stderr.text()).toBe(''); + }); +}); diff --git a/packages/cli/__tests__/runtime-codegen-command.test.ts b/packages/cli/__tests__/runtime-codegen-command.test.ts new file mode 100644 index 0000000000..4bbd8dbdea --- /dev/null +++ b/packages/cli/__tests__/runtime-codegen-command.test.ts @@ -0,0 +1,586 @@ +import * as fs from 'node:fs'; +import http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + createCommandRegistry, + executeCommand, +} from '@constructive-io/cli-runtime'; + +import { codegenCommand } from '../src/runtime/codegen-command'; + +describe('CNC codegen operation protocol', () => { + let cwd: string; + let server: http.Server | undefined; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cnc-codegen-cancel-')); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; + } + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it('passes OperationContext.signal into endpoint introspection', async () => { + let requestStarted!: () => void; + const started = new Promise((resolve) => { + requestStarted = resolve; + }); + server = http.createServer(() => { + requestStarted(); + // A successful cancellation must terminate this request without a reply. + }); + await new Promise((resolve) => + server!.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture server did not expose a TCP port.'); + } + + const controller = new AbortController(); + const registry = createCommandRegistry([codegenCommand]); + const pending = executeCommand( + registry, + codegenCommand, + { + endpoint: `http://127.0.0.1:${address.port}/graphql`, + output: 'generated', + orm: true, + }, + { + cwd, + mode: 'agent', + env: {}, + signal: controller.signal, + } + ); + + await started; + controller.abort(); + + await expect(pending).resolves.toMatchObject({ + status: 'cancelled', + error: { code: 'OPERATION_CANCELLED' }, + }); + expect(fs.existsSync(path.join(cwd, 'generated'))).toBe(false); + }); + + it.each([ + [ + 'userinfo', + (secret: string) => `http://user:${secret}@127.0.0.1:1/graphql`, + ], + [ + 'credential query', + (secret: string) => `http://127.0.0.1:1/graphql?api_key=${secret}`, + ], + ])( + 'rejects %s endpoints before emitting codegen events without leaking the secret', + async (_case, endpointFor) => { + const secret = 'codegen-endpoint-secret-that-must-not-leak'; + const registry = createCommandRegistry([codegenCommand]); + const delivered: unknown[] = []; + const outcome = await executeCommand( + registry, + codegenCommand, + { + endpoint: endpointFor(secret), + output: 'generated', + orm: true, + }, + { + cwd, + mode: 'agent', + env: {}, + sink: (event) => { + delivered.push(event); + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CODEGEN_ENDPOINT_INVALID' }, + }); + expect( + outcome.protocolEvents.filter( + ({ event }) => event === 'codegen.progress' + ) + ).toEqual([]); + expect(JSON.stringify({ outcome, delivered })).not.toContain(secret); + expect(fs.existsSync(path.join(cwd, 'generated'))).toBe(false); + } + ); + + it('redacts single-target config transport secrets while preserving the request', async () => { + const authorizationSecret = 'single-authorization-secret'; + const headerSecret = 'single-custom-header-secret'; + const querySecret = 'single-query-secret'; + const requests: Array<{ + authorization?: string; + customHeader?: string; + url?: string; + }> = []; + server = http.createServer((request, response) => { + const reflected = { + authorization: request.headers.authorization, + customHeader: request.headers['x-reflected-secret'] as + | string + | undefined, + url: request.url, + }; + requests.push(reflected); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + errors: [ + { + message: `Reflected ${reflected.authorization} ${reflected.customHeader} ${reflected.url}`, + }, + ], + }) + ); + }); + await new Promise((resolve) => + server!.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture server did not expose a TCP port.'); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + const configPath = path.join(cwd, 'graphql-codegen.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + endpoint: `${baseUrl}/graphql?tenant=${querySecret}`, + authorization: `Bearer ${authorizationSecret}`, + headers: { 'X-Reflected-Secret': headerSecret }, + output: 'generated', + orm: true, + }) + ); + const registry = createCommandRegistry([codegenCommand]); + const delivered: unknown[] = []; + + const outcome = await executeCommand( + registry, + codegenCommand, + { config: configPath }, + { + cwd, + mode: 'agent', + env: {}, + sink: (event) => { + delivered.push(event); + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CODEGEN_FAILED' }, + }); + expect(requests).toEqual([ + { + authorization: `Bearer ${authorizationSecret}`, + customHeader: headerSecret, + url: `/graphql?tenant=${querySecret}`, + }, + ]); + const progress = outcome.protocolEvents.filter( + ({ event }) => event === 'codegen.progress' + ); + expect(progress).toEqual([ + expect.objectContaining({ + phase: 'schema.fetch', + message: `Fetching schema from endpoint: ${baseUrl}/graphql...`, + }), + ]); + const serialized = JSON.stringify({ outcome, delivered }); + for (const secret of [authorizationSecret, headerSecret, querySecret]) { + expect(serialized).not.toContain(secret); + } + expect(fs.existsSync(path.join(cwd, 'generated'))).toBe(false); + }); + + it('redacts all multi-target config transport secrets before either target reports', async () => { + const targetSecrets = [ + { + name: 'alpha', + authorization: 'alpha-authorization-secret', + header: 'alpha-header-secret', + query: 'alpha-query-secret', + }, + { + name: 'beta', + authorization: 'beta-authorization-secret', + header: 'beta-header-secret', + query: 'beta-query-secret', + }, + ]; + const requests: Array<{ + authorization?: string; + customHeader?: string; + url?: string; + }> = []; + server = http.createServer((request, response) => { + const reflected = { + authorization: request.headers.authorization, + customHeader: request.headers['x-reflected-secret'] as + | string + | undefined, + url: request.url, + }; + requests.push(reflected); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + errors: [ + { + message: `Reflected ${reflected.authorization} ${reflected.customHeader} ${reflected.url}`, + }, + ], + }) + ); + }); + await new Promise((resolve) => + server!.listen(0, '127.0.0.1', resolve) + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture server did not expose a TCP port.'); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + const configPath = path.join(cwd, 'graphql-codegen.json'); + fs.writeFileSync( + configPath, + JSON.stringify( + Object.fromEntries( + targetSecrets.map((target) => [ + target.name, + { + endpoint: `${baseUrl}/${target.name}/graphql?tenant=${target.query}`, + authorization: `Bearer ${target.authorization}`, + headers: { 'X-Reflected-Secret': target.header }, + output: `generated/${target.name}`, + orm: true, + }, + ]) + ) + ) + ); + const registry = createCommandRegistry([codegenCommand]); + const delivered: unknown[] = []; + + const outcome = await executeCommand( + registry, + codegenCommand, + { config: configPath }, + { + cwd, + mode: 'agent', + env: {}, + sink: (event) => { + delivered.push(event); + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CODEGEN_FAILED' }, + }); + expect(requests).toEqual( + targetSecrets.map((target) => ({ + authorization: `Bearer ${target.authorization}`, + customHeader: target.header, + url: `/${target.name}/graphql?tenant=${target.query}`, + })) + ); + expect( + outcome.protocolEvents + .filter(({ event }) => event === 'codegen.progress') + .map((event) => (event as unknown as { message: string }).message) + ).toEqual( + targetSecrets.map( + (target) => + `Fetching schema from endpoint: ${baseUrl}/${target.name}/graphql...` + ) + ); + const serialized = JSON.stringify({ outcome, delivered }); + for (const target of targetSecrets) { + for (const secret of [ + target.authorization, + target.header, + target.query, + ]) { + expect(serialized).not.toContain(secret); + } + } + expect(fs.existsSync(path.join(cwd, 'generated'))).toBe(false); + }); + + it.each([ + [ + 'userinfo', + (secret: string) => `http://user:${secret}@127.0.0.1:1/graphql`, + ], + [ + 'credential query', + (secret: string) => `http://127.0.0.1:1/graphql?api_key=${secret}`, + ], + ['fragment', (secret: string) => `http://127.0.0.1:1/graphql#${secret}`], + ])( + 'rejects config endpoint %s before progress without leaking it', + async (_case, endpointFor) => { + const secret = 'unsafe-config-endpoint-secret'; + const configPath = path.join(cwd, 'graphql-codegen.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + endpoint: endpointFor(secret), + output: 'generated', + orm: true, + }) + ); + const registry = createCommandRegistry([codegenCommand]); + const delivered: unknown[] = []; + + const outcome = await executeCommand( + registry, + codegenCommand, + { config: configPath }, + { + cwd, + mode: 'agent', + env: {}, + sink: (event) => { + delivered.push(event); + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CODEGEN_ENDPOINT_INVALID' }, + }); + expect( + outcome.protocolEvents.filter( + ({ event }) => event === 'codegen.progress' + ) + ).toEqual([]); + expect(JSON.stringify({ outcome, delivered })).not.toContain(secret); + expect(fs.existsSync(path.join(cwd, 'generated'))).toBe(false); + } + ); + + it.each(['agent', 'ci', 'human'] as const)( + 'rejects explicit executable config before evaluation in %s mode', + async (mode) => { + const marker = path.join(cwd, `executed-${mode}`); + const configPath = path.join(cwd, `malicious-${mode}.ts`); + fs.writeFileSync( + configPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'executed');\nexport default { schemaFile: './schema.graphql', orm: true };\n` + ); + const registry = createCommandRegistry([codegenCommand]); + + const outcome = await executeCommand( + registry, + codegenCommand, + { config: configPath, dryRun: true }, + { cwd, mode, env: {} } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CODEGEN_CONFIG_EXECUTABLE_UNSUPPORTED' }, + }); + expect(fs.existsSync(marker)).toBe(false); + } + ); + + it('rejects auto-discovered executable config before dry-run evaluation', async () => { + const marker = path.join(cwd, 'discovered-config-executed'); + fs.writeFileSync( + path.join(cwd, 'graphql-codegen.config.ts'), + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'executed');\nexport default { schemaFile: './schema.graphql', orm: true };\n` + ); + const registry = createCommandRegistry([codegenCommand]); + + const outcome = await executeCommand( + registry, + codegenCommand, + { dryRun: true }, + { cwd, mode: 'agent', env: {} } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'CODEGEN_CONFIG_EXECUTABLE_UNSUPPORTED' }, + }); + expect(fs.existsSync(marker)).toBe(false); + }); + + it('auto-discovers declarative JSON config without requiring --config', async () => { + fs.writeFileSync( + path.join(cwd, 'schema.graphql'), + 'type Query { hello: String }\n' + ); + fs.writeFileSync( + path.join(cwd, 'graphql-codegen.config.json'), + JSON.stringify({ + schemaFile: './schema.graphql', + schema: { enabled: true, output: './generated' }, + }) + ); + const registry = createCommandRegistry([codegenCommand]); + + const outcome = await executeCommand( + registry, + codegenCommand, + { dryRun: true }, + { cwd, mode: 'agent', env: {} } + ); + + expect(outcome).toMatchObject({ + status: 'completed', + result: { data: { success: true, dryRun: true }, artifacts: [] }, + }); + expect(fs.existsSync(path.join(cwd, 'generated'))).toBe(false); + }); + + it('returns retained transaction warnings and artifacts without changing data', async () => { + const source = path.join(cwd, 'schema.graphql'); + fs.writeFileSync(source, 'type Query { hello: String }\n'); + const nativeFs = require('node:fs') as typeof fs; + const originalRemove = nativeFs.rmSync; + let cleanupFailureInjected = false; + const removeSpy = jest + .spyOn(nativeFs, 'rmSync') + .mockImplementation((target, options) => { + if ( + !cleanupFailureInjected && + String(target).includes('.codegen-transaction-') + ) { + cleanupFailureInjected = true; + throw new Error('injected transaction cleanup failure'); + } + return originalRemove(target, options); + }); + + const registry = createCommandRegistry([codegenCommand]); + let outcome: Awaited>; + try { + outcome = await executeCommand( + registry, + codegenCommand, + { + schemaFile: source, + schemaEnabled: true, + schemaOutput: 'generated', + }, + { cwd, mode: 'agent', env: {} } + ); + } finally { + removeSpy.mockRestore(); + } + + expect(outcome).toMatchObject({ + status: 'completed', + result: { + data: { success: true }, + warnings: [ + { + code: 'CODEGEN_RECOVERY_RETAINED', + message: expect.stringContaining( + 'injected transaction cleanup failure' + ), + }, + ], + artifacts: expect.arrayContaining([ + expect.objectContaining({ type: 'codegen-recovery' }), + ]), + }, + }); + expect( + (outcome as { result?: { data?: unknown } }).result?.data + ).not.toHaveProperty('recoveryPath'); + const recoveryArtifact = ( + outcome as { + result?: { artifacts?: Array<{ type: string; path: string }> }; + } + ).result?.artifacts?.find(({ type }) => type === 'codegen-recovery'); + expect(recoveryArtifact).toBeDefined(); + expect(fs.existsSync(recoveryArtifact!.path)).toBe(true); + }); + + it('includes rollback recovery evidence in structured failure details', async () => { + const source = path.join(cwd, 'schema.graphql'); + fs.writeFileSync(source, 'type Query { hello: String }\n'); + const registry = createCommandRegistry([codegenCommand]); + const input = { + schemaFile: source, + schemaEnabled: true, + schemaOutput: 'generated', + }; + const initial = await executeCommand(registry, codegenCommand, input, { + cwd, + mode: 'agent', + env: {}, + }); + expect(initial.status).toBe('completed'); + fs.appendFileSync( + source, + '\nextend type Query { recoveryProbe: String }\n' + ); + + const nativeFs = require('node:fs') as typeof fs; + const originalRename = nativeFs.renameSync; + let renameCall = 0; + const renameSpy = jest + .spyOn(nativeFs, 'renameSync') + .mockImplementation((from, to) => { + renameCall += 1; + if (renameCall === 2) throw new Error('injected commit failure'); + if (renameCall === 3) throw new Error('injected restore failure'); + return originalRename(from, to); + }); + + let outcome: Awaited>; + try { + outcome = await executeCommand(registry, codegenCommand, input, { + cwd, + mode: 'agent', + env: {}, + }); + } finally { + renameSpy.mockRestore(); + } + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'CODEGEN_FAILED', + details: { + recoveryPath: expect.stringContaining('.codegen-transaction-'), + rollbackErrors: [expect.stringContaining('injected restore failure')], + }, + }, + }); + const recoveryPath = ( + outcome as { error?: { details?: { recoveryPath?: string } } } + ).error?.details?.recoveryPath; + expect(recoveryPath).toBeDefined(); + expect(fs.existsSync(recoveryPath!)).toBe(true); + }); +}); diff --git a/packages/cli/__tests__/runtime-execute-command.test.ts b/packages/cli/__tests__/runtime-execute-command.test.ts new file mode 100644 index 0000000000..ca7e16a6fd --- /dev/null +++ b/packages/cli/__tests__/runtime-execute-command.test.ts @@ -0,0 +1,407 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + bindArguments, + createCommandRegistry, + executeCommand, +} from '@constructive-io/cli-runtime'; + +import { + ConfigStore, + createContext, + setContextCredentials, + setCurrentContext, +} from '../src/config'; +import { createExecuteCommandDefinition } from '../src/runtime/execute-command'; + +const NOW = new Date('2026-07-20T00:00:00.000Z'); +const STORED_TOKEN = 'stored-secret-token'; +const REAL_TMP_DIR = fs.realpathSync(os.tmpdir()); + +const response = (body: unknown, init: ResponseInit = {}): Response => + new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); + +describe('raw GraphQL execute command definition', () => { + let cwd: string; + let configDir: string; + let store: ConfigStore; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(REAL_TMP_DIR, 'cnc-execute-cwd-')); + configDir = fs.mkdtempSync(path.join(REAL_TMP_DIR, 'cnc-execute-state-')); + store = new ConfigStore({ configDir }); + createContext('api', 'https://api.example.com/graphql', store, NOW); + setCurrentContext('api', store); + setContextCredentials('api', STORED_TOKEN, undefined, store); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + it('resolves files and JSON variables from the operation cwd', async () => { + fs.mkdirSync(path.join(cwd, 'queries')); + fs.writeFileSync( + path.join(cwd, 'queries', 'viewer.graphql'), + 'query Viewer($id: ID!) { viewer(id: $id) { id } }' + ); + const fetchMock = jest.fn(async () => + response({ data: { viewer: { id: 'viewer-1' } } }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + const bound = bindArguments( + command, + { + argv: [ + '--file', + 'queries/viewer.graphql', + '--variables', + '{"id":"viewer-1"}', + '--context', + 'api', + '--timeout-ms', + '1000', + ], + }, + registry + ); + + const outcome = await executeCommand(registry, command, bound.input, { + cwd, + mode: 'agent', + env: {}, + now: () => NOW, + }); + expect(outcome).toMatchObject({ + status: 'completed', + result: { + data: { + contextName: 'api', + operation: { type: 'query', name: 'Viewer' }, + data: { viewer: { id: 'viewer-1' } }, + }, + }, + }); + const request = (fetchMock as jest.Mock).mock.calls[0][1] as RequestInit; + expect(request.headers).toHaveProperty( + 'Authorization', + `Bearer ${STORED_TOKEN}` + ); + expect(JSON.parse(request.body as string)).toMatchObject({ + operationName: 'Viewer', + variables: { id: 'viewer-1' }, + }); + expect(JSON.stringify(outcome)).not.toContain(STORED_TOKEN); + }); + + it('does not fall back to global current context outside human mode', async () => { + const fetchMock = jest.fn(async () => + response({ data: { ok: true } }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + + const agent = await executeCommand( + registry, + command, + { query: 'query Check { ok }' }, + { cwd, mode: 'agent', env: {}, now: () => NOW } + ); + expect(agent).toMatchObject({ + status: 'failed', + error: { code: 'CONTEXT_REQUIRED' }, + }); + + const human = await executeCommand( + registry, + command, + { query: 'query Check { ok }' }, + { cwd, mode: 'human', env: {}, now: () => NOW } + ); + expect(human.status).toBe('completed'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('requires allow-mutation and confirmation before agent or CI mutations', async () => { + const fetchMock = jest.fn(async () => + response({ data: { change: true } }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + const input = { + query: 'mutation Change { change }', + contextName: 'api', + }; + + const noFlag = await executeCommand(registry, command, input, { + cwd, + mode: 'agent', + env: {}, + now: () => NOW, + capabilities: { yes: true }, + }); + expect(noFlag).toMatchObject({ + status: 'failed', + error: { code: 'GRAPHQL_MUTATION_REQUIRES_APPROVAL' }, + }); + + const noConfirmation = await executeCommand( + registry, + command, + { ...input, allowMutation: true }, + { cwd, mode: 'ci', env: {}, now: () => NOW } + ); + expect(noConfirmation).toMatchObject({ + status: 'failed', + error: { code: 'GRAPHQL_MUTATION_REQUIRES_APPROVAL' }, + }); + + const allowed = await executeCommand( + registry, + command, + { ...input, allowMutation: true }, + { + cwd, + mode: 'agent', + env: {}, + now: () => NOW, + capabilities: { yes: true }, + } + ); + expect(allowed.status).toBe('completed'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('supports explicit anonymous execution without sending credentials', async () => { + const fetchMock = jest.fn(async () => + response({ data: { health: true } }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + const outcome = await executeCommand( + registry, + command, + { query: 'query Health { health }', anonymous: true }, + { + cwd, + mode: 'agent', + env: { CNC_CONTEXT: 'api' }, + now: () => NOW, + } + ); + expect(outcome).toMatchObject({ + status: 'completed', + result: { data: { anonymous: true } }, + }); + const request = (fetchMock as jest.Mock).mock.calls[0][1] as RequestInit; + expect(request.headers).not.toHaveProperty('Authorization'); + }); + + it('returns typed failures with redacted partial data and safe GraphQL errors', async () => { + const responseSecret = 'response-secret-token'; + const extensionSecret = 'extension-secret-token'; + const fetchMock = jest.fn(async () => + response({ + data: { viewer: { id: 'viewer-1', token: responseSecret } }, + errors: [ + { + message: 'Viewer name is unavailable.', + path: ['viewer', 'name'], + extensions: { token: extensionSecret }, + }, + ], + }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + + const outcome = await executeCommand( + registry, + command, + { query: 'query Viewer { viewer { id token } }', contextName: 'api' }, + { cwd, mode: 'agent', env: {}, now: () => NOW } + ); + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'GRAPHQL_RESPONSE_ERROR', + details: { + partialData: { + viewer: { id: 'viewer-1', token: '[REDACTED]' }, + }, + graphqlErrors: [ + { + message: 'Viewer name is unavailable.', + path: ['viewer', 'name'], + }, + ], + }, + }, + }); + const serialized = JSON.stringify(outcome); + expect(serialized).not.toContain(STORED_TOKEN); + expect(serialized).not.toContain(responseSecret); + expect(serialized).not.toContain(extensionSecret); + }); + + it('redacts stored credentials echoed by an upstream GraphQL error', async () => { + const fetchMock = jest.fn(async () => + response({ + data: { echoed: `Bearer ${STORED_TOKEN}` }, + errors: [{ message: `Rejected Bearer ${STORED_TOKEN}` }], + }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + + const outcome = await executeCommand( + registry, + command, + { query: 'query Echo { echoed }', contextName: 'api' }, + { cwd, mode: 'agent', env: {}, now: () => NOW } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'GRAPHQL_RESPONSE_ERROR', + details: { + partialData: { echoed: 'Bearer [REDACTED]' }, + graphqlErrors: [{ message: 'Rejected Bearer [REDACTED]' }], + }, + }, + }); + expect(JSON.stringify(outcome)).not.toContain(STORED_TOKEN); + }); + + it('redacts nested sensitive GraphQL variable values echoed by an upstream error', async () => { + const password = 'nested-password-that-must-not-leak'; + const apiKey = 'nested-api-key-that-must-not-leak'; + const fetchMock = jest.fn(async () => + response({ + data: { echoed: `Rejected ${password} and ${apiKey}` }, + errors: [ + { + message: `Password ${password} and API key ${apiKey} were rejected.`, + }, + ], + }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + const variables = { + input: { + account: { password }, + integrations: [{ apiKey }], + }, + }; + + const outcome = await executeCommand( + registry, + command, + { + query: 'query Probe($input: JSON) { probe(input: $input) }', + variables, + contextName: 'api', + anonymous: true, + }, + { cwd, mode: 'agent', env: {}, now: () => NOW } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'GRAPHQL_RESPONSE_ERROR', + details: { + partialData: { + echoed: 'Rejected [REDACTED] and [REDACTED]', + }, + graphqlErrors: [ + { + message: + 'Password [REDACTED] and API key [REDACTED] were rejected.', + }, + ], + }, + }, + }); + expect(JSON.stringify(outcome)).not.toContain(password); + expect(JSON.stringify(outcome)).not.toContain(apiKey); + expect( + JSON.parse((fetchMock as jest.Mock).mock.calls[0][1].body) + ).toMatchObject({ variables }); + }); + + it('maps an aborted request to protocol cancellation', async () => { + const fetchMock = jest.fn( + async (_input: URL | RequestInfo, init?: RequestInit) => + new Promise((_resolve, reject) => { + if (init?.signal?.aborted) { + reject(init.signal.reason); + return; + } + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ); + }) + ) as unknown as typeof fetch; + const command = createExecuteCommandDefinition({ store, fetch: fetchMock }); + const registry = createCommandRegistry([command]); + const controller = new AbortController(); + const pending = executeCommand( + registry, + command, + { query: 'query Viewer { viewer { id } }', contextName: 'api' }, + { + cwd, + mode: 'agent', + env: {}, + signal: controller.signal, + now: () => NOW, + } + ); + + await Promise.resolve(); + controller.abort(new DOMException('Test cancellation.', 'AbortError')); + + await expect(pending).resolves.toMatchObject({ + status: 'cancelled', + error: { code: 'OPERATION_CANCELLED' }, + }); + }); + + it('rejects traversal and symlink escapes from the operation cwd', async () => { + const outside = fs.mkdtempSync( + path.join(os.tmpdir(), 'cnc-execute-outside-') + ); + const outsideFile = path.join(outside, 'outside.graphql'); + fs.writeFileSync(outsideFile, 'query Outside { outside }'); + fs.symlinkSync(outsideFile, path.join(cwd, 'linked.graphql')); + const command = createExecuteCommandDefinition({ store }); + const registry = createCommandRegistry([command]); + + for (const file of ['../outside.graphql', 'linked.graphql']) { + const outcome = await executeCommand( + registry, + command, + { file, contextName: 'api' }, + { cwd, mode: 'agent', env: {}, now: () => NOW } + ); + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'GRAPHQL_FILE_OUTSIDE_CWD' }, + }); + } + fs.rmSync(outside, { recursive: true, force: true }); + }); +}); diff --git a/packages/cli/__tests__/runtime-registry-purity.test.ts b/packages/cli/__tests__/runtime-registry-purity.test.ts new file mode 100644 index 0000000000..f8632712ac --- /dev/null +++ b/packages/cli/__tests__/runtime-registry-purity.test.ts @@ -0,0 +1,215 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const packageRoot = resolve(__dirname, '..'); +const registryPath = resolve(packageRoot, 'dist/runtime/registry.js'); +const serviceCommandsPath = resolve( + packageRoot, + 'dist/runtime/service-commands.js' +); +const codegenCommandPath = resolve( + packageRoot, + 'dist/runtime/codegen-command.js' +); + +describe('CNC registry module purity', () => { + it('imports and constructs the registry without terminal output', () => { + expect(existsSync(registryPath)).toBe(true); + const { + GRAPHILE_ENV: _graphileEnv, + NODE_ENV: _nodeEnv, + ...environment + } = process.env; + const child = spawnSync( + process.execPath, + [ + '-e', + `const { createCncRegistry } = require(${JSON.stringify( + registryPath + )}); createCncRegistry({ version: 'test', store: {} });`, + ], + { + cwd: packageRoot, + env: environment, + encoding: 'utf8', + } + ); + + expect(child.status).toBe(0); + expect(child.stdout).toBe(''); + expect(child.stderr).toBe(''); + }); + + it('keeps direct service operation imports silent', () => { + expect(existsSync(serviceCommandsPath)).toBe(true); + const { + GRAPHILE_ENV: _graphileEnv, + NODE_ENV: _nodeEnv, + ...environment + } = process.env; + const child = spawnSync( + process.execPath, + [ + '-e', + ` + const { + createCommandRegistry, + executeCommand, + } = require('@constructive-io/cli-runtime'); + const { serverCommand } = require(${JSON.stringify(serviceCommandsPath)}); + const registry = createCommandRegistry([serverCommand]); + executeCommand( + registry, + serverCommand, + { database: 'app', servicesApi: false, schemas: '' }, + { cwd: ${JSON.stringify(packageRoot)}, mode: 'agent', env: {} }, + ).then((outcome) => { + process.stdout.write(JSON.stringify({ + status: outcome.status, + code: outcome.error?.code, + })); + }); + `, + ], + { + cwd: packageRoot, + env: environment, + encoding: 'utf8', + } + ); + + expect(child.status).toBe(0); + expect(child.stderr).toBe(''); + expect(JSON.parse(child.stdout)).toEqual({ + status: 'failed', + code: 'SERVER_SCHEMAS_REQUIRED', + }); + }); + + it('keeps direct codegen operation execution silent', () => { + expect(existsSync(codegenCommandPath)).toBe(true); + const { + GRAPHILE_ENV: _graphileEnv, + NODE_ENV: _nodeEnv, + ...environment + } = process.env; + const child = spawnSync( + process.execPath, + [ + '-e', + ` + const { + createCommandRegistry, + executeCommand, + } = require('@constructive-io/cli-runtime'); + const { codegenCommand } = require(${JSON.stringify(codegenCommandPath)}); + const registry = createCommandRegistry([codegenCommand]); + executeCommand( + registry, + codegenCommand, + { schemaFile: 'missing-schema.graphql', orm: true }, + { cwd: ${JSON.stringify(packageRoot)}, mode: 'agent', env: {} }, + ).then((outcome) => { + process.stdout.write(JSON.stringify({ + status: outcome.status, + code: outcome.error?.code, + })); + }); + `, + ], + { + cwd: packageRoot, + env: environment, + encoding: 'utf8', + } + ); + + expect(child.status).toBe(0); + expect(child.stderr).toBe(''); + expect(JSON.parse(child.stdout)).toEqual({ + status: 'failed', + code: 'CODEGEN_FAILED', + }); + }); + + it('rejects executable codegen config before process side effects', () => { + expect(existsSync(codegenCommandPath)).toBe(true); + const fixtureRoot = mkdtempSync( + join(tmpdir(), 'cnc-codegen-policy-child-') + ); + const changedCwd = join(fixtureRoot, 'changed-cwd'); + const marker = join(fixtureRoot, 'config-executed'); + const configPath = join(fixtureRoot, 'malicious.cjs'); + mkdirSync(changedCwd); + writeFileSync( + configPath, + ` + const fs = require('node:fs'); + process.stdout.write('executable-config-stdout'); + process.stderr.write('executable-config-stderr'); + fs.writeFileSync(${JSON.stringify(marker)}, 'executed'); + process.chdir(${JSON.stringify(changedCwd)}); + process.exit(91); + module.exports = { schemaFile: './missing.graphql', orm: true }; + ` + ); + const { + GRAPHILE_ENV: _graphileEnv, + NODE_ENV: _nodeEnv, + ...environment + } = process.env; + + try { + const child = spawnSync( + process.execPath, + [ + '-e', + ` + const { + createCommandRegistry, + executeCommand, + } = require('@constructive-io/cli-runtime'); + const { codegenCommand } = require(${JSON.stringify(codegenCommandPath)}); + const registry = createCommandRegistry([codegenCommand]); + executeCommand( + registry, + codegenCommand, + { config: ${JSON.stringify(configPath)}, dryRun: true }, + { cwd: ${JSON.stringify(fixtureRoot)}, mode: 'human', env: {} }, + ).then((outcome) => { + process.stdout.write(JSON.stringify({ + status: outcome.status, + code: outcome.error?.code, + cwd: process.cwd(), + })); + }); + `, + ], + { + cwd: packageRoot, + env: environment, + encoding: 'utf8', + } + ); + + expect(child.status).toBe(0); + expect(child.stderr).toBe(''); + expect(JSON.parse(child.stdout)).toEqual({ + status: 'failed', + code: 'CODEGEN_CONFIG_EXECUTABLE_UNSUPPORTED', + cwd: packageRoot, + }); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/__tests__/runtime-service-commands.test.ts b/packages/cli/__tests__/runtime-service-commands.test.ts new file mode 100644 index 0000000000..b7ef541462 --- /dev/null +++ b/packages/cli/__tests__/runtime-service-commands.test.ts @@ -0,0 +1,638 @@ +import { EventEmitter } from 'node:events'; +import type { AddressInfo } from 'node:net'; +import type { Server as HttpServer } from 'node:http'; + +import { + createCommandRegistry, + executeCommand, + type ProtocolEvent, +} from '@constructive-io/cli-runtime'; +import { getEnvOptions } from '@constructive-io/graphql-env'; +import { startGraphQLExplorer } from '@constructive-io/graphql-explorer'; +import { Server as GraphQLServer } from '@constructive-io/graphql-server'; +import { KnativeJobsSvc } from '@constructive-io/knative-job-service'; +import { Logger } from '@pgpmjs/logger'; +import type { Inquirerer } from 'inquirerer'; +import { getPgPool } from 'pg-cache'; + +import { + explorerCommand, + jobsCommand, + parseFunctions, + serverCommand, +} from '../src/runtime/service-commands'; +import { createServiceHooks } from '../src/runtime/service-hooks'; + +const mockPromptPoolClose = jest.fn(async (): Promise => undefined); + +jest.mock('@constructive-io/graphql-env', () => ({ + getEnvOptions: jest.fn(), +})); +jest.mock('@constructive-io/graphql-explorer', () => ({ + startGraphQLExplorer: jest.fn(), +})); +jest.mock('@constructive-io/graphql-server', () => ({ + Server: jest.fn(), + withServerEnvironment: async ( + _environment: unknown, + callback: () => Promise + ) => callback(), +})); +jest.mock('@constructive-io/knative-job-service', () => ({ + KnativeJobsSvc: jest.fn(), +})); +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn(), + PgPoolCacheManager: class { + close = mockPromptPoolClose; + }, +})); + +class FakeHttpServer extends EventEmitter { + listening = true; + + constructor(private readonly bindAddress = '127.0.0.1') { + super(); + } + + address(): AddressInfo { + return { + address: this.bindAddress, + family: 'IPv4', + port: 43123, + }; + } + + close(callback?: (error?: Error) => void): this { + this.listening = false; + this.emit('close'); + callback?.(); + return this; + } +} + +const mockGetEnvOptions = jest.mocked(getEnvOptions); +const mockStartExplorer = jest.mocked(startGraphQLExplorer); +const MockGraphQLServer = jest.mocked(GraphQLServer); +const MockJobsService = jest.mocked(KnativeJobsSvc); +const mockGetPgPool = jest.mocked(getPgPool); +const commands = createCommandRegistry([ + serverCommand, + explorerCommand, + jobsCommand, +]); + +const unresolvedFailure = async (): Promise => + new Promise(() => undefined); + +describe('service command operations', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetEnvOptions.mockImplementation( + (overrides = {}) => + ({ + pg: { database: 'configured-app' }, + features: {}, + api: {}, + ...overrides, + server: { + host: '127.0.0.1', + port: 0, + ...(overrides as { server?: object }).server, + }, + }) as never + ); + }); + + const runUntilReady = async ( + commandId: string, + input: unknown, + controller: AbortController, + env: Readonly> = {} + ) => { + const events: ProtocolEvent[] = []; + const outcome = await executeCommand(commands, commandId, input, { + cwd: process.cwd(), + mode: 'agent', + env, + signal: controller.signal, + sink: async (event) => { + events.push(event); + if (event.event === 'service.ready') { + controller.abort(new DOMException('test shutdown', 'AbortError')); + } + }, + }); + return { events, outcome }; + }; + + it('emits readiness, awaits abort cleanup, and suppresses dependency logs', async () => { + const controller = new AbortController(); + const server = new FakeHttpServer(); + const close = jest.fn(async (): Promise => { + server.close(); + }); + const log = new Logger('service-command-test'); + const stdout = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const stderr = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + MockGraphQLServer.mockImplementation( + () => + ({ + start: jest.fn(async (): Promise => { + log.info('this must not escape'); + log.error('this must not escape'); + return server as unknown as HttpServer; + }), + waitForFailure: unresolvedFailure, + close, + }) as never + ); + + try { + const { events, outcome } = await runUntilReady( + 'server.start', + {}, + controller + ); + + expect(outcome.status).toBe('cancelled'); + expect(events.map((event) => event.event)).toEqual([ + 'operation.started', + 'service.starting', + 'service.ready', + 'service.stopping', + 'service.stopped', + 'operation.cancelled', + ]); + expect(close).toHaveBeenCalledWith({ closeCaches: true }); + expect(MockGraphQLServer).toHaveBeenCalledWith( + expect.objectContaining({ + features: { + simpleInflection: true, + oppositeBaseNames: false, + postgis: true, + }, + api: expect.objectContaining({ enableServicesApi: true }), + server: expect.objectContaining({ port: 5555 }), + }), + expect.anything() + ); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + + it('requires a non-default application database in noninteractive execution', async () => { + mockGetEnvOptions.mockReturnValueOnce({ + pg: { database: 'postgres' }, + features: {}, + api: { enableServicesApi: true, exposedSchemas: [] }, + server: { host: '127.0.0.1', port: 5555 }, + } as never); + + const outcome = await executeCommand( + commands, + 'server.start', + {}, + { + cwd: process.cwd(), + mode: 'agent', + env: {}, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'SERVER_DATABASE_REQUIRED', + category: 'configuration', + path: '/database', + }, + }); + expect(MockGraphQLServer).not.toHaveBeenCalled(); + }); + + it('maps direct-schema mode onto the server API contract', async () => { + const controller = new AbortController(); + const server = new FakeHttpServer(); + MockGraphQLServer.mockImplementation( + () => + ({ + start: jest.fn(async () => server as unknown as HttpServer), + waitForFailure: unresolvedFailure, + close: jest.fn(async (): Promise => { + server.close(); + }), + }) as never + ); + + const { outcome } = await runUntilReady( + 'server.start', + { + database: 'app', + servicesApi: false, + schemas: 'public, app_public', + authRole: 'anonymous', + roleName: 'authenticated', + }, + controller + ); + + expect(outcome.status).toBe('cancelled'); + expect(MockGraphQLServer).toHaveBeenCalledWith( + expect.objectContaining({ + api: { + enableServicesApi: false, + exposedSchemas: ['public', 'app_public'], + anonRole: 'anonymous', + roleName: 'authenticated', + }, + }), + expect.anything() + ); + }); + + it('keeps database selection and historical defaults in the human adapter', async () => { + mockGetEnvOptions + .mockReturnValueOnce({ pg: { database: 'postgres' } } as never) + .mockReturnValueOnce({ pg: { database: 'postgres' } } as never); + const query = jest.fn(async () => ({ rows: [{ datname: 'app' }] })); + mockGetPgPool.mockReturnValue({ query } as never); + const prompt = jest + .fn() + .mockResolvedValueOnce({ database: 'app' }) + .mockResolvedValueOnce({ + database: 'app', + simpleInflection: true, + oppositeBaseNames: false, + postgis: true, + servicesApi: true, + port: 5555, + }); + const hooks = createServiceHooks({ prompt } as unknown as Inquirerer); + + const collected = await hooks['server.start']?.collectInteractiveInput?.( + {}, + { + cwd: process.cwd(), + env: {}, + signal: new AbortController().signal, + operationId: 'interactive-test', + } + ); + + expect(query).toHaveBeenCalledWith(expect.stringContaining('pg_database')); + expect(mockGetPgPool).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ environment: {} }) + ); + expect(mockPromptPoolClose).toHaveBeenCalledTimes(1); + expect(collected).toMatchObject({ + database: 'app', + simpleInflection: true, + oppositeBaseNames: false, + postgis: true, + servicesApi: true, + port: 5555, + }); + }); + + it('maps port conflicts safely and redacts credentials from debug errors', async () => { + const secret = 'database-password-that-must-not-leak'; + const failure = Object.assign( + new Error(`failed using password ${secret}`), + { code: 'EADDRINUSE' } + ); + MockGraphQLServer.mockImplementation( + () => + ({ + start: jest.fn(async (): Promise => { + throw failure; + }), + waitForFailure: unresolvedFailure, + close: jest.fn(async (): Promise => undefined), + }) as never + ); + + const outcome = await executeCommand( + commands, + 'server.start', + {}, + { + cwd: process.cwd(), + mode: 'agent', + env: { PGPASSWORD: secret }, + debug: true, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'SERVICE_PORT_IN_USE', + details: { systemCode: 'EADDRINUSE' }, + }, + }); + expect(JSON.stringify(outcome)).not.toContain(secret); + }); + + it('redacts credentials resolved from the project config', async () => { + const secret = 'config-file-password-that-must-not-leak'; + mockGetEnvOptions.mockReturnValueOnce({ + pg: { + database: 'configured-app', + password: secret, + }, + cdn: { + awsAccessKey: 'config-access-key', + awsSecretKey: 'config-storage-secret', + }, + api: { enableServicesApi: true, exposedSchemas: [] }, + features: {}, + server: { host: '127.0.0.1', port: 5555 }, + } as never); + MockGraphQLServer.mockImplementation( + () => + ({ + start: jest.fn(async (): Promise => { + throw new Error( + `${secret} config-access-key config-storage-secret` + ); + }), + waitForFailure: unresolvedFailure, + close: jest.fn(async (): Promise => undefined), + }) as never + ); + + const outcome = await executeCommand( + commands, + 'server.start', + {}, + { + cwd: process.cwd(), + mode: 'agent', + env: {}, + debug: true, + } + ); + const serialized = JSON.stringify(outcome); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { code: 'SERVICE_START_FAILED' }, + }); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('config-access-key'); + expect(serialized).not.toContain('config-storage-secret'); + }); + + it.each(['0.0.0.0', '::'])( + 'reports a usable localhost URL when bound to %s', + async (bindAddress) => { + const controller = new AbortController(); + const server = new FakeHttpServer(bindAddress); + MockGraphQLServer.mockImplementation( + () => + ({ + start: jest.fn(async () => server as unknown as HttpServer), + waitForFailure: unresolvedFailure, + close: jest.fn(async (): Promise => { + server.close(); + }), + }) as never + ); + + const { events } = await runUntilReady('server.start', {}, controller); + + expect(events).toContainEqual( + expect.objectContaining({ + event: 'service.ready', + url: 'http://localhost:43123', + }) + ); + } + ); + + it.each(['service.starting', 'service.ready'] as const)( + 'closes the GraphQL server when %s delivery fails', + async (failedEvent) => { + const server = new FakeHttpServer(); + const close = jest.fn(async (): Promise => { + server.close(); + }); + MockGraphQLServer.mockImplementation( + () => + ({ + start: jest.fn(async () => server as unknown as HttpServer), + waitForFailure: unresolvedFailure, + close, + }) as never + ); + + const outcome = await executeCommand( + commands, + 'server.start', + {}, + { + cwd: process.cwd(), + mode: 'agent', + env: {}, + sink: async (event) => { + if (event.event === failedEvent) { + throw new Error('event transport unavailable'); + } + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'CLI_PROTOCOL_SINK_FAILED', + category: 'internal', + }, + }); + expect(close).toHaveBeenCalledWith({ closeCaches: true }); + } + ); + + it('returns the explorer URL without exposing any browser-opening hook', async () => { + const controller = new AbortController(); + const server = new FakeHttpServer(); + const close = jest.fn(async (): Promise => { + server.close(); + }); + mockStartExplorer.mockResolvedValue({ + app: {} as never, + httpServer: server as unknown as HttpServer, + url: 'http://127.0.0.1:43123', + waitForFailure: unresolvedFailure, + close, + }); + + const { outcome } = await runUntilReady('explorer.start', {}, controller, { + EXPLICIT_SERVICE_VALUE: 'yes', + }); + + expect(outcome.status).toBe('cancelled'); + expect(mockStartExplorer).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + env: { EXPLICIT_SERVICE_VALUE: 'yes' }, + signal: controller.signal, + }) + ); + const runtime = mockStartExplorer.mock.calls[0]?.[1]; + expect(runtime).not.toHaveProperty('openBrowser'); + expect(runtime).not.toHaveProperty('browser'); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes the explorer when protocol event delivery fails', async () => { + const server = new FakeHttpServer(); + const close = jest.fn(async (): Promise => { + server.close(); + }); + mockStartExplorer.mockResolvedValue({ + app: {} as never, + httpServer: server as unknown as HttpServer, + url: 'http://127.0.0.1:43123', + waitForFailure: unresolvedFailure, + close, + }); + + const outcome = await executeCommand( + commands, + 'explorer.start', + {}, + { + cwd: process.cwd(), + mode: 'agent', + env: {}, + sink: async (event) => { + if (event.event === 'service.ready') { + throw new Error('event transport unavailable'); + } + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'CLI_PROTOCOL_SINK_FAILED', + category: 'internal', + }, + }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('passes only the operation cwd, environment, and signal into jobs', async () => { + const controller = new AbortController(); + const stop = jest.fn(async (): Promise => undefined); + const log = new Logger('jobs-service-command-test'); + const stdout = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const stderr = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + MockJobsService.mockImplementation( + () => + ({ + start: jest.fn(async () => { + log.info('this jobs log must not escape'); + log.error('this jobs log must not escape'); + return { + jobs: false, + functions: [{ name: 'send-email', port: 8081 }], + }; + }), + waitForFailure: unresolvedFailure, + stop, + }) as never + ); + + try { + const { outcome } = await runUntilReady( + 'jobs.up', + { functions: 'send-email=8081' }, + controller, + { JOBS_SUPPORTED: 'send-email' } + ); + + expect(outcome.status).toBe('cancelled'); + expect(MockJobsService).toHaveBeenCalledWith( + expect.objectContaining({ + runtime: { + cwd: process.cwd(), + env: { JOBS_SUPPORTED: 'send-email' }, + signal: controller.signal, + }, + }) + ); + expect(stop).toHaveBeenCalledTimes(1); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + + it('stops jobs when protocol event delivery fails', async () => { + const stop = jest.fn(async (): Promise => undefined); + MockJobsService.mockImplementation( + () => + ({ + start: jest.fn(async () => ({ + jobs: false, + functions: [{ name: 'send-email', port: 8081 }], + })), + waitForFailure: unresolvedFailure, + stop, + }) as never + ); + + const outcome = await executeCommand( + commands, + 'jobs.up', + { functions: 'send-email=8081' }, + { + cwd: process.cwd(), + mode: 'agent', + env: {}, + sink: async (event) => { + if (event.event === 'service.ready') { + throw new Error('event transport unavailable'); + } + }, + } + ); + + expect(outcome).toMatchObject({ + status: 'failed', + error: { + code: 'CLI_PROTOCOL_SINK_FAILED', + category: 'internal', + }, + }); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('rejects unknown and duplicate function declarations before startup', () => { + expect(() => parseFunctions('made-up=8081')).toThrow( + expect.objectContaining({ code: 'JOBS_FUNCTION_UNKNOWN' }) + ); + expect(() => parseFunctions('send-email=8081,send-email=8082')).toThrow( + expect.objectContaining({ code: 'JOBS_FUNCTION_DUPLICATE' }) + ); + }); +}); diff --git a/packages/cli/__tests__/runtime-state-commands.test.ts b/packages/cli/__tests__/runtime-state-commands.test.ts new file mode 100644 index 0000000000..73dccee50b --- /dev/null +++ b/packages/cli/__tests__/runtime-state-commands.test.ts @@ -0,0 +1,366 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + bindArguments, + createCommandRegistry, + executeCommand, + type ExecutionMode, +} from '@constructive-io/cli-runtime'; + +import { + ConfigStore, + createContext, + setContextCredentials, + setCurrentContext, +} from '../src/config'; +import { createCncRegistry } from '../src/runtime/registry'; +import { + createStateCommands, + type StateCommandDependencies, +} from '../src/runtime/state-commands'; + +const NOW = new Date('2026-07-20T00:00:00.000Z'); +const SECRET = 'secret-token-material'; +const REAL_TMP_DIR = fs.realpathSync(os.tmpdir()); + +describe('context and auth command definitions', () => { + let cwd: string; + let configDir: string; + let store: ConfigStore; + let commands: ReturnType; + let registry: ReturnType; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(REAL_TMP_DIR, 'cnc-runtime-cwd-')); + configDir = fs.mkdtempSync(path.join(REAL_TMP_DIR, 'cnc-runtime-state-')); + store = new ConfigStore({ configDir }); + const dependencies: StateCommandDependencies = { store }; + commands = createStateCommands(dependencies); + registry = createCommandRegistry(commands); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + const run = ( + commandId: string, + input: unknown, + options: { + mode?: ExecutionMode; + env?: Readonly>; + yes?: boolean; + } = {} + ) => + executeCommand(registry, commandId, input, { + cwd, + mode: options.mode ?? 'agent', + env: options.env ?? {}, + now: () => NOW, + capabilities: { yes: options.yes ?? false }, + }); + + it('registers every existing context and authentication subcommand', () => { + expect( + registry + .catalog() + .map(({ id }) => id) + .sort() + ).toEqual([ + 'auth.logout', + 'auth.set-token', + 'auth.status', + 'context.create', + 'context.current', + 'context.delete', + 'context.list', + 'context.use', + ]); + }); + + it('creates, selects, summarizes, and atomically deletes contexts', async () => { + const created = await run('context.create', { + name: 'production', + endpoint: 'https://api.example.com/graphql', + }); + expect(created).toMatchObject({ + status: 'completed', + result: { + data: { + activated: true, + context: { name: 'production' }, + }, + }, + }); + + setContextCredentials('production', SECRET, undefined, store); + const listed = await run('context.list', {}); + expect(listed).toMatchObject({ + status: 'completed', + result: { + data: { + contexts: [ + { + name: 'production', + current: true, + authentication: 'authenticated', + }, + ], + }, + }, + }); + + const refused = await run('context.delete', { name: 'production' }); + expect(refused).toMatchObject({ + status: 'failed', + error: { code: 'CLI_CONFIRMATION_REQUIRED' }, + }); + + const deleted = await run( + 'context.delete', + { name: 'production' }, + { yes: true } + ); + expect(deleted.status).toBe('completed'); + expect(store.read()).toMatchObject({ + settings: {}, + contexts: {}, + credentials: { tokens: {} }, + }); + }); + + it('rejects secret-bearing endpoint queries without persisting or returning them', async () => { + const endpointSecret = 'endpoint-secret-that-must-not-leak'; + const rejected = await run('context.create', { + name: 'unsafe', + endpoint: `https://api.example.com/graphql?api_key=${endpointSecret}`, + }); + + expect(rejected).toMatchObject({ + status: 'failed', + error: { + code: 'CONTEXT_ENDPOINT_INVALID', + category: 'validation', + }, + }); + expect(JSON.stringify(rejected)).not.toContain(endpointSecret); + expect(store.read().contexts).toEqual({}); + expect(fs.existsSync(store.statePath)).toBe(false); + }); + + it('requires explicit agent targeting and never returns token representations', async () => { + createContext('production', 'https://api.example.com/graphql', store, NOW); + setCurrentContext('production', store); + + const command = registry.requireById('auth.set-token'); + const bound = bindArguments( + command, + { + argv: ['--context', 'production'], + env: { CNC_TOKEN: SECRET }, + }, + registry + ); + const saved = await run('auth.set-token', bound.input, { + env: { CNC_TOKEN: SECRET }, + }); + expect(saved).toMatchObject({ + status: 'completed', + result: { + data: { contextName: 'production', saved: true }, + }, + }); + + const missingTarget = await run('auth.status', {}); + expect(missingTarget).toMatchObject({ + status: 'failed', + error: { code: 'CONTEXT_REQUIRED' }, + }); + + const status = await run( + 'auth.status', + {}, + { env: { CNC_CONTEXT: 'production' } } + ); + expect(status).toMatchObject({ + status: 'completed', + result: { + data: { + contexts: [ + { + contextName: 'production', + status: 'authenticated', + }, + ], + }, + }, + }); + const machineTranscript = JSON.stringify({ saved, status }); + expect(machineTranscript).not.toContain(SECRET); + expect(machineTranscript).not.toContain('****'); + }); + + it('rejects positional agent tokens but retains a warned human compatibility path', async () => { + createContext('production', 'https://api.example.com/graphql', store, NOW); + setCurrentContext('production', store); + + const rejected = await run('auth.set-token', { + contextName: 'production', + legacyValue: SECRET, + }); + expect(rejected).toMatchObject({ + status: 'failed', + error: { code: 'AUTH_POSITIONAL_TOKEN_UNSUPPORTED' }, + }); + expect(JSON.stringify(rejected)).not.toContain(SECRET); + + const accepted = await run( + 'auth.set-token', + { legacyValue: SECRET }, + { mode: 'human' } + ); + expect(accepted).toMatchObject({ + status: 'completed', + result: { + warnings: [{ code: 'CLI_DEPRECATED' }], + data: { contextName: 'production', saved: true }, + }, + }); + expect(JSON.stringify(accepted)).not.toContain(SECRET); + }); + + it('accepts adapter-injected stdin and requires confirmation for logout', async () => { + createContext('production', 'https://api.example.com/graphql', store, NOW); + const saved = await run('auth.set-token', { + contextName: 'production', + stdinValue: SECRET, + }); + expect(saved.status).toBe('completed'); + + const ambiguous = await run( + 'auth.set-token', + { + contextName: 'production', + stdinValue: 'stdin-secret', + environmentValue: 'environment-secret', + }, + { env: { CNC_TOKEN: 'environment-secret' } } + ); + expect(ambiguous).toMatchObject({ + status: 'failed', + error: { code: 'TOKEN_SOURCE_AMBIGUOUS' }, + }); + expect(JSON.stringify(ambiguous)).not.toContain('stdin-secret'); + expect(JSON.stringify(ambiguous)).not.toContain('environment-secret'); + + const refused = await run('auth.logout', { contextName: 'production' }); + expect(refused).toMatchObject({ + status: 'failed', + error: { code: 'CLI_CONFIRMATION_REQUIRED' }, + }); + const removed = await run( + 'auth.logout', + { contextName: 'production' }, + { yes: true } + ); + expect(removed).toMatchObject({ + status: 'completed', + result: { data: { contextName: 'production', removed: true } }, + }); + expect(JSON.stringify({ saved, removed })).not.toContain(SECRET); + }); + + it('restores human auth context selection without weakening explicit agent targeting', async () => { + createContext('alpha', 'https://alpha.example.com/graphql', store, NOW); + createContext('beta', 'https://beta.example.com/graphql', store, NOW); + const prompt = jest.fn( + async ( + input: Record, + questions: Array<{ name: string; options?: string[] }> + ) => { + const question = questions[0]; + if (question.name === 'contextName') { + expect(question.options).toEqual(['alpha', 'beta']); + return { ...input, contextName: 'beta' }; + } + if (question.name === 'stdinValue') { + return { ...input, stdinValue: SECRET }; + } + throw new Error(`Unexpected prompt: ${question.name}`); + } + ); + const hooks = createCncRegistry({ version: 'test', store }).createHooks({ + prompt, + } as never); + const hookContext = { + cwd, + env: {}, + signal: new AbortController().signal, + operationId: 'interactive-input', + }; + + const collected = await hooks['auth.set-token']?.collectInteractiveInput?.( + {}, + hookContext + ); + expect(collected).toMatchObject({ + contextName: 'beta', + stdinValue: SECRET, + }); + expect(prompt.mock.calls.map(([, questions]) => questions[0].name)).toEqual( + ['contextName', 'stdinValue'] + ); + + const saved = await run('auth.set-token', collected, { mode: 'human' }); + expect(saved).toMatchObject({ + status: 'completed', + result: { data: { contextName: 'beta', saved: true } }, + }); + + prompt.mockClear(); + const legacyCollected = await hooks[ + 'auth.set-token' + ]?.collectInteractiveInput?.({ legacyValue: SECRET }, hookContext); + expect(legacyCollected).toEqual({ + contextName: 'beta', + legacyValue: SECRET, + }); + expect(prompt.mock.calls.map(([, questions]) => questions[0].name)).toEqual( + ['contextName'] + ); + + prompt.mockClear(); + const logoutInput = await hooks['auth.logout']?.collectInteractiveInput?.( + {}, + hookContext + ); + expect(logoutInput).toEqual({ contextName: 'beta' }); + expect(prompt.mock.calls[0]?.[1][0]).toMatchObject({ + type: 'autocomplete', + name: 'contextName', + }); + }); + + it('fails human auth collection before requesting a token when no context exists', async () => { + const prompt = jest.fn(); + const hooks = createCncRegistry({ version: 'test', store }).createHooks({ + prompt, + } as never); + + await expect( + hooks['auth.set-token']?.collectInteractiveInput?.( + {}, + { + cwd, + env: {}, + signal: new AbortController().signal, + operationId: 'interactive-input', + } + ) + ).rejects.toMatchObject({ code: 'CONTEXT_REQUIRED' }); + expect(prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/__tests__/sdk.test.ts b/packages/cli/__tests__/sdk.test.ts new file mode 100644 index 0000000000..d8549fc326 --- /dev/null +++ b/packages/cli/__tests__/sdk.test.ts @@ -0,0 +1,206 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + ConfigStore, + createContext, + setContextCredentials, +} from '../src/config'; +import { + analyzeGraphQLDocument, + assertMutationAllowed, + execute, + executeGraphQL, + getExecutionContext, +} from '../src/sdk'; + +const NOW = new Date('2026-07-20T00:00:00.000Z'); + +function response(body: unknown, init: ResponseInit = {}): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +describe('GraphQL client', () => { + it('preserves partial data with typed GraphQL errors', async () => { + const fetchImplementation = jest.fn(async () => + response({ + data: { viewer: { id: '1' } }, + errors: [{ message: 'name unavailable', path: ['viewer', 'name'] }], + }) + ) as unknown as typeof fetch; + + const result = await executeGraphQL<{ viewer: { id: string } }>( + 'https://api.example.com/graphql', + 'query Viewer { viewer { id } }', + undefined, + undefined, + { fetch: fetchImplementation } + ); + + expect(result).toMatchObject({ + ok: false, + data: { viewer: { id: '1' } }, + errors: [{ message: 'name unavailable' }], + error: { code: 'GRAPHQL_RESPONSE_ERROR', category: 'graphql' }, + }); + }); + + it('classifies non-JSON and HTTP responses without returning their bodies', async () => { + const nonJson = jest.fn(async () => + response('upstream secret', { + headers: { 'content-type': 'text/html' }, + }) + ) as unknown as typeof fetch; + const invalid = await executeGraphQL( + 'https://api.example.com/graphql', + 'query { viewer { id } }', + undefined, + undefined, + { fetch: nonJson } + ); + expect(invalid.error).toMatchObject({ code: 'GRAPHQL_RESPONSE_INVALID' }); + expect(JSON.stringify(invalid)).not.toContain('upstream secret'); + + const unavailable = jest.fn(async () => + response('unavailable', { + status: 503, + headers: { 'content-type': 'text/plain' }, + }) + ) as unknown as typeof fetch; + const failed = await executeGraphQL( + 'https://api.example.com/graphql', + 'query { viewer { id } }', + undefined, + undefined, + { fetch: unavailable } + ); + expect(failed.error).toMatchObject({ + code: 'GRAPHQL_HTTP_ERROR', + status: 503, + retryable: true, + }); + }); + + it('distinguishes timeout from caller cancellation', async () => { + const hangingFetch = jest.fn( + async (_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + if (init?.signal?.aborted) { + reject(new Error('aborted')); + return; + } + init?.signal?.addEventListener('abort', () => + reject(new Error('aborted')) + ); + }) + ) as unknown as typeof fetch; + + const timedOut = await executeGraphQL( + 'https://api.example.com/graphql', + 'query { viewer { id } }', + undefined, + undefined, + { fetch: hangingFetch, timeoutMs: 5 } + ); + expect(timedOut.error?.code).toBe('GRAPHQL_TIMEOUT'); + + const controller = new AbortController(); + controller.abort(); + const cancelled = await executeGraphQL( + 'https://api.example.com/graphql', + 'query { viewer { id } }', + undefined, + undefined, + { fetch: hangingFetch, signal: controller.signal } + ); + expect(cancelled.error?.code).toBe('GRAPHQL_CANCELLED'); + }); +}); + +describe('GraphQL operation analysis and execution', () => { + it('requires disambiguation, selects named operations, and rejects subscriptions', () => { + const document = ` + query One { viewer { id } } + mutation Two { updateViewer(input: {}) { viewer { id } } } + `; + expect(() => analyzeGraphQLDocument(document)).toThrow( + expect.objectContaining({ code: 'GRAPHQL_OPERATION_NAME_REQUIRED' }) + ); + expect(analyzeGraphQLDocument(document, 'Two')).toEqual({ + type: 'mutation', + name: 'Two', + operationCount: 2, + }); + expect(() => + analyzeGraphQLDocument('subscription Events { event { id } }') + ).toThrow( + expect.objectContaining({ code: 'GRAPHQL_SUBSCRIPTION_UNSUPPORTED' }) + ); + }); + + it('requires both mutation capabilities in agent mode', () => { + const mutation = analyzeGraphQLDocument('mutation Change { change }'); + expect(() => + assertMutationAllowed(mutation, { agent: true, yes: true }) + ).toThrow( + expect.objectContaining({ code: 'GRAPHQL_MUTATION_REQUIRES_APPROVAL' }) + ); + expect(() => + assertMutationAllowed(mutation, { + agent: true, + yes: true, + allowMutation: true, + }) + ).not.toThrow(); + }); + + it('resolves explicit agent contexts and supports anonymous execution', async () => { + const root = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'cnc-sdk-test-') + ); + const store = new ConfigStore({ configDir: root }); + createContext('api', 'https://api.example.com/graphql', store, NOW); + setContextCredentials('api', 'stored-secret', undefined, store); + + await expect( + getExecutionContext({ agent: true, store }) + ).rejects.toMatchObject({ + code: 'CONTEXT_REQUIRED', + }); + await expect( + getExecutionContext({ + agent: true, + env: { CNC_CONTEXT: 'api' }, + anonymous: true, + store, + }) + ).resolves.toMatchObject({ context: { name: 'api' }, anonymous: true }); + + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + response({ data: { ok: true } }) + ); + const fetchImplementation = fetchMock as unknown as typeof fetch; + const result = await execute('query Check { ok }', undefined, undefined, { + agent: true, + contextName: 'api', + anonymous: true, + headers: { authorization: 'Bearer must-not-be-sent', 'x-test': 'safe' }, + store, + fetch: fetchImplementation, + }); + expect(result).toMatchObject({ ok: true, data: { ok: true } }); + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).not.toHaveProperty('Authorization'); + expect(request.headers).not.toHaveProperty('authorization'); + expect(request.headers).toHaveProperty('x-test', 'safe'); + expect(JSON.parse(request.body as string)).toMatchObject({ + operationName: 'Check', + }); + fs.rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index b28aca4565..0dba3f613a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,6 +6,44 @@ "main": "index.js", "module": "esm/index.js", "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./esm/index.js", + "require": "./index.js" + }, + "./runtime": { + "types": "./runtime/index.d.ts", + "import": "./esm/runtime/index.js", + "require": "./runtime/index.js" + }, + "./config": { + "types": "./config/index.d.ts", + "import": "./esm/config/index.js", + "require": "./config/index.js" + }, + "./sdk": { + "types": "./sdk/index.d.ts", + "import": "./esm/sdk/index.js", + "require": "./sdk/index.js" + }, + "./utils": { + "types": "./utils/index.d.ts", + "import": "./esm/utils/index.js", + "require": "./utils/index.js" + }, + "./*.js": { + "types": "./*.d.ts", + "import": "./esm/*.js", + "require": "./*.js" + }, + "./*": { + "types": "./*.d.ts", + "import": "./esm/*.js", + "require": "./*.js" + }, + "./package.json": "./package.json" + }, "homepage": "https://github.com/constructive-io/constructive", "license": "MIT", "publishConfig": { @@ -23,14 +61,17 @@ "bugs": { "url": "https://github.com/constructive-io/constructive/issues" }, + "engines": { + "node": ">=18.17.0" + }, "scripts": { "clean": "makage clean", "prepack": "npm run build", - "build": "makage build", - "build:dev": "makage build --dev", + "build": "makage build && node ./scripts/finalize-esm.js", + "build:dev": "makage build --dev && node ./scripts/finalize-esm.js", "dev": "ts-node ./src/index.ts", "lint": "eslint . --fix", - "test": "jest", + "test": "jest --runInBand", "test:watch": "jest --watch" }, "devDependencies": { @@ -45,26 +86,31 @@ "ts-node": "^10.9.2" }, "dependencies": { + "@constructive-io/cli-runtime": "workspace:^", + "@inquirerer/utils": "^3.3.9", + "@pgpmjs/logger": "workspace:^", + "@sinclair/typebox": "^0.34.49", + "appstash": "^0.7.0", + "find-and-require-package-json": "^0.9.1", + "graphql": "^16.13.0", + "inquirerer": "^4.9.1", + "js-yaml": "^4.1.0", + "shelljs": "^0.10.0", + "yanse": "^0.2.1" + }, + "optionalDependencies": { "@constructive-io/graphql-codegen": "workspace:^", "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-explorer": "workspace:^", "@constructive-io/graphql-server": "workspace:^", "@constructive-io/graphql-types": "workspace:^", "@constructive-io/knative-job-service": "workspace:^", - "@inquirerer/utils": "^3.3.9", "@pgpmjs/core": "workspace:^", - "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", - "appstash": "^0.7.0", - "find-and-require-package-json": "^0.9.1", - "inquirerer": "^4.9.1", - "js-yaml": "^4.1.0", "pg-cache": "workspace:^", "pg-env": "workspace:^", - "pgpm": "workspace:^", - "shelljs": "^0.10.0", - "yanse": "^0.2.1" + "pgpm": "workspace:^" }, "keywords": [ "cli", diff --git a/packages/cli/scripts/finalize-esm.js b/packages/cli/scripts/finalize-esm.js new file mode 100644 index 0000000000..42649c07b1 --- /dev/null +++ b/packages/cli/scripts/finalize-esm.js @@ -0,0 +1,42 @@ +const { + existsSync, + readdirSync, + readFileSync, + writeFileSync, +} = require('node:fs'); +const { dirname, join, resolve } = require('node:path'); + +const directory = join(__dirname, '..', 'dist', 'esm'); +const relativeImport = + /(\bfrom\s+['"]|\bimport\s*\(\s*['"])(\.{1,2}\/[^'"]+?)(['"]\s*\)?)/g; + +const rewriteDirectory = (current) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + rewriteDirectory(path); + continue; + } + if (!entry.name.endsWith('.js')) continue; + const source = readFileSync(path, 'utf8'); + const rewritten = source.replace( + relativeImport, + (_match, prefix, specifier, suffix) => { + if (/\.(?:js|json|mjs|cjs)$/.test(specifier)) { + return `${prefix}${specifier}${suffix}`; + } + const resolved = resolve(dirname(path), specifier); + const completed = existsSync(`${resolved}.js`) + ? `${specifier}.js` + : existsSync(join(resolved, 'index.js')) + ? `${specifier}/index.js` + : `${specifier}.js`; + return `${prefix}${completed}${suffix}`; + } + ); + writeFileSync(path, rewritten); + } +}; + +rewriteDirectory(directory); +writeFileSync(join(directory, 'package.json'), '{"type":"module"}\n'); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 12444bab09..ca997c4bfc 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,101 +1,669 @@ +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Writable, type Readable } from 'node:stream'; + import { checkForUpdates } from '@inquirerer/utils'; -import { CLIOptions, Inquirerer, ParsedArgs, cliExitWithError, extractFirst, getPackageJson } from 'inquirerer'; - -import auth from './commands/auth'; -import codegen from './commands/codegen'; -import context from './commands/context'; -import execute from './commands/execute'; -import explorer from './commands/explorer'; -import jobs from './commands/jobs'; -import server from './commands/server'; -import { usageText } from './utils'; - -const createCommandMap = (): Record => { - return { - server, - explorer, - codegen, - jobs, - context, - auth, - execute, - }; +import { + bindArguments, + CliError, + createFailureOutcome, + createJsonlSink, + exitCodeForOutcome, + InvocationError, + parseGlobalArguments, + renderExecution, + resolveExecutionSettings, + sensitiveEnvironmentValues, + assertFormatAllowed, + type CommandAdapterHookMap, + type CommandDefinition, + type ExecutionOutcome, + type ExecutionSettings, + type OperationWarning, + type OutputFormat, + type ProtocolEvent, +} from '@constructive-io/cli-runtime'; +import { withLogsSuppressed } from '@pgpmjs/logger'; +import { Inquirerer } from 'inquirerer'; +import { stripColor } from 'yanse'; + +import { withConsoleSuppressed } from './console-isolation'; + +export interface CliReadable extends Readable { + isTTY?: boolean; +} + +export interface CliWritable extends Writable { + isTTY?: boolean; +} + +class PlainTextWritable extends Writable implements CliWritable { + readonly isTTY?: boolean; + + constructor(private readonly destination: CliWritable) { + super(); + this.isTTY = destination.isTTY; + } + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + const value = Buffer.isBuffer(chunk) + ? chunk.toString('utf8') + : String(chunk); + this.destination.write(stripColor(value), callback); + } +} + +export interface RunCliOptions { + cwd: string; + env: Readonly>; + stdin: CliReadable; + stdout: CliWritable; + stderr: CliWritable; + signal?: AbortSignal; + version: string; + now?: () => Date; +} + +const withoutTerminalStyling = (options: RunCliOptions): RunCliOptions => ({ + ...options, + stdout: new PlainTextWritable(options.stdout), + stderr: new PlainTextWritable(options.stderr), +}); + +const ciEnabled = (value: string | undefined): boolean => + value !== undefined && + !['', '0', 'false', 'no'].includes(value.toLowerCase()); + +const write = async (stream: CliWritable, content: string): Promise => { + if (!content) return; + await new Promise((resolveWrite, rejectWrite) => { + stream.write(content, (error) => + error ? rejectWrite(error) : resolveWrite() + ); + }); +}; + +const inferFailureFormat = (argv: readonly string[]): OutputFormat => { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--format') { + const candidate = argv[index + 1]; + if (candidate === 'json' || candidate === 'jsonl') return candidate; + } + if (token === '--format=json') return 'json'; + if (token === '--format=jsonl') return 'jsonl'; + } + if (argv.includes('--agent')) return 'jsonl'; + return 'human'; }; -export const commands = async (argv: Partial, prompter: Inquirerer, options: CLIOptions & { skipPgTeardown?: boolean }) => { - let { first: command, newArgv } = extractFirst(argv); +const isolated = async ( + settings: ExecutionSettings, + callback: () => Promise +): Promise => { + if (settings.format === 'human') return callback(); + return withLogsSuppressed(() => withConsoleSuppressed(callback)); +}; - // Run update check early so it shows on help/version paths too - // (checkForUpdates auto-skips in CI or when INQUIRERER_SKIP_UPDATE_CHECK / CONSTRUCTIVE_SKIP_UPDATE_CHECK is set) +const readToken = async ( + stdin: CliReadable, + signal: AbortSignal +): Promise => { + const chunks: Buffer[] = []; + let length = 0; + const iterator = stdin[Symbol.asyncIterator](); try { - const pkg = getPackageJson(__dirname); - const updateResult = await checkForUpdates({ - pkgName: pkg.name, - pkgVersion: pkg.version, - toolName: 'constructive', - }); - if (updateResult.hasUpdate && updateResult.message) { - console.warn(updateResult.message); - console.warn(`Run npm i -g ${pkg.name}@latest to upgrade.`); + while (true) { + if (signal.aborted) { + throw ( + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + } + const item = await new Promise>( + (resolveItem, rejectItem) => { + const onAbort = () => { + cleanup(); + rejectItem( + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + }; + const cleanup = () => signal.removeEventListener('abort', onAbort); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return; + } + void iterator.next().then( + (value) => { + cleanup(); + resolveItem(value); + }, + (error) => { + cleanup(); + rejectItem(error); + } + ); + } + ); + if (item.done) break; + const chunk = item.value; + const buffer = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(String(chunk)); + length += buffer.length; + if (length > 1024 * 1024) { + throw new InvocationError( + 'AUTH_TOKEN_TOO_LARGE', + 'The token supplied on stdin exceeds 1 MiB.' + ); + } + chunks.push(buffer); } - } catch { - // ignore update check failures + } catch (error) { + await iterator.return?.(); + throw error; } - - if (argv.version || argv.v) { - const pkg = getPackageJson(__dirname); - console.log(pkg.version); - process.exit(0); + const token = Buffer.concat(chunks) + .toString('utf8') + .replace(/\r?\n$/, ''); + if (!token.trim()) { + throw new InvocationError( + 'AUTH_TOKEN_REQUIRED', + '--token-stdin did not receive a token.' + ); } + return token; +}; + +const requiredProperties = (command: CommandDefinition): string[] => { + const schema = command.input as { required?: unknown }; + return Array.isArray(schema.required) + ? schema.required.filter((item): item is string => typeof item === 'string') + : []; +}; - // Show usage if explicitly requested but no command specified - if ((argv.help || argv.h || command === 'help') && !command) { - console.log(usageText); - process.exit(0); +const collectRequiredInput = async ( + command: CommandDefinition, + input: Record, + prompter: Inquirerer +): Promise> => { + const missing = requiredProperties(command).filter( + (property) => input[property] === undefined + ); + if (missing.length === 0) return input; + const properties = + ( + command.input as { + properties?: Record; + } + ).properties ?? {}; + const questions = missing.map((property) => { + const binding = command.bindings.find( + (candidate) => candidate.property === property + ); + const type = properties[property]?.type; + return { + name: property, + type: + type === 'boolean' + ? ('confirm' as const) + : type === 'number' || type === 'integer' + ? ('number' as const) + : ('text' as const), + message: binding?.description ?? property, + required: true, + ...(type === 'array' + ? { + sanitize: (value: string) => + value + .split(/\s+/) + .map((part) => part.trim()) + .filter(Boolean), + } + : {}), + }; + }); + return prompter.prompt(input, questions as never); +}; + +const chooseCommand = async ( + paths: string[], + prompter: Inquirerer +): Promise => { + const selected = await prompter.prompt>({}, [ + { + type: 'autocomplete', + name: 'command', + message: 'What do you want to do?', + options: paths, + }, + ]); + return String(selected.command).split(' '); +}; + +const confirmCommand = async ( + command: CommandDefinition, + prompter: Inquirerer +): Promise => { + const answer = await prompter.prompt>({}, [ + { + type: 'confirm', + name: 'confirmed', + message: `Run ${command.path.join(' ')}?`, + default: false, + }, + ]); + if (answer.confirmed !== true) { + throw new DOMException( + 'The operation was cancelled by the user.', + 'AbortError' + ); } + return true; +}; + +const renderOutcome = async ( + outcome: ExecutionOutcome, + format: OutputFormat, + stdout: CliWritable, + stderr: CliWritable, + humanRenderer?: (result: NonNullable) => string +): Promise => { + const rendered = renderExecution(outcome, format, humanRenderer); + await write(stdout, rendered.stdout); + await write(stderr, rendered.stderr); +}; - // Show usage for help command specifically - if (command === 'help') { - console.log(usageText); - process.exit(0); +export const renderHumanLifecycleEvent = ( + event: ProtocolEvent +): string | undefined => { + if (!event.event.startsWith('service.')) return undefined; + const fields = event as Record; + const service = + typeof fields.service === 'string' && fields.service.length > 0 + ? fields.service + : 'service'; + switch (event.event) { + case 'service.starting': + return `${service}: starting`; + case 'service.ready': { + const location = + typeof fields.url === 'string' + ? ` at ${fields.url}` + : typeof fields.port === 'number' + ? ` on port ${fields.port}` + : ''; + return `${service}: ready${location}`; + } + case 'service.stopping': + return `${service}: stopping`; + case 'service.stopped': + return `${service}: stopped`; + default: + return undefined; } +}; - const commandMap = createCommandMap(); +const failure = async ( + error: unknown, + format: OutputFormat, + options: RunCliOptions, + commandId = 'cli.invocation', + debug = false, + warnings?: OperationWarning[] +): Promise => { + const sink = + format === 'jsonl' + ? createJsonlSink((line) => write(options.stdout, line)) + : undefined; + const outcome = await createFailureOutcome({ + commandId, + error, + now: options.now, + sink, + debug, + warnings, + redaction: { sensitiveValues: sensitiveEnvironmentValues(options.env) }, + }); + if (format !== 'jsonl') { + await renderOutcome(outcome, format, options.stdout, options.stderr); + } + return exitCodeForOutcome(outcome); +}; - // Prompt if no command provided - if (!command) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'command', - message: 'What do you want to do?', - options: Object.keys(commandMap) +const addExecutePromptHook = ( + hooks: CommandAdapterHookMap, + prompter: Inquirerer +): CommandAdapterHookMap => ({ + ...hooks, + execute: { + ...hooks.execute, + collectInteractiveInput: async (input) => { + const candidate = input as Record; + if (candidate.query !== undefined || candidate.file !== undefined) { + return input as never; } - ]); - command = answer.command; + return (await prompter.prompt(candidate, [ + { + type: 'text', + name: 'query', + message: 'GraphQL query', + required: true, + }, + ])) as never; + }, + }, +}); + +export async function runCli( + rawArgv: readonly string[], + options: RunCliOptions +): Promise { + let globals: ReturnType; + let settings: ExecutionSettings; + try { + globals = parseGlobalArguments(rawArgv); + settings = resolveExecutionSettings({ + agent: globals.options.agent, + interactive: globals.options.interactive, + nonInteractive: globals.options.nonInteractive, + format: globals.options.format, + noColor: globals.options.noColor, + stdinIsTTY: options.stdin.isTTY === true, + stdoutIsTTY: options.stdout.isTTY === true, + ci: ciEnabled(options.env.CI), + }); + } catch (error) { + const failureOptions = + options.stdout.isTTY === true && + !ciEnabled(options.env.CI) && + !rawArgv.includes('--no-color') && + !rawArgv.includes('--noColor') + ? options + : withoutTerminalStyling(options); + return failure( + error, + inferFailureFormat(rawArgv), + failureOptions, + 'cli.invocation', + rawArgv.includes('--debug') + ); } - // Prompt for working directory - newArgv = await prompter.prompt(newArgv, [ - { - type: 'text', - name: 'cwd', - message: 'Working directory', - required: false, - default: process.cwd(), - useDefault: true + const adapterOptions = settings.terminalEffects + ? options + : withoutTerminalStyling(options); + + const signal = options.signal ?? new AbortController().signal; + const operationCwd = resolve(options.cwd, globals.options.cwd ?? '.'); + try { + const cwdStat = await stat(operationCwd); + if (!cwdStat.isDirectory()) { + throw new InvocationError( + 'CLI_CWD_INVALID', + '--cwd must resolve to a directory.' + ); } - ]); + } catch (error) { + const known = + error instanceof CliError + ? error + : new InvocationError( + 'CLI_CWD_NOT_FOUND', + `Working directory not found: ${operationCwd}` + ); + return failure( + known, + settings.format, + adapterOptions, + 'cli.invocation', + globals.options.debug, + globals.warnings + ); + } - const commandFn = commandMap[command]; + return isolated(settings, async () => { + const adapterWarnings = [...globals.warnings]; + let registryBundle: Awaited< + ReturnType<(typeof import('./runtime/registry'))['createCncRegistry']> + >; + try { + const { createCncRegistry } = await import('./runtime/registry'); + const { createConfigStoreForEnvironment } = await import('./config'); + registryBundle = createCncRegistry({ + version: options.version, + store: createConfigStoreForEnvironment(options.env), + }); + } catch (error) { + return failure( + error, + settings.format, + adapterOptions, + 'cli.startup', + globals.options.debug, + adapterWarnings + ); + } - if (!commandFn) { - console.log(usageText); - await cliExitWithError(`Unknown command: ${command}`); - } + const { registry } = registryBundle; + const prompter = new Inquirerer({ + input: adapterOptions.stdin, + output: adapterOptions.stderr, + noTty: !settings.interactive, + }); - await commandFn(newArgv, prompter, options); - prompter.close(); + try { + let argv = globals.argv; + if (globals.options.version) { + if (argv.length > 0) { + const resolved = registry.resolve(argv); + if (!resolved) { + throw new InvocationError( + 'CLI_COMMAND_NOT_FOUND', + 'Unknown command. Use "cnc commands --format json" to discover commands.' + ); + } + bindArguments( + resolved.command, + { + argv: argv.slice(resolved.consumed), + env: options.env, + strict: true, + warnings: adapterWarnings, + validate: false, + }, + registry + ); + } + argv = ['version']; + } + if (globals.options.help) { + const resolved = registry.resolve(argv); + if (resolved) { + bindArguments( + resolved.command, + { + argv: argv.slice(resolved.consumed), + env: options.env, + strict: true, + warnings: adapterWarnings, + validate: false, + }, + registry + ); + argv = ['help', ...resolved.command.path]; + } else { + const path = argv.filter((token) => !token.startsWith('-')); + const unknownOption = argv.find((token) => token.startsWith('-')); + if (registry.list(path).length > 0 && unknownOption !== undefined) { + throw new InvocationError( + 'CLI_OPTION_UNKNOWN', + `Unknown option "${unknownOption.split('=', 1)[0]}".` + ); + } + argv = ['help', ...path]; + } + } + if (argv.length === 0) { + if (!settings.interactive) { + throw new InvocationError( + 'CLI_COMMAND_REQUIRED', + 'A command is required. Use "cnc commands --format json" to discover commands.' + ); + } + argv = await chooseCommand( + registry.list().map((command) => command.path.join(' ')), + prompter + ); + } - return argv; -}; + let resolvedCommand = registry.resolve(argv); + if ( + !resolvedCommand && + settings.interactive && + argv.length > 0 && + argv.every((token) => !token.startsWith('-')) + ) { + const descendants = registry.list(argv); + if (descendants.length > 0) { + argv = await chooseCommand( + descendants.map((command) => command.path.join(' ')), + prompter + ); + resolvedCommand = registry.resolve(argv); + } + } + if (!resolvedCommand) { + throw new InvocationError( + 'CLI_COMMAND_NOT_FOUND', + 'Unknown command. Use "cnc commands --format json" to discover commands.' + ); + } + const command = resolvedCommand.command; + assertFormatAllowed(command, settings.format); + const bound = bindArguments( + command, + { + argv: argv.slice(resolvedCommand.consumed), + env: options.env, + strict: true, + warnings: adapterWarnings, + validate: !settings.interactive, + }, + registry + ); + let input = bound.input as Record; + const sensitiveValues = [...bound.sensitiveValues]; + if (command.id === 'auth.set-token' && input.readFromStdin === true) { + const stdinToken = await readToken(adapterOptions.stdin, signal); + input = { ...input, stdinValue: stdinToken }; + sensitiveValues.push(stdinToken); + } + + let hooks = addExecutePromptHook( + registryBundle.createHooks(prompter), + prompter + ); + if ( + settings.interactive && + ['server.start', 'explorer.start', 'jobs.up'].includes(command.id) + ) { + const { createServiceHooks } = await import('./runtime/service-hooks'); + hooks = { ...hooks, ...createServiceHooks(prompter) }; + } + if (settings.interactive) { + const collected = hooks[command.id]?.collectInteractiveInput; + if (collected) { + input = (await collected(input, { + cwd: operationCwd, + env: options.env, + signal, + operationId: 'interactive-input', + })) as Record; + } + input = await collectRequiredInput(command, input, prompter); + } + + let yes = globals.options.yes; + if (command.effect === 'destructive' && !yes && settings.interactive) { + yes = await confirmCommand(command, prompter); + } + const dryRun = input.dryRun === true ? true : undefined; + const sink = + settings.format === 'jsonl' + ? createJsonlSink((line) => write(adapterOptions.stdout, line)) + : settings.format === 'human' && command.lifecycle === 'long-running' + ? async (event: ProtocolEvent): Promise => { + const message = renderHumanLifecycleEvent(event); + if (message !== undefined) { + await write(adapterOptions.stderr, `${message}\n`); + } + } + : undefined; + const { executeCommand } = await import('@constructive-io/cli-runtime'); + const outcome = await executeCommand(registry, command, input, { + cwd: operationCwd, + mode: settings.mode, + env: options.env, + signal, + now: options.now, + capabilities: { + yes, + ...(dryRun === undefined ? {} : { dryRun }), + }, + sink, + redaction: { sensitiveValues }, + initialWarnings: bound.warnings, + debug: globals.options.debug, + captureEvents: + command.lifecycle !== 'long-running' || settings.format !== 'jsonl', + }); + + if (settings.format !== 'jsonl') { + const renderHuman = hooks[command.id]?.renderHuman; + await renderOutcome( + outcome, + settings.format, + adapterOptions.stdout, + adapterOptions.stderr, + renderHuman + ); + } + if (settings.checkForUpdates) { + try { + const update = await checkForUpdates({ + pkgName: '@constructive-io/cli', + pkgVersion: options.version, + toolName: 'constructive', + }); + if (update.hasUpdate && update.message) { + await write( + adapterOptions.stderr, + `${update.message}\nRun npm i -g @constructive-io/cli@latest to upgrade.\n` + ); + } + } catch { + // Update checks never affect command completion. + } + } + return exitCodeForOutcome(outcome); + } catch (error) { + return failure( + error, + settings.format, + adapterOptions, + 'cli.invocation', + globals.options.debug, + adapterWarnings + ); + } finally { + prompter.close(); + } + }); +} diff --git a/packages/cli/src/config/config-manager.ts b/packages/cli/src/config/config-manager.ts index 2dbe2c9130..adf20ea5ca 100644 --- a/packages/cli/src/config/config-manager.ts +++ b/packages/cli/src/config/config-manager.ts @@ -1,268 +1,953 @@ /** - * Configuration manager for the CNC execution engine - * Uses appstash for directory resolution + * Versioned, atomic configuration storage for CNC. + * + * The public functions at the bottom preserve the original synchronous API. + * New operation code should prefer an injected ConfigStore so tests and + * concurrent invocations never depend on mutable module state. */ import * as fs from 'fs'; import * as path from 'path'; -import { appstash, resolve } from 'appstash'; +import { appstash, resolve as resolveAppPath } from 'appstash'; import type { + CncState, ContextConfig, - GlobalSettings, - Credentials, ContextCredentials, + Credentials, + GlobalSettings, } from './types'; -import { DEFAULT_SETTINGS } from './types'; +import { CURRENT_STATE_VERSION, DEFAULT_STATE } from './types'; const TOOL_NAME = 'cnc'; +const STATE_FILENAME = 'state.json'; +const LOCK_FILENAME = 'state.lock'; +const DEFAULT_LOCK_TIMEOUT_MS = 2_000; +const DEFAULT_STALE_LOCK_MS = 30_000; +const LOCK_RETRY_MS = 20; +const CONTEXT_NAME_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; +const SENSITIVE_ENDPOINT_QUERY_PARTS = new Set([ + 'auth', + 'authorization', + 'bearer', + 'cookie', + 'credential', + 'credentials', + 'jwt', + 'passwd', + 'password', + 'secret', + 'session', + 'signature', + 'token', +]); -/** - * Get the appstash directories for cnc - */ -export function getAppDirs() { - return appstash(TOOL_NAME, { ensure: true }); +export type ConfigErrorCode = + | 'CONFIG_INVALID' + | 'CONFIG_BASE_DIRECTORY_REQUIRED' + | 'CONFIG_VERSION_UNSUPPORTED' + | 'CONFIG_LOCK_TIMEOUT' + | 'CONFIG_SYMLINK_REJECTED' + | 'CONTEXT_NAME_INVALID' + | 'CONTEXT_ENDPOINT_INVALID' + | 'CONTEXT_NOT_FOUND' + | 'CONTEXT_REQUIRED'; + +export class ConfigStoreError extends Error { + readonly name = 'ConfigStoreError'; + + constructor( + readonly code: ConfigErrorCode, + message: string, + readonly details?: Record, + options?: { cause?: unknown } + ) { + super(message, options); + } } -/** - * Get path to a config file - */ -function getConfigPath(filename: string): string { - const dirs = getAppDirs(); - return resolve(dirs, 'config', filename); +export interface ConfigStoreOptions { + /** Absolute directory containing CNC configuration files. */ + configDir: string; + clock?: () => Date; + lockTimeoutMs?: number; + staleLockMs?: number; } -/** - * Get path to a context config file - */ -function getContextConfigPath(contextName: string): string { - const dirs = getAppDirs(); - const contextsDir = resolve(dirs, 'config', 'contexts'); - if (!fs.existsSync(contextsDir)) { - fs.mkdirSync(contextsDir, { recursive: true }); +interface LoadedState { + state: CncState; + source: 'canonical' | 'legacy' | 'empty'; +} + +interface NodeError extends Error { + code?: string; +} + +let temporaryFileSequence = 0; + +function cloneState(state: CncState): CncState { + return { + stateVersion: CURRENT_STATE_VERSION, + settings: { ...state.settings }, + contexts: Object.fromEntries( + Object.entries(state.contexts).map(([name, context]) => [ + name, + { ...context }, + ]) + ), + credentials: { + tokens: Object.fromEntries( + Object.entries(state.credentials.tokens).map(([name, credentials]) => [ + name, + { ...credentials }, + ]) + ), + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isIsoDate(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + +function assertOnlyKeys( + value: Record, + allowed: readonly string[], + label: string, + file: string +): void { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + invalidConfig( + `${label} contains unsupported fields: ${unknown.join(', ')}.`, + file + ); } - return path.join(contextsDir, `${contextName}.json`); } -/** - * Load global settings - */ -export function loadSettings(): GlobalSettings { - const settingsPath = getConfigPath('settings.json'); - if (fs.existsSync(settingsPath)) { - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - return { ...DEFAULT_SETTINGS, ...JSON.parse(content) }; - } catch { - return DEFAULT_SETTINGS; +function invalidConfig(message: string, file?: string, cause?: unknown): never { + throw new ConfigStoreError( + 'CONFIG_INVALID', + message, + file ? { file } : undefined, + { cause } + ); +} + +export function validateContextName(name: string): string { + if (!CONTEXT_NAME_PATTERN.test(name) || name === '.' || name === '..') { + throw new ConfigStoreError( + 'CONTEXT_NAME_INVALID', + 'Context names must be 1-64 characters, begin and end with a letter or number, and contain only letters, numbers, dots, underscores, or hyphens.', + { contextName: name } + ); + } + return name; +} + +export function validateEndpoint(endpoint: string): string { + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch (cause) { + throw new ConfigStoreError( + 'CONTEXT_ENDPOINT_INVALID', + 'Context endpoint must be an absolute HTTP or HTTPS URL.', + undefined, + { cause } + ); + } + + if ( + endpoint.trim() !== endpoint || + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.username !== '' || + parsed.password !== '' || + parsed.hash !== '' + ) { + throw new ConfigStoreError( + 'CONTEXT_ENDPOINT_INVALID', + 'Context endpoint must be an HTTP or HTTPS URL without embedded credentials or a fragment.' + ); + } + + for (const key of parsed.searchParams.keys()) { + const separated = key + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + const compact = separated.join(''); + const containsSensitivePart = separated.some((part) => + SENSITIVE_ENDPOINT_QUERY_PARTS.has(part) + ); + const containsSensitiveKeyVariant = + compact.includes('apikey') || + compact.includes('privatekey') || + compact.includes('signingkey') || + compact === 'key' || + compact === 'sig'; + + if (containsSensitivePart || containsSensitiveKeyVariant) { + throw new ConfigStoreError( + 'CONTEXT_ENDPOINT_INVALID', + 'Context endpoint query parameters must not contain credentials or secrets.' + ); } } - return DEFAULT_SETTINGS; + return endpoint; } -/** - * Save global settings - */ -export function saveSettings(settings: GlobalSettings): void { - const settingsPath = getConfigPath('settings.json'); - const configDir = path.dirname(settingsPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); +function parseContext(value: unknown, file: string): ContextConfig { + if (!isRecord(value)) { + return invalidConfig('Context configuration must be an object.', file); + } + assertOnlyKeys( + value, + ['name', 'endpoint', 'createdAt', 'updatedAt'], + 'Context configuration', + file + ); + const { name, endpoint, createdAt, updatedAt } = value; + if ( + typeof name !== 'string' || + typeof endpoint !== 'string' || + !isIsoDate(createdAt) || + !isIsoDate(updatedAt) + ) { + return invalidConfig( + 'Context configuration is missing a valid name, endpoint, createdAt, or updatedAt value.', + file + ); } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + validateContextName(name); + validateEndpoint(endpoint); + return { name, endpoint, createdAt, updatedAt }; } -/** - * Load credentials - */ -export function loadCredentials(): Credentials { - const credentialsPath = getConfigPath('credentials.json'); - if (fs.existsSync(credentialsPath)) { - try { - const content = fs.readFileSync(credentialsPath, 'utf8'); - return JSON.parse(content); - } catch { - return { tokens: {} }; - } +function parseSettings(value: unknown, file: string): GlobalSettings { + if (!isRecord(value)) { + return invalidConfig('Settings must be an object.', file); } - return { tokens: {} }; + assertOnlyKeys(value, ['currentContext'], 'Settings', file); + const currentContext = value.currentContext; + if (currentContext !== undefined && typeof currentContext !== 'string') { + return invalidConfig('settings.currentContext must be a string.', file); + } + if (typeof currentContext === 'string') { + validateContextName(currentContext); + } + return typeof currentContext === 'string' ? { currentContext } : {}; } -/** - * Save credentials - */ -export function saveCredentials(credentials: Credentials): void { - const credentialsPath = getConfigPath('credentials.json'); - const configDir = path.dirname(credentialsPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } - fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), { - mode: 0o600, // Read/write for owner only - }); +function parseContextCredentials( + value: unknown, + file: string +): ContextCredentials { + if ( + !isRecord(value) || + typeof value.token !== 'string' || + value.token === '' + ) { + return invalidConfig( + 'Stored credentials must contain a non-empty token.', + file + ); + } + assertOnlyKeys( + value, + ['token', 'expiresAt', 'refreshToken'], + 'Stored credentials', + file + ); + if (value.expiresAt !== undefined && !isIsoDate(value.expiresAt)) { + return invalidConfig('credentials.expiresAt must be an ISO date.', file); + } + if ( + value.refreshToken !== undefined && + typeof value.refreshToken !== 'string' + ) { + return invalidConfig('credentials.refreshToken must be a string.', file); + } + return { + token: value.token, + expiresAt: value.expiresAt as string | undefined, + refreshToken: value.refreshToken as string | undefined, + }; } -/** - * Load a context configuration - */ -export function loadContext(contextName: string): ContextConfig | null { - const contextPath = getContextConfigPath(contextName); - if (fs.existsSync(contextPath)) { - try { - const content = fs.readFileSync(contextPath, 'utf8'); - return JSON.parse(content); - } catch { - return null; +function parseCredentials(value: unknown, file: string): Credentials { + if (!isRecord(value) || !isRecord(value.tokens)) { + return invalidConfig('Credentials must contain a tokens object.', file); + } + assertOnlyKeys(value, ['tokens'], 'Credentials', file); + const tokens: Record = {}; + for (const [name, credentials] of Object.entries(value.tokens)) { + validateContextName(name); + tokens[name] = parseContextCredentials(credentials, file); + } + return { tokens }; +} + +function parseState(value: unknown, file: string): CncState { + if (!isRecord(value) || typeof value.stateVersion !== 'number') { + return invalidConfig('CNC state is missing a numeric stateVersion.', file); + } + assertOnlyKeys( + value, + ['stateVersion', 'settings', 'contexts', 'credentials'], + 'CNC state', + file + ); + if (value.stateVersion > CURRENT_STATE_VERSION) { + throw new ConfigStoreError( + 'CONFIG_VERSION_UNSUPPORTED', + `CNC state version ${value.stateVersion} is newer than supported version ${CURRENT_STATE_VERSION}.`, + { file, stateVersion: value.stateVersion } + ); + } + if (value.stateVersion !== CURRENT_STATE_VERSION) { + return invalidConfig( + `CNC state version ${value.stateVersion} cannot be migrated by this release.`, + file + ); + } + if (!isRecord(value.contexts)) { + return invalidConfig('CNC state must contain a contexts object.', file); + } + + const contexts: Record = {}; + for (const [name, rawContext] of Object.entries(value.contexts)) { + validateContextName(name); + const context = parseContext(rawContext, file); + if (context.name !== name) { + return invalidConfig( + `Context key "${name}" does not match its stored name.`, + file + ); } + contexts[name] = context; + } + + const settings = parseSettings(value.settings, file); + const credentials = parseCredentials(value.credentials, file); + if (settings.currentContext && !contexts[settings.currentContext]) { + return invalidConfig( + `Current context "${settings.currentContext}" does not exist.`, + file + ); } - return null; + const orphanedCredentials = Object.keys(credentials.tokens).filter( + (name) => !contexts[name] + ); + if (orphanedCredentials.length > 0) { + return invalidConfig( + `Stored credentials reference missing contexts: ${orphanedCredentials.join(', ')}.`, + file + ); + } + return { + stateVersion: CURRENT_STATE_VERSION, + settings, + contexts, + credentials, + }; } -/** - * Save a context configuration - */ -export function saveContext(context: ContextConfig): void { - const contextPath = getContextConfigPath(context.name); - fs.writeFileSync(contextPath, JSON.stringify(context, null, 2)); +function parseJsonFile(file: string): unknown { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (cause) { + return invalidConfig( + `Unable to parse configuration file "${file}".`, + file, + cause + ); + } } -/** - * Delete a context configuration - */ -export function deleteContext(contextName: string): boolean { - const contextPath = getContextConfigPath(contextName); - if (fs.existsSync(contextPath)) { - fs.unlinkSync(contextPath); - return true; +function assertNotSymlink(file: string): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(file); + } catch (error) { + const code = (error as NodeError).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return; + throw error; + } + if (stat.isSymbolicLink()) { + throw new ConfigStoreError( + 'CONFIG_SYMLINK_REJECTED', + `Refusing to use symlinked configuration path "${file}".`, + { file } + ); } - return false; } -/** - * List all context configurations - */ -export function listContexts(): ContextConfig[] { - const dirs = getAppDirs(); - const contextsDir = resolve(dirs, 'config', 'contexts'); - if (!fs.existsSync(contextsDir)) { - return []; +/** Reject every existing symlink component, including ancestors of configDir. */ +function assertNoSymlinkComponents(file: string): void { + const absolute = path.resolve(file); + const root = path.parse(absolute).root; + let current = root; + assertNotSymlink(current); + + const relative = path.relative(root, absolute); + if (relative === '') return; + for (const component of relative.split(path.sep)) { + current = path.join(current, component); + assertNotSymlink(current); + } +} + +function sleepSync(milliseconds: number): void { + const array = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(array, 0, 0, milliseconds); +} + +export class ConfigStore { + readonly configDir: string; + private readonly clock: () => Date; + private readonly lockTimeoutMs: number; + private readonly staleLockMs: number; + + constructor(options: ConfigStoreOptions) { + if (!path.isAbsolute(options.configDir)) { + throw new ConfigStoreError( + 'CONFIG_INVALID', + 'ConfigStore configDir must be absolute.' + ); + } + this.configDir = path.resolve(options.configDir); + this.clock = options.clock ?? (() => new Date()); + this.lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS; + this.staleLockMs = options.staleLockMs ?? DEFAULT_STALE_LOCK_MS; } - const files = fs.readdirSync(contextsDir).filter(f => f.endsWith('.json')); - const contexts: ContextConfig[] = []; + get statePath(): string { + return path.join(this.configDir, STATE_FILENAME); + } + + read(): CncState { + return cloneState(this.readWithSource().state); + } + + mutate(mutator: (state: CncState) => T): T { + return this.withLock(() => { + const loaded = this.readWithSource(); + const next = cloneState(loaded.state); + const result = mutator(next); + const validated = parseState(next, this.statePath); + if (loaded.source === 'legacy') this.backupLegacyFiles(); + this.writeAtomic(validated); + return result; + }); + } + + private ensureConfigDir(): void { + assertNoSymlinkComponents(this.configDir); + fs.mkdirSync(this.configDir, { recursive: true, mode: 0o700 }); + assertNoSymlinkComponents(this.configDir); + fs.chmodSync(this.configDir, 0o700); + } + + private readWithSource(): LoadedState { + assertNoSymlinkComponents(this.configDir); + assertNotSymlink(this.statePath); + if (fs.existsSync(this.statePath)) { + return { + state: parseState(parseJsonFile(this.statePath), this.statePath), + source: 'canonical', + }; + } + return this.readLegacyState(); + } + + private readLegacyState(): LoadedState { + const settingsPath = path.join(this.configDir, 'settings.json'); + const credentialsPath = path.join(this.configDir, 'credentials.json'); + const contextsDir = path.join(this.configDir, 'contexts'); + assertNotSymlink(settingsPath); + assertNotSymlink(credentialsPath); + assertNotSymlink(contextsDir); + const hasLegacy = + fs.existsSync(settingsPath) || + fs.existsSync(credentialsPath) || + fs.existsSync(contextsDir); + if (!hasLegacy) { + return { state: cloneState(DEFAULT_STATE), source: 'empty' }; + } - for (const file of files) { + const settings = fs.existsSync(settingsPath) + ? parseSettings(parseJsonFile(settingsPath), settingsPath) + : {}; + const credentials = fs.existsSync(credentialsPath) + ? parseCredentials(parseJsonFile(credentialsPath), credentialsPath) + : { tokens: {} }; + const contexts: Record = {}; + + if (fs.existsSync(contextsDir)) { + if (!fs.statSync(contextsDir).isDirectory()) { + return invalidConfig( + 'Legacy contexts path is not a directory.', + contextsDir + ); + } + for (const entry of fs.readdirSync(contextsDir, { + withFileTypes: true, + })) { + if (!entry.name.endsWith('.json')) continue; + const file = path.join(contextsDir, entry.name); + if (!entry.isFile()) { + if (entry.isSymbolicLink()) assertNotSymlink(file); + return invalidConfig( + 'Legacy context entry is not a regular file.', + file + ); + } + assertNotSymlink(file); + const expectedName = entry.name.slice(0, -'.json'.length); + validateContextName(expectedName); + const context = parseContext(parseJsonFile(file), file); + if (context.name !== expectedName) { + return invalidConfig( + `Legacy context filename "${entry.name}" does not match its stored name.`, + file + ); + } + contexts[context.name] = context; + } + } + + if (settings.currentContext && !contexts[settings.currentContext]) { + return invalidConfig( + `Current context "${settings.currentContext}" does not exist.`, + settingsPath + ); + } + const orphanedCredentials = Object.keys(credentials.tokens).filter( + (name) => !contexts[name] + ); + if (orphanedCredentials.length > 0) { + return invalidConfig( + `Stored credentials reference missing contexts: ${orphanedCredentials.join(', ')}.`, + credentialsPath + ); + } + return { + state: { + stateVersion: CURRENT_STATE_VERSION, + settings, + contexts, + credentials, + }, + source: 'legacy', + }; + } + + private backupLegacyFiles(): void { + const entries = ['settings.json', 'credentials.json', 'contexts'].filter( + (name) => fs.existsSync(path.join(this.configDir, name)) + ); + if (entries.length === 0) return; + + const stamp = this.clock().toISOString().replace(/[:.]/g, '-'); + const backupDir = path.join( + this.configDir, + `legacy-backup-${stamp}-${process.pid}-${temporaryFileSequence++}` + ); + fs.mkdirSync(backupDir, { mode: 0o700 }); + for (const entry of entries) { + const source = path.join(this.configDir, entry); + assertNotSymlink(source); + const destination = path.join(backupDir, entry); + if (fs.statSync(source).isDirectory()) { + fs.mkdirSync(destination, { mode: 0o700 }); + for (const child of fs.readdirSync(source, { withFileTypes: true })) { + const childSource = path.join(source, child.name); + if (!child.isFile()) { + assertNotSymlink(childSource); + return invalidConfig( + 'Legacy backup encountered a non-file context entry.', + childSource + ); + } + assertNotSymlink(childSource); + fs.copyFileSync(childSource, path.join(destination, child.name)); + fs.chmodSync(path.join(destination, child.name), 0o600); + } + } else { + fs.copyFileSync(source, destination); + fs.chmodSync(destination, 0o600); + } + } + } + + private writeAtomic(state: CncState): void { + this.ensureConfigDir(); + assertNotSymlink(this.statePath); + const temporaryPath = path.join( + this.configDir, + `.state.${process.pid}.${temporaryFileSequence++}.tmp` + ); + let descriptor: number | undefined; try { - const content = fs.readFileSync(path.join(contextsDir, file), 'utf8'); - contexts.push(JSON.parse(content)); - } catch { - // Skip invalid files + descriptor = fs.openSync( + temporaryPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600 + ); + fs.writeFileSync( + descriptor, + `${JSON.stringify(state, null, 2)}\n`, + 'utf8' + ); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporaryPath, this.statePath); + fs.chmodSync(this.statePath, 0o600); + this.fsyncDirectory(); + } catch (cause) { + if (descriptor !== undefined) fs.closeSync(descriptor); + if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath); + throw cause; } } - return contexts; -} + private fsyncDirectory(): void { + let descriptor: number | undefined; + try { + descriptor = fs.openSync(this.configDir, fs.constants.O_RDONLY); + fs.fsyncSync(descriptor); + } catch (error) { + const code = (error as NodeError).code; + if (code !== 'EINVAL' && code !== 'EPERM') throw error; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + } -/** - * Get the current active context - */ -export function getCurrentContext(): ContextConfig | null { - const settings = loadSettings(); - if (settings.currentContext) { - return loadContext(settings.currentContext); + private withLock(operation: () => T): T { + this.ensureConfigDir(); + const lockPath = path.join(this.configDir, LOCK_FILENAME); + const deadline = Date.now() + this.lockTimeoutMs; + const lockId = `${process.pid}:${Date.now()}:${temporaryFileSequence++}`; + let descriptor: number | undefined; + + while (descriptor === undefined) { + assertNotSymlink(lockPath); + try { + descriptor = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync( + descriptor, + JSON.stringify({ + id: lockId, + pid: process.pid, + createdAt: Date.now(), + }), + 'utf8' + ); + fs.fsyncSync(descriptor); + } catch (error) { + if ((error as NodeError).code !== 'EEXIST') throw error; + let stat: fs.Stats; + try { + stat = fs.statSync(lockPath); + } catch (statError) { + if ((statError as NodeError).code === 'ENOENT') continue; + throw statError; + } + if (Date.now() - stat.mtimeMs > this.staleLockMs) { + try { + fs.unlinkSync(lockPath); + } catch (unlinkError) { + if ((unlinkError as NodeError).code !== 'ENOENT') throw unlinkError; + } + continue; + } + if (Date.now() >= deadline) { + throw new ConfigStoreError( + 'CONFIG_LOCK_TIMEOUT', + 'Timed out waiting for another CNC process to finish updating configuration.', + { timeoutMs: this.lockTimeoutMs } + ); + } + sleepSync(Math.min(LOCK_RETRY_MS, Math.max(1, deadline - Date.now()))); + } + } + + try { + return operation(); + } finally { + fs.closeSync(descriptor); + if (fs.existsSync(lockPath)) { + assertNotSymlink(lockPath); + try { + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { + id?: unknown; + }; + if (lock.id === lockId) fs.unlinkSync(lockPath); + } catch (error) { + if ((error as NodeError).code !== 'ENOENT') throw error; + } + } + } } - return null; +} + +/** Get appstash directories without exposing configuration contents. */ +export function getAppDirs() { + return appstash(TOOL_NAME, { ensure: true }); +} + +export function getDefaultConfigDir(): string { + return resolveAppPath(getAppDirs(), 'config'); } /** - * Set the current active context + * Resolve the historical appstash location from an adapter-owned environment + * snapshot. The directory is deliberately not created until a state mutation + * needs it, so registry construction and discovery remain side-effect free. */ -export function setCurrentContext(contextName: string): boolean { - const context = loadContext(contextName); - if (!context) { - return false; +export function getConfigDirForEnvironment( + environment: Readonly> +): string { + const baseDir = + environment.APPSTASH_BASE_DIR ?? + environment.HOME ?? + environment.USERPROFILE; + if (baseDir === undefined || baseDir.trim().length === 0) { + throw new ConfigStoreError( + 'CONFIG_BASE_DIRECTORY_REQUIRED', + 'An explicit APPSTASH_BASE_DIR, HOME, or USERPROFILE is required to resolve CNC state.' + ); } - const settings = loadSettings(); - settings.currentContext = contextName; - saveSettings(settings); - return true; + const dirs = appstash(TOOL_NAME, { + ensure: false, + baseDir, + }); + return resolveAppPath(dirs, 'config'); +} + +export function createConfigStoreForEnvironment( + environment: Readonly> +): ConfigStore { + return new ConfigStore({ + configDir: getConfigDirForEnvironment(environment), + }); +} + +let defaultStore: ConfigStore | undefined; + +export function getDefaultConfigStore(): ConfigStore { + defaultStore ??= new ConfigStore({ configDir: getDefaultConfigDir() }); + return defaultStore; +} + +export function loadSettings( + store: ConfigStore = getDefaultConfigStore() +): GlobalSettings { + return store.read().settings; +} + +export function saveSettings( + settings: GlobalSettings, + store: ConfigStore = getDefaultConfigStore() +): void { + store.mutate((state) => { + state.settings = { ...settings }; + }); +} + +export function loadCredentials( + store: ConfigStore = getDefaultConfigStore() +): Credentials { + return store.read().credentials; +} + +export function saveCredentials( + credentials: Credentials, + store: ConfigStore = getDefaultConfigStore() +): void { + store.mutate((state) => { + state.credentials = { + tokens: Object.fromEntries( + Object.entries(credentials.tokens).map(([name, value]) => [ + name, + { ...value }, + ]) + ), + }; + }); +} + +export function loadContext( + contextName: string, + store: ConfigStore = getDefaultConfigStore() +): ContextConfig | null { + validateContextName(contextName); + return store.read().contexts[contextName] ?? null; +} + +export function saveContext( + context: ContextConfig, + store: ConfigStore = getDefaultConfigStore() +): void { + validateContextName(context.name); + validateEndpoint(context.endpoint); + store.mutate((state) => { + state.contexts[context.name] = { ...context }; + }); +} + +/** Delete context, credentials, and the active selection in one state commit. */ +export function deleteContext( + contextName: string, + store: ConfigStore = getDefaultConfigStore() +): boolean { + validateContextName(contextName); + return store.mutate((state) => { + if (!state.contexts[contextName]) return false; + delete state.contexts[contextName]; + delete state.credentials.tokens[contextName]; + if (state.settings.currentContext === contextName) { + delete state.settings.currentContext; + } + return true; + }); +} + +export function listContexts( + store: ConfigStore = getDefaultConfigStore() +): ContextConfig[] { + return Object.values(store.read().contexts).sort((a, b) => + a.name.localeCompare(b.name) + ); +} + +export function getCurrentContext( + store: ConfigStore = getDefaultConfigStore() +): ContextConfig | null { + const state = store.read(); + return state.settings.currentContext + ? (state.contexts[state.settings.currentContext] ?? null) + : null; +} + +export function setCurrentContext( + contextName: string, + store: ConfigStore = getDefaultConfigStore() +): boolean { + validateContextName(contextName); + return store.mutate((state) => { + if (!state.contexts[contextName]) return false; + state.settings.currentContext = contextName; + return true; + }); } -/** - * Create a new context configuration - */ export function createContext( name: string, - endpoint: string + endpoint: string, + store: ConfigStore = getDefaultConfigStore(), + now: Date = new Date() ): ContextConfig { - const now = new Date().toISOString(); + return createContextAndMaybeActivate(name, endpoint, store, now, false) + .context; +} +/** Create a context and optionally select it in the same locked state commit. */ +export function createContextAndMaybeActivate( + name: string, + endpoint: string, + store: ConfigStore, + now: Date, + activateIfUnset = true +): { context: ContextConfig; activated: boolean } { + validateContextName(name); + validateEndpoint(endpoint); + const timestamp = now.toISOString(); const context: ContextConfig = { name, endpoint, - createdAt: now, - updatedAt: now, + createdAt: timestamp, + updatedAt: timestamp, }; - - saveContext(context); - return context; + const activated = store.mutate((state) => { + if (state.contexts[name]) { + throw new ConfigStoreError( + 'CONFIG_INVALID', + `Context "${name}" already exists.`, + { contextName: name } + ); + } + state.contexts[name] = context; + if (activateIfUnset && state.settings.currentContext === undefined) { + state.settings.currentContext = name; + return true; + } + return false; + }); + return { context: { ...context }, activated }; } -/** - * Get credentials for a context - */ export function getContextCredentials( - contextName: string + contextName: string, + store: ConfigStore = getDefaultConfigStore() ): ContextCredentials | null { - const credentials = loadCredentials(); - return credentials.tokens[contextName] || null; + validateContextName(contextName); + return store.read().credentials.tokens[contextName] ?? null; } -/** - * Set credentials for a context - */ export function setContextCredentials( contextName: string, token: string, - options?: { - expiresAt?: string; - refreshToken?: string; - } + options?: { expiresAt?: string; refreshToken?: string }, + store: ConfigStore = getDefaultConfigStore() ): void { - const credentials = loadCredentials(); - credentials.tokens[contextName] = { - token, - expiresAt: options?.expiresAt, - refreshToken: options?.refreshToken, - }; - saveCredentials(credentials); + validateContextName(contextName); + const trimmedToken = token.trim(); + if (trimmedToken === '') { + throw new ConfigStoreError('CONFIG_INVALID', 'Token cannot be empty.'); + } + store.mutate((state) => { + if (!state.contexts[contextName]) { + throw new ConfigStoreError( + 'CONTEXT_NOT_FOUND', + `Context "${contextName}" was not found.`, + { contextName } + ); + } + state.credentials.tokens[contextName] = { + token: trimmedToken, + expiresAt: options?.expiresAt, + refreshToken: options?.refreshToken, + }; + }); } -/** - * Remove credentials for a context - */ -export function removeContextCredentials(contextName: string): boolean { - const credentials = loadCredentials(); - if (credentials.tokens[contextName]) { - delete credentials.tokens[contextName]; - saveCredentials(credentials); +export function removeContextCredentials( + contextName: string, + store: ConfigStore = getDefaultConfigStore() +): boolean { + validateContextName(contextName); + return store.mutate((state) => { + if (!state.credentials.tokens[contextName]) return false; + delete state.credentials.tokens[contextName]; return true; - } - return false; + }); } -/** - * Check if a context has valid credentials - */ -export function hasValidCredentials(contextName: string): boolean { - const creds = getContextCredentials(contextName); - if (!creds || !creds.token) { - return false; - } - if (creds.expiresAt) { - const expiresAt = new Date(creds.expiresAt); - if (expiresAt <= new Date()) { - return false; - } - } - return true; +export function hasValidCredentials( + contextName: string, + store: ConfigStore = getDefaultConfigStore(), + now: Date = new Date() +): boolean { + const credentials = getContextCredentials(contextName, store); + if (!credentials?.token) return false; + return !credentials.expiresAt || new Date(credentials.expiresAt) > now; } diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index a1c6f42014..3fd26a7886 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -4,3 +4,5 @@ export * from './types'; export * from './config-manager'; +export * from './resolution'; +export * from './secrets'; diff --git a/packages/cli/src/config/resolution.ts b/packages/cli/src/config/resolution.ts new file mode 100644 index 0000000000..3fe9a1803a --- /dev/null +++ b/packages/cli/src/config/resolution.ts @@ -0,0 +1,74 @@ +import type { ConfigStore, ConfigStoreError } from './config-manager'; +import { + ConfigStoreError as StoreError, + getDefaultConfigStore, + validateContextName, +} from './config-manager'; +import type { ContextConfig } from './types'; + +export type EnvironmentMap = Readonly>; + +export interface ResolveContextOptions { + contextName?: string; + env?: EnvironmentMap; + allowCurrentContext?: boolean; + store?: ConfigStore; +} + +export interface ResolvedContext { + context: ContextConfig; + source: 'argument' | 'environment' | 'current'; +} + +/** + * Resolve an explicit argument first, then CNC_CONTEXT, then (for human + * compatibility only) the globally selected context. + */ +export function resolveContext( + options: ResolveContextOptions = {} +): ResolvedContext { + const store = options.store ?? getDefaultConfigStore(); + const explicit = options.contextName?.trim(); + const fromEnvironment = options.env?.CNC_CONTEXT?.trim(); + const selected = explicit || fromEnvironment; + const source = explicit + ? 'argument' + : fromEnvironment + ? 'environment' + : 'current'; + const state = store.read(); + + if (!selected) { + if ( + options.allowCurrentContext === false || + !state.settings.currentContext + ) { + throw new StoreError( + 'CONTEXT_REQUIRED', + 'A context is required. Pass --context or set CNC_CONTEXT.' + ); + } + const current = state.contexts[state.settings.currentContext]; + if (!current) { + throw new StoreError( + 'CONFIG_INVALID', + `Current context "${state.settings.currentContext}" does not exist.` + ); + } + return { context: current, source }; + } + + validateContextName(selected); + const context = state.contexts[selected]; + if (!context) { + throw new StoreError( + 'CONTEXT_NOT_FOUND', + `Context "${selected}" was not found.`, + { contextName: selected } + ); + } + return { context, source }; +} + +// Preserve a named type export for callers that only import this module. +export type { ConfigStoreError }; diff --git a/packages/cli/src/config/secrets.ts b/packages/cli/src/config/secrets.ts new file mode 100644 index 0000000000..a99eb8058f --- /dev/null +++ b/packages/cli/src/config/secrets.ts @@ -0,0 +1,77 @@ +import type { EnvironmentMap } from './resolution'; + +export type TokenErrorCode = 'TOKEN_MISSING' | 'TOKEN_SOURCE_AMBIGUOUS'; + +export class TokenSourceError extends Error { + readonly name = 'TokenSourceError'; + + constructor( + readonly code: TokenErrorCode, + message: string + ) { + super(message); + } +} + +export interface ResolveTokenOptions { + /** Explicitly supplied legacy/human token. */ + token?: string; + /** Token read by the terminal adapter from stdin. */ + stdinToken?: string; + env?: EnvironmentMap; + required?: boolean; +} + +export interface ResolvedToken { + token: string; + source: 'argument' | 'stdin' | 'environment'; +} + +/** Resolve a token without reading process.env or stdin in operation code. */ +export function resolveToken( + options: ResolveTokenOptions +): ResolvedToken | undefined { + const candidates = [ + { source: 'argument' as const, value: options.token }, + { source: 'stdin' as const, value: options.stdinToken }, + { source: 'environment' as const, value: options.env?.CNC_TOKEN }, + ].filter((candidate) => candidate.value?.trim()); + + if (candidates.length > 1) { + throw new TokenSourceError( + 'TOKEN_SOURCE_AMBIGUOUS', + 'Provide a token through exactly one source: explicit input, stdin, or CNC_TOKEN.' + ); + } + const candidate = candidates[0]; + if (!candidate) { + if (options.required !== false) { + throw new TokenSourceError( + 'TOKEN_MISSING', + 'No token was provided. Use stdin or CNC_TOKEN in noninteractive mode.' + ); + } + return undefined; + } + return { token: candidate.value!.trim(), source: candidate.source }; +} + +export function maskToken(token: string): string { + if (token.length <= 10) return '****'; + return `${token.slice(0, 6)}...${token.slice(-4)}`; +} + +const SECRET_KEY = + /(?:authorization|cookie|password|refresh[-_]?token|secret|token)/i; + +/** Recursively redact likely secret fields before values enter logs or events. */ +export function redactSecrets(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redactSecrets); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value as Record).map(([key, nested]) => [ + key, + SECRET_KEY.test(key) ? '[REDACTED]' : redactSecrets(nested), + ]) + ); +} diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 8b11bad26a..36b9e3e76f 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -45,7 +45,28 @@ export interface ContextCredentials { refreshToken?: string; } +/** Current on-disk state format. */ +export const CURRENT_STATE_VERSION = 1 as const; + +/** + * Canonical CNC state. Keeping contexts, credentials, and the active selection + * in one file lets mutations such as context deletion commit atomically. + */ +export interface CncState { + stateVersion: typeof CURRENT_STATE_VERSION; + settings: GlobalSettings; + contexts: Record; + credentials: Credentials; +} + /** * Default global settings */ export const DEFAULT_SETTINGS: GlobalSettings = {}; + +export const DEFAULT_STATE: CncState = { + stateVersion: CURRENT_STATE_VERSION, + settings: DEFAULT_SETTINGS, + contexts: {}, + credentials: { tokens: {} }, +}; diff --git a/packages/cli/src/console-isolation.ts b/packages/cli/src/console-isolation.ts new file mode 100644 index 0000000000..1f822ba159 --- /dev/null +++ b/packages/cli/src/console-isolation.ts @@ -0,0 +1,43 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const consoleMethods = ['debug', 'error', 'info', 'log', 'warn'] as const; +type ConsoleMethod = (typeof consoleMethods)[number]; + +const suppression = new AsyncLocalStorage(); +let users = 0; +let originalMethods: Record | undefined; + +const install = (): void => { + if (originalMethods !== undefined) return; + originalMethods = Object.fromEntries( + consoleMethods.map((method) => [method, console[method]]) + ) as Record; + for (const method of consoleMethods) { + console[method] = ((...args: unknown[]): void => { + if (suppression.getStore() === true) return; + originalMethods?.[method].apply(console, args as never); + }) as never; + } +}; + +const uninstall = (): void => { + if (originalMethods === undefined) return; + for (const method of consoleMethods) { + console[method] = originalMethods[method] as never; + } + originalMethods = undefined; +}; + +/** Suppress dependency console output only within the current async context. */ +export const withConsoleSuppressed = async ( + callback: () => Promise +): Promise => { + users += 1; + install(); + try { + return await suppression.run(true, callback); + } finally { + users -= 1; + if (users === 0) uninstall(); + } +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts old mode 100755 new mode 100644 index efb62acc93..074f5d9303 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,30 +1,77 @@ #!/usr/bin/env node -import { CLI, CLIOptions, getPackageJson } from 'inquirerer'; +import { readFileSync, realpathSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; -import { commands } from './commands'; +import { runCli } from './commands'; -if (process.argv.includes('--version') || process.argv.includes('-v')) { - const pkg = getPackageJson(__dirname); - console.log(pkg.version); - process.exit(0); -} - -export const options: Partial = { - minimistOpts: { - alias: { - v: 'version', - h: 'help' +const readPackageVersion = (entrypoint: string | undefined): string => { + if (!entrypoint) throw new Error('Unable to locate the CNC entrypoint.'); + let directory = dirname(realpathSync(resolve(entrypoint))); + for (let depth = 0; depth < 4; depth += 1) { + try { + const candidate = JSON.parse( + readFileSync(join(directory, 'package.json'), 'utf8') + ) as { name?: unknown; version?: unknown }; + if ( + candidate.name === '@constructive-io/cli' && + typeof candidate.version === 'string' + ) { + return candidate.version; + } + } catch { + // Keep walking; the ESM directory has its own type-only package.json. } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; } + throw new Error('Unable to locate @constructive-io/cli package metadata.'); }; -const app = new CLI(commands, options); +const controller = new AbortController(); +const argv = process.argv.slice(2); +const structuredOutputRequested = argv.some( + (token, index) => + token === '--agent' || + token === '--format=json' || + token === '--format=jsonl' || + (token === '--format' && + (argv[index + 1] === 'json' || argv[index + 1] === 'jsonl')) +); +const cancel = () => { + if (!controller.signal.aborted) { + controller.abort( + new DOMException('The operation was cancelled.', 'AbortError') + ); + } +}; -app.run().then(()=> { - // all done! -}).catch(error => { - // Should not reach here with the new CLI error handling pattern - // But keep as fallback for unexpected errors - console.error('Unexpected error:', error); - process.exit(1); -}); +process.once('SIGINT', cancel); +process.once('SIGTERM', cancel); + +void runCli(argv, { + cwd: process.cwd(), + env: { ...process.env }, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + signal: controller.signal, + version: readPackageVersion(process.argv[1]), +}) + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((error) => { + // This boundary only handles stream failures before the protocol can be + // rendered; command and invocation failures are mapped by runCli. + if (!structuredOutputRequested) { + process.stderr.write( + `Error [CLI_PROCESS_FAILURE]: ${error instanceof Error ? error.message : 'Unexpected process failure.'}\n` + ); + } + process.exitCode = 70; + }) + .finally(() => { + process.removeListener('SIGINT', cancel); + process.removeListener('SIGTERM', cancel); + }); diff --git a/packages/cli/src/runtime/codegen-command.ts b/packages/cli/src/runtime/codegen-command.ts new file mode 100644 index 0000000000..3d301be02d --- /dev/null +++ b/packages/cli/src/runtime/codegen-command.ts @@ -0,0 +1,527 @@ +import { + CliError, + defineCommand, + Type, + type CommandAdapterHookMap, + type OperationContext, +} from '@constructive-io/cli-runtime'; +import type { CodegenOperationResult } from '@constructive-io/graphql-codegen'; +import { withLogsSuppressed } from '@pgpmjs/logger'; +import type { Inquirerer } from 'inquirerer'; + +import { withConsoleSuppressed } from '../console-isolation'; +import { importOptionalCapability } from './optional-capability'; + +const SENSITIVE_ENDPOINT_QUERY_PARTS = new Set([ + 'auth', + 'authorization', + 'bearer', + 'cookie', + 'credential', + 'credentials', + 'jwt', + 'passwd', + 'password', + 'secret', + 'session', + 'signature', + 'token', +]); + +const isSensitiveEndpointQueryKey = (key: string): boolean => { + const separated = key + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + const compact = separated.join(''); + return ( + separated.some((part) => SENSITIVE_ENDPOINT_QUERY_PARTS.has(part)) || + compact.includes('apikey') || + compact.includes('privatekey') || + compact.includes('signingkey') || + compact === 'key' || + compact === 'sig' + ); +}; + +/** Reject credential-bearing endpoint URLs before codegen can describe them in an event. */ +const assertSafeEndpoint = ( + endpoint: string | undefined, + context: Pick +): void => { + if (endpoint === undefined) return; + + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + throw new CliError({ + code: 'CODEGEN_ENDPOINT_INVALID', + category: 'validation', + message: 'The codegen endpoint must be an absolute HTTP or HTTPS URL.', + path: '/endpoint', + }); + } + + context.registerSensitiveValue(parsed.username); + context.registerSensitiveValue(parsed.password); + let hasSensitiveQuery = false; + for (const [key, value] of parsed.searchParams) { + if (!isSensitiveEndpointQueryKey(key)) continue; + hasSensitiveQuery = true; + context.registerSensitiveValue(value); + } + + if ( + endpoint.trim() !== endpoint || + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.username !== '' || + parsed.password !== '' || + parsed.hash !== '' || + hasSensitiveQuery + ) { + throw new CliError({ + code: 'CODEGEN_ENDPOINT_INVALID', + category: 'validation', + message: + 'The codegen endpoint must not contain embedded credentials, credential-like query parameters, or a fragment.', + path: '/endpoint', + }); + } +}; + +const suppressOperationOutput = (callback: () => Promise): Promise => + withConsoleSuppressed(() => withLogsSuppressed(callback)); + +const CodegenProgressSchema = Type.Object( + { + event: Type.Literal('codegen.progress'), + phase: Type.Union([ + Type.Literal('schema.fetch'), + Type.Literal('types.generate'), + Type.Literal('hooks.generate'), + Type.Literal('orm.generate'), + Type.Literal('cli.generate'), + Type.Literal('pgpm.prepare'), + ]), + message: Type.String(), + }, + { additionalProperties: false } +); + +const FileChangeSchema = Type.Object( + { + path: Type.String(), + absolutePath: Type.String(), + action: Type.Union([ + Type.Literal('create'), + Type.Literal('update'), + Type.Literal('delete'), + Type.Literal('unchanged'), + Type.Literal('conflict'), + ]), + previousHash: Type.Optional(Type.String()), + generatedHash: Type.Optional(Type.String()), + reason: Type.Optional(Type.String()), + }, + { additionalProperties: false } +); + +const GenerationPlanSchema = Type.Object( + { + version: Type.Literal(1), + outputDir: Type.String(), + manifestPath: Type.String(), + fingerprint: Type.String(), + changes: Type.Array(FileChangeSchema), + }, + { additionalProperties: false } +); + +type CodegenFileChange = CodegenOperationResult['fileChanges'][number]; + +const toWireFileChange = (change: CodegenFileChange) => ({ + path: change.path, + absolutePath: change.absolutePath, + action: change.action, + ...(change.previousHash === undefined + ? {} + : { previousHash: change.previousHash }), + ...(change.generatedHash === undefined + ? {} + : { generatedHash: change.generatedHash }), + ...(change.reason === undefined ? {} : { reason: change.reason }), +}); + +const toWirePlan = (plan: CodegenOperationResult['plans'][number]) => ({ + version: plan.version, + outputDir: plan.outputDir, + manifestPath: plan.manifestPath, + fingerprint: plan.fingerprint, + changes: plan.changes.map(toWireFileChange), +}); + +const CodegenResultSchema = Type.Object( + { + name: Type.Optional(Type.String()), + success: Type.Boolean(), + message: Type.String(), + output: Type.Optional(Type.String()), + tables: Type.Optional(Type.Array(Type.String())), + filesWritten: Type.Optional(Type.Array(Type.String())), + filesRemoved: Type.Optional(Type.Array(Type.String())), + errors: Type.Optional(Type.Array(Type.String())), + }, + { additionalProperties: false } +); + +const CodegenOutputSchema = Type.Object( + { + success: Type.Boolean(), + dryRun: Type.Boolean(), + planFingerprint: Type.Optional(Type.String()), + results: Type.Array(CodegenResultSchema), + plans: Type.Array(GenerationPlanSchema), + fileChanges: Type.Array(FileChangeSchema), + }, + { additionalProperties: false } +); + +const CodegenInputSchema = Type.Object( + { + config: Type.Optional(Type.String({ minLength: 1 })), + endpoint: Type.Optional(Type.String({ minLength: 1 })), + schemaFile: Type.Optional(Type.String({ minLength: 1 })), + schemaDir: Type.Optional(Type.String({ minLength: 1 })), + schemas: Type.Optional(Type.String({ minLength: 1 })), + apiNames: Type.Optional(Type.String({ minLength: 1 })), + reactQuery: Type.Optional(Type.Boolean()), + orm: Type.Optional(Type.Boolean()), + cli: Type.Optional(Type.Boolean()), + output: Type.Optional(Type.String({ minLength: 1 })), + target: Type.Optional(Type.String({ minLength: 1 })), + requestHeaderValue: Type.Optional( + Type.String({ minLength: 1, writeOnly: true }) + ), + dryRun: Type.Optional(Type.Boolean()), + verbose: Type.Optional(Type.Boolean()), + schemaEnabled: Type.Optional(Type.Boolean()), + schemaOutput: Type.Optional(Type.String({ minLength: 1 })), + schemaFilename: Type.Optional(Type.String({ minLength: 1 })), + overwriteModifiedGenerated: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } +); + +const bool = (name: string, deprecatedAlias?: string) => ({ + kind: 'option' as const, + name, + ...(deprecatedAlias ? { deprecatedAliases: [deprecatedAlias] } : {}), + negatable: true, +}); + +export const codegenCommand = defineCommand({ + id: 'codegen.generate', + path: ['codegen'], + summary: 'Generate typed GraphQL clients and export GraphQL schemas.', + input: CodegenInputSchema, + output: CodegenOutputSchema, + events: CodegenProgressSchema, + bindings: [ + { property: 'config', sources: [{ kind: 'option', name: 'config' }] }, + { property: 'endpoint', sources: [{ kind: 'option', name: 'endpoint' }] }, + { + property: 'schemaFile', + sources: [ + { + kind: 'option', + name: 'schema-file', + deprecatedAliases: ['schemaFile'], + }, + ], + }, + { + property: 'schemaDir', + sources: [ + { + kind: 'option', + name: 'schema-dir', + deprecatedAliases: ['schemaDir'], + }, + ], + }, + { property: 'schemas', sources: [{ kind: 'option', name: 'schemas' }] }, + { + property: 'apiNames', + sources: [ + { kind: 'option', name: 'api-names', deprecatedAliases: ['apiNames'] }, + ], + }, + { + property: 'reactQuery', + sources: [bool('react-query', 'reactQuery')], + valueType: 'boolean', + }, + { property: 'orm', sources: [bool('orm')], valueType: 'boolean' }, + { property: 'cli', sources: [bool('cli')], valueType: 'boolean' }, + { property: 'output', sources: [{ kind: 'option', name: 'output' }] }, + { property: 'target', sources: [{ kind: 'option', name: 'target' }] }, + { + property: 'requestHeaderValue', + sources: [ + { kind: 'option', name: 'authorization', sensitive: true }, + { + kind: 'environment', + name: 'CNC_CODEGEN_AUTHORIZATION', + sensitive: true, + }, + ], + conflict: 'error', + }, + { + property: 'dryRun', + sources: [bool('dry-run', 'dryRun')], + valueType: 'boolean', + }, + { property: 'verbose', sources: [bool('verbose')], valueType: 'boolean' }, + { + property: 'schemaEnabled', + sources: [bool('schema-enabled', 'schemaEnabled')], + valueType: 'boolean', + }, + { + property: 'schemaOutput', + sources: [ + { + kind: 'option', + name: 'schema-output', + deprecatedAliases: ['schemaOutput'], + }, + ], + }, + { + property: 'schemaFilename', + sources: [ + { + kind: 'option', + name: 'schema-filename', + deprecatedAliases: ['schemaFilename'], + }, + ], + }, + { + property: 'overwriteModifiedGenerated', + sources: [ + bool('overwrite-modified-generated', 'overwriteModifiedGenerated'), + ], + valueType: 'boolean', + }, + ], + examples: [ + { + argv: ['codegen', '--endpoint', 'http://localhost:5555/graphql', '--orm'], + }, + { + argv: ['codegen', '--config', 'graphql-codegen.config.json', '--dry-run'], + }, + { + argv: ['codegen', '--schema-file', 'schema.graphql', '--schema-enabled'], + }, + ], + lifecycle: 'finite', + effect: 'write', + capabilities: { dryRun: true, confirmation: true }, + async execute(input, context) { + assertSafeEndpoint(input.endpoint, context); + + if (input.overwriteModifiedGenerated && !context.capabilities.yes) { + throw new CliError({ + code: 'CLI_CONFIRMATION_REQUIRED', + category: 'invocation', + message: '--overwrite-modified-generated requires --yes.', + path: '/overwriteModifiedGenerated', + }); + } + + return suppressOperationOutput(async () => { + const { CodegenOperationError, runCodegenOperation } = + await importOptionalCapability( + 'codegen', + '@constructive-io/graphql-codegen', + () => import('@constructive-io/graphql-codegen') + ); + + let operation: CodegenOperationResult; + let progressQueue = Promise.resolve(); + try { + const { requestHeaderValue, ...operationInput } = input; + operation = await runCodegenOperation( + { + ...operationInput, + ...(requestHeaderValue === undefined + ? {} + : { authorization: requestHeaderValue }), + }, + { + cwd: context.cwd, + env: context.env, + signal: context.signal, + allowExecutableConfig: false, + requireSafeEndpoints: true, + onSensitiveValue: (value) => context.registerSensitiveValue(value), + overwriteModifiedGenerated: input.overwriteModifiedGenerated, + yes: context.capabilities.yes, + onProgress: (progress) => { + progressQueue = progressQueue.then(() => + context.events.emit({ event: 'codegen.progress', ...progress }) + ); + }, + } + ); + await progressQueue; + } catch (error) { + await progressQueue; + if (error instanceof CodegenOperationError) { + throw new CliError({ + code: error.code, + category: 'configuration', + message: error.message, + cause: error, + }); + } + throw error; + } + + if (operation.hasError) { + const first = operation.results.find( + ({ result }) => !result.success + )?.result; + const conflict = operation.fileChanges.some( + ({ action }) => action === 'conflict' + ); + throw new CliError({ + code: conflict ? 'GENERATED_FILE_MODIFIED' : 'CODEGEN_FAILED', + category: conflict ? 'conflict' : 'operation', + message: first?.message ?? 'Code generation failed.', + details: { + errors: operation.results.flatMap( + ({ result }) => result.errors ?? [] + ), + ...(operation.planFingerprint === undefined + ? {} + : { planFingerprint: operation.planFingerprint }), + conflicts: operation.fileChanges + .filter(({ action }) => action === 'conflict') + .map(({ absolutePath }) => absolutePath), + ...(operation.warnings === undefined + ? {} + : { warnings: operation.warnings }), + ...(operation.recoveryPath === undefined + ? {} + : { recoveryPath: operation.recoveryPath }), + ...(operation.rollbackErrors === undefined + ? {} + : { rollbackErrors: operation.rollbackErrors }), + }, + }); + } + + const fileChanges = operation.fileChanges.map(toWireFileChange); + return { + data: { + success: true, + dryRun: input.dryRun === true, + ...(operation.planFingerprint === undefined + ? {} + : { planFingerprint: operation.planFingerprint }), + results: operation.results.map(({ name, result }) => ({ + ...(name === undefined ? {} : { name }), + success: result.success, + message: result.message, + ...(result.output === undefined ? {} : { output: result.output }), + ...(result.tables === undefined ? {} : { tables: result.tables }), + ...(result.filesWritten === undefined + ? {} + : { filesWritten: result.filesWritten }), + ...(result.filesRemoved === undefined + ? {} + : { filesRemoved: result.filesRemoved }), + ...(result.errors === undefined ? {} : { errors: result.errors }), + })), + plans: operation.plans.map(toWirePlan), + fileChanges, + }, + ...(operation.warnings === undefined + ? {} + : { + warnings: operation.warnings.map((message) => ({ + code: + operation.recoveryPath === undefined + ? 'CODEGEN_WARNING' + : 'CODEGEN_RECOVERY_RETAINED', + message, + ...(operation.recoveryPath === undefined + ? {} + : { path: operation.recoveryPath }), + })), + }), + artifacts: [ + ...(input.dryRun === true + ? [] + : fileChanges + .filter( + ({ action }) => action === 'create' || action === 'update' + ) + .map(({ absolutePath, generatedHash }) => ({ + type: 'generated-file', + path: absolutePath, + ...(generatedHash === undefined + ? {} + : { digest: `sha256:${generatedHash}` }), + }))), + ...(operation.recoveryPath === undefined + ? [] + : [ + { + type: 'codegen-recovery', + path: operation.recoveryPath, + description: + 'Retained codegen transaction data requiring manual cleanup or recovery.', + }, + ]), + ], + }; + }); + }, +}); + +export const createCodegenHooks = ( + prompter: Inquirerer +): CommandAdapterHookMap => ({ + 'codegen.generate': { + collectInteractiveInput: async (input) => { + const { codegenQuestions, hasResolvedCodegenSource } = + await importOptionalCapability( + 'codegen', + '@constructive-io/graphql-codegen', + () => import('@constructive-io/graphql-codegen') + ); + if (hasResolvedCodegenSource(input as Record)) + return input as never; + return (await prompter.prompt( + input as Record, + codegenQuestions + )) as never; + }, + renderHuman: (result) => { + const data = result.data as { + dryRun: boolean; + results: Array<{ name?: string; message: string }>; + }; + return data.results + .map(({ name, message }) => `${name ? `[${name}] ` : ''}${message}`) + .join('\n'); + }, + }, +}); diff --git a/packages/cli/src/runtime/discovery-commands.ts b/packages/cli/src/runtime/discovery-commands.ts new file mode 100644 index 0000000000..4205728842 --- /dev/null +++ b/packages/cli/src/runtime/discovery-commands.ts @@ -0,0 +1,335 @@ +import { resolve } from 'node:path'; + +import { + CliError, + CommandCatalogEntrySchema, + CommandSchemaDocumentSchema, + HelpDocumentSchema, + defineCommand, + exportDocumentation, + generateCompletion, + generateDocumentation, + getHelpDocument, + renderHelp, + Type, + type CommandAdapterHookMap, + type CommandRegistry, +} from '@constructive-io/cli-runtime'; + +export interface DiscoveryCommandOptions { + getRegistry(): CommandRegistry; + toolVersion: string; + toolName?: string; +} + +export const createDiscoveryCommands = ({ + getRegistry, + toolVersion, + toolName = 'cnc', +}: DiscoveryCommandOptions) => { + const commandsCommand = defineCommand({ + id: 'discovery.commands', + path: ['commands'], + summary: 'List executable command contracts.', + input: Type.Object( + { prefix: Type.Optional(Type.Array(Type.String())) }, + { additionalProperties: false } + ), + output: Type.Object( + { commands: Type.Array(CommandCatalogEntrySchema) }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'prefix', + sources: [ + { kind: 'positional', index: 0, name: 'prefix', variadic: true }, + ], + repeated: true, + description: 'Optional command path prefix.', + }, + ], + examples: [ + { argv: ['commands', '--format', 'json'] }, + { argv: ['commands', 'context', '--format', 'json'] }, + ], + lifecycle: 'finite', + effect: 'read', + async execute(input) { + return { data: { commands: getRegistry().catalog(input.prefix ?? []) } }; + }, + }); + + const schemaCommand = defineCommand({ + id: 'discovery.schema', + path: ['schema'], + summary: 'Return the exact contract for one command.', + input: Type.Object( + { path: Type.Array(Type.String(), { minItems: 1 }) }, + { additionalProperties: false } + ), + output: Type.Object( + { schema: CommandSchemaDocumentSchema }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'path', + sources: [ + { kind: 'positional', index: 0, name: 'command', variadic: true }, + ], + repeated: true, + description: 'Command path to inspect.', + }, + ], + examples: [{ argv: ['schema', 'context', 'create', '--format', 'json'] }], + lifecycle: 'finite', + effect: 'read', + async execute(input) { + const command = getRegistry().requireByPath(input.path); + return { data: { schema: getRegistry().schema(command.id) } }; + }, + }); + + const helpCommand = defineCommand({ + id: 'discovery.help', + path: ['help'], + summary: 'Show registry-generated help.', + input: Type.Object( + { path: Type.Optional(Type.Array(Type.String())) }, + { additionalProperties: false } + ), + output: Type.Object( + { text: Type.String(), document: HelpDocumentSchema }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'path', + sources: [ + { kind: 'positional', index: 0, name: 'command', variadic: true }, + ], + repeated: true, + description: 'Optional command path.', + }, + ], + examples: [{ argv: ['help', 'execute'] }], + lifecycle: 'finite', + effect: 'read', + async execute(input) { + const path = input.path ?? []; + const registry = getRegistry(); + if ( + path.length > 0 && + !registry.getByPath(path) && + registry.list(path).length === 0 + ) { + throw new CliError({ + code: 'CLI_COMMAND_NOT_FOUND', + category: 'invocation', + message: 'No command is registered for the requested help path.', + }); + } + return { + data: { + text: renderHelp(registry, path, toolName), + document: getHelpDocument(registry, path, toolName), + }, + }; + }, + }); + + const docsExportCommand = defineCommand({ + id: 'discovery.docs-export', + path: ['docs', 'export'], + summary: 'Export version-matched Markdown, schemas, and a CNC Skill.', + input: Type.Object( + { + target: Type.String({ minLength: 1 }), + dryRun: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } + ), + output: Type.Object( + { + target: Type.String(), + fingerprint: Type.String(), + applied: Type.Boolean(), + changes: Type.Array( + Type.Object( + { + path: Type.String(), + action: Type.Union([ + Type.Literal('create'), + Type.Literal('update'), + Type.Literal('delete'), + Type.Literal('unchanged'), + Type.Literal('conflict'), + ]), + }, + { additionalProperties: false } + ) + ), + }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'target', + sources: [{ kind: 'option', name: 'target' }], + description: 'Output directory.', + }, + { + property: 'dryRun', + sources: [ + { kind: 'option', name: 'dry-run', deprecatedAliases: ['dryRun'] }, + ], + valueType: 'boolean', + description: 'Plan documentation changes without writing.', + }, + ], + examples: [ + { + argv: [ + 'docs', + 'export', + '--target', + '.constructive/agent-docs', + '--dry-run', + ], + }, + ], + lifecycle: 'finite', + effect: 'write', + capabilities: { dryRun: true }, + async execute(input, context) { + const target = resolve(context.cwd, input.target); + const documentation = generateDocumentation(getRegistry(), { + toolName, + toolVersion, + skillName: 'constructive-cli', + }); + const exported = await exportDocumentation(target, documentation, { + dryRun: input.dryRun === true, + }); + if (exported.plan.conflicts.length > 0) { + throw new CliError({ + code: 'GENERATED_FILE_MODIFIED', + category: 'conflict', + message: + 'Documentation export would overwrite modified generated files.', + details: { paths: exported.plan.conflicts }, + }); + } + const changes = exported.plan.entries.map(({ path, action }) => ({ + path, + action, + })); + return { + data: { + target: exported.plan.target, + fingerprint: exported.plan.fingerprint, + applied: exported.applied, + changes, + }, + artifacts: exported.applied + ? changes + .filter( + ({ action }) => action === 'create' || action === 'update' + ) + .map(({ path }) => ({ + type: 'agent-documentation', + path: resolve(exported.plan.target, path), + })) + : [], + }; + }, + }); + + const completionCommand = defineCommand({ + id: 'discovery.completion', + path: ['completion'], + summary: 'Generate shell completion from the command registry.', + input: Type.Object( + { + shell: Type.Union([ + Type.Literal('bash'), + Type.Literal('zsh'), + Type.Literal('fish'), + ]), + }, + { additionalProperties: false } + ), + output: Type.Object( + { shell: Type.String(), script: Type.String() }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'shell', + sources: [{ kind: 'positional', index: 0, name: 'shell' }], + description: 'Shell name: bash, zsh, or fish.', + }, + ], + examples: [{ argv: ['completion', 'zsh'] }], + lifecycle: 'finite', + effect: 'read', + async execute(input) { + return { + data: { + shell: input.shell, + script: generateCompletion(getRegistry(), input.shell, toolName), + }, + }; + }, + }); + + return [ + commandsCommand, + schemaCommand, + helpCommand, + docsExportCommand, + completionCommand, + ] as const; +}; + +export const createDiscoveryHooks = (): CommandAdapterHookMap => ({ + 'discovery.commands': { + renderHuman: (result) => { + const data = result.data as { + commands: Array<{ path: string[]; summary: string }>; + }; + return data.commands + .map(({ path, summary }) => `${path.join(' ').padEnd(28)} ${summary}`) + .join('\n'); + }, + }, + 'discovery.schema': { + renderHuman: (result) => + JSON.stringify((result.data as { schema: unknown }).schema, null, 2), + }, + 'discovery.help': { + renderHuman: (result) => (result.data as { text: string }).text.trimEnd(), + }, + 'discovery.docs-export': { + renderHuman: (result) => { + const data = result.data as { + target: string; + applied: boolean; + changes: Array<{ action: string }>; + }; + const changed = data.changes.filter( + ({ action }) => action !== 'unchanged' + ).length; + return `${data.applied ? 'Exported' : 'Planned'} ${changed} documentation change(s) in ${data.target}.`; + }, + }, + 'discovery.completion': { + renderHuman: (result) => + (result.data as { script: string }).script.trimEnd(), + }, +}); + +export type DiscoveryCommand = ReturnType< + typeof createDiscoveryCommands +>[number]; diff --git a/packages/cli/src/runtime/execute-command.ts b/packages/cli/src/runtime/execute-command.ts new file mode 100644 index 0000000000..1bc30ecd8f --- /dev/null +++ b/packages/cli/src/runtime/execute-command.ts @@ -0,0 +1,496 @@ +import { readFile, realpath } from 'node:fs/promises'; +import { isAbsolute, relative, resolve } from 'node:path'; + +import { + CliError, + defineCommand, + isSensitiveKey, + Type, +} from '@constructive-io/cli-runtime'; + +import { ConfigStoreError, redactSecrets, type ConfigStore } from '../config'; +import { + GraphQLExecutionError, + analyzeGraphQLDocument, + assertMutationAllowed, + execute, + getExecutionContext, + type FetchImplementation, + type GraphQLClientError, + type GraphQLError, +} from '../sdk'; + +const ExecuteInputSchema = Type.Object( + { + query: Type.Optional(Type.String({ minLength: 1 })), + file: Type.Optional(Type.String({ minLength: 1 })), + variables: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + contextName: Type.Optional(Type.String({ minLength: 1 })), + anonymous: Type.Optional(Type.Boolean()), + operationName: Type.Optional(Type.String({ minLength: 1 })), + timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), + allowMutation: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } +); + +const ExecuteOutputSchema = Type.Object( + { + contextName: Type.String({ minLength: 1 }), + endpoint: Type.String({ minLength: 1 }), + anonymous: Type.Boolean(), + operation: Type.Object( + { + type: Type.Union([Type.Literal('query'), Type.Literal('mutation')]), + name: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ), + status: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })), + data: Type.Unknown(), + }, + { additionalProperties: false } +); + +export interface ExecuteCommandDependencies { + /** Injected configuration storage for embeddings and isolated tests. */ + store: ConfigStore; + /** Injected transport used by tests and non-global runtimes. */ + fetch?: FetchImplementation; +} + +const isWithin = (parent: string, candidate: string): boolean => { + const pathFromParent = relative(parent, candidate); + return ( + pathFromParent === '' || + (!pathFromParent.startsWith('..') && !isAbsolute(pathFromParent)) + ); +}; + +const systemCode = (error: unknown): string | undefined => + error instanceof Error && 'code' in error + ? String((error as NodeJS.ErrnoException).code) + : undefined; + +const loadQueryFile = async (cwd: string, file: string): Promise => { + let canonicalCwd: string; + try { + canonicalCwd = await realpath(cwd); + } catch (cause) { + throw new CliError({ + code: 'CWD_NOT_FOUND', + category: 'configuration', + message: 'The configured working directory does not exist.', + details: { cwd }, + retryable: false, + cause, + }); + } + + const requestedPath = resolve(canonicalCwd, file); + if (!isWithin(canonicalCwd, requestedPath)) { + throw new CliError({ + code: 'GRAPHQL_FILE_OUTSIDE_CWD', + category: 'authorization', + message: + 'The GraphQL file must be inside the configured working directory.', + path: '/file', + details: { file }, + retryable: false, + }); + } + + let canonicalFile: string; + try { + canonicalFile = await realpath(requestedPath); + } catch (cause) { + const code = systemCode(cause); + throw new CliError({ + code: + code === 'ENOENT' + ? 'GRAPHQL_FILE_NOT_FOUND' + : 'GRAPHQL_FILE_READ_FAILED', + category: 'configuration', + message: + code === 'ENOENT' + ? `GraphQL file "${file}" was not found.` + : `GraphQL file "${file}" could not be resolved.`, + path: '/file', + details: { file, ...(code === undefined ? {} : { systemCode: code }) }, + retryable: false, + cause, + }); + } + if (!isWithin(canonicalCwd, canonicalFile)) { + throw new CliError({ + code: 'GRAPHQL_FILE_OUTSIDE_CWD', + category: 'authorization', + message: + 'The GraphQL file must not resolve outside the configured working directory.', + path: '/file', + details: { file }, + retryable: false, + }); + } + + try { + return await readFile(canonicalFile, 'utf8'); + } catch (cause) { + const code = systemCode(cause); + throw new CliError({ + code: 'GRAPHQL_FILE_READ_FAILED', + category: 'configuration', + message: `GraphQL file "${file}" could not be read.`, + path: '/file', + details: { file, ...(code === undefined ? {} : { systemCode: code }) }, + retryable: false, + cause, + }); + } +}; + +const mapConfigurationError = (error: ConfigStoreError): CliError => + new CliError({ + code: error.code, + category: + error.code === 'CONTEXT_NAME_INVALID' || + error.code === 'CONTEXT_ENDPOINT_INVALID' + ? 'validation' + : error.code === 'CONFIG_LOCK_TIMEOUT' + ? 'conflict' + : 'configuration', + message: error.message, + details: error.details, + retryable: error.code === 'CONFIG_LOCK_TIMEOUT', + cause: error, + }); + +const mapExecutionError = (error: GraphQLExecutionError): CliError => { + const authentication = + error.code === 'AUTH_REQUIRED' || error.code === 'AUTH_EXPIRED'; + const invocation = + error.code === 'GRAPHQL_OPERATION_NAME_REQUIRED' || + error.code === 'GRAPHQL_MUTATION_REQUIRES_APPROVAL'; + return new CliError({ + code: error.code, + category: authentication + ? 'authentication' + : invocation + ? 'invocation' + : 'validation', + message: error.message, + details: error.details, + retryable: false, + cause: error, + }); +}; + +const clientErrorCategory = ( + error: GraphQLClientError +): 'network' | 'operation' => { + if ( + error.category === 'http' || + error.category === 'network' || + error.category === 'timeout' + ) { + return 'network'; + } + return 'operation'; +}; + +const safeGraphQLErrors = (errors: GraphQLError[] | undefined) => + errors?.map((error) => ({ + message: error.message, + ...(error.path === undefined ? {} : { path: error.path }), + ...(error.locations === undefined ? {} : { locations: error.locations }), + })); + +/** + * Register variable values beneath secret-bearing keys before the request is + * sent. Upstream GraphQL errors frequently echo rejected input values inside a + * plain `message` field, where key-only output redaction cannot identify them. + */ +const registerSensitiveVariableValues = ( + variables: Record | undefined, + register: (value: string) => void +): void => { + if (variables === undefined) return; + const seen = new WeakSet(); + + const visit = ( + value: unknown, + parentKey: string | undefined, + inheritedSensitive: boolean + ): void => { + const sensitive = + inheritedSensitive || + (parentKey !== undefined && isSensitiveKey(parentKey)); + if (typeof value === 'string') { + if (sensitive && value.length > 0) register(value); + return; + } + if (typeof value === 'number') { + if (sensitive && Number.isFinite(value)) register(String(value)); + return; + } + if (value === null || typeof value !== 'object') return; + if (seen.has(value)) return; + seen.add(value); + + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor !== undefined && 'value' in descriptor) { + visit(descriptor.value, key, sensitive); + } + } + }; + + visit(variables, undefined, false); +}; + +const graphQLFailure = ( + error: GraphQLClientError, + options: { + data: unknown; + errors?: GraphQLError[]; + status?: number; + } +): CliError => + new CliError({ + code: error.code, + category: clientErrorCategory(error), + message: error.message, + details: redactSecrets({ + ...(options.status === undefined ? {} : { status: options.status }), + ...(options.data === null ? {} : { partialData: options.data }), + ...(options.errors === undefined + ? {} + : { graphqlErrors: safeGraphQLErrors(options.errors) }), + ...(error.details === undefined ? {} : { transport: error.details }), + }), + retryable: error.retryable, + }); + +export function createExecuteCommandDefinition( + dependencies: ExecuteCommandDependencies +) { + return defineCommand({ + id: 'execute', + path: ['execute'], + summary: 'Execute one raw GraphQL query or mutation.', + input: ExecuteInputSchema, + output: ExecuteOutputSchema, + bindings: [ + { + property: 'query', + sources: [{ kind: 'option', name: 'query' }], + description: 'Inline GraphQL document.', + }, + { + property: 'file', + sources: [{ kind: 'option', name: 'file' }], + description: 'GraphQL document path, resolved within --cwd.', + }, + { + property: 'variables', + sources: [{ kind: 'option', name: 'variables' }], + valueType: 'json', + description: 'GraphQL variables as a JSON object.', + }, + { + property: 'contextName', + sources: [ + { kind: 'option', name: 'context' }, + { kind: 'environment', name: 'CNC_CONTEXT' }, + ], + conflict: 'first', + description: 'Target context. CNC_CONTEXT is used when absent.', + }, + { + property: 'anonymous', + sources: [{ kind: 'option', name: 'anonymous', negatable: true }], + valueType: 'boolean', + description: 'Execute without stored credentials.', + }, + { + property: 'operationName', + sources: [ + { + kind: 'option', + name: 'operation-name', + deprecatedAliases: ['operationName'], + }, + ], + description: 'Operation to select from a multi-operation document.', + }, + { + property: 'timeoutMs', + sources: [ + { + kind: 'option', + name: 'timeout-ms', + deprecatedAliases: ['timeoutMs'], + }, + ], + valueType: 'number', + description: 'Request timeout in milliseconds. Defaults to 30000.', + }, + { + property: 'allowMutation', + sources: [{ kind: 'option', name: 'allow-mutation', negatable: true }], + valueType: 'boolean', + description: + 'Allow a mutation when combined with --yes in agent or CI mode.', + }, + ], + examples: [ + { + argv: [ + 'execute', + '--query', + 'query Viewer { viewer { id } }', + '--context', + 'production', + ], + }, + { + argv: [ + 'execute', + '--file', + 'queries/viewer.graphql', + '--variables', + '{"id":"viewer-id"}', + '--context', + 'production', + ], + }, + ], + lifecycle: 'finite', + effect: 'write', + capabilities: { confirmation: true }, + async execute(input, context) { + registerSensitiveVariableValues(input.variables, (value) => + context.registerSensitiveValue(value) + ); + + if ((input.query === undefined) === (input.file === undefined)) { + throw new CliError({ + code: + input.query === undefined + ? 'GRAPHQL_SOURCE_REQUIRED' + : 'GRAPHQL_SOURCE_AMBIGUOUS', + category: 'invocation', + message: + input.query === undefined + ? 'Provide exactly one of --query or --file.' + : '--query and --file cannot be used together.', + retryable: false, + }); + } + + const query = + input.query ?? (await loadQueryFile(context.cwd, input.file!)); + let analysis; + const guardedMode = context.mode !== 'human'; + try { + analysis = analyzeGraphQLDocument(query, input.operationName); + assertMutationAllowed(analysis, { + agent: guardedMode, + allowMutation: input.allowMutation === true, + yes: context.capabilities.yes, + }); + } catch (error) { + if (error instanceof GraphQLExecutionError) { + throw mapExecutionError(error); + } + throw error; + } + + let executionContext; + try { + executionContext = await getExecutionContext({ + contextName: input.contextName, + env: context.env, + agent: guardedMode, + allowCurrentContext: context.mode === 'human', + anonymous: input.anonymous === true, + store: dependencies.store, + now: context.now(), + }); + } catch (error) { + if (error instanceof ConfigStoreError) { + throw mapConfigurationError(error); + } + if (error instanceof GraphQLExecutionError) { + throw mapExecutionError(error); + } + throw error; + } + + if (!executionContext.anonymous) { + context.registerSensitiveValue(executionContext.token); + } + + let result; + try { + result = await execute(query, input.variables, executionContext, { + agent: guardedMode, + allowMutation: input.allowMutation === true, + yes: context.capabilities.yes, + operationName: input.operationName, + signal: context.signal, + timeoutMs: input.timeoutMs, + fetch: dependencies.fetch, + now: context.now(), + }); + } catch (error) { + if (error instanceof ConfigStoreError) { + throw mapConfigurationError(error); + } + if (error instanceof GraphQLExecutionError) { + throw mapExecutionError(error); + } + throw error; + } + + if (!result.ok) { + if ( + result.error?.code === 'GRAPHQL_CANCELLED' && + context.signal.aborted + ) { + throw ( + context.signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + } + throw graphQLFailure( + result.error ?? { + code: 'GRAPHQL_RESPONSE_ERROR', + category: 'graphql', + message: 'GraphQL operation failed.', + retryable: false, + }, + { + data: result.data, + errors: result.errors, + status: result.status, + } + ); + } + + return { + data: { + contextName: executionContext.context.name, + endpoint: executionContext.context.endpoint, + anonymous: executionContext.anonymous, + operation: { + type: analysis.type, + ...(analysis.name === undefined ? {} : { name: analysis.name }), + }, + ...(result.status === undefined ? {} : { status: result.status }), + data: result.data, + }, + }; + }, + }); +} diff --git a/packages/cli/src/runtime/index.ts b/packages/cli/src/runtime/index.ts new file mode 100644 index 0000000000..750bc2b62e --- /dev/null +++ b/packages/cli/src/runtime/index.ts @@ -0,0 +1,14 @@ +export * from './codegen-command'; +export * from './discovery-commands'; +export * from './execute-command'; +export * from './optional-capability'; +export * from './registry'; +export * from './service-commands'; +export * from './service-hooks'; +export * from './state-commands'; +export { + ConfigStore, + createConfigStoreForEnvironment, + getConfigDirForEnvironment, + type ConfigStoreOptions, +} from '../config'; diff --git a/packages/cli/src/runtime/optional-capability.ts b/packages/cli/src/runtime/optional-capability.ts new file mode 100644 index 0000000000..f928b164a4 --- /dev/null +++ b/packages/cli/src/runtime/optional-capability.ts @@ -0,0 +1,33 @@ +import { CliError } from '@constructive-io/cli-runtime'; + +const missingPackage = (error: unknown, packageName: string): boolean => { + if (error === null || typeof error !== 'object') return false; + const code = (error as { code?: unknown }).code; + const message = (error as { message?: unknown }).message; + return ( + (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') && + typeof message === 'string' && + message.includes(packageName) + ); +}; + +/** Load an optional feature package without turning omission into an internal error. */ +export const importOptionalCapability = async ( + capability: string, + packageName: string, + importer: () => Promise +): Promise => { + try { + return await importer(); + } catch (error) { + if (!missingPackage(error, packageName)) throw error; + throw new CliError({ + code: 'CAPABILITY_UNAVAILABLE', + category: 'configuration', + message: `The ${capability} capability is unavailable because optional package '${packageName}' is not installed.`, + details: { capability, packageName }, + retryable: false, + cause: error, + }); + } +}; diff --git a/packages/cli/src/runtime/registry.ts b/packages/cli/src/runtime/registry.ts new file mode 100644 index 0000000000..ec0a7e5cb2 --- /dev/null +++ b/packages/cli/src/runtime/registry.ts @@ -0,0 +1,229 @@ +import { + CliError, + createCommandRegistry, + defineCommand, + Type, + type CommandAdapterHookMap, + type CommandRegistry, +} from '@constructive-io/cli-runtime'; +import type { Inquirerer } from 'inquirerer'; + +import { + ConfigStore, + ConfigStoreError, + createConfigStoreForEnvironment, +} from '../config'; +import { createCodegenHooks, codegenCommand } from './codegen-command'; +import { + createDiscoveryCommands, + createDiscoveryHooks, +} from './discovery-commands'; +import { createExecuteCommandDefinition } from './execute-command'; +import { serviceCommands } from './service-commands'; +import { createStateCommands } from './state-commands'; + +export interface CncRegistryOptions { + version: string; + store: ConfigStore; +} + +export interface CncEnvironmentRegistryOptions { + version: string; + env: Readonly>; + /** Explicit state location for embeddings without a home-directory model. */ + configDir?: string; +} + +export interface CncRegistryBundle { + registry: CommandRegistry; + createHooks(prompter: Inquirerer): CommandAdapterHookMap; +} + +export const createCncRegistry = ({ + version, + store, +}: CncRegistryOptions): CncRegistryBundle => { + let registry: CommandRegistry; + const versionCommand = defineCommand({ + id: 'discovery.version', + path: ['version'], + summary: 'Print the CNC package and machine-protocol versions.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Object( + { + version: Type.String(), + protocolVersion: Type.Literal('constructive.dev/cli/v1'), + }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['version'] }], + lifecycle: 'finite', + effect: 'read', + async execute() { + return { + data: { + version, + protocolVersion: 'constructive.dev/cli/v1' as const, + }, + }; + }, + }); + const discoveryCommands = createDiscoveryCommands({ + getRegistry: () => registry, + toolVersion: version, + }); + registry = createCommandRegistry([ + ...createStateCommands({ store }), + createExecuteCommandDefinition({ store }), + codegenCommand, + ...serviceCommands, + versionCommand, + ...discoveryCommands, + ]); + + return { + registry, + createHooks(prompter) { + const collectHumanContext = async ( + input: unknown, + context: { + env: Readonly>; + } + ): Promise> => { + const candidate = input as Record; + if ( + (typeof candidate.contextName === 'string' && + candidate.contextName.trim().length > 0) || + context.env.CNC_CONTEXT?.trim() + ) { + return candidate; + } + + let state: ReturnType; + try { + state = store.read(); + } catch (error) { + if (error instanceof ConfigStoreError) { + throw new CliError({ + code: error.code, + category: 'configuration', + message: error.message, + details: error.details, + retryable: error.code === 'CONFIG_LOCK_TIMEOUT', + cause: error, + }); + } + throw error; + } + if (state.settings.currentContext !== undefined) return candidate; + + const contextNames = Object.keys(state.contexts).sort(); + if (contextNames.length === 0) { + throw new CliError({ + code: 'CONTEXT_REQUIRED', + category: 'configuration', + message: + 'No contexts are configured. Create a context before configuring authentication.', + retryable: false, + }); + } + + const answer = await prompter.prompt(candidate, [ + { + type: 'autocomplete', + name: 'contextName', + message: 'Select context', + options: contextNames, + required: true, + }, + ]); + return answer as Record; + }; + + return { + ...createDiscoveryHooks(), + ...createCodegenHooks(prompter), + 'discovery.version': { + renderHuman: (result) => (result.data as { version: string }).version, + }, + 'context.list': { + renderHuman: (result) => { + const data = result.data as { + contexts: Array<{ + name: string; + endpoint: string; + current: boolean; + authentication: string; + }>; + }; + if (data.contexts.length === 0) return 'No contexts configured.'; + return data.contexts + .map( + ({ name, endpoint, current, authentication }) => + `${current ? '* ' : ' '}${name}\t${endpoint}\t${authentication}` + ) + .join('\n'); + }, + }, + 'context.current': { + renderHuman: (result) => { + const { context } = result.data as { + context: { name: string; endpoint: string } | null; + }; + return context + ? `${context.name}\t${context.endpoint}` + : 'No current context.'; + }, + }, + 'auth.set-token': { + collectInteractiveInput: async (input, context) => { + const candidate = await collectHumanContext(input, context); + if ( + candidate.legacyValue !== undefined || + candidate.stdinValue !== undefined || + candidate.environmentValue !== undefined + ) { + return candidate as never; + } + const answer = await prompter.prompt(candidate, [ + { + type: 'password', + name: 'stdinValue', + message: 'API token', + required: true, + }, + ]); + return answer as never; + }, + }, + 'auth.logout': { + collectInteractiveInput: async (input, context) => + (await collectHumanContext(input, context)) as never, + }, + execute: { + renderHuman: (result) => + JSON.stringify((result.data as { data: unknown }).data, null, 2), + }, + }; + }, + }; +}; + +/** + * Create the complete reusable CNC registry from an explicit environment + * snapshot. This keeps in-process consumers off ambient process state and + * avoids requiring knowledge of the state-store implementation. + */ +export const createCncRegistryForEnvironment = ({ + version, + env, + configDir, +}: CncEnvironmentRegistryOptions): CncRegistryBundle => + createCncRegistry({ + version, + store: + configDir === undefined + ? createConfigStoreForEnvironment(env) + : new ConfigStore({ configDir }), + }); diff --git a/packages/cli/src/runtime/service-commands.ts b/packages/cli/src/runtime/service-commands.ts new file mode 100644 index 0000000000..56a3599551 --- /dev/null +++ b/packages/cli/src/runtime/service-commands.ts @@ -0,0 +1,911 @@ +import { stat } from 'node:fs/promises'; +import type { Server as HttpServer } from 'node:http'; +import { resolve } from 'node:path'; + +import { + CliError, + defineCommand, + isSensitiveKey, + Type, + type Static, + type OperationContext, +} from '@constructive-io/cli-runtime'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { withLogsSuppressed } from '@pgpmjs/logger'; + +import { withConsoleSuppressed } from '../console-isolation'; +import { importOptionalCapability } from './optional-capability'; + +type FunctionName = 'send-email' | 'send-verification-link'; + +interface FunctionServiceConfig { + name: FunctionName; + port?: number; +} + +interface KnativeJobsSvcOptions { + functions?: { + enabled?: boolean; + services?: FunctionServiceConfig[]; + }; + jobs?: { enabled?: boolean }; + runtime?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + }; +} + +const ServiceEventSchema = Type.Union([ + Type.Object( + { + event: Type.Literal('service.starting'), + service: Type.String(), + }, + { additionalProperties: false } + ), + Type.Object( + { + event: Type.Literal('service.ready'), + service: Type.String(), + url: Type.Optional(Type.String()), + port: Type.Optional(Type.Integer({ minimum: 0, maximum: 65535 })), + }, + { additionalProperties: false } + ), + Type.Object( + { + event: Type.Literal('service.stopping'), + service: Type.String(), + }, + { additionalProperties: false } + ), + Type.Object( + { + event: Type.Literal('service.stopped'), + service: Type.String(), + }, + { additionalProperties: false } + ), +]); + +const suppressOperationOutput = (callback: () => Promise): Promise => + withConsoleSuppressed(() => withLogsSuppressed(callback)); + +type ServiceEvent = Static; + +const ServiceOutputSchema = Type.Object( + { + service: Type.String(), + status: Type.Literal('stopped'), + url: Type.Optional(Type.String()), + port: Type.Optional(Type.Integer({ minimum: 0, maximum: 65535 })), + }, + { additionalProperties: false } +); + +const booleanOption = (name: string, deprecatedAlias?: string) => ({ + kind: 'option' as const, + name, + ...(deprecatedAlias ? { deprecatedAliases: [deprecatedAlias] } : {}), + negatable: true, +}); + +const toProcessEnv = ( + env: Readonly> +): NodeJS.ProcessEnv => ({ ...env }); + +/** Register config-file secrets before dependency errors can enter the protocol. */ +const registerResolvedConfigSecrets = ( + value: unknown, + context: Pick +): void => { + const ancestors = new WeakSet(); + const visit = ( + candidate: unknown, + parentKey?: string, + inheritedSensitive = false + ): void => { + const sensitive = + inheritedSensitive || + (parentKey !== undefined && + (isSensitiveKey(parentKey) || parentKey.toLowerCase() === 'pass')); + if (typeof candidate === 'string') { + if (sensitive && candidate.length > 0) { + context.registerSensitiveValue(candidate); + } + return; + } + if (candidate === null || typeof candidate !== 'object') return; + if (ancestors.has(candidate)) return; + ancestors.add(candidate); + for (const key of Object.keys(candidate)) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor && 'value' in descriptor) { + visit(descriptor.value, key, sensitive); + } + } + ancestors.delete(candidate); + }; + + visit(value); +}; + +const serverAddress = ( + server: HttpServer, + fallbackHost: string +): { url: string; port: number } => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const rawHost = + typeof address === 'object' && address && address.address + ? address.address + : fallbackHost; + const host = + rawHost === '::' || rawHost === '[::]' || rawHost === '0.0.0.0' + ? 'localhost' + : rawHost; + const formattedHost = host.includes(':') ? `[${host}]` : host; + return { url: `http://${formattedHost}:${port}`, port }; +}; + +const waitForAbortOrClose = async ( + signal: AbortSignal, + server?: HttpServer +): Promise => + new Promise((resolveWait, rejectWait) => { + const cleanup = () => { + signal.removeEventListener('abort', handleAbort); + server?.off('close', handleClose); + }; + const handleAbort = () => { + cleanup(); + rejectWait( + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + }; + const handleClose = () => { + cleanup(); + resolveWait(); + }; + + if (signal.aborted) { + handleAbort(); + return; + } + signal.addEventListener('abort', handleAbort, { once: true }); + server?.once('close', handleClose); + }); + +const raceWithAbort = async ( + signal: AbortSignal, + operation: Promise +): Promise => { + if (signal.aborted) { + throw ( + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + } + + let handleAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + handleAbort = () => + reject( + signal.reason ?? + new DOMException('The operation was cancelled.', 'AbortError') + ); + signal.addEventListener('abort', handleAbort, { once: true }); + }); + + try { + return await Promise.race([operation, aborted]); + } finally { + if (handleAbort) signal.removeEventListener('abort', handleAbort); + } +}; + +const serviceStartError = (service: string, error: unknown): CliError => { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return new CliError({ + code: + code === 'EADDRINUSE' ? 'SERVICE_PORT_IN_USE' : 'SERVICE_START_FAILED', + category: 'operation', + message: + code === 'EADDRINUSE' + ? `${service} could not start because its port is already in use.` + : `${service} failed to start.`, + details: code ? { systemCode: code } : undefined, + retryable: code === 'EADDRINUSE' || code === 'ECONNREFUSED', + cause: error, + }); +}; + +const emit = (context: OperationContext, event: ServiceEvent) => + context.events.emit(event); + +/** + * Finish a service lifecycle without allowing an event publication failure to + * skip resource cleanup. When the operation is already failing, preserve that + * primary error after making a best effort to stop the service. Otherwise the + * first lifecycle or cleanup failure becomes the operation failure. + */ +const finalizeService = async ( + context: OperationContext, + service: string, + cleanup: () => Promise, + primaryError: unknown +): Promise => { + let finalizationError: unknown; + try { + await emit(context, { event: 'service.stopping', service }); + } catch (error) { + finalizationError = error; + } + + let cleanedUp = false; + try { + await cleanup(); + cleanedUp = true; + } catch (error) { + finalizationError ??= error; + } + + if (cleanedUp) { + try { + await emit(context, { event: 'service.stopped', service }); + } catch (error) { + finalizationError ??= error; + } + } + + if (primaryError === undefined && finalizationError !== undefined) { + throw finalizationError; + } +}; + +const ServerInputSchema = Type.Object( + { + database: Type.Optional(Type.String({ minLength: 1 })), + host: Type.Optional(Type.String({ minLength: 1 })), + port: Type.Optional(Type.Integer({ minimum: 0, maximum: 65535 })), + origin: Type.Optional(Type.String({ minLength: 1 })), + simpleInflection: Type.Optional(Type.Boolean()), + oppositeBaseNames: Type.Optional(Type.Boolean()), + postgis: Type.Optional(Type.Boolean()), + servicesApi: Type.Optional(Type.Boolean()), + schemas: Type.Optional(Type.String()), + authRole: Type.Optional(Type.String({ minLength: 1 })), + roleName: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false } +); + +type ServerInput = Static; + +const buildServerOverrides = ( + input: ServerInput +): Partial => ({ + ...(input.database === undefined ? {} : { pg: { database: input.database } }), + features: { + simpleInflection: input.simpleInflection ?? true, + oppositeBaseNames: input.oppositeBaseNames ?? false, + postgis: input.postgis ?? true, + }, + api: { + enableServicesApi: input.servicesApi ?? true, + ...(input.schemas === undefined + ? {} + : { + exposedSchemas: input.schemas + .split(',') + .map((schema) => schema.trim()) + .filter(Boolean), + }), + ...(input.authRole === undefined ? {} : { anonRole: input.authRole }), + ...(input.roleName === undefined ? {} : { roleName: input.roleName }), + }, + server: { + ...(input.host === undefined ? {} : { host: input.host }), + port: input.port ?? 5555, + ...(input.origin === undefined ? {} : { origin: input.origin }), + }, +}); + +export const serverCommand = defineCommand({ + id: 'server.start', + path: ['server'], + summary: 'Start the Constructive GraphQL server.', + input: ServerInputSchema, + output: ServiceOutputSchema, + events: ServiceEventSchema, + bindings: [ + { + property: 'database', + sources: [ + { kind: 'option', name: 'database' }, + { kind: 'environment', name: 'PGDATABASE' }, + ], + conflict: 'first', + description: 'PostgreSQL database name.', + }, + { + property: 'host', + sources: [{ kind: 'option', name: 'host' }], + description: 'HTTP listener host.', + }, + { + property: 'port', + sources: [{ kind: 'option', name: 'port' }], + valueType: 'number', + description: 'HTTP listener port.', + }, + { + property: 'origin', + sources: [{ kind: 'option', name: 'origin' }], + description: 'CORS origin override.', + }, + { + property: 'simpleInflection', + sources: [booleanOption('simple-inflection', 'simpleInflection')], + valueType: 'boolean', + description: 'Enable simple inflection.', + }, + { + property: 'oppositeBaseNames', + sources: [booleanOption('opposite-base-names', 'oppositeBaseNames')], + valueType: 'boolean', + description: 'Enable opposite base names.', + }, + { + property: 'postgis', + sources: [booleanOption('postgis')], + valueType: 'boolean', + description: 'Enable PostGIS support.', + }, + { + property: 'servicesApi', + sources: [booleanOption('services-api', 'servicesApi')], + valueType: 'boolean', + description: 'Enable the Services API.', + }, + { + property: 'schemas', + sources: [{ kind: 'option', name: 'schemas' }], + description: 'Comma-separated schemas when Services API is disabled.', + }, + { + property: 'authRole', + sources: [ + { kind: 'option', name: 'auth-role', deprecatedAliases: ['authRole'] }, + ], + description: 'Authentication role.', + }, + { + property: 'roleName', + sources: [ + { kind: 'option', name: 'role-name', deprecatedAliases: ['roleName'] }, + ], + description: 'Default role name.', + }, + ], + examples: [ + { argv: ['server', '--database', 'app', '--port', '5555'] }, + { argv: ['server', '--no-postgis'] }, + ], + lifecycle: 'long-running', + effect: 'service', + async execute(input, context) { + return suppressOperationOutput(async () => { + const [ + { getEnvOptions }, + { Server: GraphQLServer, withServerEnvironment }, + ] = await Promise.all([ + importOptionalCapability( + 'server', + '@constructive-io/graphql-env', + () => import('@constructive-io/graphql-env') + ), + importOptionalCapability( + 'server', + '@constructive-io/graphql-server', + () => import('@constructive-io/graphql-server') + ), + ]); + + return withServerEnvironment(context.env, async () => { + const options = getEnvOptions( + buildServerOverrides(input), + context.cwd, + toProcessEnv(context.env) + ); + registerResolvedConfigSecrets(options, context); + if ( + input.database === undefined && + !context.env.PGDATABASE && + options.pg.database === 'postgres' + ) { + throw new CliError({ + code: 'SERVER_DATABASE_REQUIRED', + category: 'configuration', + message: + 'Select an application database with --database, PGDATABASE, or pg.database in the project config.', + path: '/database', + details: { + acceptedSources: ['--database', 'PGDATABASE', 'pg.database'], + }, + }); + } + if ( + options.api.enableServicesApi === false && + (options.api.exposedSchemas?.length ?? 0) === 0 + ) { + throw new CliError({ + code: 'SERVER_SCHEMAS_REQUIRED', + category: 'configuration', + message: + 'At least one exposed schema is required when the Services API is disabled.', + path: '/schemas', + }); + } + const instance = new GraphQLServer(options, { + cwd: context.cwd, + env: toProcessEnv(context.env), + }); + let httpServer: HttpServer | undefined; + let address: { url: string; port: number } | undefined; + let primaryError: unknown; + try { + await emit(context, { + event: 'service.starting', + service: 'graphql', + }); + try { + httpServer = await raceWithAbort( + context.signal, + instance.start(context.signal) + ); + } catch (error) { + if (context.signal.aborted) throw error; + throw serviceStartError('GraphQL server', error); + } + address = serverAddress( + httpServer, + options.server.host || 'localhost' + ); + await emit(context, { + event: 'service.ready', + service: 'graphql', + url: address.url, + port: address.port, + }); + try { + await Promise.race([ + waitForAbortOrClose(context.signal, httpServer), + instance.waitForFailure(), + ]); + } catch (error) { + if (context.signal.aborted) throw error; + throw serviceStartError('GraphQL server', error); + } + } catch (error) { + primaryError = error; + throw error; + } finally { + await finalizeService( + context, + 'graphql', + () => instance.close({ closeCaches: true }), + primaryError + ); + } + return { + data: { + service: 'graphql', + status: 'stopped' as const, + ...(address ?? {}), + }, + }; + }); + }); + }, +}); + +const ExplorerInputSchema = Type.Object( + { + host: Type.Optional(Type.String({ minLength: 1 })), + port: Type.Optional(Type.Integer({ minimum: 0, maximum: 65535 })), + origin: Type.Optional(Type.String({ minLength: 1 })), + simpleInflection: Type.Optional(Type.Boolean()), + oppositeBaseNames: Type.Optional(Type.Boolean()), + postgis: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false } +); + +export const explorerCommand = defineCommand({ + id: 'explorer.start', + path: ['explorer'], + summary: 'Start the Constructive GraphQL explorer.', + input: ExplorerInputSchema, + output: ServiceOutputSchema, + events: ServiceEventSchema, + bindings: [ + { + property: 'host', + sources: [{ kind: 'option', name: 'host' }], + description: 'HTTP listener host.', + }, + { + property: 'port', + sources: [{ kind: 'option', name: 'port' }], + valueType: 'number', + description: 'HTTP listener port.', + }, + { + property: 'origin', + sources: [{ kind: 'option', name: 'origin' }], + description: 'CORS origin.', + }, + { + property: 'simpleInflection', + sources: [booleanOption('simple-inflection', 'simpleInflection')], + valueType: 'boolean', + description: 'Enable simple inflection.', + }, + { + property: 'oppositeBaseNames', + sources: [booleanOption('opposite-base-names', 'oppositeBaseNames')], + valueType: 'boolean', + description: 'Enable opposite base names.', + }, + { + property: 'postgis', + sources: [booleanOption('postgis')], + valueType: 'boolean', + description: 'Enable PostGIS support.', + }, + ], + examples: [{ argv: ['explorer', '--port', '5555'] }], + lifecycle: 'long-running', + effect: 'service', + async execute(input, context) { + return suppressOperationOutput(async () => { + const [{ getEnvOptions }, { startGraphQLExplorer }] = await Promise.all([ + importOptionalCapability( + 'explorer', + '@constructive-io/graphql-env', + () => import('@constructive-io/graphql-env') + ), + importOptionalCapability( + 'explorer', + '@constructive-io/graphql-explorer', + () => import('@constructive-io/graphql-explorer') + ), + ]); + const options = getEnvOptions( + { + features: { + simpleInflection: input.simpleInflection ?? true, + oppositeBaseNames: input.oppositeBaseNames ?? false, + postgis: input.postgis ?? true, + }, + server: { + ...(input.host === undefined ? {} : { host: input.host }), + port: input.port ?? 5555, + origin: input.origin ?? 'http://localhost:3000', + }, + }, + context.cwd, + toProcessEnv(context.env) + ); + registerResolvedConfigSecrets(options, context); + let handle: Awaited> | undefined; + let primaryError: unknown; + try { + await emit(context, { + event: 'service.starting', + service: 'explorer', + }); + try { + handle = await raceWithAbort( + context.signal, + startGraphQLExplorer(options, { + cwd: context.cwd, + env: toProcessEnv(context.env), + signal: context.signal, + onError: () => undefined, + }) + ); + } catch (error) { + if (context.signal.aborted) throw error; + throw serviceStartError('GraphQL explorer', error); + } + const address = serverAddress( + handle.httpServer, + options.server.host || 'localhost' + ); + await emit(context, { + event: 'service.ready', + service: 'explorer', + url: address.url, + port: address.port, + }); + try { + await Promise.race([ + waitForAbortOrClose(context.signal, handle.httpServer), + handle.waitForFailure(), + ]); + } catch (error) { + if (context.signal.aborted) throw error; + throw serviceStartError('GraphQL explorer', error); + } + return { + data: { + service: 'explorer', + status: 'stopped' as const, + url: address.url, + port: address.port, + }, + }; + } catch (error) { + primaryError = error; + throw error; + } finally { + await finalizeService( + context, + 'explorer', + async () => handle?.close(), + primaryError + ); + } + }); + }, +}); + +interface ParsedFunctions { + mode: 'all' | 'list'; + services: FunctionServiceConfig[]; +} + +const SUPPORTED_FUNCTIONS = new Set([ + 'send-email', + 'send-verification-link', +]); + +export const parseFunctions = (value?: string): ParsedFunctions | undefined => { + if (value === undefined) return undefined; + const tokens = value + .split(',') + .map((token) => token.trim()) + .filter(Boolean); + if (tokens.length === 0) return { mode: 'list', services: [] }; + if (tokens.some((token) => ['all', '*'].includes(token.toLowerCase()))) { + if (tokens.length !== 1) { + throw new CliError({ + code: 'JOBS_FUNCTIONS_INVALID', + category: 'validation', + message: 'Use "all" without other function names.', + }); + } + return { mode: 'all', services: [] }; + } + + const services = new Map(); + for (const token of tokens) { + const [rawName, rawPort, ...surplus] = token.split(/[:=]/); + const name = rawName?.trim(); + if (!name || surplus.length > 0) { + throw new CliError({ + code: 'JOBS_FUNCTIONS_INVALID', + category: 'validation', + message: `Invalid function declaration "${token}".`, + }); + } + if (!SUPPORTED_FUNCTIONS.has(name as FunctionName)) { + throw new CliError({ + code: 'JOBS_FUNCTION_UNKNOWN', + category: 'validation', + message: `Unknown function "${name}".`, + path: '/functions', + details: { supported: [...SUPPORTED_FUNCTIONS] }, + }); + } + if (services.has(name)) { + throw new CliError({ + code: 'JOBS_FUNCTION_DUPLICATE', + category: 'validation', + message: `Function "${name}" is declared more than once.`, + path: '/functions', + }); + } + let port: number | undefined; + if (rawPort !== undefined) { + port = Number(rawPort.trim()); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new CliError({ + code: 'JOBS_FUNCTION_PORT_INVALID', + category: 'validation', + message: `Function "${name}" has an invalid port.`, + path: '/functions', + }); + } + } + services.set(name, { + name: name as FunctionName, + ...(port === undefined ? {} : { port }), + }); + } + return { mode: 'list', services: [...services.values()] }; +}; + +const JobsInputSchema = Type.Object( + { + withJobsServer: Type.Optional(Type.Boolean()), + functions: Type.Optional(Type.String()), + }, + { additionalProperties: false } +); + +const JobsOutputSchema = Type.Object( + { + service: Type.Literal('jobs'), + status: Type.Literal('stopped'), + jobs: Type.Boolean(), + jobsPort: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535 })), + functions: Type.Array( + Type.Object( + { + name: Type.String(), + port: Type.Integer({ minimum: 1, maximum: 65535 }), + }, + { additionalProperties: false } + ) + ), + }, + { additionalProperties: false } +); + +export const jobsCommand = defineCommand({ + id: 'jobs.up', + path: ['jobs', 'up'], + summary: 'Start Constructive jobs and function services.', + input: JobsInputSchema, + output: JobsOutputSchema, + events: ServiceEventSchema, + bindings: [ + { + property: 'withJobsServer', + sources: [booleanOption('with-jobs-server', 'withJobsServer')], + valueType: 'boolean', + description: 'Enable the jobs callback server, worker, and scheduler.', + }, + { + property: 'functions', + sources: [{ kind: 'option', name: 'functions' }], + description: 'Comma-separated function names, optionally with ports.', + }, + ], + examples: [ + { argv: ['jobs', 'up', '--with-jobs-server'] }, + { argv: ['jobs', 'up', '--functions', 'send-email=8081'] }, + ], + lifecycle: 'long-running', + effect: 'service', + async execute(input, context) { + return suppressOperationOutput(async () => { + const { KnativeJobsSvc } = await importOptionalCapability( + 'jobs', + '@constructive-io/knative-job-service', + () => import('@constructive-io/knative-job-service') + ); + const cwd = resolve(context.cwd); + const cwdStat = await stat(cwd).catch((): undefined => undefined); + if (!cwdStat?.isDirectory()) { + throw new CliError({ + code: 'CWD_NOT_FOUND', + category: 'configuration', + message: `Working directory does not exist: ${cwd}`, + path: '/cwd', + }); + } + + const parsedFunctions = parseFunctions(input.functions); + const options: KnativeJobsSvcOptions = { + jobs: { enabled: input.withJobsServer === true }, + ...(parsedFunctions === undefined + ? {} + : parsedFunctions.mode === 'all' + ? { functions: { enabled: true } } + : parsedFunctions.services.length > 0 + ? { + functions: { + enabled: true, + services: parsedFunctions.services, + }, + } + : {}), + runtime: { + cwd, + env: toProcessEnv(context.env), + signal: context.signal, + }, + }; + + if (!options.jobs?.enabled && !options.functions?.enabled) { + throw new CliError({ + code: 'JOBS_NO_SERVICES_ENABLED', + category: 'validation', + message: + 'Enable --with-jobs-server, provide --functions, or do both.', + }); + } + + const service = new KnativeJobsSvc(options); + let primaryError: unknown; + try { + await emit(context, { event: 'service.starting', service: 'jobs' }); + let result: Awaited>; + try { + result = await raceWithAbort(context.signal, service.start()); + } catch (error) { + if (context.signal.aborted) throw error; + throw serviceStartError('Jobs runtime', error); + } + if (result.jobs && result.jobsPort) { + await emit(context, { + event: 'service.ready', + service: 'jobs', + url: `http://localhost:${result.jobsPort}`, + port: result.jobsPort, + }); + } + for (const fn of result.functions) { + await emit(context, { + event: 'service.ready', + service: `function:${fn.name}`, + url: `http://localhost:${fn.port}`, + port: fn.port, + }); + } + try { + await Promise.race([ + waitForAbortOrClose(context.signal), + service.waitForFailure(), + ]); + } catch (error) { + if (context.signal.aborted) throw error; + throw serviceStartError('Jobs runtime', error); + } + return { + data: { + service: 'jobs' as const, + status: 'stopped' as const, + jobs: result.jobs, + ...(result.jobsPort === undefined + ? {} + : { jobsPort: result.jobsPort }), + functions: result.functions, + }, + }; + } catch (error) { + primaryError = error; + throw error; + } finally { + await finalizeService( + context, + 'jobs', + () => service.stop(), + primaryError + ); + } + }); + }, +}); + +export const serviceCommands = [ + serverCommand, + explorerCommand, + jobsCommand, +] as const; diff --git a/packages/cli/src/runtime/service-hooks.ts b/packages/cli/src/runtime/service-hooks.ts new file mode 100644 index 0000000000..6e10a44d02 --- /dev/null +++ b/packages/cli/src/runtime/service-hooks.ts @@ -0,0 +1,226 @@ +import type { CommandAdapterHookMap } from '@constructive-io/cli-runtime'; +import type { Inquirerer, OptionValue, Question } from 'inquirerer'; + +import { importOptionalCapability } from './optional-capability'; + +const loadServicePromptDependencies = async () => + Promise.all([ + importOptionalCapability( + 'service prompts', + '@constructive-io/graphql-env', + () => import('@constructive-io/graphql-env') + ), + importOptionalCapability( + 'service prompts', + 'pg-cache', + () => import('pg-cache') + ), + ]); + +const serverQuestions: Question[] = [ + { + name: 'simpleInflection', + message: 'Use simple inflection?', + type: 'confirm', + required: false, + default: true, + useDefault: true, + }, + { + name: 'oppositeBaseNames', + message: 'Use opposite base names?', + type: 'confirm', + required: false, + default: false, + useDefault: true, + }, + { + name: 'postgis', + message: 'Enable PostGIS extension?', + type: 'confirm', + required: false, + default: true, + useDefault: true, + }, + { + name: 'servicesApi', + message: 'Enable Services API?', + type: 'confirm', + required: false, + default: true, + useDefault: true, + }, + { + name: 'origin', + message: 'CORS origin (exact URL or *)', + type: 'text', + required: false, + }, + { + name: 'port', + message: 'Development server port', + type: 'number', + required: false, + default: 5555, + useDefault: true, + }, +]; + +const explorerQuestions: Question[] = [ + ...serverQuestions.filter( + ({ name }) => name !== 'servicesApi' && name !== 'origin' + ), + { + name: 'origin', + message: 'CORS origin URL', + type: 'text', + required: false, + default: 'http://localhost:3000', + useDefault: true, + }, +]; + +const listDatabases = async ( + cwd: string, + env: Readonly> +): Promise => { + const [{ getEnvOptions }, { getPgPool, PgPoolCacheManager }] = + await loadServicePromptDependencies(); + const cache = new PgPoolCacheManager(undefined, env); + try { + const options = getEnvOptions({ pg: { database: 'postgres' } }, cwd, { + ...env, + }); + const result = await getPgPool(options.pg, { + cache, + environment: env, + }).query<{ datname: string }>(` + SELECT datname FROM pg_database + WHERE datistemplate = false AND datname NOT IN ('postgres') + AND datname !~ '^pg_' + ORDER BY datname; + `); + return result.rows.map(({ datname }) => datname); + } finally { + await cache.close(); + } +}; + +const listSchemas = async ( + database: string, + cwd: string, + env: Readonly> +): Promise => { + const [{ getEnvOptions }, { getPgPool, PgPoolCacheManager }] = + await loadServicePromptDependencies(); + const cache = new PgPoolCacheManager(undefined, env); + try { + const options = getEnvOptions({ pg: { database } }, cwd, { ...env }); + const result = await getPgPool(options.pg, { + cache, + environment: env, + }).query<{ nspname: string }>(` + SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY nspname; + `); + return result.rows.map(({ nspname }) => nspname); + } finally { + await cache.close(); + } +}; + +export const createServiceHooks = ( + prompter: Inquirerer +): CommandAdapterHookMap => ({ + 'server.start': { + collectInteractiveInput: async (input, context) => { + const [{ getEnvOptions }] = await loadServicePromptDependencies(); + let candidate = { ...(input as Record) }; + if (candidate.database === undefined) { + const configured = getEnvOptions({}, context.cwd, { + ...context.env, + }).pg.database; + if (context.env.PGDATABASE || configured !== 'postgres') { + candidate.database = context.env.PGDATABASE ?? configured; + } else { + const databases = await listDatabases(context.cwd, context.env); + candidate = await prompter.prompt(candidate, [ + { + type: 'autocomplete', + name: 'database', + message: 'Select the database to use', + options: databases, + required: true, + }, + ]); + } + } + + candidate = await prompter.prompt(candidate, serverQuestions); + if (candidate.servicesApi === false) { + if (candidate.schemas === undefined) { + const schemas = await listSchemas( + String(candidate.database), + context.cwd, + context.env + ); + const answer = await prompter.prompt(candidate, [ + { + type: 'checkbox', + name: 'schemas', + message: 'Select schemas to expose', + options: schemas.map((schema) => ({ + name: schema, + value: schema, + selected: true, + })), + required: true, + }, + ]); + candidate.schemas = (answer.schemas as OptionValue[]) + .filter(({ selected }) => selected) + .map(({ value }) => value) + .join(','); + } + candidate = await prompter.prompt(candidate, [ + { + type: 'autocomplete', + name: 'authRole', + message: 'Select the authentication role', + options: ['postgres', 'authenticated', 'anonymous'], + required: true, + }, + { + type: 'autocomplete', + name: 'roleName', + message: 'Enter the default role name', + options: ['postgres', 'authenticated', 'anonymous'], + required: true, + }, + ]); + } + return candidate as never; + }, + }, + 'explorer.start': { + collectInteractiveInput: async (input) => + (await prompter.prompt( + input as Record, + explorerQuestions + )) as never, + }, + 'jobs.up': { + collectInteractiveInput: async (input) => + (await prompter.prompt(input as Record, [ + { + name: 'withJobsServer', + message: 'Enable jobs server?', + type: 'confirm', + required: false, + default: false, + useDefault: true, + }, + ])) as never, + }, +}); diff --git a/packages/cli/src/runtime/state-commands.ts b/packages/cli/src/runtime/state-commands.ts new file mode 100644 index 0000000000..f502a07b02 --- /dev/null +++ b/packages/cli/src/runtime/state-commands.ts @@ -0,0 +1,655 @@ +import { + CliError, + defineCommand, + Type, + type OperationContext, + type OperationWarning, +} from '@constructive-io/cli-runtime'; + +import { + ConfigStoreError, + TokenSourceError, + createContextAndMaybeActivate, + deleteContext, + getContextCredentials, + getCurrentContext, + listContexts, + removeContextCredentials, + resolveContext, + resolveToken, + setContextCredentials, + setCurrentContext, + type ConfigStore, + type ContextConfig, + type ContextCredentials, +} from '../config'; + +const ContextSchema = Type.Object( + { + name: Type.String({ minLength: 1 }), + endpoint: Type.String({ minLength: 1 }), + createdAt: Type.String({ minLength: 1 }), + updatedAt: Type.String({ minLength: 1 }), + }, + { additionalProperties: false } +); + +const AuthenticationStatusSchema = Type.Union([ + Type.Literal('authenticated'), + Type.Literal('expired'), + Type.Literal('missing'), +]); + +const ContextSummarySchema = Type.Object( + { + name: Type.String({ minLength: 1 }), + endpoint: Type.String({ minLength: 1 }), + createdAt: Type.String({ minLength: 1 }), + updatedAt: Type.String({ minLength: 1 }), + current: Type.Boolean(), + authentication: AuthenticationStatusSchema, + expiresAt: Type.Optional(Type.String()), + }, + { additionalProperties: false } +); + +const EmptyInputSchema = Type.Object({}, { additionalProperties: false }); + +export interface StateCommandDependencies { + /** Inject a store for embedding and isolated tests. */ + store: ConfigStore; +} + +const getStore = (dependencies: StateCommandDependencies): ConfigStore => + dependencies.store; + +const configErrorCategory = ( + code: ConfigStoreError['code'] +): 'validation' | 'configuration' | 'conflict' => { + if (code === 'CONTEXT_NAME_INVALID' || code === 'CONTEXT_ENDPOINT_INVALID') { + return 'validation'; + } + if (code === 'CONFIG_LOCK_TIMEOUT') return 'conflict'; + return 'configuration'; +}; + +const throwStateError = (error: unknown): never => { + if (error instanceof CliError) throw error; + if (error instanceof ConfigStoreError) { + throw new CliError({ + code: error.code, + category: configErrorCategory(error.code), + message: error.message, + details: error.details, + retryable: error.code === 'CONFIG_LOCK_TIMEOUT', + cause: error, + }); + } + if (error instanceof TokenSourceError) { + throw new CliError({ + code: error.code, + category: 'invocation', + message: error.message, + retryable: false, + cause: error, + }); + } + throw error; +}; + +const runStateOperation = (operation: () => T): T => { + try { + return operation(); + } catch (error) { + return throwStateError(error); + } +}; + +const requireConfirmation = ( + context: OperationContext, + command: string +): void => { + if (context.capabilities.yes) return; + throw new CliError({ + code: 'CLI_CONFIRMATION_REQUIRED', + category: 'invocation', + message: `${command} requires explicit confirmation.`, + retryable: false, + }); +}; + +const authenticationStatus = ( + credentials: ContextCredentials | null, + now: Date +): 'authenticated' | 'expired' | 'missing' => { + if (!credentials?.token) return 'missing'; + if (credentials.expiresAt && new Date(credentials.expiresAt) <= now) { + return 'expired'; + } + return 'authenticated'; +}; + +const summarizeContext = ( + context: ContextConfig, + currentContext: string | undefined, + credentials: ContextCredentials | null, + now: Date +) => ({ + ...context, + current: context.name === currentContext, + authentication: authenticationStatus(credentials, now), + ...(credentials?.expiresAt === undefined + ? {} + : { expiresAt: credentials.expiresAt }), +}); + +const resolveTargetContext = ( + contextName: string | undefined, + context: OperationContext, + store: ConfigStore +) => + runStateOperation(() => + resolveContext({ + contextName, + env: context.env, + allowCurrentContext: context.mode === 'human', + store, + }) + ).context; + +const ContextNameInputSchema = Type.Object( + { name: Type.String({ minLength: 1 }) }, + { additionalProperties: false } +); + +export function createContextCommands(dependencies: StateCommandDependencies) { + const createCommand = defineCommand({ + id: 'context.create', + path: ['context', 'create'], + summary: 'Create a named GraphQL endpoint context.', + input: Type.Object( + { + name: Type.String({ minLength: 1 }), + endpoint: Type.String({ minLength: 1 }), + }, + { additionalProperties: false } + ), + output: Type.Object( + { context: ContextSchema, activated: Type.Boolean() }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'name', + sources: [{ kind: 'positional', index: 0, name: 'name' }], + description: 'Unique context name.', + }, + { + property: 'endpoint', + sources: [{ kind: 'option', name: 'endpoint' }], + description: 'Absolute HTTP or HTTPS GraphQL endpoint.', + }, + ], + examples: [ + { + argv: [ + 'context', + 'create', + 'production', + '--endpoint', + 'https://api.example.com/graphql', + ], + }, + ], + lifecycle: 'finite', + effect: 'write', + async execute(input, context) { + const store = getStore(dependencies); + const { context: created, activated } = runStateOperation(() => + createContextAndMaybeActivate( + input.name, + input.endpoint, + store, + context.now() + ) + ); + return { + data: { context: created, activated }, + nextActions: [ + { + commandId: 'auth.set-token', + input: { contextName: created.name }, + reason: 'Configure authentication for the new context.', + }, + ], + }; + }, + }); + + const listCommand = defineCommand({ + id: 'context.list', + path: ['context', 'list'], + summary: 'List configured contexts without exposing credentials.', + input: EmptyInputSchema, + output: Type.Object( + { contexts: Type.Array(ContextSummarySchema) }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['context', 'list'] }], + lifecycle: 'finite', + effect: 'read', + async execute(_input, context) { + const store = getStore(dependencies); + const state = runStateOperation(() => store.read()); + return { + data: { + contexts: runStateOperation(() => listContexts(store)).map( + (configured) => + summarizeContext( + configured, + state.settings.currentContext, + state.credentials.tokens[configured.name] ?? null, + context.now() + ) + ), + }, + }; + }, + }); + + const useCommand = defineCommand({ + id: 'context.use', + path: ['context', 'use'], + summary: 'Select the current context for interactive use.', + input: ContextNameInputSchema, + output: Type.Object( + { + contextName: Type.String({ minLength: 1 }), + current: Type.Literal(true), + }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'name', + sources: [{ kind: 'positional', index: 0, name: 'name' }], + description: 'Context to select.', + }, + ], + examples: [{ argv: ['context', 'use', 'production'] }], + lifecycle: 'finite', + effect: 'write', + async execute(input) { + const store = getStore(dependencies); + const changed = runStateOperation(() => + setCurrentContext(input.name, store) + ); + if (!changed) { + throw new CliError({ + code: 'CONTEXT_NOT_FOUND', + category: 'configuration', + message: `Context "${input.name}" was not found.`, + details: { contextName: input.name }, + retryable: false, + nextActions: [ + { + commandId: 'context.list', + input: {}, + reason: 'Inspect the available contexts.', + }, + ], + }); + } + return { data: { contextName: input.name, current: true as const } }; + }, + }); + + const currentCommand = defineCommand({ + id: 'context.current', + path: ['context', 'current'], + summary: 'Inspect the currently selected interactive context.', + input: EmptyInputSchema, + output: Type.Object( + { context: Type.Union([ContextSummarySchema, Type.Null()]) }, + { additionalProperties: false } + ), + bindings: [], + examples: [{ argv: ['context', 'current'] }], + lifecycle: 'finite', + effect: 'read', + async execute(_input, context) { + const store = getStore(dependencies); + const current = runStateOperation(() => getCurrentContext(store)); + if (!current) return { data: { context: null } }; + return { + data: { + context: summarizeContext( + current, + current.name, + runStateOperation(() => getContextCredentials(current.name, store)), + context.now() + ), + }, + }; + }, + }); + + const deleteCommand = defineCommand({ + id: 'context.delete', + path: ['context', 'delete'], + summary: 'Delete a context and its stored credentials.', + input: ContextNameInputSchema, + output: Type.Object( + { + contextName: Type.String({ minLength: 1 }), + deleted: Type.Literal(true), + }, + { additionalProperties: false } + ), + bindings: [ + { + property: 'name', + sources: [{ kind: 'positional', index: 0, name: 'name' }], + description: 'Context to delete.', + }, + ], + examples: [{ argv: ['context', 'delete', 'production', '--yes'] }], + lifecycle: 'finite', + effect: 'destructive', + capabilities: { confirmation: true }, + async execute(input, context) { + requireConfirmation(context, 'Deleting a context'); + const deleted = runStateOperation(() => + deleteContext(input.name, getStore(dependencies)) + ); + if (!deleted) { + throw new CliError({ + code: 'CONTEXT_NOT_FOUND', + category: 'configuration', + message: `Context "${input.name}" was not found.`, + details: { contextName: input.name }, + retryable: false, + }); + } + return { + data: { contextName: input.name, deleted: true as const }, + }; + }, + }); + + return [ + createCommand, + listCommand, + useCommand, + currentCommand, + deleteCommand, + ] as const; +} + +const ContextTargetInputSchema = Type.Object( + { contextName: Type.Optional(Type.String({ minLength: 1 })) }, + { additionalProperties: false } +); + +const contextTargetBinding = { + property: 'contextName', + sources: [ + { kind: 'option' as const, name: 'context' }, + { kind: 'environment' as const, name: 'CNC_CONTEXT' }, + ], + conflict: 'first' as const, + description: 'Target context. CNC_CONTEXT is used when the option is absent.', +}; + +export function createAuthCommands(dependencies: StateCommandDependencies) { + const setTokenCommand = defineCommand({ + id: 'auth.set-token', + path: ['auth', 'set-token'], + summary: 'Store an API token for one explicit context.', + description: + 'Agents must use CNC_TOKEN or adapter-provided stdin. Positional tokens remain a deprecated human-only compatibility path.', + input: Type.Object( + { + contextName: Type.Optional(Type.String({ minLength: 1 })), + legacyValue: Type.Optional( + Type.String({ minLength: 1, writeOnly: true }) + ), + stdinValue: Type.Optional( + Type.String({ minLength: 1, writeOnly: true }) + ), + readFromStdin: Type.Optional(Type.Boolean()), + environmentValue: Type.Optional( + Type.String({ minLength: 1, writeOnly: true }) + ), + expiresAt: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false } + ), + output: Type.Object( + { + contextName: Type.String({ minLength: 1 }), + saved: Type.Literal(true), + expiresAt: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ), + bindings: [ + contextTargetBinding, + { + property: 'legacyValue', + sources: [{ kind: 'positional', index: 0, name: 'token' }], + description: 'Deprecated human-only token argument.', + }, + { + property: 'stdinValue', + sources: [], + description: 'Sensitive token injected by the terminal stdin adapter.', + }, + { + property: 'readFromStdin', + sources: [{ kind: 'option', name: 'token-stdin' }], + valueType: 'boolean', + description: 'Read the token from standard input without echoing it.', + }, + { + property: 'environmentValue', + sources: [{ kind: 'environment', name: 'CNC_TOKEN', sensitive: true }], + description: 'Sensitive token supplied through CNC_TOKEN.', + }, + { + property: 'expiresAt', + sources: [ + { + kind: 'option', + name: 'expires', + aliases: ['expires-at'], + }, + ], + description: 'Optional ISO-8601 expiration date.', + }, + ], + examples: [ + { + argv: ['auth', 'set-token', '--context', 'production', '--token-stdin'], + description: 'Read the token from standard input.', + }, + ], + lifecycle: 'finite', + effect: 'write', + async execute(input, context) { + if (context.mode !== 'human' && input.legacyValue !== undefined) { + throw new CliError({ + code: 'AUTH_POSITIONAL_TOKEN_UNSUPPORTED', + category: 'invocation', + message: + 'Positional tokens are not accepted in agent or CI mode. Use CNC_TOKEN or --token-stdin.', + retryable: false, + }); + } + if ( + input.expiresAt !== undefined && + !Number.isFinite(Date.parse(input.expiresAt)) + ) { + throw new CliError({ + code: 'AUTH_EXPIRATION_INVALID', + category: 'validation', + message: '--expires must be a valid ISO-8601 date.', + path: '/expiresAt', + retryable: false, + }); + } + + const store = getStore(dependencies); + const target = resolveTargetContext(input.contextName, context, store); + const resolved = runStateOperation(() => + resolveToken({ + token: input.legacyValue, + stdinToken: input.stdinValue, + env: { + CNC_TOKEN: input.environmentValue ?? context.env.CNC_TOKEN, + }, + }) + ); + runStateOperation(() => + setContextCredentials( + target.name, + resolved!.token, + { expiresAt: input.expiresAt }, + store + ) + ); + + const warnings: OperationWarning[] = + input.legacyValue === undefined + ? [] + : [ + { + code: 'CLI_DEPRECATED', + message: + 'Passing a token positionally is deprecated; use CNC_TOKEN or --token-stdin.', + }, + ]; + return { + data: { + contextName: target.name, + saved: true as const, + ...(input.expiresAt === undefined + ? {} + : { expiresAt: input.expiresAt }), + }, + ...(warnings.length === 0 ? {} : { warnings }), + }; + }, + }); + + const statusCommand = defineCommand({ + id: 'auth.status', + path: ['auth', 'status'], + summary: 'Inspect authentication state without exposing token material.', + input: ContextTargetInputSchema, + output: Type.Object( + { + contexts: Type.Array( + Type.Object( + { + contextName: Type.String({ minLength: 1 }), + current: Type.Boolean(), + status: AuthenticationStatusSchema, + expiresAt: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ) + ), + }, + { additionalProperties: false } + ), + bindings: [contextTargetBinding], + examples: [ + { argv: ['auth', 'status', '--context', 'production'] }, + { + argv: ['auth', 'status'], + description: 'List every context in interactive human mode.', + }, + ], + lifecycle: 'finite', + effect: 'read', + async execute(input, context) { + const store = getStore(dependencies); + const state = runStateOperation(() => store.read()); + let selected: ContextConfig[]; + if (input.contextName || context.env.CNC_CONTEXT) { + selected = [resolveTargetContext(input.contextName, context, store)]; + } else if (context.mode === 'human') { + selected = runStateOperation(() => listContexts(store)); + } else { + throw new CliError({ + code: 'CONTEXT_REQUIRED', + category: 'configuration', + message: 'A context is required. Pass --context or set CNC_CONTEXT.', + retryable: false, + }); + } + + return { + data: { + contexts: selected.map((configured) => { + const credentials = + state.credentials.tokens[configured.name] ?? null; + return { + contextName: configured.name, + current: state.settings.currentContext === configured.name, + status: authenticationStatus(credentials, context.now()), + ...(credentials?.expiresAt === undefined + ? {} + : { expiresAt: credentials.expiresAt }), + }; + }), + }, + }; + }, + }); + + const logoutCommand = defineCommand({ + id: 'auth.logout', + path: ['auth', 'logout'], + summary: 'Remove stored credentials for one context.', + input: ContextTargetInputSchema, + output: Type.Object( + { + contextName: Type.String({ minLength: 1 }), + removed: Type.Boolean(), + }, + { additionalProperties: false } + ), + bindings: [contextTargetBinding], + examples: [ + { argv: ['auth', 'logout', '--context', 'production', '--yes'] }, + ], + lifecycle: 'finite', + effect: 'destructive', + capabilities: { confirmation: true }, + async execute(input, context) { + requireConfirmation(context, 'Removing credentials'); + const store = getStore(dependencies); + const target = resolveTargetContext(input.contextName, context, store); + return { + data: { + contextName: target.name, + removed: runStateOperation(() => + removeContextCredentials(target.name, store) + ), + }, + }; + }, + }); + + return [setTokenCommand, statusCommand, logoutCommand] as const; +} + +export function createStateCommands(dependencies: StateCommandDependencies) { + return [ + ...createContextCommands(dependencies), + ...createAuthCommands(dependencies), + ] as const; +} diff --git a/packages/cli/src/sdk/client.ts b/packages/cli/src/sdk/client.ts index 6762b83116..a4c2ad71d7 100644 --- a/packages/cli/src/sdk/client.ts +++ b/packages/cli/src/sdk/client.ts @@ -1,7 +1,4 @@ -/** - * Simple GraphQL client for the CNC execution engine - * Uses native fetch - no external dependencies - */ +/** GraphQL transport with deterministic timeout, cancellation, and failures. */ export interface GraphQLError { message: string; @@ -10,28 +7,126 @@ export interface GraphQLError { extensions?: Record; } +export type GraphQLClientErrorCode = + | 'GRAPHQL_HTTP_ERROR' + | 'GRAPHQL_NETWORK_ERROR' + | 'GRAPHQL_TIMEOUT' + | 'GRAPHQL_CANCELLED' + | 'GRAPHQL_RESPONSE_INVALID' + | 'GRAPHQL_RESPONSE_ERROR'; + +export interface GraphQLClientError { + code: GraphQLClientErrorCode; + category: + | 'http' + | 'network' + | 'timeout' + | 'cancelled' + | 'protocol' + | 'graphql'; + message: string; + retryable: boolean; + status?: number; + details?: Record; +} + export interface QueryResult { ok: boolean; + /** Partial data is preserved when GraphQL returns both data and errors. */ data: T | null; errors?: GraphQLError[]; + error?: GraphQLClientError; + status?: number; } +export type FetchImplementation = typeof fetch; + export interface ClientConfig { endpoint: string; headers?: Record; + timeoutMs?: number; + fetch?: FetchImplementation; +} + +export interface GraphQLRequestOptions { + signal?: AbortSignal; + timeoutMs?: number; + operationName?: string; + fetch?: FetchImplementation; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +function failed( + error: GraphQLClientError, + options: { data?: T | null; errors?: GraphQLError[]; status?: number } = {} +): QueryResult { + return { + ok: false, + data: options.data ?? null, + errors: options.errors, + error, + status: options.status, + }; +} + +function parseGraphQLErrors(value: unknown): GraphQLError[] | undefined { + if (!Array.isArray(value)) return undefined; + const errors: GraphQLError[] = []; + for (const candidate of value) { + if ( + typeof candidate !== 'object' || + candidate === null || + typeof (candidate as { message?: unknown }).message !== 'string' + ) { + return undefined; + } + errors.push(candidate as GraphQLError); + } + return errors; +} + +function statusRetryable(status: number): boolean { + return status === 408 || status === 429 || status >= 500; } /** - * Execute a GraphQL query/mutation against an endpoint + * Execute a GraphQL request. Transport and protocol failures are returned as + * typed values so a terminal adapter can map them without parsing prose. */ export async function executeGraphQL( endpoint: string, query: string, variables?: Record, - headers?: Record + headers?: Record, + options: GraphQLRequestOptions = {} ): Promise> { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return failed({ + code: 'GRAPHQL_RESPONSE_INVALID', + category: 'protocol', + message: 'GraphQL timeout must be a positive number of milliseconds.', + retryable: false, + }); + } + + const fetchImplementation = options.fetch ?? globalThis.fetch; + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(options.signal?.reason); + if (options.signal?.aborted) { + controller.abort(options.signal.reason); + } else { + options.signal?.addEventListener('abort', onAbort, { once: true }); + } + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new Error('GraphQL request timed out.')); + }, timeoutMs); + try { - const response = await fetch(endpoint, { + const response = await fetchImplementation(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -41,61 +136,160 @@ export async function executeGraphQL( body: JSON.stringify({ query, variables: variables ?? {}, + ...(options.operationName + ? { operationName: options.operationName } + : {}), }), + signal: controller.signal, }); - if (!response.ok) { - return { - ok: false, - data: null, - errors: [ - { message: `HTTP ${response.status}: ${response.statusText}` }, - ], - }; + const responseText = await response.text(); + let payload: unknown; + try { + payload = JSON.parse(responseText); + } catch { + if (!response.ok) { + return failed( + { + code: 'GRAPHQL_HTTP_ERROR', + category: 'http', + message: `GraphQL endpoint returned HTTP ${response.status}.`, + retryable: statusRetryable(response.status), + status: response.status, + details: { + contentType: response.headers.get('content-type') ?? undefined, + responseWasJson: false, + }, + }, + { status: response.status } + ); + } + return failed( + { + code: 'GRAPHQL_RESPONSE_INVALID', + category: 'protocol', + message: 'GraphQL endpoint returned a non-JSON response.', + retryable: false, + status: response.status, + details: { + contentType: response.headers.get('content-type') ?? undefined, + }, + }, + { status: response.status } + ); } - const json = (await response.json()) as { - data?: T; - errors?: GraphQLError[]; - }; - - if (json.errors && json.errors.length > 0) { - return { - ok: false, - data: json.data ?? null, - errors: json.errors, - }; + const record = + typeof payload === 'object' && payload !== null && !Array.isArray(payload) + ? (payload as Record) + : undefined; + const graphQLErrors = record + ? parseGraphQLErrors(record.errors) + : undefined; + const hasData = record + ? Object.prototype.hasOwnProperty.call(record, 'data') + : false; + const data = hasData ? (record!.data as T | null) : null; + + if (!response.ok) { + return failed( + { + code: 'GRAPHQL_HTTP_ERROR', + category: 'http', + message: `GraphQL endpoint returned HTTP ${response.status}.`, + retryable: statusRetryable(response.status), + status: response.status, + }, + { data, errors: graphQLErrors, status: response.status } + ); } - return { - ok: true, - data: json.data as T, - }; - } catch (error) { - return { - ok: false, - data: null, - errors: [ + if ( + !record || + (!hasData && (!graphQLErrors || graphQLErrors.length === 0)) + ) { + return failed( + { + code: 'GRAPHQL_RESPONSE_INVALID', + category: 'protocol', + message: 'GraphQL endpoint returned an invalid response envelope.', + retryable: false, + status: response.status, + }, + { status: response.status } + ); + } + if (record.errors !== undefined && !graphQLErrors) { + return failed( { - message: - error instanceof Error ? error.message : 'Unknown error occurred', + code: 'GRAPHQL_RESPONSE_INVALID', + category: 'protocol', + message: 'GraphQL response errors field is malformed.', + retryable: false, + status: response.status, }, - ], - }; + { data, status: response.status } + ); + } + if (graphQLErrors && graphQLErrors.length > 0) { + return failed( + { + code: 'GRAPHQL_RESPONSE_ERROR', + category: 'graphql', + message: 'GraphQL operation completed with errors.', + retryable: false, + status: response.status, + }, + { data, errors: graphQLErrors, status: response.status } + ); + } + return { ok: true, data, status: response.status }; + } catch (cause) { + if (timedOut) { + return failed({ + code: 'GRAPHQL_TIMEOUT', + category: 'timeout', + message: `GraphQL request exceeded its ${timeoutMs}ms timeout.`, + retryable: true, + details: { timeoutMs }, + }); + } + if (options.signal?.aborted || controller.signal.aborted) { + return failed({ + code: 'GRAPHQL_CANCELLED', + category: 'cancelled', + message: 'GraphQL request was cancelled.', + retryable: true, + }); + } + return failed({ + code: 'GRAPHQL_NETWORK_ERROR', + category: 'network', + message: 'Unable to reach the GraphQL endpoint.', + retryable: true, + details: { + cause: cause instanceof Error ? cause.name : 'UnknownError', + }, + }); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); } } -/** - * Create a configured client for a specific endpoint - */ +/** Create a configured endpoint client. */ export function createClient(config: ClientConfig) { return { execute: ( query: string, - variables?: Record - ): Promise> => { - return executeGraphQL(config.endpoint, query, variables, config.headers); - }, + variables?: Record, + options: Omit = {} + ): Promise> => + executeGraphQL(config.endpoint, query, variables, config.headers, { + ...options, + timeoutMs: config.timeoutMs, + fetch: config.fetch, + }), config, }; } diff --git a/packages/cli/src/sdk/executor.ts b/packages/cli/src/sdk/executor.ts index e39b0f6660..ba6f6709d3 100644 --- a/packages/cli/src/sdk/executor.ts +++ b/packages/cli/src/sdk/executor.ts @@ -1,77 +1,278 @@ -/** - * Simple GraphQL executor for the CNC execution engine - * Executes raw GraphQL queries against configured endpoints - */ +/** Raw GraphQL operation analysis, safety gates, and configured execution. */ -import { executeGraphQL, QueryResult } from './client'; import { - getCurrentContext, - loadContext, - getContextCredentials, - hasValidCredentials, + Kind, + OperationTypeNode, + parse, + type OperationDefinitionNode, +} from 'graphql'; +import { + ConfigStoreError, + getDefaultConfigStore, + resolveContext, + type ConfigStore, + type ContextConfig, + type EnvironmentMap, } from '../config'; -import type { ContextConfig } from '../config'; - -/** - * Execution context - bundles context config with credentials - */ -export interface ExecutionContext { - context: ContextConfig; - token: string; +import { + executeGraphQL, + type FetchImplementation, + type QueryResult, +} from './client'; + +export type GraphQLExecutionErrorCode = + | 'AUTH_REQUIRED' + | 'AUTH_EXPIRED' + | 'GRAPHQL_DOCUMENT_INVALID' + | 'GRAPHQL_OPERATION_NOT_FOUND' + | 'GRAPHQL_OPERATION_NAME_REQUIRED' + | 'GRAPHQL_SUBSCRIPTION_UNSUPPORTED' + | 'GRAPHQL_MUTATION_REQUIRES_APPROVAL'; + +export class GraphQLExecutionError extends Error { + readonly name = 'GraphQLExecutionError'; + + constructor( + readonly code: GraphQLExecutionErrorCode, + message: string, + readonly details?: Record, + options?: { cause?: unknown } + ) { + super(message, options); + } } -/** - * Get execution context for the current or specified context - */ -export async function getExecutionContext( - contextName?: string -): Promise { - let context: ContextConfig | null; +export type ExecutionContext = + | { context: ContextConfig; token: string; anonymous: false } + | { context: ContextConfig; token?: undefined; anonymous: true }; - if (contextName) { - context = loadContext(contextName); - if (!context) { - throw new Error(`Context "${contextName}" not found.`); - } - } else { - context = getCurrentContext(); - if (!context) { - throw new Error( - 'No active context. Run "cnc context create" or "cnc context use" first.' +export interface ExecutionContextOptions { + contextName?: string; + env?: EnvironmentMap; + agent?: boolean; + allowCurrentContext?: boolean; + anonymous?: boolean; + store?: ConfigStore; + now?: Date; +} + +export interface GraphQLOperationAnalysis { + type: 'query' | 'mutation'; + name?: string; + operationCount: number; +} + +export interface MutationGateOptions { + agent?: boolean; + allowMutation?: boolean; + yes?: boolean; +} + +export interface ExecuteOptions extends MutationGateOptions { + contextName?: string; + env?: EnvironmentMap; + allowCurrentContext?: boolean; + anonymous?: boolean; + operationName?: string; + signal?: AbortSignal; + timeoutMs?: number; + headers?: Record; + fetch?: FetchImplementation; + store?: ConfigStore; + now?: Date; +} + +function operationsFromDocument(query: string): OperationDefinitionNode[] { + let document; + try { + document = parse(query); + } catch (cause) { + throw new GraphQLExecutionError( + 'GRAPHQL_DOCUMENT_INVALID', + 'GraphQL document could not be parsed.', + undefined, + { cause } + ); + } + return document.definitions.filter( + (definition): definition is OperationDefinitionNode => + definition.kind === Kind.OPERATION_DEFINITION + ); +} + +/** Parse and select one executable query or mutation. */ +export function analyzeGraphQLDocument( + query: string, + operationName?: string +): GraphQLOperationAnalysis { + const operations = operationsFromDocument(query); + if (operations.length === 0) { + throw new GraphQLExecutionError( + 'GRAPHQL_OPERATION_NOT_FOUND', + 'GraphQL document does not contain an executable operation.' + ); + } + + let selected: OperationDefinitionNode | undefined; + if (operationName) { + selected = operations.find( + (operation) => operation.name?.value === operationName + ); + if (!selected) { + throw new GraphQLExecutionError( + 'GRAPHQL_OPERATION_NOT_FOUND', + `GraphQL operation "${operationName}" was not found.`, + { + operationName, + availableOperations: operations + .map((operation) => operation.name?.value) + .filter((name): name is string => Boolean(name)), + } ); } + } else if (operations.length > 1) { + throw new GraphQLExecutionError( + 'GRAPHQL_OPERATION_NAME_REQUIRED', + 'GraphQL document contains multiple operations; operationName is required.', + { + availableOperations: operations + .map((operation) => operation.name?.value) + .filter((name): name is string => Boolean(name)), + } + ); + } else { + selected = operations[0]; } - if (!hasValidCredentials(context.name)) { - throw new Error( - `No valid credentials for context "${context.name}". Run "cnc auth set-token" first.` + if (selected.operation === OperationTypeNode.SUBSCRIPTION) { + throw new GraphQLExecutionError( + 'GRAPHQL_SUBSCRIPTION_UNSUPPORTED', + 'Raw execution does not support GraphQL subscriptions.' ); } + return { + type: + selected.operation === OperationTypeNode.MUTATION ? 'mutation' : 'query', + name: selected.name?.value, + operationCount: operations.length, + }; +} - const creds = getContextCredentials(context.name); - if (!creds || !creds.token) { - throw new Error( - `No token found for context "${context.name}". Run "cnc auth set-token" first.` +/** Require both explicit capabilities before an agent executes a mutation. */ +export function assertMutationAllowed( + operation: GraphQLOperationAnalysis, + options: MutationGateOptions +): void { + if ( + options.agent && + operation.type === 'mutation' && + (!options.allowMutation || !options.yes) + ) { + throw new GraphQLExecutionError( + 'GRAPHQL_MUTATION_REQUIRES_APPROVAL', + 'Agent mutation execution requires both --allow-mutation and --yes.', + { + allowMutation: options.allowMutation === true, + confirmed: options.yes === true, + } ); } +} +/** Resolve an endpoint and optional stored credentials without reading env. */ +export async function getExecutionContext( + contextNameOrOptions?: string | ExecutionContextOptions +): Promise { + const options: ExecutionContextOptions = + typeof contextNameOrOptions === 'string' + ? { contextName: contextNameOrOptions } + : (contextNameOrOptions ?? {}); + const store = options.store ?? getDefaultConfigStore(); + const resolved = resolveContext({ + contextName: options.contextName, + env: options.env, + allowCurrentContext: options.allowCurrentContext ?? !options.agent, + store, + }); + + if (options.anonymous) { + return { context: resolved.context, anonymous: true }; + } + const credentials = store.read().credentials.tokens[resolved.context.name]; + if (!credentials?.token) { + throw new GraphQLExecutionError( + 'AUTH_REQUIRED', + `No credentials are configured for context "${resolved.context.name}".`, + { contextName: resolved.context.name } + ); + } + if ( + credentials.expiresAt && + new Date(credentials.expiresAt) <= (options.now ?? new Date()) + ) { + throw new GraphQLExecutionError( + 'AUTH_EXPIRED', + `Credentials for context "${resolved.context.name}" have expired.`, + { contextName: resolved.context.name, expiresAt: credentials.expiresAt } + ); + } return { - context, - token: creds.token, + context: resolved.context, + token: credentials.token, + anonymous: false, }; } -/** - * Execute a raw GraphQL query/mutation - */ +/** Execute one selected raw GraphQL query or mutation. */ export async function execute( query: string, variables?: Record, - execContext?: ExecutionContext + executionContext?: ExecutionContext, + options: ExecuteOptions = {} ): Promise> { - const ctx = execContext || (await getExecutionContext()); + const operation = analyzeGraphQLDocument(query, options.operationName); + assertMutationAllowed(operation, options); + const context = + executionContext ?? + (await getExecutionContext({ + contextName: options.contextName, + env: options.env, + agent: options.agent, + allowCurrentContext: options.allowCurrentContext, + anonymous: options.anonymous, + store: options.store, + now: options.now, + })); + if (!context.anonymous && !context.token) { + throw new GraphQLExecutionError( + 'AUTH_REQUIRED', + `No credentials are configured for context "${context.context.name}".`, + { contextName: context.context.name } + ); + } + const requestHeaders = Object.fromEntries( + Object.entries(options.headers ?? {}).filter( + ([name]) => name.toLowerCase() !== 'authorization' + ) + ); + const authorization = + context.anonymous || !context.token + ? {} + : { Authorization: `Bearer ${context.token}` }; - return executeGraphQL(ctx.context.endpoint, query, variables, { - Authorization: `Bearer ${ctx.token}`, - }); + return executeGraphQL( + context.context.endpoint, + query, + variables, + { ...requestHeaders, ...authorization }, + { + operationName: operation.name, + signal: options.signal, + timeoutMs: options.timeoutMs, + fetch: options.fetch, + } + ); } + +// ConfigStoreError remains part of the executor's typed failure surface. +export { ConfigStoreError }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2451bb76bc..d1b1c238dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,8 +418,8 @@ importers: specifier: workspace:^ version: link:../graphile-realtime-subscriptions/dist lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 pg-cache: specifier: workspace:^ version: link:../../postgres/pg-cache/dist @@ -853,8 +853,8 @@ importers: specifier: 16.13.0 version: 16.13.0 lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 postgraphile: specifier: 5.0.3 version: 5.0.3(bc368b92b12e127d4eb1f1e143433708) @@ -1022,9 +1022,9 @@ importers: graphql: specifier: 16.13.0 version: 16.13.0 - pg-cache: - specifier: workspace:^ - version: link:../../postgres/pg-cache/dist + pg: + specifier: ^8.21.0 + version: 8.21.0 pg-env: specifier: workspace:^ version: link:../../postgres/pg-env/dist @@ -1195,8 +1195,8 @@ importers: specifier: ^0.7.1 version: 0.7.1 lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 pg: specifier: ^8.21.0 version: 8.21.0 @@ -1403,6 +1403,9 @@ importers: oxfmt: specifier: ^0.51.0 version: 0.51.0 + pg: + specifier: ^8.21.0 + version: 8.21.0 pg-cache: specifier: workspace:^ version: link:../../postgres/pg-cache/dist @@ -1669,8 +1672,8 @@ importers: specifier: ^0.7.1 version: 0.7.1 lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 postgraphile: specifier: 5.0.3 version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) @@ -1858,8 +1861,8 @@ importers: specifier: ^13.0.0 version: 13.0.0(graphql@16.13.0) lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 pg: specifier: ^8.21.0 version: 8.21.0 @@ -2413,60 +2416,33 @@ importers: packages/cli: dependencies: - '@constructive-io/graphql-codegen': - specifier: workspace:^ - version: link:../../graphql/codegen/dist - '@constructive-io/graphql-env': - specifier: workspace:^ - version: link:../../graphql/env/dist - '@constructive-io/graphql-explorer': - specifier: workspace:^ - version: link:../../graphql/explorer/dist - '@constructive-io/graphql-server': - specifier: workspace:^ - version: link:../../graphql/server/dist - '@constructive-io/graphql-types': - specifier: workspace:^ - version: link:../../graphql/types/dist - '@constructive-io/knative-job-service': + '@constructive-io/cli-runtime': specifier: workspace:^ - version: link:../../jobs/knative-job-service/dist + version: link:../cli-runtime/dist '@inquirerer/utils': specifier: ^3.3.9 version: 3.3.9 - '@pgpmjs/core': - specifier: workspace:^ - version: link:../../pgpm/core/dist '@pgpmjs/logger': specifier: workspace:^ version: link:../../pgpm/logger/dist - '@pgpmjs/server-utils': - specifier: workspace:^ - version: link:../server-utils/dist - '@pgpmjs/types': - specifier: workspace:^ - version: link:../../pgpm/types/dist + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 appstash: specifier: ^0.7.0 version: 0.7.0 find-and-require-package-json: specifier: ^0.9.1 version: 0.9.1 + graphql: + specifier: 16.13.0 + version: 16.13.0 inquirerer: specifier: ^4.9.1 version: 4.9.1 js-yaml: specifier: ^4.1.0 version: 4.1.1 - pg-cache: - specifier: workspace:^ - version: link:../../postgres/pg-cache/dist - pg-env: - specifier: workspace:^ - version: link:../../postgres/pg-env/dist - pgpm: - specifier: workspace:^ - version: link:../../pgpm/cli/dist shelljs: specifier: ^0.10.0 version: 0.10.0 @@ -2501,6 +2477,57 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.11)(typescript@5.9.3) + optionalDependencies: + '@constructive-io/graphql-codegen': + specifier: workspace:^ + version: link:../../graphql/codegen/dist + '@constructive-io/graphql-env': + specifier: workspace:^ + version: link:../../graphql/env/dist + '@constructive-io/graphql-explorer': + specifier: workspace:^ + version: link:../../graphql/explorer/dist + '@constructive-io/graphql-server': + specifier: workspace:^ + version: link:../../graphql/server/dist + '@constructive-io/graphql-types': + specifier: workspace:^ + version: link:../../graphql/types/dist + '@constructive-io/knative-job-service': + specifier: workspace:^ + version: link:../../jobs/knative-job-service/dist + '@pgpmjs/core': + specifier: workspace:^ + version: link:../../pgpm/core/dist + '@pgpmjs/server-utils': + specifier: workspace:^ + version: link:../server-utils/dist + '@pgpmjs/types': + specifier: workspace:^ + version: link:../../pgpm/types/dist + pg-cache: + specifier: workspace:^ + version: link:../../postgres/pg-cache/dist + pg-env: + specifier: workspace:^ + version: link:../../postgres/pg-env/dist + pgpm: + specifier: workspace:^ + version: link:../../pgpm/cli/dist + publishDirectory: dist + + packages/cli-runtime: + dependencies: + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 + ajv: + specifier: ^8.20.0 + version: 8.20.0 + devDependencies: + makage: + specifier: ^0.3.0 + version: 0.3.0 publishDirectory: dist packages/client: @@ -2580,8 +2607,8 @@ importers: specifier: workspace:^ version: link:../../pgpm/types/dist lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 pg: specifier: ^8.21.0 version: 8.21.0 @@ -2756,8 +2783,8 @@ importers: specifier: ^5.2.1 version: 5.2.1 lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 devDependencies: '@types/cors': specifier: ^2.8.17 @@ -3182,8 +3209,8 @@ importers: specifier: workspace:^ version: link:../../pgpm/types/dist lru-cache: - specifier: ^11.2.7 - version: 11.2.7 + specifier: ^10.4.3 + version: 10.4.3 pg: specifier: ^8.21.0 version: 8.21.0 @@ -7118,12 +7145,6 @@ packages: integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, } - '@sinclair/typebox@0.34.47': - resolution: - { - integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==, - } - '@sinclair/typebox@0.34.49': resolution: { @@ -17391,7 +17412,7 @@ snapshots: '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.47 + '@sinclair/typebox': 0.34.49 '@jest/schemas@30.4.1': dependencies: @@ -18526,8 +18547,6 @@ snapshots: '@sinclair/typebox@0.27.8': {} - '@sinclair/typebox@0.34.47': {} - '@sinclair/typebox@0.34.49': {} '@sinonjs/commons@3.0.1': From 45ec656b36597575502d001511ca447866d25186 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:38:09 +0700 Subject: [PATCH 07/18] docs(cli): document registry-backed agent workflows Describe the command registry as the implementation authority, publish the structured invocation contract, and update human and agent guidance for discovery, state, GraphQL, codegen, and service operations. --- packages/cli/AGENTS.md | 32 ++++-- packages/cli/README.md | 236 ++++++++++++++++++++++------------------- 2 files changed, 151 insertions(+), 117 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 842f29f834..861d8f54a9 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -3,31 +3,47 @@ The `@constructive-io/cli` package provides the user-facing CLI for the Constructive ecosystem. - **Binaries:** `constructive` (full) and `cnc` (shorthand) -- **What it covers:** Constructive GraphQL workflows only (server, explorer, codegen) +- **What it covers:** contexts/authentication, raw GraphQL execution, codegen, + and GraphQL/jobs service lifecycles **Note:** Database operations (init, add, deploy, revert, etc.) are handled by the separate `pgpm` CLI. Users should install both tools for the complete workflow. ## Entry Points - **Router:** `packages/cli/src/commands.ts` - - Builds a command map with GraphQL commands implemented in this package. + - Owns global argument parsing, terminal mode resolution, prompt collection, + structured output isolation, and process exit-code mapping. - **Executable:** `packages/cli/src/index.ts` (compiled to `dist/index.js`) +- **Registry:** `packages/cli/src/runtime/registry.ts` +- **Reusable runtime:** `packages/cli-runtime` ## Commands -The CLI provides GraphQL-focused commands: +The registry-backed command operations are: -- `packages/cli/src/commands/server.ts` – start the Constructive GraphQL server -- `packages/cli/src/commands/explorer.ts` – start the Constructive GraphQL explorer -- `packages/cli/src/commands/codegen.ts` – run GraphQL codegen (`@constructive-io/graphql-codegen`), including `--schema-enabled` for SDL export +- `runtime/state-commands.ts` – context and authentication operations +- `runtime/execute-command.ts` – guarded raw GraphQL execution +- `runtime/codegen-command.ts` – ownership-aware codegen planning/application +- `runtime/service-commands.ts` – server, explorer, and jobs lifecycles +- `runtime/discovery-commands.ts` – help, schemas, docs, and completions + +Files under `src/commands/` are retained legacy terminal handlers; new command +behavior must be implemented as a `CommandDefinition` and registered in +`runtime/registry.ts`. ## Debugging Tips - **Command routing:** `packages/cli/src/commands.ts` -- **Codegen handler:** `packages/cli/src/commands/codegen.ts` delegates to `runCodegenHandler()` from `@constructive-io/graphql-codegen` +- **Command contracts:** `cnc commands --format json` and + `cnc schema --format json` +- **Codegen operation:** `runCodegenOperation()` from + `@constructive-io/graphql-codegen`; terminal rendering stays outside it - **Schema building:** `graphile-schema` package provides `buildSchemaSDL` (from database) and `fetchEndpointSchemaSDL` (from endpoint) ## Tests -- `packages/cli/__tests__/*` covers the GraphQL commands (codegen, cli) +- `packages/cli/__tests__/*` covers protocol behavior, state safety, raw + GraphQL, codegen, services, and the compatibility gate +- `packages/cli-runtime/__tests__/*` covers registry, binding, validation, + redaction, discovery projections, and protocol envelopes - Database/PGPM tests are located in `pgpm/cli/__tests__/` diff --git a/packages/cli/README.md b/packages/cli/README.md index 7835f72752..17cd721503 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,139 +22,174 @@ Constructive CLI provides GraphQL server capabilities and code generation tools npm install -g @constructive-io/cli ``` -## Commands +Both `cnc` and `constructive` invoke the same binary. -### `cnc server` - -Start the GraphQL development server. +The core protocol, context, authentication, and raw GraphQL commands support +Node.js 18.17 and newer. Codegen and the GraphQL server/explorer stack are +optional feature packages and currently require Node.js 22.19 or newer. On a +supported Node.js version, the default npm install includes those optional +features. For a core-only installation on Node.js 18 or 20, omit them: ```bash -# Start with defaults (port 5555) -cnc server - -# Custom port and options -cnc server --port 8080 --no-postgis - -# With custom CORS origin -cnc server --origin http://localhost:3000 +npm install -g @constructive-io/cli --omit=optional ``` -**Options:** +Feature commands remain discoverable in a core-only installation and fail +with the typed `CAPABILITY_UNAVAILABLE` error instead of a module-loader or +internal error. The jobs service is optional as well, so `jobs up` has the same +behavior when optional packages are explicitly omitted. -- `--port ` - Server port (default: 5555) -- `--origin ` - CORS origin URL -- `--simpleInflection` - Use simple inflection (default: true) -- `--oppositeBaseNames` - Use opposite base names (default: false) -- `--postgis` - Enable PostGIS extension (default: true) -- `--metaApi` - Enable Meta API (default: true) +## Command registry -### `cnc explorer` - -Launch GraphiQL explorer for your API. +The CLI contract is generated from a typed registry, so help, JSON Schema, +Markdown, Skills, and shell completions cannot drift from the executable +commands. ```bash -# Launch explorer -cnc explorer - -# With custom CORS origin -cnc explorer --origin http://localhost:3000 +cnc commands --format json +cnc schema execute --format json +cnc help codegen +cnc docs export --target .constructive/agent-docs --dry-run +cnc completion zsh ``` -**Options:** +The current command families are: -- `--port ` - Server port (default: 5555) -- `--origin ` - CORS origin URL (default: http://localhost:3000) -- `--simpleInflection` - Use simple inflection (default: true) -- `--oppositeBaseNames` - Use opposite base names (default: false) -- `--postgis` - Enable PostGIS extension (default: true) +- `context create|list|use|current|delete` +- `auth set-token|status|logout` +- `execute` +- `codegen` +- `server` +- `explorer` +- `jobs up` -### `cnc codegen` +Run `cnc help ` for the exact, version-matched options. Canonical +flags use kebab case; historical camel-case flags remain temporary deprecated +aliases and produce `CLI_DEPRECATED` warnings. -Generate TypeScript types, operations, and SDK from a GraphQL schema or endpoint. +The same registry is a stable in-process API for adapters such as future MCP +servers: -```bash -# Generate React Query hooks from endpoint -cnc codegen --endpoint http://localhost:5555/graphql --output ./codegen --react-query +```ts +import { createCncRegistryForEnvironment } from '@constructive-io/cli/runtime'; -# Generate ORM client from endpoint -cnc codegen --endpoint http://localhost:5555/graphql --output ./codegen --orm +const { registry } = createCncRegistryForEnvironment({ + version: '7.x', + env: { HOME: '/srv/agent' }, +}); +``` -# Generate both React Query hooks and ORM client -cnc codegen --endpoint http://localhost:5555/graphql --output ./codegen --react-query --orm +Embeddings without a home-directory model must pass an absolute `configDir`; +the factory never falls back to the host process environment. -# From schema file -cnc codegen --schema-file ./schema.graphql --output ./codegen --react-query +## Agent protocol -# From database with schemas -cnc codegen --schemas public,app_public --output ./codegen --react-query +`--agent` enables strict noninteractive JSONL output. It disables prompts, +ANSI effects, browser opening, and update checks, and stdout contains protocol +events only. -# From database with API names -cnc codegen --api-names my_api --output ./codegen --orm +```bash +cnc commands --agent +cnc execute \ + --context preview \ + --query 'query Viewer { viewer { id } }' \ + --agent ``` -**Options:** +Finite commands also support a single terminal envelope with `--format json`. +Long-running services require JSONL so readiness and shutdown remain +observable. -- `--config ` - Path to config file -- `--endpoint ` - GraphQL endpoint URL -- `--schema-file ` - Path to GraphQL schema file -- `--schemas ` - Comma-separated PostgreSQL schemas -- `--api-names ` - Comma-separated API names -- `--react-query` - Generate React Query hooks -- `--orm` - Generate ORM client -- `--output ` - Output directory (default: ./codegen) -- `--authorization ` - Authorization header value -- `--browser-compatible` - Generate browser-compatible code (default: true) -- `--dry-run` - Preview without writing files -- `--verbose` - Verbose output +```json +{"protocolVersion":"constructive.dev/cli/v1","event":"operation.started","operationId":"...","commandId":"discovery.version","timestamp":"..."} +{"protocolVersion":"constructive.dev/cli/v1","event":"operation.completed","operationId":"...","commandId":"discovery.version","timestamp":"...","durationMs":1,"result":{"data":{"version":"7.x","protocolVersion":"constructive.dev/cli/v1"}}} +``` -### `cnc codegen --schema-enabled` +Exit codes are stable: `0` success, `1` known operation failure, `2` invalid +invocation, `70` internal contract failure, and `130` cancellation. -Export GraphQL schema SDL without running full code generation. Works with any source (endpoint, file, database, PGPM). +## Contexts, authentication, and raw GraphQL ```bash -# From database schemas -cnc codegen --schema-enabled --schemas myapp,public --schema-output ./schemas +cnc context create preview --endpoint https://api.example.com/graphql +CNC_TOKEN='...' cnc auth set-token --context preview --agent +cnc execute --context preview --file queries/viewer.graphql --format json +cnc execute --context preview --anonymous --query 'query Health { health }' +``` -# From running server -cnc codegen --schema-enabled --endpoint http://localhost:3000/graphql --schema-output ./schemas +Agent and CI execution require an explicit `--context` or `CNC_CONTEXT`; human +mode may fall back to the globally selected context for compatibility. +Agents provide tokens through `CNC_TOKEN` or `--token-stdin`, and token values +are never returned by the CLI. -# From schema file (useful for converting/validating) -cnc codegen --schema-enabled --schema-file ./input.graphql --schema-output ./schemas +Mutations in agent or CI mode require both `--allow-mutation` and `--yes`. +Subscriptions are rejected by raw execution, and the request timeout defaults +to 30 seconds. -# From a directory of .graphql files (multi-target) -cnc codegen --schema-enabled --schema-dir ./schemas --schema-output ./exported +## Code generation -# Custom filename -cnc codegen --schema-enabled --endpoint http://localhost:3000/graphql --schema-output ./schemas --schema-filename public.graphql +```bash +cnc codegen \ + --endpoint http://localhost:5555/graphql \ + --output ./generated/graphql \ + --orm \ + --react-query \ + --dry-run \ + --format json ``` -**Options:** +Codegen computes the complete ownership-aware plan before writing. Dry runs do +not mutate the filesystem, repeated generation is a no-op, stale files are +pruned only when the ownership manifest proves CNC owns them, and modified +generated files require `--overwrite-modified-generated --yes`. -- `--schema-enabled` - Enable schema SDL export -- `--schema-output ` - Output directory for the exported schema file -- `--schema-filename ` - Filename for the exported schema (default: schema.graphql) -- `--endpoint ` - GraphQL endpoint URL -- `--schema-file ` - Path to GraphQL schema file -- `--schemas ` - Comma-separated PostgreSQL schemas -- `--api-names ` - Comma-separated API names (multi-target when >1) -- `--schema-dir ` - Directory of .graphql files (auto-creates one target per file) -- `--output ` - Output directory (default: ./generated/graphql) -- `--authorization ` - Authorization header value +CNC loads declarative `graphql-codegen.config.json` files only. Executable +JavaScript or TypeScript configs remain a legacy feature of the standalone +GraphQL codegen adapter and are rejected by CNC before evaluation. -## Configuration +Schema-only export uses the same planner: -### Environment Variables +```bash +cnc codegen \ + --schema-enabled \ + --schema-file ./schema.graphql \ + --schema-output ./schemas \ + --schema-filename public.graphql +``` -Constructive respects standard PostgreSQL environment variables: +## Services ```bash -export PGHOST=localhost -export PGPORT=5432 -export PGDATABASE=myapp -export PGUSER=postgres -export PGPASSWORD=password +cnc server --port 5555 --no-postgis +cnc explorer --port 5556 +cnc jobs up --with-jobs-server --functions send-email=8081 +``` + +In JSONL mode, services emit `service.starting`, `service.ready`, +`service.stopping`, and `service.stopped`. SIGINT and SIGTERM await cleanup and +finish with `operation.cancelled` and exit code `130`. + +## Global options + +```text +--agent +--interactive +--non-interactive +--format human|json|jsonl +--cwd +--yes +--no-color +--debug +--help +--version ``` +`--dry-run` and overwrite acknowledgements are command-local because they are +only exposed where the operation can enforce them. + +Constructive respects standard PostgreSQL environment variables for server and +database-backed codegen operations. + ## Database Operations For database migrations, packages, and deployment, use **pgpm**: @@ -173,20 +208,3 @@ Common pgpm commands: - `pgpm revert` - Revert database changes See the [pgpm documentation](https://pgpm.io) for more details. - -## Getting Help - -```bash -# Global help -cnc --help - -# Command-specific help -cnc server --help -cnc codegen -h -``` - -## Global Options - -- `--help, -h` - Show help information -- `--version, -v` - Show version information -- `--cwd ` - Set working directory From 9a5033e03c502404d68178557babee759bdf5628 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 28 Jul 2026 11:38:15 +0700 Subject: [PATCH 08/18] ci(cli): verify published agent protocol artifacts Pack the complete CNC release set once and exercise engine-strict installations, CJS and ESM exports, both binaries, structured output, exit semantics, cancellation, lifecycle events, and signal cleanup across supported Node versions. --- .github/workflows/run-tests.yaml | 100 +- .../cli/scripts/verify-packed-artifacts.mjs | 1002 +++++++++++++++++ 2 files changed, 1101 insertions(+), 1 deletion(-) create mode 100644 packages/cli/scripts/verify-packed-artifacts.mjs diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 43dd96b75a..270133d4fd 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -101,7 +101,7 @@ jobs: - batch: packages-core packages: 'packages/url-domains postgres/query-builder packages/csrf packages/oauth packages/12factor-env packages/orm' - batch: packages-services - packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli postgres/pgsql-client postgres/pg-ast' + packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli-runtime packages/cli postgres/pgsql-client postgres/pg-ast' - batch: graphql packages: 'graphql/query graphql/codegen' - batch: pglite @@ -138,6 +138,104 @@ jobs: echo "::endgroup::" done + # Pack once from the Node 22 build, then exercise the published package + # boundary on every supported Node line. This intentionally installs the + # tarballs in an empty project so workspace links cannot mask packaging bugs. + cli-package-artifacts: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download workspace + uses: actions/download-artifact@v4 + with: + name: workspace-build + + - name: Extract workspace + run: tar -xzf workspace.tar.gz && rm workspace.tar.gz + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Pack CNC release set + run: | + mkdir -p /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/cli-runtime pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-codegen pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-explorer pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-query pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-server pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/express-context pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/job-pg pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/job-scheduler pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/job-utils pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/knative-job-fn pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/knative-job-service pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/knative-job-worker pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/send-email-fn pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/send-verification-link-fn pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @pgpmjs/env pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @pgpmjs/logger pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @pgpmjs/server-utils pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter pg-cache pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter pg-env pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter graphile-cache pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter graphile-bucket-provisioner-plugin pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter graphile-schema pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter graphile-settings pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter graphile-presigned-url-plugin pack --pack-destination /tmp/cnc-package-artifacts + npm_config_ignore_scripts=true pnpm --filter @constructive-io/cli pack --pack-destination /tmp/cnc-package-artifacts + cp packages/cli/scripts/verify-packed-artifacts.mjs /tmp/cnc-package-artifacts/ + + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: cnc-package-artifacts + path: /tmp/cnc-package-artifacts + retention-days: 1 + + cli-package-matrix: + needs: cli-package-artifacts + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - node-version: '18.17.0' + suite: core + - node-version: '20' + suite: core + - node-version: '22' + suite: core + - node-version: '22.19.0' + suite: full + steps: + - name: Download package artifacts + uses: actions/download-artifact@v4 + with: + name: cnc-package-artifacts + path: cnc-package-artifacts + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Verify installed package entrypoints + run: node cnc-package-artifacts/verify-packed-artifacts.mjs --artifacts cnc-package-artifacts --suite ${{ matrix.suite }} + # ========================================================================= # TIER 2 – PostgreSQL-only tests # ========================================================================= diff --git a/packages/cli/scripts/verify-packed-artifacts.mjs b/packages/cli/scripts/verify-packed-artifacts.mjs new file mode 100644 index 0000000000..9f59471f5b --- /dev/null +++ b/packages/cli/scripts/verify-packed-artifacts.mjs @@ -0,0 +1,1002 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +const protocolVersion = 'constructive.dev/cli/v1'; +const terminalEvents = new Set([ + 'operation.completed', + 'operation.failed', + 'operation.cancelled', +]); + +const createCliEnvironment = (home) => { + const inheritedKeys = [ + 'PATH', + 'Path', + 'PATHEXT', + 'SystemRoot', + 'SYSTEMROOT', + 'ComSpec', + 'TMPDIR', + 'TMP', + 'TEMP', + ]; + const inherited = Object.fromEntries( + inheritedKeys.flatMap((key) => + process.env[key] === undefined ? [] : [[key, process.env[key]]] + ) + ); + return { + ...inherited, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + APPDATA: home, + CI: 'true', + NO_COLOR: '1', + NO_UPDATE_NOTIFIER: '1', + GRAPHILE_ENV: 'production', + }; +}; + +const fail = (message) => { + throw new Error(message); +}; + +const parseArguments = (argv) => { + if ( + ![2, 4].includes(argv.length) || + argv[0] !== '--artifacts' || + (argv.length === 4 && argv[2] !== '--suite') + ) { + fail( + 'Usage: node verify-packed-artifacts.mjs --artifacts [--suite core|full]' + ); + } + + const suite = argv[3] ?? 'full'; + if (suite !== 'core' && suite !== 'full') { + fail(`Unknown packed acceptance suite: ${suite}`); + } + + const artifactDirectory = resolve(argv[1]); + if (!existsSync(artifactDirectory)) { + fail(`Artifact directory does not exist: ${artifactDirectory}`); + } + + const archives = readdirSync(artifactDirectory).filter((name) => + name.endsWith('.tgz') + ); + const requiredArchives = { + runtime: archives.filter((name) => + /^constructive-io-cli-runtime-[0-9].*\.tgz$/.test(name) + ), + cli: archives.filter((name) => + /^constructive-io-cli-[0-9].*\.tgz$/.test(name) + ), + codegen: archives.filter((name) => + /^constructive-io-graphql-codegen-[0-9].*\.tgz$/.test(name) + ), + graphqlExplorer: archives.filter((name) => + /^constructive-io-graphql-explorer-[0-9].*\.tgz$/.test(name) + ), + logger: archives.filter((name) => + /^pgpmjs-logger-[0-9].*\.tgz$/.test(name) + ), + pgCache: archives.filter((name) => /^pg-cache-[0-9].*\.tgz$/.test(name)), + graphileSettings: archives.filter((name) => + /^graphile-settings-[0-9].*\.tgz$/.test(name) + ), + graphileCache: archives.filter((name) => + /^graphile-cache-[0-9].*\.tgz$/.test(name) + ), + bucketProvisioner: archives.filter((name) => + /^graphile-bucket-provisioner-plugin-[0-9].*\.tgz$/.test(name) + ), + graphileSchema: archives.filter((name) => + /^graphile-schema-[0-9].*\.tgz$/.test(name) + ), + presignedUrl: archives.filter((name) => + /^graphile-presigned-url-plugin-[0-9].*\.tgz$/.test(name) + ), + graphqlQuery: archives.filter((name) => + /^constructive-io-graphql-query-[0-9].*\.tgz$/.test(name) + ), + expressContext: archives.filter((name) => + /^constructive-io-express-context-[0-9].*\.tgz$/.test(name) + ), + serverUtils: archives.filter((name) => + /^pgpmjs-server-utils-[0-9].*\.tgz$/.test(name) + ), + graphqlServer: archives.filter((name) => + /^constructive-io-graphql-server-[0-9].*\.tgz$/.test(name) + ), + jobPg: archives.filter((name) => + /^constructive-io-job-pg-[0-9].*\.tgz$/.test(name) + ), + jobScheduler: archives.filter((name) => + /^constructive-io-job-scheduler-[0-9].*\.tgz$/.test(name) + ), + jobUtils: archives.filter((name) => + /^constructive-io-job-utils-[0-9].*\.tgz$/.test(name) + ), + knativeJobFn: archives.filter((name) => + /^constructive-io-knative-job-fn-[0-9].*\.tgz$/.test(name) + ), + knativeJobService: archives.filter((name) => + /^constructive-io-knative-job-service-[0-9].*\.tgz$/.test(name) + ), + knativeJobWorker: archives.filter((name) => + /^constructive-io-knative-job-worker-[0-9].*\.tgz$/.test(name) + ), + sendEmail: archives.filter((name) => + /^constructive-io-send-email-fn-[0-9].*\.tgz$/.test(name) + ), + sendVerificationLink: archives.filter((name) => + /^constructive-io-send-verification-link-fn-[0-9].*\.tgz$/.test(name) + ), + pgpmEnv: archives.filter((name) => /^pgpmjs-env-[0-9].*\.tgz$/.test(name)), + pgEnv: archives.filter((name) => /^pg-env-[0-9].*\.tgz$/.test(name)), + }; + + const invalidArchives = Object.entries(requiredArchives).filter( + ([, matches]) => matches.length !== 1 + ); + if (invalidArchives.length > 0) { + fail( + `Expected one archive for each CNC release-set package in ${artifactDirectory}; invalid counts: ${invalidArchives.map(([name, matches]) => `${name}=${matches.length}`).join(', ')}; found: ${archives.join(', ') || '(none)'}` + ); + } + + const selectedArchives = + suite === 'full' + ? archives + : [ + ...requiredArchives.runtime, + ...requiredArchives.cli, + ...requiredArchives.logger, + ...requiredArchives.serverUtils, + ...requiredArchives.pgCache, + ...requiredArchives.pgEnv, + ...requiredArchives.pgpmEnv, + ]; + + return { + suite, + installArchives: selectedArchives + .sort() + .map((archive) => join(artifactDirectory, archive)), + }; +}; + +const run = (command, args, options = {}) => { + const { label, ...spawnOptions } = options; + const child = spawnSync(command, args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + ...spawnOptions, + }); + + if (child.error) { + fail( + `${label ?? basename(command)} could not start: ${child.error.message}` + ); + } + + return child; +}; + +const assertSuccessfulCheck = (label, child) => { + if (child.status !== 0) { + fail( + [ + `${label} exited with ${child.status ?? `signal ${child.signal}`}.`, + child.stdout ? `stdout:\n${child.stdout}` : undefined, + child.stderr ? `stderr:\n${child.stderr}` : undefined, + ] + .filter(Boolean) + .join('\n') + ); + } +}; + +const assertPublishedEntrypoints = (label, packageDirectory) => { + const manifestPath = join(packageDirectory, 'package.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + + for (const field of ['main', 'module', 'types']) { + const target = manifest[field]; + if (typeof target !== 'string' || target.length === 0) { + fail(`${label} does not declare a non-empty ${field} entrypoint.`); + } + if (!existsSync(join(packageDirectory, target))) { + fail(`${label} declares missing ${field} entrypoint ${target}.`); + } + } +}; + +const readCliProtocol = (label, child, expectedStatus, expectedCommandId) => { + if (child.status !== expectedStatus) { + fail( + [ + `${label} exited with ${child.status ?? `signal ${child.signal}`}; expected ${expectedStatus}.`, + child.stdout ? `stdout:\n${child.stdout}` : undefined, + child.stderr ? `stderr:\n${child.stderr}` : undefined, + ] + .filter(Boolean) + .join('\n') + ); + } + if (child.stderr !== '') { + fail(`${label} wrote to structured stderr:\n${child.stderr}`); + } + if (/\u001b\[[0-?]*[ -/]*[@-~]/.test(child.stdout)) { + fail(`${label} emitted ANSI control sequences in agent mode.`); + } + if (!child.stdout.endsWith('\n')) { + fail(`${label} did not terminate its JSONL stream with a newline.`); + } + + const payload = child.stdout.endsWith('\r\n') + ? child.stdout.slice(0, -2) + : child.stdout.slice(0, -1); + const lines = payload.split(/\r?\n/); + if (lines.some((line) => line.length === 0 || line !== line.trim())) { + fail(`${label} emitted stray whitespace in its JSONL stream.`); + } + let events; + try { + events = lines.map((line) => JSON.parse(line)); + } catch (error) { + fail( + `${label} emitted non-JSON protocol output: ${ + error instanceof Error ? error.message : String(error) + }\n${child.stdout}` + ); + } + + if (events[0]?.event !== 'operation.started') { + fail(`${label} did not begin with operation.started.`); + } + if (events.filter((event) => terminalEvents.has(event?.event)).length !== 1) { + fail(`${label} did not emit exactly one terminal event.`); + } + + for (const event of events) { + if (event?.protocolVersion !== protocolVersion) { + fail(`${label} emitted an unexpected protocol version.`); + } + if (event?.commandId !== expectedCommandId) { + fail(`${label} emitted an unexpected command ID.`); + } + if ( + typeof event?.operationId !== 'string' || + event.operationId.length < 1 + ) { + fail(`${label} emitted an invalid operation ID.`); + } + if ( + typeof event?.timestamp !== 'string' || + Number.isNaN(Date.parse(event.timestamp)) + ) { + fail(`${label} emitted an invalid timestamp.`); + } + } + if (events.some((event) => event.operationId !== events[0].operationId)) { + fail(`${label} changed operation IDs before its terminal event.`); + } + + return events; +}; + +const assertCliProtocol = (label, child) => { + const events = readCliProtocol(label, child, 0, 'discovery.version'); + if (events.length !== 2) { + fail(`${label} emitted ${events.length} events; expected exactly 2.`); + } + if (events[1]?.event !== 'operation.completed') { + fail(`${label} did not end with operation.completed.`); + } + + if ( + typeof events[1]?.result?.data?.version !== 'string' || + events[1].result.data.version.length < 1 || + events[1]?.result?.data?.protocolVersion !== protocolVersion + ) { + fail(`${label} emitted an invalid discovery.version result.`); + } +}; + +const assertJsonVersionEnvelope = (label, child) => { + if (child.status !== 0) { + fail(`${label} exited with ${child.status}; expected 0.`); + } + if (child.stderr !== '') { + fail(`${label} wrote to structured stderr:\n${child.stderr}`); + } + if (/\u001b\[[0-?]*[ -/]*[@-~]/.test(child.stdout)) { + fail(`${label} emitted ANSI control sequences in JSON mode.`); + } + if (child.stdout.trim().split(/\r?\n/).length !== 1) { + fail(`${label} did not emit exactly one JSON envelope.`); + } + + let envelope; + try { + envelope = JSON.parse(child.stdout); + } catch (error) { + fail( + `${label} emitted invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if ( + envelope?.protocolVersion !== protocolVersion || + envelope?.event !== 'operation.completed' || + envelope?.commandId !== 'discovery.version' || + typeof envelope?.result?.data?.version !== 'string' + ) { + fail(`${label} emitted an invalid discovery.version JSON envelope.`); + } +}; + +const assertInvalidInvocation = (label, child) => { + const events = readCliProtocol(label, child, 2, 'cli.invocation'); + if ( + events.length !== 2 || + events[1]?.event !== 'operation.failed' || + events[1]?.error?.code !== 'CLI_COMMAND_NOT_FOUND' || + events[1]?.error?.category !== 'invocation' + ) { + fail(`${label} did not emit the expected invalid-invocation failure.`); + } +}; + +const assertInternalFailure = (label, child) => { + const events = readCliProtocol(label, child, 70, 'fixture.invalid-output'); + if ( + events.length !== 2 || + events[1]?.event !== 'operation.failed' || + events[1]?.error?.code !== 'CLI_INTERNAL_ERROR' || + events[1]?.error?.category !== 'internal' + ) { + fail(`${label} did not emit the expected internal contract failure.`); + } +}; + +const assertSignalCancellation = (label, child) => { + const events = readCliProtocol(label, child, 130, 'explorer.start'); + const names = events.map((event) => event.event); + for (const required of [ + 'service.starting', + 'service.ready', + 'service.stopping', + 'service.stopped', + 'operation.cancelled', + ]) { + if (!names.includes(required)) { + fail(`${label} did not emit ${required}: ${names.join(',')}`); + } + } +}; + +const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + +const assertCodegenSourceFailure = (label, child) => { + const events = readCliProtocol(label, child, 1, 'codegen.generate'); + if (events.length !== 2) { + fail(`${label} emitted ${events.length} events; expected exactly 2.`); + } + if (events[1]?.event !== 'operation.failed') { + fail(`${label} did not end with operation.failed.`); + } + + if ( + events[1]?.error?.code !== 'CODEGEN_SOURCE_REQUIRED' || + events[1]?.error?.category !== 'configuration' || + events[1]?.error?.retryable !== false + ) { + fail(`${label} did not emit the expected known codegen source failure.`); + } +}; + +const assertCapabilityUnavailable = (label, child, expectedCommandId) => { + const events = readCliProtocol(label, child, 1, expectedCommandId); + if (events.length !== 2 || events[1]?.event !== 'operation.failed') { + fail(`${label} did not emit one failed terminal event.`); + } + if ( + events[1]?.error?.code !== 'CAPABILITY_UNAVAILABLE' || + events[1]?.error?.category !== 'configuration' || + events[1]?.error?.retryable !== false + ) { + fail(`${label} did not emit the expected unavailable-capability error.`); + } +}; + +const main = () => { + const { installArchives, suite } = parseArguments(process.argv.slice(2)); + const installationDirectory = mkdtempSync( + join(tmpdir(), 'cnc-package-acceptance-') + ); + + try { + writeFileSync( + join(installationDirectory, 'package.json'), + '{"name":"cnc-package-acceptance","private":true}\n' + ); + + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const installation = run( + npm, + [ + 'install', + '--ignore-scripts', + '--engine-strict', + '--no-audit', + '--no-fund', + '--loglevel=error', + ...(suite === 'core' ? ['--omit=optional'] : []), + ...installArchives, + ], + { cwd: installationDirectory, label: 'package installation' } + ); + assertSuccessfulCheck('package installation', installation); + + const runtimeChecks = [ + [ + 'runtime CommonJS require()', + [ + '-e', + `const runtime = require('@constructive-io/cli-runtime'); +if (runtime.PROTOCOL_VERSION !== '${protocolVersion}') process.exit(1); +if (typeof runtime.CommandRegistry !== 'function') process.exit(1);`, + ], + ], + [ + 'runtime ESM import()', + [ + '--input-type=module', + '-e', + `const runtime = await import('@constructive-io/cli-runtime'); +if (runtime.PROTOCOL_VERSION !== '${protocolVersion}') process.exit(1); +if (typeof runtime.CommandRegistry !== 'function') process.exit(1);`, + ], + ], + ]; + + for (const [label, args] of runtimeChecks) { + const child = run(process.execPath, args, { + cwd: installationDirectory, + label, + }); + assertSuccessfulCheck(label, child); + if (child.stdout !== '' || child.stderr !== '') { + fail(`${label} produced unexpected output.`); + } + } + + const cncRuntimeChecks = [ + [ + 'CNC runtime CommonJS require()', + [ + '-e', + `const runtime = require('@constructive-io/cli/runtime'); +const config = require('@constructive-io/cli/config'); +const manager = require('@constructive-io/cli/config/config-manager'); +if (typeof runtime.createCncRegistryForEnvironment !== 'function') process.exit(1); +if (typeof runtime.ConfigStore !== 'function') process.exit(1); +if (config.ConfigStore !== manager.ConfigStore) process.exit(1); +runtime.createCncRegistryForEnvironment({ version: 'test', env: {}, configDir: process.cwd() });`, + ], + ], + [ + 'CNC runtime ESM import()', + [ + '--input-type=module', + '-e', + `const runtime = await import('@constructive-io/cli/runtime'); +const config = await import('@constructive-io/cli/config'); +const manager = await import('@constructive-io/cli/config/config-manager'); +if (typeof runtime.createCncRegistryForEnvironment !== 'function') process.exit(1); +if (typeof runtime.ConfigStore !== 'function') process.exit(1); +if (config.ConfigStore !== manager.ConfigStore) process.exit(1); +runtime.createCncRegistryForEnvironment({ version: 'test', env: {}, configDir: process.cwd() });`, + ], + ], + ]; + + for (const [label, args] of cncRuntimeChecks) { + const child = run(process.execPath, args, { + cwd: installationDirectory, + label, + }); + assertSuccessfulCheck(label, child); + if (child.stdout !== '' || child.stderr !== '') { + fail(`${label} produced unexpected output.`); + } + } + + const cliPackageDirectory = join( + installationDirectory, + 'node_modules', + '@constructive-io', + 'cli' + ); + const binaryDirectory = join(installationDirectory, 'node_modules', '.bin'); + const binarySuffix = process.platform === 'win32' ? '.cmd' : ''; + const cliEnvironment = createCliEnvironment(installationDirectory); + const packageImportChecks = [ + [ + 'graphql-codegen CommonJS require()', + [ + '-e', + `const codegen = require('@constructive-io/graphql-codegen'); +if (typeof codegen.generate !== 'function') process.exit(1);`, + ], + ], + [ + 'graphql-codegen ESM import()', + [ + '--input-type=module', + '-e', + `const codegen = await import('@constructive-io/graphql-codegen'); +if (typeof codegen.generate !== 'function' && typeof codegen.default?.generate !== 'function') process.exit(1);`, + ], + ], + [ + 'send-email CommonJS require()', + [ + '-e', + `const email = require('@constructive-io/send-email-fn'); +if (typeof email.default?.post !== 'function') process.exit(1);`, + ], + ], + [ + 'send-email ESM import()', + [ + '--input-type=module', + '-e', + `const email = await import('@constructive-io/send-email-fn'); +const app = email.default?.default ?? email.default; +if (typeof app?.post !== 'function') process.exit(1);`, + ], + ], + [ + 'send-verification-link CommonJS require()', + [ + '-e', + `const email = require('@constructive-io/send-verification-link-fn'); +if (typeof email.default?.post !== 'function') process.exit(1);`, + ], + ], + [ + 'send-verification-link ESM import()', + [ + '--input-type=module', + '-e', + `const email = await import('@constructive-io/send-verification-link-fn'); +const app = email.default?.default ?? email.default; +if (typeof app?.post !== 'function') process.exit(1);`, + ], + ], + ]; + + if (suite === 'full') { + const codegenPackageDirectory = join( + installationDirectory, + 'node_modules', + '@constructive-io', + 'graphql-codegen' + ); + const sendEmailPackageDirectory = join( + installationDirectory, + 'node_modules', + '@constructive-io', + 'send-email-fn' + ); + const sendVerificationLinkPackageDirectory = join( + installationDirectory, + 'node_modules', + '@constructive-io', + 'send-verification-link-fn' + ); + + assertPublishedEntrypoints( + '@constructive-io/graphql-codegen', + codegenPackageDirectory + ); + assertPublishedEntrypoints( + '@constructive-io/send-email-fn', + sendEmailPackageDirectory + ); + assertPublishedEntrypoints( + '@constructive-io/send-verification-link-fn', + sendVerificationLinkPackageDirectory + ); + + for (const [label, args] of packageImportChecks) { + const child = run(process.execPath, args, { + cwd: installationDirectory, + env: cliEnvironment, + label, + }); + assertSuccessfulCheck(label, child); + if (child.stdout !== '' || child.stderr !== '') { + fail(`${label} produced unexpected output.`); + } + } + } + + const directCliChecks = [ + [ + 'CLI CommonJS entrypoint', + process.execPath, + [join(cliPackageDirectory, 'index.js')], + ], + [ + 'CLI ESM entrypoint', + process.execPath, + [join(cliPackageDirectory, 'esm', 'index.js')], + ], + ]; + + for (const [label, command, prefixArguments] of directCliChecks) { + const child = run(command, [...prefixArguments, 'version', '--agent'], { + cwd: installationDirectory, + env: cliEnvironment, + label, + }); + assertCliProtocol(label, child); + } + + const jsonVersion = run( + process.execPath, + [join(cliPackageDirectory, 'index.js'), 'version', '--format', 'json'], + { + cwd: installationDirectory, + env: cliEnvironment, + input: '', + label: 'CLI JSON envelope with closed stdin', + } + ); + assertJsonVersionEnvelope( + 'CLI JSON envelope with closed stdin', + jsonVersion + ); + + const invalidInvocation = run( + process.execPath, + [join(cliPackageDirectory, 'index.js'), 'definitely-unknown', '--agent'], + { + cwd: installationDirectory, + env: cliEnvironment, + input: '', + label: 'CLI invalid invocation', + } + ); + assertInvalidInvocation('CLI invalid invocation', invalidInvocation); + + const invalidOutputFixture = run( + process.execPath, + [ + '-e', + `const { + createCommandRegistry, createJsonlSink, defineCommand, executeCommand, + exitCodeForOutcome, Type, +} = require('@constructive-io/cli-runtime'); +const command = defineCommand({ + id: 'fixture.invalid-output', path: ['fixture'], summary: 'Invalid output fixture.', + input: Type.Object({}, { additionalProperties: false }), + output: Type.Object({ ok: Type.Boolean() }, { additionalProperties: false }), + bindings: [], examples: [{ argv: ['fixture'] }], lifecycle: 'finite', effect: 'read', + async execute() { return { data: { ok: 'invalid' } }; }, +}); +(async () => { + const outcome = await executeCommand( + createCommandRegistry([command]), command, {}, + { cwd: process.cwd(), mode: 'agent', sink: createJsonlSink((line) => process.stdout.write(line)) }, + ); + process.exitCode = exitCodeForOutcome(outcome); +})().catch((error) => { + process.stderr.write(error instanceof Error ? error.message : String(error)); + process.exitCode = 71; +});`, + ], + { + cwd: installationDirectory, + env: cliEnvironment, + input: '', + label: 'packed runtime invalid output', + } + ); + assertInternalFailure( + 'packed runtime invalid output', + invalidOutputFixture + ); + + const nonTtyInteractive = run( + process.execPath, + [ + join(cliPackageDirectory, 'index.js'), + 'version', + '--interactive', + '--format', + 'human', + ], + { + cwd: installationDirectory, + env: cliEnvironment, + input: '', + label: 'non-TTY interactive rejection', + } + ); + if ( + nonTtyInteractive.status !== 2 || + nonTtyInteractive.stdout !== '' || + !nonTtyInteractive.stderr.includes('CLI_INTERACTIVE_REQUIRES_TTY') + ) { + fail( + `non-TTY interactive rejection did not exit 2 with CLI_INTERACTIVE_REQUIRES_TTY.\n${nonTtyInteractive.stdout}${nonTtyInteractive.stderr}` + ); + } + + if (process.platform === 'linux') { + const ttyCommand = [ + process.execPath, + join(cliPackageDirectory, 'index.js'), + 'version', + '--interactive', + '--format', + 'human', + ] + .map(shellQuote) + .join(' '); + const ttyProbe = run( + 'script', + ['-q', '-e', '-c', ttyCommand, '/dev/null'], + { + cwd: installationDirectory, + env: cliEnvironment, + input: '', + label: 'Linux TTY interactive probe', + } + ); + assertSuccessfulCheck('Linux TTY interactive probe', ttyProbe); + if ( + ttyProbe.stderr !== '' || + ttyProbe.stdout.trim().length === 0 || + ttyProbe.stdout.includes(protocolVersion) || + /\u001b\[[0-?]*[ -/]*[@-~]/.test(ttyProbe.stdout) + ) { + fail( + `Linux TTY interactive probe emitted unexpected output.\n${ttyProbe.stdout}${ttyProbe.stderr}` + ); + } + } + + if (suite === 'full') { + const codegenProbe = run( + process.execPath, + [ + join(cliPackageDirectory, 'index.js'), + 'codegen', + '--agent', + '--debug', + ], + { + cwd: installationDirectory, + env: cliEnvironment, + label: 'packed codegen adapter', + } + ); + assertCodegenSourceFailure('packed codegen adapter', codegenProbe); + } else { + const unavailableProbes = [ + [ + 'packed codegen capability', + ['codegen', '--agent'], + 'codegen.generate', + ], + ['packed server capability', ['server', '--agent'], 'server.start'], + [ + 'packed explorer capability', + ['explorer', '--agent'], + 'explorer.start', + ], + ['packed jobs capability', ['jobs', 'up', '--agent'], 'jobs.up'], + ]; + for (const [label, args, commandId] of unavailableProbes) { + const child = run( + process.execPath, + [join(cliPackageDirectory, 'index.js'), ...args], + { + cwd: installationDirectory, + env: cliEnvironment, + label, + } + ); + assertCapabilityUnavailable(label, child, commandId); + } + } + + const serviceProbePrefix = ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { createCncRegistryForEnvironment } = require('@constructive-io/cli/runtime'); +const { executeCommand } = require('@constructive-io/cli-runtime'); +const controller = new AbortController(); +const bundle = createCncRegistryForEnvironment({ version: 'test', env: { HOME: process.cwd() }, configDir: process.cwd() }); +let sawReady = false; +const timeout = setTimeout(() => controller.abort(new DOMException('service probe timed out', 'AbortError')), 15000); +`; + const serviceProbeSuffix = ` +clearTimeout(timeout); +if (!sawReady) throw new Error('service never emitted readiness'); +if (outcome.status !== 'cancelled') throw new Error('service did not cancel cleanly: ' + JSON.stringify(outcome.error)); +const names = outcome.protocolEvents.map((event) => event.event); +for (const required of ['service.starting', 'service.ready', 'service.stopping', 'service.stopped', 'operation.cancelled']) { + if (!names.includes(required)) throw new Error('missing lifecycle event ' + required + ': ' + names.join(',')); +} +`; + const serviceChecks = [ + [ + 'packed explorer lifecycle adapter', + `${serviceProbePrefix} +const outcome = await executeCommand(bundle.registry, 'explorer.start', { port: 0 }, { + cwd: process.cwd(), mode: 'agent', env: { HOME: process.cwd() }, signal: controller.signal, + sink: async (event) => { + if (event.event === 'service.ready') { + sawReady = true; + controller.abort(new DOMException('service probe complete', 'AbortError')); + } + }, +}); +${serviceProbeSuffix}`, + ], + [ + 'packed jobs lifecycle adapter', + `${serviceProbePrefix} +const net = require('node:net'); +const port = await new Promise((resolvePort, rejectPort) => { + const reservation = net.createServer(); + reservation.once('error', rejectPort); + reservation.listen(0, '127.0.0.1', () => { + const address = reservation.address(); + reservation.close((error) => error ? rejectPort(error) : resolvePort(address.port)); + }); +}); +const outcome = await executeCommand(bundle.registry, 'jobs.up', { functions: 'send-email=' + port }, { + cwd: process.cwd(), mode: 'agent', env: { HOME: process.cwd() }, signal: controller.signal, + sink: async (event) => { + if (event.event === 'service.ready') { + sawReady = true; + controller.abort(new DOMException('service probe complete', 'AbortError')); + } + }, +}); +${serviceProbeSuffix}`, + ], + ]; + if (suite === 'full') { + for (const [label, source] of serviceChecks) { + const child = run( + process.execPath, + ['--input-type=module', '-e', source], + { + cwd: installationDirectory, + env: cliEnvironment, + timeout: 30000, + label, + } + ); + assertSuccessfulCheck(label, child); + if (child.stdout !== '' || child.stderr !== '') { + fail( + `${label} produced unexpected output.\n${child.stdout}${child.stderr}` + ); + } + } + + const signalSupervisor = ` +import { spawn } from 'node:child_process'; +const child = spawn(process.execPath, [ + ${JSON.stringify(join(cliPackageDirectory, 'index.js'))}, + 'explorer', '--port', '0', '--agent', +], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); +let stdout = ''; +let stderr = ''; +let pending = ''; +let signalled = false; +let timedOut = false; +child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + stdout += text; + pending += text; + for (;;) { + const newline = pending.indexOf('\\n'); + if (newline < 0) break; + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + try { + const event = JSON.parse(line); + if (!signalled && event.event === 'service.ready') { + signalled = true; + child.kill('SIGINT'); + } + } catch {} + } +}); +child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); +const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); +}, 20000); +const result = await new Promise((resolveClose) => { + child.once('close', (code, signal) => resolveClose({ code, signal })); +}); +clearTimeout(timeout); +process.stdout.write(stdout); +process.stderr.write(stderr); +if (timedOut) process.stderr.write('explorer SIGINT probe timed out\\n'); +process.exitCode = result.code ?? 1; +`; + const signalProbe = run( + process.execPath, + ['--input-type=module', '-e', signalSupervisor], + { + cwd: installationDirectory, + env: cliEnvironment, + input: '', + timeout: 30000, + label: 'packed explorer SIGINT lifecycle', + } + ); + assertSignalCancellation('packed explorer SIGINT lifecycle', signalProbe); + } + + const aliasChecks = [ + ['cnc bin alias', join(binaryDirectory, `cnc${binarySuffix}`)], + [ + 'constructive bin alias', + join(binaryDirectory, `constructive${binarySuffix}`), + ], + ]; + for (const [label, command] of aliasChecks) { + const child = run(command, ['version', '--agent'], { + cwd: installationDirectory, + env: cliEnvironment, + label, + }); + assertCliProtocol(label, child); + } + + process.stdout.write( + `Packed CNC ${suite} acceptance passed on Node ${process.version}.\n` + ); + } finally { + rmSync(installationDirectory, { recursive: true, force: true }); + } +}; + +try { + main(); +} catch (error) { + process.stderr.write( + `Packed CNC acceptance failed on Node ${process.version}: ${ + error instanceof Error ? error.message : String(error) + }\n` + ); + process.exitCode = 1; +} From 15b7b4bf5af34d3ffc4c6ee9be3ed5d05bbc37a5 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:04:27 +0700 Subject: [PATCH 09/18] fix(jobs): await worker initialization before readiness --- .../job-scheduler/__tests__/lifecycle.test.ts | 32 ++++++++++++++++ jobs/job-scheduler/src/index.ts | 38 +++++++++++++++---- .../__tests__/lifecycle.test.ts | 32 ++++++++++++++++ jobs/knative-job-worker/src/index.ts | 38 +++++++++++++++---- 4 files changed, 126 insertions(+), 14 deletions(-) diff --git a/jobs/job-scheduler/__tests__/lifecycle.test.ts b/jobs/job-scheduler/__tests__/lifecycle.test.ts index 3d1d875f83..9f6d7608dc 100644 --- a/jobs/job-scheduler/__tests__/lifecycle.test.ts +++ b/jobs/job-scheduler/__tests__/lifecycle.test.ts @@ -1,5 +1,7 @@ import type { Pool } from 'pg'; import { withLogsSuppressed } from '@pgpmjs/logger'; +import { EventEmitter } from 'node:events'; +import * as jobUtils from '@constructive-io/job-utils'; import Scheduler from '../src'; @@ -37,4 +39,34 @@ describe('job scheduler lifecycle', () => { jest.advanceTimersByTime(5000); expect(connect).toHaveBeenCalledTimes(1); }); + + it('does not become ready before initialization succeeds', async () => { + const failure = new Error('scheduler initialization failed'); + const releaseScheduledJobs = jest + .spyOn(jobUtils, 'releaseScheduledJobs') + .mockRejectedValue(failure); + const client = Object.assign(new EventEmitter(), { + query: jest.fn(async (): Promise => undefined), + release: jest.fn(), + }); + const scheduler = new Scheduler({ + tasks: [], + pgPool: { + connect: jest.fn(async () => client), + } as unknown as Pool, + lifecycle: { + close: jest.fn(async (): Promise => undefined), + onClose: jest.fn(), + }, + failFastOnInitialConnect: true, + }); + + try { + await expect(scheduler.listen()).rejects.toBe(failure); + expect(client.release).toHaveBeenCalledTimes(1); + } finally { + releaseScheduledJobs.mockRestore(); + await scheduler.stop(); + } + }); }); diff --git a/jobs/job-scheduler/src/index.ts b/jobs/job-scheduler/src/index.ts index 08b04d2cd5..2d307432f4 100644 --- a/jobs/job-scheduler/src/index.ts +++ b/jobs/job-scheduler/src/index.ts @@ -36,6 +36,7 @@ export default class Scheduler { private readonly lifecycle: Pick; private readonly onFatal?: (error: Error) => void; private readonly failFastOnInitialConnect: boolean; + private polling?: Promise; constructor({ tasks, @@ -97,7 +98,6 @@ export default class Scheduler { this.runtime ); this._initialized = true; - await this.doNext(client); } async handleFatalError( client: PgClientLike, @@ -179,7 +179,7 @@ export default class Scheduler { async doNext(client: PgClientLike): Promise { if (this.stopped) return; if (!this._initialized) { - return await this.initialize(client); + await this.initialize(client); } if (this.doNextTimer) { @@ -200,7 +200,7 @@ export default class Scheduler { if (!job || !job.id) { if (!this.stopped) { this.doNextTimer = setTimeout( - () => this.doNext(client), + () => this.startPolling(client), this.idleDelay ); } @@ -236,12 +236,27 @@ export default class Scheduler { } catch (err: unknown) { if (!this.stopped) { this.doNextTimer = setTimeout( - () => this.doNext(client), + () => this.startPolling(client), this.idleDelay ); } } } + + private startPolling(client: PgClientLike): void { + if (this.stopped || this.polling) return; + const polling = this.doNext(client) + .catch((error: unknown) => { + if (this.stopped) return; + const fatal = error instanceof Error ? error : new Error(String(error)); + this.onFatal?.(fatal); + }) + .finally(() => { + if (this.polling === polling) this.polling = undefined; + }); + this.polling = polling; + } + async listen(): Promise { if (this.stopped) return; let client: PoolClient; @@ -275,15 +290,21 @@ export default class Scheduler { log.info('a NEW scheduled JOB!'); if (this.doNextTimer) { // Must be idle, do something! - void this.doNext(client); + this.startPolling(client); } }); try { await client.query('LISTEN "scheduled_jobs:insert"'); + await this.initialize(client); } catch (error) { this.listenClient = undefined; this.listenRelease = undefined; client.removeAllListeners('notification'); + try { + await client.query('UNLISTEN "scheduled_jobs:insert"'); + } catch { + // The listener may not have been installed yet. + } release(); if (this.failFastOnInitialConnect && !this._initialized) throw error; this.scheduleReconnect(); @@ -310,14 +331,17 @@ export default class Scheduler { this.scheduleReconnect(); }); log.info(`${this.workerId} connected and looking for scheduled jobs...`); - void this.doNext(client); + this.startPolling(client); } private scheduleReconnect(): void { if (this.stopped || this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; - void this.listen(); + void this.listen().catch((error: unknown) => { + const fatal = error instanceof Error ? error : new Error(String(error)); + this.onFatal?.(fatal); + }); }, 5000); this.reconnectTimer.unref?.(); } diff --git a/jobs/knative-job-worker/__tests__/lifecycle.test.ts b/jobs/knative-job-worker/__tests__/lifecycle.test.ts index 8505647bdb..62687429cd 100644 --- a/jobs/knative-job-worker/__tests__/lifecycle.test.ts +++ b/jobs/knative-job-worker/__tests__/lifecycle.test.ts @@ -1,5 +1,7 @@ import type { Pool } from 'pg'; import { withLogsSuppressed } from '@pgpmjs/logger'; +import { EventEmitter } from 'node:events'; +import * as jobUtils from '@constructive-io/job-utils'; import Worker from '../src'; @@ -34,4 +36,34 @@ describe('job worker lifecycle', () => { jest.advanceTimersByTime(5000); expect(connect).toHaveBeenCalledTimes(1); }); + + it('does not become ready before initialization succeeds', async () => { + const failure = new Error('worker initialization failed'); + const releaseJobs = jest + .spyOn(jobUtils, 'releaseJobs') + .mockRejectedValue(failure); + const client = Object.assign(new EventEmitter(), { + query: jest.fn(async (): Promise => undefined), + release: jest.fn(), + }); + const worker = new Worker({ + tasks: [], + pgPool: { + connect: jest.fn(async () => client), + } as unknown as Pool, + lifecycle: { + close: jest.fn(async (): Promise => undefined), + onClose: jest.fn(), + }, + failFastOnInitialConnect: true, + }); + + try { + await expect(worker.listen()).rejects.toBe(failure); + expect(client.release).toHaveBeenCalledTimes(1); + } finally { + releaseJobs.mockRestore(); + await worker.stop(); + } + }); }); diff --git a/jobs/knative-job-worker/src/index.ts b/jobs/knative-job-worker/src/index.ts index 687a9135f5..63f6652205 100644 --- a/jobs/knative-job-worker/src/index.ts +++ b/jobs/knative-job-worker/src/index.ts @@ -32,6 +32,7 @@ export default class Worker { private readonly lifecycle: Pick; private readonly onFatal?: (error: Error) => void; private readonly failFastOnInitialConnect: boolean; + private polling?: Promise; constructor({ tasks, @@ -80,7 +81,6 @@ export default class Worker { await jobs.releaseJobs(client, { workerId: this.workerId }, this.runtime); this._initialized = true; - await this.doNext(client); } async handleFatalError( client: PgClientLike, @@ -152,7 +152,7 @@ export default class Worker { async doNext(client: PgClientLike): Promise { if (this.stopped) return; if (!this._initialized) { - return await this.initialize(client); + await this.initialize(client); } log.debug('checking for jobs...'); @@ -175,7 +175,7 @@ export default class Worker { if (!job || !job.id) { if (!this.stopped) { this.doNextTimer = setTimeout( - () => this.doNext(client), + () => this.startPolling(client), this.idleDelay ); } @@ -210,12 +210,27 @@ export default class Worker { } catch (err: unknown) { if (!this.stopped) { this.doNextTimer = setTimeout( - () => this.doNext(client), + () => this.startPolling(client), this.idleDelay ); } } } + + private startPolling(client: PgClientLike): void { + if (this.stopped || this.polling) return; + const polling = this.doNext(client) + .catch((error: unknown) => { + if (this.stopped) return; + const fatal = error instanceof Error ? error : new Error(String(error)); + this.onFatal?.(fatal); + }) + .finally(() => { + if (this.polling === polling) this.polling = undefined; + }); + this.polling = polling; + } + async listen(): Promise { if (this.stopped) return; let client: PoolClient; @@ -248,15 +263,21 @@ export default class Worker { client.on('notification', () => { if (this.doNextTimer) { // Must be idle, do something! - void this.doNext(client); + this.startPolling(client); } }); try { await client.query('LISTEN "jobs:insert"'); + await this.initialize(client); } catch (error) { this.listenClient = undefined; this.listenRelease = undefined; client.removeAllListeners('notification'); + try { + await client.query('UNLISTEN "jobs:insert"'); + } catch { + // The listener may not have been installed yet. + } release(); if (this.failFastOnInitialConnect && !this._initialized) throw error; this.scheduleReconnect(); @@ -283,14 +304,17 @@ export default class Worker { this.scheduleReconnect(); }); log.info(`${this.workerId} connected and looking for jobs...`); - void this.doNext(client); + this.startPolling(client); } private scheduleReconnect(): void { if (this.stopped || this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; - void this.listen(); + void this.listen().catch((error: unknown) => { + const fatal = error instanceof Error ? error : new Error(String(error)); + this.onFatal?.(fatal); + }); }, 5000); this.reconnectTimer.unref?.(); } From ac92ac04379c825d7a75cc86086f114bbc812407 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:06:52 +0700 Subject: [PATCH 10/18] fix(codegen): roll back staged manifest publication --- .../codegen/write-generated-files.test.ts | 52 +++++++++++++++++++ graphql/codegen/src/core/output/writer.ts | 21 ++++---- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts b/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts index 2f1a3565fd..87efd604e3 100644 --- a/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts +++ b/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts @@ -387,6 +387,58 @@ describe('writeGeneratedFiles', () => { ).toBe(true); }); + it('leaves no manifest staging artifact when publication fails', async () => { + const generated = path.join(tempDir, 'model.ts'); + const manifest = path.join(tempDir, GENERATED_FILES_MANIFEST); + const initial = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 1;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + expect(initial.success).toBe(true); + const before = { + entries: fs.readdirSync(tempDir).sort(), + generated: fs.readFileSync(generated), + manifest: fs.readFileSync(manifest), + }; + + const nativeFs = require('node:fs') as typeof fs; + const originalRename = nativeFs.renameSync; + let failureInjected = false; + const renameSpy = jest + .spyOn(nativeFs, 'renameSync') + .mockImplementation((source, destination) => { + if ( + !failureInjected && + String(source).includes('.codegen-transaction-') && + path.basename(String(source)) === GENERATED_FILES_MANIFEST && + path.basename(String(destination)) === GENERATED_FILES_MANIFEST + ) { + failureInjected = true; + throw new Error('injected manifest publication failure'); + } + return originalRename(source, destination); + }); + + let result: Awaited>; + try { + result = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 2;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + } finally { + renameSpy.mockRestore(); + } + + expect(result.success).toBe(false); + expect(fs.readdirSync(tempDir).sort()).toEqual(before.entries); + expect(fs.readFileSync(generated)).toEqual(before.generated); + expect(fs.readFileSync(manifest)).toEqual(before.manifest); + }); + it('does not roll back committed files when transaction cleanup fails', async () => { const generated = path.join(tempDir, 'model.ts'); const initial = await writeGeneratedFiles( diff --git a/graphql/codegen/src/core/output/writer.ts b/graphql/codegen/src/core/output/writer.ts index 0b20eb8e6f..f70745aebf 100644 --- a/graphql/codegen/src/core/output/writer.ts +++ b/graphql/codegen/src/core/output/writer.ts @@ -694,6 +694,14 @@ function stagePlan(transaction: PlanTransaction): void { fs.mkdirSync(path.dirname(stagedPath), { recursive: true }); fs.writeFileSync(stagedPath, desired.content, 'utf8'); } + if (prepared.manifestChanged && prepared.manifestContent !== undefined) { + const stagedManifestPath = path.join(stagedRoot, GENERATED_FILES_MANIFEST); + fs.mkdirSync(path.dirname(stagedManifestPath), { recursive: true }); + fs.writeFileSync(stagedManifestPath, prepared.manifestContent, { + encoding: 'utf8', + mode: 0o600, + }); + } } function readOptionalHash(filePath: string): string | undefined { @@ -774,7 +782,7 @@ function commitPlanFiles( } function publishPlanManifest(transaction: PlanTransaction): void { - const { prepared, backupRoot } = transaction; + const { prepared, backupRoot, stagedRoot } = transaction; if (!prepared.manifestChanged) return; const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); @@ -785,15 +793,8 @@ function publishPlanManifest(transaction: PlanTransaction): void { } if (prepared.manifestContent === undefined) return; - const manifestTempPath = path.join( - prepared.publicPlan.outputDir, - `.${GENERATED_FILES_MANIFEST}.${process.pid}.${Date.now()}.tmp` - ); - fs.writeFileSync(manifestTempPath, prepared.manifestContent, { - encoding: 'utf8', - mode: 0o600, - }); - fs.renameSync(manifestTempPath, prepared.publicPlan.manifestPath); + const stagedManifestPath = path.join(stagedRoot, GENERATED_FILES_MANIFEST); + fs.renameSync(stagedManifestPath, prepared.publicPlan.manifestPath); transaction.manifestPublished = true; } From f9401136e69f31944caaf72cbdff08fbd624ce6d Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:12:37 +0700 Subject: [PATCH 11/18] fix(codegen): serialize output planning and writes --- .../codegen/write-generated-files.test.ts | 44 ++++ graphql/codegen/src/core/output/lock.ts | 197 ++++++++++++++++++ graphql/codegen/src/core/output/writer.ts | 55 +++-- 3 files changed, 273 insertions(+), 23 deletions(-) create mode 100644 graphql/codegen/src/core/output/lock.ts diff --git a/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts b/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts index 87efd604e3..4da608e040 100644 --- a/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts +++ b/graphql/codegen/src/__tests__/codegen/write-generated-files.test.ts @@ -1,12 +1,14 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { createHash } from 'node:crypto'; import { GENERATED_FILES_MANIFEST, writeGeneratedFileJobs, writeGeneratedFiles, } from '../../core/output'; +import { acquireOutputLocks } from '../../core/output/lock'; describe('writeGeneratedFiles', () => { let tempDir: string; @@ -358,6 +360,9 @@ describe('writeGeneratedFiles', () => { const renameSpy = jest .spyOn(nativeFs, 'renameSync') .mockImplementation((source, destination) => { + if (String(source).includes('.constructive-codegen.lock.')) { + return originalRename(source, destination); + } renameCall += 1; if (renameCall === 2) throw new Error('injected commit failure'); if (renameCall === 3) throw new Error('injected restore failure'); @@ -528,6 +533,45 @@ describe('writeGeneratedFiles', () => { ).toContain('handwritten'); }); + it('plans against state observed while holding the output lock', async () => { + const generated = path.join(tempDir, 'model.ts'); + const manifestPath = path.join(tempDir, GENERATED_FILES_MANIFEST); + const initial = await writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 1;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + expect(initial.success).toBe(true); + + const locks = await acquireOutputLocks([tempDir]); + const pending = writeGeneratedFiles( + [{ path: 'model.ts', content: 'export const version = 3;\n' }], + tempDir, + [], + { showProgress: false, formatFiles: false } + ); + const concurrentContent = 'export const version = 2;\n'; + const concurrentHash = createHash('sha256') + .update(concurrentContent) + .digest('hex'); + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + fs.writeFileSync(generated, concurrentContent); + manifest.files['model.ts'].sha256 = concurrentHash; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } finally { + await locks.release(); + } + + const result = await pending; + expect(result.success).toBe(true); + expect( + result.plan?.changes.find((change) => change.path === 'model.ts') + ?.previousHash + ).toBe(concurrentHash); + }); + it('rolls back earlier output roots when a later commit fails', async () => { const firstOutput = path.join(tempDir, 'first'); const secondOutput = path.join(tempDir, 'second'); diff --git a/graphql/codegen/src/core/output/lock.ts b/graphql/codegen/src/core/output/lock.ts new file mode 100644 index 0000000000..40dad5d93c --- /dev/null +++ b/graphql/codegen/src/core/output/lock.ts @@ -0,0 +1,197 @@ +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import { hostname } from 'node:os'; +import * as path from 'node:path'; + +import { getAbortReason, throwIfAborted } from '../cancellation'; + +const LOCK_TIMEOUT_MS = 30_000; +const LOCK_RETRY_MS = 25; +const OWNER_FILENAME = 'owner.json'; +const REAPER_FILENAME = '.reaper'; + +interface LockOwner { + token: string; + pid: number; + hostname: string; + createdAt: string; +} + +export interface OutputLockSet { + release(): Promise; +} + +const waitForRetry = async ( + milliseconds: number, + signal?: AbortSignal +): Promise => { + throwIfAborted(signal); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort); + resolve(); + }, milliseconds); + const handleAbort = () => { + clearTimeout(timer); + reject(getAbortReason(signal!)); + }; + signal?.addEventListener('abort', handleAbort, { once: true }); + }); +}; + +const lockPathFor = (outputDir: string): string => + path.join( + path.dirname(outputDir), + `.${path.basename(outputDir) || 'root'}.constructive-codegen.lock` + ); + +const readOwner = (lockPath: string): LockOwner | undefined => { + try { + const owner = JSON.parse( + fs.readFileSync(path.join(lockPath, OWNER_FILENAME), 'utf8') + ) as Partial; + if ( + typeof owner.token !== 'string' || + typeof owner.pid !== 'number' || + typeof owner.hostname !== 'string' || + typeof owner.createdAt !== 'string' + ) { + return undefined; + } + return owner as LockOwner; + } catch { + return undefined; + } +}; + +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +}; + +const reclaimDeadLock = (lockPath: string, expected: LockOwner): boolean => { + if (expected.hostname !== hostname() || processIsAlive(expected.pid)) { + return false; + } + + const reaperPath = path.join(lockPath, REAPER_FILENAME); + let descriptor: number | undefined; + try { + descriptor = fs.openSync(reaperPath, 'wx', 0o600); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'EEXIST') return false; + throw error; + } + + try { + const current = readOwner(lockPath); + if (!current || current.token !== expected.token) return false; + fs.unlinkSync(path.join(lockPath, OWNER_FILENAME)); + fs.closeSync(descriptor); + descriptor = undefined; + fs.unlinkSync(reaperPath); + fs.rmdirSync(lockPath); + return true; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(reaperPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +}; + +const tryCreateLock = (lockPath: string, owner: LockOwner): boolean => { + const candidate = `${lockPath}.${owner.token}.tmp`; + fs.mkdirSync(candidate, { recursive: false, mode: 0o700 }); + try { + fs.writeFileSync( + path.join(candidate, OWNER_FILENAME), + `${JSON.stringify(owner)}\n`, + { encoding: 'utf8', mode: 0o600 } + ); + try { + fs.renameSync(candidate, lockPath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM') { + return false; + } + throw error; + } + } finally { + fs.rmSync(candidate, { recursive: true, force: true }); + } +}; + +const releaseLock = (lockPath: string, token: string): void => { + const owner = readOwner(lockPath); + if (!owner || owner.token !== token) { + throw new Error(`Generated output lock ownership changed: ${lockPath}`); + } + fs.unlinkSync(path.join(lockPath, OWNER_FILENAME)); + fs.rmdirSync(lockPath); +}; + +const acquireLock = async ( + outputDir: string, + signal?: AbortSignal +): Promise<() => void> => { + const lockPath = lockPathFor(outputDir); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + const owner: LockOwner = { + token: randomUUID(), + pid: process.pid, + hostname: hostname(), + createdAt: new Date().toISOString(), + }; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (true) { + throwIfAborted(signal); + const stat = fs.lstatSync(lockPath, { throwIfNoEntry: false }); + if (stat?.isSymbolicLink()) { + throw new Error(`Refusing symbolic-link output lock: ${lockPath}`); + } + if (!stat && tryCreateLock(lockPath, owner)) { + return () => releaseLock(lockPath, owner.token); + } + + const existing = readOwner(lockPath); + if (existing && reclaimDeadLock(lockPath, existing)) continue; + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for generated output lock: ${lockPath}` + ); + } + await waitForRetry(LOCK_RETRY_MS, signal); + } +}; + +export async function acquireOutputLocks( + outputDirs: readonly string[], + signal?: AbortSignal +): Promise { + const releases: Array<() => void> = []; + try { + for (const outputDir of [...new Set(outputDirs)].sort()) { + releases.push(await acquireLock(outputDir, signal)); + } + } catch (error) { + for (const release of releases.reverse()) release(); + throw error; + } + + return { + async release(): Promise { + for (const release of releases.reverse()) release(); + }, + }; +} diff --git a/graphql/codegen/src/core/output/writer.ts b/graphql/codegen/src/core/output/writer.ts index f70745aebf..b5da4e016b 100644 --- a/graphql/codegen/src/core/output/writer.ts +++ b/graphql/codegen/src/core/output/writer.ts @@ -11,6 +11,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { GeneratedFile } from '../codegen'; +import { rethrowIfCancelled, throwIfAborted } from '../cancellation'; +import { acquireOutputLocks } from './lock'; export type { GeneratedFile }; @@ -1135,32 +1137,24 @@ export async function writeGeneratedFileJobs( jobs: GeneratedFileWriteJob[], options: WriteBatchOptions = {} ): Promise { - if (options.signal?.aborted) { - throw ( - options.signal.reason ?? - Object.assign(new Error('Operation cancelled.'), { name: 'AbortError' }) - ); - } - let preparedPlans: PreparedPlan[]; - try { - preparedPlans = await Promise.all( - mergeWriteJobs(jobs).map((job) => + throwIfAborted(options.signal); + const mergedJobs = mergeWriteJobs(jobs); + const preparePlans = async (): Promise => + Promise.all( + mergedJobs.map((job) => prepareGenerationPlan(job.files, job.outputDir, job.options ?? {}) ) ); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { success: false, results: [], errors: [message] }; - } - - if (options.signal?.aborted) { - throw ( - options.signal.reason ?? - Object.assign(new Error('Operation cancelled.'), { name: 'AbortError' }) - ); - } - if (options.dryRun) { + let preparedPlans: PreparedPlan[]; + try { + preparedPlans = await preparePlans(); + throwIfAborted(options.signal); + } catch (error) { + rethrowIfCancelled(error, options.signal); + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, results: [], errors: [message] }; + } const results = preparedPlans.map((prepared) => { const errors = conflictErrors(prepared.publicPlan); return resultForPrepared(prepared, { @@ -1176,5 +1170,20 @@ export async function writeGeneratedFileJobs( }; } - return applyPreparedPlans(preparedPlans, options.showProgress ?? true); + let locks: Awaited> | undefined; + try { + locks = await acquireOutputLocks( + mergedJobs.map((job) => job.outputDir), + options.signal + ); + const preparedPlans = await preparePlans(); + throwIfAborted(options.signal); + return applyPreparedPlans(preparedPlans, options.showProgress ?? true); + } catch (error) { + rethrowIfCancelled(error, options.signal); + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, results: [], errors: [message] }; + } finally { + await locks?.release(); + } } From c67b8a0a411c26ff627fc179a3b9d455fda7ae9b Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:16:07 +0700 Subject: [PATCH 12/18] fix(cli): resolve auth from one state snapshot --- packages/cli/__tests__/sdk.test.ts | 33 ++++++++++++++++ packages/cli/src/config/config-manager.ts | 6 ++- packages/cli/src/config/resolution.ts | 27 ++++++++----- packages/cli/src/runtime/state-commands.ts | 46 +++++++++++++--------- packages/cli/src/sdk/executor.ts | 8 ++-- 5 files changed, 88 insertions(+), 32 deletions(-) diff --git a/packages/cli/__tests__/sdk.test.ts b/packages/cli/__tests__/sdk.test.ts index d8549fc326..64d14bc86e 100644 --- a/packages/cli/__tests__/sdk.test.ts +++ b/packages/cli/__tests__/sdk.test.ts @@ -2,7 +2,9 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + CURRENT_STATE_VERSION, ConfigStore, + type CncState, createContext, setContextCredentials, } from '../src/config'; @@ -203,4 +205,35 @@ describe('GraphQL operation analysis and execution', () => { }); fs.rmSync(root, { recursive: true, force: true }); }); + + it('resolves endpoint and credentials from one state snapshot', async () => { + const state = (endpoint: string, token: string): CncState => ({ + stateVersion: CURRENT_STATE_VERSION, + settings: {}, + contexts: { + production: { + name: 'production', + endpoint, + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }, + }, + credentials: { tokens: { production: { token } } }, + }); + const read = jest + .fn() + .mockReturnValueOnce(state('https://old.example/graphql', 'old-token')) + .mockReturnValue(state('https://new.example/graphql', 'new-token')); + + await expect( + getExecutionContext({ + contextName: 'production', + store: { read } as unknown as ConfigStore, + }) + ).resolves.toMatchObject({ + context: { endpoint: 'https://old.example/graphql' }, + token: 'old-token', + }); + expect(read).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/cli/src/config/config-manager.ts b/packages/cli/src/config/config-manager.ts index adf20ea5ca..b5e7e7cba4 100644 --- a/packages/cli/src/config/config-manager.ts +++ b/packages/cli/src/config/config-manager.ts @@ -824,7 +824,11 @@ export function deleteContext( export function listContexts( store: ConfigStore = getDefaultConfigStore() ): ContextConfig[] { - return Object.values(store.read().contexts).sort((a, b) => + return listContextsFromState(store.read()); +} + +export function listContextsFromState(state: CncState): ContextConfig[] { + return Object.values(state.contexts).sort((a, b) => a.name.localeCompare(b.name) ); } diff --git a/packages/cli/src/config/resolution.ts b/packages/cli/src/config/resolution.ts index 3fe9a1803a..baf3c07279 100644 --- a/packages/cli/src/config/resolution.ts +++ b/packages/cli/src/config/resolution.ts @@ -4,7 +4,7 @@ import { getDefaultConfigStore, validateContextName, } from './config-manager'; -import type { ContextConfig } from './types'; +import type { CncState, ContextConfig } from './types'; export type EnvironmentMap = Readonly>; @@ -20,14 +20,13 @@ export interface ResolvedContext { source: 'argument' | 'environment' | 'current'; } -/** - * Resolve an explicit argument first, then CNC_CONTEXT, then (for human - * compatibility only) the globally selected context. - */ -export function resolveContext( - options: ResolveContextOptions = {} +type ResolveContextFromStateOptions = Omit; + +/** Resolve a context from one immutable state snapshot. */ +export function resolveContextFromState( + state: CncState, + options: ResolveContextFromStateOptions = {} ): ResolvedContext { - const store = options.store ?? getDefaultConfigStore(); const explicit = options.contextName?.trim(); const fromEnvironment = options.env?.CNC_CONTEXT?.trim(); const selected = explicit || fromEnvironment; @@ -36,7 +35,6 @@ export function resolveContext( : fromEnvironment ? 'environment' : 'current'; - const state = store.read(); if (!selected) { if ( @@ -70,5 +68,16 @@ export function resolveContext( return { context, source }; } +/** + * Resolve an explicit argument first, then CNC_CONTEXT, then (for human + * compatibility only) the globally selected context. + */ +export function resolveContext( + options: ResolveContextOptions = {} +): ResolvedContext { + const store = options.store ?? getDefaultConfigStore(); + return resolveContextFromState(store.read(), options); +} + // Preserve a named type export for callers that only import this module. export type { ConfigStoreError }; diff --git a/packages/cli/src/runtime/state-commands.ts b/packages/cli/src/runtime/state-commands.ts index f502a07b02..e00be929c2 100644 --- a/packages/cli/src/runtime/state-commands.ts +++ b/packages/cli/src/runtime/state-commands.ts @@ -13,13 +13,15 @@ import { deleteContext, getContextCredentials, getCurrentContext, - listContexts, + listContextsFromState, removeContextCredentials, resolveContext, + resolveContextFromState, resolveToken, setContextCredentials, setCurrentContext, type ConfigStore, + type CncState, type ContextConfig, type ContextCredentials, } from '../config'; @@ -146,15 +148,22 @@ const summarizeContext = ( const resolveTargetContext = ( contextName: string | undefined, context: OperationContext, - store: ConfigStore + store: ConfigStore, + state?: CncState ) => runStateOperation(() => - resolveContext({ - contextName, - env: context.env, - allowCurrentContext: context.mode === 'human', - store, - }) + state + ? resolveContextFromState(state, { + contextName, + env: context.env, + allowCurrentContext: context.mode === 'human', + }) + : resolveContext({ + contextName, + env: context.env, + allowCurrentContext: context.mode === 'human', + store, + }) ).context; const ContextNameInputSchema = Type.Object( @@ -244,14 +253,13 @@ export function createContextCommands(dependencies: StateCommandDependencies) { const state = runStateOperation(() => store.read()); return { data: { - contexts: runStateOperation(() => listContexts(store)).map( - (configured) => - summarizeContext( - configured, - state.settings.currentContext, - state.credentials.tokens[configured.name] ?? null, - context.now() - ) + contexts: listContextsFromState(state).map((configured) => + summarizeContext( + configured, + state.settings.currentContext, + state.credentials.tokens[configured.name] ?? null, + context.now() + ) ), }, }; @@ -579,9 +587,11 @@ export function createAuthCommands(dependencies: StateCommandDependencies) { const state = runStateOperation(() => store.read()); let selected: ContextConfig[]; if (input.contextName || context.env.CNC_CONTEXT) { - selected = [resolveTargetContext(input.contextName, context, store)]; + selected = [ + resolveTargetContext(input.contextName, context, store, state), + ]; } else if (context.mode === 'human') { - selected = runStateOperation(() => listContexts(store)); + selected = listContextsFromState(state); } else { throw new CliError({ code: 'CONTEXT_REQUIRED', diff --git a/packages/cli/src/sdk/executor.ts b/packages/cli/src/sdk/executor.ts index ba6f6709d3..49253bc0fc 100644 --- a/packages/cli/src/sdk/executor.ts +++ b/packages/cli/src/sdk/executor.ts @@ -9,7 +9,7 @@ import { import { ConfigStoreError, getDefaultConfigStore, - resolveContext, + resolveContextFromState, type ConfigStore, type ContextConfig, type EnvironmentMap, @@ -188,17 +188,17 @@ export async function getExecutionContext( ? { contextName: contextNameOrOptions } : (contextNameOrOptions ?? {}); const store = options.store ?? getDefaultConfigStore(); - const resolved = resolveContext({ + const state = store.read(); + const resolved = resolveContextFromState(state, { contextName: options.contextName, env: options.env, allowCurrentContext: options.allowCurrentContext ?? !options.agent, - store, }); if (options.anonymous) { return { context: resolved.context, anonymous: true }; } - const credentials = store.read().credentials.tokens[resolved.context.name]; + const credentials = state.credentials.tokens[resolved.context.name]; if (!credentials?.token) { throw new GraphQLExecutionError( 'AUTH_REQUIRED', From 7396a21facf8ebebc3fc50ea6fd67a3ad32cc8c5 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:18:18 +0700 Subject: [PATCH 13/18] fix(cli-runtime): validate command metadata on registration --- .../__tests__/registry-bindings.test.ts | 47 +++++++++++++ packages/cli-runtime/src/registry.ts | 66 +++++++++++++++++-- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/cli-runtime/__tests__/registry-bindings.test.ts b/packages/cli-runtime/__tests__/registry-bindings.test.ts index d8d82e9164..1679147029 100644 --- a/packages/cli-runtime/__tests__/registry-bindings.test.ts +++ b/packages/cli-runtime/__tests__/registry-bindings.test.ts @@ -184,4 +184,51 @@ describe('command registry and argument bindings', () => { expect.objectContaining({ code: 'CLI_EVENT_SCHEMA_INVALID' }) ); }); + + it('enforces metadata and shell-bound requirements at registration', () => { + const command = createExampleCommand(); + const malformedBinding = { + ...command, + bindings: [ + { + ...command.bindings[0], + description: 42, + }, + ...command.bindings.slice(1), + ], + } as unknown as typeof command; + expect(() => createCommandRegistry([malformedBinding])).toThrow( + expect.objectContaining({ code: 'CLI_BINDING_SCHEMA_INVALID' }) + ); + + const missingRequiredShellInput = defineCommand({ + id: 'adapter.run', + path: ['adapter', 'run'], + summary: 'Run with adapter input.', + input: Type.Object( + { + name: Type.String({ minLength: 1 }), + adapterValue: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ), + output: Type.Null(), + bindings: [ + { + property: 'name', + sources: [{ kind: 'option', name: 'name' }], + }, + { property: 'adapterValue', sources: [] }, + ], + examples: [{ argv: ['adapter', 'run'] }], + lifecycle: 'finite', + effect: 'read', + async execute() { + return { data: null }; + }, + }); + expect(() => createCommandRegistry([missingRequiredShellInput])).toThrow( + expect.objectContaining({ code: 'CLI_COMMAND_EXAMPLE_INVALID' }) + ); + }); }); diff --git a/packages/cli-runtime/src/registry.ts b/packages/cli-runtime/src/registry.ts index 501cf4435f..116c180106 100644 --- a/packages/cli-runtime/src/registry.ts +++ b/packages/cli-runtime/src/registry.ts @@ -94,6 +94,42 @@ export type CommandSchemaDocument = Static; const commandSchemaDocumentValidator = compileSchema( CommandSchemaDocumentSchema ); +const inputBindingMetadataValidator = compileSchema(InputBindingSchema); +const commandExampleMetadataValidator = compileSchema(CommandExampleSchema); +const safetyCapabilitiesMetadataValidator = compileSchema( + SafetyCapabilitiesSchema +); + +function validateMetadata(command: CommandDefinition): void { + for (const [index, binding] of command.bindings.entries()) { + if (!inputBindingMetadataValidator.validate(binding)) { + throw new ContractError( + 'CLI_BINDING_SCHEMA_INVALID', + `Binding ${index + 1} in "${command.id}" violates the command metadata schema.`, + { issues: inputBindingMetadataValidator.issues() } + ); + } + } + for (const [index, example] of command.examples.entries()) { + if (!commandExampleMetadataValidator.validate(example)) { + throw new ContractError( + 'CLI_COMMAND_EXAMPLE_INVALID', + `Example ${index + 1} for "${command.id}" violates the command metadata schema.`, + { issues: commandExampleMetadataValidator.issues() } + ); + } + } + if ( + command.capabilities !== undefined && + !safetyCapabilitiesMetadataValidator.validate(command.capabilities) + ) { + throw new ContractError( + 'CLI_COMMAND_CAPABILITY_INVALID', + `Capabilities for "${command.id}" violate the command metadata schema.`, + { issues: safetyCapabilitiesMetadataValidator.issues() } + ); + } +} function snapshotCapabilities( capabilities: SafetyCapabilities | undefined @@ -640,6 +676,22 @@ function validateBindings(command: CommandDefinition): void { } function validateExamples(command: CommandDefinition): void { + const sourceLessProperties = new Set( + command.bindings + .filter((binding) => binding.sources.length === 0) + .map((binding) => binding.property) + ); + const exampleInputSchema = cloneSchema(command.input) as TSchema & { + required?: unknown; + }; + if (Array.isArray(exampleInputSchema.required)) { + exampleInputSchema.required = exampleInputSchema.required.filter( + (property): property is string => + typeof property === 'string' && !sourceLessProperties.has(property) + ); + } + const exampleInputValidator = compileSchema(exampleInputSchema); + for (const [index, example] of command.examples.entries()) { if ( example === null || @@ -681,14 +733,19 @@ function validateExamples(command: CommandDefinition): void { `The example does not resolve to "${command.path.join(' ')}" after global options are parsed.` ); } - bindArguments(command, { + const bound = bindArguments(command, { argv: parsed.argv.slice(command.path.length), env: {}, strict: true, - validate: !command.bindings.some( - (binding) => binding.sources.length === 0 - ), + validate: false, }); + if (!exampleInputValidator.validate(bound.input)) { + throw new InvocationError( + 'CLI_INPUT_INVALID', + 'The example does not satisfy the command input schema.', + { details: { issues: exampleInputValidator.issues() } } + ); + } } catch (error) { throw new ContractError( 'CLI_COMMAND_EXAMPLE_INVALID', @@ -708,6 +765,7 @@ export class CommandRegistry { for (const sourceCommand of commands) { const command = snapshotCommand(sourceCommand); validateIdentifier(command); + validateMetadata(command); validateBindings(command); validateEventSchema(command); const pathKey = command.path.join(' '); From 3b4a2796242e50e8d9a09b9b623f8dc548a02752 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:22:27 +0700 Subject: [PATCH 14/18] fix(cli): refuse reclaiming live state locks --- packages/cli/__tests__/config-store.test.ts | 47 +++-- packages/cli/src/config/config-manager.ts | 84 ++------- packages/cli/src/config/directory-lock.ts | 190 ++++++++++++++++++++ 3 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 packages/cli/src/config/directory-lock.ts diff --git a/packages/cli/__tests__/config-store.test.ts b/packages/cli/__tests__/config-store.test.ts index d93a30a4ff..71f38a9ed5 100644 --- a/packages/cli/__tests__/config-store.test.ts +++ b/packages/cli/__tests__/config-store.test.ts @@ -227,16 +227,38 @@ describe('ConfigStore', () => { ).toThrow(expect.objectContaining({ code: 'CONFIG_LOCK_TIMEOUT' })); }); - it('recovers a stale lock but refuses a symlinked config directory', () => { + it('reclaims only stale locks whose owning process is dead', () => { const staleStore = new ConfigStore({ configDir: root, lockTimeoutMs: 50, staleLockMs: 10, }); const lock = path.join(root, 'state.lock'); - fs.writeFileSync(lock, 'stale'); - const old = new Date(Date.now() - 1_000); - fs.utimesSync(lock, old, old); + const writeOwner = (pid: number, token: string) => { + fs.mkdirSync(lock); + fs.writeFileSync( + path.join(lock, 'owner.json'), + JSON.stringify({ + token, + pid, + hostname: os.hostname(), + createdAt: Date.now() - 1_000, + }) + ); + }; + + writeOwner(process.pid, 'live-owner'); + expect(() => + createContext( + 'blocked', + 'https://api.example.com/graphql', + staleStore, + NOW + ) + ).toThrow(expect.objectContaining({ code: 'CONFIG_LOCK_TIMEOUT' })); + fs.rmSync(lock, { recursive: true }); + + writeOwner(2_147_483_647, 'dead-owner'); createContext( 'recovered', 'https://api.example.com/graphql', @@ -244,20 +266,6 @@ describe('ConfigStore', () => { NOW ); expect(staleStore.read().contexts.recovered).toBeDefined(); - - const target = fs.mkdtempSync( - path.join(REAL_TMP_DIR, 'cnc-config-target-') - ); - const link = `${target}-link`; - fs.symlinkSync(target, link, 'dir'); - try { - expect(() => new ConfigStore({ configDir: link }).read()).toThrow( - expect.objectContaining({ code: 'CONFIG_SYMLINK_REJECTED' }) - ); - } finally { - fs.unlinkSync(link); - fs.rmSync(target, { recursive: true, force: true }); - } }); it('refuses a symlink in an existing config directory ancestor', () => { @@ -270,6 +278,9 @@ describe('ConfigStore', () => { fs.symlinkSync(target, linkedParent, 'dir'); try { + expect(() => new ConfigStore({ configDir: linkedParent }).read()).toThrow( + expect.objectContaining({ code: 'CONFIG_SYMLINK_REJECTED' }) + ); const nestedStore = new ConfigStore({ configDir: path.join(linkedParent, 'nested', 'config'), }); diff --git a/packages/cli/src/config/config-manager.ts b/packages/cli/src/config/config-manager.ts index b5e7e7cba4..158999b4a9 100644 --- a/packages/cli/src/config/config-manager.ts +++ b/packages/cli/src/config/config-manager.ts @@ -17,6 +17,7 @@ import type { GlobalSettings, } from './types'; import { CURRENT_STATE_VERSION, DEFAULT_STATE } from './types'; +import { DirectoryLockTimeoutError, withDirectoryLock } from './directory-lock'; const TOOL_NAME = 'cnc'; const STATE_FILENAME = 'state.json'; @@ -403,11 +404,6 @@ function assertNoSymlinkComponents(file: string): void { } } -function sleepSync(milliseconds: number): void { - const array = new Int32Array(new SharedArrayBuffer(4)); - Atomics.wait(array, 0, 0, milliseconds); -} - export class ConfigStore { readonly configDir: string; private readonly clock: () => Date; @@ -634,67 +630,25 @@ export class ConfigStore { private withLock(operation: () => T): T { this.ensureConfigDir(); const lockPath = path.join(this.configDir, LOCK_FILENAME); - const deadline = Date.now() + this.lockTimeoutMs; - const lockId = `${process.pid}:${Date.now()}:${temporaryFileSequence++}`; - let descriptor: number | undefined; - - while (descriptor === undefined) { - assertNotSymlink(lockPath); - try { - descriptor = fs.openSync(lockPath, 'wx', 0o600); - fs.writeFileSync( - descriptor, - JSON.stringify({ - id: lockId, - pid: process.pid, - createdAt: Date.now(), - }), - 'utf8' - ); - fs.fsyncSync(descriptor); - } catch (error) { - if ((error as NodeError).code !== 'EEXIST') throw error; - let stat: fs.Stats; - try { - stat = fs.statSync(lockPath); - } catch (statError) { - if ((statError as NodeError).code === 'ENOENT') continue; - throw statError; - } - if (Date.now() - stat.mtimeMs > this.staleLockMs) { - try { - fs.unlinkSync(lockPath); - } catch (unlinkError) { - if ((unlinkError as NodeError).code !== 'ENOENT') throw unlinkError; - } - continue; - } - if (Date.now() >= deadline) { - throw new ConfigStoreError( - 'CONFIG_LOCK_TIMEOUT', - 'Timed out waiting for another CNC process to finish updating configuration.', - { timeoutMs: this.lockTimeoutMs } - ); - } - sleepSync(Math.min(LOCK_RETRY_MS, Math.max(1, deadline - Date.now()))); - } - } - try { - return operation(); - } finally { - fs.closeSync(descriptor); - if (fs.existsSync(lockPath)) { - assertNotSymlink(lockPath); - try { - const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { - id?: unknown; - }; - if (lock.id === lockId) fs.unlinkSync(lockPath); - } catch (error) { - if ((error as NodeError).code !== 'ENOENT') throw error; - } - } + return withDirectoryLock( + { + lockPath, + timeoutMs: this.lockTimeoutMs, + staleMs: this.staleLockMs, + retryMs: LOCK_RETRY_MS, + assertSafePath: assertNotSymlink, + }, + operation + ); + } catch (error) { + if (!(error instanceof DirectoryLockTimeoutError)) throw error; + throw new ConfigStoreError( + 'CONFIG_LOCK_TIMEOUT', + 'Timed out waiting for another CNC process to finish updating configuration.', + { timeoutMs: this.lockTimeoutMs }, + { cause: error } + ); } } } diff --git a/packages/cli/src/config/directory-lock.ts b/packages/cli/src/config/directory-lock.ts new file mode 100644 index 0000000000..619f060b8b --- /dev/null +++ b/packages/cli/src/config/directory-lock.ts @@ -0,0 +1,190 @@ +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import { hostname } from 'node:os'; +import * as path from 'node:path'; + +const OWNER_FILENAME = 'owner.json'; +const REAPER_FILENAME = '.reaper'; + +interface LockOwner { + token: string; + pid: number; + hostname: string; + createdAt: number; +} + +export class DirectoryLockTimeoutError extends Error { + readonly name = 'DirectoryLockTimeoutError'; +} + +export interface DirectoryLockOptions { + lockPath: string; + timeoutMs: number; + staleMs: number; + retryMs: number; + assertSafePath(path: string): void; +} + +const sleepSync = (milliseconds: number): void => { + const array = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(array, 0, 0, milliseconds); +}; + +const readOwner = (lockPath: string): LockOwner | undefined => { + try { + const owner = JSON.parse( + fs.readFileSync(path.join(lockPath, OWNER_FILENAME), 'utf8') + ) as Partial; + if ( + typeof owner.token !== 'string' || + typeof owner.pid !== 'number' || + typeof owner.hostname !== 'string' || + typeof owner.createdAt !== 'number' + ) { + return undefined; + } + return owner as LockOwner; + } catch { + return undefined; + } +}; + +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +}; + +const createCandidate = (lockPath: string, owner: LockOwner): boolean => { + const candidate = `${lockPath}.${owner.token}.tmp`; + fs.mkdirSync(candidate, { mode: 0o700 }); + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + path.join(candidate, OWNER_FILENAME), + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600 + ); + fs.writeFileSync(descriptor, `${JSON.stringify(owner)}\n`, 'utf8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + try { + fs.renameSync(candidate, lockPath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + code === 'EEXIST' || + code === 'ENOTEMPTY' || + code === 'ENOTDIR' || + code === 'EPERM' + ) { + return false; + } + throw error; + } + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + fs.rmSync(candidate, { recursive: true, force: true }); + } +}; + +const reclaimDeadOwner = (lockPath: string, expected: LockOwner): boolean => { + if (expected.hostname !== hostname() || processIsAlive(expected.pid)) { + return false; + } + + const reaperPath = path.join(lockPath, REAPER_FILENAME); + let descriptor: number | undefined; + try { + descriptor = fs.openSync(reaperPath, 'wx', 0o600); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'EEXIST') return false; + throw error; + } + + try { + const current = readOwner(lockPath); + if ( + !current || + current.token !== expected.token || + processIsAlive(current.pid) + ) { + return false; + } + fs.unlinkSync(path.join(lockPath, OWNER_FILENAME)); + fs.closeSync(descriptor); + descriptor = undefined; + fs.unlinkSync(reaperPath); + fs.rmdirSync(lockPath); + return true; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(reaperPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +}; + +const release = (lockPath: string, token: string): void => { + const owner = readOwner(lockPath); + if (!owner || owner.token !== token) { + throw new Error(`Configuration lock ownership changed: ${lockPath}`); + } + fs.unlinkSync(path.join(lockPath, OWNER_FILENAME)); + fs.rmdirSync(lockPath); +}; + +export function withDirectoryLock( + options: DirectoryLockOptions, + operation: () => T +): T { + const deadline = Date.now() + options.timeoutMs; + const owner: LockOwner = { + token: randomUUID(), + pid: process.pid, + hostname: hostname(), + createdAt: Date.now(), + }; + + while (true) { + options.assertSafePath(options.lockPath); + let stat: fs.Stats | undefined; + try { + stat = fs.lstatSync(options.lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (!stat && createCandidate(options.lockPath, owner)) break; + + const existing = stat?.isDirectory() + ? readOwner(options.lockPath) + : undefined; + if ( + existing && + Date.now() - existing.createdAt > options.staleMs && + reclaimDeadOwner(options.lockPath, existing) + ) { + continue; + } + if (Date.now() >= deadline) { + throw new DirectoryLockTimeoutError( + `Timed out waiting for lock: ${options.lockPath}` + ); + } + sleepSync(Math.min(options.retryMs, Math.max(1, deadline - Date.now()))); + } + + try { + return operation(); + } finally { + release(options.lockPath, owner.token); + } +} From c14df61424cf8c456a24eec6ea61cc29d2a930e6 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:33:50 +0700 Subject: [PATCH 15/18] fix(codegen): validate and classify config files --- .../__tests__/codegen/generate-safety.test.ts | 23 ++ .../__tests__/config/security-policy.test.ts | 18 ++ graphql/codegen/src/cli/handler.ts | 24 +- graphql/codegen/src/core/config/index.ts | 5 + graphql/codegen/src/core/config/loader.ts | 35 ++- graphql/codegen/src/core/config/resolver.ts | 18 +- graphql/codegen/src/core/config/schema.ts | 248 ++++++++++++++++++ graphql/codegen/src/types/config.ts | 3 + graphql/codegen/src/types/index.ts | 7 +- packages/cli/src/runtime/codegen-command.ts | 1 + 10 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 graphql/codegen/src/core/config/schema.ts diff --git a/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts b/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts index 5856b02114..4eeb962a5f 100644 --- a/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts +++ b/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts @@ -80,6 +80,29 @@ describe('generate() filesystem safety', () => { expect(fs.existsSync(output)).toBe(false); }); + it('accepts a multi-target config whose target is named endpoint', async () => { + const configPath = path.join(tempDir, 'graphql-codegen.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + endpoint: { + schemaFile: EXAMPLE_SCHEMA, + output: './generated/endpoint', + orm: true, + docs: false, + }, + }) + ); + + const result = await runCodegenOperation( + { config: configPath, dryRun: true }, + { cwd: tempDir, onProgress: () => undefined } + ); + + expect(result.hasError).toBe(false); + expect(result.results.map(({ name }) => name)).toEqual(['endpoint']); + }); + it('prunes stale owned files while preserving unknown handwritten files', async () => { const output = path.join(tempDir, 'generated'); const initial = await generate({ diff --git a/graphql/codegen/src/__tests__/config/security-policy.test.ts b/graphql/codegen/src/__tests__/config/security-policy.test.ts index 67960b8d3f..65f3385088 100644 --- a/graphql/codegen/src/__tests__/config/security-policy.test.ts +++ b/graphql/codegen/src/__tests__/config/security-policy.test.ts @@ -75,6 +75,24 @@ describe('codegen configuration security policy', () => { }); }); + it('rejects malformed target fields while loading configuration', async () => { + const configPath = path.join(cwd, 'graphql-codegen.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + public: { schemaFile: 42, output: false, orm: 'yes' }, + }) + ); + + await expect( + loadConfigFile(configPath, cwd, {}, { allowExecutableConfig: false }) + ).resolves.toMatchObject({ + success: false, + code: 'CODEGEN_CONFIG_INVALID', + path: '/public/schemaFile', + }); + }); + it('retains explicit executable-config compatibility for the legacy adapter', async () => { const configPath = path.join(cwd, 'graphql-codegen.config.ts'); fs.writeFileSync( diff --git a/graphql/codegen/src/cli/handler.ts b/graphql/codegen/src/cli/handler.ts index 358db057d1..d221f5f768 100644 --- a/graphql/codegen/src/cli/handler.ts +++ b/graphql/codegen/src/cli/handler.ts @@ -47,11 +47,13 @@ interface Prompter { export class CodegenOperationError extends Error { readonly code: string; + readonly path?: string; - constructor(code: string, message: string) { + constructor(code: string, message: string, path?: string) { super(message); this.name = 'CodegenOperationError'; this.code = code; + this.path = path; } } @@ -166,24 +168,18 @@ export async function runCodegenOperation( allowExecutableConfig: options.allowExecutableConfig === true, }); throwIfAborted(options.signal); - if (!loaded.success) { + if (loaded.success === false) { throw new CodegenOperationError( loaded.code ?? 'CODEGEN_CONFIG_INVALID', - loaded.error ?? 'Unable to load the codegen configuration.' + loaded.error ?? 'Unable to load the codegen configuration.', + loaded.path ); } - const config = loaded.config as Record; + const config = loaded.config; reportConfigSensitiveValues(config, options.onSensitiveValue); - const isMulti = !( - 'endpoint' in config || - 'schemaFile' in config || - 'schemaDir' in config || - 'db' in config - ); - - if (isMulti) { - const targets = config as Record; + if (loaded.normalized.kind === 'multi') { + const targets = loaded.normalized.targets; if (targetName && !targets[targetName]) { throw new CodegenOperationError( 'CODEGEN_TARGET_NOT_FOUND', @@ -235,7 +231,7 @@ export async function runCodegenOperation( }; } - fileConfig = config as GraphQLSDKConfigTarget; + fileConfig = loaded.normalized.target; } const seeded = seedArgvFromConfig(args, fileConfig); diff --git a/graphql/codegen/src/core/config/index.ts b/graphql/codegen/src/core/config/index.ts index 5bc71180e7..46445dc9cf 100644 --- a/graphql/codegen/src/core/config/index.ts +++ b/graphql/codegen/src/core/config/index.ts @@ -12,6 +12,11 @@ export { type LoadConfigFileOptions, type LoadConfigFileResult, } from './loader'; +export { + normalizeCodegenConfig, + type ConfigValidationFailure, + type NormalizedCodegenConfig, +} from './schema'; export { type ConfigOverrideOptions, loadAndResolveConfig, diff --git a/graphql/codegen/src/core/config/loader.ts b/graphql/codegen/src/core/config/loader.ts index 714d1c6f92..8ad5e7b3c6 100644 --- a/graphql/codegen/src/core/config/loader.ts +++ b/graphql/codegen/src/core/config/loader.ts @@ -9,6 +9,12 @@ import * as path from 'node:path'; import { createJiti } from 'jiti'; +import type { + GraphQLSDKConfigTarget, + GraphQLSDKMultiConfig, +} from '../../types/config'; +import { normalizeCodegenConfig, type NormalizedCodegenConfig } from './schema'; + export const CONFIG_FILENAME = 'graphql-codegen.config.ts'; export const JSON_CONFIG_FILENAME = 'graphql-codegen.config.json'; export const CONFIG_FILENAMES = [ @@ -50,13 +56,18 @@ export function findConfigFile( } } -export interface LoadConfigFileResult { - success: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config?: any; - error?: string; - code?: LoadConfigFileErrorCode; -} +export type LoadConfigFileResult = + | { + success: true; + config: GraphQLSDKConfigTarget | GraphQLSDKMultiConfig; + normalized: NormalizedCodegenConfig; + } + | { + success: false; + error: string; + code: LoadConfigFileErrorCode; + path?: string; + }; /** * Load and validate a config file @@ -104,17 +115,21 @@ export async function loadConfigFile( fsCache: false, }).import(resolvedPath, { default: true }); - if (!config || typeof config !== 'object' || Array.isArray(config)) { + const normalized = normalizeCodegenConfig(config); + if ('message' in normalized) { return { success: false, code: 'CODEGEN_CONFIG_INVALID', - error: 'Config file must export a configuration object', + error: `Invalid codegen configuration at ${normalized.path}: ${normalized.message}.`, + path: normalized.path, }; } return { success: true, - config, + config: + normalized.kind === 'single' ? normalized.target : normalized.targets, + normalized, }; } catch (err) { if (declarativeJson) { diff --git a/graphql/codegen/src/core/config/resolver.ts b/graphql/codegen/src/core/config/resolver.ts index a6d485c3e5..4a7b6346d2 100644 --- a/graphql/codegen/src/core/config/resolver.ts +++ b/graphql/codegen/src/core/config/resolver.ts @@ -70,10 +70,16 @@ export async function loadAndResolveConfig( if (resolvedConfigPath) { const loadResult = await loadConfigFile(resolvedConfigPath, cwd); - if (!loadResult.success) { + if (loadResult.success === false) { return { success: false, error: loadResult.error }; } - baseConfig = loadResult.config; + if (loadResult.normalized.kind !== 'single') { + return { + success: false, + error: 'This operation requires a single-target codegen configuration.', + }; + } + baseConfig = loadResult.normalized.target; } const mergedConfig = mergeConfig(baseConfig, overrides); @@ -121,11 +127,15 @@ export async function loadWatchConfig(options: { if (configPath) { const loadResult = await loadConfigFile(configPath, cwd); - if (!loadResult.success) { + if (loadResult.success === false) { console.error('x', loadResult.error); return null; } - baseConfig = loadResult.config; + if (loadResult.normalized.kind !== 'single') { + console.error('x Watch mode requires a single-target configuration.'); + return null; + } + baseConfig = loadResult.normalized.target; } const sourceOverrides: GraphQLSDKConfigTarget = {}; diff --git a/graphql/codegen/src/core/config/schema.ts b/graphql/codegen/src/core/config/schema.ts new file mode 100644 index 0000000000..838d6fb8b0 --- /dev/null +++ b/graphql/codegen/src/core/config/schema.ts @@ -0,0 +1,248 @@ +import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; + +import type { + GraphQLSDKConfigTarget, + GraphQLSDKMultiConfig, +} from '../../types/config'; + +export type NormalizedCodegenConfig = + | { kind: 'single'; target: GraphQLSDKConfigTarget } + | { kind: 'multi'; targets: GraphQLSDKMultiConfig }; + +export interface ConfigValidationFailure { + message: string; + path: string; +} + +const stringArray = { + type: 'array', + items: { type: 'string' }, +} as const; + +const filterSchema = { + type: 'object', + additionalProperties: false, + properties: { + include: stringArray, + exclude: stringArray, + systemExclude: stringArray, + }, +} as const; + +const targetSchema = { + $id: 'https://constructive.dev/graphql-codegen/target/v1', + type: 'object', + additionalProperties: false, + properties: { + endpoint: { type: 'string' }, + schemaFile: { type: 'string' }, + schemaDir: { type: 'string' }, + db: { + type: 'object', + additionalProperties: false, + properties: { + config: { type: 'object', additionalProperties: true }, + pgpm: { + type: 'object', + additionalProperties: false, + properties: { + modulePath: { type: 'string' }, + workspacePath: { type: 'string' }, + moduleName: { type: 'string' }, + }, + }, + schemas: stringArray, + apiNames: stringArray, + keepDb: { type: 'boolean' }, + }, + }, + headers: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + output: { type: 'string' }, + tables: filterSchema, + queries: filterSchema, + mutations: filterSchema, + excludeFields: stringArray, + hooks: { + type: 'object', + additionalProperties: false, + properties: { + queries: { type: 'boolean' }, + mutations: { type: 'boolean' }, + queryKeyPrefix: { type: 'string' }, + }, + }, + postgraphile: { + type: 'object', + additionalProperties: false, + properties: { schema: { type: 'string' } }, + }, + codegen: { + type: 'object', + additionalProperties: false, + properties: { + skipQueryField: { type: 'boolean' }, + comments: { type: 'boolean' }, + condition: { type: 'boolean' }, + }, + }, + orm: { type: 'boolean' }, + reactQuery: { type: 'boolean' }, + cli: { + anyOf: [ + { type: 'boolean' }, + { + type: 'object', + additionalProperties: false, + properties: { + toolName: { type: 'string' }, + builtinNames: { + type: 'object', + additionalProperties: false, + properties: { + auth: { type: 'string' }, + context: { type: 'string' }, + config: { type: 'string' }, + }, + }, + entryPoint: { type: 'boolean' }, + }, + }, + ], + }, + docs: { + anyOf: [ + { type: 'boolean' }, + { + type: 'object', + additionalProperties: false, + properties: { + readme: { type: 'boolean' }, + agents: { type: 'boolean' }, + skills: { type: 'boolean' }, + }, + }, + ], + }, + schema: { + type: 'object', + additionalProperties: false, + properties: { + enabled: { type: 'boolean' }, + output: { type: 'string' }, + filename: { type: 'string' }, + }, + }, + skillsPath: { type: 'string' }, + queryKeys: { + type: 'object', + additionalProperties: false, + properties: { + style: { enum: ['flat', 'hierarchical'] }, + relationships: { + type: 'object', + additionalProperties: { + type: 'object', + additionalProperties: false, + required: ['parent', 'foreignKey'], + properties: { + parent: { type: 'string' }, + foreignKey: { type: 'string' }, + ancestors: stringArray, + }, + }, + }, + generateScopedKeys: { type: 'boolean' }, + generateCascadeHelpers: { type: 'boolean' }, + generateMutationKeys: { type: 'boolean' }, + }, + }, + watch: { + type: 'object', + additionalProperties: false, + properties: { + pollInterval: { type: 'number' }, + debounce: { type: 'number' }, + touchFile: { type: 'string' }, + clearScreen: { type: 'boolean' }, + }, + }, + authorization: { type: 'string' }, + verbose: { type: 'boolean' }, + dryRun: { type: 'boolean' }, + skipCustomOperations: { type: 'boolean' }, + }, +} as const; + +const multiSchema = { + $id: 'https://constructive.dev/graphql-codegen/targets/v1', + type: 'object', + minProperties: 1, + additionalProperties: { $ref: targetSchema.$id }, +} as const; + +const ajv = new Ajv({ allErrors: true, strict: true }); +ajv.addSchema(targetSchema); +const validateTarget = ajv.getSchema( + targetSchema.$id +) as ValidateFunction; +const validateTargets = ajv.compile( + multiSchema +) as ValidateFunction; + +const targetPropertyNames = new Set(Object.keys(targetSchema.properties)); + +const pointerSegment = (value: string): string => + value.replace(/~/g, '~0').replace(/\//g, '~1'); + +const issuePath = (error: ErrorObject | undefined): string => { + if (!error) return '/'; + if ( + error.keyword === 'additionalProperties' && + typeof error.params.additionalProperty === 'string' + ) { + return `${error.instancePath}/${pointerSegment(error.params.additionalProperty)}`; + } + return error.instancePath || '/'; +}; + +const failureFrom = ( + errors: ErrorObject[] | null | undefined +): ConfigValidationFailure => { + const error = errors?.[0]; + return { + path: issuePath(error), + message: error?.message ?? 'must match the codegen configuration schema', + }; +}; + +export function normalizeCodegenConfig( + value: unknown +): NormalizedCodegenConfig | ConfigValidationFailure { + const singleValid = validateTarget(value); + const singleErrors = validateTarget.errors; + const multiValid = validateTargets(value); + const multiErrors = validateTargets.errors; + + if (singleValid && !multiValid) { + return { kind: 'single', target: value }; + } + if (multiValid && !singleValid) { + return { kind: 'multi', targets: value }; + } + if (singleValid && multiValid) { + // Some legacy single-target documents such as { headers: {} } are also + // valid target maps. Preserve their historical meaning. A colliding target + // with real target fields only validates as multi-target. + return { kind: 'single', target: value }; + } + + const looksLikeSingleTarget = + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).some((key) => targetPropertyNames.has(key)); + return failureFrom(looksLikeSingleTarget ? singleErrors : multiErrors); +} diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 6d7393e66b..8be5f47be5 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -451,6 +451,9 @@ export interface GraphQLSDKConfigTarget { */ export type GraphQLSDKConfig = GraphQLSDKConfigTarget; +/** Named targets accepted by multi-target codegen configuration files. */ +export type GraphQLSDKMultiConfig = Record; + /** * Watch mode configuration options * diff --git a/graphql/codegen/src/types/index.ts b/graphql/codegen/src/types/index.ts index 0d1272b6d6..85e4696eb1 100644 --- a/graphql/codegen/src/types/index.ts +++ b/graphql/codegen/src/types/index.ts @@ -49,7 +49,12 @@ export type { } from './selection'; // Config types -export type { GraphQLSDKConfig, GraphQLSDKConfigTarget, SchemaConfig } from './config'; +export type { + GraphQLSDKConfig, + GraphQLSDKConfigTarget, + GraphQLSDKMultiConfig, + SchemaConfig, +} from './config'; export { DEFAULT_CONFIG, defineConfig, diff --git a/packages/cli/src/runtime/codegen-command.ts b/packages/cli/src/runtime/codegen-command.ts index 3d301be02d..6f0e52dc07 100644 --- a/packages/cli/src/runtime/codegen-command.ts +++ b/packages/cli/src/runtime/codegen-command.ts @@ -387,6 +387,7 @@ export const codegenCommand = defineCommand({ code: error.code, category: 'configuration', message: error.message, + path: error.path, cause: error, }); } From d7a6737017cf169dfa2c777091d8f492b81efaa2 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:41:37 +0700 Subject: [PATCH 16/18] fix(codegen): separate target planning from apply --- .../__tests__/codegen/generate-safety.test.ts | 53 +++ .../src/__tests__/codegen/schema-only.test.ts | 17 +- graphql/codegen/src/core/generate.ts | 320 ++++++++++-------- .../__tests__/runtime-codegen-command.test.ts | 20 +- 4 files changed, 264 insertions(+), 146 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts b/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts index 4eeb962a5f..45a79bbdf4 100644 --- a/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts +++ b/graphql/codegen/src/__tests__/codegen/generate-safety.test.ts @@ -206,10 +206,63 @@ describe('generate() filesystem safety', () => { }); expect(result.hasError).toBe(true); + expect(result.results[0].result).toMatchObject({ + success: false, + message: expect.stringContaining('was not applied'), + filesWritten: [], + }); + expect(result.results[0].result.message).not.toMatch(/generated|written/i); expect(fs.existsSync(firstOutput)).toBe(false); expect(fs.existsSync(secondOutput)).toBe(false); }); + it('does not report a target as applied when the coordinated write fails', async () => { + const output = path.join(tempDir, 'generated', 'only'); + const nativeFs = require('node:fs') as typeof fs; + const originalRename = nativeFs.renameSync; + let failureInjected = false; + const renameSpy = jest + .spyOn(nativeFs, 'renameSync') + .mockImplementation((from, to) => { + if ( + !failureInjected && + String(from).includes('.codegen-transaction-') && + String(from).includes(`${path.sep}staged${path.sep}`) + ) { + failureInjected = true; + throw new Error('injected coordinated write failure'); + } + return originalRename(from, to); + }); + + let result: Awaited>; + try { + result = await generateMulti({ + configs: { + only: { + schemaFile: EXAMPLE_SCHEMA, + output, + orm: true, + docs: false, + }, + }, + cwd: tempDir, + onProgress: () => undefined, + }); + } finally { + renameSpy.mockRestore(); + } + + expect(result.hasError).toBe(true); + expect(result.results[0].result).toMatchObject({ + success: false, + message: expect.stringContaining('was not applied'), + filesWritten: [], + }); + expect(result.results[0].result.message).not.toMatch(/generated|written/i); + expect(fs.existsSync(path.join(output, 'index.ts'))).toBe(false); + }); + it('preserves retained-transaction warnings on multi-target results', async () => { const output = path.join(tempDir, 'generated', 'first'); const nativeFs = require('node:fs') as typeof fs; diff --git a/graphql/codegen/src/__tests__/codegen/schema-only.test.ts b/graphql/codegen/src/__tests__/codegen/schema-only.test.ts index d435429d14..3d62307c71 100644 --- a/graphql/codegen/src/__tests__/codegen/schema-only.test.ts +++ b/graphql/codegen/src/__tests__/codegen/schema-only.test.ts @@ -141,13 +141,22 @@ describe('generate() with schema.enabled', () => { const nativeFs = require('node:fs') as typeof fs; const originalRename = nativeFs.renameSync; - let renameCall = 0; + const target = path.join(fs.realpathSync(output), 'schema.graphql'); const renameSpy = jest .spyOn(nativeFs, 'renameSync') .mockImplementation((from, to) => { - renameCall += 1; - if (renameCall === 2) throw new Error('injected commit failure'); - if (renameCall === 3) throw new Error('injected restore failure'); + const sourcePath = String(from); + if ( + String(to) === target && + sourcePath.includes('.codegen-transaction-') + ) { + if (sourcePath.includes(`${path.sep}staged${path.sep}`)) { + throw new Error('injected commit failure'); + } + if (sourcePath.includes(`${path.sep}backup${path.sep}`)) { + throw new Error('injected restore failure'); + } + } return originalRename(from, to); }); diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 5b9d60f037..1c0c5c8337 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -72,6 +72,7 @@ import type { FileChange, GeneratedFileWriteJob, GenerationPlan, + WriteBatchResult, WriteResult, } from './output'; import { GENERATED_FILES_MANIFEST, writeGeneratedFileJobs } from './output'; @@ -138,23 +139,24 @@ export interface GenerateResult { }; } -/** - * Main generate function - takes a single config and generates code - * - * This is the primary entry point for programmatic usage. - * For multiple configs, call this function in a loop. - */ -export interface GenerateInternalOptions { +interface PrepareGenerationOptions { skipCli?: boolean; - /** Collect fully generated write jobs without applying them. */ - writeJobs?: GeneratedFileWriteJob[]; - /** - * Internal-only name for the target when generating skills. - * Used by generateMulti() so skill names are stable and composable. - */ targetName?: string; } +interface PreparedGeneration { + result: GenerateResult; + writeJobs: GeneratedFileWriteJob[]; + messages: { + applied: string; + dryRun: string; + }; +} + +type GenerationPreparation = + | { ok: false; result: GenerateResult } + | { ok: true; prepared: PreparedGeneration }; + function resolveSkillsOutputDir( config: GraphQLSDKConfigTarget, outputRoot: string, @@ -258,33 +260,67 @@ function recoveryFields( }; } -async function executeGeneratedWriteJobs( +const failedPreparation = (result: GenerateResult): GenerationPreparation => ({ + ok: false, + result, +}); + +async function applyGenerationJobs( jobs: GeneratedFileWriteJob[], - options: GenerateOptions, - internalOptions?: GenerateInternalOptions -): Promise { - const collecting = internalOptions?.writeJobs !== undefined; - const batch = await writeGeneratedFileJobs(jobs, { - dryRun: collecting || options.dryRun, + options: Pick +): Promise { + throwIfAborted(options.signal); + return writeGeneratedFileJobs(jobs, { + dryRun: options.dryRun, showProgress: false, signal: options.signal, }); - if (batch.success && collecting) { - internalOptions.writeJobs!.push(...jobs); - } - if (batch.results.length > 0) return batch.results; - return [ - { - success: false, - errors: batch.errors ?? ['Failed to prepare generated files.'], - }, - ]; } -export async function generate( +function completePreparedGeneration( + prepared: PreparedGeneration, + dryRun: boolean, + writeResults: WriteResult[] = [] +): GenerateResult { + const filesWritten = dryRun + ? [] + : writeResults.flatMap((result) => result.filesWritten ?? []); + const filesRemoved = dryRun + ? [] + : writeResults.flatMap((result) => result.filesRemoved ?? []); + return { + ...prepared.result, + success: true, + message: dryRun ? prepared.messages.dryRun : prepared.messages.applied, + filesWritten, + filesRemoved, + ...planFields(writeResults), + ...recoveryFields(writeResults), + }; +} + +function failPreparedGeneration( + prepared: PreparedGeneration, + message: string, + writeResults: WriteResult[] = [], + errors: string[] = [message] +): GenerateResult { + return { + ...prepared.result, + success: false, + message, + errors, + filesWritten: [], + filesRemoved: [], + ...planFields(writeResults), + ...recoveryFields(writeResults), + }; +} + +async function planTargetGeneration( options: GenerateOptions = {}, - internalOptions?: GenerateInternalOptions -): Promise { + internalOptions?: PrepareGenerationOptions +): Promise { throwIfAborted(options.signal); const cwd = path.resolve(options.cwd ?? process.cwd()); const { @@ -318,12 +354,12 @@ export async function generate( const schemaEnabled = !!options.schema?.enabled; if (!schemaEnabled && !runReactQuery && !runOrm && !runCli) { - return { + return failedPreparation({ success: false, message: 'No generators enabled. Use reactQuery: true, orm: true, or cli: true in your config.', output: outputRoot, - }; + }); } // Validate source @@ -333,11 +369,11 @@ export async function generate( db: config.db, }); if (!sourceValidation.valid) { - return { + return failedPreparation({ success: false, message: sourceValidation.error!, output: outputRoot, - }; + }); } const source = createSchemaSource({ @@ -364,66 +400,50 @@ export async function generate( throwIfAborted(options.signal); if (!sdl.trim()) { - return { + return failedPreparation({ success: false, message: 'Schema introspection returned empty SDL.', output: outputRoot, - }; + }); } const outDir = config.schema?.output || outputRoot; const filename = options.schema?.filename || 'schema.graphql'; const filePath = path.join(outDir, filename); - // Cancellation is deliberately checked before, not after, the atomic - // writer. An abort that races with a completed commit reports success. throwIfAborted(options.signal); - const [writeResult] = await executeGeneratedWriteJobs( - [ - { - files: [{ path: filename, content: sdl }], - outputDir: outDir, - options: { - pruneStaleFiles: false, - overwriteModifiedGenerated: options.overwriteModifiedGenerated, - confirmOverwrite: options.yes, - showProgress: false, + return { + ok: true, + prepared: { + result: { + success: true, + message: `Schema ready to export to ${filePath}`, + output: outDir, + }, + writeJobs: [ + { + files: [{ path: filename, content: sdl }], + outputDir: outDir, + options: { + pruneStaleFiles: false, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + confirmOverwrite: options.yes, + showProgress: false, + }, }, + ], + messages: { + dryRun: `Dry run complete. Would export schema to ${filePath}`, + applied: `Schema exported to ${filePath}`, }, - ], - options, - internalOptions - ); - const structuredPlan = planFields([writeResult]); - const recovery = recoveryFields([writeResult]); - if (!writeResult.success) { - return { - success: false, - message: `Failed to export schema: ${writeResult.errors?.join(', ')}`, - output: outDir, - errors: writeResult.errors, - ...structuredPlan, - ...recovery, - }; - } - - return { - success: true, - message: options.dryRun - ? `Dry run complete. Would export schema to ${filePath}` - : `Schema exported to ${filePath}`, - output: outDir, - filesWritten: writeResult.filesWritten, - filesRemoved: writeResult.filesRemoved, - ...structuredPlan, - ...recovery, + }, }; } catch (err) { rethrowIfCancelled(err, options.signal); - return { + return failedPreparation({ success: false, message: `Failed to export schema: ${err instanceof Error ? err.message : 'Unknown error'}`, output: outputRoot, - }; + }); } } @@ -451,11 +471,11 @@ export async function generate( throwIfAborted(options.signal); } catch (err) { rethrowIfCancelled(err, options.signal); - return { + return failedPreparation({ success: false, message: `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}`, output: outputRoot, - }; + }); } const { tables, customOperations } = pipelineResult; @@ -463,15 +483,13 @@ export async function generate( // Validate tables const tablesValidation = validateTablesFound(tables); if (!tablesValidation.valid) { - return { + return failedPreparation({ success: false, message: tablesValidation.error!, output: outputRoot, - }; + }); } - const allFilesWritten: string[] = []; - const allFilesRemoved: string[] = []; const bothEnabled = runReactQuery && runOrm; const filesToWrite: Array<{ path: string; content: string }> = []; @@ -742,31 +760,6 @@ export async function generate( showProgress: false, }, })); - const writeResults = await executeGeneratedWriteJobs( - coordinatedJobs, - options, - internalOptions - ); - const recovery = recoveryFields(writeResults); - const failedWrite = writeResults.find((result) => !result.success); - if (failedWrite) { - return { - success: false, - message: `Failed to ${internalOptions?.writeJobs ? 'plan' : 'write'} generated files: ${failedWrite.errors?.join(', ')}`, - output: outputRoot, - errors: failedWrite.errors, - filesWritten: allFilesWritten, - filesRemoved: allFilesRemoved, - ...planFields(writeResults), - ...recovery, - }; - } - if (!internalOptions?.writeJobs && !options.dryRun) { - for (const writeResult of writeResults) { - allFilesWritten.push(...(writeResult.filesWritten ?? [])); - allFilesRemoved.push(...(writeResult.filesRemoved ?? [])); - } - } const generators = [ runReactQuery && 'React Query', @@ -777,27 +770,58 @@ export async function generate( .join(' and '); return { - success: true, - message: options.dryRun - ? `Dry run complete. Would generate ${generators} for ${tables.length} tables.` - : `Generated ${generators} for ${tables.length} tables. Files written to ${outputRoot}`, - output: outputRoot, - tables: tables.map((t) => t.name), - filesWritten: allFilesWritten, - filesRemoved: allFilesRemoved, - ...planFields(writeResults), - ...recovery, - pipelineData: { - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, + ok: true, + prepared: { + result: { + success: true, + message: `Generated files are ready to apply to ${outputRoot}`, + output: outputRoot, + tables: tables.map((t) => t.name), + pipelineData: { + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + }, + }, + writeJobs: coordinatedJobs, + messages: { + dryRun: `Dry run complete. Would generate ${generators} for ${tables.length} tables.`, + applied: `Generated ${generators} for ${tables.length} tables. Files written to ${outputRoot}`, }, }, }; } +/** Generate one target and apply its complete filesystem plan atomically. */ +export async function generate( + options: GenerateOptions = {} +): Promise { + const preparation = await planTargetGeneration(options); + if (preparation.ok === false) return preparation.result; + + const batch = await applyGenerationJobs( + preparation.prepared.writeJobs, + options + ); + if (!batch.success) { + const errors = batch.errors ?? ['Failed to apply generated files.']; + return failPreparedGeneration( + preparation.prepared, + `Failed to ${options.dryRun ? 'plan' : 'write'} generated files: ${errors.join(', ')}`, + batch.results, + errors + ); + } + return completePreparedGeneration( + preparation.prepared, + options.dryRun === true, + batch.results + ); +} + export function expandApiNamesToMultiTarget( config: GraphQLSDKConfigTarget ): Record | null { @@ -1280,6 +1304,7 @@ export async function generateMulti( const cliTargets: MultiTargetCliTarget[] = []; const additionalWriteResults: WriteResult[] = []; const collectedWriteJobs: GeneratedFileWriteJob[] = []; + const preparedTargets = new Map(); let staleTargetWriteJobs: GeneratedFileWriteJob[] = []; const additionalWriteJobs: Array<{ files: Array<{ path: string; content: string }>; @@ -1340,7 +1365,7 @@ export async function generateMulti( cwd, env ); - const result = await generate( + const preparation = await planTargetGeneration( { ...targetConfig, verbose, @@ -1359,14 +1384,19 @@ export async function generateMulti( ? { skipCli: true, targetName: name, - writeJobs: collectedWriteJobs, } - : { targetName: name, writeJobs: collectedWriteJobs } + : { targetName: name } ); + const result = + preparation.ok === false + ? preparation.result + : preparation.prepared.result; results.push({ name, result }); - if (!result.success) { + if (preparation.ok === false) { hasError = true; } else { + preparedTargets.set(name, preparation.prepared); + collectedWriteJobs.push(...preparation.prepared.writeJobs); const displayConfig = getConfigOptions(targetConfig); const resolvedConfig = resolveTargetPaths(displayConfig, cwd); const gens: string[] = []; @@ -1510,9 +1540,7 @@ export async function generateMulti( // Write a root barrel (index.ts) that re-exports each target as a // namespace so the package has a single entry-point. Derive the // common output root from the first target's output path. - const successfulNames = results - .filter((r) => r.result.success) - .map((r) => r.name); + const successfulNames = [...preparedTargets.keys()]; if (successfulNames.length > 0) { const firstOutput = resolveTargetPaths( getConfigOptions(configs[successfulNames[0]]), @@ -1578,9 +1606,8 @@ export async function generateMulti( }, })), ]; - const batch = await writeGeneratedFileJobs(finalJobs, { + const batch = await applyGenerationJobs(finalJobs, { dryRun, - showProgress: false, signal: options.signal, }); additionalWriteResults.push(...batch.results); @@ -1613,14 +1640,31 @@ export async function generateMulti( } } - const targetPlans = results.flatMap(({ result }) => result.plans ?? []); + const failedAttempt = results.find(({ result }) => !result.success); + const notAppliedMessage = + failedAttempt?.name === 'write' + ? 'Generation was not applied because the coordinated filesystem apply failed.' + : failedAttempt + ? `Generation was not applied because target "${failedAttempt.name}" failed during planning.` + : 'Generation was not applied.'; + const finalResults = results.map(({ name, result }) => { + const prepared = preparedTargets.get(name); + if (!prepared) return { name, result }; + return { + name, + result: hasError + ? failPreparedGeneration(prepared, notAppliedMessage) + : completePreparedGeneration(prepared, dryRun === true), + }; + }); + const targetPlans = finalResults.flatMap(({ result }) => result.plans ?? []); const coordinatedPlans = additionalWriteResults.flatMap((result) => result.plan ? [result.plan] : [] ); const plans = coordinatedPlans.length > 0 ? coordinatedPlans : targetPlans; const recovery = recoveryFields(additionalWriteResults); return { - results, + results: finalResults, hasError, plans, planFingerprint: combinePlanFingerprint(plans), diff --git a/packages/cli/__tests__/runtime-codegen-command.test.ts b/packages/cli/__tests__/runtime-codegen-command.test.ts index 4bbd8dbdea..269c6e23bc 100644 --- a/packages/cli/__tests__/runtime-codegen-command.test.ts +++ b/packages/cli/__tests__/runtime-codegen-command.test.ts @@ -546,13 +546,25 @@ describe('CNC codegen operation protocol', () => { const nativeFs = require('node:fs') as typeof fs; const originalRename = nativeFs.renameSync; - let renameCall = 0; + const target = path.join( + fs.realpathSync(path.join(cwd, 'generated')), + 'schema.graphql' + ); const renameSpy = jest .spyOn(nativeFs, 'renameSync') .mockImplementation((from, to) => { - renameCall += 1; - if (renameCall === 2) throw new Error('injected commit failure'); - if (renameCall === 3) throw new Error('injected restore failure'); + const sourcePath = String(from); + if ( + String(to) === target && + sourcePath.includes('.codegen-transaction-') + ) { + if (sourcePath.includes(`${path.sep}staged${path.sep}`)) { + throw new Error('injected commit failure'); + } + if (sourcePath.includes(`${path.sep}backup${path.sep}`)) { + throw new Error('injected restore failure'); + } + } return originalRename(from, to); }); From c913477091219c8070cd0caea25184930c895b8d Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:47:44 +0700 Subject: [PATCH 17/18] fix(cli): remove legacy command authorities --- packages/cli/AGENTS.md | 6 +- packages/cli/__tests__/codegen.test.ts | 64 ---- .../__tests__/packaged-entrypoints.test.ts | 35 ++- packages/cli/package.json | 15 +- packages/cli/src/commands.ts | 49 +-- packages/cli/src/commands/auth.ts | 257 ---------------- packages/cli/src/commands/codegen.ts | 49 --- packages/cli/src/commands/context.ts | 289 ------------------ packages/cli/src/commands/execute.ts | 112 ------- packages/cli/src/commands/explorer.ts | 113 ------- packages/cli/src/commands/jobs.ts | 205 ------------- packages/cli/src/commands/server.ts | 226 -------------- packages/cli/src/runtime/execute-command.ts | 25 ++ packages/cli/src/runtime/registry.ts | 30 +- 14 files changed, 100 insertions(+), 1375 deletions(-) delete mode 100644 packages/cli/__tests__/codegen.test.ts delete mode 100644 packages/cli/src/commands/auth.ts delete mode 100644 packages/cli/src/commands/codegen.ts delete mode 100644 packages/cli/src/commands/context.ts delete mode 100644 packages/cli/src/commands/execute.ts delete mode 100644 packages/cli/src/commands/explorer.ts delete mode 100644 packages/cli/src/commands/jobs.ts delete mode 100644 packages/cli/src/commands/server.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 861d8f54a9..50a9183cae 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -27,9 +27,9 @@ The registry-backed command operations are: - `runtime/service-commands.ts` – server, explorer, and jobs lifecycles - `runtime/discovery-commands.ts` – help, schemas, docs, and completions -Files under `src/commands/` are retained legacy terminal handlers; new command -behavior must be implemented as a `CommandDefinition` and registered in -`runtime/registry.ts`. +Command behavior must be implemented as a `CommandDefinition` and registered +in `runtime/registry.ts`; terminal prompts and human rendering live in the +registry bundle's adapter hooks. ## Debugging Tips diff --git a/packages/cli/__tests__/codegen.test.ts b/packages/cli/__tests__/codegen.test.ts deleted file mode 100644 index 9282a84879..0000000000 --- a/packages/cli/__tests__/codegen.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ParsedArgs } from 'inquirerer' -import codegenCommand from '../src/commands/codegen' - -const mockRunCodegenHandler = jest.fn, [Record, unknown]>(); - -jest.mock('@constructive-io/graphql-codegen', () => ({ - runCodegenHandler: (argv: Record, prompter: unknown) => mockRunCodegenHandler(argv, prompter), -})); - -const createMockPrompter = () => ({ - prompt: jest.fn(async (argv: any) => argv), -}); - -describe('codegen command', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('prints usage and exits with code 0 when --help is set', async () => { - const spyLog = jest.spyOn(console, 'log').mockImplementation(() => {}) - const spyExit = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error('exit:' + code) }) as any) - - const argv: Partial = { help: true } - const mockPrompter = createMockPrompter() - - await expect(codegenCommand(argv, mockPrompter as any, {} as any)).rejects.toThrow('exit:0') - expect(spyLog).toHaveBeenCalled() - const first = (spyLog.mock.calls[0]?.[0] as string) || '' - expect(first).toContain('Constructive GraphQL Codegen') - expect(mockRunCodegenHandler).not.toHaveBeenCalled() - - spyLog.mockRestore() - spyExit.mockRestore() - }) - - it('delegates to runCodegenHandler for endpoint flow', async () => { - const argv: Partial = { - endpoint: 'http://localhost:3000/graphql', - authorization: 'Bearer testtoken', - output: 'graphql/codegen/dist', - verbose: true, - dryRun: true, - reactQuery: true, - } - const mockPrompter = createMockPrompter() - - await codegenCommand(argv, mockPrompter as any, {} as any) - - expect(mockRunCodegenHandler).toHaveBeenCalledWith(argv, mockPrompter) - }) - - it('delegates to runCodegenHandler for db options', async () => { - const argv: Partial = { - schemas: 'public,app', - output: 'graphql/codegen/dist', - reactQuery: true, - } - const mockPrompter = createMockPrompter() - - await codegenCommand(argv, mockPrompter as any, {} as any) - - expect(mockRunCodegenHandler).toHaveBeenCalledWith(argv, mockPrompter) - }) -}) diff --git a/packages/cli/__tests__/packaged-entrypoints.test.ts b/packages/cli/__tests__/packaged-entrypoints.test.ts index c3ce6a41bd..f48306615f 100644 --- a/packages/cli/__tests__/packaged-entrypoints.test.ts +++ b/packages/cli/__tests__/packaged-entrypoints.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -10,6 +10,39 @@ describe('built CNC entrypoints', () => { const esmRuntime = pathToFileURL( resolve(packageRoot, 'dist/esm/runtime/index.js') ).href; + + it('publishes only registry-backed package entrypoints', () => { + const manifest = JSON.parse( + readFileSync(resolve(packageRoot, 'dist/package.json'), 'utf8') + ) as { exports: Record }; + expect(Object.keys(manifest.exports)).toEqual([ + '.', + './runtime', + './config', + './config/config-manager', + './sdk', + './utils', + './package.json', + ]); + + for (const family of [ + 'auth', + 'codegen', + 'context', + 'execute', + 'explorer', + 'jobs', + 'server', + ]) { + expect( + existsSync(resolve(packageRoot, 'dist/commands', `${family}.js`)) + ).toBe(false); + expect( + existsSync(resolve(packageRoot, 'dist/esm/commands', `${family}.js`)) + ).toBe(false); + } + }); + it.each([ ['CommonJS', resolve(packageRoot, 'dist/index.js')], ['ESM', resolve(packageRoot, 'dist/esm/index.js')], diff --git a/packages/cli/package.json b/packages/cli/package.json index 0dba3f613a..4df86c1a18 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,11 @@ "import": "./esm/config/index.js", "require": "./config/index.js" }, + "./config/config-manager": { + "types": "./config/config-manager.d.ts", + "import": "./esm/config/config-manager.js", + "require": "./config/config-manager.js" + }, "./sdk": { "types": "./sdk/index.d.ts", "import": "./esm/sdk/index.js", @@ -32,16 +37,6 @@ "import": "./esm/utils/index.js", "require": "./utils/index.js" }, - "./*.js": { - "types": "./*.d.ts", - "import": "./esm/*.js", - "require": "./*.js" - }, - "./*": { - "types": "./*.d.ts", - "import": "./esm/*.js", - "require": "./*.js" - }, "./package.json": "./package.json" }, "homepage": "https://github.com/constructive-io/constructive", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index ca997c4bfc..e7779c5da0 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -15,7 +15,6 @@ import { resolveExecutionSettings, sensitiveEnvironmentValues, assertFormatAllowed, - type CommandAdapterHookMap, type CommandDefinition, type ExecutionOutcome, type ExecutionSettings, @@ -337,30 +336,6 @@ const failure = async ( return exitCodeForOutcome(outcome); }; -const addExecutePromptHook = ( - hooks: CommandAdapterHookMap, - prompter: Inquirerer -): CommandAdapterHookMap => ({ - ...hooks, - execute: { - ...hooks.execute, - collectInteractiveInput: async (input) => { - const candidate = input as Record; - if (candidate.query !== undefined || candidate.file !== undefined) { - return input as never; - } - return (await prompter.prompt(candidate, [ - { - type: 'text', - name: 'query', - message: 'GraphQL query', - required: true, - }, - ])) as never; - }, - }, -}); - export async function runCli( rawArgv: readonly string[], options: RunCliOptions @@ -560,23 +535,15 @@ export async function runCli( ); let input = bound.input as Record; const sensitiveValues = [...bound.sensitiveValues]; - if (command.id === 'auth.set-token' && input.readFromStdin === true) { - const stdinToken = await readToken(adapterOptions.stdin, signal); - input = { ...input, stdinValue: stdinToken }; - sensitiveValues.push(stdinToken); - } + const terminalInput = await registryBundle.prepareTerminalInput({ + commandId: command.id, + input, + readSecretFromStdin: () => readToken(adapterOptions.stdin, signal), + }); + input = terminalInput.input; + sensitiveValues.push(...terminalInput.sensitiveValues); - let hooks = addExecutePromptHook( - registryBundle.createHooks(prompter), - prompter - ); - if ( - settings.interactive && - ['server.start', 'explorer.start', 'jobs.up'].includes(command.id) - ) { - const { createServiceHooks } = await import('./runtime/service-hooks'); - hooks = { ...hooks, ...createServiceHooks(prompter) }; - } + const hooks = registryBundle.createHooks(prompter); if (settings.interactive) { const collected = hooks[command.id]?.collectInteractiveInput; if (collected) { diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts deleted file mode 100644 index 43544fd1f5..0000000000 --- a/packages/cli/src/commands/auth.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Authentication commands for the CNC execution engine - */ - -import { CLIOptions, Inquirerer, extractFirst } from 'inquirerer'; -import chalk from 'yanse'; -import { - getCurrentContext, - loadContext, - listContexts, - getContextCredentials, - setContextCredentials, - removeContextCredentials, - hasValidCredentials, - loadSettings, -} from '../config'; - -const usage = ` -Constructive Authentication: - - cnc auth [OPTIONS] - -Commands: - set-token Set API token for the current context - status Show authentication status - logout Remove credentials for the current context - -Options: - --context Specify context (defaults to current context) - --expires Token expiration date (ISO format) - -Examples: - cnc auth set-token eyJhbGciOiJIUzI1NiIs... - cnc auth status - cnc auth logout - cnc auth set-token --context my-api - - --help, -h Show this help message -`; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - if (argv.help || argv.h) { - console.log(usage); - process.exit(0); - } - - const { first: subcommand, newArgv } = extractFirst(argv); - - if (!subcommand) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'subcommand', - message: 'What do you want to do?', - options: ['set-token', 'status', 'logout'], - }, - ]); - return handleSubcommand(answer.subcommand as string, newArgv, prompter); - } - - return handleSubcommand(subcommand, newArgv, prompter); -}; - -async function handleSubcommand( - subcommand: string, - argv: Partial>, - prompter: Inquirerer -) { - switch (subcommand) { - case 'set-token': - return handleSetToken(argv, prompter); - case 'status': - return handleStatus(argv); - case 'logout': - return handleLogout(argv, prompter); - default: - console.log(usage); - console.error(chalk.red(`Unknown subcommand: ${subcommand}`)); - process.exit(1); - } -} - -async function getTargetContext( - argv: Partial>, - prompter: Inquirerer -): Promise { - if (argv.context && typeof argv.context === 'string') { - const context = loadContext(argv.context); - if (!context) { - console.error(chalk.red(`Context "${argv.context}" not found.`)); - process.exit(1); - } - return argv.context; - } - - const current = getCurrentContext(); - if (current) { - return current.name; - } - - const contexts = listContexts(); - if (contexts.length === 0) { - console.error(chalk.red('No contexts configured.')); - console.log(chalk.gray('Run "cnc context create " to create one first.')); - process.exit(1); - } - - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'context', - message: 'Select context', - options: contexts.map(c => c.name), - }, - ]); - - return answer.context as string; -} - -async function handleSetToken( - argv: Partial>, - prompter: Inquirerer -) { - const contextName = await getTargetContext(argv, prompter); - const { first: token, newArgv } = extractFirst(argv); - - let tokenValue = token as string; - - if (!tokenValue) { - const answer = await prompter.prompt(newArgv, [ - { - type: 'password', - name: 'token', - message: 'API Token', - required: true, - }, - ]); - tokenValue = (answer as Record).token as string; - } - - if (!tokenValue || tokenValue.trim() === '') { - console.error(chalk.red('Token cannot be empty.')); - process.exit(1); - } - - const expiresAt = argv.expires as string | undefined; - - setContextCredentials(contextName, tokenValue.trim(), { expiresAt }); - - console.log(chalk.green(`Token saved for context: ${contextName}`)); - if (expiresAt) { - console.log(chalk.gray(`Expires: ${expiresAt}`)); - } -} - -function handleStatus(argv: Partial>) { - const settings = loadSettings(); - const contexts = listContexts(); - - if (contexts.length === 0) { - console.log(chalk.gray('No contexts configured.')); - return; - } - - if (argv.context && typeof argv.context === 'string') { - const context = loadContext(argv.context); - if (!context) { - console.error(chalk.red(`Context "${argv.context}" not found.`)); - process.exit(1); - } - showContextAuthStatus(context.name, settings.currentContext === context.name); - return; - } - - console.log(chalk.bold('Authentication Status:')); - console.log(); - - for (const context of contexts) { - const isCurrent = context.name === settings.currentContext; - showContextAuthStatus(context.name, isCurrent); - } -} - -function showContextAuthStatus(contextName: string, isCurrent: boolean) { - const creds = getContextCredentials(contextName); - const hasAuth = hasValidCredentials(contextName); - const marker = isCurrent ? chalk.green('*') : ' '; - - console.log(`${marker} ${chalk.bold(contextName)}`); - - if (hasAuth && creds) { - console.log(` Status: ${chalk.green('Authenticated')}`); - console.log(` Token: ${maskToken(creds.token)}`); - if (creds.expiresAt) { - const expiresAt = new Date(creds.expiresAt); - const now = new Date(); - if (expiresAt <= now) { - console.log(` Expires: ${chalk.red(creds.expiresAt + ' (expired)')}`); - } else { - console.log(` Expires: ${creds.expiresAt}`); - } - } - } else if (creds && creds.token) { - console.log(` Status: ${chalk.red('Expired')}`); - console.log(` Token: ${maskToken(creds.token)}`); - if (creds.expiresAt) { - console.log(` Expired: ${creds.expiresAt}`); - } - } else { - console.log(` Status: ${chalk.yellow('Not authenticated')}`); - } - console.log(); -} - -function maskToken(token: string): string { - if (token.length <= 10) { - return '****'; - } - return token.substring(0, 6) + '...' + token.substring(token.length - 4); -} - -async function handleLogout( - argv: Partial>, - prompter: Inquirerer -) { - const contextName = await getTargetContext(argv, prompter); - - const creds = getContextCredentials(contextName); - if (!creds) { - console.log(chalk.gray(`No credentials found for context: ${contextName}`)); - return; - } - - const confirm = await prompter.prompt(argv, [ - { - type: 'confirm', - name: 'confirm', - message: `Remove credentials for context "${contextName}"?`, - default: false, - }, - ]); - - if (!confirm.confirm) { - console.log(chalk.gray('Cancelled.')); - return; - } - - if (removeContextCredentials(contextName)) { - console.log(chalk.green(`Credentials removed for context: ${contextName}`)); - } else { - console.log(chalk.gray(`No credentials found for context: ${contextName}`)); - } -} diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts deleted file mode 100644 index 062656f64a..0000000000 --- a/packages/cli/src/commands/codegen.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { CLIOptions, Inquirerer } from 'inquirerer'; -import { runCodegenHandler } from '@constructive-io/graphql-codegen'; - -const usage = ` -Constructive GraphQL Codegen: - - cnc codegen [OPTIONS] - -Source Options (choose one): - --config Path to graphql-codegen config file - --endpoint GraphQL endpoint URL - --schema-file Path to GraphQL schema file - --schema-dir Directory of .graphql files (auto-expands to multi-target) - -Database Options: - --schemas Comma-separated PostgreSQL schemas - --api-names Comma-separated API names - -Generator Options: - --react-query Generate React Query hooks (default) - --orm Generate ORM client - --output Output directory (default: codegen) - --target Target name (for multi-target configs) - --authorization Authorization header value - --dry-run Preview without writing files - --verbose Verbose output - -Schema Export: - --schema-enabled Export GraphQL SDL instead of running full codegen. - Works with any source (endpoint, file, database, PGPM). - With multiple apiNames, writes one .graphql per API. - --schema-output Output directory for the exported schema file - --schema-filename Filename for the exported schema (default: schema.graphql) - - --help, -h Show this help message -`; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - if (argv.help || argv.h) { - console.log(usage); - process.exit(0); - } - - await runCodegenHandler(argv as Record, prompter); -}; diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts deleted file mode 100644 index d91b711112..0000000000 --- a/packages/cli/src/commands/context.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Context management commands for the CNC execution engine - * Similar to kubectl contexts - manages named endpoint + credential configurations - */ - -import { CLIOptions, Inquirerer, extractFirst } from 'inquirerer'; -import chalk from 'yanse'; -import { - createContext, - listContexts, - loadContext, - deleteContext, - getCurrentContext, - setCurrentContext, - loadSettings, - saveSettings, - getContextCredentials, - hasValidCredentials, -} from '../config'; - -const usage = ` -Constructive Context Management: - - cnc context [OPTIONS] - -Commands: - create Create a new context - list List all contexts - use Set the active context - current Show current context - delete Delete a context - -Create Options: - --endpoint GraphQL endpoint URL - -Examples: - cnc context create my-api --endpoint https://api.example.com/graphql - cnc context list - cnc context use my-api - cnc context current - cnc context delete my-api - - --help, -h Show this help message -`; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - if (argv.help || argv.h) { - console.log(usage); - process.exit(0); - } - - const { first: subcommand, newArgv } = extractFirst(argv); - - if (!subcommand) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'subcommand', - message: 'What do you want to do?', - options: ['create', 'list', 'use', 'current', 'delete'], - }, - ]); - return handleSubcommand(answer.subcommand as string, newArgv, prompter); - } - - return handleSubcommand(subcommand, newArgv, prompter); -}; - -async function handleSubcommand( - subcommand: string, - argv: Partial>, - prompter: Inquirerer -) { - switch (subcommand) { - case 'create': - return handleCreate(argv, prompter); - case 'list': - return handleList(); - case 'use': - return handleUse(argv, prompter); - case 'current': - return handleCurrent(); - case 'delete': - return handleDelete(argv, prompter); - default: - console.log(usage); - console.error(chalk.red(`Unknown subcommand: ${subcommand}`)); - process.exit(1); - } -} - -async function handleCreate( - argv: Partial>, - prompter: Inquirerer -) { - const { first: name, newArgv } = extractFirst(argv); - const settings = loadSettings(); - - const answers = await prompter.prompt( - { name, ...newArgv }, - [ - { - type: 'text', - name: 'name', - message: 'Context name', - required: true, - }, - { - type: 'text', - name: 'endpoint', - message: 'GraphQL endpoint URL', - required: true, - }, - ] - ); - - const answersRecord = answers as Record; - const contextName = answersRecord.name as string; - const endpoint = answersRecord.endpoint as string; - - const existing = loadContext(contextName); - if (existing) { - console.error(chalk.red(`Context "${contextName}" already exists.`)); - console.log(chalk.gray(`Use "cnc context delete ${contextName}" to remove it first.`)); - process.exit(1); - } - - const context = createContext(contextName, endpoint); - - if (!settings.currentContext) { - setCurrentContext(contextName); - console.log(chalk.green(`Created and activated context: ${contextName}`)); - } else { - console.log(chalk.green(`Created context: ${contextName}`)); - } - - console.log(); - console.log(` Endpoint: ${context.endpoint}`); - console.log(); - console.log(chalk.gray(`Next: Run "cnc auth set-token " to configure authentication.`)); -} - -function handleList() { - const contexts = listContexts(); - const settings = loadSettings(); - - if (contexts.length === 0) { - console.log(chalk.gray('No contexts configured.')); - console.log(chalk.gray('Run "cnc context create " to create one.')); - return; - } - - console.log(chalk.bold('Contexts:')); - console.log(); - - for (const context of contexts) { - const isCurrent = context.name === settings.currentContext; - const hasAuth = hasValidCredentials(context.name); - const marker = isCurrent ? chalk.green('*') : ' '; - const authStatus = hasAuth ? chalk.green('[authenticated]') : chalk.yellow('[no token]'); - - console.log(`${marker} ${chalk.bold(context.name)} ${authStatus}`); - console.log(` Endpoint: ${context.endpoint}`); - console.log(); - } -} - -async function handleUse( - argv: Partial>, - prompter: Inquirerer -) { - const { first: name } = extractFirst(argv); - const contexts = listContexts(); - - if (contexts.length === 0) { - console.log(chalk.gray('No contexts configured.')); - console.log(chalk.gray('Run "cnc context create " to create one.')); - return; - } - - let contextName = name as string; - - if (!contextName) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'name', - message: 'Select context', - options: contexts.map(c => c.name), - }, - ]); - contextName = answer.name as string; - } - - if (setCurrentContext(contextName)) { - console.log(chalk.green(`Switched to context: ${contextName}`)); - } else { - console.error(chalk.red(`Context "${contextName}" not found.`)); - process.exit(1); - } -} - -function handleCurrent() { - const current = getCurrentContext(); - if (!current) { - console.log(chalk.gray('No current context set.')); - console.log(chalk.gray('Run "cnc context use " to set one.')); - return; - } - - const creds = getContextCredentials(current.name); - const hasAuth = hasValidCredentials(current.name); - - console.log(); - console.log(chalk.bold(`Current context: ${current.name}`)); - console.log(); - console.log(` Endpoint: ${current.endpoint}`); - console.log(` Created: ${current.createdAt}`); - console.log(` Updated: ${current.updatedAt}`); - console.log(); - console.log(chalk.bold('Authentication:')); - if (hasAuth) { - console.log(` Status: ${chalk.green('Authenticated')}`); - if (creds?.expiresAt) { - console.log(` Expires: ${creds.expiresAt}`); - } - } else { - console.log(` Status: ${chalk.yellow('Not authenticated')}`); - console.log(chalk.gray(` Run "cnc auth set-token " to configure.`)); - } - console.log(); -} - -async function handleDelete( - argv: Partial>, - prompter: Inquirerer -) { - const { first: name } = extractFirst(argv); - const contexts = listContexts(); - - if (contexts.length === 0) { - console.log(chalk.gray('No contexts configured.')); - return; - } - - let contextName = name as string; - - if (!contextName) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'name', - message: 'Select context to delete', - options: contexts.map(c => c.name), - }, - ]); - contextName = answer.name as string; - } - - const confirm = await prompter.prompt(argv, [ - { - type: 'confirm', - name: 'confirm', - message: `Are you sure you want to delete context "${contextName}"?`, - default: false, - }, - ]); - - if (!confirm.confirm) { - console.log(chalk.gray('Cancelled.')); - return; - } - - if (deleteContext(contextName)) { - const settings = loadSettings(); - if (settings.currentContext === contextName) { - settings.currentContext = undefined; - saveSettings(settings); - } - console.log(chalk.green(`Deleted context: ${contextName}`)); - } else { - console.error(chalk.red(`Context "${contextName}" not found.`)); - process.exit(1); - } -} diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts deleted file mode 100644 index 69e8859da8..0000000000 --- a/packages/cli/src/commands/execute.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Execute command for running GraphQL queries - */ - -import * as fs from 'fs'; -import { CLIOptions, Inquirerer } from 'inquirerer'; -import chalk from 'yanse'; -import { execute, getExecutionContext } from '../sdk'; - -const usage = ` -Constructive Execute - Run GraphQL Queries: - - cnc execute [OPTIONS] - -Options: - --query GraphQL query/mutation string - --file Path to file containing GraphQL query - --variables Variables as JSON string - --context Context to use (defaults to current) - -Examples: - # Execute inline query - cnc execute --query 'query { databases { nodes { id name } } }' - - # Execute from file - cnc execute --file query.graphql - - # With variables - cnc execute --query 'query($id: UUID!) { database(id: $id) { name } }' --variables '{"id":"..."}' - - --help, -h Show this help message -`; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - if (argv.help || argv.h) { - console.log(usage); - process.exit(0); - } - - let query: string | undefined; - let variables: Record | undefined; - - if (argv.file) { - const filePath = argv.file as string; - if (!fs.existsSync(filePath)) { - console.error(chalk.red(`File not found: ${filePath}`)); - process.exit(1); - } - query = fs.readFileSync(filePath, 'utf8'); - } else if (argv.query) { - query = argv.query as string; - } else { - const answers = await prompter.prompt(argv, [ - { - type: 'text', - name: 'query', - message: 'GraphQL query', - required: true, - }, - ]); - query = answers.query as string; - } - - if (argv.variables) { - try { - variables = JSON.parse(argv.variables as string); - } catch { - console.error(chalk.red('Invalid JSON in --variables')); - process.exit(1); - } - } - - let execContext; - try { - execContext = await getExecutionContext(argv.context as string | undefined); - } catch (error) { - console.error( - chalk.red( - error instanceof Error ? error.message : 'Failed to get execution context' - ) - ); - process.exit(1); - } - - console.log(chalk.gray(`Context: ${execContext.context.name}`)); - console.log(chalk.gray(`Endpoint: ${execContext.context.endpoint}`)); - console.log(); - - const result = await execute(query, variables, execContext); - - if (result.ok) { - console.log(chalk.green('Success!')); - console.log(); - console.log(JSON.stringify(result.data, null, 2)); - } else { - console.error(chalk.red('Failed!')); - console.log(); - if (result.errors) { - for (const error of result.errors) { - console.error(chalk.red(` - ${error.message}`)); - if (error.path) { - console.error(chalk.gray(` Path: ${error.path.join('.')}`)); - } - } - } - process.exit(1); - } -}; diff --git a/packages/cli/src/commands/explorer.ts b/packages/cli/src/commands/explorer.ts deleted file mode 100644 index 5d24a50f8b..0000000000 --- a/packages/cli/src/commands/explorer.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { getEnvOptions } from '@constructive-io/graphql-env'; -import { GraphQLExplorer as explorer } from '@constructive-io/graphql-explorer'; -import type { ConstructiveOptions } from '@constructive-io/graphql-types'; -import { Logger } from '@pgpmjs/logger'; -import { CLIOptions, Inquirerer, Question } from 'inquirerer'; - -const log = new Logger('explorer'); - -const explorerUsageText = ` -Constructive GraphQL Explorer: - - cnc explorer [OPTIONS] - - Launch GraphiQL explorer interface. - -Options: - --help, -h Show this help message - --port Server port (default: 5555) - --origin CORS origin URL (default: http://localhost:3000) - --simpleInflection Use simple inflection (default: true) - --oppositeBaseNames Use opposite base names (default: false) - --postgis Enable PostGIS extension (default: true) - --cwd Working directory (default: current directory) - -Examples: - cnc explorer Launch explorer with defaults - cnc explorer --origin http://localhost:4000 Launch explorer with custom origin -`; - -const questions: Question[] = [ - { - name: 'simpleInflection', - message: 'Use simple inflection?', - type: 'confirm', - required: false, - default: true, - useDefault: true - }, - { - name: 'oppositeBaseNames', - message: 'Use opposite base names?', - type: 'confirm', - required: false, - default: false, - useDefault: true - }, - { - name: 'postgis', - message: 'Enable PostGIS extension?', - type: 'confirm', - required: false, - default: true, - useDefault: true - }, - { - name: 'port', - message: 'Development server port', - type: 'number', - required: false, - default: 5555, - useDefault: true - }, - { - name: 'origin', - message: 'CORS origin URL', - type: 'text', - required: false, - default: 'http://localhost:3000', - useDefault: true - } -]; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - // Show usage if explicitly requested - if (argv.help || argv.h) { - console.log(explorerUsageText); - process.exit(0); - } - - log.info('🔧 Constructive GraphQL Explorer Configuration:\n'); - - const { - oppositeBaseNames, - origin, - port, - postgis, - simpleInflection - } = await prompter.prompt(argv, questions); - - const options: ConstructiveOptions = getEnvOptions({ - features: { - oppositeBaseNames, - simpleInflection, - postgis - }, - server: { - origin, - port - } - }); - - log.success('✅ Selected Configuration:'); - for (const [key, value] of Object.entries(options)) { - log.debug(`${key}: ${JSON.stringify(value)}`); - } - - log.success('🚀 Launching Explorer...\n'); - explorer(options); -}; diff --git a/packages/cli/src/commands/jobs.ts b/packages/cli/src/commands/jobs.ts deleted file mode 100644 index c2c7558cfb..0000000000 --- a/packages/cli/src/commands/jobs.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { existsSync } from 'fs'; -import { resolve } from 'path'; -import { - KnativeJobsSvc, - KnativeJobsSvcOptions, - FunctionName, - FunctionServiceConfig -} from '@constructive-io/knative-job-service'; -import { CLIOptions, Inquirerer, Question, cliExitWithError, extractFirst } from 'inquirerer'; - -const jobsUsageText = ` -Constructive Jobs: - - cnc jobs [OPTIONS] - - Start or manage Constructive jobs services. - -Subcommands: - up Start jobs runtime (jobs + functions) - -Options: - --help, -h Show this help message - --cwd Working directory (default: current directory) - --with-jobs-server Enable jobs server (default: disabled; flag-only) - --functions Comma-separated functions, optionally with ports (e.g. "fn=8080") - -Examples: - cnc jobs up - cnc jobs up --cwd /path/to/constructive - cnc jobs up --with-jobs-server --functions send-email,send-verification-link=8082 -`; - -const questions: Question[] = [ - { - name: 'withJobsServer', - alias: 'with-jobs-server', - message: 'Enable jobs server?', - type: 'confirm', - required: false, - default: false, - useDefault: true - } -]; - -const ensureCwd = (cwd: string): string => { - const resolved = resolve(cwd); - if (!existsSync(resolved)) { - throw new Error(`Working directory does not exist: ${resolved}`); - } - process.chdir(resolved); - return resolved; -}; - -type ParsedFunctionsArg = { - mode: 'all' | 'list'; - names: string[]; - ports: Record; -}; - -const parseFunctionsArg = (value: unknown): ParsedFunctionsArg | undefined => { - if (value === undefined) return undefined; - - const values = Array.isArray(value) ? value : [value]; - - const tokens: string[] = []; - for (const value of values) { - if (value === true) { - tokens.push('all'); - continue; - } - if (value === false || value === undefined || value === null) continue; - const raw = String(value); - raw - .split(',') - .map((part) => part.trim()) - .filter(Boolean) - .forEach((part) => tokens.push(part)); - } - - if (!tokens.length) { - return { mode: 'list', names: [], ports: {} }; - } - - const hasAll = tokens.some((token) => { - const normalized = token.trim().toLowerCase(); - return normalized === 'all' || normalized === '*'; - }); - - if (hasAll) { - if (tokens.length > 1) { - throw new Error('Use "all" without other function names.'); - } - return { mode: 'all', names: [], ports: {} }; - } - - const names: string[] = []; - const ports: Record = {}; - - for (const token of tokens) { - const trimmed = token.trim(); - if (!trimmed) continue; - - const separatorIndex = trimmed.search(/[:=]/); - if (separatorIndex === -1) { - names.push(trimmed); - continue; - } - - const name = trimmed.slice(0, separatorIndex).trim(); - const portText = trimmed.slice(separatorIndex + 1).trim(); - - if (!name) { - throw new Error(`Missing function name in "${token}".`); - } - if (!portText) { - throw new Error(`Missing port for function "${name}".`); - } - - const port = Number(portText); - if (!Number.isFinite(port) || port <= 0) { - throw new Error(`Invalid port "${portText}" for function "${name}".`); - } - - names.push(name); - ports[name] = port; - } - - const uniqueNames: string[] = []; - const seen = new Set(); - for (const name of names) { - if (seen.has(name)) continue; - seen.add(name); - uniqueNames.push(name); - } - - return { mode: 'list', names: uniqueNames, ports }; -}; - -const buildKnativeJobsSvcOptions = ( - args: Partial> -): KnativeJobsSvcOptions => { - const parsedFunctions = parseFunctionsArg(args.functions); - - let functions: KnativeJobsSvcOptions['functions']; - if (parsedFunctions) { - if (parsedFunctions.mode === 'all') { - functions = { enabled: true }; - } else if (parsedFunctions.names.length) { - const services: FunctionServiceConfig[] = parsedFunctions.names.map( - (name) => ({ - name: name as FunctionName, - port: parsedFunctions.ports[name] - }) - ); - functions = { enabled: true, services }; - } else { - functions = undefined; - } - } - - return { - jobs: { enabled: args.withJobsServer === true }, - functions - }; -}; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - if (argv.help || argv.h) { - console.log(jobsUsageText); - process.exit(0); - } - - const { first: subcommand, newArgv } = extractFirst(argv); - const args = newArgv as Partial>; - - if (!subcommand) { - console.log(jobsUsageText); - await cliExitWithError('No subcommand provided. Use "up".'); - return; - } - - switch (subcommand) { - case 'up': { - try { - ensureCwd((args.cwd as string) || process.cwd()); - const promptAnswers = await prompter.prompt(args, questions); - const server = new KnativeJobsSvc(buildKnativeJobsSvcOptions(promptAnswers)); - await server.start(); - } catch (error) { - await cliExitWithError( - `Failed to start jobs runtime: ${(error as Error).message}` - ); - } - break; - } - - default: - console.log(jobsUsageText); - await cliExitWithError(`Unknown subcommand: ${subcommand}. Use "up".`); - } -}; diff --git a/packages/cli/src/commands/server.ts b/packages/cli/src/commands/server.ts deleted file mode 100644 index dd88ef56c9..0000000000 --- a/packages/cli/src/commands/server.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { getEnvOptions } from '@constructive-io/graphql-env'; -import type { ConstructiveOptions } from '@constructive-io/graphql-types'; -import { Logger } from '@pgpmjs/logger'; -import { GraphQLServer as server } from '@constructive-io/graphql-server'; -import { CLIOptions, Inquirerer, OptionValue,Question } from 'inquirerer'; -import { getPgPool } from 'pg-cache'; - -const log = new Logger('server'); - -const serverUsageText = ` -Constructive GraphQL Server: - - cnc server [OPTIONS] - - Start Constructive GraphQL development server. - -Options: - --help, -h Show this help message - --port Server port (default: 5555) - --origin CORS origin URL (exact URL or * for wildcard) - --simpleInflection Use simple inflection (default: true) - --oppositeBaseNames Use opposite base names (default: false) - --postgis Enable PostGIS extension (default: true) - --servicesApi Enable Services API (default: true) - --cwd Working directory (default: current directory) - -Examples: - cnc server Start server with defaults - cnc server --port 8080 Start server on custom port - cnc server --no-postgis Start server without PostGIS - cnc server --origin http://localhost:3000 Set CORS origin -`; - -const questions: Question[] = [ - { - name: 'simpleInflection', - message: 'Use simple inflection?', - type: 'confirm', - required: false, - default: true, - useDefault: true - }, - { - name: 'oppositeBaseNames', - message: 'Use opposite base names?', - type: 'confirm', - required: false, - default: false, - useDefault: true - }, - { - name: 'postgis', - message: 'Enable PostGIS extension?', - type: 'confirm', - required: false, - default: true, - useDefault: true - }, - { - name: 'servicesApi', - message: 'Enable Services API?', - type: 'confirm', - required: false, - default: true, - useDefault: true - }, - { - name: 'origin', - message: 'CORS origin (exact URL or *)', - type: 'text', - required: false, - // no default to avoid accidentally opening up CORS; pass explicitly or via env - }, - { - name: 'port', - message: 'Development server port', - type: 'number', - required: false, - default: 5555, - useDefault: true - } -]; - -export default async ( - argv: Partial>, - prompter: Inquirerer, - _options: CLIOptions -) => { - // Show usage if explicitly requested - if (argv.help || argv.h) { - console.log(serverUsageText); - process.exit(0); - } - - log.info('🔧 Constructive GraphQL Server Configuration:\n'); - - let selectedDb: string | undefined = process.env.PGDATABASE; - - if (!selectedDb) { - const db = await getPgPool({ database: 'postgres' }); - const result = await db.query(` - SELECT datname FROM pg_database - WHERE datistemplate = false AND datname NOT IN ('postgres') - AND datname !~ '^pg_' - ORDER BY datname; - `); - - const dbChoices = result.rows.map(row => row.datname); - const { database } = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'database', - message: 'Select the database to use', - options: dbChoices, - required: true - } - ]); - - selectedDb = database; - log.info(`📌 Using database: "${selectedDb}"`); - } - - const { - oppositeBaseNames, - port, - postgis, - simpleInflection, - servicesApi, - origin - } = await prompter.prompt(argv, questions); - - // Warn when passing CORS override via CLI, especially in production - if (origin && origin.trim().length) { - const env = (process.env.NODE_ENV || 'development').toLowerCase(); - if (env === 'production') { - if (origin.trim() === '*') { - log.warn('CORS wildcard ("*") provided via --origin in production: this effectively disables CORS and is not recommended. Prefer per-API CORS via meta schema.'); - } else { - log.warn(`CORS override (origin=${origin.trim()}) provided via --origin in production. Prefer per-API CORS via meta schema.`); - } - } - } - - let selectedSchemas: string[] = []; - let authRole: string | undefined; - let roleName: string | undefined; - if (!servicesApi) { - const db = await getPgPool({ database: selectedDb }); - const result = await db.query(` - SELECT nspname - FROM pg_namespace - WHERE nspname NOT IN ('pg_catalog', 'information_schema') - ORDER BY nspname; - `); - - const schemaChoices = result.rows.map(row => ({ - name: row.nspname, - value: row.nspname, - selected: true - })); - const { schemas } = await prompter.prompt(argv, [ - { - type: 'checkbox', - name: 'schemas', - message: 'Select schemas to expose', - options: schemaChoices, - required: true - } - ]); - - selectedSchemas = (schemas as OptionValue[]).filter(s => s.selected).map(s => s.value); - const { authRole: selectedAuthRole, roleName: selectedRoleName } = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'authRole', - message: 'Select the authentication role', - options: ['postgres', 'authenticated', 'anonymous'], - required: true - }, - { - type: 'autocomplete', - name: 'roleName', - message: 'Enter the default role name:', - options: ['postgres', 'authenticated', 'anonymous'], - required: true - } - ]); - authRole = selectedAuthRole; - roleName = selectedRoleName; - } - - const options: ConstructiveOptions = getEnvOptions({ - pg: { database: selectedDb }, - features: { - oppositeBaseNames, - simpleInflection, - postgis - }, - api: { - enableServicesApi: servicesApi, - ...(servicesApi === false && { exposedSchemas: selectedSchemas, authRole, roleName }) - }, - server: { - port, - ...(origin ? { origin } : {}) - } - } as ConstructiveOptions); - - log.success('✅ Selected Configuration:'); - for (const [key, value] of Object.entries(options)) { - log.debug(`${key}: ${JSON.stringify(value)}`); - } - - // Debug: Log API routing configuration - const apiOpts = (options as any).api || {}; - log.debug(`📡 API Routing: isPublic=${apiOpts.isPublic}, enableServicesApi=${apiOpts.enableServicesApi}`); - if (apiOpts.isPublic === false) { - log.debug(` Header-based routing enabled (X-Api-Name, X-Database-Id, X-Meta-Schema)`); - } - if (apiOpts.metaSchemas?.length) { - log.debug(` Meta schemas: ${apiOpts.metaSchemas.join(', ')}`); - } - - log.success('🚀 Launching Server...\n'); - server(options); -}; diff --git a/packages/cli/src/runtime/execute-command.ts b/packages/cli/src/runtime/execute-command.ts index 1bc30ecd8f..8c0125ba9d 100644 --- a/packages/cli/src/runtime/execute-command.ts +++ b/packages/cli/src/runtime/execute-command.ts @@ -6,7 +6,9 @@ import { defineCommand, isSensitiveKey, Type, + type CommandAdapterHookMap, } from '@constructive-io/cli-runtime'; +import type { Inquirerer } from 'inquirerer'; import { ConfigStoreError, redactSecrets, type ConfigStore } from '../config'; import { @@ -494,3 +496,26 @@ export function createExecuteCommandDefinition( }, }); } + +export const createExecuteHooks = ( + prompter: Inquirerer +): CommandAdapterHookMap => ({ + execute: { + collectInteractiveInput: async (input) => { + const candidate = input as Record; + if (candidate.query !== undefined || candidate.file !== undefined) { + return candidate as never; + } + return (await prompter.prompt(candidate, [ + { + type: 'text', + name: 'query', + message: 'GraphQL query', + required: true, + }, + ])) as never; + }, + renderHuman: (result) => + JSON.stringify((result.data as { data: unknown }).data, null, 2), + }, +}); diff --git a/packages/cli/src/runtime/registry.ts b/packages/cli/src/runtime/registry.ts index ec0a7e5cb2..5dd24a65f2 100644 --- a/packages/cli/src/runtime/registry.ts +++ b/packages/cli/src/runtime/registry.ts @@ -18,8 +18,12 @@ import { createDiscoveryCommands, createDiscoveryHooks, } from './discovery-commands'; -import { createExecuteCommandDefinition } from './execute-command'; +import { + createExecuteCommandDefinition, + createExecuteHooks, +} from './execute-command'; import { serviceCommands } from './service-commands'; +import { createServiceHooks } from './service-hooks'; import { createStateCommands } from './state-commands'; export interface CncRegistryOptions { @@ -37,6 +41,14 @@ export interface CncEnvironmentRegistryOptions { export interface CncRegistryBundle { registry: CommandRegistry; createHooks(prompter: Inquirerer): CommandAdapterHookMap; + prepareTerminalInput(options: { + commandId: string; + input: Record; + readSecretFromStdin(): Promise; + }): Promise<{ + input: Record; + sensitiveValues: string[]; + }>; } export const createCncRegistry = ({ @@ -84,6 +96,16 @@ export const createCncRegistry = ({ return { registry, + async prepareTerminalInput({ commandId, input, readSecretFromStdin }) { + if (commandId !== 'auth.set-token' || input.readFromStdin !== true) { + return { input, sensitiveValues: [] }; + } + const stdinValue = await readSecretFromStdin(); + return { + input: { ...input, stdinValue }, + sensitiveValues: [stdinValue], + }; + }, createHooks(prompter) { const collectHumanContext = async ( input: unknown, @@ -144,6 +166,8 @@ export const createCncRegistry = ({ return { ...createDiscoveryHooks(), ...createCodegenHooks(prompter), + ...createExecuteHooks(prompter), + ...createServiceHooks(prompter), 'discovery.version': { renderHuman: (result) => (result.data as { version: string }).version, }, @@ -201,10 +225,6 @@ export const createCncRegistry = ({ collectInteractiveInput: async (input, context) => (await collectHumanContext(input, context)) as never, }, - execute: { - renderHuman: (result) => - JSON.stringify((result.data as { data: unknown }).data, null, 2), - }, }; }, }; From 17c29bbe2c3de2be50adf496bdfc45250f255ed7 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 21:57:02 +0700 Subject: [PATCH 18/18] refactor: decompose oversized protocol modules --- .github/workflows/run-tests.yaml | 1 + .../src/logger.ts | 3 + .../src/plugin.ts | 280 +-- .../src/upload-runtime.ts | 280 +++ graphql/codegen/src/core/generate.ts | 1625 +---------------- graphql/codegen/src/core/multi-generation.ts | 477 +++++ graphql/codegen/src/core/output/filesystem.ts | 118 ++ graphql/codegen/src/core/output/manifest.ts | 97 + graphql/codegen/src/core/output/planner.ts | 318 ++++ .../codegen/src/core/output/transaction.ts | 445 +++++ graphql/codegen/src/core/output/types.ts | 120 ++ graphql/codegen/src/core/output/writer.ts | 1094 +---------- .../codegen/src/core/shared-pgpm-source.ts | 210 +++ graphql/codegen/src/core/stale-targets.ts | 204 +++ graphql/codegen/src/core/target-generation.ts | 766 ++++++++ packages/cli-runtime/src/execution.ts | 66 +- packages/cli-runtime/src/operation-context.ts | 67 + .../cli/scripts/packed-acceptance-support.mjs | 158 ++ .../cli/scripts/verify-packed-artifacts.mjs | 168 +- 19 files changed, 3355 insertions(+), 3142 deletions(-) create mode 100644 graphile/graphile-presigned-url-plugin/src/logger.ts create mode 100644 graphile/graphile-presigned-url-plugin/src/upload-runtime.ts create mode 100644 graphql/codegen/src/core/multi-generation.ts create mode 100644 graphql/codegen/src/core/output/filesystem.ts create mode 100644 graphql/codegen/src/core/output/manifest.ts create mode 100644 graphql/codegen/src/core/output/planner.ts create mode 100644 graphql/codegen/src/core/output/transaction.ts create mode 100644 graphql/codegen/src/core/output/types.ts create mode 100644 graphql/codegen/src/core/shared-pgpm-source.ts create mode 100644 graphql/codegen/src/core/stale-targets.ts create mode 100644 graphql/codegen/src/core/target-generation.ts create mode 100644 packages/cli-runtime/src/operation-context.ts create mode 100644 packages/cli/scripts/packed-acceptance-support.mjs diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 270133d4fd..f8d53156d5 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -197,6 +197,7 @@ jobs: npm_config_ignore_scripts=true pnpm --filter graphile-presigned-url-plugin pack --pack-destination /tmp/cnc-package-artifacts npm_config_ignore_scripts=true pnpm --filter @constructive-io/cli pack --pack-destination /tmp/cnc-package-artifacts cp packages/cli/scripts/verify-packed-artifacts.mjs /tmp/cnc-package-artifacts/ + cp packages/cli/scripts/packed-acceptance-support.mjs /tmp/cnc-package-artifacts/ - name: Upload package artifacts uses: actions/upload-artifact@v4 diff --git a/graphile/graphile-presigned-url-plugin/src/logger.ts b/graphile/graphile-presigned-url-plugin/src/logger.ts new file mode 100644 index 0000000000..5fe830355e --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/src/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@pgpmjs/logger'; + +export const log = new Logger('graphile-presigned-url:plugin'); diff --git a/graphile/graphile-presigned-url-plugin/src/plugin.ts b/graphile/graphile-presigned-url-plugin/src/plugin.ts index 5affb95e39..2093992aa8 100644 --- a/graphile/graphile-presigned-url-plugin/src/plugin.ts +++ b/graphile/graphile-presigned-url-plugin/src/plugin.ts @@ -20,7 +20,6 @@ import { access, context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; import 'graphile-build'; -import { Logger } from '@pgpmjs/logger'; import type { PresignedUrlPluginOptions, @@ -28,6 +27,7 @@ import type { StorageModuleConfig, BucketConfig, } from './types'; +import { log } from './logger'; import { getBucketConfig, isS3BucketProvisioned, @@ -36,53 +36,8 @@ import { resolveStorageConfigFromCodec, StorageModuleCacheScope, } from './storage-module-cache'; -import { generatePresignedPutUrl, deleteS3Object } from './s3-signer'; - -const log = new Logger('graphile-presigned-url:plugin'); - -// --- Protocol-level constants (not configurable) --- - -const MAX_CONTENT_HASH_LENGTH = 128; -const MAX_CONTENT_TYPE_LENGTH = 255; -const MAX_CUSTOM_KEY_LENGTH = 1024; -const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/; -const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-\/]*$/; - -// --- Helpers --- - -function isValidSha256(hash: string): boolean { - return SHA256_HEX_REGEX.test(hash); -} - -function buildS3Key(contentHash: string): string { - return contentHash; -} - -function validateCustomKey(key: string): string | null { - if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) { - return 'INVALID_KEY_LENGTH: must be 1-1024 characters'; - } - if (key.includes('..')) { - return 'INVALID_KEY: path traversal (..) not allowed'; - } - if (key.startsWith('/')) { - return 'INVALID_KEY: leading slash not allowed'; - } - if (key.includes('\0')) { - return 'INVALID_KEY: null bytes not allowed'; - } - if (!CUSTOM_KEY_REGEX.test(key)) { - return 'INVALID_KEY: must start with alphanumeric and contain only alphanumeric, dots, hyphens, underscores, and slashes'; - } - return null; -} - -function derivePathFromKey(key: string): string | null { - const lastSlash = key.lastIndexOf('/'); - if (lastSlash <= 0) return null; - const dir = key.substring(0, lastSlash); - return dir.replace(/\//g, '.'); -} +import { deleteS3Object } from './s3-signer'; +import { processSingleFile } from './upload-runtime'; async function resolveDatabaseId(pgClient: any): Promise { const result = await pgClient.query({ @@ -859,234 +814,5 @@ export function createPresignedUrlPlugin( }; } -// --- Shared upload logic --- - -async function processSingleFile( - options: PresignedUrlPluginOptions, - txClient: any, - storageConfig: StorageModuleConfig, - databaseId: string, - bucket: BucketConfig, - s3ForDb: S3Config, - input: any -) { - const { contentHash, contentType, size, filename, key: customKey } = input; - - if ( - !contentHash || - typeof contentHash !== 'string' || - contentHash.length > MAX_CONTENT_HASH_LENGTH - ) { - throw new Error('INVALID_CONTENT_HASH'); - } - if (!isValidSha256(contentHash)) { - throw new Error( - 'INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256' - ); - } - if ( - !contentType || - typeof contentType !== 'string' || - contentType.length > MAX_CONTENT_TYPE_LENGTH - ) { - throw new Error('INVALID_CONTENT_TYPE'); - } - if ( - typeof size !== 'number' || - size <= 0 || - size > storageConfig.defaultMaxFileSize - ) { - throw new Error( - `INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes` - ); - } - if (filename !== undefined && filename !== null) { - if ( - typeof filename !== 'string' || - filename.length > storageConfig.maxFilenameLength - ) { - throw new Error('INVALID_FILENAME'); - } - } - - // Validate content type against bucket's allowed_mime_types - if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) { - const allowed = bucket.allowed_mime_types as string[]; - const isAllowed = allowed.some((pattern: string) => { - if (pattern === '*/*') return true; - if (pattern.endsWith('/*')) { - const prefix = pattern.slice(0, -1); - return contentType.startsWith(prefix); - } - return contentType === pattern; - }); - if (!isAllowed) { - throw new Error( - `CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types` - ); - } - } - - // Validate size against bucket's max_file_size - if (bucket.max_file_size && size > bucket.max_file_size) { - throw new Error( - `FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes` - ); - } - - // Determine S3 key - let s3Key: string; - let isCustomKey = false; - if (customKey) { - if (!bucket.allow_custom_keys) { - throw new Error( - 'CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys' - ); - } - const keyError = validateCustomKey(customKey); - if (keyError) { - throw new Error(keyError); - } - s3Key = customKey; - isCustomKey = true; - } else { - s3Key = buildS3Key(contentHash); - } - - // Dedup / versioning check - let previousVersionId: string | null = null; - - if (isCustomKey) { - const existingResult = await txClient.query({ - text: `SELECT id, content_hash - FROM ${storageConfig.filesQualifiedName} - WHERE key = $1 - AND bucket_id = $2 - ORDER BY created_at DESC - LIMIT 1`, - values: [s3Key, bucket.id], - }); - - if (existingResult.rows.length > 0) { - const existing = existingResult.rows[0]; - if (existing.content_hash === contentHash) { - log.info( - `Dedup hit (custom key): file ${existing.id} for key ${s3Key}` - ); - return { - uploadUrl: null as string | null, - fileId: existing.id as string, - key: s3Key, - deduplicated: true, - expiresAt: null as string | null, - previousVersionId: null as string | null, - }; - } - previousVersionId = existing.id; - log.info( - `Versioning: new version of key ${s3Key}, previous=${previousVersionId}` - ); - } - } else { - const dedupResult = await txClient.query({ - text: `SELECT id - FROM ${storageConfig.filesQualifiedName} - WHERE content_hash = $1 - AND bucket_id = $2 - LIMIT 1`, - values: [contentHash, bucket.id], - }); - - if (dedupResult.rows.length > 0) { - const existingFile = dedupResult.rows[0]; - log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`); - - return { - uploadUrl: null as string | null, - fileId: existingFile.id as string, - key: s3Key, - deduplicated: true, - expiresAt: null as string | null, - previousVersionId: null as string | null, - }; - } - } - - // Auto-derive ltree path from custom key directory (only when has_path_shares) - const derivedPath = - isCustomKey && storageConfig.hasPathShares - ? derivePathFromKey(s3Key) - : null; - - // Create file record - const hasOwnerColumn = storageConfig.scope !== 'app'; - const columns = [ - 'bucket_id', - 'key', - 'content_hash', - 'mime_type', - 'size', - 'filename', - 'is_public', - ]; - const values: any[] = [ - bucket.id, - s3Key, - contentHash, - contentType, - size, - filename || null, - bucket.is_public, - ]; - - if (hasOwnerColumn) { - columns.push('owner_id'); - values.push(bucket.owner_id); - } - if (previousVersionId) { - columns.push('previous_version_id'); - values.push(previousVersionId); - } - if (derivedPath) { - columns.push('path'); - values.push(derivedPath); - } - - const placeholders = values - .map((_: any, i: number) => `$${i + 1}`) - .join(', '); - const fileResult = await txClient.query({ - text: `INSERT INTO ${storageConfig.filesQualifiedName} - (${columns.join(', ')}) - VALUES (${placeholders}) - RETURNING id`, - values, - }); - - const fileId = fileResult.rows[0].id; - - // Generate presigned PUT URL - const uploadUrl = await generatePresignedPutUrl( - s3ForDb, - s3Key, - contentType, - size, - storageConfig.uploadUrlExpirySeconds - ); - - const expiresAt = new Date( - Date.now() + storageConfig.uploadUrlExpirySeconds * 1000 - ).toISOString(); - - return { - uploadUrl, - fileId, - key: s3Key, - deduplicated: false, - expiresAt, - previousVersionId, - }; -} - export const PresignedUrlPlugin = createPresignedUrlPlugin; export default PresignedUrlPlugin; diff --git a/graphile/graphile-presigned-url-plugin/src/upload-runtime.ts b/graphile/graphile-presigned-url-plugin/src/upload-runtime.ts new file mode 100644 index 0000000000..d682387902 --- /dev/null +++ b/graphile/graphile-presigned-url-plugin/src/upload-runtime.ts @@ -0,0 +1,280 @@ +import { log } from './logger'; +import { generatePresignedPutUrl } from './s3-signer'; +import type { + BucketConfig, + PresignedUrlPluginOptions, + S3Config, + StorageModuleConfig, +} from './types'; + +const MAX_CONTENT_HASH_LENGTH = 128; +const MAX_CONTENT_TYPE_LENGTH = 255; +const MAX_CUSTOM_KEY_LENGTH = 1024; +const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/; +const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-\/]*$/; + +function isValidSha256(hash: string): boolean { + return SHA256_HEX_REGEX.test(hash); +} + +function buildS3Key(contentHash: string): string { + return contentHash; +} + +function validateCustomKey(key: string): string | null { + if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) { + return 'INVALID_KEY_LENGTH: must be 1-1024 characters'; + } + if (key.includes('..')) { + return 'INVALID_KEY: path traversal (..) not allowed'; + } + if (key.startsWith('/')) { + return 'INVALID_KEY: leading slash not allowed'; + } + if (key.includes('\0')) { + return 'INVALID_KEY: null bytes not allowed'; + } + if (!CUSTOM_KEY_REGEX.test(key)) { + return 'INVALID_KEY: must start with alphanumeric and contain only alphanumeric, dots, hyphens, underscores, and slashes'; + } + return null; +} + +function derivePathFromKey(key: string): string | null { + const lastSlash = key.lastIndexOf('/'); + if (lastSlash <= 0) return null; + const dir = key.substring(0, lastSlash); + return dir.replace(/\//g, '.'); +} + +/** + * Validate, deduplicate, persist, and sign one upload inside the caller's + * transaction. Graphile field construction stays in the plugin; this module + * owns the storage runtime shared by single and bulk upload mutations. + */ +export async function processSingleFile( + options: PresignedUrlPluginOptions, + txClient: any, + storageConfig: StorageModuleConfig, + databaseId: string, + bucket: BucketConfig, + s3ForDb: S3Config, + input: any +) { + const { contentHash, contentType, size, filename, key: customKey } = input; + + if ( + !contentHash || + typeof contentHash !== 'string' || + contentHash.length > MAX_CONTENT_HASH_LENGTH + ) { + throw new Error('INVALID_CONTENT_HASH'); + } + if (!isValidSha256(contentHash)) { + throw new Error( + 'INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256' + ); + } + if ( + !contentType || + typeof contentType !== 'string' || + contentType.length > MAX_CONTENT_TYPE_LENGTH + ) { + throw new Error('INVALID_CONTENT_TYPE'); + } + if ( + typeof size !== 'number' || + size <= 0 || + size > storageConfig.defaultMaxFileSize + ) { + throw new Error( + `INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes` + ); + } + if (filename !== undefined && filename !== null) { + if ( + typeof filename !== 'string' || + filename.length > storageConfig.maxFilenameLength + ) { + throw new Error('INVALID_FILENAME'); + } + } + + // Validate content type against bucket's allowed_mime_types + if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) { + const allowed = bucket.allowed_mime_types as string[]; + const isAllowed = allowed.some((pattern: string) => { + if (pattern === '*/*') return true; + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, -1); + return contentType.startsWith(prefix); + } + return contentType === pattern; + }); + if (!isAllowed) { + throw new Error( + `CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types` + ); + } + } + + // Validate size against bucket's max_file_size + if (bucket.max_file_size && size > bucket.max_file_size) { + throw new Error( + `FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes` + ); + } + + // Determine S3 key + let s3Key: string; + let isCustomKey = false; + if (customKey) { + if (!bucket.allow_custom_keys) { + throw new Error( + 'CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys' + ); + } + const keyError = validateCustomKey(customKey); + if (keyError) { + throw new Error(keyError); + } + s3Key = customKey; + isCustomKey = true; + } else { + s3Key = buildS3Key(contentHash); + } + + // Dedup / versioning check + let previousVersionId: string | null = null; + + if (isCustomKey) { + const existingResult = await txClient.query({ + text: `SELECT id, content_hash + FROM ${storageConfig.filesQualifiedName} + WHERE key = $1 + AND bucket_id = $2 + ORDER BY created_at DESC + LIMIT 1`, + values: [s3Key, bucket.id], + }); + + if (existingResult.rows.length > 0) { + const existing = existingResult.rows[0]; + if (existing.content_hash === contentHash) { + log.info( + `Dedup hit (custom key): file ${existing.id} for key ${s3Key}` + ); + return { + uploadUrl: null as string | null, + fileId: existing.id as string, + key: s3Key, + deduplicated: true, + expiresAt: null as string | null, + previousVersionId: null as string | null, + }; + } + previousVersionId = existing.id; + log.info( + `Versioning: new version of key ${s3Key}, previous=${previousVersionId}` + ); + } + } else { + const dedupResult = await txClient.query({ + text: `SELECT id + FROM ${storageConfig.filesQualifiedName} + WHERE content_hash = $1 + AND bucket_id = $2 + LIMIT 1`, + values: [contentHash, bucket.id], + }); + + if (dedupResult.rows.length > 0) { + const existingFile = dedupResult.rows[0]; + log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`); + + return { + uploadUrl: null as string | null, + fileId: existingFile.id as string, + key: s3Key, + deduplicated: true, + expiresAt: null as string | null, + previousVersionId: null as string | null, + }; + } + } + + // Auto-derive ltree path from custom key directory (only when has_path_shares) + const derivedPath = + isCustomKey && storageConfig.hasPathShares + ? derivePathFromKey(s3Key) + : null; + + // Create file record + const hasOwnerColumn = storageConfig.scope !== 'app'; + const columns = [ + 'bucket_id', + 'key', + 'content_hash', + 'mime_type', + 'size', + 'filename', + 'is_public', + ]; + const values: any[] = [ + bucket.id, + s3Key, + contentHash, + contentType, + size, + filename || null, + bucket.is_public, + ]; + + if (hasOwnerColumn) { + columns.push('owner_id'); + values.push(bucket.owner_id); + } + if (previousVersionId) { + columns.push('previous_version_id'); + values.push(previousVersionId); + } + if (derivedPath) { + columns.push('path'); + values.push(derivedPath); + } + + const placeholders = values + .map((_: any, i: number) => `$${i + 1}`) + .join(', '); + const fileResult = await txClient.query({ + text: `INSERT INTO ${storageConfig.filesQualifiedName} + (${columns.join(', ')}) + VALUES (${placeholders}) + RETURNING id`, + values, + }); + + const fileId = fileResult.rows[0].id; + + // Generate presigned PUT URL + const uploadUrl = await generatePresignedPutUrl( + s3ForDb, + s3Key, + contentType, + size, + storageConfig.uploadUrlExpirySeconds + ); + + const expiresAt = new Date( + Date.now() + storageConfig.uploadUrlExpirySeconds * 1000 + ).toISOString(); + + return { + uploadUrl, + fileId, + key: s3Key, + deduplicated: false, + expiresAt, + previousVersionId, + }; +} diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 1c0c5c8337..f56c760df9 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -1,810 +1,48 @@ /** - * Main generate function - orchestrates the entire codegen pipeline + * Public code generation facade. * - * This is the primary entry point for programmatic usage. - * The CLI is a thin wrapper around this function. + * Single-target planning lives in target-generation; multi-target ownership, + * shared PGPM sources, and coordinated application live in multi-generation. */ -import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import path from 'node:path'; -import { buildClientSchema, printSchema } from 'graphql'; - -import { PgpmPackage } from '@pgpmjs/core'; -import { pgCache } from 'pg-cache'; -import { createEphemeralDb, type EphemeralDbResult } from 'pgsql-client'; -import { deployPgpm } from 'pgsql-seed'; - -import type { - CliConfig, - DbConfig, - GraphQLSDKConfigTarget, - PgpmConfig, - SchemaConfig, -} from '../types/config'; -import { getConfigOptions, mergeConfig } from '../types/config'; -import type { PgConfig } from 'pg-env'; -import type { Operation, Table, TypeRegistry } from '../types/schema'; -import { generate as generateReactQueryFiles } from './codegen'; -import { - generateRootBarrel, - generateMultiTargetBarrel, -} from './codegen/barrel'; -import { - generateCli as generateCliFiles, - generateMultiTargetCli, -} from './codegen/cli'; -import type { MultiTargetCliTarget } from './codegen/cli'; -import { - generateReadme as generateCliReadme, - generateAgentsDocs as generateCliAgentsDocs, - generateSkills as generateCliSkills, - generateMultiTargetReadme, - generateMultiTargetAgentsDocs, - generateMultiTargetSkills, -} from './codegen/cli/docs-generator'; -import type { MultiTargetDocsInput } from './codegen/cli/docs-generator'; -import { resolveDocsConfig } from './codegen/docs-utils'; -import { - generateHooksReadme, - generateHooksAgentsDocs, - generateHooksSkills, -} from './codegen/hooks-docs-generator'; -import { generateOrm as generateOrmFiles } from './codegen/orm'; -import { - generateOrmReadme, - generateOrmAgentsDocs, - generateOrmSkills, -} from './codegen/orm/docs-generator'; -import { generateSharedTypes } from './codegen/shared'; +import type { GraphQLSDKConfigTarget } from '../types/config'; import { - generateTargetReadme, - generateRootRootReadme, -} from './codegen/target-docs-generator'; -import type { RootRootReadmeTarget } from './codegen/target-docs-generator'; -import { - createSchemaSource, - resolvePgConfig, - validateSourceOptions, -} from './introspect'; -import { rethrowIfCancelled, throwIfAborted } from './cancellation'; -import type { - FileChange, - GeneratedFileWriteJob, - GenerationPlan, - WriteBatchResult, - WriteResult, -} from './output'; -import { GENERATED_FILES_MANIFEST, writeGeneratedFileJobs } from './output'; -import { runCodegenPipeline, validateTablesFound } from './pipeline'; -import { findWorkspaceRoot } from './workspace'; - -export interface GenerateOptions extends GraphQLSDKConfigTarget { - authorization?: string; - verbose?: boolean; - dryRun?: boolean; - skipCustomOperations?: boolean; - /** Base directory for all relative source and output paths. */ - cwd?: string; - /** Allow replacing or deleting generated files modified since the last run. */ - overwriteModifiedGenerated?: boolean; - /** Explicit confirmation required with overwriteModifiedGenerated. */ - yes?: boolean; - /** Receives progress without coupling the core generator to terminal output. */ - onProgress?: (event: GenerateProgressEvent) => void; - /** Cancels fetch and generation before the filesystem commit begins. */ - signal?: AbortSignal; - /** Explicit environment used to resolve omitted PostgreSQL settings. */ - env?: Readonly>; -} - -export interface GenerateProgressEvent { - phase: - | 'schema.fetch' - | 'types.generate' - | 'hooks.generate' - | 'orm.generate' - | 'cli.generate' - | 'pgpm.prepare'; - message: string; -} - -export interface GenerateResult { - success: boolean; - message: string; - output?: string; - tables?: string[]; - filesWritten?: string[]; - filesRemoved?: string[]; - /** Complete filesystem plans, including unchanged files and conflicts. */ - plans?: GenerationPlan[]; - /** Stable hash of the ordered plan fingerprints. */ - planFingerprint?: string; - /** Flattened file changes for agent-oriented consumers. */ - fileChanges?: FileChange[]; - errors?: string[]; - /** Non-fatal writer diagnostics, including retained transaction data. */ - warnings?: string[]; - /** Transaction directory retained because cleanup or rollback was incomplete. */ - recoveryPath?: string; - /** Filesystem restoration failures requiring manual recovery. */ - rollbackErrors?: string[]; - pipelineData?: { - tables: Table[]; - customOperations: { - queries: Operation[]; - mutations: Operation[]; - typeRegistry?: TypeRegistry; - }; - }; -} - -interface PrepareGenerationOptions { - skipCli?: boolean; - targetName?: string; -} - -interface PreparedGeneration { - result: GenerateResult; - writeJobs: GeneratedFileWriteJob[]; - messages: { - applied: string; - dryRun: string; - }; -} - -type GenerationPreparation = - | { ok: false; result: GenerateResult } - | { ok: true; prepared: PreparedGeneration }; - -function resolveSkillsOutputDir( - config: GraphQLSDKConfigTarget, - outputRoot: string, - cwd: string -): string { - const resolvedOutputRoot = resolvePathFrom(cwd, outputRoot)!; - const workspaceRoot = - findWorkspaceRoot(resolvedOutputRoot) ?? findWorkspaceRoot(cwd) ?? cwd; - - if (config.skillsPath) { - return path.isAbsolute(config.skillsPath) - ? config.skillsPath - : path.resolve(workspaceRoot, config.skillsPath); - } - - return path.resolve(workspaceRoot, '.agents/skills'); -} - -function resolvePathFrom( - cwd: string, - value: string | undefined -): string | undefined { - if (!value) return value; - return path.isAbsolute(value) - ? path.normalize(value) - : path.resolve(cwd, value); -} - -function resolveTargetPaths( - target: GraphQLSDKConfigTarget, - cwd: string -): GraphQLSDKConfigTarget { - return { - ...target, - output: resolvePathFrom(cwd, target.output), - schemaFile: resolvePathFrom(cwd, target.schemaFile), - schemaDir: resolvePathFrom(cwd, target.schemaDir), - schema: target.schema - ? { - ...target.schema, - output: resolvePathFrom(cwd, target.schema.output), - } - : undefined, - db: target.db - ? { - ...target.db, - pgpm: target.db.pgpm - ? { - ...target.db.pgpm, - modulePath: resolvePathFrom(cwd, target.db.pgpm.modulePath), - workspacePath: resolvePathFrom( - cwd, - target.db.pgpm.workspacePath - ), - } - : undefined, - } - : undefined, - }; -} - -function combinePlanFingerprint(plans: GenerationPlan[]): string | undefined { - if (plans.length === 0) return undefined; - return createHash('sha256') - .update( - JSON.stringify( - plans.map((plan) => ({ - outputDir: plan.outputDir, - fingerprint: plan.fingerprint, - })) - ) - ) - .digest('hex'); -} - -function planFields( - results: WriteResult[] -): Pick { - const plans = results.flatMap((result) => (result.plan ? [result.plan] : [])); - return { - plans, - planFingerprint: combinePlanFingerprint(plans), - fileChanges: plans.flatMap((plan) => plan.changes), - }; -} - -function recoveryFields( - results: WriteResult[] -): Pick { - const warnings = results.flatMap((result) => result.warnings ?? []); - const rollbackErrors = results.flatMap( - (result) => result.rollbackErrors ?? [] - ); - const recoveryPath = results.find( - (result) => result.recoveryPath !== undefined - )?.recoveryPath; - return { - ...(warnings.length === 0 ? {} : { warnings }), - ...(recoveryPath === undefined ? {} : { recoveryPath }), - ...(rollbackErrors.length === 0 ? {} : { rollbackErrors }), - }; -} - -const failedPreparation = (result: GenerateResult): GenerationPreparation => ({ - ok: false, - result, -}); - -async function applyGenerationJobs( - jobs: GeneratedFileWriteJob[], - options: Pick -): Promise { - throwIfAborted(options.signal); - return writeGeneratedFileJobs(jobs, { - dryRun: options.dryRun, - showProgress: false, - signal: options.signal, - }); -} - -function completePreparedGeneration( - prepared: PreparedGeneration, - dryRun: boolean, - writeResults: WriteResult[] = [] -): GenerateResult { - const filesWritten = dryRun - ? [] - : writeResults.flatMap((result) => result.filesWritten ?? []); - const filesRemoved = dryRun - ? [] - : writeResults.flatMap((result) => result.filesRemoved ?? []); - return { - ...prepared.result, - success: true, - message: dryRun ? prepared.messages.dryRun : prepared.messages.applied, - filesWritten, - filesRemoved, - ...planFields(writeResults), - ...recoveryFields(writeResults), - }; -} - -function failPreparedGeneration( - prepared: PreparedGeneration, - message: string, - writeResults: WriteResult[] = [], - errors: string[] = [message] -): GenerateResult { - return { - ...prepared.result, - success: false, - message, - errors, - filesWritten: [], - filesRemoved: [], - ...planFields(writeResults), - ...recoveryFields(writeResults), - }; -} - -async function planTargetGeneration( - options: GenerateOptions = {}, - internalOptions?: PrepareGenerationOptions -): Promise { - throwIfAborted(options.signal); - const cwd = path.resolve(options.cwd ?? process.cwd()); - const { - cwd: _cwd, - overwriteModifiedGenerated: _overwriteModifiedGenerated, - yes: _yes, - onProgress: _onProgress, - signal: _signal, - env: _env, - ...configOverrides - } = options; - const report = (event: GenerateProgressEvent): void => { - if (options.onProgress) options.onProgress(event); - else console.log(event.message); - }; - // Resolve every relative path against the operation cwd once. Downstream - // generation never needs to mutate or rediscover the process cwd. - const config = resolveTargetPaths(getConfigOptions(configOverrides), cwd); - const outputRoot = config.output!; - - // Determine which generators to run - // ORM is always required when React Query is enabled (hooks delegate to ORM) - // This handles minimist setting orm=false when --orm flag is absent - const runReactQuery = config.reactQuery ?? false; - const runCli = internalOptions?.skipCli ? false : !!config.cli; - const runOrm = - runReactQuery || - !!config.cli || - (options.orm !== undefined ? !!options.orm : false); - - const schemaEnabled = !!options.schema?.enabled; - - if (!schemaEnabled && !runReactQuery && !runOrm && !runCli) { - return failedPreparation({ - success: false, - message: - 'No generators enabled. Use reactQuery: true, orm: true, or cli: true in your config.', - output: outputRoot, - }); - } - - // Validate source - const sourceValidation = validateSourceOptions({ - endpoint: config.endpoint || undefined, - schemaFile: config.schemaFile || undefined, - db: config.db, - }); - if (!sourceValidation.valid) { - return failedPreparation({ - success: false, - message: sourceValidation.error!, - output: outputRoot, - }); - } - - const source = createSchemaSource({ - endpoint: config.endpoint || undefined, - schemaFile: config.schemaFile || undefined, - db: config.db, - authorization: options.authorization || config.headers?.Authorization, - headers: config.headers, - env: options.env ?? {}, - }); - - if (schemaEnabled && !runReactQuery && !runOrm && !runCli) { - try { - throwIfAborted(options.signal); - report({ - phase: 'schema.fetch', - message: `Fetching schema from ${source.describe()}...`, - }); - throwIfAborted(options.signal); - const { introspection } = await source.fetch(options.signal); - throwIfAborted(options.signal); - const schema = buildClientSchema(introspection as any); - const sdl = printSchema(schema); - throwIfAborted(options.signal); - - if (!sdl.trim()) { - return failedPreparation({ - success: false, - message: 'Schema introspection returned empty SDL.', - output: outputRoot, - }); - } - - const outDir = config.schema?.output || outputRoot; - const filename = options.schema?.filename || 'schema.graphql'; - const filePath = path.join(outDir, filename); - throwIfAborted(options.signal); - return { - ok: true, - prepared: { - result: { - success: true, - message: `Schema ready to export to ${filePath}`, - output: outDir, - }, - writeJobs: [ - { - files: [{ path: filename, content: sdl }], - outputDir: outDir, - options: { - pruneStaleFiles: false, - overwriteModifiedGenerated: options.overwriteModifiedGenerated, - confirmOverwrite: options.yes, - showProgress: false, - }, - }, - ], - messages: { - dryRun: `Dry run complete. Would export schema to ${filePath}`, - applied: `Schema exported to ${filePath}`, - }, - }, - }; - } catch (err) { - rethrowIfCancelled(err, options.signal); - return failedPreparation({ - success: false, - message: `Failed to export schema: ${err instanceof Error ? err.message : 'Unknown error'}`, - output: outputRoot, - }); - } - } - - // Run pipeline - let pipelineResult: Awaited>; - try { - throwIfAborted(options.signal); - report({ - phase: 'schema.fetch', - message: `Fetching schema from ${source.describe()}...`, - }); - throwIfAborted(options.signal); - pipelineResult = await runCodegenPipeline({ - source, - config, - verbose: options.verbose, - onLog: (message) => - report({ - phase: 'schema.fetch', - message, - }), - skipCustomOperations: options.skipCustomOperations, - signal: options.signal, - }); - throwIfAborted(options.signal); - } catch (err) { - rethrowIfCancelled(err, options.signal); - return failedPreparation({ - success: false, - message: `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}`, - output: outputRoot, - }); - } - - const { tables, customOperations } = pipelineResult; - - // Validate tables - const tablesValidation = validateTablesFound(tables); - if (!tablesValidation.valid) { - return failedPreparation({ - success: false, - message: tablesValidation.error!, - output: outputRoot, - }); - } - - const bothEnabled = runReactQuery && runOrm; - const filesToWrite: Array<{ path: string; content: string }> = []; - - // Generate shared types when both are enabled - if (bothEnabled) { - throwIfAborted(options.signal); - report({ phase: 'types.generate', message: 'Generating shared types...' }); - throwIfAborted(options.signal); - const sharedResult = generateSharedTypes({ - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, - }, - config, - }); - throwIfAborted(options.signal); - // The root barrel below is the final owner of index.ts and already exports - // shared ./types alongside the enabled generators. The old writer silently - // overwrote this shared-only barrel; keep the plan unambiguous instead. - filesToWrite.push( - ...sharedResult.files.filter((file) => file.path !== 'index.ts') - ); - } - - // Generate React Query hooks - if (runReactQuery) { - throwIfAborted(options.signal); - report({ - phase: 'hooks.generate', - message: 'Generating React Query hooks...', - }); - throwIfAborted(options.signal); - const { files } = generateReactQueryFiles({ - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, - }, - config, - sharedTypesPath: bothEnabled ? '..' : undefined, - }); - throwIfAborted(options.signal); - filesToWrite.push( - ...files.map((file) => ({ - ...file, - path: path.posix.join('hooks', file.path), - })) - ); - } - - // Generate ORM client - if (runOrm) { - throwIfAborted(options.signal); - report({ phase: 'orm.generate', message: 'Generating ORM client...' }); - throwIfAborted(options.signal); - const { files } = generateOrmFiles({ - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, - }, - config, - sharedTypesPath: bothEnabled ? '..' : undefined, - }); - throwIfAborted(options.signal); - filesToWrite.push( - ...files.map((file) => ({ - ...file, - path: path.posix.join('orm', file.path), - })) - ); - } - - // Generate CLI commands - if (runCli) { - throwIfAborted(options.signal); - report({ phase: 'cli.generate', message: 'Generating CLI commands...' }); - throwIfAborted(options.signal); - const { files } = generateCliFiles({ - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - }, - config, - typeRegistry: customOperations.typeRegistry, - }); - throwIfAborted(options.signal); - filesToWrite.push( - ...files.map((file) => ({ - path: path.posix.join('cli', file.fileName), - content: file.content, - })) - ); - } - - // Generate barrel file at output root - const barrelContent = generateRootBarrel({ - hasTypes: bothEnabled, - hasHooks: runReactQuery, - hasOrm: runOrm, - hasCli: runCli, - }); - filesToWrite.push({ path: 'index.ts', content: barrelContent }); - - // Generate docs for each enabled generator - const docsConfig = resolveDocsConfig(config.docs); - const allCustomOps: Operation[] = [ - ...(customOperations.queries ?? []), - ...(customOperations.mutations ?? []), - ]; - const targetName = internalOptions?.targetName ?? 'default'; - const skillsToWrite: Array<{ path: string; content: string }> = []; - - if (runOrm) { - if (docsConfig.readme) { - const readme = generateOrmReadme( - tables, - allCustomOps, - customOperations.typeRegistry - ); - filesToWrite.push({ - path: path.posix.join('orm', readme.fileName), - content: readme.content, - }); - } - if (docsConfig.agents) { - const agents = generateOrmAgentsDocs(tables, allCustomOps); - filesToWrite.push({ - path: path.posix.join('orm', agents.fileName), - content: agents.content, - }); - } - if (docsConfig.skills) { - for (const skill of generateOrmSkills( - tables, - allCustomOps, - targetName, - customOperations.typeRegistry - )) { - skillsToWrite.push({ path: skill.fileName, content: skill.content }); - } - } - } - - if (runReactQuery) { - if (docsConfig.readme) { - const readme = generateHooksReadme( - tables, - allCustomOps, - customOperations.typeRegistry - ); - filesToWrite.push({ - path: path.posix.join('hooks', readme.fileName), - content: readme.content, - }); - } - if (docsConfig.agents) { - const agents = generateHooksAgentsDocs(tables, allCustomOps); - filesToWrite.push({ - path: path.posix.join('hooks', agents.fileName), - content: agents.content, - }); - } - if (docsConfig.skills) { - for (const skill of generateHooksSkills( - tables, - allCustomOps, - targetName, - customOperations.typeRegistry - )) { - skillsToWrite.push({ path: skill.fileName, content: skill.content }); - } - } - } - - if (runCli) { - const toolName = - typeof config.cli === 'object' && config.cli?.toolName - ? config.cli.toolName - : 'app'; - if (docsConfig.readme) { - const readme = generateCliReadme( - tables, - allCustomOps, - toolName, - customOperations.typeRegistry - ); - filesToWrite.push({ - path: path.posix.join('cli', readme.fileName), - content: readme.content, - }); - } - if (docsConfig.agents) { - const agents = generateCliAgentsDocs( - tables, - allCustomOps, - toolName, - customOperations.typeRegistry - ); - filesToWrite.push({ - path: path.posix.join('cli', agents.fileName), - content: agents.content, - }); - } - if (docsConfig.skills) { - for (const skill of generateCliSkills( - tables, - allCustomOps, - toolName, - targetName, - customOperations.typeRegistry - )) { - skillsToWrite.push({ path: skill.fileName, content: skill.content }); - } - } - } - - // Generate per-target README at output root - if (docsConfig.readme) { - const targetReadme = generateTargetReadme({ - hasOrm: runOrm, - hasHooks: runReactQuery, - hasCli: runCli, - tableCount: tables.length, - customQueryCount: customOperations.queries.length, - customMutationCount: customOperations.mutations.length, - config, - }); - filesToWrite.push({ - path: targetReadme.fileName, - content: targetReadme.content, - }); - } - - const writeJobs: Array<{ - files: Array<{ path: string; content: string }>; - outputDir: string; - pruneStaleFiles: boolean; - }> = [ - { - files: filesToWrite, - outputDir: outputRoot, - pruneStaleFiles: true, - }, - ]; - throwIfAborted(options.signal); - if (skillsToWrite.length > 0) { - writeJobs.push({ - files: skillsToWrite, - outputDir: resolveSkillsOutputDir(config, outputRoot, cwd), - pruneStaleFiles: false, - }); - } - - throwIfAborted(options.signal); - const coordinatedJobs: GeneratedFileWriteJob[] = writeJobs.map((job) => ({ - files: job.files, - outputDir: job.outputDir, - options: { - pruneStaleFiles: job.pruneStaleFiles, - overwriteModifiedGenerated: options.overwriteModifiedGenerated, - confirmOverwrite: options.yes, - showProgress: false, - }, - })); - - const generators = [ - runReactQuery && 'React Query', - runOrm && 'ORM', - runCli && 'CLI', - ] - .filter(Boolean) - .join(' and '); - - return { - ok: true, - prepared: { - result: { - success: true, - message: `Generated files are ready to apply to ${outputRoot}`, - output: outputRoot, - tables: tables.map((t) => t.name), - pipelineData: { - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, - }, - }, - }, - writeJobs: coordinatedJobs, - messages: { - dryRun: `Dry run complete. Would generate ${generators} for ${tables.length} tables.`, - applied: `Generated ${generators} for ${tables.length} tables. Files written to ${outputRoot}`, - }, - }, - }; -} + applyGenerationJobs, + completePreparedGeneration, + failPreparedGeneration, + planTargetGeneration, + resolvePathFrom, + type GenerateOptions, + type GenerateResult, +} from './target-generation'; + +export type { + GenerateOptions, + GenerateProgressEvent, + GenerateResult, +} from './target-generation'; +export type { + GenerateMultiOptions, + GenerateMultiResult, +} from './multi-generation'; +export { + generateMulti, + removeStaleTargetDirs, + TARGETS_MANIFEST, +} from './multi-generation'; /** Generate one target and apply its complete filesystem plan atomically. */ export async function generate( - options: GenerateOptions = {} + options: GenerateOptions = {}, ): Promise { const preparation = await planTargetGeneration(options); if (preparation.ok === false) return preparation.result; const batch = await applyGenerationJobs( preparation.prepared.writeJobs, - options + options, ); if (!batch.success) { const errors = batch.errors ?? ['Failed to apply generated files.']; @@ -812,18 +50,18 @@ export async function generate( preparation.prepared, `Failed to ${options.dryRun ? 'plan' : 'write'} generated files: ${errors.join(', ')}`, batch.results, - errors + errors, ); } return completePreparedGeneration( preparation.prepared, options.dryRun === true, - batch.results + batch.results, ); } export function expandApiNamesToMultiTarget( - config: GraphQLSDKConfigTarget + config: GraphQLSDKConfigTarget, ): Record | null { const apiNames = config.db?.apiNames; if (!apiNames || apiNames.length <= 1) return null; @@ -846,7 +84,7 @@ export function expandApiNamesToMultiTarget( export function expandSchemaDirToMultiTarget( config: GraphQLSDKConfigTarget, - cwd: string = process.cwd() + cwd: string = process.cwd(), ): Record | null { const schemaDir = config.schemaDir; if (!schemaDir) return null; @@ -858,7 +96,7 @@ export function expandSchemaDirToMultiTarget( const graphqlFiles = fs .readdirSync(resolvedDir) - .filter((f) => f.endsWith('.graphql')) + .filter((file) => file.endsWith('.graphql')) .sort(); if (graphqlFiles.length === 0) return null; @@ -877,798 +115,3 @@ export function expandSchemaDirToMultiTarget( } return targets; } - -export interface GenerateMultiOptions { - configs: Record; - cliOverrides?: Partial; - verbose?: boolean; - dryRun?: boolean; - schema?: SchemaConfig; - unifiedCli?: CliConfig | boolean; - /** Remove subdirectories in the output root that don't match any current target name. */ - cleanStaleTargets?: boolean; - /** Base directory for relative config, source, and output paths. */ - cwd?: string; - overwriteModifiedGenerated?: boolean; - yes?: boolean; - onProgress?: (event: GenerateProgressEvent) => void; - signal?: AbortSignal; - /** Explicit environment used to resolve omitted PostgreSQL settings. */ - env?: Readonly>; -} - -export interface GenerateMultiResult { - results: Array<{ name: string; result: GenerateResult }>; - hasError: boolean; - plans?: GenerationPlan[]; - planFingerprint?: string; - fileChanges?: FileChange[]; - warnings?: string[]; - recoveryPath?: string; - rollbackErrors?: string[]; -} - -interface SharedPgpmSource { - key: string; - description: string; - ephemeralDb: EphemeralDbResult; - deployed: boolean; -} - -function getPgpmSourceKey(pgpm: PgpmConfig, cwd: string): string | null { - if (pgpm.modulePath) return `module:${resolvePathFrom(cwd, pgpm.modulePath)}`; - if (pgpm.workspacePath && pgpm.moduleName) - return `workspace:${resolvePathFrom(cwd, pgpm.workspacePath)}:${pgpm.moduleName}`; - return null; -} - -function getSharedPgpmSourceKey( - pgpm: PgpmConfig, - cwd: string, - pgConfig: PgConfig -): string | null { - const sourceKey = getPgpmSourceKey(pgpm, cwd); - if (!sourceKey) return null; - const connectionFingerprint = createHash('sha256') - .update( - JSON.stringify([ - pgConfig.host, - pgConfig.port, - pgConfig.user, - pgConfig.password, - pgConfig.database, - ]) - ) - .digest('hex'); - return `${sourceKey}:connection:${connectionFingerprint}`; -} - -function getModulePathFromPgpm(pgpm: PgpmConfig, cwd: string): string { - if (pgpm.modulePath) return resolvePathFrom(cwd, pgpm.modulePath)!; - if (pgpm.workspacePath && pgpm.moduleName) { - const workspace = new PgpmPackage( - resolvePathFrom(cwd, pgpm.workspacePath)! - ); - const moduleProject = workspace.getModuleProject(pgpm.moduleName); - const modulePath = moduleProject.getModulePath(); - if (!modulePath) { - throw new Error(`Module "${pgpm.moduleName}" not found in workspace`); - } - return modulePath; - } - throw new Error( - 'Invalid PGPM config: requires modulePath or workspacePath+moduleName' - ); -} - -async function prepareSharedPgpmSources( - configs: Record, - cliOverrides: Partial | undefined, - cwd: string, - env: Readonly>, - onProgress?: (event: GenerateProgressEvent) => void, - signal?: AbortSignal -): Promise> { - const sharedSources = new Map(); - const pgpmTargets = new Map< - string, - { - count: number; - pgpm: PgpmConfig; - baseConfig: PgConfig; - description: string; - } - >(); - - throwIfAborted(signal); - - for (const name of Object.keys(configs)) { - throwIfAborted(signal); - const merged = mergeConfig(configs[name], cliOverrides ?? {}); - const pgpm = merged.db?.pgpm; - if (!pgpm) continue; - const baseConfig = resolvePgConfig(merged.db?.config, env); - const key = getSharedPgpmSourceKey(pgpm, cwd, baseConfig); - if (!key) continue; - const existing = pgpmTargets.get(key); - pgpmTargets.set(key, { - count: (existing?.count ?? 0) + 1, - pgpm, - baseConfig, - description: getPgpmSourceKey(pgpm, cwd)!, - }); - } - - try { - for (const [key, target] of pgpmTargets) { - throwIfAborted(signal); - if (target.count < 2) continue; - - throwIfAborted(signal); - const ephemeralDb = createEphemeralDb({ - prefix: 'codegen_pgpm_shared_', - verbose: false, - baseConfig: target.baseConfig, - }); - const shared: SharedPgpmSource = { - key, - description: target.description, - ephemeralDb, - deployed: false, - }; - sharedSources.set(key, shared); - - throwIfAborted(signal); - const modulePath = getModulePathFromPgpm(target.pgpm, cwd); - await deployPgpm(ephemeralDb.config, modulePath, false); - shared.deployed = true; - throwIfAborted(signal); - - const event: GenerateProgressEvent = { - phase: 'pgpm.prepare', - message: `[multi-target] Shared PGPM source deployed once for ${target.count} targets: ${target.description}`, - }; - if (onProgress) onProgress(event); - else console.log(event.message); - } - } catch (error) { - try { - for (const shared of sharedSources.values()) { - pgCache.delete(shared.ephemeralDb.config.database); - } - await pgCache.waitForDisposals(); - } finally { - for (const shared of sharedSources.values()) { - shared.ephemeralDb.teardown({ keepDb: false }); - } - } - throw error; - } - - return sharedSources; -} - -function applySharedPgpmDb( - config: GraphQLSDKConfigTarget, - sharedSources: Map, - cwd: string, - env: Readonly> -): GraphQLSDKConfigTarget { - const pgpm = config.db?.pgpm; - if (!pgpm) return config; - - const key = getSharedPgpmSourceKey( - pgpm, - cwd, - resolvePgConfig(config.db?.config, env) - ); - if (!key) return config; - - const shared = sharedSources.get(key); - if (!shared) return config; - - const sharedDbConfig: DbConfig = { - ...config.db, - pgpm: undefined, - config: shared.ephemeralDb.config, - keepDb: true, - }; - - return { - ...config, - db: sharedDbConfig, - }; -} - -/** Manifest file listing generated target names, written to the output root. */ -export const TARGETS_MANIFEST = '.targets'; - -function isSafeTargetName(target: unknown): target is string { - return ( - typeof target === 'string' && - target.length > 0 && - target !== '.' && - target !== '..' && - !path.isAbsolute(target) && - !target.includes('/') && - !target.includes('\\') && - !target.includes('\0') - ); -} - -function readTargetNamesManifest(outputRoot: string): string[] | null { - const manifestPath = path.join(outputRoot, TARGETS_MANIFEST); - if (!fs.existsSync(manifestPath)) return null; - const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - if ( - !Array.isArray(parsed) || - !parsed.every(isSafeTargetName) || - new Set(parsed).size !== parsed.length - ) { - throw new Error(`Invalid target ownership manifest: ${manifestPath}`); - } - return parsed; -} - -function getStaleTargetWriteJobs( - outputRoot: string, - currentTargetNames: string[] -): GeneratedFileWriteJob[] { - if (!currentTargetNames.every(isSafeTargetName)) { - throw new Error('Target names must be single safe path segments.'); - } - const previousTargets = readTargetNamesManifest(outputRoot); - if (!previousTargets) return []; - const current = new Set(currentTargetNames); - const jobs: GeneratedFileWriteJob[] = []; - - for (const target of previousTargets) { - if (current.has(target)) continue; - const targetDir = path.join(outputRoot, target); - if (!fs.existsSync(targetDir)) continue; - if (fs.lstatSync(targetDir).isSymbolicLink()) { - throw new Error( - `Refusing to clean a symbolic-link target directory: ${targetDir}` - ); - } - const generatedManifest = path.join(targetDir, GENERATED_FILES_MANIFEST); - // Legacy empty/unmanaged directories are preserved. Only a writer-owned - // target can participate in the transactional cleanup plan. - if (!fs.existsSync(generatedManifest)) continue; - jobs.push({ - files: [], - outputDir: targetDir, - options: { - pruneStaleFiles: true, - removeManifestWhenEmpty: true, - showProgress: false, - }, - }); - } - return jobs; -} - -function removeEmptyDirectoryTree(directory: string): void { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - if (entry.isDirectory()) { - removeEmptyDirectoryTree(path.join(directory, entry.name)); - } - } - try { - fs.rmdirSync(directory); - } catch { - // Files or non-empty child directories are unowned and must survive. - } -} - -/** - * Remove stale generated target directories from `outputRoot`. - * Reads the `.targets` manifest (written by `generateMulti`) to know which - * directories were previously generated. Only those are eligible for removal; - * hand-written directories (e.g. `config/`, `utils/`) are never touched. - * Returns the list of directory names that were removed. - * - * @deprecated Use generateMulti({ cleanStaleTargets: true }), which plans this - * cleanup and commits it with the rest of the generation operation. - */ -export function removeStaleTargetDirs( - outputRoot: string, - currentTargetNames: string[], - verbose?: boolean -): string[] { - const removed: string[] = []; - if (!fs.existsSync(outputRoot)) return removed; - - const manifestPath = path.join(outputRoot, TARGETS_MANIFEST); - if (!fs.existsSync(manifestPath)) return removed; - - let previousTargets: string[]; - try { - previousTargets = readTargetNamesManifest(outputRoot) ?? []; - } catch { - return removed; - } - - if (!currentTargetNames.every(isSafeTargetName)) return removed; - - const currentTargets = new Set(currentTargetNames); - const staleTargets = previousTargets.filter((t) => !currentTargets.has(t)); - - for (const target of staleTargets) { - const dirPath = path.join(outputRoot, target); - if (!fs.existsSync(dirPath)) continue; - if (fs.lstatSync(dirPath).isSymbolicLink()) continue; - - const entries = fs.readdirSync(dirPath); - if (entries.length === 0) { - fs.rmdirSync(dirPath); - removed.push(target); - if (verbose) { - console.log(`Removed stale target directory: ${target}`); - } - continue; - } - - const generatedManifestPath = path.join(dirPath, GENERATED_FILES_MANIFEST); - if (!fs.existsSync(generatedManifestPath)) continue; - - let ownedFiles: Record; - try { - const generatedManifest = JSON.parse( - fs.readFileSync(generatedManifestPath, 'utf8') - ) as { files?: Record }; - if ( - !generatedManifest.files || - typeof generatedManifest.files !== 'object' - ) { - continue; - } - ownedFiles = Object.fromEntries( - Object.entries(generatedManifest.files).map(([filePath, entry]) => { - if ( - !entry || - typeof entry.sha256 !== 'string' || - !/^[a-f0-9]{64}$/.test(entry.sha256) || - path.isAbsolute(filePath) || - filePath.includes('\\') || - filePath - .split('/') - .some( - (segment) => - segment === '' || segment === '.' || segment === '..' - ) - ) { - throw new Error('Invalid generated manifest'); - } - return [filePath, { sha256: entry.sha256 }]; - }) - ); - } catch { - continue; - } - - const ownedPaths = Object.entries(ownedFiles).map(([filePath, entry]) => ({ - absolutePath: path.join(dirPath, ...filePath.split('/')), - sha256: entry.sha256, - })); - const modifiedOwnedFile = ownedPaths.some(({ absolutePath, sha256 }) => { - if (!fs.existsSync(absolutePath)) return false; - return ( - createHash('sha256') - .update(fs.readFileSync(absolutePath)) - .digest('hex') !== sha256 - ); - }); - if (modifiedOwnedFile) continue; - - for (const { absolutePath } of ownedPaths) { - fs.rmSync(absolutePath, { force: true }); - } - fs.rmSync(generatedManifestPath, { force: true }); - removeEmptyDirectoryTree(dirPath); - if (!fs.existsSync(dirPath)) { - removed.push(target); - if (verbose) { - console.log(`Removed stale target directory: ${target}`); - } - } - } - return removed; -} - -export async function generateMulti( - options: GenerateMultiOptions -): Promise { - throwIfAborted(options.signal); - const { - configs, - cliOverrides, - verbose, - dryRun, - schema, - unifiedCli, - cleanStaleTargets, - overwriteModifiedGenerated, - yes, - } = options; - const cwd = path.resolve(options.cwd ?? process.cwd()); - const env = options.env ?? {}; - const names = Object.keys(configs); - const results: Array<{ name: string; result: GenerateResult }> = []; - let hasError = false; - - const schemaEnabled = !!schema?.enabled; - const targetInfos: RootRootReadmeTarget[] = []; - const useUnifiedCli = !schemaEnabled && !!unifiedCli && names.length > 1; - - const cliTargets: MultiTargetCliTarget[] = []; - const additionalWriteResults: WriteResult[] = []; - const collectedWriteJobs: GeneratedFileWriteJob[] = []; - const preparedTargets = new Map(); - let staleTargetWriteJobs: GeneratedFileWriteJob[] = []; - const additionalWriteJobs: Array<{ - files: Array<{ path: string; content: string }>; - outputDir: string; - adoptUnownedPaths?: string[]; - }> = []; - - // Stale cleanup is a read-only planning input. It is committed with every - // other generated root only after all targets have generated successfully. - if (cleanStaleTargets && names.length > 0) { - try { - throwIfAborted(options.signal); - const firstOutput = resolveTargetPaths( - getConfigOptions(configs[names[0]]), - cwd - ).output!; - const outputRoot = path.dirname(firstOutput); - staleTargetWriteJobs = getStaleTargetWriteJobs(outputRoot, names); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Failed to plan stale target cleanup.'; - return { - results: [ - { - name: 'targets', - result: { - success: false, - message, - errors: [message], - }, - }, - ], - hasError: true, - plans: [], - fileChanges: [], - }; - } - } - - const sharedSources = await prepareSharedPgpmSources( - configs, - cliOverrides, - cwd, - env, - options.onProgress, - options.signal - ); - - try { - for (const name of names) { - throwIfAborted(options.signal); - const baseConfig = mergeConfig(configs[name], cliOverrides ?? {}); - const targetConfig = applySharedPgpmDb( - baseConfig, - sharedSources, - cwd, - env - ); - const preparation = await planTargetGeneration( - { - ...targetConfig, - verbose, - dryRun, - cwd, - overwriteModifiedGenerated, - yes, - onProgress: options.onProgress, - signal: options.signal, - env, - schema: schemaEnabled - ? { ...schema, filename: schema?.filename ?? `${name}.graphql` } - : targetConfig.schema, - }, - useUnifiedCli - ? { - skipCli: true, - targetName: name, - } - : { targetName: name } - ); - const result = - preparation.ok === false - ? preparation.result - : preparation.prepared.result; - results.push({ name, result }); - if (preparation.ok === false) { - hasError = true; - } else { - preparedTargets.set(name, preparation.prepared); - collectedWriteJobs.push(...preparation.prepared.writeJobs); - const displayConfig = getConfigOptions(targetConfig); - const resolvedConfig = resolveTargetPaths(displayConfig, cwd); - const gens: string[] = []; - if (resolvedConfig.reactQuery) gens.push('React Query'); - if ( - resolvedConfig.orm || - resolvedConfig.reactQuery || - !!resolvedConfig.cli - ) - gens.push('ORM'); - if (resolvedConfig.cli) gens.push('CLI'); - targetInfos.push({ - name, - output: displayConfig.output!, - endpoint: resolvedConfig.endpoint || undefined, - generators: gens, - }); - - if (useUnifiedCli && result.pipelineData) { - const isAuthTarget = name === 'auth'; - cliTargets.push({ - name, - endpoint: resolvedConfig.endpoint || '', - ormImportPath: `../${displayConfig.output!.replace(/^\.\//, '')}/orm`, - tables: result.pipelineData.tables, - customOperations: result.pipelineData.customOperations, - isAuthTarget, - typeRegistry: result.pipelineData.customOperations.typeRegistry, - }); - } - } - } - - if (useUnifiedCli && cliTargets.length > 0) { - throwIfAborted(options.signal); - const cliConfig = typeof unifiedCli === 'object' ? unifiedCli : {}; - const toolName = cliConfig.toolName ?? 'app'; - const firstTargetConfig = configs[names[0]]; - const { files } = generateMultiTargetCli({ - toolName, - builtinNames: cliConfig.builtinNames, - targets: cliTargets, - entryPoint: cliConfig.entryPoint, - }); - throwIfAborted(options.signal); - - const cliFilesToWrite = files.map((file) => ({ - path: path.posix.join('cli', file.fileName), - content: file.content, - })); - - const firstTargetDocsConfig = names.length > 0 && configs[names[0]]?.docs; - const docsConfig = resolveDocsConfig(firstTargetDocsConfig); - const { resolveBuiltinNames } = await import('./codegen/cli'); - const builtinNames = resolveBuiltinNames( - cliTargets.map((t) => t.name), - cliConfig.builtinNames - ); - - // Merge all target type registries into a combined registry for docs generation - const combinedRegistry = new Map< - string, - import('../types/schema').ResolvedType - >(); - for (const t of cliTargets) { - if (t.typeRegistry) { - for (const [key, value] of t.typeRegistry) { - combinedRegistry.set(key, value); - } - } - } - - const docsInput: MultiTargetDocsInput = { - toolName, - builtinNames, - registry: combinedRegistry.size > 0 ? combinedRegistry : undefined, - targets: cliTargets.map((t) => ({ - name: t.name, - endpoint: t.endpoint, - tables: t.tables, - customOperations: [ - ...(t.customOperations?.queries ?? []), - ...(t.customOperations?.mutations ?? []), - ], - isAuthTarget: t.isAuthTarget, - })), - }; - - if (docsConfig.readme) { - const readme = generateMultiTargetReadme(docsInput); - cliFilesToWrite.push({ - path: path.posix.join('cli', readme.fileName), - content: readme.content, - }); - } - if (docsConfig.agents) { - const agents = generateMultiTargetAgentsDocs(docsInput); - cliFilesToWrite.push({ - path: path.posix.join('cli', agents.fileName), - content: agents.content, - }); - } - additionalWriteJobs.push({ - files: cliFilesToWrite, - outputDir: cwd, - }); - - if (docsConfig.skills) { - const cliSkillsToWrite = generateMultiTargetSkills(docsInput).map( - (skill) => ({ - path: skill.fileName, - content: skill.content, - }) - ); - - const firstTargetResolved = getConfigOptions({ - ...(firstTargetConfig ?? {}), - ...(cliOverrides ?? {}), - }); - const skillsOutputDir = resolveSkillsOutputDir( - firstTargetResolved, - firstTargetResolved.output, - cwd - ); - additionalWriteJobs.push({ - files: cliSkillsToWrite, - outputDir: skillsOutputDir, - }); - } - } - - // Generate root-root README and barrel if multi-target - if (names.length > 1 && targetInfos.length > 0) { - throwIfAborted(options.signal); - const rootReadme = generateRootRootReadme(targetInfos); - additionalWriteJobs.push({ - files: [{ path: rootReadme.fileName, content: rootReadme.content }], - outputDir: cwd, - }); - - // Write a root barrel (index.ts) that re-exports each target as a - // namespace so the package has a single entry-point. Derive the - // common output root from the first target's output path. - const successfulNames = [...preparedTargets.keys()]; - if (successfulNames.length > 0) { - const firstOutput = resolveTargetPaths( - getConfigOptions(configs[successfulNames[0]]), - cwd - ).output!; - const outputRoot = path.dirname(firstOutput); - const barrelContent = generateMultiTargetBarrel(successfulNames); - additionalWriteJobs.push({ - files: [ - { path: 'index.ts', content: barrelContent }, - { - path: TARGETS_MANIFEST, - content: `${JSON.stringify(successfulNames.sort())}\n`, - }, - ], - outputDir: outputRoot, - adoptUnownedPaths: [TARGETS_MANIFEST], - }); - } - } - - if (cleanStaleTargets && names.length === 1 && targetInfos.length === 1) { - const firstOutput = resolveTargetPaths( - getConfigOptions({ - ...configs[names[0]], - ...(cliOverrides ?? {}), - }), - cwd - ).output!; - additionalWriteJobs.push({ - files: [ - { - path: TARGETS_MANIFEST, - content: `${JSON.stringify(names)}\n`, - }, - ], - outputDir: path.dirname(firstOutput), - adoptUnownedPaths: [TARGETS_MANIFEST], - }); - } - - if (!hasError) { - throwIfAborted(options.signal); - const finalJobs: GeneratedFileWriteJob[] = [ - ...collectedWriteJobs, - ...staleTargetWriteJobs.map((job) => ({ - ...job, - options: { - ...(job.options ?? {}), - overwriteModifiedGenerated, - confirmOverwrite: yes, - }, - })), - ...additionalWriteJobs.map((job) => ({ - files: job.files, - outputDir: job.outputDir, - options: { - pruneStaleFiles: false, - overwriteModifiedGenerated, - confirmOverwrite: yes, - adoptUnownedPaths: job.adoptUnownedPaths, - showProgress: false, - }, - })), - ]; - const batch = await applyGenerationJobs(finalJobs, { - dryRun, - signal: options.signal, - }); - additionalWriteResults.push(...batch.results); - if (!batch.success) { - hasError = true; - const errors = batch.errors ?? ['Failed to apply generated files.']; - const recovery = recoveryFields(batch.results); - results.push({ - name: 'write', - result: { - success: false, - message: errors.join(', '), - errors, - ...planFields(batch.results), - ...recovery, - }, - }); - } - } - } finally { - for (const shared of sharedSources.values()) { - const keepDb = Object.values(configs).some((c) => c.db?.keepDb); - try { - // deployPgpm() caches connections that must be closed before dropping. - pgCache.delete(shared.ephemeralDb.config.database); - await pgCache.waitForDisposals(); - } finally { - shared.ephemeralDb.teardown({ keepDb }); - } - } - } - - const failedAttempt = results.find(({ result }) => !result.success); - const notAppliedMessage = - failedAttempt?.name === 'write' - ? 'Generation was not applied because the coordinated filesystem apply failed.' - : failedAttempt - ? `Generation was not applied because target "${failedAttempt.name}" failed during planning.` - : 'Generation was not applied.'; - const finalResults = results.map(({ name, result }) => { - const prepared = preparedTargets.get(name); - if (!prepared) return { name, result }; - return { - name, - result: hasError - ? failPreparedGeneration(prepared, notAppliedMessage) - : completePreparedGeneration(prepared, dryRun === true), - }; - }); - const targetPlans = finalResults.flatMap(({ result }) => result.plans ?? []); - const coordinatedPlans = additionalWriteResults.flatMap((result) => - result.plan ? [result.plan] : [] - ); - const plans = coordinatedPlans.length > 0 ? coordinatedPlans : targetPlans; - const recovery = recoveryFields(additionalWriteResults); - return { - results: finalResults, - hasError, - plans, - planFingerprint: combinePlanFingerprint(plans), - fileChanges: plans.flatMap((plan) => plan.changes), - ...recovery, - }; -} diff --git a/graphql/codegen/src/core/multi-generation.ts b/graphql/codegen/src/core/multi-generation.ts new file mode 100644 index 0000000000..e89ed0ed41 --- /dev/null +++ b/graphql/codegen/src/core/multi-generation.ts @@ -0,0 +1,477 @@ +/** + * Coordinated multi-target code generation. + */ +import path from 'node:path'; + +import type { + CliConfig, + GraphQLSDKConfigTarget, + SchemaConfig, +} from '../types/config'; +import { getConfigOptions, mergeConfig } from '../types/config'; +import { generateMultiTargetBarrel } from './codegen/barrel'; +import { + generateMultiTargetCli, + type MultiTargetCliTarget, +} from './codegen/cli'; +import { + generateMultiTargetReadme, + generateMultiTargetAgentsDocs, + generateMultiTargetSkills, + type MultiTargetDocsInput, +} from './codegen/cli/docs-generator'; +import { resolveDocsConfig } from './codegen/docs-utils'; +import { + generateRootRootReadme, + type RootRootReadmeTarget, +} from './codegen/target-docs-generator'; +import { throwIfAborted } from './cancellation'; +import type { + FileChange, + GeneratedFileWriteJob, + GenerationPlan, + WriteResult, +} from './output'; +import { + applySharedPgpmDb, + disposeSharedPgpmSources, + prepareSharedPgpmSources, +} from './shared-pgpm-source'; +import { getStaleTargetWriteJobs, TARGETS_MANIFEST } from './stale-targets'; +import { + applyGenerationJobs, + combinePlanFingerprint, + completePreparedGeneration, + failPreparedGeneration, + planFields, + planTargetGeneration, + recoveryFields, + resolveSkillsOutputDir, + resolveTargetPaths, + type GenerateProgressEvent, + type GenerateResult, + type PreparedGeneration, +} from './target-generation'; + +export { removeStaleTargetDirs, TARGETS_MANIFEST } from './stale-targets'; + +export interface GenerateMultiOptions { + configs: Record; + cliOverrides?: Partial; + verbose?: boolean; + dryRun?: boolean; + schema?: SchemaConfig; + unifiedCli?: CliConfig | boolean; + /** Remove subdirectories in the output root that don't match any current target name. */ + cleanStaleTargets?: boolean; + /** Base directory for relative config, source, and output paths. */ + cwd?: string; + overwriteModifiedGenerated?: boolean; + yes?: boolean; + onProgress?: (event: GenerateProgressEvent) => void; + signal?: AbortSignal; + /** Explicit environment used to resolve omitted PostgreSQL settings. */ + env?: Readonly>; +} + +export interface GenerateMultiResult { + results: Array<{ name: string; result: GenerateResult }>; + hasError: boolean; + plans?: GenerationPlan[]; + planFingerprint?: string; + fileChanges?: FileChange[]; + warnings?: string[]; + recoveryPath?: string; + rollbackErrors?: string[]; +} + +export async function generateMulti( + options: GenerateMultiOptions, +): Promise { + throwIfAborted(options.signal); + const { + configs, + cliOverrides, + verbose, + dryRun, + schema, + unifiedCli, + cleanStaleTargets, + overwriteModifiedGenerated, + yes, + } = options; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const env = options.env ?? {}; + const names = Object.keys(configs); + const results: Array<{ name: string; result: GenerateResult }> = []; + let hasError = false; + + const schemaEnabled = !!schema?.enabled; + const targetInfos: RootRootReadmeTarget[] = []; + const useUnifiedCli = !schemaEnabled && !!unifiedCli && names.length > 1; + + const cliTargets: MultiTargetCliTarget[] = []; + const additionalWriteResults: WriteResult[] = []; + const collectedWriteJobs: GeneratedFileWriteJob[] = []; + const preparedTargets = new Map(); + let staleTargetWriteJobs: GeneratedFileWriteJob[] = []; + const additionalWriteJobs: Array<{ + files: Array<{ path: string; content: string }>; + outputDir: string; + adoptUnownedPaths?: string[]; + }> = []; + + // Stale cleanup is a read-only planning input. It is committed with every + // other generated root only after all targets have generated successfully. + if (cleanStaleTargets && names.length > 0) { + try { + throwIfAborted(options.signal); + const firstOutput = resolveTargetPaths( + getConfigOptions(configs[names[0]]), + cwd, + ).output!; + const outputRoot = path.dirname(firstOutput); + staleTargetWriteJobs = getStaleTargetWriteJobs(outputRoot, names); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to plan stale target cleanup.'; + return { + results: [ + { + name: 'targets', + result: { + success: false, + message, + errors: [message], + }, + }, + ], + hasError: true, + plans: [], + fileChanges: [], + }; + } + } + + const sharedSources = await prepareSharedPgpmSources( + configs, + cliOverrides, + cwd, + env, + options.onProgress, + options.signal, + ); + + try { + for (const name of names) { + throwIfAborted(options.signal); + const baseConfig = mergeConfig(configs[name], cliOverrides ?? {}); + const targetConfig = applySharedPgpmDb( + baseConfig, + sharedSources, + cwd, + env, + ); + const preparation = await planTargetGeneration( + { + ...targetConfig, + verbose, + dryRun, + cwd, + overwriteModifiedGenerated, + yes, + onProgress: options.onProgress, + signal: options.signal, + env, + schema: schemaEnabled + ? { ...schema, filename: schema?.filename ?? `${name}.graphql` } + : targetConfig.schema, + }, + useUnifiedCli + ? { + skipCli: true, + targetName: name, + } + : { targetName: name }, + ); + const result = + preparation.ok === false + ? preparation.result + : preparation.prepared.result; + results.push({ name, result }); + if (preparation.ok === false) { + hasError = true; + } else { + preparedTargets.set(name, preparation.prepared); + collectedWriteJobs.push(...preparation.prepared.writeJobs); + const displayConfig = getConfigOptions(targetConfig); + const resolvedConfig = resolveTargetPaths(displayConfig, cwd); + const gens: string[] = []; + if (resolvedConfig.reactQuery) gens.push('React Query'); + if ( + resolvedConfig.orm || + resolvedConfig.reactQuery || + !!resolvedConfig.cli + ) + gens.push('ORM'); + if (resolvedConfig.cli) gens.push('CLI'); + targetInfos.push({ + name, + output: displayConfig.output!, + endpoint: resolvedConfig.endpoint || undefined, + generators: gens, + }); + + if (useUnifiedCli && result.pipelineData) { + const isAuthTarget = name === 'auth'; + cliTargets.push({ + name, + endpoint: resolvedConfig.endpoint || '', + ormImportPath: `../${displayConfig.output!.replace(/^\.\//, '')}/orm`, + tables: result.pipelineData.tables, + customOperations: result.pipelineData.customOperations, + isAuthTarget, + typeRegistry: result.pipelineData.customOperations.typeRegistry, + }); + } + } + } + + if (useUnifiedCli && cliTargets.length > 0) { + throwIfAborted(options.signal); + const cliConfig = typeof unifiedCli === 'object' ? unifiedCli : {}; + const toolName = cliConfig.toolName ?? 'app'; + const firstTargetConfig = configs[names[0]]; + const { files } = generateMultiTargetCli({ + toolName, + builtinNames: cliConfig.builtinNames, + targets: cliTargets, + entryPoint: cliConfig.entryPoint, + }); + throwIfAborted(options.signal); + + const cliFilesToWrite = files.map((file) => ({ + path: path.posix.join('cli', file.fileName), + content: file.content, + })); + + const firstTargetDocsConfig = names.length > 0 && configs[names[0]]?.docs; + const docsConfig = resolveDocsConfig(firstTargetDocsConfig); + const { resolveBuiltinNames } = await import('./codegen/cli'); + const builtinNames = resolveBuiltinNames( + cliTargets.map((t) => t.name), + cliConfig.builtinNames, + ); + + // Merge all target type registries into a combined registry for docs generation + const combinedRegistry = new Map< + string, + import('../types/schema').ResolvedType + >(); + for (const t of cliTargets) { + if (t.typeRegistry) { + for (const [key, value] of t.typeRegistry) { + combinedRegistry.set(key, value); + } + } + } + + const docsInput: MultiTargetDocsInput = { + toolName, + builtinNames, + registry: combinedRegistry.size > 0 ? combinedRegistry : undefined, + targets: cliTargets.map((t) => ({ + name: t.name, + endpoint: t.endpoint, + tables: t.tables, + customOperations: [ + ...(t.customOperations?.queries ?? []), + ...(t.customOperations?.mutations ?? []), + ], + isAuthTarget: t.isAuthTarget, + })), + }; + + if (docsConfig.readme) { + const readme = generateMultiTargetReadme(docsInput); + cliFilesToWrite.push({ + path: path.posix.join('cli', readme.fileName), + content: readme.content, + }); + } + if (docsConfig.agents) { + const agents = generateMultiTargetAgentsDocs(docsInput); + cliFilesToWrite.push({ + path: path.posix.join('cli', agents.fileName), + content: agents.content, + }); + } + additionalWriteJobs.push({ + files: cliFilesToWrite, + outputDir: cwd, + }); + + if (docsConfig.skills) { + const cliSkillsToWrite = generateMultiTargetSkills(docsInput).map( + (skill) => ({ + path: skill.fileName, + content: skill.content, + }), + ); + + const firstTargetResolved = getConfigOptions({ + ...(firstTargetConfig ?? {}), + ...(cliOverrides ?? {}), + }); + const skillsOutputDir = resolveSkillsOutputDir( + firstTargetResolved, + firstTargetResolved.output, + cwd, + ); + additionalWriteJobs.push({ + files: cliSkillsToWrite, + outputDir: skillsOutputDir, + }); + } + } + + // Generate root-root README and barrel if multi-target + if (names.length > 1 && targetInfos.length > 0) { + throwIfAborted(options.signal); + const rootReadme = generateRootRootReadme(targetInfos); + additionalWriteJobs.push({ + files: [{ path: rootReadme.fileName, content: rootReadme.content }], + outputDir: cwd, + }); + + // Write a root barrel (index.ts) that re-exports each target as a + // namespace so the package has a single entry-point. Derive the + // common output root from the first target's output path. + const successfulNames = [...preparedTargets.keys()]; + if (successfulNames.length > 0) { + const firstOutput = resolveTargetPaths( + getConfigOptions(configs[successfulNames[0]]), + cwd, + ).output!; + const outputRoot = path.dirname(firstOutput); + const barrelContent = generateMultiTargetBarrel(successfulNames); + additionalWriteJobs.push({ + files: [ + { path: 'index.ts', content: barrelContent }, + { + path: TARGETS_MANIFEST, + content: `${JSON.stringify(successfulNames.sort())}\n`, + }, + ], + outputDir: outputRoot, + adoptUnownedPaths: [TARGETS_MANIFEST], + }); + } + } + + if (cleanStaleTargets && names.length === 1 && targetInfos.length === 1) { + const firstOutput = resolveTargetPaths( + getConfigOptions({ + ...configs[names[0]], + ...(cliOverrides ?? {}), + }), + cwd, + ).output!; + additionalWriteJobs.push({ + files: [ + { + path: TARGETS_MANIFEST, + content: `${JSON.stringify(names)}\n`, + }, + ], + outputDir: path.dirname(firstOutput), + adoptUnownedPaths: [TARGETS_MANIFEST], + }); + } + + if (!hasError) { + throwIfAborted(options.signal); + const finalJobs: GeneratedFileWriteJob[] = [ + ...collectedWriteJobs, + ...staleTargetWriteJobs.map((job) => ({ + ...job, + options: { + ...(job.options ?? {}), + overwriteModifiedGenerated, + confirmOverwrite: yes, + }, + })), + ...additionalWriteJobs.map((job) => ({ + files: job.files, + outputDir: job.outputDir, + options: { + pruneStaleFiles: false, + overwriteModifiedGenerated, + confirmOverwrite: yes, + adoptUnownedPaths: job.adoptUnownedPaths, + showProgress: false, + }, + })), + ]; + const batch = await applyGenerationJobs(finalJobs, { + dryRun, + signal: options.signal, + }); + additionalWriteResults.push(...batch.results); + if (!batch.success) { + hasError = true; + const errors = batch.errors ?? ['Failed to apply generated files.']; + const recovery = recoveryFields(batch.results); + results.push({ + name: 'write', + result: { + success: false, + message: errors.join(', '), + errors, + ...planFields(batch.results), + ...recovery, + }, + }); + } + } + } finally { + await disposeSharedPgpmSources( + sharedSources, + Object.values(configs).some((config) => config.db?.keepDb), + ); + } + + const failedAttempt = results.find(({ result }) => !result.success); + const notAppliedMessage = + failedAttempt?.name === 'write' + ? 'Generation was not applied because the coordinated filesystem apply failed.' + : failedAttempt + ? `Generation was not applied because target "${failedAttempt.name}" failed during planning.` + : 'Generation was not applied.'; + const finalResults = results.map(({ name, result }) => { + const prepared = preparedTargets.get(name); + if (!prepared) return { name, result }; + return { + name, + result: hasError + ? failPreparedGeneration(prepared, notAppliedMessage) + : completePreparedGeneration(prepared, dryRun === true), + }; + }); + const targetPlans = finalResults.flatMap(({ result }) => result.plans ?? []); + const coordinatedPlans = additionalWriteResults.flatMap((result) => + result.plan ? [result.plan] : [], + ); + const plans = coordinatedPlans.length > 0 ? coordinatedPlans : targetPlans; + const recovery = recoveryFields(additionalWriteResults); + return { + results: finalResults, + hasError, + plans, + planFingerprint: combinePlanFingerprint(plans), + fileChanges: plans.flatMap((plan) => plan.changes), + ...recovery, + }; +} diff --git a/graphql/codegen/src/core/output/filesystem.ts b/graphql/codegen/src/core/output/filesystem.ts new file mode 100644 index 0000000000..b02a65fecb --- /dev/null +++ b/graphql/codegen/src/core/output/filesystem.ts @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { GENERATED_FILES_MANIFEST } from './types'; + +export function hashContent(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} + +function isPathInside(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ); +} + +/** + * Resolve an output directory through the deepest existing ancestor. This lets + * an explicitly symlinked cwd/output root work while giving all descendant + * containment checks one canonical boundary. + */ +export function canonicalizeOutputDir(outputDir: string): string { + const absolute = path.resolve(outputDir); + const missingSegments: string[] = []; + let existing = absolute; + + while (true) { + try { + fs.lstatSync(existing); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + const parent = path.dirname(existing); + if (parent === existing) throw error; + missingSegments.unshift(path.basename(existing)); + existing = parent; + } + } + + const canonicalAncestor = fs.realpathSync.native(existing); + const existingStat = fs.statSync(canonicalAncestor); + if (missingSegments.length === 0 && !existingStat.isDirectory()) { + throw new Error(`Generated output path is not a directory: ${absolute}`); + } + return path.join(canonicalAncestor, ...missingSegments); +} + +/** Refuse any symlink below the canonical output root. */ +export function assertContainedPath( + outputDir: string, + candidate: string, +): void { + if (!isPathInside(outputDir, candidate)) { + throw new Error(`Generated file escapes output directory: ${candidate}`); + } + + let current = candidate; + while (current !== outputDir) { + try { + if (fs.lstatSync(current).isSymbolicLink()) { + throw new Error( + `Generated file path traverses a symlink below the output directory: ${current}`, + ); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Generated file escapes output directory: ${candidate}`); + } + current = parent; + } +} + +export function normalizeRelativePath(filePath: string): string { + if (!filePath || path.isAbsolute(filePath)) { + throw new Error(`Generated file path must be relative: ${filePath}`); + } + + const normalized = filePath.replace(/\\/g, '/'); + const segments = normalized.split('/'); + if ( + segments.some( + (segment) => segment === '' || segment === '.' || segment === '..', + ) + ) { + throw new Error(`Generated file path is unsafe: ${filePath}`); + } + + const relativePath = path.posix.normalize(normalized); + if ( + relativePath === GENERATED_FILES_MANIFEST || + relativePath.startsWith('../') + ) { + throw new Error(`Generated file path is reserved or unsafe: ${filePath}`); + } + + return relativePath; +} + +export function removeEmptyParents(startDir: string, stopDir: string): void { + let current = startDir; + while (current !== stopDir && current.startsWith(`${stopDir}${path.sep}`)) { + try { + fs.rmdirSync(current); + } catch { + return; + } + current = path.dirname(current); + } +} diff --git a/graphql/codegen/src/core/output/manifest.ts b/graphql/codegen/src/core/output/manifest.ts new file mode 100644 index 0000000000..cf31216444 --- /dev/null +++ b/graphql/codegen/src/core/output/manifest.ts @@ -0,0 +1,97 @@ +import * as fs from 'node:fs'; + +import { normalizeRelativePath } from './filesystem'; +import { + GENERATOR_NAME, + MANIFEST_VERSION, + type GeneratedFilesManifest, + type ManifestFileEntry, +} from './types'; + +const LEGACY_GENERATED_MARKERS = [ + '@generated by @constructive-io/graphql-codegen', + '@constructive-io/graphql-codegen - DO NOT EDIT', +]; + +function isManifest(value: unknown): value is GeneratedFilesManifest { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + if ( + candidate.version !== MANIFEST_VERSION || + candidate.generator !== GENERATOR_NAME || + !candidate.files || + typeof candidate.files !== 'object' || + Array.isArray(candidate.files) + ) { + return false; + } + + return Object.entries(candidate.files).every(([filePath, entry]) => { + try { + if (normalizeRelativePath(filePath) !== filePath) return false; + } catch { + return false; + } + return ( + !!entry && + typeof entry === 'object' && + typeof entry.sha256 === 'string' && + /^[a-f0-9]{64}$/.test(entry.sha256) + ); + }); +} + +export function readManifest(manifestPath: string): { + manifest: GeneratedFilesManifest; + content?: string; +} { + if (!fs.existsSync(manifestPath)) { + return { + manifest: { + version: MANIFEST_VERSION, + generator: GENERATOR_NAME, + files: {}, + }, + }; + } + + const content = fs.readFileSync(manifestPath, 'utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error( + `Generated ownership manifest is not valid JSON: ${manifestPath}`, + ); + } + + if (!isManifest(parsed)) { + throw new Error( + `Generated ownership manifest has an unsupported or invalid shape: ${manifestPath}`, + ); + } + + return { manifest: parsed, content }; +} + +export function isLegacyGenerated(content: Buffer): boolean { + const prefix = content.subarray(0, 4096).toString('utf8'); + return LEGACY_GENERATED_MARKERS.some((marker) => prefix.includes(marker)); +} + +export function createManifestContent( + files: Record, +): string { + const sortedFiles = Object.fromEntries( + Object.entries(files).sort(([left], [right]) => left.localeCompare(right)), + ); + return `${JSON.stringify( + { + version: MANIFEST_VERSION, + generator: GENERATOR_NAME, + files: sortedFiles, + } satisfies GeneratedFilesManifest, + null, + 2, + )}\n`; +} diff --git a/graphql/codegen/src/core/output/planner.ts b/graphql/codegen/src/core/output/planner.ts new file mode 100644 index 0000000000..9048bc1479 --- /dev/null +++ b/graphql/codegen/src/core/output/planner.ts @@ -0,0 +1,318 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + assertContainedPath, + canonicalizeOutputDir, + hashContent, + normalizeRelativePath, +} from './filesystem'; +import { + createManifestContent, + isLegacyGenerated, + readManifest, +} from './manifest'; +import { + GENERATED_FILES_MANIFEST, + type FileChange, + type GeneratedFile, + type GenerationPlan, + type ManifestFileEntry, + type PreparedFile, + type PreparedPlan, + type WriteOptions, +} from './types'; + +type OxfmtFormatFn = ( + fileName: string, + sourceText: string, + options?: Record, +) => Promise<{ code: string; errors: unknown[] }>; + +async function getOxfmtFormat(): Promise { + try { + const oxfmt = await import('oxfmt'); + return oxfmt.format; + } catch { + return null; + } +} + +async function formatFileContent( + fileName: string, + content: string, + formatFn: OxfmtFormatFn, +): Promise { + try { + const result = await formatFn(fileName, content, { + singleQuote: true, + trailingComma: 'es5', + tabWidth: 2, + semi: true, + }); + return result.code; + } catch { + return content; + } +} + +function fingerprintChanges( + changes: FileChange[], + manifestContent: string | undefined, +): string { + const canonical = changes + .map((change) => ({ + path: change.path, + action: change.action, + previousHash: change.previousHash, + generatedHash: change.generatedHash, + reason: change.reason, + })) + .sort((left, right) => left.path.localeCompare(right.path)); + return hashContent( + JSON.stringify({ + changes: canonical, + manifestHash: + manifestContent === undefined ? null : hashContent(manifestContent), + }), + ); +} + +export async function prepareGenerationPlan( + files: GeneratedFile[], + outputDir: string, + options: WriteOptions, +): Promise { + const { + formatFiles = true, + pruneStaleFiles = false, + overwriteModifiedGenerated = false, + confirmOverwrite = false, + adoptUnownedPaths = [], + removeManifestWhenEmpty = false, + } = options; + + if (overwriteModifiedGenerated && !confirmOverwrite) { + throw new Error( + 'overwriteModifiedGenerated requires explicit confirmOverwrite confirmation', + ); + } + + const resolvedOutputDir = canonicalizeOutputDir(outputDir); + const manifestPath = path.join(resolvedOutputDir, GENERATED_FILES_MANIFEST); + assertContainedPath(resolvedOutputDir, manifestPath); + const { manifest, content: previousManifestContent } = + readManifest(manifestPath); + const desiredFiles = new Map(); + const explicitlyAdoptedPaths = new Set( + adoptUnownedPaths.map(normalizeRelativePath), + ); + const formatFn = formatFiles ? await getOxfmtFormat() : null; + + for (const file of files) { + const relativePath = normalizeRelativePath(file.path); + if (desiredFiles.has(relativePath)) { + throw new Error(`Duplicate generated file path: ${relativePath}`); + } + + const content = + formatFn && relativePath.endsWith('.ts') + ? await formatFileContent(relativePath, file.content, formatFn) + : file.content; + desiredFiles.set(relativePath, { + relativePath, + absolutePath: path.join(resolvedOutputDir, ...relativePath.split('/')), + content, + hash: hashContent(content), + }); + assertContainedPath( + resolvedOutputDir, + path.join(resolvedOutputDir, ...relativePath.split('/')), + ); + } + + const changes: FileChange[] = []; + const nextManifestFiles: Record = {}; + + for (const desired of desiredFiles.values()) { + const previousEntry = manifest.files[desired.relativePath]; + let existing: Buffer | undefined; + try { + existing = fs.readFileSync(desired.absolutePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + if (!existing) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'create', + generatedHash: desired.hash, + reason: 'new-generated-file', + }); + nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; + continue; + } + + const existingHash = hashContent(existing); + const isExplicitlyAdopted = explicitlyAdoptedPaths.has( + desired.relativePath, + ); + const isRecognizedLegacyFile = isLegacyGenerated(existing); + if (!previousEntry && !isRecognizedLegacyFile && !isExplicitlyAdopted) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'conflict', + previousHash: existingHash, + generatedHash: desired.hash, + reason: 'unowned-existing-file', + }); + continue; + } + + if (existingHash === desired.hash) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'unchanged', + previousHash: existingHash, + generatedHash: desired.hash, + reason: 'generated-content-unchanged', + }); + nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; + continue; + } + + const modifiedOwnedFile = + !!previousEntry && existingHash !== previousEntry.sha256; + const conflict = modifiedOwnedFile; + + if (conflict && !overwriteModifiedGenerated) { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'conflict', + previousHash: existingHash, + generatedHash: desired.hash, + reason: modifiedOwnedFile + ? 'modified-generated-file' + : 'modified-generated-file', + }); + } else { + changes.push({ + path: desired.relativePath, + absolutePath: desired.absolutePath, + action: 'update', + previousHash: existingHash, + generatedHash: desired.hash, + reason: modifiedOwnedFile + ? 'modified-generated-file' + : !previousEntry + ? 'legacy-generated-file' + : 'generated-content-changed', + }); + } + nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; + } + + for (const [relativePath, previousEntry] of Object.entries(manifest.files)) { + if (desiredFiles.has(relativePath)) continue; + const absolutePath = path.join( + resolvedOutputDir, + ...relativePath.split('/'), + ); + assertContainedPath(resolvedOutputDir, absolutePath); + let existing: Buffer | undefined; + try { + existing = fs.readFileSync(absolutePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (!existing) continue; + + const existingHash = hashContent(existing); + if (!pruneStaleFiles) { + changes.push({ + path: relativePath, + absolutePath, + action: 'unchanged', + previousHash: existingHash, + reason: 'retained-owned-file', + }); + nextManifestFiles[relativePath] = previousEntry; + continue; + } + + const modified = existingHash !== previousEntry.sha256; + if (modified && !overwriteModifiedGenerated) { + changes.push({ + path: relativePath, + absolutePath, + action: 'conflict', + previousHash: existingHash, + reason: 'modified-generated-file', + }); + nextManifestFiles[relativePath] = previousEntry; + } else { + changes.push({ + path: relativePath, + absolutePath, + action: 'delete', + previousHash: existingHash, + reason: modified ? 'modified-generated-file' : 'generated-file-removed', + }); + } + } + + changes.sort((left, right) => left.path.localeCompare(right.path)); + const manifestContent = + removeManifestWhenEmpty && Object.keys(nextManifestFiles).length === 0 + ? undefined + : createManifestContent(nextManifestFiles); + const manifestChanged = previousManifestContent !== manifestContent; + changes.push({ + path: GENERATED_FILES_MANIFEST, + absolutePath: manifestPath, + action: manifestChanged + ? manifestContent === undefined + ? 'delete' + : previousManifestContent === undefined + ? 'create' + : 'update' + : 'unchanged', + previousHash: + previousManifestContent === undefined + ? undefined + : hashContent(previousManifestContent), + generatedHash: + manifestContent === undefined ? undefined : hashContent(manifestContent), + reason: 'ownership-manifest', + }); + + const publicPlan: GenerationPlan = { + version: 1, + outputDir: resolvedOutputDir, + manifestPath, + fingerprint: fingerprintChanges(changes, manifestContent), + changes, + }; + + return { + publicPlan, + desiredFiles, + manifestContent, + manifestChanged, + removeOutputDirWhenEmpty: removeManifestWhenEmpty, + }; +} + +/** Compute a complete generation plan without mutating the target filesystem. */ +export async function planGeneratedFiles( + files: GeneratedFile[], + outputDir: string, + options: Omit = {}, +): Promise { + return (await prepareGenerationPlan(files, outputDir, options)).publicPlan; +} diff --git a/graphql/codegen/src/core/output/transaction.ts b/graphql/codegen/src/core/output/transaction.ts new file mode 100644 index 0000000000..501ebd03d0 --- /dev/null +++ b/graphql/codegen/src/core/output/transaction.ts @@ -0,0 +1,445 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + assertContainedPath, + canonicalizeOutputDir, + hashContent, + removeEmptyParents, +} from './filesystem'; +import { + GENERATED_FILES_MANIFEST, + type FileChange, + type GenerationPlan, + type PreparedPlan, + type WriteBatchResult, + type WriteResult, +} from './types'; + +interface CommittedChange { + change: FileChange; + backupCreated: boolean; + targetWritten: boolean; +} + +interface PlanTransaction { + prepared: PreparedPlan; + applicableChanges: FileChange[]; + transactionRoot: string; + stagedRoot: string; + backupRoot: string; + committed: CommittedChange[]; + written: string[]; + removed: string[]; + manifestBackupCreated: boolean; + manifestPublished: boolean; +} + +export function conflictErrors(plan: GenerationPlan): string[] { + return plan.changes + .filter((change) => change.action === 'conflict') + .map( + (conflict) => + `${conflict.reason === 'unowned-existing-file' ? 'Refusing to overwrite unowned file' : 'Generated file was modified'}: ${conflict.absolutePath}`, + ); +} + +function createPlanTransaction(prepared: PreparedPlan): PlanTransaction { + const outputParent = path.dirname(prepared.publicPlan.outputDir); + fs.mkdirSync(outputParent, { recursive: true }); + const transactionRoot = fs.mkdtempSync( + path.join( + outputParent, + `.${path.basename(prepared.publicPlan.outputDir)}.codegen-transaction-`, + ), + ); + return { + prepared, + applicableChanges: prepared.publicPlan.changes.filter( + (change) => + change.path !== GENERATED_FILES_MANIFEST && + (change.action === 'create' || + change.action === 'update' || + change.action === 'delete'), + ), + transactionRoot, + stagedRoot: path.join(transactionRoot, 'staged'), + backupRoot: path.join(transactionRoot, 'backup'), + committed: [], + written: [], + removed: [], + manifestBackupCreated: false, + manifestPublished: false, + }; +} + +function stagePlan(transaction: PlanTransaction): void { + const { prepared, applicableChanges, stagedRoot } = transaction; + for (const change of applicableChanges) { + if (change.action === 'delete') continue; + const desired = prepared.desiredFiles.get(change.path); + if (!desired) { + throw new Error(`Missing prepared content for ${change.path}`); + } + const stagedPath = path.join(stagedRoot, ...change.path.split('/')); + fs.mkdirSync(path.dirname(stagedPath), { recursive: true }); + fs.writeFileSync(stagedPath, desired.content, 'utf8'); + } + if (prepared.manifestChanged && prepared.manifestContent !== undefined) { + const stagedManifestPath = path.join(stagedRoot, GENERATED_FILES_MANIFEST); + fs.mkdirSync(path.dirname(stagedManifestPath), { recursive: true }); + fs.writeFileSync(stagedManifestPath, prepared.manifestContent, { + encoding: 'utf8', + mode: 0o600, + }); + } +} + +function readOptionalHash(filePath: string): string | undefined { + try { + return hashContent(fs.readFileSync(filePath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function assertPlanStillCurrent(transaction: PlanTransaction): void { + const { publicPlan } = transaction.prepared; + const currentOutputDir = canonicalizeOutputDir(publicPlan.outputDir); + if (currentOutputDir !== publicPlan.outputDir) { + throw new Error( + `Generated output path changed after planning: ${publicPlan.outputDir}`, + ); + } + + for (const change of publicPlan.changes) { + assertContainedPath(publicPlan.outputDir, change.absolutePath); + const observedHash = readOptionalHash(change.absolutePath); + if (observedHash !== change.previousHash) { + throw new Error( + `Generated output changed after planning: ${change.absolutePath}`, + ); + } + } +} + +function commitPlanFiles( + transaction: PlanTransaction, + showProgress: boolean, + progress: { current: number; total: number }, +): void { + const { prepared, applicableChanges, backupRoot, stagedRoot } = transaction; + fs.mkdirSync(prepared.publicPlan.outputDir, { recursive: true }); + + for (const change of applicableChanges) { + progress.current += 1; + if ( + showProgress && + (progress.current === 1 || + progress.current === progress.total || + progress.current % 100 === 0) + ) { + console.log( + `Applying generated files: ${progress.current}/${progress.total}`, + ); + } + + const targetPath = change.absolutePath; + let backupCreated = false; + if (fs.existsSync(targetPath)) { + const backupPath = path.join(backupRoot, ...change.path.split('/')); + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.renameSync(targetPath, backupPath); + backupCreated = true; + } + const committedChange: CommittedChange = { + change, + backupCreated, + targetWritten: false, + }; + transaction.committed.push(committedChange); + + if (change.action !== 'delete') { + const stagedPath = path.join(stagedRoot, ...change.path.split('/')); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.renameSync(stagedPath, targetPath); + committedChange.targetWritten = true; + transaction.written.push(targetPath); + } else { + transaction.removed.push(targetPath); + } + } +} + +function publishPlanManifest(transaction: PlanTransaction): void { + const { prepared, backupRoot, stagedRoot } = transaction; + if (!prepared.manifestChanged) return; + + const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); + if (fs.existsSync(prepared.publicPlan.manifestPath)) { + fs.mkdirSync(path.dirname(manifestBackupPath), { recursive: true }); + fs.renameSync(prepared.publicPlan.manifestPath, manifestBackupPath); + transaction.manifestBackupCreated = true; + } + + if (prepared.manifestContent === undefined) return; + const stagedManifestPath = path.join(stagedRoot, GENERATED_FILES_MANIFEST); + fs.renameSync(stagedManifestPath, prepared.publicPlan.manifestPath); + transaction.manifestPublished = true; +} + +function rollbackTransaction(transaction: PlanTransaction): string[] { + const errors: string[] = []; + const attempt = (description: string, operation: () => void): void => { + try { + operation(); + } catch (error) { + errors.push( + `${description}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + }; + const { prepared, backupRoot } = transaction; + + if (transaction.manifestPublished) { + attempt('remove partially published ownership manifest', () => { + if (fs.existsSync(prepared.publicPlan.manifestPath)) { + fs.rmSync(prepared.publicPlan.manifestPath, { force: true }); + } + }); + } + const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); + if (transaction.manifestBackupCreated) { + attempt('restore previous ownership manifest', () => { + if (!fs.existsSync(manifestBackupPath)) { + throw new Error(`Backup is missing: ${manifestBackupPath}`); + } + fs.mkdirSync(path.dirname(prepared.publicPlan.manifestPath), { + recursive: true, + }); + fs.renameSync(manifestBackupPath, prepared.publicPlan.manifestPath); + }); + } + + for (const committedChange of [...transaction.committed].reverse()) { + const { change, backupCreated, targetWritten } = committedChange; + if (targetWritten) { + attempt(`remove partially written file ${change.absolutePath}`, () => { + if (fs.existsSync(change.absolutePath)) { + fs.rmSync(change.absolutePath, { force: true }); + } + }); + } + const backupPath = path.join(backupRoot, ...change.path.split('/')); + if (backupCreated) { + attempt(`restore previous file ${change.absolutePath}`, () => { + if (!fs.existsSync(backupPath)) { + throw new Error(`Backup is missing: ${backupPath}`); + } + fs.mkdirSync(path.dirname(change.absolutePath), { recursive: true }); + fs.renameSync(backupPath, change.absolutePath); + }); + } else { + attempt(`remove empty directories for ${change.absolutePath}`, () => { + removeEmptyParents( + path.dirname(change.absolutePath), + prepared.publicPlan.outputDir, + ); + }); + } + } + + return errors; +} + +export function resultForPrepared( + prepared: PreparedPlan, + overrides: Partial, +): WriteResult { + return { + success: true, + filesWritten: [], + filesRemoved: [], + plan: prepared.publicPlan, + planFingerprint: prepared.publicPlan.fingerprint, + ...overrides, + }; +} + +export async function applyPreparedPlans( + preparedPlans: PreparedPlan[], + showProgress: boolean, +): Promise { + const allConflictErrors = preparedPlans.flatMap(({ publicPlan }) => + conflictErrors(publicPlan), + ); + if (allConflictErrors.length > 0) { + return { + success: false, + errors: allConflictErrors, + results: preparedPlans.map((prepared) => { + const errors = conflictErrors(prepared.publicPlan); + return resultForPrepared(prepared, { + success: errors.length === 0, + ...(errors.length === 0 ? {} : { errors }), + }); + }), + }; + } + + const transactions: PlanTransaction[] = []; + const total = preparedPlans.reduce( + (count, prepared) => + count + + prepared.publicPlan.changes.filter( + (change) => + change.path !== GENERATED_FILES_MANIFEST && + (change.action === 'create' || + change.action === 'update' || + change.action === 'delete'), + ).length, + 0, + ); + const progress = { current: 0, total }; + + try { + for (const prepared of preparedPlans) { + const hasWork = + prepared.manifestChanged || + prepared.publicPlan.changes.some( + (change) => + change.path !== GENERATED_FILES_MANIFEST && + (change.action === 'create' || + change.action === 'update' || + change.action === 'delete'), + ); + if (!hasWork) continue; + const transaction = createPlanTransaction(prepared); + transactions.push(transaction); + stagePlan(transaction); + } + + for (const transaction of transactions) { + assertPlanStillCurrent(transaction); + } + for (const transaction of transactions) { + commitPlanFiles(transaction, showProgress, progress); + } + // Every ownership manifest is published after every content mutation. + for (const transaction of transactions) { + publishPlanManifest(transaction); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const rollbackByPlan = new Map(); + for (const transaction of [...transactions].reverse()) { + rollbackByPlan.set( + transaction.prepared.publicPlan, + rollbackTransaction(transaction), + ); + } + + for (const transaction of transactions) { + const rollbackErrors = rollbackByPlan.get( + transaction.prepared.publicPlan, + )!; + if (rollbackErrors.length === 0) { + try { + fs.rmSync(transaction.transactionRoot, { + recursive: true, + force: true, + }); + } catch (cleanupError) { + rollbackErrors.push( + `remove transaction directory: ${cleanupError instanceof Error ? cleanupError.message : 'Unknown error'}`, + ); + } + } + } + + const errors = [`Failed to apply generated file plans: ${message}`]; + return { + success: false, + errors, + results: preparedPlans.map((prepared) => { + const transaction = transactions.find( + (candidate) => candidate.prepared === prepared, + ); + const rollbackErrors = rollbackByPlan.get(prepared.publicPlan) ?? []; + return resultForPrepared(prepared, { + success: false, + errors, + ...(rollbackErrors.length === 0 + ? {} + : { + rollbackErrors, + recoveryPath: transaction?.transactionRoot, + }), + }); + }), + }; + } + + // At this point every content change and ownership manifest is committed. + // Cleanup cannot safely change that outcome: deleting one transaction's + // backups and then rolling every root back would make earlier roots + // unrestorable. Retain any transaction directory that cannot be removed and + // report it as a warning instead. + const cleanupWarningsByPlan = new Map(); + for (const transaction of transactions) { + for (const { change } of transaction.committed) { + if (change.action === 'delete') { + removeEmptyParents( + path.dirname(change.absolutePath), + transaction.prepared.publicPlan.outputDir, + ); + } + } + + try { + fs.rmSync(transaction.transactionRoot, { + recursive: true, + force: true, + }); + } catch (cleanupError) { + cleanupWarningsByPlan.set(transaction.prepared.publicPlan, [ + `Generated files were committed, but the transaction directory could not be removed: ${ + cleanupError instanceof Error ? cleanupError.message : 'Unknown error' + }`, + ]); + } + + if (transaction.prepared.removeOutputDirWhenEmpty) { + try { + fs.rmdirSync(transaction.prepared.publicPlan.outputDir); + } catch { + // Unowned files or directories keep the output root alive. + } + } + } + + const byPlan = new Map( + transactions.map((transaction) => [ + transaction.prepared.publicPlan, + transaction, + ]), + ); + return { + success: true, + results: preparedPlans.map((prepared) => { + const transaction = byPlan.get(prepared.publicPlan); + const warnings = cleanupWarningsByPlan.get(prepared.publicPlan) ?? []; + return resultForPrepared(prepared, { + filesWritten: transaction?.written ?? [], + filesRemoved: transaction?.removed ?? [], + ...(warnings.length === 0 + ? {} + : { + warnings, + recoveryPath: transaction?.transactionRoot, + }), + }); + }), + }; +} diff --git a/graphql/codegen/src/core/output/types.ts b/graphql/codegen/src/core/output/types.ts new file mode 100644 index 0000000000..f00bfa8358 --- /dev/null +++ b/graphql/codegen/src/core/output/types.ts @@ -0,0 +1,120 @@ +import type { GeneratedFile } from '../codegen'; + +export type { GeneratedFile }; + +export const GENERATED_FILES_MANIFEST = '.constructive-codegen-manifest.json'; +export const MANIFEST_VERSION = 1 as const; +export const GENERATOR_NAME = '@constructive-io/graphql-codegen' as const; + +export interface ManifestFileEntry { + sha256: string; +} + +export interface GeneratedFilesManifest { + version: typeof MANIFEST_VERSION; + generator: typeof GENERATOR_NAME; + files: Record; +} + +export type FileChangeAction = + | 'create' + | 'update' + | 'delete' + | 'unchanged' + | 'conflict'; + +export interface FileChange { + /** POSIX path relative to the output directory. */ + path: string; + absolutePath: string; + action: FileChangeAction; + previousHash?: string; + generatedHash?: string; + reason?: + | 'new-generated-file' + | 'generated-content-changed' + | 'generated-file-removed' + | 'generated-content-unchanged' + | 'modified-generated-file' + | 'unowned-existing-file' + | 'legacy-generated-file' + | 'retained-owned-file' + | 'ownership-manifest'; +} + +export interface GenerationPlan { + version: 1; + outputDir: string; + manifestPath: string; + fingerprint: string; + changes: FileChange[]; +} + +/** Result of planning or writing files. */ +export interface WriteResult { + success: boolean; + filesWritten?: string[]; + filesRemoved?: string[]; + errors?: string[]; + /** Non-fatal cleanup or recovery information. */ + warnings?: string[]; + /** Retained transaction directory when rollback or cleanup was incomplete. */ + recoveryPath?: string; + rollbackErrors?: string[]; + plan?: GenerationPlan; + planFingerprint?: string; +} + +export interface WriteOptions { + /** Show progress output (default: true). */ + showProgress?: boolean; + /** Format TypeScript files with oxfmt before planning (default: true). */ + formatFiles?: boolean; + /** Remove previously owned files absent from the current file set. */ + pruneStaleFiles?: boolean; + /** Compute and return the complete plan without mutating the filesystem. */ + dryRun?: boolean; + /** Permit replacing or deleting owned files changed since generation. */ + overwriteModifiedGenerated?: boolean; + /** Required confirmation paired with overwriteModifiedGenerated. */ + confirmOverwrite?: boolean; + /** Known legacy generated paths that predate the ownership manifest. */ + adoptUnownedPaths?: string[]; + /** Delete the ownership manifest when the desired owned file set is empty. */ + removeManifestWhenEmpty?: boolean; +} + +export interface GeneratedFileWriteJob { + files: GeneratedFile[]; + outputDir: string; + options?: Omit; +} + +export interface WriteBatchResult { + success: boolean; + results: WriteResult[]; + errors?: string[]; +} + +export interface WriteBatchOptions extends Pick< + WriteOptions, + 'dryRun' | 'showProgress' +> { + /** Cancellation is honored until the coordinated commit begins. */ + signal?: AbortSignal; +} + +export interface PreparedFile { + relativePath: string; + absolutePath: string; + content: string; + hash: string; +} + +export interface PreparedPlan { + publicPlan: GenerationPlan; + desiredFiles: Map; + manifestContent?: string; + manifestChanged: boolean; + removeOutputDirWhenEmpty: boolean; +} diff --git a/graphql/codegen/src/core/output/writer.ts b/graphql/codegen/src/core/output/writer.ts index b5da4e016b..c31c16a33b 100644 --- a/graphql/codegen/src/core/output/writer.ts +++ b/graphql/codegen/src/core/output/writer.ts @@ -2,1058 +2,42 @@ * Safe output planning and application for generated files. * * Generation is deliberately split into a read-only planning phase and a - * transactional apply phase. The ownership manifest is the authority for - * pruning and modified-file detection; directory scans are never used to - * decide what may be deleted. + * transactional apply phase. This module is the public orchestration facade; + * path, manifest, planning, and transaction details live beside it. */ -import { createHash } from 'node:crypto'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import type { GeneratedFile } from '../codegen'; import { rethrowIfCancelled, throwIfAborted } from '../cancellation'; +import { canonicalizeOutputDir } from './filesystem'; import { acquireOutputLocks } from './lock'; - -export type { GeneratedFile }; - -export const GENERATED_FILES_MANIFEST = '.constructive-codegen-manifest.json'; - -const MANIFEST_VERSION = 1; -const GENERATOR_NAME = '@constructive-io/graphql-codegen'; -const LEGACY_GENERATED_MARKERS = [ - '@generated by @constructive-io/graphql-codegen', - '@constructive-io/graphql-codegen - DO NOT EDIT', -]; - -interface ManifestFileEntry { - sha256: string; -} - -export interface GeneratedFilesManifest { - version: 1; - generator: typeof GENERATOR_NAME; - files: Record; -} - -export type FileChangeAction = - | 'create' - | 'update' - | 'delete' - | 'unchanged' - | 'conflict'; - -export interface FileChange { - /** POSIX path relative to the output directory. */ - path: string; - absolutePath: string; - action: FileChangeAction; - previousHash?: string; - generatedHash?: string; - reason?: - | 'new-generated-file' - | 'generated-content-changed' - | 'generated-file-removed' - | 'generated-content-unchanged' - | 'modified-generated-file' - | 'unowned-existing-file' - | 'legacy-generated-file' - | 'retained-owned-file' - | 'ownership-manifest'; -} - -export interface GenerationPlan { - version: 1; - outputDir: string; - manifestPath: string; - fingerprint: string; - changes: FileChange[]; -} - -/** Result of planning or writing files. */ -export interface WriteResult { - success: boolean; - filesWritten?: string[]; - filesRemoved?: string[]; - errors?: string[]; - /** Non-fatal cleanup or recovery information. */ - warnings?: string[]; - /** Retained transaction directory when rollback or cleanup was incomplete. */ - recoveryPath?: string; - rollbackErrors?: string[]; - plan?: GenerationPlan; - planFingerprint?: string; -} - -export interface WriteOptions { - /** Show progress output (default: true). */ - showProgress?: boolean; - /** Format TypeScript files with oxfmt before planning (default: true). */ - formatFiles?: boolean; - /** Remove previously owned files absent from the current file set. */ - pruneStaleFiles?: boolean; - /** Compute and return the complete plan without mutating the filesystem. */ - dryRun?: boolean; - /** Permit replacing or deleting owned files changed since generation. */ - overwriteModifiedGenerated?: boolean; - /** Required confirmation paired with overwriteModifiedGenerated. */ - confirmOverwrite?: boolean; - /** Known legacy generated paths that predate the ownership manifest. */ - adoptUnownedPaths?: string[]; - /** Delete the ownership manifest when the desired owned file set is empty. */ - removeManifestWhenEmpty?: boolean; -} - -export interface GeneratedFileWriteJob { - files: GeneratedFile[]; - outputDir: string; - options?: Omit; -} - -export interface WriteBatchResult { - success: boolean; - results: WriteResult[]; - errors?: string[]; -} - -export interface WriteBatchOptions extends Pick< +import { planGeneratedFiles, prepareGenerationPlan } from './planner'; +import { + applyPreparedPlans, + conflictErrors, + resultForPrepared, +} from './transaction'; +import type { + GeneratedFile, + GeneratedFileWriteJob, + PreparedPlan, + WriteBatchOptions, + WriteBatchResult, WriteOptions, - 'dryRun' | 'showProgress' -> { - /** Cancellation is honored until the coordinated commit begins. */ - signal?: AbortSignal; -} - -interface PreparedFile { - relativePath: string; - absolutePath: string; - content: string; - hash: string; -} - -interface PreparedPlan { - publicPlan: GenerationPlan; - desiredFiles: Map; - manifestContent?: string; - manifestChanged: boolean; - removeOutputDirWhenEmpty: boolean; -} - -type OxfmtFormatFn = ( - fileName: string, - sourceText: string, - options?: Record -) => Promise<{ code: string; errors: unknown[] }>; - -function hashContent(content: string | Buffer): string { - return createHash('sha256').update(content).digest('hex'); -} - -function isPathInside(parent: string, candidate: string): boolean { - const relative = path.relative(parent, candidate); - return ( - relative === '' || - (!relative.startsWith(`..${path.sep}`) && - relative !== '..' && - !path.isAbsolute(relative)) - ); -} - -/** - * Resolve an output directory through the deepest existing ancestor. This lets - * an explicitly symlinked cwd/output root work while giving all descendant - * containment checks one canonical boundary. - */ -function canonicalizeOutputDir(outputDir: string): string { - const absolute = path.resolve(outputDir); - const missingSegments: string[] = []; - let existing = absolute; - - while (true) { - try { - fs.lstatSync(existing); - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; - const parent = path.dirname(existing); - if (parent === existing) throw error; - missingSegments.unshift(path.basename(existing)); - existing = parent; - } - } - - const canonicalAncestor = fs.realpathSync.native(existing); - const existingStat = fs.statSync(canonicalAncestor); - if (missingSegments.length === 0 && !existingStat.isDirectory()) { - throw new Error(`Generated output path is not a directory: ${absolute}`); - } - return path.join(canonicalAncestor, ...missingSegments); -} - -/** Refuse any symlink below the canonical output root. */ -function assertContainedPath(outputDir: string, candidate: string): void { - if (!isPathInside(outputDir, candidate)) { - throw new Error(`Generated file escapes output directory: ${candidate}`); - } - - let current = candidate; - while (current !== outputDir) { - try { - if (fs.lstatSync(current).isSymbolicLink()) { - throw new Error( - `Generated file path traverses a symlink below the output directory: ${current}` - ); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; - } - const parent = path.dirname(current); - if (parent === current) { - throw new Error(`Generated file escapes output directory: ${candidate}`); - } - current = parent; - } -} - -function normalizeRelativePath(filePath: string): string { - if (!filePath || path.isAbsolute(filePath)) { - throw new Error(`Generated file path must be relative: ${filePath}`); - } - - const normalized = filePath.replace(/\\/g, '/'); - const segments = normalized.split('/'); - if ( - segments.some( - (segment) => segment === '' || segment === '.' || segment === '..' - ) - ) { - throw new Error(`Generated file path is unsafe: ${filePath}`); - } - - const relativePath = path.posix.normalize(normalized); - if ( - relativePath === GENERATED_FILES_MANIFEST || - relativePath.startsWith('../') - ) { - throw new Error(`Generated file path is reserved or unsafe: ${filePath}`); - } - - return relativePath; -} - -function isManifest(value: unknown): value is GeneratedFilesManifest { - if (!value || typeof value !== 'object') return false; - const candidate = value as Partial; - if ( - candidate.version !== MANIFEST_VERSION || - candidate.generator !== GENERATOR_NAME || - !candidate.files || - typeof candidate.files !== 'object' || - Array.isArray(candidate.files) - ) { - return false; - } - - return Object.entries(candidate.files).every(([filePath, entry]) => { - try { - if (normalizeRelativePath(filePath) !== filePath) return false; - } catch { - return false; - } - return ( - !!entry && - typeof entry === 'object' && - typeof entry.sha256 === 'string' && - /^[a-f0-9]{64}$/.test(entry.sha256) - ); - }); -} - -function readManifest(manifestPath: string): { - manifest: GeneratedFilesManifest; - content?: string; -} { - if (!fs.existsSync(manifestPath)) { - return { - manifest: { - version: MANIFEST_VERSION, - generator: GENERATOR_NAME, - files: {}, - }, - }; - } - - const content = fs.readFileSync(manifestPath, 'utf8'); - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { - throw new Error( - `Generated ownership manifest is not valid JSON: ${manifestPath}` - ); - } - - if (!isManifest(parsed)) { - throw new Error( - `Generated ownership manifest has an unsupported or invalid shape: ${manifestPath}` - ); - } - - return { manifest: parsed, content }; -} - -function isLegacyGenerated(content: Buffer): boolean { - const prefix = content.subarray(0, 4096).toString('utf8'); - return LEGACY_GENERATED_MARKERS.some((marker) => prefix.includes(marker)); -} - -async function getOxfmtFormat(): Promise { - try { - const oxfmt = await import('oxfmt'); - return oxfmt.format; - } catch { - return null; - } -} - -async function formatFileContent( - fileName: string, - content: string, - formatFn: OxfmtFormatFn -): Promise { - try { - const result = await formatFn(fileName, content, { - singleQuote: true, - trailingComma: 'es5', - tabWidth: 2, - semi: true, - }); - return result.code; - } catch { - return content; - } -} - -function createManifestContent( - files: Record -): string { - const sortedFiles = Object.fromEntries( - Object.entries(files).sort(([left], [right]) => left.localeCompare(right)) - ); - return `${JSON.stringify( - { - version: MANIFEST_VERSION, - generator: GENERATOR_NAME, - files: sortedFiles, - } satisfies GeneratedFilesManifest, - null, - 2 - )}\n`; -} - -function fingerprintChanges( - changes: FileChange[], - manifestContent: string | undefined -): string { - const canonical = changes - .map((change) => ({ - path: change.path, - action: change.action, - previousHash: change.previousHash, - generatedHash: change.generatedHash, - reason: change.reason, - })) - .sort((left, right) => left.path.localeCompare(right.path)); - return hashContent( - JSON.stringify({ - changes: canonical, - manifestHash: - manifestContent === undefined ? null : hashContent(manifestContent), - }) - ); -} - -async function prepareGenerationPlan( - files: GeneratedFile[], - outputDir: string, - options: WriteOptions -): Promise { - const { - formatFiles = true, - pruneStaleFiles = false, - overwriteModifiedGenerated = false, - confirmOverwrite = false, - adoptUnownedPaths = [], - removeManifestWhenEmpty = false, - } = options; - - if (overwriteModifiedGenerated && !confirmOverwrite) { - throw new Error( - 'overwriteModifiedGenerated requires explicit confirmOverwrite confirmation' - ); - } - - const resolvedOutputDir = canonicalizeOutputDir(outputDir); - const manifestPath = path.join(resolvedOutputDir, GENERATED_FILES_MANIFEST); - assertContainedPath(resolvedOutputDir, manifestPath); - const { manifest, content: previousManifestContent } = - readManifest(manifestPath); - const desiredFiles = new Map(); - const explicitlyAdoptedPaths = new Set( - adoptUnownedPaths.map(normalizeRelativePath) - ); - const formatFn = formatFiles ? await getOxfmtFormat() : null; - - for (const file of files) { - const relativePath = normalizeRelativePath(file.path); - if (desiredFiles.has(relativePath)) { - throw new Error(`Duplicate generated file path: ${relativePath}`); - } - - const content = - formatFn && relativePath.endsWith('.ts') - ? await formatFileContent(relativePath, file.content, formatFn) - : file.content; - desiredFiles.set(relativePath, { - relativePath, - absolutePath: path.join(resolvedOutputDir, ...relativePath.split('/')), - content, - hash: hashContent(content), - }); - assertContainedPath( - resolvedOutputDir, - path.join(resolvedOutputDir, ...relativePath.split('/')) - ); - } - - const changes: FileChange[] = []; - const nextManifestFiles: Record = {}; - - for (const desired of desiredFiles.values()) { - const previousEntry = manifest.files[desired.relativePath]; - let existing: Buffer | undefined; - try { - existing = fs.readFileSync(desired.absolutePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - - if (!existing) { - changes.push({ - path: desired.relativePath, - absolutePath: desired.absolutePath, - action: 'create', - generatedHash: desired.hash, - reason: 'new-generated-file', - }); - nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; - continue; - } - - const existingHash = hashContent(existing); - const isExplicitlyAdopted = explicitlyAdoptedPaths.has( - desired.relativePath - ); - const isRecognizedLegacyFile = isLegacyGenerated(existing); - if (!previousEntry && !isRecognizedLegacyFile && !isExplicitlyAdopted) { - changes.push({ - path: desired.relativePath, - absolutePath: desired.absolutePath, - action: 'conflict', - previousHash: existingHash, - generatedHash: desired.hash, - reason: 'unowned-existing-file', - }); - continue; - } - - if (existingHash === desired.hash) { - changes.push({ - path: desired.relativePath, - absolutePath: desired.absolutePath, - action: 'unchanged', - previousHash: existingHash, - generatedHash: desired.hash, - reason: 'generated-content-unchanged', - }); - nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; - continue; - } - - const modifiedOwnedFile = - !!previousEntry && existingHash !== previousEntry.sha256; - const conflict = modifiedOwnedFile; - - if (conflict && !overwriteModifiedGenerated) { - changes.push({ - path: desired.relativePath, - absolutePath: desired.absolutePath, - action: 'conflict', - previousHash: existingHash, - generatedHash: desired.hash, - reason: modifiedOwnedFile - ? 'modified-generated-file' - : 'modified-generated-file', - }); - } else { - changes.push({ - path: desired.relativePath, - absolutePath: desired.absolutePath, - action: 'update', - previousHash: existingHash, - generatedHash: desired.hash, - reason: modifiedOwnedFile - ? 'modified-generated-file' - : !previousEntry - ? 'legacy-generated-file' - : 'generated-content-changed', - }); - } - nextManifestFiles[desired.relativePath] = { sha256: desired.hash }; - } - - for (const [relativePath, previousEntry] of Object.entries(manifest.files)) { - if (desiredFiles.has(relativePath)) continue; - const absolutePath = path.join( - resolvedOutputDir, - ...relativePath.split('/') - ); - assertContainedPath(resolvedOutputDir, absolutePath); - let existing: Buffer | undefined; - try { - existing = fs.readFileSync(absolutePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - if (!existing) continue; - - const existingHash = hashContent(existing); - if (!pruneStaleFiles) { - changes.push({ - path: relativePath, - absolutePath, - action: 'unchanged', - previousHash: existingHash, - reason: 'retained-owned-file', - }); - nextManifestFiles[relativePath] = previousEntry; - continue; - } - - const modified = existingHash !== previousEntry.sha256; - if (modified && !overwriteModifiedGenerated) { - changes.push({ - path: relativePath, - absolutePath, - action: 'conflict', - previousHash: existingHash, - reason: 'modified-generated-file', - }); - nextManifestFiles[relativePath] = previousEntry; - } else { - changes.push({ - path: relativePath, - absolutePath, - action: 'delete', - previousHash: existingHash, - reason: modified ? 'modified-generated-file' : 'generated-file-removed', - }); - } - } - - changes.sort((left, right) => left.path.localeCompare(right.path)); - const manifestContent = - removeManifestWhenEmpty && Object.keys(nextManifestFiles).length === 0 - ? undefined - : createManifestContent(nextManifestFiles); - const manifestChanged = previousManifestContent !== manifestContent; - changes.push({ - path: GENERATED_FILES_MANIFEST, - absolutePath: manifestPath, - action: manifestChanged - ? manifestContent === undefined - ? 'delete' - : previousManifestContent === undefined - ? 'create' - : 'update' - : 'unchanged', - previousHash: - previousManifestContent === undefined - ? undefined - : hashContent(previousManifestContent), - generatedHash: - manifestContent === undefined ? undefined : hashContent(manifestContent), - reason: 'ownership-manifest', - }); - - const publicPlan: GenerationPlan = { - version: 1, - outputDir: resolvedOutputDir, - manifestPath, - fingerprint: fingerprintChanges(changes, manifestContent), - changes, - }; - - return { - publicPlan, - desiredFiles, - manifestContent, - manifestChanged, - removeOutputDirWhenEmpty: removeManifestWhenEmpty, - }; -} - -/** - * Compute a complete generation plan without mutating the target filesystem. - */ -export async function planGeneratedFiles( - files: GeneratedFile[], - outputDir: string, - options: Omit = {} -): Promise { - return (await prepareGenerationPlan(files, outputDir, options)).publicPlan; -} - -function removeEmptyParents(startDir: string, stopDir: string): void { - let current = startDir; - while (current !== stopDir && current.startsWith(`${stopDir}${path.sep}`)) { - try { - fs.rmdirSync(current); - } catch { - return; - } - current = path.dirname(current); - } -} - -interface CommittedChange { - change: FileChange; - backupCreated: boolean; - targetWritten: boolean; -} - -interface PlanTransaction { - prepared: PreparedPlan; - applicableChanges: FileChange[]; - transactionRoot: string; - stagedRoot: string; - backupRoot: string; - committed: CommittedChange[]; - written: string[]; - removed: string[]; - manifestBackupCreated: boolean; - manifestPublished: boolean; -} - -function conflictErrors(plan: GenerationPlan): string[] { - return plan.changes - .filter((change) => change.action === 'conflict') - .map( - (conflict) => - `${conflict.reason === 'unowned-existing-file' ? 'Refusing to overwrite unowned file' : 'Generated file was modified'}: ${conflict.absolutePath}` - ); -} - -function createPlanTransaction(prepared: PreparedPlan): PlanTransaction { - const outputParent = path.dirname(prepared.publicPlan.outputDir); - fs.mkdirSync(outputParent, { recursive: true }); - const transactionRoot = fs.mkdtempSync( - path.join( - outputParent, - `.${path.basename(prepared.publicPlan.outputDir)}.codegen-transaction-` - ) - ); - return { - prepared, - applicableChanges: prepared.publicPlan.changes.filter( - (change) => - change.path !== GENERATED_FILES_MANIFEST && - (change.action === 'create' || - change.action === 'update' || - change.action === 'delete') - ), - transactionRoot, - stagedRoot: path.join(transactionRoot, 'staged'), - backupRoot: path.join(transactionRoot, 'backup'), - committed: [], - written: [], - removed: [], - manifestBackupCreated: false, - manifestPublished: false, - }; -} - -function stagePlan(transaction: PlanTransaction): void { - const { prepared, applicableChanges, stagedRoot } = transaction; - for (const change of applicableChanges) { - if (change.action === 'delete') continue; - const desired = prepared.desiredFiles.get(change.path); - if (!desired) { - throw new Error(`Missing prepared content for ${change.path}`); - } - const stagedPath = path.join(stagedRoot, ...change.path.split('/')); - fs.mkdirSync(path.dirname(stagedPath), { recursive: true }); - fs.writeFileSync(stagedPath, desired.content, 'utf8'); - } - if (prepared.manifestChanged && prepared.manifestContent !== undefined) { - const stagedManifestPath = path.join(stagedRoot, GENERATED_FILES_MANIFEST); - fs.mkdirSync(path.dirname(stagedManifestPath), { recursive: true }); - fs.writeFileSync(stagedManifestPath, prepared.manifestContent, { - encoding: 'utf8', - mode: 0o600, - }); - } -} - -function readOptionalHash(filePath: string): string | undefined { - try { - return hashContent(fs.readFileSync(filePath)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; - throw error; - } -} - -function assertPlanStillCurrent(transaction: PlanTransaction): void { - const { publicPlan } = transaction.prepared; - const currentOutputDir = canonicalizeOutputDir(publicPlan.outputDir); - if (currentOutputDir !== publicPlan.outputDir) { - throw new Error( - `Generated output path changed after planning: ${publicPlan.outputDir}` - ); - } - - for (const change of publicPlan.changes) { - assertContainedPath(publicPlan.outputDir, change.absolutePath); - const observedHash = readOptionalHash(change.absolutePath); - if (observedHash !== change.previousHash) { - throw new Error( - `Generated output changed after planning: ${change.absolutePath}` - ); - } - } -} - -function commitPlanFiles( - transaction: PlanTransaction, - showProgress: boolean, - progress: { current: number; total: number } -): void { - const { prepared, applicableChanges, backupRoot, stagedRoot } = transaction; - fs.mkdirSync(prepared.publicPlan.outputDir, { recursive: true }); - - for (const change of applicableChanges) { - progress.current += 1; - if ( - showProgress && - (progress.current === 1 || - progress.current === progress.total || - progress.current % 100 === 0) - ) { - console.log( - `Applying generated files: ${progress.current}/${progress.total}` - ); - } - - const targetPath = change.absolutePath; - let backupCreated = false; - if (fs.existsSync(targetPath)) { - const backupPath = path.join(backupRoot, ...change.path.split('/')); - fs.mkdirSync(path.dirname(backupPath), { recursive: true }); - fs.renameSync(targetPath, backupPath); - backupCreated = true; - } - const committedChange: CommittedChange = { - change, - backupCreated, - targetWritten: false, - }; - transaction.committed.push(committedChange); - - if (change.action !== 'delete') { - const stagedPath = path.join(stagedRoot, ...change.path.split('/')); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.renameSync(stagedPath, targetPath); - committedChange.targetWritten = true; - transaction.written.push(targetPath); - } else { - transaction.removed.push(targetPath); - } - } -} - -function publishPlanManifest(transaction: PlanTransaction): void { - const { prepared, backupRoot, stagedRoot } = transaction; - if (!prepared.manifestChanged) return; - - const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); - if (fs.existsSync(prepared.publicPlan.manifestPath)) { - fs.mkdirSync(path.dirname(manifestBackupPath), { recursive: true }); - fs.renameSync(prepared.publicPlan.manifestPath, manifestBackupPath); - transaction.manifestBackupCreated = true; - } - - if (prepared.manifestContent === undefined) return; - const stagedManifestPath = path.join(stagedRoot, GENERATED_FILES_MANIFEST); - fs.renameSync(stagedManifestPath, prepared.publicPlan.manifestPath); - transaction.manifestPublished = true; -} - -function rollbackTransaction(transaction: PlanTransaction): string[] { - const errors: string[] = []; - const attempt = (description: string, operation: () => void): void => { - try { - operation(); - } catch (error) { - errors.push( - `${description}: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - }; - const { prepared, backupRoot } = transaction; - - if (transaction.manifestPublished) { - attempt('remove partially published ownership manifest', () => { - if (fs.existsSync(prepared.publicPlan.manifestPath)) { - fs.rmSync(prepared.publicPlan.manifestPath, { force: true }); - } - }); - } - const manifestBackupPath = path.join(backupRoot, GENERATED_FILES_MANIFEST); - if (transaction.manifestBackupCreated) { - attempt('restore previous ownership manifest', () => { - if (!fs.existsSync(manifestBackupPath)) { - throw new Error(`Backup is missing: ${manifestBackupPath}`); - } - fs.mkdirSync(path.dirname(prepared.publicPlan.manifestPath), { - recursive: true, - }); - fs.renameSync(manifestBackupPath, prepared.publicPlan.manifestPath); - }); - } - - for (const committedChange of [...transaction.committed].reverse()) { - const { change, backupCreated, targetWritten } = committedChange; - if (targetWritten) { - attempt(`remove partially written file ${change.absolutePath}`, () => { - if (fs.existsSync(change.absolutePath)) { - fs.rmSync(change.absolutePath, { force: true }); - } - }); - } - const backupPath = path.join(backupRoot, ...change.path.split('/')); - if (backupCreated) { - attempt(`restore previous file ${change.absolutePath}`, () => { - if (!fs.existsSync(backupPath)) { - throw new Error(`Backup is missing: ${backupPath}`); - } - fs.mkdirSync(path.dirname(change.absolutePath), { recursive: true }); - fs.renameSync(backupPath, change.absolutePath); - }); - } else { - attempt(`remove empty directories for ${change.absolutePath}`, () => { - removeEmptyParents( - path.dirname(change.absolutePath), - prepared.publicPlan.outputDir - ); - }); - } - } - - return errors; -} - -function resultForPrepared( - prepared: PreparedPlan, - overrides: Partial -): WriteResult { - return { - success: true, - filesWritten: [], - filesRemoved: [], - plan: prepared.publicPlan, - planFingerprint: prepared.publicPlan.fingerprint, - ...overrides, - }; -} - -async function applyPreparedPlans( - preparedPlans: PreparedPlan[], - showProgress: boolean -): Promise { - const allConflictErrors = preparedPlans.flatMap(({ publicPlan }) => - conflictErrors(publicPlan) - ); - if (allConflictErrors.length > 0) { - return { - success: false, - errors: allConflictErrors, - results: preparedPlans.map((prepared) => { - const errors = conflictErrors(prepared.publicPlan); - return resultForPrepared(prepared, { - success: errors.length === 0, - ...(errors.length === 0 ? {} : { errors }), - }); - }), - }; - } - - const transactions: PlanTransaction[] = []; - const total = preparedPlans.reduce( - (count, prepared) => - count + - prepared.publicPlan.changes.filter( - (change) => - change.path !== GENERATED_FILES_MANIFEST && - (change.action === 'create' || - change.action === 'update' || - change.action === 'delete') - ).length, - 0 - ); - const progress = { current: 0, total }; - - try { - for (const prepared of preparedPlans) { - const hasWork = - prepared.manifestChanged || - prepared.publicPlan.changes.some( - (change) => - change.path !== GENERATED_FILES_MANIFEST && - (change.action === 'create' || - change.action === 'update' || - change.action === 'delete') - ); - if (!hasWork) continue; - const transaction = createPlanTransaction(prepared); - transactions.push(transaction); - stagePlan(transaction); - } - - for (const transaction of transactions) { - assertPlanStillCurrent(transaction); - } - for (const transaction of transactions) { - commitPlanFiles(transaction, showProgress, progress); - } - // Every ownership manifest is published after every content mutation. - for (const transaction of transactions) { - publishPlanManifest(transaction); - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - const rollbackByPlan = new Map(); - for (const transaction of [...transactions].reverse()) { - rollbackByPlan.set( - transaction.prepared.publicPlan, - rollbackTransaction(transaction) - ); - } - - for (const transaction of transactions) { - const rollbackErrors = rollbackByPlan.get( - transaction.prepared.publicPlan - )!; - if (rollbackErrors.length === 0) { - try { - fs.rmSync(transaction.transactionRoot, { - recursive: true, - force: true, - }); - } catch (cleanupError) { - rollbackErrors.push( - `remove transaction directory: ${cleanupError instanceof Error ? cleanupError.message : 'Unknown error'}` - ); - } - } - } - - const errors = [`Failed to apply generated file plans: ${message}`]; - return { - success: false, - errors, - results: preparedPlans.map((prepared) => { - const transaction = transactions.find( - (candidate) => candidate.prepared === prepared - ); - const rollbackErrors = rollbackByPlan.get(prepared.publicPlan) ?? []; - return resultForPrepared(prepared, { - success: false, - errors, - ...(rollbackErrors.length === 0 - ? {} - : { - rollbackErrors, - recoveryPath: transaction?.transactionRoot, - }), - }); - }), - }; - } - - // At this point every content change and ownership manifest is committed. - // Cleanup cannot safely change that outcome: deleting one transaction's - // backups and then rolling every root back would make earlier roots - // unrestorable. Retain any transaction directory that cannot be removed and - // report it as a warning instead. - const cleanupWarningsByPlan = new Map(); - for (const transaction of transactions) { - for (const { change } of transaction.committed) { - if (change.action === 'delete') { - removeEmptyParents( - path.dirname(change.absolutePath), - transaction.prepared.publicPlan.outputDir - ); - } - } - - try { - fs.rmSync(transaction.transactionRoot, { - recursive: true, - force: true, - }); - } catch (cleanupError) { - cleanupWarningsByPlan.set(transaction.prepared.publicPlan, [ - `Generated files were committed, but the transaction directory could not be removed: ${ - cleanupError instanceof Error ? cleanupError.message : 'Unknown error' - }`, - ]); - } - - if (transaction.prepared.removeOutputDirWhenEmpty) { - try { - fs.rmdirSync(transaction.prepared.publicPlan.outputDir); - } catch { - // Unowned files or directories keep the output root alive. - } - } - } - - const byPlan = new Map( - transactions.map((transaction) => [ - transaction.prepared.publicPlan, - transaction, - ]) - ); - return { - success: true, - results: preparedPlans.map((prepared) => { - const transaction = byPlan.get(prepared.publicPlan); - const warnings = cleanupWarningsByPlan.get(prepared.publicPlan) ?? []; - return resultForPrepared(prepared, { - filesWritten: transaction?.written ?? [], - filesRemoved: transaction?.removed ?? [], - ...(warnings.length === 0 - ? {} - : { - warnings, - recoveryPath: transaction?.transactionRoot, - }), - }); - }), - }; -} + WriteResult, +} from './types'; + +export { planGeneratedFiles } from './planner'; +export { + GENERATED_FILES_MANIFEST, + type FileChange, + type FileChangeAction, + type GeneratedFile, + type GeneratedFilesManifest, + type GeneratedFileWriteJob, + type GenerationPlan, + type WriteBatchOptions, + type WriteBatchResult, + type WriteOptions, + type WriteResult, +} from './types'; /** * Plan and optionally apply generated files. @@ -1065,7 +49,7 @@ export async function writeGeneratedFiles( files: GeneratedFile[], outputDir: string, _subdirs: string[], - options: WriteOptions = {} + options: WriteOptions = {}, ): Promise { const { dryRun, ...jobOptions } = options; const batch = await writeGeneratedFileJobs( @@ -1079,7 +63,7 @@ export async function writeGeneratedFiles( { dryRun, showProgress: options.showProgress, - } + }, ); return ( batch.results[0] ?? { @@ -1090,7 +74,7 @@ export async function writeGeneratedFiles( } function mergeWriteJobs( - jobs: GeneratedFileWriteJob[] + jobs: GeneratedFileWriteJob[], ): GeneratedFileWriteJob[] { const grouped = new Map(); for (const job of jobs) { @@ -1135,15 +119,15 @@ function mergeWriteJobs( */ export async function writeGeneratedFileJobs( jobs: GeneratedFileWriteJob[], - options: WriteBatchOptions = {} + options: WriteBatchOptions = {}, ): Promise { throwIfAborted(options.signal); const mergedJobs = mergeWriteJobs(jobs); const preparePlans = async (): Promise => Promise.all( mergedJobs.map((job) => - prepareGenerationPlan(job.files, job.outputDir, job.options ?? {}) - ) + prepareGenerationPlan(job.files, job.outputDir, job.options ?? {}), + ), ); if (options.dryRun) { let preparedPlans: PreparedPlan[]; @@ -1174,7 +158,7 @@ export async function writeGeneratedFileJobs( try { locks = await acquireOutputLocks( mergedJobs.map((job) => job.outputDir), - options.signal + options.signal, ); const preparedPlans = await preparePlans(); throwIfAborted(options.signal); diff --git a/graphql/codegen/src/core/shared-pgpm-source.ts b/graphql/codegen/src/core/shared-pgpm-source.ts new file mode 100644 index 0000000000..c274ba22aa --- /dev/null +++ b/graphql/codegen/src/core/shared-pgpm-source.ts @@ -0,0 +1,210 @@ +/** + * Shared ephemeral PGPM source lifecycle for multi-target generation. + */ +import { createHash } from 'node:crypto'; + +import { PgpmPackage } from '@pgpmjs/core'; +import type { PgConfig } from 'pg-env'; +import { pgCache } from 'pg-cache'; +import { createEphemeralDb, type EphemeralDbResult } from 'pgsql-client'; +import { deployPgpm } from 'pgsql-seed'; + +import type { + DbConfig, + GraphQLSDKConfigTarget, + PgpmConfig, +} from '../types/config'; +import { mergeConfig } from '../types/config'; +import { throwIfAborted } from './cancellation'; +import { resolvePgConfig } from './introspect'; +import { + resolvePathFrom, + type GenerateProgressEvent, +} from './target-generation'; + +export interface SharedPgpmSource { + key: string; + description: string; + ephemeralDb: EphemeralDbResult; + deployed: boolean; +} + +function getPgpmSourceKey(pgpm: PgpmConfig, cwd: string): string | null { + if (pgpm.modulePath) return `module:${resolvePathFrom(cwd, pgpm.modulePath)}`; + if (pgpm.workspacePath && pgpm.moduleName) + return `workspace:${resolvePathFrom(cwd, pgpm.workspacePath)}:${pgpm.moduleName}`; + return null; +} + +function getSharedPgpmSourceKey( + pgpm: PgpmConfig, + cwd: string, + pgConfig: PgConfig, +): string | null { + const sourceKey = getPgpmSourceKey(pgpm, cwd); + if (!sourceKey) return null; + const connectionFingerprint = createHash('sha256') + .update( + JSON.stringify([ + pgConfig.host, + pgConfig.port, + pgConfig.user, + pgConfig.password, + pgConfig.database, + ]), + ) + .digest('hex'); + return `${sourceKey}:connection:${connectionFingerprint}`; +} + +function getModulePathFromPgpm(pgpm: PgpmConfig, cwd: string): string { + if (pgpm.modulePath) return resolvePathFrom(cwd, pgpm.modulePath)!; + if (pgpm.workspacePath && pgpm.moduleName) { + const workspace = new PgpmPackage( + resolvePathFrom(cwd, pgpm.workspacePath)!, + ); + const moduleProject = workspace.getModuleProject(pgpm.moduleName); + const modulePath = moduleProject.getModulePath(); + if (!modulePath) { + throw new Error(`Module "${pgpm.moduleName}" not found in workspace`); + } + return modulePath; + } + throw new Error( + 'Invalid PGPM config: requires modulePath or workspacePath+moduleName', + ); +} + +export async function prepareSharedPgpmSources( + configs: Record, + cliOverrides: Partial | undefined, + cwd: string, + env: Readonly>, + onProgress?: (event: GenerateProgressEvent) => void, + signal?: AbortSignal, +): Promise> { + const sharedSources = new Map(); + const pgpmTargets = new Map< + string, + { + count: number; + pgpm: PgpmConfig; + baseConfig: PgConfig; + description: string; + } + >(); + + throwIfAborted(signal); + + for (const name of Object.keys(configs)) { + throwIfAborted(signal); + const merged = mergeConfig(configs[name], cliOverrides ?? {}); + const pgpm = merged.db?.pgpm; + if (!pgpm) continue; + const baseConfig = resolvePgConfig(merged.db?.config, env); + const key = getSharedPgpmSourceKey(pgpm, cwd, baseConfig); + if (!key) continue; + const existing = pgpmTargets.get(key); + pgpmTargets.set(key, { + count: (existing?.count ?? 0) + 1, + pgpm, + baseConfig, + description: getPgpmSourceKey(pgpm, cwd)!, + }); + } + + try { + for (const [key, target] of pgpmTargets) { + throwIfAborted(signal); + if (target.count < 2) continue; + + throwIfAborted(signal); + const ephemeralDb = createEphemeralDb({ + prefix: 'codegen_pgpm_shared_', + verbose: false, + baseConfig: target.baseConfig, + }); + const shared: SharedPgpmSource = { + key, + description: target.description, + ephemeralDb, + deployed: false, + }; + sharedSources.set(key, shared); + + throwIfAborted(signal); + const modulePath = getModulePathFromPgpm(target.pgpm, cwd); + await deployPgpm(ephemeralDb.config, modulePath, false); + shared.deployed = true; + throwIfAborted(signal); + + const event: GenerateProgressEvent = { + phase: 'pgpm.prepare', + message: `[multi-target] Shared PGPM source deployed once for ${target.count} targets: ${target.description}`, + }; + if (onProgress) onProgress(event); + else console.log(event.message); + } + } catch (error) { + try { + for (const shared of sharedSources.values()) { + pgCache.delete(shared.ephemeralDb.config.database); + } + await pgCache.waitForDisposals(); + } finally { + for (const shared of sharedSources.values()) { + shared.ephemeralDb.teardown({ keepDb: false }); + } + } + throw error; + } + + return sharedSources; +} + +export function applySharedPgpmDb( + config: GraphQLSDKConfigTarget, + sharedSources: Map, + cwd: string, + env: Readonly>, +): GraphQLSDKConfigTarget { + const pgpm = config.db?.pgpm; + if (!pgpm) return config; + + const key = getSharedPgpmSourceKey( + pgpm, + cwd, + resolvePgConfig(config.db?.config, env), + ); + if (!key) return config; + + const shared = sharedSources.get(key); + if (!shared) return config; + + const sharedDbConfig: DbConfig = { + ...config.db, + pgpm: undefined, + config: shared.ephemeralDb.config, + keepDb: true, + }; + + return { + ...config, + db: sharedDbConfig, + }; +} + +export async function disposeSharedPgpmSources( + sharedSources: Map, + keepDb: boolean, +): Promise { + for (const shared of sharedSources.values()) { + try { + // deployPgpm() caches connections that must be closed before dropping. + pgCache.delete(shared.ephemeralDb.config.database); + await pgCache.waitForDisposals(); + } finally { + shared.ephemeralDb.teardown({ keepDb }); + } + } +} diff --git a/graphql/codegen/src/core/stale-targets.ts b/graphql/codegen/src/core/stale-targets.ts new file mode 100644 index 0000000000..cecdf35713 --- /dev/null +++ b/graphql/codegen/src/core/stale-targets.ts @@ -0,0 +1,204 @@ +/** + * Ownership manifest handling and stale generated-target cleanup. + */ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import path from 'node:path'; + +import { GENERATED_FILES_MANIFEST, type GeneratedFileWriteJob } from './output'; + +/** Manifest file listing generated target names, written to the output root. */ +export const TARGETS_MANIFEST = '.targets'; + +function isSafeTargetName(target: unknown): target is string { + return ( + typeof target === 'string' && + target.length > 0 && + target !== '.' && + target !== '..' && + !path.isAbsolute(target) && + !target.includes('/') && + !target.includes('\\') && + !target.includes('\0') + ); +} + +function readTargetNamesManifest(outputRoot: string): string[] | null { + const manifestPath = path.join(outputRoot, TARGETS_MANIFEST); + if (!fs.existsSync(manifestPath)) return null; + const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if ( + !Array.isArray(parsed) || + !parsed.every(isSafeTargetName) || + new Set(parsed).size !== parsed.length + ) { + throw new Error(`Invalid target ownership manifest: ${manifestPath}`); + } + return parsed; +} + +export function getStaleTargetWriteJobs( + outputRoot: string, + currentTargetNames: string[], +): GeneratedFileWriteJob[] { + if (!currentTargetNames.every(isSafeTargetName)) { + throw new Error('Target names must be single safe path segments.'); + } + const previousTargets = readTargetNamesManifest(outputRoot); + if (!previousTargets) return []; + const current = new Set(currentTargetNames); + const jobs: GeneratedFileWriteJob[] = []; + + for (const target of previousTargets) { + if (current.has(target)) continue; + const targetDir = path.join(outputRoot, target); + if (!fs.existsSync(targetDir)) continue; + if (fs.lstatSync(targetDir).isSymbolicLink()) { + throw new Error( + `Refusing to clean a symbolic-link target directory: ${targetDir}`, + ); + } + const generatedManifest = path.join(targetDir, GENERATED_FILES_MANIFEST); + // Legacy empty/unmanaged directories are preserved. Only a writer-owned + // target can participate in the transactional cleanup plan. + if (!fs.existsSync(generatedManifest)) continue; + jobs.push({ + files: [], + outputDir: targetDir, + options: { + pruneStaleFiles: true, + removeManifestWhenEmpty: true, + showProgress: false, + }, + }); + } + return jobs; +} + +function removeEmptyDirectoryTree(directory: string): void { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + removeEmptyDirectoryTree(path.join(directory, entry.name)); + } + } + try { + fs.rmdirSync(directory); + } catch { + // Files or non-empty child directories are unowned and must survive. + } +} + +/** + * Remove stale generated target directories from `outputRoot`. + * Reads the `.targets` manifest (written by `generateMulti`) to know which + * directories were previously generated. Only those are eligible for removal; + * hand-written directories (e.g. `config/`, `utils/`) are never touched. + * Returns the list of directory names that were removed. + * + * @deprecated Use generateMulti({ cleanStaleTargets: true }), which plans this + * cleanup and commits it with the rest of the generation operation. + */ +export function removeStaleTargetDirs( + outputRoot: string, + currentTargetNames: string[], + verbose?: boolean, +): string[] { + const removed: string[] = []; + if (!fs.existsSync(outputRoot)) return removed; + + const manifestPath = path.join(outputRoot, TARGETS_MANIFEST); + if (!fs.existsSync(manifestPath)) return removed; + + let previousTargets: string[]; + try { + previousTargets = readTargetNamesManifest(outputRoot) ?? []; + } catch { + return removed; + } + + if (!currentTargetNames.every(isSafeTargetName)) return removed; + + const currentTargets = new Set(currentTargetNames); + const staleTargets = previousTargets.filter((t) => !currentTargets.has(t)); + + for (const target of staleTargets) { + const dirPath = path.join(outputRoot, target); + if (!fs.existsSync(dirPath)) continue; + if (fs.lstatSync(dirPath).isSymbolicLink()) continue; + + const entries = fs.readdirSync(dirPath); + if (entries.length === 0) { + fs.rmdirSync(dirPath); + removed.push(target); + if (verbose) { + console.log(`Removed stale target directory: ${target}`); + } + continue; + } + + const generatedManifestPath = path.join(dirPath, GENERATED_FILES_MANIFEST); + if (!fs.existsSync(generatedManifestPath)) continue; + + let ownedFiles: Record; + try { + const generatedManifest = JSON.parse( + fs.readFileSync(generatedManifestPath, 'utf8'), + ) as { files?: Record }; + if ( + !generatedManifest.files || + typeof generatedManifest.files !== 'object' + ) { + continue; + } + ownedFiles = Object.fromEntries( + Object.entries(generatedManifest.files).map(([filePath, entry]) => { + if ( + !entry || + typeof entry.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(entry.sha256) || + path.isAbsolute(filePath) || + filePath.includes('\\') || + filePath + .split('/') + .some( + (segment) => + segment === '' || segment === '.' || segment === '..', + ) + ) { + throw new Error('Invalid generated manifest'); + } + return [filePath, { sha256: entry.sha256 }]; + }), + ); + } catch { + continue; + } + + const ownedPaths = Object.entries(ownedFiles).map(([filePath, entry]) => ({ + absolutePath: path.join(dirPath, ...filePath.split('/')), + sha256: entry.sha256, + })); + const modifiedOwnedFile = ownedPaths.some(({ absolutePath, sha256 }) => { + if (!fs.existsSync(absolutePath)) return false; + return ( + createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex') !== sha256 + ); + }); + if (modifiedOwnedFile) continue; + + for (const { absolutePath } of ownedPaths) { + fs.rmSync(absolutePath, { force: true }); + } + fs.rmSync(generatedManifestPath, { force: true }); + removeEmptyDirectoryTree(dirPath); + if (!fs.existsSync(dirPath)) { + removed.push(target); + if (verbose) { + console.log(`Removed stale target directory: ${target}`); + } + } + } + return removed; +} diff --git a/graphql/codegen/src/core/target-generation.ts b/graphql/codegen/src/core/target-generation.ts new file mode 100644 index 0000000000..70e642d726 --- /dev/null +++ b/graphql/codegen/src/core/target-generation.ts @@ -0,0 +1,766 @@ +/** + * Single-target code generation planning and result assembly. + * + * This module owns source introspection and generated artifact construction. + * Filesystem application is delegated to the output writer. + */ +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +import { buildClientSchema, printSchema } from 'graphql'; + +import type { GraphQLSDKConfigTarget } from '../types/config'; +import { getConfigOptions } from '../types/config'; +import type { Operation, Table, TypeRegistry } from '../types/schema'; +import { generate as generateReactQueryFiles } from './codegen'; +import { generateRootBarrel } from './codegen/barrel'; +import { generateCli as generateCliFiles } from './codegen/cli'; +import { + generateReadme as generateCliReadme, + generateAgentsDocs as generateCliAgentsDocs, + generateSkills as generateCliSkills, +} from './codegen/cli/docs-generator'; +import { resolveDocsConfig } from './codegen/docs-utils'; +import { + generateHooksReadme, + generateHooksAgentsDocs, + generateHooksSkills, +} from './codegen/hooks-docs-generator'; +import { generateOrm as generateOrmFiles } from './codegen/orm'; +import { + generateOrmReadme, + generateOrmAgentsDocs, + generateOrmSkills, +} from './codegen/orm/docs-generator'; +import { generateSharedTypes } from './codegen/shared'; +import { generateTargetReadme } from './codegen/target-docs-generator'; +import { createSchemaSource, validateSourceOptions } from './introspect'; +import { rethrowIfCancelled, throwIfAborted } from './cancellation'; +import type { + FileChange, + GeneratedFileWriteJob, + GenerationPlan, + WriteBatchResult, + WriteResult, +} from './output'; +import { writeGeneratedFileJobs } from './output'; +import { runCodegenPipeline, validateTablesFound } from './pipeline'; +import { findWorkspaceRoot } from './workspace'; + +export interface GenerateOptions extends GraphQLSDKConfigTarget { + authorization?: string; + verbose?: boolean; + dryRun?: boolean; + skipCustomOperations?: boolean; + /** Base directory for all relative source and output paths. */ + cwd?: string; + /** Allow replacing or deleting generated files modified since the last run. */ + overwriteModifiedGenerated?: boolean; + /** Explicit confirmation required with overwriteModifiedGenerated. */ + yes?: boolean; + /** Receives progress without coupling the core generator to terminal output. */ + onProgress?: (event: GenerateProgressEvent) => void; + /** Cancels fetch and generation before the filesystem commit begins. */ + signal?: AbortSignal; + /** Explicit environment used to resolve omitted PostgreSQL settings. */ + env?: Readonly>; +} + +export interface GenerateProgressEvent { + phase: + | 'schema.fetch' + | 'types.generate' + | 'hooks.generate' + | 'orm.generate' + | 'cli.generate' + | 'pgpm.prepare'; + message: string; +} + +export interface GenerateResult { + success: boolean; + message: string; + output?: string; + tables?: string[]; + filesWritten?: string[]; + filesRemoved?: string[]; + /** Complete filesystem plans, including unchanged files and conflicts. */ + plans?: GenerationPlan[]; + /** Stable hash of the ordered plan fingerprints. */ + planFingerprint?: string; + /** Flattened file changes for agent-oriented consumers. */ + fileChanges?: FileChange[]; + errors?: string[]; + /** Non-fatal writer diagnostics, including retained transaction data. */ + warnings?: string[]; + /** Transaction directory retained because cleanup or rollback was incomplete. */ + recoveryPath?: string; + /** Filesystem restoration failures requiring manual recovery. */ + rollbackErrors?: string[]; + pipelineData?: { + tables: Table[]; + customOperations: { + queries: Operation[]; + mutations: Operation[]; + typeRegistry?: TypeRegistry; + }; + }; +} + +interface PrepareGenerationOptions { + skipCli?: boolean; + targetName?: string; +} + +export interface PreparedGeneration { + result: GenerateResult; + writeJobs: GeneratedFileWriteJob[]; + messages: { + applied: string; + dryRun: string; + }; +} + +export type GenerationPreparation = + | { ok: false; result: GenerateResult } + | { ok: true; prepared: PreparedGeneration }; + +export function resolveSkillsOutputDir( + config: GraphQLSDKConfigTarget, + outputRoot: string, + cwd: string, +): string { + const resolvedOutputRoot = resolvePathFrom(cwd, outputRoot)!; + const workspaceRoot = + findWorkspaceRoot(resolvedOutputRoot) ?? findWorkspaceRoot(cwd) ?? cwd; + + if (config.skillsPath) { + return path.isAbsolute(config.skillsPath) + ? config.skillsPath + : path.resolve(workspaceRoot, config.skillsPath); + } + + return path.resolve(workspaceRoot, '.agents/skills'); +} + +export function resolvePathFrom( + cwd: string, + value: string | undefined, +): string | undefined { + if (!value) return value; + return path.isAbsolute(value) + ? path.normalize(value) + : path.resolve(cwd, value); +} + +export function resolveTargetPaths( + target: GraphQLSDKConfigTarget, + cwd: string, +): GraphQLSDKConfigTarget { + return { + ...target, + output: resolvePathFrom(cwd, target.output), + schemaFile: resolvePathFrom(cwd, target.schemaFile), + schemaDir: resolvePathFrom(cwd, target.schemaDir), + schema: target.schema + ? { + ...target.schema, + output: resolvePathFrom(cwd, target.schema.output), + } + : undefined, + db: target.db + ? { + ...target.db, + pgpm: target.db.pgpm + ? { + ...target.db.pgpm, + modulePath: resolvePathFrom(cwd, target.db.pgpm.modulePath), + workspacePath: resolvePathFrom( + cwd, + target.db.pgpm.workspacePath, + ), + } + : undefined, + } + : undefined, + }; +} + +export function combinePlanFingerprint( + plans: GenerationPlan[], +): string | undefined { + if (plans.length === 0) return undefined; + return createHash('sha256') + .update( + JSON.stringify( + plans.map((plan) => ({ + outputDir: plan.outputDir, + fingerprint: plan.fingerprint, + })), + ), + ) + .digest('hex'); +} + +export function planFields( + results: WriteResult[], +): Pick { + const plans = results.flatMap((result) => (result.plan ? [result.plan] : [])); + return { + plans, + planFingerprint: combinePlanFingerprint(plans), + fileChanges: plans.flatMap((plan) => plan.changes), + }; +} + +export function recoveryFields( + results: WriteResult[], +): Pick { + const warnings = results.flatMap((result) => result.warnings ?? []); + const rollbackErrors = results.flatMap( + (result) => result.rollbackErrors ?? [], + ); + const recoveryPath = results.find( + (result) => result.recoveryPath !== undefined, + )?.recoveryPath; + return { + ...(warnings.length === 0 ? {} : { warnings }), + ...(recoveryPath === undefined ? {} : { recoveryPath }), + ...(rollbackErrors.length === 0 ? {} : { rollbackErrors }), + }; +} + +const failedPreparation = (result: GenerateResult): GenerationPreparation => ({ + ok: false, + result, +}); + +export async function applyGenerationJobs( + jobs: GeneratedFileWriteJob[], + options: Pick, +): Promise { + throwIfAborted(options.signal); + return writeGeneratedFileJobs(jobs, { + dryRun: options.dryRun, + showProgress: false, + signal: options.signal, + }); +} + +export function completePreparedGeneration( + prepared: PreparedGeneration, + dryRun: boolean, + writeResults: WriteResult[] = [], +): GenerateResult { + const filesWritten = dryRun + ? [] + : writeResults.flatMap((result) => result.filesWritten ?? []); + const filesRemoved = dryRun + ? [] + : writeResults.flatMap((result) => result.filesRemoved ?? []); + return { + ...prepared.result, + success: true, + message: dryRun ? prepared.messages.dryRun : prepared.messages.applied, + filesWritten, + filesRemoved, + ...planFields(writeResults), + ...recoveryFields(writeResults), + }; +} + +export function failPreparedGeneration( + prepared: PreparedGeneration, + message: string, + writeResults: WriteResult[] = [], + errors: string[] = [message], +): GenerateResult { + return { + ...prepared.result, + success: false, + message, + errors, + filesWritten: [], + filesRemoved: [], + ...planFields(writeResults), + ...recoveryFields(writeResults), + }; +} + +export async function planTargetGeneration( + options: GenerateOptions = {}, + internalOptions?: PrepareGenerationOptions, +): Promise { + throwIfAborted(options.signal); + const cwd = path.resolve(options.cwd ?? process.cwd()); + const { + cwd: _cwd, + overwriteModifiedGenerated: _overwriteModifiedGenerated, + yes: _yes, + onProgress: _onProgress, + signal: _signal, + env: _env, + ...configOverrides + } = options; + const report = (event: GenerateProgressEvent): void => { + if (options.onProgress) options.onProgress(event); + else console.log(event.message); + }; + // Resolve every relative path against the operation cwd once. Downstream + // generation never needs to mutate or rediscover the process cwd. + const config = resolveTargetPaths(getConfigOptions(configOverrides), cwd); + const outputRoot = config.output!; + + // Determine which generators to run + // ORM is always required when React Query is enabled (hooks delegate to ORM) + // This handles minimist setting orm=false when --orm flag is absent + const runReactQuery = config.reactQuery ?? false; + const runCli = internalOptions?.skipCli ? false : !!config.cli; + const runOrm = + runReactQuery || + !!config.cli || + (options.orm !== undefined ? !!options.orm : false); + + const schemaEnabled = !!options.schema?.enabled; + + if (!schemaEnabled && !runReactQuery && !runOrm && !runCli) { + return failedPreparation({ + success: false, + message: + 'No generators enabled. Use reactQuery: true, orm: true, or cli: true in your config.', + output: outputRoot, + }); + } + + // Validate source + const sourceValidation = validateSourceOptions({ + endpoint: config.endpoint || undefined, + schemaFile: config.schemaFile || undefined, + db: config.db, + }); + if (!sourceValidation.valid) { + return failedPreparation({ + success: false, + message: sourceValidation.error!, + output: outputRoot, + }); + } + + const source = createSchemaSource({ + endpoint: config.endpoint || undefined, + schemaFile: config.schemaFile || undefined, + db: config.db, + authorization: options.authorization || config.headers?.Authorization, + headers: config.headers, + env: options.env ?? {}, + }); + + if (schemaEnabled && !runReactQuery && !runOrm && !runCli) { + try { + throwIfAborted(options.signal); + report({ + phase: 'schema.fetch', + message: `Fetching schema from ${source.describe()}...`, + }); + throwIfAborted(options.signal); + const { introspection } = await source.fetch(options.signal); + throwIfAborted(options.signal); + const schema = buildClientSchema(introspection as any); + const sdl = printSchema(schema); + throwIfAborted(options.signal); + + if (!sdl.trim()) { + return failedPreparation({ + success: false, + message: 'Schema introspection returned empty SDL.', + output: outputRoot, + }); + } + + const outDir = config.schema?.output || outputRoot; + const filename = options.schema?.filename || 'schema.graphql'; + const filePath = path.join(outDir, filename); + throwIfAborted(options.signal); + return { + ok: true, + prepared: { + result: { + success: true, + message: `Schema ready to export to ${filePath}`, + output: outDir, + }, + writeJobs: [ + { + files: [{ path: filename, content: sdl }], + outputDir: outDir, + options: { + pruneStaleFiles: false, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + confirmOverwrite: options.yes, + showProgress: false, + }, + }, + ], + messages: { + dryRun: `Dry run complete. Would export schema to ${filePath}`, + applied: `Schema exported to ${filePath}`, + }, + }, + }; + } catch (err) { + rethrowIfCancelled(err, options.signal); + return failedPreparation({ + success: false, + message: `Failed to export schema: ${err instanceof Error ? err.message : 'Unknown error'}`, + output: outputRoot, + }); + } + } + + // Run pipeline + let pipelineResult: Awaited>; + try { + throwIfAborted(options.signal); + report({ + phase: 'schema.fetch', + message: `Fetching schema from ${source.describe()}...`, + }); + throwIfAborted(options.signal); + pipelineResult = await runCodegenPipeline({ + source, + config, + verbose: options.verbose, + onLog: (message) => + report({ + phase: 'schema.fetch', + message, + }), + skipCustomOperations: options.skipCustomOperations, + signal: options.signal, + }); + throwIfAborted(options.signal); + } catch (err) { + rethrowIfCancelled(err, options.signal); + return failedPreparation({ + success: false, + message: `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}`, + output: outputRoot, + }); + } + + const { tables, customOperations } = pipelineResult; + + // Validate tables + const tablesValidation = validateTablesFound(tables); + if (!tablesValidation.valid) { + return failedPreparation({ + success: false, + message: tablesValidation.error!, + output: outputRoot, + }); + } + + const bothEnabled = runReactQuery && runOrm; + const filesToWrite: Array<{ path: string; content: string }> = []; + + // Generate shared types when both are enabled + if (bothEnabled) { + throwIfAborted(options.signal); + report({ phase: 'types.generate', message: 'Generating shared types...' }); + throwIfAborted(options.signal); + const sharedResult = generateSharedTypes({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + config, + }); + throwIfAborted(options.signal); + // The root barrel below is the final owner of index.ts and already exports + // shared ./types alongside the enabled generators. The old writer silently + // overwrote this shared-only barrel; keep the plan unambiguous instead. + filesToWrite.push( + ...sharedResult.files.filter((file) => file.path !== 'index.ts'), + ); + } + + // Generate React Query hooks + if (runReactQuery) { + throwIfAborted(options.signal); + report({ + phase: 'hooks.generate', + message: 'Generating React Query hooks...', + }); + throwIfAborted(options.signal); + const { files } = generateReactQueryFiles({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + config, + sharedTypesPath: bothEnabled ? '..' : undefined, + }); + throwIfAborted(options.signal); + filesToWrite.push( + ...files.map((file) => ({ + ...file, + path: path.posix.join('hooks', file.path), + })), + ); + } + + // Generate ORM client + if (runOrm) { + throwIfAborted(options.signal); + report({ phase: 'orm.generate', message: 'Generating ORM client...' }); + throwIfAborted(options.signal); + const { files } = generateOrmFiles({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + config, + sharedTypesPath: bothEnabled ? '..' : undefined, + }); + throwIfAborted(options.signal); + filesToWrite.push( + ...files.map((file) => ({ + ...file, + path: path.posix.join('orm', file.path), + })), + ); + } + + // Generate CLI commands + if (runCli) { + throwIfAborted(options.signal); + report({ phase: 'cli.generate', message: 'Generating CLI commands...' }); + throwIfAborted(options.signal); + const { files } = generateCliFiles({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + }, + config, + typeRegistry: customOperations.typeRegistry, + }); + throwIfAborted(options.signal); + filesToWrite.push( + ...files.map((file) => ({ + path: path.posix.join('cli', file.fileName), + content: file.content, + })), + ); + } + + // Generate barrel file at output root + const barrelContent = generateRootBarrel({ + hasTypes: bothEnabled, + hasHooks: runReactQuery, + hasOrm: runOrm, + hasCli: runCli, + }); + filesToWrite.push({ path: 'index.ts', content: barrelContent }); + + // Generate docs for each enabled generator + const docsConfig = resolveDocsConfig(config.docs); + const allCustomOps: Operation[] = [ + ...(customOperations.queries ?? []), + ...(customOperations.mutations ?? []), + ]; + const targetName = internalOptions?.targetName ?? 'default'; + const skillsToWrite: Array<{ path: string; content: string }> = []; + + if (runOrm) { + if (docsConfig.readme) { + const readme = generateOrmReadme( + tables, + allCustomOps, + customOperations.typeRegistry, + ); + filesToWrite.push({ + path: path.posix.join('orm', readme.fileName), + content: readme.content, + }); + } + if (docsConfig.agents) { + const agents = generateOrmAgentsDocs(tables, allCustomOps); + filesToWrite.push({ + path: path.posix.join('orm', agents.fileName), + content: agents.content, + }); + } + if (docsConfig.skills) { + for (const skill of generateOrmSkills( + tables, + allCustomOps, + targetName, + customOperations.typeRegistry, + )) { + skillsToWrite.push({ path: skill.fileName, content: skill.content }); + } + } + } + + if (runReactQuery) { + if (docsConfig.readme) { + const readme = generateHooksReadme( + tables, + allCustomOps, + customOperations.typeRegistry, + ); + filesToWrite.push({ + path: path.posix.join('hooks', readme.fileName), + content: readme.content, + }); + } + if (docsConfig.agents) { + const agents = generateHooksAgentsDocs(tables, allCustomOps); + filesToWrite.push({ + path: path.posix.join('hooks', agents.fileName), + content: agents.content, + }); + } + if (docsConfig.skills) { + for (const skill of generateHooksSkills( + tables, + allCustomOps, + targetName, + customOperations.typeRegistry, + )) { + skillsToWrite.push({ path: skill.fileName, content: skill.content }); + } + } + } + + if (runCli) { + const toolName = + typeof config.cli === 'object' && config.cli?.toolName + ? config.cli.toolName + : 'app'; + if (docsConfig.readme) { + const readme = generateCliReadme( + tables, + allCustomOps, + toolName, + customOperations.typeRegistry, + ); + filesToWrite.push({ + path: path.posix.join('cli', readme.fileName), + content: readme.content, + }); + } + if (docsConfig.agents) { + const agents = generateCliAgentsDocs( + tables, + allCustomOps, + toolName, + customOperations.typeRegistry, + ); + filesToWrite.push({ + path: path.posix.join('cli', agents.fileName), + content: agents.content, + }); + } + if (docsConfig.skills) { + for (const skill of generateCliSkills( + tables, + allCustomOps, + toolName, + targetName, + customOperations.typeRegistry, + )) { + skillsToWrite.push({ path: skill.fileName, content: skill.content }); + } + } + } + + // Generate per-target README at output root + if (docsConfig.readme) { + const targetReadme = generateTargetReadme({ + hasOrm: runOrm, + hasHooks: runReactQuery, + hasCli: runCli, + tableCount: tables.length, + customQueryCount: customOperations.queries.length, + customMutationCount: customOperations.mutations.length, + config, + }); + filesToWrite.push({ + path: targetReadme.fileName, + content: targetReadme.content, + }); + } + + const writeJobs: Array<{ + files: Array<{ path: string; content: string }>; + outputDir: string; + pruneStaleFiles: boolean; + }> = [ + { + files: filesToWrite, + outputDir: outputRoot, + pruneStaleFiles: true, + }, + ]; + throwIfAborted(options.signal); + if (skillsToWrite.length > 0) { + writeJobs.push({ + files: skillsToWrite, + outputDir: resolveSkillsOutputDir(config, outputRoot, cwd), + pruneStaleFiles: false, + }); + } + + throwIfAborted(options.signal); + const coordinatedJobs: GeneratedFileWriteJob[] = writeJobs.map((job) => ({ + files: job.files, + outputDir: job.outputDir, + options: { + pruneStaleFiles: job.pruneStaleFiles, + overwriteModifiedGenerated: options.overwriteModifiedGenerated, + confirmOverwrite: options.yes, + showProgress: false, + }, + })); + + const generators = [ + runReactQuery && 'React Query', + runOrm && 'ORM', + runCli && 'CLI', + ] + .filter(Boolean) + .join(' and '); + + return { + ok: true, + prepared: { + result: { + success: true, + message: `Generated files are ready to apply to ${outputRoot}`, + output: outputRoot, + tables: tables.map((t) => t.name), + pipelineData: { + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + }, + }, + writeJobs: coordinatedJobs, + messages: { + dryRun: `Dry run complete. Would generate ${generators} for ${tables.length} tables.`, + applied: `Generated ${generators} for ${tables.length} tables. Files written to ${outputRoot}`, + }, + }, + }; +} diff --git a/packages/cli-runtime/src/execution.ts b/packages/cli-runtime/src/execution.ts index 2c4690b8ba..b8ded352fe 100644 --- a/packages/cli-runtime/src/execution.ts +++ b/packages/cli-runtime/src/execution.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import { isAbsolute } from 'node:path'; import { Type } from '@sinclair/typebox'; import { @@ -9,7 +8,6 @@ import { ExecutionOutcome, ExecutionOutcomeSchema, NextAction, - OperationContext, OperationResult, OperationWarning, PROTOCOL_VERSION, @@ -38,6 +36,12 @@ import { } from './redaction'; import { assertOperationResultMetadata, CommandRegistry } from './registry'; import { assertJsonValue, compileSchema } from './schema'; +import { createOperationContext } from './operation-context'; + +export { + createOperationContext, + type CreateOperationContextOptions, +} from './operation-context'; const RESERVED_EVENTS = new Set([ 'operation.started', @@ -123,64 +127,6 @@ class ProtocolSinkError extends Error { } } -export interface CreateOperationContextOptions { - cwd: string; - mode: ExecutionMode; - env: Readonly>; - signal: AbortSignal; - operationId?: string; - now?: () => Date; - events?: { emit(event: TEvent): Promise }; - capabilities?: Partial; - registerSensitiveValue?: (value: string) => void; -} - -export function createOperationContext( - options: CreateOperationContextOptions -): OperationContext { - if (!isAbsolute(options.cwd)) { - throw new ContractError( - 'CLI_CWD_NOT_ABSOLUTE', - 'OperationContext.cwd must be an absolute path.', - { - cwd: options.cwd, - } - ); - } - const env = Object.freeze({ ...options.env }); - const capabilities: ApprovedCapabilities = Object.freeze({ - yes: options.capabilities?.yes ?? false, - ...(options.capabilities?.dryRun === undefined - ? {} - : { dryRun: options.capabilities.dryRun }), - ...(options.capabilities?.idempotencyKey === undefined - ? {} - : { idempotencyKey: options.capabilities.idempotencyKey }), - acknowledgedRisks: Object.freeze([ - ...(options.capabilities?.acknowledgedRisks ?? []), - ]), - }); - return Object.freeze({ - cwd: options.cwd, - mode: options.mode, - env, - signal: options.signal, - operationId: options.operationId ?? randomUUID(), - now: options.now ?? (() => new Date()), - events: options.events ?? { emit: async () => undefined }, - capabilities, - registerSensitiveValue(value: string): void { - if (typeof value !== 'string') { - throw new ContractError( - 'CLI_SENSITIVE_VALUE_INVALID', - 'Sensitive values registered by an operation must be strings.' - ); - } - if (value.length > 0) options.registerSensitiveValue?.(value); - }, - }); -} - export interface ExecuteCommandOptions { cwd: string; mode: ExecutionMode; diff --git a/packages/cli-runtime/src/operation-context.ts b/packages/cli-runtime/src/operation-context.ts new file mode 100644 index 0000000000..2ca042b0d2 --- /dev/null +++ b/packages/cli-runtime/src/operation-context.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'node:crypto'; +import { isAbsolute } from 'node:path'; + +import { + type ApprovedCapabilities, + type ExecutionMode, + type OperationContext, +} from './contracts'; +import { ContractError } from './errors'; + +export interface CreateOperationContextOptions { + cwd: string; + mode: ExecutionMode; + env: Readonly>; + signal: AbortSignal; + operationId?: string; + now?: () => Date; + events?: { emit(event: TEvent): Promise }; + capabilities?: Partial; + registerSensitiveValue?: (value: string) => void; +} + +export function createOperationContext( + options: CreateOperationContextOptions +): OperationContext { + if (!isAbsolute(options.cwd)) { + throw new ContractError( + 'CLI_CWD_NOT_ABSOLUTE', + 'OperationContext.cwd must be an absolute path.', + { + cwd: options.cwd, + } + ); + } + const env = Object.freeze({ ...options.env }); + const capabilities: ApprovedCapabilities = Object.freeze({ + yes: options.capabilities?.yes ?? false, + ...(options.capabilities?.dryRun === undefined + ? {} + : { dryRun: options.capabilities.dryRun }), + ...(options.capabilities?.idempotencyKey === undefined + ? {} + : { idempotencyKey: options.capabilities.idempotencyKey }), + acknowledgedRisks: Object.freeze([ + ...(options.capabilities?.acknowledgedRisks ?? []), + ]), + }); + return Object.freeze({ + cwd: options.cwd, + mode: options.mode, + env, + signal: options.signal, + operationId: options.operationId ?? randomUUID(), + now: options.now ?? (() => new Date()), + events: options.events ?? { emit: async () => undefined }, + capabilities, + registerSensitiveValue(value: string): void { + if (typeof value !== 'string') { + throw new ContractError( + 'CLI_SENSITIVE_VALUE_INVALID', + 'Sensitive values registered by an operation must be strings.' + ); + } + if (value.length > 0) options.registerSensitiveValue?.(value); + }, + }); +} diff --git a/packages/cli/scripts/packed-acceptance-support.mjs b/packages/cli/scripts/packed-acceptance-support.mjs new file mode 100644 index 0000000000..b4273be005 --- /dev/null +++ b/packages/cli/scripts/packed-acceptance-support.mjs @@ -0,0 +1,158 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export const createCliEnvironment = (home) => { + const inheritedKeys = [ + 'PATH', + 'Path', + 'PATHEXT', + 'SystemRoot', + 'SYSTEMROOT', + 'ComSpec', + 'TMPDIR', + 'TMP', + 'TEMP', + ]; + const inherited = Object.fromEntries( + inheritedKeys.flatMap((key) => + process.env[key] === undefined ? [] : [[key, process.env[key]]] + ) + ); + return { + ...inherited, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + APPDATA: home, + CI: 'true', + NO_COLOR: '1', + NO_UPDATE_NOTIFIER: '1', + GRAPHILE_ENV: 'production', + }; +}; + +export const parsePackedArguments = (argv) => { + if ( + ![2, 4].includes(argv.length) || + argv[0] !== '--artifacts' || + (argv.length === 4 && argv[2] !== '--suite') + ) { + throw new Error( + 'Usage: node verify-packed-artifacts.mjs --artifacts [--suite core|full]' + ); + } + + const suite = argv[3] ?? 'full'; + if (suite !== 'core' && suite !== 'full') { + throw new Error(`Unknown packed acceptance suite: ${suite}`); + } + + const artifactDirectory = resolve(argv[1]); + if (!existsSync(artifactDirectory)) { + throw new Error(`Artifact directory does not exist: ${artifactDirectory}`); + } + + const archives = readdirSync(artifactDirectory).filter((name) => + name.endsWith('.tgz') + ); + const requiredArchives = { + runtime: archives.filter((name) => + /^constructive-io-cli-runtime-[0-9].*\.tgz$/.test(name) + ), + cli: archives.filter((name) => + /^constructive-io-cli-[0-9].*\.tgz$/.test(name) + ), + codegen: archives.filter((name) => + /^constructive-io-graphql-codegen-[0-9].*\.tgz$/.test(name) + ), + graphqlExplorer: archives.filter((name) => + /^constructive-io-graphql-explorer-[0-9].*\.tgz$/.test(name) + ), + logger: archives.filter((name) => + /^pgpmjs-logger-[0-9].*\.tgz$/.test(name) + ), + pgCache: archives.filter((name) => /^pg-cache-[0-9].*\.tgz$/.test(name)), + graphileSettings: archives.filter((name) => + /^graphile-settings-[0-9].*\.tgz$/.test(name) + ), + graphileCache: archives.filter((name) => + /^graphile-cache-[0-9].*\.tgz$/.test(name) + ), + bucketProvisioner: archives.filter((name) => + /^graphile-bucket-provisioner-plugin-[0-9].*\.tgz$/.test(name) + ), + graphileSchema: archives.filter((name) => + /^graphile-schema-[0-9].*\.tgz$/.test(name) + ), + presignedUrl: archives.filter((name) => + /^graphile-presigned-url-plugin-[0-9].*\.tgz$/.test(name) + ), + graphqlQuery: archives.filter((name) => + /^constructive-io-graphql-query-[0-9].*\.tgz$/.test(name) + ), + expressContext: archives.filter((name) => + /^constructive-io-express-context-[0-9].*\.tgz$/.test(name) + ), + serverUtils: archives.filter((name) => + /^pgpmjs-server-utils-[0-9].*\.tgz$/.test(name) + ), + graphqlServer: archives.filter((name) => + /^constructive-io-graphql-server-[0-9].*\.tgz$/.test(name) + ), + jobPg: archives.filter((name) => + /^constructive-io-job-pg-[0-9].*\.tgz$/.test(name) + ), + jobScheduler: archives.filter((name) => + /^constructive-io-job-scheduler-[0-9].*\.tgz$/.test(name) + ), + jobUtils: archives.filter((name) => + /^constructive-io-job-utils-[0-9].*\.tgz$/.test(name) + ), + knativeJobFn: archives.filter((name) => + /^constructive-io-knative-job-fn-[0-9].*\.tgz$/.test(name) + ), + knativeJobService: archives.filter((name) => + /^constructive-io-knative-job-service-[0-9].*\.tgz$/.test(name) + ), + knativeJobWorker: archives.filter((name) => + /^constructive-io-knative-job-worker-[0-9].*\.tgz$/.test(name) + ), + sendEmail: archives.filter((name) => + /^constructive-io-send-email-fn-[0-9].*\.tgz$/.test(name) + ), + sendVerificationLink: archives.filter((name) => + /^constructive-io-send-verification-link-fn-[0-9].*\.tgz$/.test(name) + ), + pgpmEnv: archives.filter((name) => /^pgpmjs-env-[0-9].*\.tgz$/.test(name)), + pgEnv: archives.filter((name) => /^pg-env-[0-9].*\.tgz$/.test(name)), + }; + + const invalidArchives = Object.entries(requiredArchives).filter( + ([, matches]) => matches.length !== 1 + ); + if (invalidArchives.length > 0) { + throw new Error( + `Expected one archive for each CNC release-set package in ${artifactDirectory}; invalid counts: ${invalidArchives.map(([name, matches]) => `${name}=${matches.length}`).join(', ')}; found: ${archives.join(', ') || '(none)'}` + ); + } + + const selectedArchives = + suite === 'full' + ? archives + : [ + ...requiredArchives.runtime, + ...requiredArchives.cli, + ...requiredArchives.logger, + ...requiredArchives.serverUtils, + ...requiredArchives.pgCache, + ...requiredArchives.pgEnv, + ...requiredArchives.pgpmEnv, + ]; + + return { + suite, + installArchives: selectedArchives + .sort() + .map((archive) => join(artifactDirectory, archive)), + }; +}; diff --git a/packages/cli/scripts/verify-packed-artifacts.mjs b/packages/cli/scripts/verify-packed-artifacts.mjs index 9f59471f5b..e95395ca03 100644 --- a/packages/cli/scripts/verify-packed-artifacts.mjs +++ b/packages/cli/scripts/verify-packed-artifacts.mjs @@ -5,12 +5,16 @@ import { existsSync, mkdtempSync, readFileSync, - readdirSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { basename, join, resolve } from 'node:path'; +import { basename, join } from 'node:path'; + +import { + createCliEnvironment, + parsePackedArguments, +} from './packed-acceptance-support.mjs'; const protocolVersion = 'constructive.dev/cli/v1'; const terminalEvents = new Set([ @@ -19,166 +23,10 @@ const terminalEvents = new Set([ 'operation.cancelled', ]); -const createCliEnvironment = (home) => { - const inheritedKeys = [ - 'PATH', - 'Path', - 'PATHEXT', - 'SystemRoot', - 'SYSTEMROOT', - 'ComSpec', - 'TMPDIR', - 'TMP', - 'TEMP', - ]; - const inherited = Object.fromEntries( - inheritedKeys.flatMap((key) => - process.env[key] === undefined ? [] : [[key, process.env[key]]] - ) - ); - return { - ...inherited, - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: home, - APPDATA: home, - CI: 'true', - NO_COLOR: '1', - NO_UPDATE_NOTIFIER: '1', - GRAPHILE_ENV: 'production', - }; -}; - const fail = (message) => { throw new Error(message); }; -const parseArguments = (argv) => { - if ( - ![2, 4].includes(argv.length) || - argv[0] !== '--artifacts' || - (argv.length === 4 && argv[2] !== '--suite') - ) { - fail( - 'Usage: node verify-packed-artifacts.mjs --artifacts [--suite core|full]' - ); - } - - const suite = argv[3] ?? 'full'; - if (suite !== 'core' && suite !== 'full') { - fail(`Unknown packed acceptance suite: ${suite}`); - } - - const artifactDirectory = resolve(argv[1]); - if (!existsSync(artifactDirectory)) { - fail(`Artifact directory does not exist: ${artifactDirectory}`); - } - - const archives = readdirSync(artifactDirectory).filter((name) => - name.endsWith('.tgz') - ); - const requiredArchives = { - runtime: archives.filter((name) => - /^constructive-io-cli-runtime-[0-9].*\.tgz$/.test(name) - ), - cli: archives.filter((name) => - /^constructive-io-cli-[0-9].*\.tgz$/.test(name) - ), - codegen: archives.filter((name) => - /^constructive-io-graphql-codegen-[0-9].*\.tgz$/.test(name) - ), - graphqlExplorer: archives.filter((name) => - /^constructive-io-graphql-explorer-[0-9].*\.tgz$/.test(name) - ), - logger: archives.filter((name) => - /^pgpmjs-logger-[0-9].*\.tgz$/.test(name) - ), - pgCache: archives.filter((name) => /^pg-cache-[0-9].*\.tgz$/.test(name)), - graphileSettings: archives.filter((name) => - /^graphile-settings-[0-9].*\.tgz$/.test(name) - ), - graphileCache: archives.filter((name) => - /^graphile-cache-[0-9].*\.tgz$/.test(name) - ), - bucketProvisioner: archives.filter((name) => - /^graphile-bucket-provisioner-plugin-[0-9].*\.tgz$/.test(name) - ), - graphileSchema: archives.filter((name) => - /^graphile-schema-[0-9].*\.tgz$/.test(name) - ), - presignedUrl: archives.filter((name) => - /^graphile-presigned-url-plugin-[0-9].*\.tgz$/.test(name) - ), - graphqlQuery: archives.filter((name) => - /^constructive-io-graphql-query-[0-9].*\.tgz$/.test(name) - ), - expressContext: archives.filter((name) => - /^constructive-io-express-context-[0-9].*\.tgz$/.test(name) - ), - serverUtils: archives.filter((name) => - /^pgpmjs-server-utils-[0-9].*\.tgz$/.test(name) - ), - graphqlServer: archives.filter((name) => - /^constructive-io-graphql-server-[0-9].*\.tgz$/.test(name) - ), - jobPg: archives.filter((name) => - /^constructive-io-job-pg-[0-9].*\.tgz$/.test(name) - ), - jobScheduler: archives.filter((name) => - /^constructive-io-job-scheduler-[0-9].*\.tgz$/.test(name) - ), - jobUtils: archives.filter((name) => - /^constructive-io-job-utils-[0-9].*\.tgz$/.test(name) - ), - knativeJobFn: archives.filter((name) => - /^constructive-io-knative-job-fn-[0-9].*\.tgz$/.test(name) - ), - knativeJobService: archives.filter((name) => - /^constructive-io-knative-job-service-[0-9].*\.tgz$/.test(name) - ), - knativeJobWorker: archives.filter((name) => - /^constructive-io-knative-job-worker-[0-9].*\.tgz$/.test(name) - ), - sendEmail: archives.filter((name) => - /^constructive-io-send-email-fn-[0-9].*\.tgz$/.test(name) - ), - sendVerificationLink: archives.filter((name) => - /^constructive-io-send-verification-link-fn-[0-9].*\.tgz$/.test(name) - ), - pgpmEnv: archives.filter((name) => /^pgpmjs-env-[0-9].*\.tgz$/.test(name)), - pgEnv: archives.filter((name) => /^pg-env-[0-9].*\.tgz$/.test(name)), - }; - - const invalidArchives = Object.entries(requiredArchives).filter( - ([, matches]) => matches.length !== 1 - ); - if (invalidArchives.length > 0) { - fail( - `Expected one archive for each CNC release-set package in ${artifactDirectory}; invalid counts: ${invalidArchives.map(([name, matches]) => `${name}=${matches.length}`).join(', ')}; found: ${archives.join(', ') || '(none)'}` - ); - } - - const selectedArchives = - suite === 'full' - ? archives - : [ - ...requiredArchives.runtime, - ...requiredArchives.cli, - ...requiredArchives.logger, - ...requiredArchives.serverUtils, - ...requiredArchives.pgCache, - ...requiredArchives.pgEnv, - ...requiredArchives.pgpmEnv, - ]; - - return { - suite, - installArchives: selectedArchives - .sort() - .map((archive) => join(artifactDirectory, archive)), - }; -}; - const run = (command, args, options = {}) => { const { label, ...spawnOptions } = options; const child = spawnSync(command, args, { @@ -426,7 +274,9 @@ const assertCapabilityUnavailable = (label, child, expectedCommandId) => { }; const main = () => { - const { installArchives, suite } = parseArguments(process.argv.slice(2)); + const { installArchives, suite } = parsePackedArguments( + process.argv.slice(2) + ); const installationDirectory = mkdtempSync( join(tmpdir(), 'cnc-package-acceptance-') );