@objectstack/metadata 3.0.5 → 3.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +19 -0
- package/ROADMAP.md +14 -13
- package/dist/index.d.mts +398 -2
- package/dist/index.d.ts +398 -2
- package/dist/index.js +411 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +409 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +4 -0
- package/src/loaders/database-loader.test.ts +559 -0
- package/src/loaders/database-loader.ts +352 -0
- package/src/metadata-manager.ts +25 -0
- package/src/node.ts +1 -0
- package/src/objects/sys-metadata.object.ts +187 -0
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Database Metadata Loader
|
|
5
|
+
*
|
|
6
|
+
* Loads and persists metadata via an IDataDriver instance, enabling
|
|
7
|
+
* database-backed storage for platform and user scoped metadata.
|
|
8
|
+
* Uses the `sys_metadata` table (configurable) following the
|
|
9
|
+
* MetadataRecordSchema envelope defined in @objectstack/spec.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
MetadataLoadOptions,
|
|
14
|
+
MetadataLoadResult,
|
|
15
|
+
MetadataStats,
|
|
16
|
+
MetadataLoaderContract,
|
|
17
|
+
MetadataSaveOptions,
|
|
18
|
+
MetadataSaveResult,
|
|
19
|
+
MetadataRecord,
|
|
20
|
+
} from '@objectstack/spec/system';
|
|
21
|
+
import { SysMetadataObject } from '../objects/sys-metadata.object.js';
|
|
22
|
+
import type { IDataDriver } from '@objectstack/spec/contracts';
|
|
23
|
+
import type { MetadataLoader } from './loader-interface.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration for the DatabaseLoader.
|
|
27
|
+
*/
|
|
28
|
+
export interface DatabaseLoaderOptions {
|
|
29
|
+
/** The IDataDriver instance to use for database operations */
|
|
30
|
+
driver: IDataDriver;
|
|
31
|
+
|
|
32
|
+
/** The table name to store metadata records (default: 'sys_metadata') */
|
|
33
|
+
tableName?: string;
|
|
34
|
+
|
|
35
|
+
/** Tenant ID for multi-tenant isolation */
|
|
36
|
+
tenantId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* DatabaseLoader — Datasource-backed metadata persistence.
|
|
41
|
+
*
|
|
42
|
+
* Implements the MetadataLoader interface to provide database read/write
|
|
43
|
+
* for metadata records. Uses the MetadataRecordSchema envelope to persist
|
|
44
|
+
* metadata with scope, versioning, and audit fields.
|
|
45
|
+
*/
|
|
46
|
+
export class DatabaseLoader implements MetadataLoader {
|
|
47
|
+
readonly contract: MetadataLoaderContract = {
|
|
48
|
+
name: 'database',
|
|
49
|
+
protocol: 'datasource:',
|
|
50
|
+
capabilities: {
|
|
51
|
+
read: true,
|
|
52
|
+
write: true,
|
|
53
|
+
watch: false,
|
|
54
|
+
list: true,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
private driver: IDataDriver;
|
|
59
|
+
private tableName: string;
|
|
60
|
+
private tenantId?: string;
|
|
61
|
+
private schemaReady = false;
|
|
62
|
+
|
|
63
|
+
constructor(options: DatabaseLoaderOptions) {
|
|
64
|
+
this.driver = options.driver;
|
|
65
|
+
this.tableName = options.tableName ?? 'sys_metadata';
|
|
66
|
+
this.tenantId = options.tenantId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ensure the metadata table exists.
|
|
71
|
+
* Uses IDataDriver.syncSchema with the SysMetadataObject definition
|
|
72
|
+
* to idempotently create/update the table.
|
|
73
|
+
*/
|
|
74
|
+
private async ensureSchema(): Promise<void> {
|
|
75
|
+
if (this.schemaReady) return;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await this.driver.syncSchema(this.tableName, {
|
|
79
|
+
...SysMetadataObject,
|
|
80
|
+
name: this.tableName,
|
|
81
|
+
});
|
|
82
|
+
this.schemaReady = true;
|
|
83
|
+
} catch {
|
|
84
|
+
// If syncSchema fails (e.g. table already exists), mark ready and continue
|
|
85
|
+
this.schemaReady = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build base filter conditions for queries.
|
|
91
|
+
* Always includes tenantId when configured.
|
|
92
|
+
*/
|
|
93
|
+
private baseFilter(type: string, name?: string): Record<string, unknown> {
|
|
94
|
+
const filter: Record<string, unknown> = { type };
|
|
95
|
+
if (name !== undefined) {
|
|
96
|
+
filter.name = name;
|
|
97
|
+
}
|
|
98
|
+
if (this.tenantId) {
|
|
99
|
+
filter.tenant_id = this.tenantId;
|
|
100
|
+
}
|
|
101
|
+
return filter;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Convert a database row to a metadata payload.
|
|
106
|
+
* Parses the JSON `metadata` column back into an object.
|
|
107
|
+
*/
|
|
108
|
+
private rowToData(row: Record<string, unknown>): Record<string, unknown> | null {
|
|
109
|
+
if (!row || !row.metadata) return null;
|
|
110
|
+
|
|
111
|
+
const payload = typeof row.metadata === 'string'
|
|
112
|
+
? JSON.parse(row.metadata as string)
|
|
113
|
+
: row.metadata;
|
|
114
|
+
|
|
115
|
+
return payload as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Convert a database row to a MetadataRecord-like object.
|
|
120
|
+
*/
|
|
121
|
+
private rowToRecord(row: Record<string, unknown>): MetadataRecord {
|
|
122
|
+
return {
|
|
123
|
+
id: row.id as string,
|
|
124
|
+
name: row.name as string,
|
|
125
|
+
type: row.type as string,
|
|
126
|
+
namespace: (row.namespace as string) ?? 'default',
|
|
127
|
+
packageId: row.package_id as string | undefined,
|
|
128
|
+
managedBy: row.managed_by as MetadataRecord['managedBy'],
|
|
129
|
+
scope: (row.scope as MetadataRecord['scope']) ?? 'platform',
|
|
130
|
+
metadata: this.rowToData(row) ?? {},
|
|
131
|
+
extends: row.extends as string | undefined,
|
|
132
|
+
strategy: (row.strategy as MetadataRecord['strategy']) ?? 'merge',
|
|
133
|
+
owner: row.owner as string | undefined,
|
|
134
|
+
state: (row.state as MetadataRecord['state']) ?? 'active',
|
|
135
|
+
tenantId: row.tenant_id as string | undefined,
|
|
136
|
+
version: (row.version as number) ?? 1,
|
|
137
|
+
checksum: row.checksum as string | undefined,
|
|
138
|
+
source: row.source as MetadataRecord['source'],
|
|
139
|
+
tags: row.tags ? (typeof row.tags === 'string' ? JSON.parse(row.tags as string) : row.tags as string[]) : undefined,
|
|
140
|
+
createdBy: row.created_by as string | undefined,
|
|
141
|
+
createdAt: row.created_at as string | undefined,
|
|
142
|
+
updatedBy: row.updated_by as string | undefined,
|
|
143
|
+
updatedAt: row.updated_at as string | undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ==========================================
|
|
148
|
+
// MetadataLoader Interface Implementation
|
|
149
|
+
// ==========================================
|
|
150
|
+
|
|
151
|
+
async load(
|
|
152
|
+
type: string,
|
|
153
|
+
name: string,
|
|
154
|
+
_options?: MetadataLoadOptions
|
|
155
|
+
): Promise<MetadataLoadResult> {
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
|
|
158
|
+
await this.ensureSchema();
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const row = await this.driver.findOne(this.tableName, {
|
|
162
|
+
object: this.tableName,
|
|
163
|
+
where: this.baseFilter(type, name),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!row) {
|
|
167
|
+
return {
|
|
168
|
+
data: null,
|
|
169
|
+
loadTime: Date.now() - startTime,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const data = this.rowToData(row);
|
|
174
|
+
const record = this.rowToRecord(row);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
data,
|
|
178
|
+
source: 'database',
|
|
179
|
+
format: 'json',
|
|
180
|
+
etag: record.checksum,
|
|
181
|
+
loadTime: Date.now() - startTime,
|
|
182
|
+
};
|
|
183
|
+
} catch {
|
|
184
|
+
return {
|
|
185
|
+
data: null,
|
|
186
|
+
loadTime: Date.now() - startTime,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async loadMany<T = any>(
|
|
192
|
+
type: string,
|
|
193
|
+
_options?: MetadataLoadOptions
|
|
194
|
+
): Promise<T[]> {
|
|
195
|
+
await this.ensureSchema();
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const rows = await this.driver.find(this.tableName, {
|
|
199
|
+
object: this.tableName,
|
|
200
|
+
where: this.baseFilter(type),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return rows
|
|
204
|
+
.map(row => this.rowToData(row))
|
|
205
|
+
.filter((data): data is Record<string, unknown> => data !== null) as T[];
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async exists(type: string, name: string): Promise<boolean> {
|
|
212
|
+
await this.ensureSchema();
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const count = await this.driver.count(this.tableName, {
|
|
216
|
+
object: this.tableName,
|
|
217
|
+
where: this.baseFilter(type, name),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return count > 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async stat(type: string, name: string): Promise<MetadataStats | null> {
|
|
227
|
+
await this.ensureSchema();
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const row = await this.driver.findOne(this.tableName, {
|
|
231
|
+
object: this.tableName,
|
|
232
|
+
where: this.baseFilter(type, name),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!row) return null;
|
|
236
|
+
|
|
237
|
+
const record = this.rowToRecord(row);
|
|
238
|
+
const metadataStr = typeof row.metadata === 'string'
|
|
239
|
+
? row.metadata as string
|
|
240
|
+
: JSON.stringify(row.metadata);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
size: metadataStr.length,
|
|
244
|
+
mtime: record.updatedAt ?? record.createdAt ?? new Date().toISOString(),
|
|
245
|
+
format: 'json',
|
|
246
|
+
etag: record.checksum,
|
|
247
|
+
};
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async list(type: string): Promise<string[]> {
|
|
254
|
+
await this.ensureSchema();
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const rows = await this.driver.find(this.tableName, {
|
|
258
|
+
object: this.tableName,
|
|
259
|
+
where: this.baseFilter(type),
|
|
260
|
+
fields: ['name'],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return rows
|
|
264
|
+
.map(row => row.name as string)
|
|
265
|
+
.filter(name => typeof name === 'string');
|
|
266
|
+
} catch {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async save(
|
|
272
|
+
type: string,
|
|
273
|
+
name: string,
|
|
274
|
+
data: any,
|
|
275
|
+
_options?: MetadataSaveOptions
|
|
276
|
+
): Promise<MetadataSaveResult> {
|
|
277
|
+
const startTime = Date.now();
|
|
278
|
+
|
|
279
|
+
await this.ensureSchema();
|
|
280
|
+
|
|
281
|
+
const now = new Date().toISOString();
|
|
282
|
+
const metadataJson = JSON.stringify(data);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const existing = await this.driver.findOne(this.tableName, {
|
|
286
|
+
object: this.tableName,
|
|
287
|
+
where: this.baseFilter(type, name),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (existing) {
|
|
291
|
+
// Update existing record
|
|
292
|
+
const version = ((existing.version as number) ?? 0) + 1;
|
|
293
|
+
await this.driver.update(this.tableName, existing.id as string, {
|
|
294
|
+
metadata: metadataJson,
|
|
295
|
+
version,
|
|
296
|
+
updated_at: now,
|
|
297
|
+
state: 'active',
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
success: true,
|
|
302
|
+
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
303
|
+
size: metadataJson.length,
|
|
304
|
+
saveTime: Date.now() - startTime,
|
|
305
|
+
};
|
|
306
|
+
} else {
|
|
307
|
+
// Create new record
|
|
308
|
+
const id = generateId();
|
|
309
|
+
await this.driver.create(this.tableName, {
|
|
310
|
+
id,
|
|
311
|
+
name,
|
|
312
|
+
type,
|
|
313
|
+
namespace: 'default',
|
|
314
|
+
scope: (data as any)?.scope ?? 'platform',
|
|
315
|
+
metadata: metadataJson,
|
|
316
|
+
strategy: 'merge',
|
|
317
|
+
state: 'active',
|
|
318
|
+
version: 1,
|
|
319
|
+
source: 'database',
|
|
320
|
+
...(this.tenantId ? { tenant_id: this.tenantId } : {}),
|
|
321
|
+
created_at: now,
|
|
322
|
+
updated_at: now,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
success: true,
|
|
327
|
+
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
328
|
+
size: metadataJson.length,
|
|
329
|
+
saveTime: Date.now() - startTime,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`DatabaseLoader save failed for ${type}/${name}: ${
|
|
335
|
+
error instanceof Error ? error.message : String(error)
|
|
336
|
+
}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Generate a simple unique ID for metadata records.
|
|
344
|
+
* Uses crypto.randomUUID when available, falls back to timestamp-based ID.
|
|
345
|
+
*/
|
|
346
|
+
function generateId(): string {
|
|
347
|
+
if (typeof globalThis.crypto !== 'undefined' && typeof globalThis.crypto.randomUUID === 'function') {
|
|
348
|
+
return globalThis.crypto.randomUUID();
|
|
349
|
+
}
|
|
350
|
+
// Fallback for environments without crypto.randomUUID
|
|
351
|
+
return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
|
352
|
+
}
|
package/src/metadata-manager.ts
CHANGED
|
@@ -39,7 +39,9 @@ import { JSONSerializer } from './serializers/json-serializer.js';
|
|
|
39
39
|
import { YAMLSerializer } from './serializers/yaml-serializer.js';
|
|
40
40
|
import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
|
|
41
41
|
import type { MetadataSerializer } from './serializers/serializer-interface.js';
|
|
42
|
+
import type { IDataDriver } from '@objectstack/spec/contracts';
|
|
42
43
|
import type { MetadataLoader } from './loaders/loader-interface.js';
|
|
44
|
+
import { DatabaseLoader } from './loaders/database-loader.js';
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Watch callback function (legacy)
|
|
@@ -48,6 +50,8 @@ export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
|
|
|
48
50
|
|
|
49
51
|
export interface MetadataManagerOptions extends MetadataManagerConfig {
|
|
50
52
|
loaders?: MetadataLoader[];
|
|
53
|
+
/** Optional IDataDriver instance. When provided alongside config.datasource, auto-configures DatabaseLoader. */
|
|
54
|
+
driver?: IDataDriver;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
@@ -99,6 +103,11 @@ export class MetadataManager implements IMetadataService {
|
|
|
99
103
|
if (config.loaders && config.loaders.length > 0) {
|
|
100
104
|
config.loaders.forEach(loader => this.registerLoader(loader));
|
|
101
105
|
}
|
|
106
|
+
|
|
107
|
+
// Auto-configure DatabaseLoader when datasource + driver are provided
|
|
108
|
+
if (config.datasource && config.driver) {
|
|
109
|
+
this.setDatabaseDriver(config.driver);
|
|
110
|
+
}
|
|
102
111
|
// Note: No default loader in base class. Subclasses (NodeMetadataManager) or caller must provide one.
|
|
103
112
|
}
|
|
104
113
|
|
|
@@ -109,6 +118,22 @@ export class MetadataManager implements IMetadataService {
|
|
|
109
118
|
this.typeRegistry = entries;
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Configure and register a DatabaseLoader for database-backed metadata persistence.
|
|
123
|
+
* Can be called at any time to enable database storage (e.g. after kernel resolves the driver).
|
|
124
|
+
*
|
|
125
|
+
* @param driver - An IDataDriver instance for database operations
|
|
126
|
+
*/
|
|
127
|
+
setDatabaseDriver(driver: IDataDriver): void {
|
|
128
|
+
const tableName = this.config.tableName ?? 'sys_metadata';
|
|
129
|
+
const dbLoader = new DatabaseLoader({
|
|
130
|
+
driver,
|
|
131
|
+
tableName,
|
|
132
|
+
});
|
|
133
|
+
this.registerLoader(dbLoader);
|
|
134
|
+
this.logger.info('DatabaseLoader configured', { datasource: this.config.datasource, tableName });
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
/**
|
|
113
138
|
* Register a new metadata loader (data source)
|
|
114
139
|
*/
|
package/src/node.ts
CHANGED
|
@@ -7,4 +7,5 @@
|
|
|
7
7
|
export * from './index.js';
|
|
8
8
|
export { NodeMetadataManager } from './node-metadata-manager.js';
|
|
9
9
|
export { FilesystemLoader } from './loaders/filesystem-loader.js';
|
|
10
|
+
export { DatabaseLoader, type DatabaseLoaderOptions } from './loaders/database-loader.js';
|
|
10
11
|
export { MetadataPlugin } from './plugin.js';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { ObjectSchema, Field } from '@objectstack/spec/data';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* sys_metadata — System Metadata Object
|
|
7
|
+
*
|
|
8
|
+
* Canonical ObjectStack object definition for the metadata persistence table.
|
|
9
|
+
* Stores all platform-scope and user-scope metadata records (Objects, Views,
|
|
10
|
+
* Flows, etc.) using the MetadataRecordSchema envelope.
|
|
11
|
+
*
|
|
12
|
+
* This is a system object (isSystem: true) — protected from deletion and
|
|
13
|
+
* automatically provisioned by the DatabaseLoader on first use.
|
|
14
|
+
*
|
|
15
|
+
* @see MetadataRecordSchema in metadata-persistence.zod.ts
|
|
16
|
+
*/
|
|
17
|
+
export const SysMetadataObject = ObjectSchema.create({
|
|
18
|
+
name: 'sys_metadata',
|
|
19
|
+
label: 'System Metadata',
|
|
20
|
+
pluralLabel: 'System Metadata',
|
|
21
|
+
icon: 'settings',
|
|
22
|
+
isSystem: true,
|
|
23
|
+
description: 'Stores platform and user-scope metadata records (objects, views, flows, etc.)',
|
|
24
|
+
|
|
25
|
+
fields: {
|
|
26
|
+
/** Primary Key (UUID) */
|
|
27
|
+
id: Field.text({
|
|
28
|
+
label: 'ID',
|
|
29
|
+
required: true,
|
|
30
|
+
readonly: true,
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
/** Machine name — unique identifier used in code references */
|
|
34
|
+
name: Field.text({
|
|
35
|
+
label: 'Name',
|
|
36
|
+
required: true,
|
|
37
|
+
searchable: true,
|
|
38
|
+
maxLength: 255,
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
/** Metadata type (e.g. "object", "view", "flow") */
|
|
42
|
+
type: Field.text({
|
|
43
|
+
label: 'Metadata Type',
|
|
44
|
+
required: true,
|
|
45
|
+
searchable: true,
|
|
46
|
+
maxLength: 100,
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
/** Namespace / module grouping (e.g. "crm", "core") */
|
|
50
|
+
namespace: Field.text({
|
|
51
|
+
label: 'Namespace',
|
|
52
|
+
required: false,
|
|
53
|
+
defaultValue: 'default',
|
|
54
|
+
maxLength: 100,
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
/** Package that owns/delivered this metadata */
|
|
58
|
+
package_id: Field.text({
|
|
59
|
+
label: 'Package ID',
|
|
60
|
+
required: false,
|
|
61
|
+
maxLength: 255,
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
/** Who manages this record: package, platform, or user */
|
|
65
|
+
managed_by: Field.select(['package', 'platform', 'user'], {
|
|
66
|
+
label: 'Managed By',
|
|
67
|
+
required: false,
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
/** Scope: system (code), platform (admin DB), user (personal DB) */
|
|
71
|
+
scope: Field.select(['system', 'platform', 'user'], {
|
|
72
|
+
label: 'Scope',
|
|
73
|
+
required: true,
|
|
74
|
+
defaultValue: 'platform',
|
|
75
|
+
}),
|
|
76
|
+
|
|
77
|
+
/** JSON payload — the actual metadata configuration */
|
|
78
|
+
metadata: Field.textarea({
|
|
79
|
+
label: 'Metadata',
|
|
80
|
+
required: true,
|
|
81
|
+
description: 'JSON-serialized metadata payload',
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
/** Parent metadata name for extension/override */
|
|
85
|
+
extends: Field.text({
|
|
86
|
+
label: 'Extends',
|
|
87
|
+
required: false,
|
|
88
|
+
maxLength: 255,
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
/** Merge strategy when extending parent metadata */
|
|
92
|
+
strategy: Field.select(['merge', 'replace'], {
|
|
93
|
+
label: 'Strategy',
|
|
94
|
+
required: false,
|
|
95
|
+
defaultValue: 'merge',
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
/** Owner user ID (for user-scope items) */
|
|
99
|
+
owner: Field.text({
|
|
100
|
+
label: 'Owner',
|
|
101
|
+
required: false,
|
|
102
|
+
maxLength: 255,
|
|
103
|
+
}),
|
|
104
|
+
|
|
105
|
+
/** Lifecycle state */
|
|
106
|
+
state: Field.select(['draft', 'active', 'archived', 'deprecated'], {
|
|
107
|
+
label: 'State',
|
|
108
|
+
required: false,
|
|
109
|
+
defaultValue: 'active',
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
/** Tenant ID for multi-tenant isolation */
|
|
113
|
+
tenant_id: Field.text({
|
|
114
|
+
label: 'Tenant ID',
|
|
115
|
+
required: false,
|
|
116
|
+
maxLength: 255,
|
|
117
|
+
}),
|
|
118
|
+
|
|
119
|
+
/** Version number for optimistic concurrency */
|
|
120
|
+
version: Field.number({
|
|
121
|
+
label: 'Version',
|
|
122
|
+
required: false,
|
|
123
|
+
defaultValue: 1,
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
/** Content checksum for change detection */
|
|
127
|
+
checksum: Field.text({
|
|
128
|
+
label: 'Checksum',
|
|
129
|
+
required: false,
|
|
130
|
+
maxLength: 64,
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
/** Origin of this metadata record */
|
|
134
|
+
source: Field.select(['filesystem', 'database', 'api', 'migration'], {
|
|
135
|
+
label: 'Source',
|
|
136
|
+
required: false,
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
/** Classification tags (JSON array) */
|
|
140
|
+
tags: Field.textarea({
|
|
141
|
+
label: 'Tags',
|
|
142
|
+
required: false,
|
|
143
|
+
description: 'JSON-serialized array of classification tags',
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
/** Audit fields */
|
|
147
|
+
created_by: Field.text({
|
|
148
|
+
label: 'Created By',
|
|
149
|
+
required: false,
|
|
150
|
+
readonly: true,
|
|
151
|
+
maxLength: 255,
|
|
152
|
+
}),
|
|
153
|
+
|
|
154
|
+
created_at: Field.datetime({
|
|
155
|
+
label: 'Created At',
|
|
156
|
+
required: false,
|
|
157
|
+
readonly: true,
|
|
158
|
+
}),
|
|
159
|
+
|
|
160
|
+
updated_by: Field.text({
|
|
161
|
+
label: 'Updated By',
|
|
162
|
+
required: false,
|
|
163
|
+
maxLength: 255,
|
|
164
|
+
}),
|
|
165
|
+
|
|
166
|
+
updated_at: Field.datetime({
|
|
167
|
+
label: 'Updated At',
|
|
168
|
+
required: false,
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
indexes: [
|
|
173
|
+
{ fields: ['type', 'name'], unique: true },
|
|
174
|
+
{ fields: ['type', 'scope'] },
|
|
175
|
+
{ fields: ['tenant_id'] },
|
|
176
|
+
{ fields: ['state'] },
|
|
177
|
+
{ fields: ['namespace'] },
|
|
178
|
+
],
|
|
179
|
+
|
|
180
|
+
enable: {
|
|
181
|
+
trackHistory: true,
|
|
182
|
+
searchable: false,
|
|
183
|
+
apiEnabled: true,
|
|
184
|
+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
|
|
185
|
+
trash: false,
|
|
186
|
+
},
|
|
187
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -8,6 +8,7 @@ export default defineConfig({
|
|
|
8
8
|
alias: {
|
|
9
9
|
'@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'),
|
|
10
10
|
'@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
|
|
11
|
+
'@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
|
|
11
12
|
'@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
|
|
12
13
|
'@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'),
|
|
13
14
|
'@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'),
|