Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@objectstack/metadata-core",
"@objectstack/metadata-fs",
"@objectstack/objectql",
"@objectstack/metadata-protocol",
"@objectstack/observability",
"@objectstack/formula",
"@objectstack/lint",
Expand Down
16 changes: 16 additions & 0 deletions .changeset/extract-metadata-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
---

Extract metadata management into `@objectstack/metadata-protocol` (ADR-0076)

`protocol.ts` (the `ObjectStackProtocol` implementation — sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics) plus its `sys-metadata-repository`, `metadata-diagnostics`, `seed-loader`, and `build-probes` helpers were metadata-domain code that lived inside `@objectstack/objectql` for historical reasons. They now live in a dedicated **`@objectstack/metadata-protocol`** package.

The protocol no longer depends on the concrete `ObjectQL` class — it is typed against an injected `MetadataHostEngine` interface (the engine is still injected at runtime). Dependency direction is now one-way (`objectql → metadata-protocol`); there is no cycle.

**Non-breaking**: `@objectstack/objectql` re-exports every previously public symbol (`ObjectStackProtocolImplementation`, `SysMetadataRepository`, `SysMetadataEngine`, `SeedLoaderService`, `runBuildProbes`, …), so existing imports keep working.

This is Step 1 of ADR-0076. A later step turns the protocol into a capability plugin so `objectql` itself stops depending on it (making the engine lean by construction).

Also adds a lean **`@objectstack/objectql/core`** entry — the engine/registry/hooks/validation surface only, with no kernel plugin or metadata protocol — so a thin embedder can import just the engine and never pull `@objectstack/metadata-protocol` into its bundle. A boundary ratchet test guards the entry.
7 changes: 7 additions & 0 deletions packages/metadata-protocol/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @objectstack/metadata-protocol

The ObjectStack metadata-management protocol, extracted from `@objectstack/objectql` per [ADR-0076](../../docs/adr/0076-objectql-core-tiering.md).

Implements `ObjectStackProtocol`: `sys_metadata` CRUD, draft/publish lifecycle, write locks, package ownership/installation, metadata diagnostics, seed application, and build probes.

It uses a data engine purely as storage + schema registry, injected at runtime via the `MetadataHostEngine` interface — so this package does **not** depend on `@objectstack/objectql`.
61 changes: 61 additions & 0 deletions packages/metadata-protocol/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@objectstack/metadata-protocol",
"version": "11.0.0",
"license": "Apache-2.0",
"description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"objectstack",
"metadata",
"protocol",
"sys-metadata"
],
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^26.0.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
"author": "ObjectStack",
"repository": {
"type": "git",
"url": "https://github.com/objectstack-ai/framework.git",
"directory": "packages/metadata-protocol"
},
"homepage": "https://objectstack.ai/docs",
"bugs": "https://github.com/objectstack-ai/framework/issues",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=18.0.0"
}
}
25 changes: 25 additions & 0 deletions packages/metadata-protocol/src/host-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { IDataEngine } from '@objectstack/core';

/**
* The engine surface the metadata protocol needs from its host (ADR-0076).
*
* The protocol uses the data engine purely as storage + a schema registry; it
* is injected a concrete engine at runtime (today `@objectstack/objectql`'s
* `ObjectQL`). Typing against this interface — instead of the concrete class —
* is what lets `@objectstack/metadata-protocol` avoid a dependency on
* `@objectstack/objectql` (which would otherwise form a cycle, since the
* ObjectQL plugin constructs the protocol).
*/
export interface MetadataHostEngine extends IDataEngine {
/** Schema registry (listItems/getItem/registerItem/getObject/registerObject/installPackage/...). */
registry: any;
/** DDL: create/sync the physical table for an object schema. */
syncObjectSchema(...args: any[]): Promise<any>;
/** DDL: drop the physical table for an object schema. */
dropObjectSchema(...args: any[]): Promise<any>;
// Protocol accesses additional engine members structurally; keep it permissive
// for this relocation (behavior unchanged — the concrete engine is injected).
[key: string]: any;
}
47 changes: 47 additions & 0 deletions packages/metadata-protocol/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// Self-contained smoke for @objectstack/metadata-protocol: the package must
// import cleanly and expose its public surface without needing a data engine.
// (Protocol behavior is exercised end-to-end by the @objectstack/objectql suite,
// which injects a real engine.)

import { describe, it, expect } from 'vitest';
import {
ObjectStackProtocolImplementation,
ConcurrentUpdateError,
normalizeViewMetadata,
SysMetadataRepository,
resetEnvWritableMetadataTypes,
computeMetadataDiagnostics,
computeViewReferenceDiagnostics,
decorateMetadataItem,
SeedLoaderService,
runBuildProbes,
} from './index.js';

describe('@objectstack/metadata-protocol public surface', () => {
it('exposes the protocol, repository, seed-loader and probe entry points', () => {
expect(typeof ObjectStackProtocolImplementation).toBe('function');
expect(typeof SysMetadataRepository).toBe('function');
expect(typeof SeedLoaderService).toBe('function');
expect(typeof runBuildProbes).toBe('function');
expect(ConcurrentUpdateError.prototype).toBeInstanceOf(Error);
});

it('exposes pure metadata helpers', () => {
expect(typeof normalizeViewMetadata).toBe('function');
expect(typeof computeMetadataDiagnostics).toBe('function');
expect(typeof computeViewReferenceDiagnostics).toBe('function');
expect(typeof decorateMetadataItem).toBe('function');
expect(typeof resetEnvWritableMetadataTypes).toBe('function');
});

it('normalizeViewMetadata passes a non-view item through without throwing', () => {
const item = { name: 'acct', label: 'Account' };
expect(() => normalizeViewMetadata('object', item, 'acct')).not.toThrow();
});

it('resetEnvWritableMetadataTypes is callable (state reset, no engine needed)', () => {
expect(() => resetEnvWritableMetadataTypes()).not.toThrow();
});
});
31 changes: 31 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js';

export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
export type {
SysMetadataEngine,
SysMetadataRepositoryOptions,
OverlayState,
ExtendedOperation,
} from './sys-metadata-repository.js';

export {
computeMetadataDiagnostics,
computeViewReferenceDiagnostics,
decorateMetadataItem,
decorateMetadataItems,
} from './metadata-diagnostics.js';
export type { MetadataDiagnostics } from './metadata-diagnostics.js';

export type { MetadataHostEngine } from './host-engine.js';

export { SeedLoaderService } from './seed-loader.js';
export { runBuildProbes } from './build-probes.js';
export type {
RuntimeBuildIssue,
BuildProbeReport,
RunBuildProbesOptions,
ProbeEngine,
ProbeAnalytics,
} from './build-probes.js';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { ObjectStackProtocol } from '@objectstack/spec/api';
import { IDataEngine } from '@objectstack/core';
import { readEnvWithDeprecation } from '@objectstack/types';
import type { ObjectQL } from './engine.js';
import type { MetadataHostEngine } from './host-engine.js';
import { SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js';
import { ConflictError } from '@objectstack/metadata-core';
import type {
Expand Down Expand Up @@ -667,7 +667,7 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{
}

export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
private engine: ObjectQL;
private engine: MetadataHostEngine;
private getServicesRegistry?: () => Map<string, any>;
private getFeedService?: () => IFeedService | undefined;
/**
Expand All @@ -694,7 +694,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
getFeedService?: () => IFeedService | undefined,
environmentId?: string,
) {
this.engine = engine as ObjectQL;
this.engine = engine as MetadataHostEngine;
this.getServicesRegistry = getServicesRegistry;
this.getFeedService = getFeedService;
this.environmentId = environmentId;
Expand Down
17 changes: 17 additions & 0 deletions packages/metadata-protocol/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": [
"node"
]
}
}
14 changes: 14 additions & 0 deletions packages/metadata-protocol/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
splitting: true,
sourcemap: true,
clean: true,
dts: !process.env.OS_SKIP_DTS,
format: ['esm', 'cjs'],
target: 'es2020',
external: ['vitest'],
});
8 changes: 7 additions & 1 deletion packages/objectql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./core": {
"types": "./dist/core.d.ts",
"import": "./dist/core.mjs",
"require": "./dist/core.js"
}
},
"scripts": {
"build": "tsup --config ../../tsup.config.ts",
"build": "tsup",
"test": "vitest run"
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"ajv": "^8.20.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/build-probes.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import { runBuildProbes, type ProbeEngine } from './build-probes.js';
import { ObjectStackProtocolImplementation } from './protocol.js';
import { runBuildProbes, type ProbeEngine } from '@objectstack/metadata-protocol';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';

/**
* ADR-0038 L3 — runtime probes. Each probe is one real read; findings are
Expand Down
73 changes: 73 additions & 0 deletions packages/objectql/src/core-boundary.ratchet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// ADR-0076 D2 boundary ratchet. The lean engine entry `@objectstack/objectql/core`
// (src/core.ts) and its entire local import closure must NOT depend on the kernel
// plugin, the kernel factory, or the metadata-management protocol — so a thin
// embedder importing `@objectstack/objectql/core` never pulls
// `@objectstack/metadata-protocol` (or its 268KB) into its graph.
//
// If this test fails, you added a forbidden import somewhere reachable from
// core.ts. Keep metadata/plugin/kernel concerns out of the core closure.

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const SRC = dirname(fileURLToPath(import.meta.url));

const FORBIDDEN_PACKAGES = ['@objectstack/metadata-protocol'];
const FORBIDDEN_LOCAL = ['plugin', 'kernel-factory'];

function localImports(source: string): string[] {
const out: string[] = [];
const re = /(?:from|import)\s*\(?\s*['"](\.\.?\/[^'"]+)['"]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(source))) out.push(m[1]);
return out;
}

function toTsPath(fromFile: string, spec: string): string {
const base = resolve(dirname(fromFile), spec.replace(/\.js$/, ''));
return base.endsWith('.ts') ? base : `${base}.ts`;
}

describe('ADR-0076 D2 — @objectstack/objectql/core boundary', () => {
it('core.ts closure pulls neither metadata-protocol nor plugin/kernel-factory', () => {
const entry = resolve(SRC, 'core.ts');
const visited = new Set<string>();
const violations: string[] = [];
const stack = [entry];

while (stack.length) {
const file = stack.pop()!;
if (visited.has(file)) continue;
visited.add(file);

let src: string;
try {
src = readFileSync(file, 'utf8');
} catch {
continue; // generated / non-existent; ignore
}

for (const pkg of FORBIDDEN_PACKAGES) {
if (new RegExp(`['"]${pkg.replace('/', '\\/')}['"]`).test(src)) {
violations.push(`${file} imports forbidden package ${pkg}`);
}
}

for (const spec of localImports(src)) {
const base = spec.replace(/\.js$/, '').split('/').pop();
if (FORBIDDEN_LOCAL.includes(base ?? '')) {
violations.push(`${file} imports forbidden local module ./${base}`);
}
stack.push(toTsPath(file, spec));
}
}

expect(violations, violations.join('\n')).toEqual([]);
// sanity: the engine itself IS in the closure
expect([...visited].some((f) => f.endsWith('/engine.ts'))).toBe(true);
});
});
Loading