Skip to content

Commit a972362

Browse files
Copilothotlong
andcommitted
refactor: define sys_metadata as formal ObjectStack object (SysMetadataObject)
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 8188b24 commit a972362

4 files changed

Lines changed: 205 additions & 35 deletions

File tree

packages/metadata/src/loaders/database-loader.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,22 @@ describe('DatabaseLoader', () => {
178178
});
179179

180180
describe('schema bootstrapping', () => {
181-
it('should call syncSchema on first operation', async () => {
181+
it('should call syncSchema with SysMetadataObject on first operation', async () => {
182182
await loader.list('object');
183183
expect(mockDriver.syncSchema).toHaveBeenCalledOnce();
184184
expect(mockDriver.syncSchema).toHaveBeenCalledWith(
185185
'sys_metadata',
186-
expect.objectContaining({ name: 'sys_metadata' })
186+
expect.objectContaining({
187+
name: 'sys_metadata',
188+
isSystem: true,
189+
fields: expect.objectContaining({
190+
id: expect.objectContaining({ type: 'text' }),
191+
name: expect.objectContaining({ type: 'text' }),
192+
type: expect.objectContaining({ type: 'text' }),
193+
scope: expect.objectContaining({ type: 'select' }),
194+
metadata: expect.objectContaining({ type: 'textarea' }),
195+
}),
196+
})
187197
);
188198
});
189199

packages/metadata/src/loaders/database-loader.ts

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
MetadataSaveResult,
1919
MetadataRecord,
2020
} from '@objectstack/spec/system';
21+
import { SysMetadataObject } from '@objectstack/spec/system';
2122
import type { IDataDriver } from '@objectstack/spec/contracts';
2223
import type { MetadataLoader } from './loader-interface.js';
2324

@@ -35,37 +36,6 @@ export interface DatabaseLoaderOptions {
3536
tenantId?: string;
3637
}
3738

38-
/**
39-
* Schema definition for the sys_metadata table.
40-
* Used by syncSchema to auto-create the table on first use.
41-
*/
42-
const METADATA_TABLE_SCHEMA = {
43-
name: 'sys_metadata',
44-
fields: {
45-
id: { type: 'text', required: true },
46-
name: { type: 'text', required: true },
47-
type: { type: 'text', required: true },
48-
namespace: { type: 'text', required: false },
49-
package_id: { type: 'text', required: false },
50-
managed_by: { type: 'text', required: false },
51-
scope: { type: 'text', required: true },
52-
metadata: { type: 'text', required: true },
53-
extends: { type: 'text', required: false },
54-
strategy: { type: 'text', required: false },
55-
owner: { type: 'text', required: false },
56-
state: { type: 'text', required: false },
57-
tenant_id: { type: 'text', required: false },
58-
version: { type: 'number', required: false },
59-
checksum: { type: 'text', required: false },
60-
source: { type: 'text', required: false },
61-
tags: { type: 'text', required: false },
62-
created_by: { type: 'text', required: false },
63-
created_at: { type: 'text', required: false },
64-
updated_by: { type: 'text', required: false },
65-
updated_at: { type: 'text', required: false },
66-
},
67-
};
68-
6939
/**
7040
* DatabaseLoader — Datasource-backed metadata persistence.
7141
*
@@ -98,14 +68,15 @@ export class DatabaseLoader implements MetadataLoader {
9868

9969
/**
10070
* Ensure the metadata table exists.
101-
* Uses IDataDriver.syncSchema to idempotently create/update the table.
71+
* Uses IDataDriver.syncSchema with the SysMetadataObject definition
72+
* to idempotently create/update the table.
10273
*/
10374
private async ensureSchema(): Promise<void> {
10475
if (this.schemaReady) return;
10576

10677
try {
10778
await this.driver.syncSchema(this.tableName, {
108-
...METADATA_TABLE_SCHEMA,
79+
...SysMetadataObject,
10980
name: this.tableName,
11081
});
11182
this.schemaReady = true;

packages/spec/src/system/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export * from './notification.zod';
4040
export * from './translation.zod';
4141
export * from './collaboration.zod';
4242
export * from './metadata-persistence.zod';
43+
export * from './sys-metadata.object';
4344
export * from './core-services.zod';
4445

4546
// Multi-Tenant & Licensing
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema } from '../data/object.zod';
4+
import { Field } from '../data/field.zod';
5+
6+
/**
7+
* sys_metadata — System Metadata Object
8+
*
9+
* Canonical ObjectStack object definition for the metadata persistence table.
10+
* Stores all platform-scope and user-scope metadata records (Objects, Views,
11+
* Flows, etc.) using the MetadataRecordSchema envelope.
12+
*
13+
* This is a system object (isSystem: true) — protected from deletion and
14+
* automatically provisioned by the DatabaseLoader on first use.
15+
*
16+
* @see MetadataRecordSchema in metadata-persistence.zod.ts
17+
*/
18+
export const SysMetadataObject = ObjectSchema.create({
19+
name: 'sys_metadata',
20+
label: 'System Metadata',
21+
pluralLabel: 'System Metadata',
22+
icon: 'settings',
23+
isSystem: true,
24+
description: 'Stores platform and user-scope metadata records (objects, views, flows, etc.)',
25+
26+
fields: {
27+
/** Primary Key (UUID) */
28+
id: Field.text({
29+
label: 'ID',
30+
required: true,
31+
readonly: true,
32+
}),
33+
34+
/** Machine name — unique identifier used in code references */
35+
name: Field.text({
36+
label: 'Name',
37+
required: true,
38+
searchable: true,
39+
maxLength: 255,
40+
}),
41+
42+
/** Metadata type (e.g. "object", "view", "flow") */
43+
type: Field.text({
44+
label: 'Metadata Type',
45+
required: true,
46+
searchable: true,
47+
maxLength: 100,
48+
}),
49+
50+
/** Namespace / module grouping (e.g. "crm", "core") */
51+
namespace: Field.text({
52+
label: 'Namespace',
53+
required: false,
54+
defaultValue: 'default',
55+
maxLength: 100,
56+
}),
57+
58+
/** Package that owns/delivered this metadata */
59+
package_id: Field.text({
60+
label: 'Package ID',
61+
required: false,
62+
maxLength: 255,
63+
}),
64+
65+
/** Who manages this record: package, platform, or user */
66+
managed_by: Field.select(['package', 'platform', 'user'], {
67+
label: 'Managed By',
68+
required: false,
69+
}),
70+
71+
/** Scope: system (code), platform (admin DB), user (personal DB) */
72+
scope: Field.select(['system', 'platform', 'user'], {
73+
label: 'Scope',
74+
required: true,
75+
defaultValue: 'platform',
76+
}),
77+
78+
/** JSON payload — the actual metadata configuration */
79+
metadata: Field.textarea({
80+
label: 'Metadata',
81+
required: true,
82+
description: 'JSON-serialized metadata payload',
83+
}),
84+
85+
/** Parent metadata name for extension/override */
86+
extends: Field.text({
87+
label: 'Extends',
88+
required: false,
89+
maxLength: 255,
90+
}),
91+
92+
/** Merge strategy when extending parent metadata */
93+
strategy: Field.select(['merge', 'replace'], {
94+
label: 'Strategy',
95+
required: false,
96+
defaultValue: 'merge',
97+
}),
98+
99+
/** Owner user ID (for user-scope items) */
100+
owner: Field.text({
101+
label: 'Owner',
102+
required: false,
103+
maxLength: 255,
104+
}),
105+
106+
/** Lifecycle state */
107+
state: Field.select(['draft', 'active', 'archived', 'deprecated'], {
108+
label: 'State',
109+
required: false,
110+
defaultValue: 'active',
111+
}),
112+
113+
/** Tenant ID for multi-tenant isolation */
114+
tenant_id: Field.text({
115+
label: 'Tenant ID',
116+
required: false,
117+
maxLength: 255,
118+
}),
119+
120+
/** Version number for optimistic concurrency */
121+
version: Field.number({
122+
label: 'Version',
123+
required: false,
124+
defaultValue: 1,
125+
}),
126+
127+
/** Content checksum for change detection */
128+
checksum: Field.text({
129+
label: 'Checksum',
130+
required: false,
131+
maxLength: 64,
132+
}),
133+
134+
/** Origin of this metadata record */
135+
source: Field.select(['filesystem', 'database', 'api', 'migration'], {
136+
label: 'Source',
137+
required: false,
138+
}),
139+
140+
/** Classification tags (JSON array) */
141+
tags: Field.textarea({
142+
label: 'Tags',
143+
required: false,
144+
description: 'JSON-serialized array of classification tags',
145+
}),
146+
147+
/** Audit fields */
148+
created_by: Field.text({
149+
label: 'Created By',
150+
required: false,
151+
readonly: true,
152+
maxLength: 255,
153+
}),
154+
155+
created_at: Field.datetime({
156+
label: 'Created At',
157+
required: false,
158+
readonly: true,
159+
}),
160+
161+
updated_by: Field.text({
162+
label: 'Updated By',
163+
required: false,
164+
maxLength: 255,
165+
}),
166+
167+
updated_at: Field.datetime({
168+
label: 'Updated At',
169+
required: false,
170+
}),
171+
},
172+
173+
indexes: [
174+
{ fields: ['type', 'name'], unique: true },
175+
{ fields: ['type', 'scope'] },
176+
{ fields: ['tenant_id'] },
177+
{ fields: ['state'] },
178+
{ fields: ['namespace'] },
179+
],
180+
181+
enable: {
182+
trackHistory: true,
183+
searchable: false,
184+
apiEnabled: true,
185+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
186+
trash: false,
187+
},
188+
});

0 commit comments

Comments
 (0)