@objectstack/metadata 4.0.4 → 4.1.0

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/dist/node.js CHANGED
@@ -174,277 +174,8 @@ export default metadata;
174
174
  }
175
175
  };
176
176
 
177
- // src/objects/sys-metadata.object.ts
178
- import { ObjectSchema, Field } from "@objectstack/spec/data";
179
- var SysMetadataObject = ObjectSchema.create({
180
- namespace: "sys",
181
- name: "metadata",
182
- label: "System Metadata",
183
- pluralLabel: "System Metadata",
184
- icon: "settings",
185
- isSystem: true,
186
- description: "Stores platform and user-scope metadata records (objects, views, flows, etc.)",
187
- fields: {
188
- /** Primary Key (UUID) */
189
- id: Field.text({
190
- label: "ID",
191
- required: true,
192
- readonly: true
193
- }),
194
- /** Machine name — unique identifier used in code references */
195
- name: Field.text({
196
- label: "Name",
197
- required: true,
198
- searchable: true,
199
- maxLength: 255
200
- }),
201
- /** Metadata type (e.g. "object", "view", "flow") */
202
- type: Field.text({
203
- label: "Metadata Type",
204
- required: true,
205
- searchable: true,
206
- maxLength: 100
207
- }),
208
- /** Namespace / module grouping (e.g. "crm", "core") */
209
- namespace: Field.text({
210
- label: "Namespace",
211
- required: false,
212
- defaultValue: "default",
213
- maxLength: 100
214
- }),
215
- /** Package that owns/delivered this metadata */
216
- package_id: Field.text({
217
- label: "Package ID",
218
- required: false,
219
- maxLength: 255
220
- }),
221
- /** Who manages this record: package, platform, or user */
222
- managed_by: Field.select(["package", "platform", "user"], {
223
- label: "Managed By",
224
- required: false
225
- }),
226
- /** Scope: system (code), platform (admin DB), user (personal DB) */
227
- scope: Field.select(["system", "platform", "user"], {
228
- label: "Scope",
229
- required: true,
230
- defaultValue: "platform"
231
- }),
232
- /** JSON payload — the actual metadata configuration */
233
- metadata: Field.textarea({
234
- label: "Metadata",
235
- required: true,
236
- description: "JSON-serialized metadata payload"
237
- }),
238
- /** Parent metadata name for extension/override */
239
- extends: Field.text({
240
- label: "Extends",
241
- required: false,
242
- maxLength: 255
243
- }),
244
- /** Merge strategy when extending parent metadata */
245
- strategy: Field.select(["merge", "replace"], {
246
- label: "Strategy",
247
- required: false,
248
- defaultValue: "merge"
249
- }),
250
- /** Owner user ID (for user-scope items) */
251
- owner: Field.text({
252
- label: "Owner",
253
- required: false,
254
- maxLength: 255
255
- }),
256
- /** Lifecycle state */
257
- state: Field.select(["draft", "active", "archived", "deprecated"], {
258
- label: "State",
259
- required: false,
260
- defaultValue: "active"
261
- }),
262
- /** Tenant ID for multi-tenant isolation */
263
- tenant_id: Field.text({
264
- label: "Tenant ID",
265
- required: false,
266
- maxLength: 255
267
- }),
268
- /** Version number for optimistic concurrency */
269
- version: Field.number({
270
- label: "Version",
271
- required: false,
272
- defaultValue: 1
273
- }),
274
- /** Content checksum for change detection */
275
- checksum: Field.text({
276
- label: "Checksum",
277
- required: false,
278
- maxLength: 64
279
- }),
280
- /** Origin of this metadata record */
281
- source: Field.select(["filesystem", "database", "api", "migration"], {
282
- label: "Source",
283
- required: false
284
- }),
285
- /** Classification tags (JSON array) */
286
- tags: Field.textarea({
287
- label: "Tags",
288
- required: false,
289
- description: "JSON-serialized array of classification tags"
290
- }),
291
- /** Audit fields */
292
- created_by: Field.text({
293
- label: "Created By",
294
- required: false,
295
- readonly: true,
296
- maxLength: 255
297
- }),
298
- created_at: Field.datetime({
299
- label: "Created At",
300
- required: false,
301
- readonly: true
302
- }),
303
- updated_by: Field.text({
304
- label: "Updated By",
305
- required: false,
306
- maxLength: 255
307
- }),
308
- updated_at: Field.datetime({
309
- label: "Updated At",
310
- required: false
311
- })
312
- },
313
- indexes: [
314
- { fields: ["type", "name"], unique: true },
315
- { fields: ["type", "scope"] },
316
- { fields: ["tenant_id"] },
317
- { fields: ["state"] },
318
- { fields: ["namespace"] }
319
- ],
320
- enable: {
321
- trackHistory: true,
322
- searchable: false,
323
- apiEnabled: true,
324
- apiMethods: ["get", "list", "create", "update", "delete"],
325
- trash: false
326
- }
327
- });
328
-
329
- // src/objects/sys-metadata-history.object.ts
330
- import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
331
- var SysMetadataHistoryObject = ObjectSchema2.create({
332
- namespace: "sys",
333
- name: "metadata_history",
334
- label: "Metadata History",
335
- pluralLabel: "Metadata History",
336
- icon: "history",
337
- isSystem: true,
338
- description: "Version history and audit trail for metadata changes",
339
- fields: {
340
- /** Primary Key (UUID) */
341
- id: Field2.text({
342
- label: "ID",
343
- required: true,
344
- readonly: true
345
- }),
346
- /** Foreign key to sys_metadata.id */
347
- metadata_id: Field2.text({
348
- label: "Metadata ID",
349
- required: true,
350
- readonly: true,
351
- maxLength: 255
352
- }),
353
- /** Machine name (denormalized for easier querying) */
354
- name: Field2.text({
355
- label: "Name",
356
- required: true,
357
- searchable: true,
358
- readonly: true,
359
- maxLength: 255
360
- }),
361
- /** Metadata type (denormalized for easier querying) */
362
- type: Field2.text({
363
- label: "Metadata Type",
364
- required: true,
365
- searchable: true,
366
- readonly: true,
367
- maxLength: 100
368
- }),
369
- /** Version number at this snapshot */
370
- version: Field2.number({
371
- label: "Version",
372
- required: true,
373
- readonly: true
374
- }),
375
- /** Type of operation that created this history entry */
376
- operation_type: Field2.select(["create", "update", "publish", "revert", "delete"], {
377
- label: "Operation Type",
378
- required: true,
379
- readonly: true
380
- }),
381
- /** Historical metadata snapshot (JSON payload) */
382
- metadata: Field2.textarea({
383
- label: "Metadata",
384
- required: true,
385
- readonly: true,
386
- description: "JSON-serialized metadata snapshot at this version"
387
- }),
388
- /** SHA-256 checksum of metadata content */
389
- checksum: Field2.text({
390
- label: "Checksum",
391
- required: true,
392
- readonly: true,
393
- maxLength: 64
394
- }),
395
- /** Checksum of the previous version */
396
- previous_checksum: Field2.text({
397
- label: "Previous Checksum",
398
- required: false,
399
- readonly: true,
400
- maxLength: 64
401
- }),
402
- /** Human-readable description of changes */
403
- change_note: Field2.textarea({
404
- label: "Change Note",
405
- required: false,
406
- readonly: true,
407
- description: "Description of what changed in this version"
408
- }),
409
- /** Tenant ID for multi-tenant isolation */
410
- tenant_id: Field2.text({
411
- label: "Tenant ID",
412
- required: false,
413
- readonly: true,
414
- maxLength: 255
415
- }),
416
- /** User who made this change */
417
- recorded_by: Field2.text({
418
- label: "Recorded By",
419
- required: false,
420
- readonly: true,
421
- maxLength: 255
422
- }),
423
- /** When was this version recorded */
424
- recorded_at: Field2.datetime({
425
- label: "Recorded At",
426
- required: true,
427
- readonly: true
428
- })
429
- },
430
- indexes: [
431
- { fields: ["metadata_id", "version"], unique: true },
432
- { fields: ["metadata_id", "recorded_at"] },
433
- { fields: ["type", "name"] },
434
- { fields: ["recorded_at"] },
435
- { fields: ["operation_type"] },
436
- { fields: ["tenant_id"] }
437
- ],
438
- enable: {
439
- trackHistory: false,
440
- // Don't track history of history records
441
- searchable: false,
442
- apiEnabled: true,
443
- apiMethods: ["get", "list"],
444
- // Read-only via API
445
- trash: false
446
- }
447
- });
177
+ // src/loaders/database-loader.ts
178
+ import { SysMetadataObject, SysMetadataHistoryObject } from "@objectstack/platform-objects/metadata";
448
179
 
449
180
  // src/utils/metadata-history-utils.ts
450
181
  async function calculateChecksum(metadata) {
@@ -544,6 +275,114 @@ function generateDiffSummary(diff) {
544
275
  return summary.join(", ");
545
276
  }
546
277
 
278
+ // src/utils/lru-cache.ts
279
+ var LRUCache = class {
280
+ constructor(options = {}) {
281
+ this.map = /* @__PURE__ */ new Map();
282
+ this.hits = 0;
283
+ this.misses = 0;
284
+ this.maxSize = options.maxSize && options.maxSize > 0 ? options.maxSize : 0;
285
+ this.ttl = options.ttl && options.ttl > 0 ? options.ttl : 0;
286
+ }
287
+ get(key) {
288
+ const entry = this.map.get(key);
289
+ if (!entry) {
290
+ this.misses++;
291
+ return void 0;
292
+ }
293
+ if (entry.expiresAt !== 0 && entry.expiresAt <= Date.now()) {
294
+ this.map.delete(key);
295
+ this.misses++;
296
+ return void 0;
297
+ }
298
+ this.map.delete(key);
299
+ this.map.set(key, entry);
300
+ this.hits++;
301
+ return entry.value;
302
+ }
303
+ set(key, value) {
304
+ if (this.map.has(key)) {
305
+ this.map.delete(key);
306
+ } else if (this.maxSize > 0 && this.map.size >= this.maxSize) {
307
+ const oldest = this.map.keys().next();
308
+ if (!oldest.done) this.map.delete(oldest.value);
309
+ }
310
+ this.map.set(key, {
311
+ value,
312
+ expiresAt: this.ttl > 0 ? Date.now() + this.ttl : 0
313
+ });
314
+ }
315
+ has(key) {
316
+ return this.get(key) !== void 0;
317
+ }
318
+ delete(key) {
319
+ return this.map.delete(key);
320
+ }
321
+ clear() {
322
+ this.map.clear();
323
+ }
324
+ get size() {
325
+ return this.map.size;
326
+ }
327
+ /** Diagnostic counters — useful for `metrics` endpoints. */
328
+ stats() {
329
+ const total = this.hits + this.misses;
330
+ return {
331
+ size: this.map.size,
332
+ hits: this.hits,
333
+ misses: this.misses,
334
+ hitRate: total === 0 ? 0 : this.hits / total
335
+ };
336
+ }
337
+ /** Resets hit/miss counters without dropping cached entries. */
338
+ resetStats() {
339
+ this.hits = 0;
340
+ this.misses = 0;
341
+ }
342
+ };
343
+
344
+ // src/migrations/add-sys-metadata-overlay-index.ts
345
+ var INDEX_NAME = "idx_sys_metadata_overlay_active";
346
+ var TABLE = "sys_metadata";
347
+ var COLUMNS = "(type, name, organization_id, project_id, scope)";
348
+ var WHERE = "state = 'active'";
349
+ async function addSysMetadataOverlayIndex(driver) {
350
+ const driverAny = driver;
351
+ const exec = async (sql) => {
352
+ if (typeof driverAny.raw === "function") {
353
+ await driverAny.raw(sql);
354
+ } else if (typeof driverAny.execute === "function") {
355
+ await driverAny.execute(sql);
356
+ } else {
357
+ throw new Error("driver has neither raw nor execute");
358
+ }
359
+ };
360
+ const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;
361
+ const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;
362
+ try {
363
+ await exec(partialSql);
364
+ return { index: INDEX_NAME, status: "created" };
365
+ } catch (err) {
366
+ const msg = err instanceof Error ? err.message : String(err);
367
+ if (/partial|where clause|syntax/i.test(msg)) {
368
+ try {
369
+ await exec(fallbackSql);
370
+ return { index: INDEX_NAME, status: "fallback_non_unique" };
371
+ } catch (fallbackErr) {
372
+ return {
373
+ index: INDEX_NAME,
374
+ status: "error",
375
+ error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)
376
+ };
377
+ }
378
+ }
379
+ if (/already exists/i.test(msg)) {
380
+ return { index: INDEX_NAME, status: "already_exists" };
381
+ }
382
+ return { index: INDEX_NAME, status: "error", error: msg };
383
+ }
384
+ }
385
+
547
386
  // src/loaders/database-loader.ts
548
387
  var DatabaseLoader = class {
549
388
  constructor(options) {
@@ -559,11 +398,103 @@ var DatabaseLoader = class {
559
398
  };
560
399
  this.schemaReady = false;
561
400
  this.historySchemaReady = false;
401
+ if (!options.driver && !options.engine) {
402
+ throw new Error("DatabaseLoader requires either a driver or engine");
403
+ }
562
404
  this.driver = options.driver;
405
+ this.engine = options.engine;
563
406
  this.tableName = options.tableName ?? "sys_metadata";
564
407
  this.historyTableName = options.historyTableName ?? "sys_metadata_history";
565
- this.tenantId = options.tenantId;
408
+ this.organizationId = options.organizationId;
409
+ this.projectId = options.projectId;
566
410
  this.trackHistory = options.trackHistory !== false;
411
+ const cacheOpts = options.cache;
412
+ const cacheEnabled = cacheOpts?.enabled !== false;
413
+ if (cacheEnabled) {
414
+ const lruOpts = {
415
+ maxSize: cacheOpts?.maxSize ?? 500,
416
+ ttl: cacheOpts?.ttl ?? 6e4
417
+ };
418
+ this.loadCache = new LRUCache(lruOpts);
419
+ this.loadManyCache = new LRUCache(lruOpts);
420
+ this.listCache = new LRUCache(lruOpts);
421
+ this.statCache = new LRUCache(lruOpts);
422
+ }
423
+ }
424
+ // ==========================================
425
+ // Cache helpers
426
+ // ==========================================
427
+ cacheKey(type, name) {
428
+ return `${type}::${name}`;
429
+ }
430
+ /**
431
+ * Invalidate all cached entries for a specific (type, name) pair plus
432
+ * the type-level aggregates (`loadMany`, `list`). Called from every write
433
+ * path (`save`, `delete`, `registerRollback`).
434
+ */
435
+ invalidate(type, name) {
436
+ if (!this.loadCache) return;
437
+ const key = this.cacheKey(type, name);
438
+ this.loadCache.delete(key);
439
+ this.statCache?.delete(key);
440
+ this.loadManyCache?.delete(type);
441
+ this.listCache?.delete(type);
442
+ }
443
+ /** Drop the entire cache — useful after bulk imports or schema changes. */
444
+ invalidateAll() {
445
+ this.loadCache?.clear();
446
+ this.loadManyCache?.clear();
447
+ this.listCache?.clear();
448
+ this.statCache?.clear();
449
+ }
450
+ /** Diagnostic: aggregated cache statistics for `metrics` endpoints. */
451
+ getCacheStats() {
452
+ return {
453
+ enabled: this.loadCache !== void 0,
454
+ load: this.loadCache?.stats() ?? null,
455
+ loadMany: this.loadManyCache?.stats() ?? null,
456
+ list: this.listCache?.stats() ?? null,
457
+ stat: this.statCache?.stats() ?? null
458
+ };
459
+ }
460
+ // ==========================================
461
+ // Internal CRUD helpers (driver vs engine)
462
+ // ==========================================
463
+ async _find(table, query) {
464
+ if (this.engine) {
465
+ return this.engine.find(table, query);
466
+ }
467
+ return this.driver.find(table, { object: table, ...query });
468
+ }
469
+ async _findOne(table, query) {
470
+ if (this.engine) {
471
+ return this.engine.findOne(table, query);
472
+ }
473
+ return this.driver.findOne(table, { object: table, ...query });
474
+ }
475
+ async _count(table, query) {
476
+ if (this.engine) {
477
+ return this.engine.count(table, query);
478
+ }
479
+ return this.driver.count(table, { object: table, ...query });
480
+ }
481
+ async _create(table, data) {
482
+ if (this.engine) {
483
+ return this.engine.insert(table, data);
484
+ }
485
+ return this.driver.create(table, data);
486
+ }
487
+ async _update(table, id, data) {
488
+ if (this.engine) {
489
+ return this.engine.update(table, { id, ...data });
490
+ }
491
+ return this.driver.update(table, id, data);
492
+ }
493
+ async _delete(table, id) {
494
+ if (this.engine) {
495
+ return this.engine.delete(table, { where: { id } });
496
+ }
497
+ return this.driver.delete(table, id);
567
498
  }
568
499
  /**
569
500
  * Ensure the metadata table exists.
@@ -572,12 +503,37 @@ var DatabaseLoader = class {
572
503
  */
573
504
  async ensureSchema() {
574
505
  if (this.schemaReady) return;
506
+ if (this.engine) {
507
+ this.schemaReady = true;
508
+ try {
509
+ const engineAny = this.engine;
510
+ let driver = engineAny?.driver ?? engineAny?.getDriver?.();
511
+ if (!driver && engineAny?.drivers instanceof Map) {
512
+ for (const candidate of engineAny.drivers.values()) {
513
+ const c = candidate;
514
+ if (c && (typeof c.raw === "function" || typeof c.execute === "function")) {
515
+ driver = candidate;
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ if (driver) {
521
+ await addSysMetadataOverlayIndex(driver);
522
+ }
523
+ } catch {
524
+ }
525
+ return;
526
+ }
575
527
  try {
576
528
  await this.driver.syncSchema(this.tableName, {
577
529
  ...SysMetadataObject,
578
530
  name: this.tableName
579
531
  });
580
532
  this.schemaReady = true;
533
+ try {
534
+ await addSysMetadataOverlayIndex(this.driver);
535
+ } catch {
536
+ }
581
537
  } catch {
582
538
  this.schemaReady = true;
583
539
  }
@@ -588,6 +544,10 @@ var DatabaseLoader = class {
588
544
  */
589
545
  async ensureHistorySchema() {
590
546
  if (!this.trackHistory || this.historySchemaReady) return;
547
+ if (this.engine) {
548
+ this.historySchemaReady = true;
549
+ return;
550
+ }
591
551
  try {
592
552
  await this.driver.syncSchema(this.historyTableName, {
593
553
  ...SysMetadataHistoryObject,
@@ -600,16 +560,18 @@ var DatabaseLoader = class {
600
560
  }
601
561
  /**
602
562
  * Build base filter conditions for queries.
603
- * Always includes tenantId when configured.
563
+ * Filters by organizationId when configured; project_id when projectId is set,
564
+ * or null (platform-global) when not set.
604
565
  */
605
566
  baseFilter(type, name) {
606
567
  const filter = { type };
607
568
  if (name !== void 0) {
608
569
  filter.name = name;
609
570
  }
610
- if (this.tenantId) {
611
- filter.tenant_id = this.tenantId;
571
+ if (this.organizationId) {
572
+ filter.organization_id = this.organizationId;
612
573
  }
574
+ filter.project_id = this.projectId ?? null;
613
575
  return filter;
614
576
  }
615
577
  /**
@@ -648,10 +610,11 @@ var DatabaseLoader = class {
648
610
  changeNote,
649
611
  recordedBy,
650
612
  recordedAt: now,
651
- ...this.tenantId ? { tenantId: this.tenantId } : {}
613
+ ...this.organizationId ? { organizationId: this.organizationId } : {},
614
+ ...this.projectId !== void 0 ? { projectId: this.projectId } : {}
652
615
  };
653
616
  try {
654
- await this.driver.create(this.historyTableName, {
617
+ await this._create(this.historyTableName, {
655
618
  id: historyRecord.id,
656
619
  metadata_id: historyRecord.metadataId,
657
620
  name: historyRecord.name,
@@ -664,7 +627,8 @@ var DatabaseLoader = class {
664
627
  change_note: historyRecord.changeNote,
665
628
  recorded_by: historyRecord.recordedBy,
666
629
  recorded_at: historyRecord.recordedAt,
667
- ...this.tenantId ? { tenant_id: this.tenantId } : {}
630
+ ...this.organizationId ? { organization_id: this.organizationId } : {},
631
+ ...this.projectId !== void 0 ? { project_id: this.projectId } : {}
668
632
  });
669
633
  } catch (error) {
670
634
  console.error(`Failed to create history record for ${type}/${name}:`, error);
@@ -696,7 +660,8 @@ var DatabaseLoader = class {
696
660
  strategy: row.strategy ?? "merge",
697
661
  owner: row.owner,
698
662
  state: row.state ?? "active",
699
- tenantId: row.tenant_id,
663
+ organizationId: row.organization_id,
664
+ projectId: row.project_id,
700
665
  version: row.version ?? 1,
701
666
  checksum: row.checksum,
702
667
  source: row.source,
@@ -713,12 +678,24 @@ var DatabaseLoader = class {
713
678
  async load(type, name, _options) {
714
679
  const startTime = Date.now();
715
680
  await this.ensureSchema();
681
+ const key = this.cacheKey(type, name);
682
+ if (this.loadCache) {
683
+ const cached = this.loadCache.get(key);
684
+ if (cached !== void 0) {
685
+ return {
686
+ data: cached,
687
+ source: "database",
688
+ format: "json",
689
+ loadTime: Date.now() - startTime
690
+ };
691
+ }
692
+ }
716
693
  try {
717
- const row = await this.driver.findOne(this.tableName, {
718
- object: this.tableName,
694
+ const row = await this._findOne(this.tableName, {
719
695
  where: this.baseFilter(type, name)
720
696
  });
721
697
  if (!row) {
698
+ this.loadCache?.set(key, null);
722
699
  return {
723
700
  data: null,
724
701
  loadTime: Date.now() - startTime
@@ -726,6 +703,7 @@ var DatabaseLoader = class {
726
703
  }
727
704
  const data = this.rowToData(row);
728
705
  const record = this.rowToRecord(row);
706
+ this.loadCache?.set(key, data);
729
707
  return {
730
708
  data,
731
709
  source: "database",
@@ -742,21 +720,29 @@ var DatabaseLoader = class {
742
720
  }
743
721
  async loadMany(type, _options) {
744
722
  await this.ensureSchema();
723
+ if (this.loadManyCache) {
724
+ const cached = this.loadManyCache.get(type);
725
+ if (cached !== void 0) return cached;
726
+ }
745
727
  try {
746
- const rows = await this.driver.find(this.tableName, {
747
- object: this.tableName,
728
+ const rows = await this._find(this.tableName, {
748
729
  where: this.baseFilter(type)
749
730
  });
750
- return rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
731
+ const result = rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
732
+ this.loadManyCache?.set(type, result);
733
+ return result;
751
734
  } catch {
752
735
  return [];
753
736
  }
754
737
  }
755
738
  async exists(type, name) {
756
739
  await this.ensureSchema();
740
+ if (this.loadCache) {
741
+ const cached = this.loadCache.get(this.cacheKey(type, name));
742
+ if (cached !== void 0) return cached !== null;
743
+ }
757
744
  try {
758
- const count = await this.driver.count(this.tableName, {
759
- object: this.tableName,
745
+ const count = await this._count(this.tableName, {
760
746
  where: this.baseFilter(type, name)
761
747
  });
762
748
  return count > 0;
@@ -766,33 +752,47 @@ var DatabaseLoader = class {
766
752
  }
767
753
  async stat(type, name) {
768
754
  await this.ensureSchema();
755
+ const key = this.cacheKey(type, name);
756
+ if (this.statCache) {
757
+ const cached = this.statCache.get(key);
758
+ if (cached !== void 0) return cached;
759
+ }
769
760
  try {
770
- const row = await this.driver.findOne(this.tableName, {
771
- object: this.tableName,
761
+ const row = await this._findOne(this.tableName, {
772
762
  where: this.baseFilter(type, name)
773
763
  });
774
- if (!row) return null;
764
+ if (!row) {
765
+ this.statCache?.set(key, null);
766
+ return null;
767
+ }
775
768
  const record = this.rowToRecord(row);
776
769
  const metadataStr = typeof row.metadata === "string" ? row.metadata : JSON.stringify(row.metadata);
777
- return {
770
+ const stats = {
778
771
  size: metadataStr.length,
779
772
  mtime: record.updatedAt ?? record.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
780
773
  format: "json",
781
774
  etag: record.checksum
782
775
  };
776
+ this.statCache?.set(key, stats);
777
+ return stats;
783
778
  } catch {
784
779
  return null;
785
780
  }
786
781
  }
787
782
  async list(type) {
788
783
  await this.ensureSchema();
784
+ if (this.listCache) {
785
+ const cached = this.listCache.get(type);
786
+ if (cached !== void 0) return cached;
787
+ }
789
788
  try {
790
- const rows = await this.driver.find(this.tableName, {
791
- object: this.tableName,
789
+ const rows = await this._find(this.tableName, {
792
790
  where: this.baseFilter(type),
793
791
  fields: ["name"]
794
792
  });
795
- return rows.map((row) => row.name).filter((name) => typeof name === "string");
793
+ const names = rows.map((row) => row.name).filter((name) => typeof name === "string");
794
+ this.listCache?.set(type, names);
795
+ return names;
796
796
  } catch {
797
797
  return [];
798
798
  }
@@ -804,8 +804,7 @@ var DatabaseLoader = class {
804
804
  async getHistoryRecord(type, name, version) {
805
805
  if (!this.trackHistory) return null;
806
806
  await this.ensureHistorySchema();
807
- const metadataRow = await this.driver.findOne(this.tableName, {
808
- object: this.tableName,
807
+ const metadataRow = await this._findOne(this.tableName, {
809
808
  where: this.baseFilter(type, name)
810
809
  });
811
810
  if (!metadataRow) return null;
@@ -813,11 +812,11 @@ var DatabaseLoader = class {
813
812
  metadata_id: metadataRow.id,
814
813
  version
815
814
  };
816
- if (this.tenantId) {
817
- filter.tenant_id = this.tenantId;
815
+ if (this.organizationId) {
816
+ filter.organization_id = this.organizationId;
818
817
  }
819
- const row = await this.driver.findOne(this.historyTableName, {
820
- object: this.historyTableName,
818
+ filter.project_id = this.projectId ?? null;
819
+ const row = await this._findOne(this.historyTableName, {
821
820
  where: filter
822
821
  });
823
822
  if (!row) return null;
@@ -832,11 +831,80 @@ var DatabaseLoader = class {
832
831
  checksum: row.checksum,
833
832
  previousChecksum: row.previous_checksum,
834
833
  changeNote: row.change_note,
835
- tenantId: row.tenant_id,
834
+ organizationId: row.organization_id,
835
+ projectId: row.project_id,
836
836
  recordedBy: row.recorded_by,
837
837
  recordedAt: row.recorded_at
838
838
  };
839
839
  }
840
+ /**
841
+ * Query history records with pagination and filtering.
842
+ * Encapsulates history table queries so MetadataManager doesn't need
843
+ * direct driver access.
844
+ */
845
+ async queryHistory(type, name, options) {
846
+ if (!this.trackHistory) {
847
+ return { records: [], total: 0, hasMore: false };
848
+ }
849
+ await this.ensureSchema();
850
+ await this.ensureHistorySchema();
851
+ const filter = { type, name };
852
+ if (this.organizationId) filter.organization_id = this.organizationId;
853
+ filter.project_id = this.projectId ?? null;
854
+ const metadataRecord = await this._findOne(this.tableName, { where: filter });
855
+ if (!metadataRecord) {
856
+ return { records: [], total: 0, hasMore: false };
857
+ }
858
+ const historyFilter = {
859
+ metadata_id: metadataRecord.id
860
+ };
861
+ if (this.organizationId) historyFilter.organization_id = this.organizationId;
862
+ historyFilter.project_id = this.projectId ?? null;
863
+ if (options?.operationType) historyFilter.operation_type = options.operationType;
864
+ if (options?.since) historyFilter.recorded_at = { $gte: options.since };
865
+ if (options?.until) {
866
+ if (historyFilter.recorded_at) {
867
+ historyFilter.recorded_at.$lte = options.until;
868
+ } else {
869
+ historyFilter.recorded_at = { $lte: options.until };
870
+ }
871
+ }
872
+ const limit = options?.limit ?? 50;
873
+ const offset = options?.offset ?? 0;
874
+ const historyRecords = await this._find(this.historyTableName, {
875
+ where: historyFilter,
876
+ orderBy: [
877
+ { field: "recorded_at", order: "desc" },
878
+ { field: "version", order: "desc" }
879
+ ],
880
+ limit: limit + 1,
881
+ offset
882
+ });
883
+ const hasMore = historyRecords.length > limit;
884
+ const records = historyRecords.slice(0, limit);
885
+ const total = await this._count(this.historyTableName, { where: historyFilter });
886
+ const includeMetadata = options?.includeMetadata !== false;
887
+ const result = records.map((row) => {
888
+ const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
889
+ return {
890
+ id: row.id,
891
+ metadataId: row.metadata_id,
892
+ name: row.name,
893
+ type: row.type,
894
+ version: row.version,
895
+ operationType: row.operation_type,
896
+ metadata: includeMetadata ? parsedMetadata : null,
897
+ checksum: row.checksum,
898
+ previousChecksum: row.previous_checksum,
899
+ changeNote: row.change_note,
900
+ organizationId: row.organization_id,
901
+ projectId: row.project_id,
902
+ recordedBy: row.recorded_by,
903
+ recordedAt: row.recorded_at
904
+ };
905
+ });
906
+ return { records: result, total, hasMore };
907
+ }
840
908
  /**
841
909
  * Perform a rollback: persist `restoredData` as the new current state and record a
842
910
  * single 'revert' history entry (instead of the usual 'update' entry that `save()`
@@ -849,8 +917,7 @@ var DatabaseLoader = class {
849
917
  const now = (/* @__PURE__ */ new Date()).toISOString();
850
918
  const metadataJson = JSON.stringify(restoredData);
851
919
  const newChecksum = await calculateChecksum(restoredData);
852
- const existing = await this.driver.findOne(this.tableName, {
853
- object: this.tableName,
920
+ const existing = await this._findOne(this.tableName, {
854
921
  where: this.baseFilter(type, name)
855
922
  });
856
923
  if (!existing) {
@@ -858,13 +925,14 @@ var DatabaseLoader = class {
858
925
  }
859
926
  const previousChecksum = existing.checksum;
860
927
  const newVersion = (existing.version ?? 0) + 1;
861
- await this.driver.update(this.tableName, existing.id, {
928
+ await this._update(this.tableName, existing.id, {
862
929
  metadata: metadataJson,
863
930
  version: newVersion,
864
931
  checksum: newChecksum,
865
932
  updated_at: now,
866
933
  state: "active"
867
934
  });
935
+ this.invalidate(type, name);
868
936
  await this.createHistoryRecord(
869
937
  existing.id,
870
938
  type,
@@ -884,13 +952,13 @@ var DatabaseLoader = class {
884
952
  const metadataJson = JSON.stringify(data);
885
953
  const newChecksum = await calculateChecksum(data);
886
954
  try {
887
- const existing = await this.driver.findOne(this.tableName, {
888
- object: this.tableName,
955
+ const existing = await this._findOne(this.tableName, {
889
956
  where: this.baseFilter(type, name)
890
957
  });
891
958
  if (existing) {
892
959
  const previousChecksum = existing.checksum;
893
960
  if (newChecksum === previousChecksum) {
961
+ this.loadCache?.set(this.cacheKey(type, name), data);
894
962
  return {
895
963
  success: true,
896
964
  path: `datasource://${this.tableName}/${type}/${name}`,
@@ -899,13 +967,14 @@ var DatabaseLoader = class {
899
967
  };
900
968
  }
901
969
  const version = (existing.version ?? 0) + 1;
902
- await this.driver.update(this.tableName, existing.id, {
970
+ await this._update(this.tableName, existing.id, {
903
971
  metadata: metadataJson,
904
972
  version,
905
973
  checksum: newChecksum,
906
974
  updated_at: now,
907
975
  state: "active"
908
976
  });
977
+ this.invalidate(type, name);
909
978
  await this.createHistoryRecord(
910
979
  existing.id,
911
980
  type,
@@ -923,7 +992,7 @@ var DatabaseLoader = class {
923
992
  };
924
993
  } else {
925
994
  const id = generateId();
926
- await this.driver.create(this.tableName, {
995
+ await this._create(this.tableName, {
927
996
  id,
928
997
  name,
929
998
  type,
@@ -935,10 +1004,12 @@ var DatabaseLoader = class {
935
1004
  state: "active",
936
1005
  version: 1,
937
1006
  source: "database",
938
- ...this.tenantId ? { tenant_id: this.tenantId } : {},
1007
+ ...this.organizationId ? { organization_id: this.organizationId } : {},
1008
+ ...this.projectId !== void 0 ? { project_id: this.projectId } : { project_id: null },
939
1009
  created_at: now,
940
1010
  updated_at: now
941
1011
  });
1012
+ this.invalidate(type, name);
942
1013
  await this.createHistoryRecord(
943
1014
  id,
944
1015
  type,
@@ -965,14 +1036,14 @@ var DatabaseLoader = class {
965
1036
  */
966
1037
  async delete(type, name) {
967
1038
  await this.ensureSchema();
968
- const existing = await this.driver.findOne(this.tableName, {
969
- object: this.tableName,
1039
+ const existing = await this._findOne(this.tableName, {
970
1040
  where: this.baseFilter(type, name)
971
1041
  });
972
1042
  if (!existing) {
973
1043
  return;
974
1044
  }
975
- await this.driver.delete(this.tableName, existing.id);
1045
+ await this._delete(this.tableName, existing.id);
1046
+ this.invalidate(type, name);
976
1047
  }
977
1048
  };
978
1049
  function generateId() {
@@ -983,7 +1054,7 @@ function generateId() {
983
1054
  }
984
1055
 
985
1056
  // src/metadata-manager.ts
986
- var MetadataManager = class {
1057
+ var _MetadataManager = class _MetadataManager {
987
1058
  constructor(config) {
988
1059
  this.loaders = /* @__PURE__ */ new Map();
989
1060
  this.watchCallbacks = /* @__PURE__ */ new Map();
@@ -995,6 +1066,18 @@ var MetadataManager = class {
995
1066
  this.typeRegistry = [];
996
1067
  // Dependency tracking: "type:name" -> dependencies
997
1068
  this.dependencies = /* @__PURE__ */ new Map();
1069
+ // Short-lived cache for list() results. Built primarily to break the
1070
+ // deadlock that occurs when security/permission middleware calls
1071
+ // `list('permission')` from inside a user-initiated DB transaction: the
1072
+ // DatabaseLoader's `engine.find('sys_metadata', ...)` would then try to
1073
+ // acquire a fresh knex connection while the transaction is still holding
1074
+ // SQLite's single connection — knex waits the full `acquireConnectionTimeout`
1075
+ // (60s) before returning []. The cache absorbs the repeated lookups so the
1076
+ // loader is only hit once per TTL window.
1077
+ //
1078
+ // Invalidated on every `register()` / `unregister()` to keep CRUD writes
1079
+ // visible to subsequent reads.
1080
+ this.listCache = /* @__PURE__ */ new Map();
998
1081
  this.config = config;
999
1082
  this.logger = createLogger({ level: "info", format: "pretty" });
1000
1083
  this.serializers = /* @__PURE__ */ new Map();
@@ -1029,16 +1112,57 @@ var MetadataManager = class {
1029
1112
  * Can be called at any time to enable database storage (e.g. after kernel resolves the driver).
1030
1113
  *
1031
1114
  * @param driver - An IDataDriver instance for database operations
1032
- */
1033
- setDatabaseDriver(driver) {
1115
+ * @param organizationId - Organization ID for multi-tenant isolation
1116
+ * @param projectId - Project ID (undefined = platform-global)
1117
+ */
1118
+ setDatabaseDriver(driver, organizationId, projectId) {
1119
+ if (projectId !== void 0) {
1120
+ this.logger.info("Project kernel \u2014 skipping DatabaseLoader for sys_metadata (control-plane only)", {
1121
+ organizationId,
1122
+ projectId
1123
+ });
1124
+ return;
1125
+ }
1034
1126
  const tableName = this.config.tableName ?? "sys_metadata";
1035
1127
  const dbLoader = new DatabaseLoader({
1036
1128
  driver,
1037
- tableName
1129
+ tableName,
1130
+ organizationId,
1131
+ projectId,
1132
+ cache: this.config.cache?.databaseLoader
1038
1133
  });
1039
1134
  this.registerLoader(dbLoader);
1040
1135
  this.logger.info("DatabaseLoader configured", { datasource: this.config.datasource, tableName });
1041
1136
  }
1137
+ /**
1138
+ * Configure and register a DatabaseLoader backed by an IDataEngine (ObjectQL).
1139
+ * The engine handles datasource routing automatically — sys_metadata will
1140
+ * be routed to the correct driver via the standard namespace mapping.
1141
+ * No manual driver resolution needed.
1142
+ *
1143
+ * @param engine - An IDataEngine instance (typically the ObjectQL service)
1144
+ * @param organizationId - Organization ID for multi-tenant isolation
1145
+ * @param projectId - Project ID (undefined = platform-global)
1146
+ */
1147
+ setDataEngine(engine, organizationId, projectId) {
1148
+ if (projectId !== void 0) {
1149
+ this.logger.info("Project kernel \u2014 skipping DatabaseLoader for sys_metadata (control-plane only)", {
1150
+ organizationId,
1151
+ projectId
1152
+ });
1153
+ return;
1154
+ }
1155
+ const tableName = this.config.tableName ?? "sys_metadata";
1156
+ const dbLoader = new DatabaseLoader({
1157
+ engine,
1158
+ tableName,
1159
+ organizationId,
1160
+ projectId,
1161
+ cache: this.config.cache?.databaseLoader
1162
+ });
1163
+ this.registerLoader(dbLoader);
1164
+ this.logger.info("DatabaseLoader configured via DataEngine", { tableName });
1165
+ }
1042
1166
  /**
1043
1167
  * Set the realtime service for publishing metadata change events.
1044
1168
  * Should be called after kernel resolves the realtime service.
@@ -1066,10 +1190,19 @@ var MetadataManager = class {
1066
1190
  * should not be written to during runtime registration.
1067
1191
  */
1068
1192
  async register(type, name, data) {
1193
+ if (this.config.persistence?.writable === false) {
1194
+ const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
1195
+ if (this.config.validation?.throwOnError) {
1196
+ throw new Error(msg);
1197
+ }
1198
+ this.logger.warn(msg);
1199
+ return;
1200
+ }
1069
1201
  if (!this.registry.has(type)) {
1070
1202
  this.registry.set(type, /* @__PURE__ */ new Map());
1071
1203
  }
1072
1204
  this.registry.get(type).set(name, data);
1205
+ this.invalidateListCache(type);
1073
1206
  for (const loader of this.loaders.values()) {
1074
1207
  if (loader.save && loader.contract.protocol === "datasource:" && loader.contract.capabilities.write) {
1075
1208
  await loader.save(type, name, data);
@@ -1111,6 +1244,10 @@ var MetadataManager = class {
1111
1244
  * List all metadata items of a given type
1112
1245
  */
1113
1246
  async list(type) {
1247
+ const cached = this.listCache.get(type);
1248
+ if (cached && Date.now() - cached.ts < _MetadataManager.LIST_CACHE_TTL_MS) {
1249
+ return cached.items;
1250
+ }
1114
1251
  const items = /* @__PURE__ */ new Map();
1115
1252
  const typeStore = this.registry.get(type);
1116
1253
  if (typeStore) {
@@ -1131,7 +1268,16 @@ var MetadataManager = class {
1131
1268
  this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
1132
1269
  }
1133
1270
  }
1134
- return Array.from(items.values());
1271
+ const result = Array.from(items.values());
1272
+ this.cacheListResult(type, result);
1273
+ return result;
1274
+ }
1275
+ cacheListResult(type, items) {
1276
+ this.listCache.set(type, { ts: Date.now(), items });
1277
+ }
1278
+ /** Internal helper: drop the cached `list()` result for a type. */
1279
+ invalidateListCache(type) {
1280
+ this.listCache.delete(type);
1135
1281
  }
1136
1282
  /**
1137
1283
  * Unregister/remove a metadata item by type and name.
@@ -1145,6 +1291,7 @@ var MetadataManager = class {
1145
1291
  this.registry.delete(type);
1146
1292
  }
1147
1293
  }
1294
+ this.invalidateListCache(type);
1148
1295
  for (const loader of this.loaders.values()) {
1149
1296
  if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
1150
1297
  if (typeof loader.delete === "function") {
@@ -1559,6 +1706,14 @@ var MetadataManager = class {
1559
1706
  * Save/update an overlay for a metadata item
1560
1707
  */
1561
1708
  async saveOverlay(overlay) {
1709
+ if (this.config.persistence?.overlayWritable === false) {
1710
+ const msg = `MetadataManager overlays are read-only (persistence.overlayWritable=false); refusing to save overlay for ${overlay.baseType}/${overlay.baseName}`;
1711
+ if (this.config.validation?.throwOnError) {
1712
+ throw new Error(msg);
1713
+ }
1714
+ this.logger.warn(msg);
1715
+ return;
1716
+ }
1562
1717
  const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
1563
1718
  this.overlays.set(key, overlay);
1564
1719
  }
@@ -1952,84 +2107,14 @@ var MetadataManager = class {
1952
2107
  if (!dbLoader) {
1953
2108
  throw new Error("History tracking requires a database loader to be configured");
1954
2109
  }
1955
- const driver = dbLoader.driver;
1956
- const tableName = dbLoader.tableName;
1957
- const historyTableName = dbLoader.historyTableName;
1958
- const tenantId = dbLoader.tenantId;
1959
- const filter = { type, name };
1960
- if (tenantId) {
1961
- filter.tenant_id = tenantId;
1962
- }
1963
- const metadataRecord = await driver.findOne(tableName, {
1964
- object: tableName,
1965
- where: filter
1966
- });
1967
- if (!metadataRecord) {
1968
- return {
1969
- records: [],
1970
- total: 0,
1971
- hasMore: false
1972
- };
1973
- }
1974
- const historyFilter = {
1975
- metadata_id: metadataRecord.id
1976
- };
1977
- if (tenantId) {
1978
- historyFilter.tenant_id = tenantId;
1979
- }
1980
- if (options?.operationType) {
1981
- historyFilter.operation_type = options.operationType;
1982
- }
1983
- if (options?.since) {
1984
- historyFilter.recorded_at = { $gte: options.since };
1985
- }
1986
- if (options?.until) {
1987
- if (historyFilter.recorded_at) {
1988
- historyFilter.recorded_at.$lte = options.until;
1989
- } else {
1990
- historyFilter.recorded_at = { $lte: options.until };
1991
- }
1992
- }
1993
- const limit = options?.limit ?? 50;
1994
- const offset = options?.offset ?? 0;
1995
- const historyRecords = await driver.find(historyTableName, {
1996
- object: historyTableName,
1997
- where: historyFilter,
1998
- orderBy: [{ field: "recorded_at", order: "desc" }],
1999
- limit: limit + 1,
2000
- // Fetch one extra to determine hasMore
2001
- offset
2002
- });
2003
- const hasMore = historyRecords.length > limit;
2004
- const records = historyRecords.slice(0, limit);
2005
- const total = await driver.count(historyTableName, {
2006
- object: historyTableName,
2007
- where: historyFilter
2008
- });
2009
- const includeMetadata = options?.includeMetadata !== false;
2010
- const historyResult = records.map((row) => {
2011
- const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
2012
- return {
2013
- id: row.id,
2014
- metadataId: row.metadata_id,
2015
- name: row.name,
2016
- type: row.type,
2017
- version: row.version,
2018
- operationType: row.operation_type,
2019
- metadata: includeMetadata ? parsedMetadata : null,
2020
- checksum: row.checksum,
2021
- previousChecksum: row.previous_checksum,
2022
- changeNote: row.change_note,
2023
- tenantId: row.tenant_id,
2024
- recordedBy: row.recorded_by,
2025
- recordedAt: row.recorded_at
2026
- };
2110
+ return dbLoader.queryHistory(type, name, {
2111
+ operationType: options?.operationType,
2112
+ since: options?.since,
2113
+ until: options?.until,
2114
+ limit: options?.limit,
2115
+ offset: options?.offset,
2116
+ includeMetadata: options?.includeMetadata
2027
2117
  });
2028
- return {
2029
- records: historyResult,
2030
- total,
2031
- hasMore
2032
- };
2033
2118
  }
2034
2119
  /**
2035
2120
  * Rollback a metadata item to a specific version.
@@ -2098,6 +2183,12 @@ var MetadataManager = class {
2098
2183
  };
2099
2184
  }
2100
2185
  };
2186
+ _MetadataManager.LIST_CACHE_TTL_MS = 3e4;
2187
+ var MetadataManager = _MetadataManager;
2188
+
2189
+ // src/plugin.ts
2190
+ import { readFile as readFile2 } from "fs/promises";
2191
+ import { createHash as createHash2 } from "crypto";
2101
2192
 
2102
2193
  // src/node-metadata-manager.ts
2103
2194
  import * as path2 from "path";
@@ -2215,7 +2306,7 @@ var FilesystemLoader = class {
2215
2306
  );
2216
2307
  for (const pattern of globPatterns) {
2217
2308
  const files = await glob(pattern, {
2218
- ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
2309
+ ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
2219
2310
  nodir: true
2220
2311
  });
2221
2312
  for (const file of files) {
@@ -2504,124 +2595,6 @@ var NodeMetadataManager = class extends MetadataManager {
2504
2595
  }
2505
2596
  };
2506
2597
 
2507
- // src/plugin.ts
2508
- import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
2509
- var MetadataPlugin = class {
2510
- constructor(options = {}) {
2511
- this.name = "com.objectstack.metadata";
2512
- this.type = "standard";
2513
- this.version = "1.0.0";
2514
- this.init = async (ctx) => {
2515
- ctx.logger.info("Initializing Metadata Manager", {
2516
- root: this.options.rootDir || process.cwd(),
2517
- watch: this.options.watch
2518
- });
2519
- ctx.registerService("metadata", this.manager);
2520
- console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
2521
- try {
2522
- ctx.getService("manifest").register({
2523
- id: "com.objectstack.metadata",
2524
- name: "Metadata",
2525
- version: "1.0.0",
2526
- type: "plugin",
2527
- namespace: "sys",
2528
- objects: [SysMetadataObject]
2529
- });
2530
- } catch {
2531
- }
2532
- ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
2533
- mode: "file-system",
2534
- features: ["watch", "persistence", "multi-format", "query", "overlay", "type-registry"]
2535
- });
2536
- };
2537
- this.start = async (ctx) => {
2538
- ctx.logger.info("Loading metadata from file system...");
2539
- const sortedTypes = [...DEFAULT_METADATA_TYPE_REGISTRY].sort((a, b) => a.loadOrder - b.loadOrder);
2540
- let totalLoaded = 0;
2541
- for (const entry of sortedTypes) {
2542
- try {
2543
- const items = await this.manager.loadMany(entry.type, {
2544
- recursive: true
2545
- });
2546
- if (items.length > 0) {
2547
- for (const item of items) {
2548
- const meta = item;
2549
- if (meta?.name) {
2550
- await this.manager.register(entry.type, meta.name, item);
2551
- }
2552
- }
2553
- ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`);
2554
- totalLoaded += items.length;
2555
- }
2556
- } catch (e) {
2557
- ctx.logger.debug(`No ${entry.type} metadata found`, { error: e.message });
2558
- }
2559
- }
2560
- ctx.logger.info("Metadata loading complete", {
2561
- totalItems: totalLoaded,
2562
- registeredTypes: sortedTypes.length
2563
- });
2564
- let driverBridged = false;
2565
- try {
2566
- const ql = ctx.getService("objectql");
2567
- if (ql) {
2568
- const tableName = this.manager["config"]?.tableName ?? "sys_metadata";
2569
- const driver = ql.getDriverForObject?.(tableName);
2570
- if (driver) {
2571
- ctx.logger.info("[MetadataPlugin] Bridging driver to MetadataManager via ObjectQL routing", {
2572
- tableName,
2573
- driver: driver.name
2574
- });
2575
- this.manager.setDatabaseDriver(driver);
2576
- driverBridged = true;
2577
- } else {
2578
- ctx.logger.debug("[MetadataPlugin] ObjectQL could not resolve driver for metadata table", { tableName });
2579
- }
2580
- }
2581
- } catch {
2582
- }
2583
- if (!driverBridged) {
2584
- try {
2585
- const services = ctx.getServices();
2586
- for (const [serviceName, service] of services) {
2587
- if (serviceName.startsWith("driver.") && service) {
2588
- ctx.logger.info("[MetadataPlugin] Bridging driver to MetadataManager (fallback: first driver)", {
2589
- driverService: serviceName
2590
- });
2591
- this.manager.setDatabaseDriver(service);
2592
- break;
2593
- }
2594
- }
2595
- } catch (e) {
2596
- ctx.logger.debug("[MetadataPlugin] No driver service found", { error: e.message });
2597
- }
2598
- }
2599
- try {
2600
- const realtimeService = ctx.getService("realtime");
2601
- if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
2602
- ctx.logger.info("[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing");
2603
- this.manager.setRealtimeService(realtimeService);
2604
- }
2605
- } catch (e) {
2606
- ctx.logger.debug("[MetadataPlugin] No realtime service found \u2014 metadata events will not be published", {
2607
- error: e.message
2608
- });
2609
- }
2610
- };
2611
- this.options = {
2612
- watch: true,
2613
- ...options
2614
- };
2615
- const rootDir = this.options.rootDir || process.cwd();
2616
- this.manager = new NodeMetadataManager({
2617
- rootDir,
2618
- watch: this.options.watch ?? true,
2619
- formats: ["yaml", "json", "typescript", "javascript"]
2620
- });
2621
- this.manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
2622
- }
2623
- };
2624
-
2625
2598
  // src/loaders/memory-loader.ts
2626
2599
  var MemoryLoader = class {
2627
2600
  constructor() {
@@ -2700,6 +2673,289 @@ var MemoryLoader = class {
2700
2673
  }
2701
2674
  };
2702
2675
 
2676
+ // src/plugin.ts
2677
+ import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
2678
+ import {
2679
+ SysMetadataObject as SysMetadataObject2,
2680
+ SysMetadataHistoryObject as SysMetadataHistoryObject2
2681
+ } from "@objectstack/platform-objects/metadata";
2682
+ var queryableMetadataObjects = [
2683
+ SysMetadataObject2,
2684
+ SysMetadataHistoryObject2
2685
+ ];
2686
+ var ARTIFACT_FIELD_TO_TYPE = {
2687
+ objects: "object",
2688
+ objectExtensions: "object_extension",
2689
+ apps: "app",
2690
+ views: "view",
2691
+ pages: "page",
2692
+ dashboards: "dashboard",
2693
+ reports: "report",
2694
+ actions: "action",
2695
+ themes: "theme",
2696
+ workflows: "workflow",
2697
+ approvals: "approval",
2698
+ flows: "flow",
2699
+ roles: "role",
2700
+ permissions: "permission",
2701
+ sharingRules: "sharing_rule",
2702
+ policies: "policy",
2703
+ apis: "api",
2704
+ webhooks: "webhook",
2705
+ agents: "agent",
2706
+ skills: "skill",
2707
+ ragPipelines: "rag_pipeline",
2708
+ hooks: "hook",
2709
+ mappings: "mapping",
2710
+ analyticsCubes: "analytics_cube",
2711
+ connectors: "connector",
2712
+ data: "dataset"
2713
+ };
2714
+ var MetadataPlugin = class {
2715
+ constructor(options = {}) {
2716
+ this.name = "com.objectstack.metadata";
2717
+ this.type = "standard";
2718
+ this.version = "1.0.0";
2719
+ this.init = async (ctx) => {
2720
+ ctx.logger.info("Initializing Metadata Manager", {
2721
+ root: this.options.rootDir || process.cwd(),
2722
+ watch: this.options.watch,
2723
+ artifactSource: this.options.artifactSource?.mode
2724
+ });
2725
+ ctx.registerService("metadata", this.manager);
2726
+ console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
2727
+ const registerSysObjects = this.options.registerSystemObjects !== false;
2728
+ if (registerSysObjects) {
2729
+ try {
2730
+ const manifestService = ctx.getService("manifest");
2731
+ manifestService.register({
2732
+ id: "com.objectstack.metadata-objects",
2733
+ name: "Metadata Platform Objects",
2734
+ version: "1.0.0",
2735
+ type: "plugin",
2736
+ scope: "system",
2737
+ defaultDatasource: "cloud",
2738
+ objects: queryableMetadataObjects
2739
+ });
2740
+ ctx.logger.info("Registered system metadata objects", {
2741
+ queryable: queryableMetadataObjects.map((object) => object.name)
2742
+ });
2743
+ } catch {
2744
+ }
2745
+ }
2746
+ ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
2747
+ mode: this.options.artifactSource?.mode ?? "file-system",
2748
+ features: ["watch", "multi-format", "query", "overlay", "type-registry"]
2749
+ });
2750
+ };
2751
+ this.start = async (ctx) => {
2752
+ const src = this.options.artifactSource;
2753
+ const mode = this.options.config?.bootstrap ?? "eager";
2754
+ ctx.logger.info("[MetadataPlugin] Bootstrapping metadata", {
2755
+ bootstrap: mode,
2756
+ artifactSource: src?.mode ?? "none"
2757
+ });
2758
+ if (mode === "artifact-only") {
2759
+ if (src?.mode === "local-file") {
2760
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
2761
+ } else if (src?.mode === "artifact-api") {
2762
+ await this._loadFromArtifactApi(ctx, src);
2763
+ } else {
2764
+ throw new Error("[MetadataPlugin] bootstrap=artifact-only requires options.artifactSource to be set");
2765
+ }
2766
+ } else if (mode === "lazy") {
2767
+ if (src?.mode === "local-file") {
2768
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
2769
+ } else if (src?.mode === "artifact-api") {
2770
+ await this._loadFromArtifactApi(ctx, src);
2771
+ } else {
2772
+ ctx.logger.info("[MetadataPlugin] lazy bootstrap \u2014 skipping filesystem priming; metadata loads on demand");
2773
+ }
2774
+ } else {
2775
+ if (src?.mode === "local-file") {
2776
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
2777
+ } else if (src?.mode === "artifact-api") {
2778
+ await this._loadFromArtifactApi(ctx, src);
2779
+ } else {
2780
+ await this._loadFromFileSystem(ctx);
2781
+ }
2782
+ }
2783
+ try {
2784
+ const realtimeService = ctx.getService("realtime");
2785
+ if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
2786
+ ctx.logger.info("[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing");
2787
+ this.manager.setRealtimeService(realtimeService);
2788
+ }
2789
+ } catch (e) {
2790
+ ctx.logger.debug("[MetadataPlugin] No realtime service found \u2014 metadata events will not be published", {
2791
+ error: e.message
2792
+ });
2793
+ }
2794
+ };
2795
+ this.options = {
2796
+ watch: true,
2797
+ ...options
2798
+ };
2799
+ const rootDir = this.options.rootDir || process.cwd();
2800
+ const bootstrapMode = this.options.config?.bootstrap ?? "eager";
2801
+ const effectiveWatch = bootstrapMode === "artifact-only" ? false : this.options.watch ?? true;
2802
+ this.manager = new NodeMetadataManager({
2803
+ rootDir,
2804
+ watch: effectiveWatch,
2805
+ formats: ["yaml", "json", "typescript", "javascript"]
2806
+ });
2807
+ this.manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
2808
+ }
2809
+ /**
2810
+ * Fetch JSON content from a URL with configurable timeout.
2811
+ */
2812
+ async _fetchJson(url, fetchTimeoutMs, token) {
2813
+ const envTimeout = Number(process.env.OS_ARTIFACT_FETCH_TIMEOUT_MS);
2814
+ const timeoutMs = fetchTimeoutMs ?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : void 0) ?? 6e4;
2815
+ const controller = new AbortController();
2816
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
2817
+ try {
2818
+ const headers = { Accept: "application/json, */*;q=0.5" };
2819
+ if (token) headers.Authorization = `Bearer ${token}`;
2820
+ const res = await fetch(url, { redirect: "follow", signal: controller.signal, headers });
2821
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
2822
+ const content = await res.text();
2823
+ return JSON.parse(content);
2824
+ } catch (e) {
2825
+ if (e?.name === "AbortError") {
2826
+ throw new Error(
2827
+ `fetch timed out after ${timeoutMs}ms \u2014 set artifactSource.fetchTimeoutMs or OS_ARTIFACT_FETCH_TIMEOUT_MS to extend it (0 disables)`
2828
+ );
2829
+ }
2830
+ throw e;
2831
+ } finally {
2832
+ if (timer) clearTimeout(timer);
2833
+ }
2834
+ }
2835
+ /**
2836
+ * Parse raw artifact JSON (envelope or bare definition) and register all
2837
+ * metadata items into the MetadataManager.
2838
+ */
2839
+ async _parseAndRegisterArtifact(ctx, raw, label) {
2840
+ const { ProjectArtifactSchema } = await import("@objectstack/spec/cloud");
2841
+ const { ObjectStackDefinitionSchema } = await import("@objectstack/spec");
2842
+ let metadata;
2843
+ const obj = raw;
2844
+ if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== void 0) {
2845
+ const artifact = ProjectArtifactSchema.parse(obj);
2846
+ metadata = artifact.metadata;
2847
+ } else if (obj?.success && obj?.data?.metadata) {
2848
+ const artifact = ProjectArtifactSchema.parse(obj.data);
2849
+ metadata = artifact.metadata;
2850
+ } else {
2851
+ const def = ObjectStackDefinitionSchema.parse(obj);
2852
+ const canonical = JSON.stringify(def, Object.keys(def).sort());
2853
+ const checksum = createHash2("sha256").update(canonical).digest("hex");
2854
+ const projectId = this.options.projectId ?? "proj_local";
2855
+ ProjectArtifactSchema.parse({
2856
+ schemaVersion: "0.1",
2857
+ projectId,
2858
+ commitId: "local-dev",
2859
+ checksum,
2860
+ metadata: def
2861
+ });
2862
+ metadata = def;
2863
+ }
2864
+ const memLoader = new MemoryLoader();
2865
+ const manifestPackageId = metadata?.manifest?.id ?? metadata?.id ?? void 0;
2866
+ let totalRegistered = 0;
2867
+ for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
2868
+ const items = metadata[field];
2869
+ if (!Array.isArray(items) || items.length === 0) continue;
2870
+ for (const item of items) {
2871
+ const name = item?.name;
2872
+ if (!name) continue;
2873
+ if (manifestPackageId && item._packageId === void 0) {
2874
+ item._packageId = manifestPackageId;
2875
+ }
2876
+ await memLoader.save(metaType, name, item);
2877
+ await this.manager.register(metaType, name, item);
2878
+ totalRegistered++;
2879
+ }
2880
+ }
2881
+ this.manager.registerLoader(memLoader);
2882
+ ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
2883
+ return totalRegistered;
2884
+ }
2885
+ async _loadFromLocalFile(ctx, filePath, fetchTimeoutMs) {
2886
+ const isUrl = /^https?:\/\//i.test(filePath);
2887
+ ctx.logger.info(
2888
+ `[MetadataPlugin] Loading metadata from ${isUrl ? "remote URL" : "local artifact file"}`,
2889
+ { path: filePath }
2890
+ );
2891
+ let raw;
2892
+ try {
2893
+ if (isUrl) {
2894
+ raw = await this._fetchJson(filePath, fetchTimeoutMs);
2895
+ } else {
2896
+ const content = await readFile2(filePath, "utf8");
2897
+ raw = JSON.parse(content);
2898
+ }
2899
+ } catch (e) {
2900
+ throw new Error(`[MetadataPlugin] Cannot read artifact ${isUrl ? "URL" : "file"} at "${filePath}": ${e.message}`);
2901
+ }
2902
+ await this._parseAndRegisterArtifact(ctx, raw, filePath);
2903
+ }
2904
+ /**
2905
+ * P2: Load metadata from the cloud artifact API endpoint.
2906
+ */
2907
+ async _loadFromArtifactApi(ctx, src) {
2908
+ const projectId = this.options.projectId;
2909
+ if (!projectId) {
2910
+ throw new Error("[MetadataPlugin] artifact-api source requires options.projectId to be set");
2911
+ }
2912
+ let artifactUrl = src.url.replace(/\/+$/, "");
2913
+ if (!/\/api\/v\d+\/cloud\/projects\//i.test(artifactUrl)) {
2914
+ artifactUrl = `${artifactUrl}/api/v1/cloud/projects/${projectId}/artifact`;
2915
+ }
2916
+ if (src.commitId) {
2917
+ artifactUrl += `${artifactUrl.includes("?") ? "&" : "?"}commit=${encodeURIComponent(src.commitId)}`;
2918
+ }
2919
+ ctx.logger.info("[MetadataPlugin] Loading metadata from artifact API", { url: artifactUrl });
2920
+ let raw;
2921
+ try {
2922
+ raw = await this._fetchJson(artifactUrl, src.fetchTimeoutMs, src.token);
2923
+ } catch (e) {
2924
+ throw new Error(`[MetadataPlugin] Cannot load artifact from API "${artifactUrl}": ${e.message}`);
2925
+ }
2926
+ await this._parseAndRegisterArtifact(ctx, raw, artifactUrl);
2927
+ }
2928
+ async _loadFromFileSystem(ctx) {
2929
+ ctx.logger.info("Loading metadata from file system...");
2930
+ const sortedTypes = [...DEFAULT_METADATA_TYPE_REGISTRY].sort((a, b) => a.loadOrder - b.loadOrder);
2931
+ let totalLoaded = 0;
2932
+ for (const entry of sortedTypes) {
2933
+ try {
2934
+ const items = await this.manager.loadMany(entry.type, {
2935
+ recursive: true,
2936
+ patterns: entry.filePatterns
2937
+ });
2938
+ if (items.length > 0) {
2939
+ for (const item of items) {
2940
+ const meta = item;
2941
+ if (meta?.name) {
2942
+ await this.manager.register(entry.type, meta.name, item);
2943
+ }
2944
+ }
2945
+ ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`);
2946
+ totalLoaded += items.length;
2947
+ }
2948
+ } catch (e) {
2949
+ ctx.logger.debug(`No ${entry.type} metadata found`, { error: e.message });
2950
+ }
2951
+ }
2952
+ ctx.logger.info("Metadata loading complete", {
2953
+ totalItems: totalLoaded,
2954
+ registeredTypes: sortedTypes.length
2955
+ });
2956
+ }
2957
+ };
2958
+
2703
2959
  // src/loaders/remote-loader.ts
2704
2960
  var RemoteLoader = class {
2705
2961
  constructor(baseUrl, authToken) {
@@ -2797,6 +3053,9 @@ var RemoteLoader = class {
2797
3053
  }
2798
3054
  };
2799
3055
 
3056
+ // src/index.ts
3057
+ import { SysMetadataObject as SysMetadataObject3, SysMetadataHistoryObject as SysMetadataHistoryObject3 } from "@objectstack/platform-objects/metadata";
3058
+
2800
3059
  // src/routes/history-routes.ts
2801
3060
  function registerMetadataHistoryRoutes(app, metadataService) {
2802
3061
  app.get("/api/v1/metadata/:type/:name/history", async (c) => {
@@ -2952,7 +3211,8 @@ var HistoryCleanupManager = class {
2952
3211
  async runCleanup() {
2953
3212
  const driver = this.dbLoader.driver;
2954
3213
  const historyTableName = this.dbLoader.historyTableName;
2955
- const tenantId = this.dbLoader.tenantId;
3214
+ const organizationId = this.dbLoader.organizationId;
3215
+ const projectId = this.dbLoader.projectId;
2956
3216
  let deleted = 0;
2957
3217
  let errors = 0;
2958
3218
  try {
@@ -2963,8 +3223,11 @@ var HistoryCleanupManager = class {
2963
3223
  const filter = {
2964
3224
  recorded_at: { $lt: cutoffISO }
2965
3225
  };
2966
- if (tenantId) {
2967
- filter.tenant_id = tenantId;
3226
+ if (organizationId) {
3227
+ filter.organization_id = organizationId;
3228
+ }
3229
+ if (projectId !== void 0) {
3230
+ filter.project_id = projectId;
2968
3231
  }
2969
3232
  try {
2970
3233
  const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
@@ -2976,9 +3239,12 @@ var HistoryCleanupManager = class {
2976
3239
  }
2977
3240
  if (this.policy.maxVersions) {
2978
3241
  try {
3242
+ const baseWhere = {};
3243
+ if (organizationId) baseWhere.organization_id = organizationId;
3244
+ if (projectId !== void 0) baseWhere.project_id = projectId;
2979
3245
  const metadataIds = await driver.find(historyTableName, {
2980
3246
  object: historyTableName,
2981
- where: tenantId ? { tenant_id: tenantId } : {},
3247
+ where: baseWhere,
2982
3248
  fields: ["metadata_id"]
2983
3249
  });
2984
3250
  const uniqueIds = /* @__PURE__ */ new Set();
@@ -2988,10 +3254,7 @@ var HistoryCleanupManager = class {
2988
3254
  }
2989
3255
  }
2990
3256
  for (const metadataId of uniqueIds) {
2991
- const filter = { metadata_id: metadataId };
2992
- if (tenantId) {
2993
- filter.tenant_id = tenantId;
2994
- }
3257
+ const filter = { metadata_id: metadataId, ...baseWhere };
2995
3258
  try {
2996
3259
  const historyRecords = await driver.find(historyTableName, {
2997
3260
  object: historyTableName,
@@ -3065,20 +3328,22 @@ var HistoryCleanupManager = class {
3065
3328
  async getCleanupStats() {
3066
3329
  const driver = this.dbLoader.driver;
3067
3330
  const historyTableName = this.dbLoader.historyTableName;
3068
- const tenantId = this.dbLoader.tenantId;
3331
+ const organizationId = this.dbLoader.organizationId;
3332
+ const projectId = this.dbLoader.projectId;
3069
3333
  let recordsByAge = 0;
3070
3334
  let recordsByCount = 0;
3071
3335
  try {
3336
+ const baseWhere = {};
3337
+ if (organizationId) baseWhere.organization_id = organizationId;
3338
+ if (projectId !== void 0) baseWhere.project_id = projectId;
3072
3339
  if (this.policy.maxAgeDays) {
3073
3340
  const cutoffDate = /* @__PURE__ */ new Date();
3074
3341
  cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
3075
3342
  const cutoffISO = cutoffDate.toISOString();
3076
3343
  const filter = {
3077
- recorded_at: { $lt: cutoffISO }
3344
+ recorded_at: { $lt: cutoffISO },
3345
+ ...baseWhere
3078
3346
  };
3079
- if (tenantId) {
3080
- filter.tenant_id = tenantId;
3081
- }
3082
3347
  recordsByAge = await driver.count(historyTableName, {
3083
3348
  object: historyTableName,
3084
3349
  where: filter
@@ -3087,7 +3352,7 @@ var HistoryCleanupManager = class {
3087
3352
  if (this.policy.maxVersions) {
3088
3353
  const metadataIds = await driver.find(historyTableName, {
3089
3354
  object: historyTableName,
3090
- where: tenantId ? { tenant_id: tenantId } : {},
3355
+ where: baseWhere,
3091
3356
  fields: ["metadata_id"]
3092
3357
  });
3093
3358
  const uniqueIds = /* @__PURE__ */ new Set();
@@ -3097,10 +3362,7 @@ var HistoryCleanupManager = class {
3097
3362
  }
3098
3363
  }
3099
3364
  for (const metadataId of uniqueIds) {
3100
- const filter = { metadata_id: metadataId };
3101
- if (tenantId) {
3102
- filter.tenant_id = tenantId;
3103
- }
3365
+ const filter = { metadata_id: metadataId, ...baseWhere };
3104
3366
  const count = await driver.count(historyTableName, {
3105
3367
  object: historyTableName,
3106
3368
  where: filter
@@ -3187,8 +3449,8 @@ export {
3187
3449
  migration_exports as Migration,
3188
3450
  NodeMetadataManager,
3189
3451
  RemoteLoader,
3190
- SysMetadataHistoryObject,
3191
- SysMetadataObject,
3452
+ SysMetadataHistoryObject3 as SysMetadataHistoryObject,
3453
+ SysMetadataObject3 as SysMetadataObject,
3192
3454
  TypeScriptSerializer,
3193
3455
  YAMLSerializer,
3194
3456
  calculateChecksum,