diff --git a/.changeset/fix-sys-metadata-storage-home.md b/.changeset/fix-sys-metadata-storage-home.md new file mode 100644 index 000000000..8ede4ab1c --- /dev/null +++ b/.changeset/fix-sys-metadata-storage-home.md @@ -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. diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 4f60dc877..1fe3e679c 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -36,6 +36,7 @@ "change-log" ], "dependencies": { + "@objectstack/spec": "workspace:*", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 43e3c84a9..3ec060346 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -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'; diff --git a/packages/metadata-core/src/objects/index.ts b/packages/metadata-core/src/objects/index.ts new file mode 100644 index 000000000..7720d94c5 --- /dev/null +++ b/packages/metadata-core/src/objects/index.ts @@ -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'; diff --git a/packages/platform-objects/src/metadata/sys-metadata-audit.object.ts b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts similarity index 100% rename from packages/platform-objects/src/metadata/sys-metadata-audit.object.ts rename to packages/metadata-core/src/objects/sys-metadata-audit.object.ts diff --git a/packages/platform-objects/src/metadata/sys-metadata-history.object.ts b/packages/metadata-core/src/objects/sys-metadata-history.object.ts similarity index 100% rename from packages/platform-objects/src/metadata/sys-metadata-history.object.ts rename to packages/metadata-core/src/objects/sys-metadata-history.object.ts diff --git a/packages/platform-objects/src/metadata/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts similarity index 100% rename from packages/platform-objects/src/metadata/sys-metadata.object.ts rename to packages/metadata-core/src/objects/sys-metadata.object.ts diff --git a/packages/platform-objects/src/metadata/sys-view-definition.object.ts b/packages/metadata-core/src/objects/sys-view-definition.object.ts similarity index 100% rename from packages/platform-objects/src/metadata/sys-view-definition.object.ts rename to packages/metadata-core/src/objects/sys-view-definition.object.ts diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 31e605003..dd5ee69ec 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -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 — diff --git a/packages/metadata/src/loaders/database-loader.ts b/packages/metadata/src/loaders/database-loader.ts index 7c0fa71db..598f88eb1 100644 --- a/packages/metadata/src/loaders/database-loader.ts +++ b/packages/metadata/src/loaders/database-loader.ts @@ -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'; diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 7ade927ed..0dfc10871 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -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 diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 514a9aba1..f9cf285ba 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -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); }); }); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 87139db78..6c0e48b04 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -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 }; @@ -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, diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index bf59a6104..7b320b298 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -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": { @@ -67,6 +67,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@objectstack/metadata-core": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/packages/platform-objects/src/metadata/index.ts b/packages/platform-objects/src/metadata/index.ts index 4ce1c524f..be43ea37b 100644 --- a/packages/platform-objects/src/metadata/index.ts +++ b/packages/platform-objects/src/metadata/index.ts @@ -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'; diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts index bf67c08d4..535c9c715 100644 --- a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -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' }); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 3d36ab5e4..4a40b805d 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -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: @@ -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); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts index 84e8c7a50..19a6aef4e 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts @@ -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' }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8724cab55..1ca1a8e5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -891,6 +891,9 @@ importers: packages/metadata-core: dependencies: + '@objectstack/spec': + specifier: workspace:* + version: link:../spec zod: specifier: ^4.4.3 version: 4.4.3 @@ -982,6 +985,9 @@ importers: packages/platform-objects: dependencies: + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../metadata-core '@objectstack/spec': specifier: workspace:* version: link:../spec