diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7697789bf2..2d4fe4ca8c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -100,3 +100,10 @@ jobs: - name: Type check example apps run: pnpm --filter './examples/*' run typecheck + + # Backward-compatibility gate: a frozen third-party-style consumer + # (#2035). Unlike the examples it must NOT be migrated to accommodate a + # spec change — a red typecheck here means the spec dropped/narrowed an + # export a published-spec third party already uses. See the package README. + - name: Type check downstream consumer contract + run: pnpm --filter @objectstack/downstream-contract run typecheck diff --git a/packages/downstream-contract/README.md b/packages/downstream-contract/README.md new file mode 100644 index 0000000000..f1df2276fa --- /dev/null +++ b/packages/downstream-contract/README.md @@ -0,0 +1,45 @@ +# @objectstack/downstream-contract + +A **frozen, representative third-party consumer** of `@objectstack/spec`, used as a +backward-compatibility gate. + +## Why this exists + +Every other consumer the framework tests — the example apps, `@objectstack/dogfood` — +lives in this monorepo and **co-evolves with the spec in the same commit**. When a spec +change would break them, the same PR just fixes them. So they can never catch a change +that breaks a *real* third party (e.g. [hotcrm](https://github.com/objectstack-ai/hotcrm)) +that is pinned to a **published** release and authors metadata independently. + +This package closes that gap. It is authored exactly the way an external project does — +importing from the package entry points, mixing the builder (`ObjectSchema.create`), +the `defineX` factories, and **bare metadata literals** (the pattern third parties on +older releases used, see #2035) — and then validates that metadata against the spec. + +## The contract (read before editing) + +These fixtures are **frozen**. The gate is: + +> A spec change that requires editing the fixtures to stay green is, by definition, a +> **breaking change** for third parties. + +So if a change here turns red: + +- **Do not** edit the fixtures to make it pass. That hides the very break this package + exists to surface. +- Treat it as a deliberate decision: either revert/adjust the spec change to stay + backward compatible, or — if the break is intended — bump `@objectstack/spec` to a new + **major** and document the migration, *then* update the fixtures in the same PR. + +## What it checks + +- `pnpm --filter @objectstack/downstream-contract typecheck` — the fixtures are typed + with real spec types (`ActionInput`, `ReportInput`, `PageInput`, …). A removed or + narrowed export fails here (the #2023 class of break). +- `pnpm --filter @objectstack/downstream-contract test` — runs each bare-literal fixture + through its schema's `.parse()` and assembles everything via `defineStack` (schema + + cross-reference validation — the heart of `objectstack validate`). + +For the live counterpart, the release pipeline can additionally run hotcrm's own +`validate && typecheck && build` against the unreleased spec; this package is the fast, +deterministic, in-CI floor. diff --git a/packages/downstream-contract/package.json b/packages/downstream-contract/package.json new file mode 100644 index 0000000000..eeefcaa674 --- /dev/null +++ b/packages/downstream-contract/package.json @@ -0,0 +1,19 @@ +{ + "name": "@objectstack/downstream-contract", + "version": "0.0.0", + "description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/downstream-contract/src/account.object.ts b/packages/downstream-contract/src/account.object.ts new file mode 100644 index 0000000000..1472b65570 --- /dev/null +++ b/packages/downstream-contract/src/account.object.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// Builder authoring path (object/field already have a good entry point). +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Account = ObjectSchema.create({ + name: 'dc_account', + label: 'Account', + pluralLabel: 'Accounts', + icon: 'building', + description: 'Downstream-contract account object (builder authoring path).', + fields: { + name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 255 }), + stage: Field.select({ + label: 'Stage', + options: [ + { label: 'Prospect', value: 'prospect', default: true }, + { label: 'Customer', value: 'customer' }, + { label: 'Churned', value: 'churned' }, + ], + }), + }, +}); diff --git a/packages/downstream-contract/src/account.view.ts b/packages/downstream-contract/src/account.view.ts new file mode 100644 index 0000000000..961b6d9eba --- /dev/null +++ b/packages/downstream-contract/src/account.view.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// Existing-factory authoring path. +import { defineView } from '@objectstack/spec/ui'; + +const data = { provider: 'object' as const, object: 'dc_account' }; + +export const AccountViews = defineView({ + list: { + label: 'All Accounts', + type: 'grid', + data, + columns: [{ field: 'name' }, { field: 'stage' }], + }, +}); diff --git a/packages/downstream-contract/src/log-call.action.ts b/packages/downstream-contract/src/log-call.action.ts new file mode 100644 index 0000000000..6bc38f3be6 --- /dev/null +++ b/packages/downstream-contract/src/log-call.action.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// FROZEN bare-literal fixture — authored WITHOUT the factory, the way a third +// party on an older spec did (#2035). No author-time `.parse()` runs here; only +// the contract test's schema parse validates it. DO NOT migrate this to the +// factory — that would hide the backward-compat break this file exists to catch. +import type { ActionInput } from '@objectstack/spec/ui'; + +export const LogCallAction: ActionInput = { + name: 'dc_log_call', + label: 'Log Call', + objectName: 'dc_account', + type: 'script', + target: 'logCall', + locations: ['record_header'], + successMessage: 'Call logged.', +}; diff --git a/packages/downstream-contract/src/modern.action.ts b/packages/downstream-contract/src/modern.action.ts new file mode 100644 index 0000000000..12d5b2114e --- /dev/null +++ b/packages/downstream-contract/src/modern.action.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// New-factory authoring path (#2035) — an updated consumer. +import { defineAction } from '@objectstack/spec/ui'; + +export const ArchiveAccountAction = defineAction({ + name: 'dc_archive_account', + label: 'Archive', + objectName: 'dc_account', + type: 'script', + target: 'archiveAccount', + locations: ['record_header'], +}); diff --git a/packages/downstream-contract/src/pipeline.report.ts b/packages/downstream-contract/src/pipeline.report.ts new file mode 100644 index 0000000000..040c37dcab --- /dev/null +++ b/packages/downstream-contract/src/pipeline.report.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// FROZEN bare-literal fixture (see log-call.action.ts). +import type { ReportInput } from '@objectstack/spec/ui'; + +export const AccountsByStageReport: ReportInput = { + name: 'dc_accounts_by_stage', + label: 'Accounts by Stage', + description: 'Account count grouped by stage.', + type: 'summary', + dataset: 'dc_account_metrics', + rows: ['stage'], + values: ['account_count'], +}; diff --git a/packages/downstream-contract/src/stack.ts b/packages/downstream-contract/src/stack.ts new file mode 100644 index 0000000000..0f5a759230 --- /dev/null +++ b/packages/downstream-contract/src/stack.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// Assembles the consumer's metadata exactly as an objectstack.config.ts would, +// so the contract test exercises defineStack's schema parse + cross-reference +// validation (the metadata core of `objectstack validate`). +import { defineStack } from '@objectstack/spec'; +import { Account } from './account.object.js'; +import { AccountViews } from './account.view.js'; +import { LogCallAction } from './log-call.action.js'; +import { ArchiveAccountAction } from './modern.action.js'; + +export const ContractStack = defineStack({ + manifest: { + id: 'com.objectstack.downstream_contract', + namespace: 'dc', + version: '1.0.0', + type: 'app', + name: 'Downstream Contract', + description: 'Frozen third-party consumer gating spec backward compatibility (#2035).', + }, + objects: [Account], + views: [AccountViews], + actions: [LogCallAction, ArchiveAccountAction], +}); diff --git a/packages/downstream-contract/src/welcome.page.ts b/packages/downstream-contract/src/welcome.page.ts new file mode 100644 index 0000000000..6dd419c130 --- /dev/null +++ b/packages/downstream-contract/src/welcome.page.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// FROZEN bare-literal fixture (see log-call.action.ts). Pages were one of the +// 16 domains with no factory before #2035, so real third parties authored them +// exactly like this. +import type { PageInput } from '@objectstack/spec/ui'; + +export const WelcomePage: PageInput = { + name: 'dc_welcome', + label: 'Welcome', + type: 'home', + kind: 'full', + template: 'default', + regions: [ + { + name: 'main', + width: 'full', + components: [ + { + type: 'page:header', + properties: { title: 'Welcome', subtitle: 'Downstream contract page.' }, + }, + ], + }, + ], +}; diff --git a/packages/downstream-contract/test/contract.test.ts b/packages/downstream-contract/test/contract.test.ts new file mode 100644 index 0000000000..cde669ad18 --- /dev/null +++ b/packages/downstream-contract/test/contract.test.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ActionSchema, ReportSchema, PageSchema } from '@objectstack/spec/ui'; +import { LogCallAction } from '../src/log-call.action.js'; +import { AccountsByStageReport } from '../src/pipeline.report.js'; +import { WelcomePage } from '../src/welcome.page.js'; +import { ContractStack } from '../src/stack.js'; + +// FROZEN — see README. A failure means a spec change dropped or narrowed a +// property a published-spec third party already uses. That is breaking: adjust +// the spec or bump major; do NOT edit the fixtures to make this pass. +describe('downstream consumer contract (#2035)', () => { + it('bare-literal third-party metadata still parses against the current spec', () => { + expect(() => ActionSchema.parse(LogCallAction)).not.toThrow(); + expect(() => ReportSchema.parse(AccountsByStageReport)).not.toThrow(); + expect(() => PageSchema.parse(WelcomePage)).not.toThrow(); + }); + + it('assembles into a stack — schema + cross-reference validation passes', () => { + expect(ContractStack).toBeDefined(); + expect(ContractStack.objects).toHaveLength(1); + expect(ContractStack.actions).toHaveLength(2); + }); +}); diff --git a/packages/downstream-contract/tsconfig.json b/packages/downstream-contract/tsconfig.json new file mode 100644 index 0000000000..2cf6eda84d --- /dev/null +++ b/packages/downstream-contract/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a496ed9f..ffaccc6624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -856,6 +856,19 @@ importers: specifier: ^4.1.9 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.2)(msw@2.14.6(@types/node@25.9.3)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/downstream-contract: + dependencies: + '@objectstack/spec': + specifier: workspace:* + version: link:../spec + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.2)(msw@2.14.6(@types/node@25.9.3)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/formula: dependencies: '@marcbachmann/cel-js':