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
6 changes: 6 additions & 0 deletions .changeset/metadata-provenance-stamping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@objectstack/objectql": minor
"@objectstack/metadata": minor
---

Metadata registered through the metadata-service path now carries package provenance. `loadMetadataFromService` and `MetadataFacade.register` pass each item's own `_packageId` through to `registry.registerItem` so `applyProtection` stamps `_packageId`/`_provenance: 'package'` (never a synthetic id — `isArtifactBacked()` write authorization keys off `_packageId`). New `MetadataPluginOptions.packageId` lets hosts running the filesystem scanner declare the owning package id for scanned source-file metadata, closing the same gap for hand-wired kernels. GET /api/v1/meta/:type consumers (e.g. objectui NavigationSyncEffect) can now distinguish package-shipped items from user-authored rows without name heuristics.
67 changes: 67 additions & 0 deletions packages/metadata/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,70 @@ describe('MetadataPlugin._parseAndRegisterArtifact — view name resolution (PR-
expect(label).toBe('Service Workflow');
});
});

// ─────────────────────────────────────────────────────────────────────────
// Filesystem-scanner provenance: when the host declares its package id
// (options.packageId — the project's `defineStack` manifest id), scanned
// source-file metadata must be stamped `_packageId`/`_provenance` exactly
// like the artifact path, so GET /meta consumers (objectui
// NavigationSyncEffect) can tell code-defined items from user-authored
// rows. Without the option, items must stay unstamped — `_packageId`
// feeds isArtifactBacked() write authorization.
// ─────────────────────────────────────────────────────────────────────────
describe('MetadataPlugin._loadFromFileSystem — package provenance stamping', () => {
const fakeCtx = {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
} as any;

it('stamps _packageId/_provenance on scanned items when options.packageId is set', async () => {
const plugin = new MetadataPlugin({
watch: false,
config: { bootstrap: 'eager' },
packageId: 'com.example.proj',
});
const mgr = (plugin as any).manager as NodeMetadataManager;
vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) =>
type === 'page' ? [{ name: 'home_page', label: 'Home' }] : []);

await (plugin as any)._loadFromFileSystem(fakeCtx);

const item = await mgr.get('page', 'home_page') as any;
expect(item).toBeDefined();
expect(item._packageId).toBe('com.example.proj');
expect(item._provenance).toBe('package');
});

it('does not overwrite an item\'s pre-existing _packageId', async () => {
const plugin = new MetadataPlugin({
watch: false,
config: { bootstrap: 'eager' },
packageId: 'com.example.proj',
});
const mgr = (plugin as any).manager as NodeMetadataManager;
vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) =>
type === 'page' ? [{ name: 'vendor_page', _packageId: 'com.vendor.pkg' }] : []);

await (plugin as any)._loadFromFileSystem(fakeCtx);

const item = await mgr.get('page', 'vendor_page') as any;
expect(item._packageId).toBe('com.vendor.pkg');
expect(item._provenance).toBe('package');
});

it('leaves scanned items unstamped when options.packageId is not configured', async () => {
const plugin = new MetadataPlugin({
watch: false,
config: { bootstrap: 'eager' },
});
const mgr = (plugin as any).manager as NodeMetadataManager;
vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) =>
type === 'page' ? [{ name: 'plain_page', label: 'Plain' }] : []);

await (plugin as any)._loadFromFileSystem(fakeCtx);

const item = await mgr.get('page', 'plain_page') as any;
expect(item).toBeDefined();
expect(item._packageId).toBeUndefined();
expect(item._provenance).toBeUndefined();
});
});
23 changes: 23 additions & 0 deletions packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ export interface MetadataPluginOptions {
* business-data namespaces.
*/
registerSystemObjects?: boolean;
/**
* Owning package id for source-file metadata loaded by the filesystem
* scanner (`watch`/eager mode) — the project's `defineStack({ manifest:
* { id } })` id. When set, scanned items are stamped with
* `_packageId`/`_provenance: 'package'` via `applyProtection`, exactly
* like the artifact path, so GET /meta consumers can tell code-defined
* metadata from user-authored rows.
*
* Leave unset when the host has no package identity — items then stay
* unstamped (runtime-authored semantics). Do NOT pass a guessed value:
* `_packageId` feeds `isArtifactBacked()` write authorization, so a
* wrong id silently changes who may edit these items.
*/
packageId?: string;
}

export class MetadataPlugin implements Plugin {
Expand Down Expand Up @@ -654,6 +668,15 @@ export class MetadataPlugin implements Plugin {
for (const item of items) {
const meta = item as any;
if (meta?.name) {
// Stamp package provenance when the host declared
// its package id (see MetadataPluginOptions.packageId)
// — same applyProtection call the artifact path
// uses, so both load paths produce identical
// _packageId/_provenance state. No-op when the
// option is unset or the item is already stamped.
applyProtection(meta, {
packageId: this.options.packageId,
});
await this.manager.register(entry.type, meta.name, item);
}
}
Expand Down
58 changes: 58 additions & 0 deletions packages/objectql/src/metadata-facade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry } from './registry';
import { MetadataFacade } from './metadata-facade';

describe('MetadataFacade provenance passthrough', () => {
let registry: SchemaRegistry;
let facade: MetadataFacade;

beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false });
facade = new MetadataFacade(registry);
});

it('passes the item\'s own _packageId through to registerItem so _provenance is stamped', async () => {
await facade.register('page', 'installed_apps', {
name: 'installed_apps',
_packageId: 'com.example.marketplace',
});

const item = registry.getItem<any>('page', 'installed_apps');
expect(item).toBeDefined();
expect(item._packageId).toBe('com.example.marketplace');
expect(item._provenance).toBe('package');

// listItems is what protocol.getMetaItems serves from — the stamp
// must survive enumeration too.
const listed = registry.listItems<any>('page');
expect(listed).toHaveLength(1);
expect(listed[0]._packageId).toBe('com.example.marketplace');
expect(listed[0]._provenance).toBe('package');
});

it('leaves runtime-authored items (no _packageId) unstamped', async () => {
await facade.register('page', 'my_user_page', { name: 'my_user_page' });

const item = registry.getItem<any>('page', 'my_user_page');
expect(item).toBeDefined();
expect(item._packageId).toBeUndefined();
expect(item._provenance).toBeUndefined();
});

it('never invents a synthetic package id for object registrations', async () => {
await facade.register('object', 'task', {
name: 'task',
label: 'Task',
fields: {},
});

// getItem('object', …) routes to the merged-object path, so read the
// generic collection directly to inspect what register() stored.
const stored = (registry as any).metadata.get('object')?.get('task');
expect(stored).toBeDefined();
expect(stored._packageId).toBeUndefined();
expect(stored._provenance).toBeUndefined();
});
});
10 changes: 8 additions & 2 deletions packages/objectql/src/metadata-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ export class MetadataFacade {
const definition = typeof data === 'object' && data !== null
? { ...data, name: data.name ?? name }
: data;
// Pass through the item's own source package id (when stamped by an
// artifact loader) so provenance survives re-registration. Never
// synthesize one here — unstamped items are runtime-authored by
// definition and must not become artifact-backed (protocol.ts
// isArtifactBacked gates write authorization on _packageId).
const packageId = definition?._packageId;
if (type === 'object') {
this.registry.registerItem(type, definition, 'name' as any);
this.registry.registerItem(type, definition, 'name' as any, packageId);
} else {
this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any);
this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any, packageId);
}
}

Expand Down
49 changes: 49 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,55 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
// Note: The actual sync happens in start phase
// We can verify by checking if ObjectQL detected external service
});

it('passes each item\'s _packageId through so synced items carry provenance', async () => {
// Arrange — a view stamped by an artifact loader (carries _packageId)
// and a runtime-authored view (no stamp). The sync must preserve the
// distinction: stamped items get _provenance, unstamped stay clean.
const packagedView = {
name: 'pkg_view',
label: 'Packaged View',
_packageId: 'com.example.crm',
};
const runtimeView = {
name: 'user_view',
label: 'User View',
};

const mockMetadataService = {
load: async () => null,
loadMany: async (type: string) => (type === 'view' ? [packagedView, runtimeView] : []),
save: async () => ({ success: true, path: '/test' }),
exists: async () => false,
list: async () => []
};

await kernel.use({
name: 'mock-metadata',
type: 'metadata',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('metadata', mockMetadataService);
}
});

await kernel.use(new ObjectQLPlugin());

// Act
await kernel.bootstrap();

// Assert
const objectql = kernel.getService('objectql') as any;
const synced = objectql.registry.getItem('view', 'pkg_view');
expect(synced).toBeDefined();
expect(synced._packageId).toBe('com.example.crm');
expect(synced._provenance).toBe('package');

const runtime = objectql.registry.getItem('view', 'user_view');
expect(runtime).toBeDefined();
expect(runtime._packageId).toBeUndefined();
expect(runtime._provenance).toBeUndefined();
});
});

describe('Schema Sync on Start', () => {
Expand Down
13 changes: 11 additions & 2 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,9 +893,18 @@ export class ObjectQLPlugin implements Plugin {
return;
}

// Register other types in the registry
// Register other types in the registry. Pass through
// the item's own source package id (stamped by the
// metadata plugin's artifact loader) so registerItem's
// applyProtection re-stamps _packageId/_provenance and
// GET /meta consumers can tell package-shipped items
// from user-authored ones. Items without _packageId
// (FS project files, runtime-authored rows) must stay
// unstamped — a synthetic id like 'metadata-service'
// would flip isArtifactBacked() and the two-tier write
// authorization for genuinely runtime-authored items.
if (this.ql?.registry?.registerItem) {
this.ql.registry.registerItem(type, item, keyField);
this.ql.registry.registerItem(type, item, keyField, item._packageId);
}
});

Expand Down
20 changes: 20 additions & 0 deletions packages/objectql/src/protocol-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,26 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
});
});

it('serves _packageId/_provenance for items registered with a source package id', async () => {
// Package-shipped item — registered with its real package id, as
// the engine manifest path and the metadata-service sync both do.
registry.registerItem('page', { name: 'pkg_page', label: 'Pkg Page' }, 'name', 'com.example.crm');
// Runtime-authored item — no package id, must stay unstamped so
// clients can tell the two apart (objectui NavigationSyncEffect).
registry.registerItem('page', { name: 'user_page', label: 'User Page' }, 'name');
mockEngine.find.mockResolvedValue([]);

const result = await protocol.getMetaItems({ type: 'page' });

const pkgPage = result.items.find((i: any) => i.name === 'pkg_page');
expect(pkgPage._packageId).toBe('com.example.crm');
expect(pkgPage._provenance).toBe('package');

const userPage = result.items.find((i: any) => i.name === 'user_page');
expect(userPage._packageId).toBeUndefined();
expect(userPage._provenance).toBeUndefined();
});

it('should fall back to DB when registry is empty for type', async () => {
mockEngine.find.mockResolvedValue([
{
Expand Down