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
15 changes: 15 additions & 0 deletions .changeset/fix-sys-metadata-storage-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/metadata-core": patch
"@objectstack/platform-objects": patch
"@objectstack/metadata": patch
"@objectstack/objectql": patch
"@objectstack/driver-sql": patch
---

fix(metadata): home the metadata-storage objects in metadata-core and register them from ObjectQL

Standalone "host config" apps boot without `@objectstack/metadata`'s MetadataPlugin, so nobody registered the metadata-storage objects (`sys_metadata`, `_history`, `_audit`, `sys_view_definition`) into ObjectQL — their tables were never schema-synced and ObjectQL's own protocol (`loadMetaFromDb` / `getMetaItems`) failed with `no such table: sys_metadata` on every read.

- Move the four storage-object definitions from `@objectstack/platform-objects/metadata` to `@objectstack/metadata-core` (the lowest package shared by their real consumers); `platform-objects/metadata` now re-exports them for back-compat.
- `ObjectQLPlugin` registers these objects itself (gated on `environmentId === undefined`, mirroring `restoreMetadataFromDb`) so their tables always sync on platform/standalone kernels.
- Gate the SQL driver's tenant-audit warning on actual multi-tenant mode — `organization_id` now exists on every table, so column presence alone no longer implies "tenant-scoped"; single-tenant boots no longer spam the warning for system writes.
1 change: 1 addition & 0 deletions packages/metadata-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"change-log"
],
"dependencies": {
"@objectstack/spec": "workspace:*",
"zod": "^4.4.3"
},
"peerDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/metadata-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './repository.js';
export * from './in-memory-repository.js';
export * from './cache.js';
export * from './layered-repository.js';
export * from './objects/index.js';
22 changes: 22 additions & 0 deletions packages/metadata-core/src/objects/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* metadata-core/objects — Metadata Storage Object Definitions
*
* `sys_metadata` + `sys_metadata_history` + `sys_metadata_audit` are the
* canonical single-source-of-truth storage substrate for ALL metadata
* customisations (ADR-0005). `sys_view_definition` backs runtime-authored
* shared/personal views (ADR-0017).
*
* These definitions live HERE (the metadata core package) — not in
* `@objectstack/platform-objects` — because the packages that actually read
* and write these tables depend on metadata-core: the ObjectQL protocol
* (`loadMetaFromDb` / `getMetaItems` / `saveMetaItem`) and the metadata
* layer's `DatabaseLoader`. Keeping them in the lowest shared package lets
* both register/sync the tables without a cross-package dependency.
*/

export { SysMetadataObject, SysMetadataObject as SysMetadata } from './sys-metadata.object.js';
export { SysMetadataHistoryObject } from './sys-metadata-history.object.js';
export { SysMetadataAuditObject } from './sys-metadata-audit.object.js';
export { SysViewDefinitionObject } from './sys-view-definition.object.js';
2 changes: 1 addition & 1 deletion packages/metadata/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export { RemoteLoader } from './loaders/remote-loader.js';
export { DatabaseLoader, type DatabaseLoaderOptions } from './loaders/database-loader.js';

// Objects
export { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/platform-objects/metadata';
export { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';

// Routes
// NOTE: `registerMetadataHistoryRoutes` (Hono-style) was removed —
Expand Down
2 changes: 1 addition & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type {
MetadataRecord,
MetadataHistoryRecord,
} from '@objectstack/spec/system';
import { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/platform-objects/metadata';
import { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';
import type { MetadataLoader } from './loader-interface.js';
import { calculateChecksum } from '../utils/metadata-history-utils.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
SysMetadataHistoryObject,
SysMetadataAuditObject,
SysViewDefinitionObject,
} from '@objectstack/platform-objects/metadata';
} from '@objectstack/metadata-core';

// `SysMetadataObject` + `SysMetadataHistoryObject` are the customer overlay
// storage substrate (ADR-0005). They must always be auto-provisioned so
Expand Down
12 changes: 7 additions & 5 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,14 +978,16 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
// Act
await kernel.bootstrap();

// Assert — loadMetaFromDb must appear before any syncSchema call
// Assert — the RESTORED object must be synced AFTER it was hydrated from
// sys_metadata, so its table exists. (The built-in metadata-storage
// objects — sys_metadata, … — are registered up-front by ObjectQLPlugin
// and synced in the FIRST pass, i.e. before loadMetaFromDb; only the
// DB-restored custom objects depend on the post-hydration second pass.)
const loadIdx = operations.indexOf('loadMetaFromDb');
expect(loadIdx).toBeGreaterThanOrEqual(0);

const firstSync = operations.findIndex((op) => op.startsWith('syncSchema:'));
if (firstSync >= 0) {
expect(loadIdx).toBeLessThan(firstSync);
}
const restoredSyncIdx = operations.indexOf('syncSchema:restored_obj');
expect(restoredSyncIdx).toBeGreaterThan(loadIdx);
});
});

Expand Down
37 changes: 37 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { ObjectQL } from './engine.js';
import { ObjectStackProtocolImplementation } from './protocol.js';
import { Plugin, PluginContext } from '@objectstack/core';
import { StorageNameMapping } from '@objectstack/spec/system';
import {
SysMetadataObject,
SysMetadataHistoryObject,
SysMetadataAuditObject,
SysViewDefinitionObject,
} from '@objectstack/metadata-core';

export type { Plugin, PluginContext };

Expand Down Expand Up @@ -141,6 +147,37 @@ export class ObjectQLPlugin implements Plugin {
services: ['objectql', 'data', 'manifest'],
});

// Register the metadata-storage objects this engine's own protocol reads
// and writes — `sys_metadata` (loadMetaFromDb / getMetaItems / saveMetaItem),
// its history/audit siblings, and `sys_view_definition`. Doing it here
// guarantees their tables get schema-synced in start() even when no
// MetadataPlugin is present (e.g. standalone "host config" apps, where the
// CLI auto-registers a bare ObjectQLPlugin and nothing else owns these
// tables → "no such table: sys_metadata" on every read).
//
// Gated on `environmentId === undefined` — the SAME condition that gates
// `restoreMetadataFromDb` below: platform / standalone kernels own their
// local sys_metadata, whereas per-project (cloud) kernels source metadata
// from the control plane and must NOT provision these tables locally.
// Definitions live in @objectstack/metadata-core (shared by this protocol
// and the metadata layer's DatabaseLoader). registerApp is idempotent, so
// a MetadataPlugin that also registers them is harmless.
if (this.environmentId === undefined) {
this.ql.registerApp({
id: 'com.objectstack.metadata-objects',
name: 'Metadata Platform Objects',
version: '1.0.0',
type: 'plugin',
scope: 'system',
objects: [
SysMetadataObject,
SysMetadataHistoryObject,
SysMetadataAuditObject,
SysViewDefinitionObject,
],
});
}

// Register Protocol Implementation
const protocolShim = new ObjectStackProtocolImplementation(
this.ql,
Expand Down
3 changes: 2 additions & 1 deletion packages/platform-objects/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@objectstack/platform-objects",
"version": "7.6.0",
"license": "Apache-2.0",
"description": "Core platform object schemas for ObjectStack identity, security, audit, tenant, and metadata objects",
"description": "Core platform object schemas for ObjectStack \u2014 identity, security, audit, tenant, and metadata objects",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
Expand Down Expand Up @@ -67,6 +67,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@objectstack/metadata-core": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
Expand Down
30 changes: 17 additions & 13 deletions packages/platform-objects/src/metadata/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* platform-objects/metadata — Metadata Storage Objects
* platform-objects/metadata — BACK-COMPAT RE-EXPORT.
*
* `sys_metadata` + `sys_metadata_history` are the canonical, single-source-of-truth
* storage substrate for ALL metadata customisations (see ADR 0005). The previously
* shipped per-type projection objects (`sys_object`, `sys_view`, `sys_flow`,
* `sys_agent`, `sys_tool`) were removed in 2026-05 — they duplicated Zod schemas
* from `@objectstack/spec` and the projection pipeline they fed has been removed
* along with them. Out-of-box metadata lives in the compiled artifact (loaded by
* `SchemaRegistry`); customer overrides live in `sys_metadata` as JSON.
* The metadata-storage object definitions (`sys_metadata`,
* `sys_metadata_history`, `sys_metadata_audit`, `sys_view_definition`) have
* MOVED to `@objectstack/metadata-core` — the lowest package shared by their
* actual consumers (the ObjectQL protocol that reads/writes them, and the
* metadata layer's `DatabaseLoader`). They no longer live in platform-objects.
*
* This module re-exports them so the legacy `@objectstack/platform-objects/metadata`
* import path keeps working during the migration. Prefer importing from
* `@objectstack/metadata-core` directly.
*/

export { SysMetadataObject, SysMetadataObject as SysMetadata } from './sys-metadata.object.js';
export { SysMetadataHistoryObject } from './sys-metadata-history.object.js';
export { SysMetadataAuditObject } from './sys-metadata-audit.object.js';
// Runtime view storage (shared / personal layers) — "Object has-many View".
export { SysViewDefinitionObject } from './sys-view-definition.object.js';
export {
SysMetadataObject,
SysMetadata,
SysMetadataHistoryObject,
SysMetadataAuditObject,
SysViewDefinitionObject,
} from '@objectstack/metadata-core';
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ describe('SqlDriver tenant scope (organization_id)', () => {
});
// Swap logger to capture warns.
(driver as any).logger = { warn: (msg: string, meta: any) => warnSpy.push({ msg, meta }) };
// The tenant-audit warning only fires in multi-tenant mode (single-tenant
// stacks now always have an organization_id column but no isolation).
(driver as any)._multiTenantMode = true;
await driver.initObjects(objects);

await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' });
Expand Down
24 changes: 24 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,23 @@ export class SqlDriver implements IDataDriver {
return cached ?? null;
}

/**
* Whether the host kernel runs in multi-tenant mode — read once from
* `OS_MULTI_ORG_ENABLED` (or the deprecated `OS_MULTI_TENANT`), matching how
* the SchemaRegistry / SecurityPlugin pick the mode. Used to gate the
* tenant-audit warning: it's only meaningful where tenant isolation is
* actually enforced (org-scoping installed).
*/
private _multiTenantMode?: boolean;
protected isMultiTenantMode(): boolean {
if (this._multiTenantMode === undefined) {
const raw =
process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT ?? 'false';
this._multiTenantMode = String(raw).toLowerCase() !== 'false';
}
return this._multiTenantMode;
}

/**
* Apply a `WHERE tenant_field = ?` clause to the given query builder
* when:
Expand Down Expand Up @@ -1402,6 +1419,13 @@ export class SqlDriver implements IDataDriver {
): void {
if (process.env.OS_TENANT_AUDIT === '0') return;
if ((options as any)?.bypassTenantAudit === true) return;
// Only meaningful in multi-tenant deployments. Single-tenant stacks have no
// tenant isolation, yet the kernel now ALWAYS provisions an `organization_id`
// column (its existence is decoupled from the tenant flag). Column presence
// alone therefore no longer implies "tenant-scoped" — without this gate every
// system/sudo write (e.g. the notification/http delivery dispatchers' claim
// updates) would spam a meaningless warning on single-tenant boots.
if (!this.isMultiTenantMode()) return;
const tenantId = (options as any)?.tenantId;
if (tenantId !== undefined && tenantId !== null && tenantId !== '') return;
const field = this.resolveTenantField(object);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => {
driver = new SqliteWasmDriver({ filename: ':memory:' });
// Swap logger to capture warns.
(driver as any).logger = { warn: (msg: string, meta: any) => warnSpy.push({ msg, meta }) };
// The tenant-audit warning only fires in multi-tenant mode (single-tenant
// stacks now always have an organization_id column but no isolation).
(driver as any)._multiTenantMode = true;
await driver.initObjects(objects);

await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' });
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.