From f1ba76d65bdc123ec9cd72a91870d2db5bd7a4c9 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 13 Mar 2026 18:58:08 -0600 Subject: [PATCH] add domain primitives --- .../src/core/app-module/app-module.test.ts | 182 ++++++++++++++++++ .../cli-kit/src/core/app-module/app-module.ts | 112 +++++++++++ .../src/core/contract/contract.test.ts | 157 +++++++++++++++ .../cli-kit/src/core/contract/contract.ts | 126 ++++++++++++ .../module-specification.test.ts | 46 +++++ .../module-specification.ts | 35 ++++ .../specification-catalog.test.ts | 178 +++++++++++++++++ .../specification-catalog.ts | 128 ++++++++++++ 8 files changed, 964 insertions(+) create mode 100644 packages/cli-kit/src/core/app-module/app-module.test.ts create mode 100644 packages/cli-kit/src/core/app-module/app-module.ts create mode 100644 packages/cli-kit/src/core/contract/contract.test.ts create mode 100644 packages/cli-kit/src/core/contract/contract.ts create mode 100644 packages/cli-kit/src/core/module-specification/module-specification.test.ts create mode 100644 packages/cli-kit/src/core/module-specification/module-specification.ts create mode 100644 packages/cli-kit/src/core/specification-catalog/specification-catalog.test.ts create mode 100644 packages/cli-kit/src/core/specification-catalog/specification-catalog.ts diff --git a/packages/cli-kit/src/core/app-module/app-module.test.ts b/packages/cli-kit/src/core/app-module/app-module.test.ts new file mode 100644 index 0000000000..1460eb9521 --- /dev/null +++ b/packages/cli-kit/src/core/app-module/app-module.test.ts @@ -0,0 +1,182 @@ +import {AppModule, contentHashIdentity} from './app-module.js' +import {ModuleSpecification} from '../module-specification/module-specification.js' +import {Contract} from '../contract/contract.js' +import {describe, expect, test} from 'vitest' + +function specWithContract( + contract?: Contract, + overrides: Partial[0]> = {}, +): ModuleSpecification { + return new ModuleSpecification({ + identifier: 'test_spec', + name: 'Test', + externalIdentifier: 'test_spec_external', + contract, + appModuleLimit: 1, + uidIsClientProvided: false, + features: [], + ...overrides, + }) +} + +describe('AppModule', () => { + describe('construction', () => { + test('deep copies config', () => { + const config = {name: 'hello', nested: {value: 42}} + const mod = new AppModule({ + spec: specWithContract(), + config, + sourcePath: '/tmp/shopify.app.toml', + }) + + config.name = 'mutated' + ;(config.nested as {value: number}).value = 0 + + expect(mod.config.name).toBe('hello') + expect((mod.config.nested as {value: number}).value).toBe(42) + }) + + test('preserves sourcePath', () => { + const mod = new AppModule({ + spec: specWithContract(), + config: {name: 'test'}, + sourcePath: '/tmp/extensions/my-ext/shopify.extension.toml', + }) + expect(mod.sourcePath).toBe('/tmp/extensions/my-ext/shopify.extension.toml') + }) + + test('preserves directory and entryPath when provided', () => { + const mod = new AppModule({ + spec: specWithContract(), + config: {}, + sourcePath: '/tmp/ext.toml', + directory: '/tmp/extensions/my-ext', + entryPath: '/tmp/extensions/my-ext/src/index.ts', + }) + expect(mod.directory).toBe('/tmp/extensions/my-ext') + expect(mod.entryPath).toBe('/tmp/extensions/my-ext/src/index.ts') + }) + }) + + describe('validation state', () => { + test('starts as unvalidated', () => { + const mod = new AppModule({ + spec: specWithContract(), + config: {}, + sourcePath: '/tmp/app.toml', + }) + expect(mod.isUnvalidated).toBe(true) + expect(mod.isValid).toBe(false) + expect(mod.isInvalid).toBe(false) + expect(mod.errors).toHaveLength(0) + }) + + test('transitions to valid when contract passes', async () => { + const contract = await Contract.fromJsonSchema( + JSON.stringify({type: 'object', properties: {name: {type: 'string'}}}), + ) + const mod = new AppModule({ + spec: specWithContract(contract), + config: {name: 'hello'}, + sourcePath: '/tmp/app.toml', + }) + + const state = mod.validate() + expect(state.status).toBe('valid') + expect(mod.isValid).toBe(true) + expect(mod.isInvalid).toBe(false) + }) + + test('transitions to invalid when contract fails', async () => { + const contract = await Contract.fromJsonSchema( + JSON.stringify({ + type: 'object', + properties: {name: {type: 'string'}}, + required: ['name'], + additionalProperties: false, + }), + ) + const mod = new AppModule({ + spec: specWithContract(contract), + config: {wrong_field: 'oops'}, + sourcePath: '/tmp/app.toml', + }) + + const state = mod.validate() + expect(state.status).toBe('invalid') + expect(mod.isInvalid).toBe(true) + expect(mod.errors.length).toBeGreaterThan(0) + }) + + test('is valid when spec has no contract', () => { + const mod = new AppModule({ + spec: specWithContract(undefined), + config: {anything: 'goes'}, + sourcePath: '/tmp/app.toml', + }) + + mod.validate() + expect(mod.isValid).toBe(true) + }) + + test('second validate call returns same state (idempotent)', async () => { + const contract = await Contract.fromJsonSchema( + JSON.stringify({type: 'object', properties: {name: {type: 'string'}}}), + ) + const mod = new AppModule({ + spec: specWithContract(contract), + config: {name: 'hello'}, + sourcePath: '/tmp/app.toml', + }) + + const state1 = mod.validate() + const state2 = mod.validate() + expect(state1).toBe(state2) + }) + }) + + describe('identity', () => { + test('uses fixed identity when uidIsClientProvided is false', () => { + const mod = new AppModule({ + spec: specWithContract(undefined, {identifier: 'app_home', uidIsClientProvided: false}), + config: {name: 'My App'}, + sourcePath: '/tmp/app.toml', + }) + expect(mod.handle).toBe('app_home') + expect(mod.uid).toBe('app_home') + }) + + test('uses config-derived identity when uidIsClientProvided is true', () => { + const mod = new AppModule({ + spec: specWithContract(undefined, {identifier: 'function', uidIsClientProvided: true}), + config: {handle: 'my-func', uid: 'custom-uid', name: 'My Function'}, + sourcePath: '/tmp/ext.toml', + }) + expect(mod.handle).toBe('my-func') + expect(mod.uid).toBe('custom-uid') + }) + + test('uses explicit identity override when provided', () => { + const mod = new AppModule({ + spec: specWithContract(undefined, {identifier: 'webhook_subscription'}), + config: {topic: 'products/create', uri: '/webhooks', filter: ''}, + sourcePath: '/tmp/app.toml', + identity: contentHashIdentity(['topic', 'uri', 'filter']), + }) + // Handle is a hash of the content fields + expect(mod.handle).toBeTruthy() + expect(mod.handle).not.toBe('webhook_subscription') + // UID is the joined content fields + expect(mod.uid).toBe('products/create::/webhooks::') + }) + + test('type is always the spec identifier', () => { + const mod = new AppModule({ + spec: specWithContract(undefined, {identifier: 'branding'}), + config: {}, + sourcePath: '/tmp/app.toml', + }) + expect(mod.type).toBe('branding') + }) + }) +}) diff --git a/packages/cli-kit/src/core/app-module/app-module.ts b/packages/cli-kit/src/core/app-module/app-module.ts new file mode 100644 index 0000000000..2e0dd07c72 --- /dev/null +++ b/packages/cli-kit/src/core/app-module/app-module.ts @@ -0,0 +1,112 @@ +import {ModuleSpecification} from '../module-specification/module-specification.js' +import {ValidationError} from '../contract/contract.js' +import {JsonMapType} from '../../public/node/toml/codec.js' +import {slugify} from '../../public/common/string.js' +import {hashString, nonRandomUUID} from '../../public/node/crypto.js' + +export type ValidationState = + | {status: 'unvalidated'} + | {status: 'valid'} + | {status: 'invalid'; errors: ValidationError[]} + +/** + * How a module resolves its handle and uid. + */ +export interface ModuleIdentity { + resolveHandle(config: JsonMapType): string + resolveUid(config: JsonMapType, handle: string): string +} + +export const fixedIdentity = (id: string): ModuleIdentity => ({ + resolveHandle: () => id, + resolveUid: () => id, +}) + +export const configDerivedIdentity: ModuleIdentity = { + resolveHandle: (config) => (config.handle as string) ?? slugify(config.name as string), + resolveUid: (config, handle) => (config.uid as string) ?? nonRandomUUID(handle), +} + +export const contentHashIdentity = (fields: string[]): ModuleIdentity => ({ + resolveHandle: (config) => hashString(fields.map((field) => String(config[field] ?? '')).join(':')), + resolveUid: (config) => fields.map((field) => String(config[field] ?? '')).join('::'), +}) + +/** + * A concrete module instance — a specification paired with actual config data. + * + * Immutable config, one-way validation state. If the underlying file changes, + * the system creates new AppModules — it doesn't update existing ones. + */ +export class AppModule { + readonly spec: ModuleSpecification + readonly config: JsonMapType + readonly sourcePath: string + readonly directory?: string + readonly entryPath?: string + readonly identity: ModuleIdentity + private _state: ValidationState = {status: 'unvalidated'} + + constructor(options: { + spec: ModuleSpecification + config: JsonMapType + sourcePath: string + directory?: string + entryPath?: string + identity?: ModuleIdentity + }) { + this.spec = options.spec + this.config = structuredClone(options.config) + this.sourcePath = options.sourcePath + this.directory = options.directory + this.entryPath = options.entryPath + this.identity = + options.identity ?? + (options.spec.uidIsClientProvided ? configDerivedIdentity : fixedIdentity(options.spec.identifier)) + } + + get state(): ValidationState { + return this._state + } + + get isValid(): boolean { + return this._state.status === 'valid' + } + + get isInvalid(): boolean { + return this._state.status === 'invalid' + } + + get isUnvalidated(): boolean { + return this._state.status === 'unvalidated' + } + + get errors(): ValidationError[] { + return this._state.status === 'invalid' ? this._state.errors : [] + } + + /** + * Validates the config and transitions state. Can only be called once. + * How validation works (contract, schema, etc.) is an implementation detail. + */ + validate(): ValidationState { + if (this._state.status !== 'unvalidated') return this._state + + const errors = this.spec.contract?.validate(this.config) ?? [] + this._state = errors.length === 0 ? {status: 'valid'} : {status: 'invalid', errors} + + return this._state + } + + get handle(): string { + return this.identity.resolveHandle(this.config) + } + + get uid(): string { + return this.identity.resolveUid(this.config, this.handle) + } + + get type(): string { + return this.spec.identifier + } +} diff --git a/packages/cli-kit/src/core/contract/contract.test.ts b/packages/cli-kit/src/core/contract/contract.test.ts new file mode 100644 index 0000000000..ec73833503 --- /dev/null +++ b/packages/cli-kit/src/core/contract/contract.test.ts @@ -0,0 +1,157 @@ +import {Contract} from './contract.js' +import {zod} from '../../public/node/schema.js' +import {describe, expect, test} from 'vitest' +import type {JsonMapType} from '../../public/node/toml/codec.js' + +const SIMPLE_JSON_SCHEMA = JSON.stringify({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + additionalProperties: false, + properties: { + name: {type: 'string'}, + count: {type: 'number'}, + }, + required: ['name'], +}) + +const NESTED_JSON_SCHEMA = JSON.stringify({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + additionalProperties: false, + properties: { + app_url: {type: 'string'}, + embedded: {type: 'boolean'}, + preferences_url: {type: 'string'}, + }, + required: ['app_url', 'embedded'], +}) + +describe('Contract', () => { + describe('fromJsonSchema', () => { + test('validates valid input with no errors', async () => { + const contract = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const errors = contract.validate({name: 'test', count: 42}) + expect(errors).toHaveLength(0) + }) + + test('returns errors for invalid input', async () => { + const contract = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const errors = contract.validate({count: 42}) + expect(errors.length).toBeGreaterThan(0) + }) + + test('rejects additional properties', async () => { + const contract = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const errors = contract.validate({name: 'test', unexpected: true}) + expect(errors.length).toBeGreaterThan(0) + }) + + test('never mutates the input', async () => { + const contract = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const input = {name: 'test', count: 42} + const inputCopy = structuredClone(input) + contract.validate(input) + expect(input).toStrictEqual(inputCopy) + }) + + test('exposes properties from the schema', async () => { + const contract = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + expect(Object.keys(contract.properties)).toStrictEqual(['name', 'count']) + }) + + test('exposes required keys', async () => { + const contract = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + expect(contract.required).toStrictEqual(['name']) + }) + }) + + describe('fromLocalSchema', () => { + test('validates valid input', () => { + const schema = zod.object({ + name: zod.string(), + value: zod.number().optional(), + }) + const contract = Contract.fromLocalSchema(schema) + const errors = contract.validate({name: 'hello'}) + expect(errors).toHaveLength(0) + }) + + test('returns errors for invalid input', () => { + const schema = zod.object({name: zod.string()}) + const contract = Contract.fromLocalSchema(schema) + const errors = contract.validate({name: 123}) + expect(errors.length).toBeGreaterThan(0) + }) + }) + + describe('withAdapter', () => { + test('transforms before validating', () => { + const serverSchema = zod.object({ + app_url: zod.string(), + embedded: zod.boolean(), + }) + const transform = (config: JsonMapType): JsonMapType => ({ + app_url: config.application_url as string, + embedded: config.embedded as boolean, + }) + const contract = Contract.withAdapter({schema: serverSchema, transform}) + + const errors = contract.validate({application_url: 'https://example.com', embedded: true}) + expect(errors).toHaveLength(0) + }) + + test('returns errors when transformed data is invalid', () => { + const serverSchema = zod.object({ + app_url: zod.string().url(), + }) + const transform = (config: JsonMapType): JsonMapType => ({ + app_url: config.application_url as string, + }) + const contract = Contract.withAdapter({schema: serverSchema, transform}) + + const errors = contract.validate({application_url: 'not-a-url'}) + expect(errors.length).toBeGreaterThan(0) + }) + + test('never mutates the input', () => { + const schema = zod.object({val: zod.string()}) + const transform = (config: JsonMapType): JsonMapType => ({val: config.source as string}) + const contract = Contract.withAdapter({schema, transform}) + + const input = {source: 'hello'} + const inputCopy = structuredClone(input) + contract.validate(input) + expect(input).toStrictEqual(inputCopy) + }) + }) + + describe('compose', () => { + test('collects errors from all contracts', async () => { + const contract1 = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const contract2 = Contract.fromLocalSchema(zod.object({name: zod.string().min(10)})) + + const composed = Contract.compose(contract1, contract2) + const errors = composed.validate({name: 'hi'}) + expect(errors.length).toBeGreaterThan(0) + }) + + test('returns no errors when all contracts pass', async () => { + const contract1 = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const contract2 = Contract.fromLocalSchema(zod.object({name: zod.string()})) + + const composed = Contract.compose(contract1, contract2) + const errors = composed.validate({name: 'hello'}) + expect(errors).toHaveLength(0) + }) + + test('merges properties from all contracts', async () => { + const contract1 = await Contract.fromJsonSchema(SIMPLE_JSON_SCHEMA) + const contract2 = await Contract.fromJsonSchema(NESTED_JSON_SCHEMA) + + const composed = Contract.compose(contract1, contract2) + const propKeys = Object.keys(composed.properties) + expect(propKeys).toContain('name') + expect(propKeys).toContain('app_url') + }) + }) +}) diff --git a/packages/cli-kit/src/core/contract/contract.ts b/packages/cli-kit/src/core/contract/contract.ts new file mode 100644 index 0000000000..b840a4b319 --- /dev/null +++ b/packages/cli-kit/src/core/contract/contract.ts @@ -0,0 +1,126 @@ +import {normaliseJsonSchema, jsonSchemaValidate} from '../../public/node/json-schema.js' +import {JsonMapType} from '../../public/node/toml/codec.js' +import type {ZodType} from 'zod' + +export interface ValidationError { + path: string[] + message: string +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type JsonSchemaProperties = Record + +/** + * A contract validates module config. + * + * From the outside, it's always `validate(config) → errors[]`. + * How it validates internally — JSON Schema, Zod, with or without + * a transform step — is an implementation detail. + */ +export class Contract { + /** + * Golden path: JSON Schema from the platform. + * Validates directly — file shape = server shape. + */ + static async fromJsonSchema(raw: string): Promise { + const schema = await normaliseJsonSchema(raw) + return new Contract({ + validate: (input) => { + const result = jsonSchemaValidate(structuredClone(input), schema, 'fail') + if (result.state === 'ok') return [] + return (result.errors ?? []).map((err) => ({ + path: (err.path ?? []).map(String), + message: err.message ?? 'Validation error', + })) + }, + properties: (schema.properties ?? {}) as JsonSchemaProperties, + required: (schema.required ?? []) as string[], + }) + } + + /** + * Transitional: adapter wrapping a sync transform + Zod schema. + * + * Accepts file-shape config. Internally transforms to server shape, + * then validates the server shape. The transform is contained — nothing leaks. + * + * The transform MUST be synchronous. Specs with async transforms + * (e.g., function reads files from disk) should use `fromLocalSchema` instead. + */ + static withAdapter(options: {schema: ZodType; transform: (config: JsonMapType) => JsonMapType}): Contract { + return new Contract({ + validate: (fileConfig) => { + const serverShape = options.transform(structuredClone(fileConfig)) + const result = options.schema.safeParse(serverShape) + if (result.success) return [] + return result.error.issues.map((issue) => ({ + path: issue.path.map(String), + message: issue.message, + })) + }, + properties: {}, + required: [], + }) + } + + /** + * Transitional: local Zod schema without transform. + * + * Validates file-shape config directly against the Zod schema. + * Used for specs with async transforms that can't use `withAdapter`. + */ + static fromLocalSchema(schema: ZodType): Contract { + return new Contract({ + validate: (config) => { + const result = schema.safeParse(config) + if (result.success) return [] + return result.error.issues.map((issue) => ({ + path: issue.path.map(String), + message: issue.message, + })) + }, + properties: {}, + required: [], + }) + } + + /** + * Compose: run multiple validations, collect all errors. + * + * Useful during transition when both a local schema and a platform + * contract exist for the same module. + */ + static compose(...contracts: Contract[]): Contract { + return new Contract({ + validate: (input) => contracts.flatMap((ct) => ct.validate(input)), + properties: Object.assign({}, ...contracts.map((ct) => ct.properties)), + required: [...new Set(contracts.flatMap((ct) => ct.required))], + }) + } + + private readonly validateFn: (input: JsonMapType) => ValidationError[] + private readonly _properties: JsonSchemaProperties + private readonly _required: string[] + + private constructor(options: { + validate: (input: JsonMapType) => ValidationError[] + properties: JsonSchemaProperties + required: string[] + }) { + this.validateFn = options.validate + this._properties = options.properties + this._required = options.required + } + + validate(input: JsonMapType): ValidationError[] { + return this.validateFn(input) + } + + get properties(): JsonSchemaProperties { + return this._properties + } + + get required(): string[] { + return this._required + } +} diff --git a/packages/cli-kit/src/core/module-specification/module-specification.test.ts b/packages/cli-kit/src/core/module-specification/module-specification.test.ts new file mode 100644 index 0000000000..b338054894 --- /dev/null +++ b/packages/cli-kit/src/core/module-specification/module-specification.test.ts @@ -0,0 +1,46 @@ +import {ModuleSpecification} from './module-specification.js' +import {Contract} from '../contract/contract.js' +import {describe, expect, test} from 'vitest' + +describe('ModuleSpecification', () => { + test('stores all provided fields as readonly', () => { + const spec = new ModuleSpecification({ + identifier: 'app_home', + name: 'App home', + externalIdentifier: 'app_home_external', + appModuleLimit: 1, + uidIsClientProvided: false, + features: ['argo'], + }) + + expect(spec.identifier).toBe('app_home') + expect(spec.name).toBe('App home') + expect(spec.externalIdentifier).toBe('app_home_external') + expect(spec.appModuleLimit).toBe(1) + expect(spec.uidIsClientProvided).toBe(false) + expect(spec.features).toStrictEqual(['argo']) + expect(spec.contract).toBeUndefined() + }) + + test('stores a contract when provided', async () => { + const contract = await Contract.fromJsonSchema( + JSON.stringify({ + type: 'object', + properties: {name: {type: 'string'}}, + }), + ) + + const spec = new ModuleSpecification({ + identifier: 'branding', + name: 'Branding', + externalIdentifier: 'branding_external', + contract, + appModuleLimit: 1, + uidIsClientProvided: false, + features: [], + }) + + expect(spec.contract).toBe(contract) + expect(spec.contract!.validate({name: 'My App'})).toHaveLength(0) + }) +}) diff --git a/packages/cli-kit/src/core/module-specification/module-specification.ts b/packages/cli-kit/src/core/module-specification/module-specification.ts new file mode 100644 index 0000000000..53897c7b07 --- /dev/null +++ b/packages/cli-kit/src/core/module-specification/module-specification.ts @@ -0,0 +1,35 @@ +import {Contract} from '../contract/contract.js' + +/** + * The platform's definition of a module type. + * + * Constructed from the server payload. Knows nothing about TOML files, + * transforms, or local CLI code. + */ +export class ModuleSpecification { + readonly identifier: string + readonly name: string + readonly externalIdentifier: string + readonly contract?: Contract + readonly appModuleLimit: number + readonly uidIsClientProvided: boolean + readonly features: string[] + + constructor(options: { + identifier: string + name: string + externalIdentifier: string + contract?: Contract + appModuleLimit: number + uidIsClientProvided: boolean + features: string[] + }) { + this.identifier = options.identifier + this.name = options.name + this.externalIdentifier = options.externalIdentifier + this.contract = options.contract + this.appModuleLimit = options.appModuleLimit + this.uidIsClientProvided = options.uidIsClientProvided + this.features = options.features + } +} diff --git a/packages/cli-kit/src/core/specification-catalog/specification-catalog.test.ts b/packages/cli-kit/src/core/specification-catalog/specification-catalog.test.ts new file mode 100644 index 0000000000..d75aced535 --- /dev/null +++ b/packages/cli-kit/src/core/specification-catalog/specification-catalog.test.ts @@ -0,0 +1,178 @@ +import {SpecificationCatalog, RemoteSpecInput} from './specification-catalog.js' +import {zod} from '../../public/node/schema.js' +import {describe, expect, test} from 'vitest' + +function remoteSpec(overrides: Partial = {}): RemoteSpecInput { + return { + identifier: 'test_spec', + name: 'Test Spec', + externalIdentifier: 'test_spec_external', + experience: 'extension', + options: { + registrationLimit: 1, + uidIsClientProvided: false, + }, + ...overrides, + } +} + +const SIMPLE_JSON_SCHEMA = JSON.stringify({ + type: 'object', + properties: { + name: {type: 'string'}, + }, + required: ['name'], +}) + +describe('SpecificationCatalog', () => { + describe('build', () => { + test('creates specs from remote specs', async () => { + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [ + remoteSpec({identifier: 'app_home', name: 'App Home'}), + remoteSpec({identifier: 'branding', name: 'Branding'}), + ], + }) + + expect(catalog.all()).toHaveLength(2) + expect(catalog.get('app_home')?.name).toBe('App Home') + expect(catalog.get('branding')?.name).toBe('Branding') + }) + + test('filters out deprecated specs', async () => { + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [ + remoteSpec({identifier: 'active', experience: 'extension'}), + remoteSpec({identifier: 'old', experience: 'deprecated'}), + ], + }) + + expect(catalog.all()).toHaveLength(1) + expect(catalog.get('active')).toBeDefined() + expect(catalog.get('old')).toBeUndefined() + }) + + test('builds server contract from validationSchema', async () => { + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [ + remoteSpec({ + identifier: 'channel_config', + validationSchema: {jsonSchema: SIMPLE_JSON_SCHEMA}, + }), + ], + }) + + const spec = catalog.get('channel_config')! + expect(spec.contract).toBeDefined() + expect(spec.contract!.validate({name: 'hello'})).toHaveLength(0) + expect(spec.contract!.validate({}).length).toBeGreaterThan(0) + }) + + test('builds adapter contract from local schema only (no transform)', async () => { + const localSchema = zod.object({application_url: zod.string().url()}) + + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [remoteSpec({identifier: 'app_home'})], + localSchemas: {app_home: localSchema}, + }) + + const spec = catalog.get('app_home')! + expect(spec.contract).toBeDefined() + expect(spec.contract!.validate({application_url: 'https://example.com'})).toHaveLength(0) + expect(spec.contract!.validate({application_url: 'not-a-url'}).length).toBeGreaterThan(0) + }) + + test('builds adapter contract from local schema + sync transform', async () => { + const serverSchema = zod.object({app_url: zod.string().url()}) + const transform = (config: Record) => ({ + app_url: config.application_url as string, + }) + + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [remoteSpec({identifier: 'app_home'})], + localSchemas: {app_home: serverSchema}, + syncTransforms: {app_home: transform}, + }) + + const spec = catalog.get('app_home')! + expect(spec.contract).toBeDefined() + // Validates file-shape input by transforming internally + expect(spec.contract!.validate({application_url: 'https://example.com'})).toHaveLength(0) + expect(spec.contract!.validate({application_url: 'not-a-url'}).length).toBeGreaterThan(0) + }) + + test('composes server contract with adapter contract when both exist', async () => { + const localSchema = zod.object({name: zod.string().min(3)}) + + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [ + remoteSpec({ + identifier: 'branding', + validationSchema: {jsonSchema: SIMPLE_JSON_SCHEMA}, + }), + ], + localSchemas: {branding: localSchema}, + }) + + const spec = catalog.get('branding')! + // 'hi' passes JSON Schema (it's a string) but fails Zod (min 3) + const errors = spec.contract!.validate({name: 'hi'}) + expect(errors.length).toBeGreaterThan(0) + }) + + test('includes local-only specs not in remote', async () => { + const localSchema = zod.object({enabled: zod.boolean()}) + + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [remoteSpec({identifier: 'remote_spec'})], + localSchemas: {gated_spec: localSchema}, + localOnlyIdentifiers: ['gated_spec'], + }) + + expect(catalog.all()).toHaveLength(2) + expect(catalog.get('gated_spec')).toBeDefined() + expect(catalog.get('gated_spec')!.contract).toBeDefined() + }) + + test('does not duplicate specs in both remote and localOnlyIdentifiers', async () => { + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [remoteSpec({identifier: 'app_home'})], + localOnlyIdentifiers: ['app_home'], + }) + + expect(catalog.all()).toHaveLength(1) + }) + + test('maps remote fields correctly', async () => { + const catalog = await SpecificationCatalog.build({ + remoteSpecs: [ + remoteSpec({ + identifier: 'function', + name: 'Function', + externalIdentifier: 'function_external', + options: {registrationLimit: 50, uidIsClientProvided: true}, + }), + ], + }) + + const spec = catalog.get('function')! + expect(spec.identifier).toBe('function') + expect(spec.name).toBe('Function') + expect(spec.externalIdentifier).toBe('function_external') + expect(spec.appModuleLimit).toBe(50) + expect(spec.uidIsClientProvided).toBe(true) + }) + }) + + describe('lookup', () => { + test('get returns undefined for unknown identifier', async () => { + const catalog = await SpecificationCatalog.build({remoteSpecs: []}) + expect(catalog.get('nonexistent')).toBeUndefined() + }) + + test('all returns empty array when no specs', async () => { + const catalog = await SpecificationCatalog.build({remoteSpecs: []}) + expect(catalog.all()).toHaveLength(0) + }) + }) +}) diff --git a/packages/cli-kit/src/core/specification-catalog/specification-catalog.ts b/packages/cli-kit/src/core/specification-catalog/specification-catalog.ts new file mode 100644 index 0000000000..404844a29d --- /dev/null +++ b/packages/cli-kit/src/core/specification-catalog/specification-catalog.ts @@ -0,0 +1,128 @@ +import {Contract} from '../contract/contract.js' +import {ModuleSpecification} from '../module-specification/module-specification.js' +import {JsonMapType} from '../../public/node/toml/codec.js' +import type {ZodType} from 'zod' + +/** + * The subset of a remote specification that the catalog needs. + * Callers map their platform-specific types to this shape. + */ +export interface RemoteSpecInput { + identifier: string + name: string + externalName?: string + externalIdentifier: string + experience: 'extension' | 'configuration' | 'deprecated' + options: { + registrationLimit: number + uidIsClientProvided: boolean + } + validationSchema?: { + jsonSchema: string + } | null + features?: unknown +} + +/** + * Lookup table over the server's module types. + * + * Built from remote specs + optional local adapter schemas/transforms. + * Contracts are assembled during construction from available sources. + */ +export class SpecificationCatalog { + static async build(options: { + remoteSpecs: RemoteSpecInput[] + /** Zod schemas for transitional local validation, keyed by spec identifier. */ + localSchemas?: Record + /** Sync-only forward transforms for validation adapter contracts. */ + syncTransforms?: Record JsonMapType> + /** Identifiers of specs that exist locally but may not be in remoteSpecs. */ + localOnlyIdentifiers?: string[] + }): Promise { + const {remoteSpecs, localSchemas = {}, syncTransforms = {}, localOnlyIdentifiers = []} = options + const specs: ModuleSpecification[] = [] + const seenIdentifiers = new Set() + + const contractPromises = remoteSpecs + .filter((remote) => remote.experience !== 'deprecated') + .map(async (remote) => { + const serverContract = remote.validationSchema?.jsonSchema + ? await Contract.fromJsonSchema(remote.validationSchema.jsonSchema) + : undefined + return {remote, serverContract} + }) + + const resolved = await Promise.all(contractPromises) + + for (const {remote, serverContract} of resolved) { + seenIdentifiers.add(remote.identifier) + + const localSchema = localSchemas[remote.identifier] + const syncTransform = syncTransforms[remote.identifier] + + let adapterContract: Contract | undefined + if (localSchema && syncTransform) { + adapterContract = Contract.withAdapter({schema: localSchema, transform: syncTransform}) + } else if (localSchema) { + adapterContract = Contract.fromLocalSchema(localSchema) + } + + const contract = + serverContract && adapterContract + ? Contract.compose(serverContract, adapterContract) + : (serverContract ?? adapterContract) + + specs.push( + new ModuleSpecification({ + identifier: remote.identifier, + name: remote.name ?? remote.externalName ?? remote.identifier, + externalIdentifier: remote.externalIdentifier, + contract, + appModuleLimit: remote.options.registrationLimit, + uidIsClientProvided: remote.options.uidIsClientProvided, + features: normalizeFeatures(remote), + }), + ) + } + + for (const identifier of localOnlyIdentifiers) { + if (seenIdentifiers.has(identifier)) continue + const localSchema = localSchemas[identifier] + specs.push( + new ModuleSpecification({ + identifier, + name: identifier, + externalIdentifier: identifier, + contract: localSchema ? Contract.fromLocalSchema(localSchema) : undefined, + appModuleLimit: 1, + uidIsClientProvided: false, + features: [], + }), + ) + } + + return new SpecificationCatalog(specs) + } + + private readonly byIdentifier: Map + + private constructor(specs: ModuleSpecification[]) { + this.byIdentifier = new Map(specs.map((spec) => [spec.identifier, spec])) + } + + get(identifier: string): ModuleSpecification | undefined { + return this.byIdentifier.get(identifier) + } + + all(): ModuleSpecification[] { + return [...this.byIdentifier.values()] + } +} + +function normalizeFeatures(remote: RemoteSpecInput): string[] { + const features = remote.features + if (features && typeof features === 'object' && 'argo' in features && features.argo) { + return ['argo'] + } + return [] +}