@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.cjs CHANGED
@@ -40,8 +40,8 @@ __export(node_exports, {
40
40
  Migration: () => migration_exports,
41
41
  NodeMetadataManager: () => NodeMetadataManager,
42
42
  RemoteLoader: () => RemoteLoader,
43
- SysMetadataHistoryObject: () => SysMetadataHistoryObject,
44
- SysMetadataObject: () => SysMetadataObject,
43
+ SysMetadataHistoryObject: () => import_metadata3.SysMetadataHistoryObject,
44
+ SysMetadataObject: () => import_metadata3.SysMetadataObject,
45
45
  TypeScriptSerializer: () => TypeScriptSerializer,
46
46
  YAMLSerializer: () => YAMLSerializer,
47
47
  calculateChecksum: () => calculateChecksum,
@@ -221,277 +221,8 @@ export default metadata;
221
221
  }
222
222
  };
223
223
 
224
- // src/objects/sys-metadata.object.ts
225
- var import_data = require("@objectstack/spec/data");
226
- var SysMetadataObject = import_data.ObjectSchema.create({
227
- namespace: "sys",
228
- name: "metadata",
229
- label: "System Metadata",
230
- pluralLabel: "System Metadata",
231
- icon: "settings",
232
- isSystem: true,
233
- description: "Stores platform and user-scope metadata records (objects, views, flows, etc.)",
234
- fields: {
235
- /** Primary Key (UUID) */
236
- id: import_data.Field.text({
237
- label: "ID",
238
- required: true,
239
- readonly: true
240
- }),
241
- /** Machine name — unique identifier used in code references */
242
- name: import_data.Field.text({
243
- label: "Name",
244
- required: true,
245
- searchable: true,
246
- maxLength: 255
247
- }),
248
- /** Metadata type (e.g. "object", "view", "flow") */
249
- type: import_data.Field.text({
250
- label: "Metadata Type",
251
- required: true,
252
- searchable: true,
253
- maxLength: 100
254
- }),
255
- /** Namespace / module grouping (e.g. "crm", "core") */
256
- namespace: import_data.Field.text({
257
- label: "Namespace",
258
- required: false,
259
- defaultValue: "default",
260
- maxLength: 100
261
- }),
262
- /** Package that owns/delivered this metadata */
263
- package_id: import_data.Field.text({
264
- label: "Package ID",
265
- required: false,
266
- maxLength: 255
267
- }),
268
- /** Who manages this record: package, platform, or user */
269
- managed_by: import_data.Field.select(["package", "platform", "user"], {
270
- label: "Managed By",
271
- required: false
272
- }),
273
- /** Scope: system (code), platform (admin DB), user (personal DB) */
274
- scope: import_data.Field.select(["system", "platform", "user"], {
275
- label: "Scope",
276
- required: true,
277
- defaultValue: "platform"
278
- }),
279
- /** JSON payload — the actual metadata configuration */
280
- metadata: import_data.Field.textarea({
281
- label: "Metadata",
282
- required: true,
283
- description: "JSON-serialized metadata payload"
284
- }),
285
- /** Parent metadata name for extension/override */
286
- extends: import_data.Field.text({
287
- label: "Extends",
288
- required: false,
289
- maxLength: 255
290
- }),
291
- /** Merge strategy when extending parent metadata */
292
- strategy: import_data.Field.select(["merge", "replace"], {
293
- label: "Strategy",
294
- required: false,
295
- defaultValue: "merge"
296
- }),
297
- /** Owner user ID (for user-scope items) */
298
- owner: import_data.Field.text({
299
- label: "Owner",
300
- required: false,
301
- maxLength: 255
302
- }),
303
- /** Lifecycle state */
304
- state: import_data.Field.select(["draft", "active", "archived", "deprecated"], {
305
- label: "State",
306
- required: false,
307
- defaultValue: "active"
308
- }),
309
- /** Tenant ID for multi-tenant isolation */
310
- tenant_id: import_data.Field.text({
311
- label: "Tenant ID",
312
- required: false,
313
- maxLength: 255
314
- }),
315
- /** Version number for optimistic concurrency */
316
- version: import_data.Field.number({
317
- label: "Version",
318
- required: false,
319
- defaultValue: 1
320
- }),
321
- /** Content checksum for change detection */
322
- checksum: import_data.Field.text({
323
- label: "Checksum",
324
- required: false,
325
- maxLength: 64
326
- }),
327
- /** Origin of this metadata record */
328
- source: import_data.Field.select(["filesystem", "database", "api", "migration"], {
329
- label: "Source",
330
- required: false
331
- }),
332
- /** Classification tags (JSON array) */
333
- tags: import_data.Field.textarea({
334
- label: "Tags",
335
- required: false,
336
- description: "JSON-serialized array of classification tags"
337
- }),
338
- /** Audit fields */
339
- created_by: import_data.Field.text({
340
- label: "Created By",
341
- required: false,
342
- readonly: true,
343
- maxLength: 255
344
- }),
345
- created_at: import_data.Field.datetime({
346
- label: "Created At",
347
- required: false,
348
- readonly: true
349
- }),
350
- updated_by: import_data.Field.text({
351
- label: "Updated By",
352
- required: false,
353
- maxLength: 255
354
- }),
355
- updated_at: import_data.Field.datetime({
356
- label: "Updated At",
357
- required: false
358
- })
359
- },
360
- indexes: [
361
- { fields: ["type", "name"], unique: true },
362
- { fields: ["type", "scope"] },
363
- { fields: ["tenant_id"] },
364
- { fields: ["state"] },
365
- { fields: ["namespace"] }
366
- ],
367
- enable: {
368
- trackHistory: true,
369
- searchable: false,
370
- apiEnabled: true,
371
- apiMethods: ["get", "list", "create", "update", "delete"],
372
- trash: false
373
- }
374
- });
375
-
376
- // src/objects/sys-metadata-history.object.ts
377
- var import_data2 = require("@objectstack/spec/data");
378
- var SysMetadataHistoryObject = import_data2.ObjectSchema.create({
379
- namespace: "sys",
380
- name: "metadata_history",
381
- label: "Metadata History",
382
- pluralLabel: "Metadata History",
383
- icon: "history",
384
- isSystem: true,
385
- description: "Version history and audit trail for metadata changes",
386
- fields: {
387
- /** Primary Key (UUID) */
388
- id: import_data2.Field.text({
389
- label: "ID",
390
- required: true,
391
- readonly: true
392
- }),
393
- /** Foreign key to sys_metadata.id */
394
- metadata_id: import_data2.Field.text({
395
- label: "Metadata ID",
396
- required: true,
397
- readonly: true,
398
- maxLength: 255
399
- }),
400
- /** Machine name (denormalized for easier querying) */
401
- name: import_data2.Field.text({
402
- label: "Name",
403
- required: true,
404
- searchable: true,
405
- readonly: true,
406
- maxLength: 255
407
- }),
408
- /** Metadata type (denormalized for easier querying) */
409
- type: import_data2.Field.text({
410
- label: "Metadata Type",
411
- required: true,
412
- searchable: true,
413
- readonly: true,
414
- maxLength: 100
415
- }),
416
- /** Version number at this snapshot */
417
- version: import_data2.Field.number({
418
- label: "Version",
419
- required: true,
420
- readonly: true
421
- }),
422
- /** Type of operation that created this history entry */
423
- operation_type: import_data2.Field.select(["create", "update", "publish", "revert", "delete"], {
424
- label: "Operation Type",
425
- required: true,
426
- readonly: true
427
- }),
428
- /** Historical metadata snapshot (JSON payload) */
429
- metadata: import_data2.Field.textarea({
430
- label: "Metadata",
431
- required: true,
432
- readonly: true,
433
- description: "JSON-serialized metadata snapshot at this version"
434
- }),
435
- /** SHA-256 checksum of metadata content */
436
- checksum: import_data2.Field.text({
437
- label: "Checksum",
438
- required: true,
439
- readonly: true,
440
- maxLength: 64
441
- }),
442
- /** Checksum of the previous version */
443
- previous_checksum: import_data2.Field.text({
444
- label: "Previous Checksum",
445
- required: false,
446
- readonly: true,
447
- maxLength: 64
448
- }),
449
- /** Human-readable description of changes */
450
- change_note: import_data2.Field.textarea({
451
- label: "Change Note",
452
- required: false,
453
- readonly: true,
454
- description: "Description of what changed in this version"
455
- }),
456
- /** Tenant ID for multi-tenant isolation */
457
- tenant_id: import_data2.Field.text({
458
- label: "Tenant ID",
459
- required: false,
460
- readonly: true,
461
- maxLength: 255
462
- }),
463
- /** User who made this change */
464
- recorded_by: import_data2.Field.text({
465
- label: "Recorded By",
466
- required: false,
467
- readonly: true,
468
- maxLength: 255
469
- }),
470
- /** When was this version recorded */
471
- recorded_at: import_data2.Field.datetime({
472
- label: "Recorded At",
473
- required: true,
474
- readonly: true
475
- })
476
- },
477
- indexes: [
478
- { fields: ["metadata_id", "version"], unique: true },
479
- { fields: ["metadata_id", "recorded_at"] },
480
- { fields: ["type", "name"] },
481
- { fields: ["recorded_at"] },
482
- { fields: ["operation_type"] },
483
- { fields: ["tenant_id"] }
484
- ],
485
- enable: {
486
- trackHistory: false,
487
- // Don't track history of history records
488
- searchable: false,
489
- apiEnabled: true,
490
- apiMethods: ["get", "list"],
491
- // Read-only via API
492
- trash: false
493
- }
494
- });
224
+ // src/loaders/database-loader.ts
225
+ var import_metadata = require("@objectstack/platform-objects/metadata");
495
226
 
496
227
  // src/utils/metadata-history-utils.ts
497
228
  async function calculateChecksum(metadata) {
@@ -591,6 +322,114 @@ function generateDiffSummary(diff) {
591
322
  return summary.join(", ");
592
323
  }
593
324
 
325
+ // src/utils/lru-cache.ts
326
+ var LRUCache = class {
327
+ constructor(options = {}) {
328
+ this.map = /* @__PURE__ */ new Map();
329
+ this.hits = 0;
330
+ this.misses = 0;
331
+ this.maxSize = options.maxSize && options.maxSize > 0 ? options.maxSize : 0;
332
+ this.ttl = options.ttl && options.ttl > 0 ? options.ttl : 0;
333
+ }
334
+ get(key) {
335
+ const entry = this.map.get(key);
336
+ if (!entry) {
337
+ this.misses++;
338
+ return void 0;
339
+ }
340
+ if (entry.expiresAt !== 0 && entry.expiresAt <= Date.now()) {
341
+ this.map.delete(key);
342
+ this.misses++;
343
+ return void 0;
344
+ }
345
+ this.map.delete(key);
346
+ this.map.set(key, entry);
347
+ this.hits++;
348
+ return entry.value;
349
+ }
350
+ set(key, value) {
351
+ if (this.map.has(key)) {
352
+ this.map.delete(key);
353
+ } else if (this.maxSize > 0 && this.map.size >= this.maxSize) {
354
+ const oldest = this.map.keys().next();
355
+ if (!oldest.done) this.map.delete(oldest.value);
356
+ }
357
+ this.map.set(key, {
358
+ value,
359
+ expiresAt: this.ttl > 0 ? Date.now() + this.ttl : 0
360
+ });
361
+ }
362
+ has(key) {
363
+ return this.get(key) !== void 0;
364
+ }
365
+ delete(key) {
366
+ return this.map.delete(key);
367
+ }
368
+ clear() {
369
+ this.map.clear();
370
+ }
371
+ get size() {
372
+ return this.map.size;
373
+ }
374
+ /** Diagnostic counters — useful for `metrics` endpoints. */
375
+ stats() {
376
+ const total = this.hits + this.misses;
377
+ return {
378
+ size: this.map.size,
379
+ hits: this.hits,
380
+ misses: this.misses,
381
+ hitRate: total === 0 ? 0 : this.hits / total
382
+ };
383
+ }
384
+ /** Resets hit/miss counters without dropping cached entries. */
385
+ resetStats() {
386
+ this.hits = 0;
387
+ this.misses = 0;
388
+ }
389
+ };
390
+
391
+ // src/migrations/add-sys-metadata-overlay-index.ts
392
+ var INDEX_NAME = "idx_sys_metadata_overlay_active";
393
+ var TABLE = "sys_metadata";
394
+ var COLUMNS = "(type, name, organization_id, project_id, scope)";
395
+ var WHERE = "state = 'active'";
396
+ async function addSysMetadataOverlayIndex(driver) {
397
+ const driverAny = driver;
398
+ const exec = async (sql) => {
399
+ if (typeof driverAny.raw === "function") {
400
+ await driverAny.raw(sql);
401
+ } else if (typeof driverAny.execute === "function") {
402
+ await driverAny.execute(sql);
403
+ } else {
404
+ throw new Error("driver has neither raw nor execute");
405
+ }
406
+ };
407
+ const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;
408
+ const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;
409
+ try {
410
+ await exec(partialSql);
411
+ return { index: INDEX_NAME, status: "created" };
412
+ } catch (err) {
413
+ const msg = err instanceof Error ? err.message : String(err);
414
+ if (/partial|where clause|syntax/i.test(msg)) {
415
+ try {
416
+ await exec(fallbackSql);
417
+ return { index: INDEX_NAME, status: "fallback_non_unique" };
418
+ } catch (fallbackErr) {
419
+ return {
420
+ index: INDEX_NAME,
421
+ status: "error",
422
+ error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)
423
+ };
424
+ }
425
+ }
426
+ if (/already exists/i.test(msg)) {
427
+ return { index: INDEX_NAME, status: "already_exists" };
428
+ }
429
+ return { index: INDEX_NAME, status: "error", error: msg };
430
+ }
431
+ }
432
+
594
433
  // src/loaders/database-loader.ts
595
434
  var DatabaseLoader = class {
596
435
  constructor(options) {
@@ -606,11 +445,103 @@ var DatabaseLoader = class {
606
445
  };
607
446
  this.schemaReady = false;
608
447
  this.historySchemaReady = false;
448
+ if (!options.driver && !options.engine) {
449
+ throw new Error("DatabaseLoader requires either a driver or engine");
450
+ }
609
451
  this.driver = options.driver;
452
+ this.engine = options.engine;
610
453
  this.tableName = options.tableName ?? "sys_metadata";
611
454
  this.historyTableName = options.historyTableName ?? "sys_metadata_history";
612
- this.tenantId = options.tenantId;
455
+ this.organizationId = options.organizationId;
456
+ this.projectId = options.projectId;
613
457
  this.trackHistory = options.trackHistory !== false;
458
+ const cacheOpts = options.cache;
459
+ const cacheEnabled = cacheOpts?.enabled !== false;
460
+ if (cacheEnabled) {
461
+ const lruOpts = {
462
+ maxSize: cacheOpts?.maxSize ?? 500,
463
+ ttl: cacheOpts?.ttl ?? 6e4
464
+ };
465
+ this.loadCache = new LRUCache(lruOpts);
466
+ this.loadManyCache = new LRUCache(lruOpts);
467
+ this.listCache = new LRUCache(lruOpts);
468
+ this.statCache = new LRUCache(lruOpts);
469
+ }
470
+ }
471
+ // ==========================================
472
+ // Cache helpers
473
+ // ==========================================
474
+ cacheKey(type, name) {
475
+ return `${type}::${name}`;
476
+ }
477
+ /**
478
+ * Invalidate all cached entries for a specific (type, name) pair plus
479
+ * the type-level aggregates (`loadMany`, `list`). Called from every write
480
+ * path (`save`, `delete`, `registerRollback`).
481
+ */
482
+ invalidate(type, name) {
483
+ if (!this.loadCache) return;
484
+ const key = this.cacheKey(type, name);
485
+ this.loadCache.delete(key);
486
+ this.statCache?.delete(key);
487
+ this.loadManyCache?.delete(type);
488
+ this.listCache?.delete(type);
489
+ }
490
+ /** Drop the entire cache — useful after bulk imports or schema changes. */
491
+ invalidateAll() {
492
+ this.loadCache?.clear();
493
+ this.loadManyCache?.clear();
494
+ this.listCache?.clear();
495
+ this.statCache?.clear();
496
+ }
497
+ /** Diagnostic: aggregated cache statistics for `metrics` endpoints. */
498
+ getCacheStats() {
499
+ return {
500
+ enabled: this.loadCache !== void 0,
501
+ load: this.loadCache?.stats() ?? null,
502
+ loadMany: this.loadManyCache?.stats() ?? null,
503
+ list: this.listCache?.stats() ?? null,
504
+ stat: this.statCache?.stats() ?? null
505
+ };
506
+ }
507
+ // ==========================================
508
+ // Internal CRUD helpers (driver vs engine)
509
+ // ==========================================
510
+ async _find(table, query) {
511
+ if (this.engine) {
512
+ return this.engine.find(table, query);
513
+ }
514
+ return this.driver.find(table, { object: table, ...query });
515
+ }
516
+ async _findOne(table, query) {
517
+ if (this.engine) {
518
+ return this.engine.findOne(table, query);
519
+ }
520
+ return this.driver.findOne(table, { object: table, ...query });
521
+ }
522
+ async _count(table, query) {
523
+ if (this.engine) {
524
+ return this.engine.count(table, query);
525
+ }
526
+ return this.driver.count(table, { object: table, ...query });
527
+ }
528
+ async _create(table, data) {
529
+ if (this.engine) {
530
+ return this.engine.insert(table, data);
531
+ }
532
+ return this.driver.create(table, data);
533
+ }
534
+ async _update(table, id, data) {
535
+ if (this.engine) {
536
+ return this.engine.update(table, { id, ...data });
537
+ }
538
+ return this.driver.update(table, id, data);
539
+ }
540
+ async _delete(table, id) {
541
+ if (this.engine) {
542
+ return this.engine.delete(table, { where: { id } });
543
+ }
544
+ return this.driver.delete(table, id);
614
545
  }
615
546
  /**
616
547
  * Ensure the metadata table exists.
@@ -619,12 +550,37 @@ var DatabaseLoader = class {
619
550
  */
620
551
  async ensureSchema() {
621
552
  if (this.schemaReady) return;
553
+ if (this.engine) {
554
+ this.schemaReady = true;
555
+ try {
556
+ const engineAny = this.engine;
557
+ let driver = engineAny?.driver ?? engineAny?.getDriver?.();
558
+ if (!driver && engineAny?.drivers instanceof Map) {
559
+ for (const candidate of engineAny.drivers.values()) {
560
+ const c = candidate;
561
+ if (c && (typeof c.raw === "function" || typeof c.execute === "function")) {
562
+ driver = candidate;
563
+ break;
564
+ }
565
+ }
566
+ }
567
+ if (driver) {
568
+ await addSysMetadataOverlayIndex(driver);
569
+ }
570
+ } catch {
571
+ }
572
+ return;
573
+ }
622
574
  try {
623
575
  await this.driver.syncSchema(this.tableName, {
624
- ...SysMetadataObject,
576
+ ...import_metadata.SysMetadataObject,
625
577
  name: this.tableName
626
578
  });
627
579
  this.schemaReady = true;
580
+ try {
581
+ await addSysMetadataOverlayIndex(this.driver);
582
+ } catch {
583
+ }
628
584
  } catch {
629
585
  this.schemaReady = true;
630
586
  }
@@ -635,9 +591,13 @@ var DatabaseLoader = class {
635
591
  */
636
592
  async ensureHistorySchema() {
637
593
  if (!this.trackHistory || this.historySchemaReady) return;
594
+ if (this.engine) {
595
+ this.historySchemaReady = true;
596
+ return;
597
+ }
638
598
  try {
639
599
  await this.driver.syncSchema(this.historyTableName, {
640
- ...SysMetadataHistoryObject,
600
+ ...import_metadata.SysMetadataHistoryObject,
641
601
  name: this.historyTableName
642
602
  });
643
603
  this.historySchemaReady = true;
@@ -647,16 +607,18 @@ var DatabaseLoader = class {
647
607
  }
648
608
  /**
649
609
  * Build base filter conditions for queries.
650
- * Always includes tenantId when configured.
610
+ * Filters by organizationId when configured; project_id when projectId is set,
611
+ * or null (platform-global) when not set.
651
612
  */
652
613
  baseFilter(type, name) {
653
614
  const filter = { type };
654
615
  if (name !== void 0) {
655
616
  filter.name = name;
656
617
  }
657
- if (this.tenantId) {
658
- filter.tenant_id = this.tenantId;
618
+ if (this.organizationId) {
619
+ filter.organization_id = this.organizationId;
659
620
  }
621
+ filter.project_id = this.projectId ?? null;
660
622
  return filter;
661
623
  }
662
624
  /**
@@ -695,10 +657,11 @@ var DatabaseLoader = class {
695
657
  changeNote,
696
658
  recordedBy,
697
659
  recordedAt: now,
698
- ...this.tenantId ? { tenantId: this.tenantId } : {}
660
+ ...this.organizationId ? { organizationId: this.organizationId } : {},
661
+ ...this.projectId !== void 0 ? { projectId: this.projectId } : {}
699
662
  };
700
663
  try {
701
- await this.driver.create(this.historyTableName, {
664
+ await this._create(this.historyTableName, {
702
665
  id: historyRecord.id,
703
666
  metadata_id: historyRecord.metadataId,
704
667
  name: historyRecord.name,
@@ -711,7 +674,8 @@ var DatabaseLoader = class {
711
674
  change_note: historyRecord.changeNote,
712
675
  recorded_by: historyRecord.recordedBy,
713
676
  recorded_at: historyRecord.recordedAt,
714
- ...this.tenantId ? { tenant_id: this.tenantId } : {}
677
+ ...this.organizationId ? { organization_id: this.organizationId } : {},
678
+ ...this.projectId !== void 0 ? { project_id: this.projectId } : {}
715
679
  });
716
680
  } catch (error) {
717
681
  console.error(`Failed to create history record for ${type}/${name}:`, error);
@@ -743,7 +707,8 @@ var DatabaseLoader = class {
743
707
  strategy: row.strategy ?? "merge",
744
708
  owner: row.owner,
745
709
  state: row.state ?? "active",
746
- tenantId: row.tenant_id,
710
+ organizationId: row.organization_id,
711
+ projectId: row.project_id,
747
712
  version: row.version ?? 1,
748
713
  checksum: row.checksum,
749
714
  source: row.source,
@@ -760,12 +725,24 @@ var DatabaseLoader = class {
760
725
  async load(type, name, _options) {
761
726
  const startTime = Date.now();
762
727
  await this.ensureSchema();
728
+ const key = this.cacheKey(type, name);
729
+ if (this.loadCache) {
730
+ const cached = this.loadCache.get(key);
731
+ if (cached !== void 0) {
732
+ return {
733
+ data: cached,
734
+ source: "database",
735
+ format: "json",
736
+ loadTime: Date.now() - startTime
737
+ };
738
+ }
739
+ }
763
740
  try {
764
- const row = await this.driver.findOne(this.tableName, {
765
- object: this.tableName,
741
+ const row = await this._findOne(this.tableName, {
766
742
  where: this.baseFilter(type, name)
767
743
  });
768
744
  if (!row) {
745
+ this.loadCache?.set(key, null);
769
746
  return {
770
747
  data: null,
771
748
  loadTime: Date.now() - startTime
@@ -773,6 +750,7 @@ var DatabaseLoader = class {
773
750
  }
774
751
  const data = this.rowToData(row);
775
752
  const record = this.rowToRecord(row);
753
+ this.loadCache?.set(key, data);
776
754
  return {
777
755
  data,
778
756
  source: "database",
@@ -789,21 +767,29 @@ var DatabaseLoader = class {
789
767
  }
790
768
  async loadMany(type, _options) {
791
769
  await this.ensureSchema();
770
+ if (this.loadManyCache) {
771
+ const cached = this.loadManyCache.get(type);
772
+ if (cached !== void 0) return cached;
773
+ }
792
774
  try {
793
- const rows = await this.driver.find(this.tableName, {
794
- object: this.tableName,
775
+ const rows = await this._find(this.tableName, {
795
776
  where: this.baseFilter(type)
796
777
  });
797
- return rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
778
+ const result = rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
779
+ this.loadManyCache?.set(type, result);
780
+ return result;
798
781
  } catch {
799
782
  return [];
800
783
  }
801
784
  }
802
785
  async exists(type, name) {
803
786
  await this.ensureSchema();
787
+ if (this.loadCache) {
788
+ const cached = this.loadCache.get(this.cacheKey(type, name));
789
+ if (cached !== void 0) return cached !== null;
790
+ }
804
791
  try {
805
- const count = await this.driver.count(this.tableName, {
806
- object: this.tableName,
792
+ const count = await this._count(this.tableName, {
807
793
  where: this.baseFilter(type, name)
808
794
  });
809
795
  return count > 0;
@@ -813,33 +799,47 @@ var DatabaseLoader = class {
813
799
  }
814
800
  async stat(type, name) {
815
801
  await this.ensureSchema();
802
+ const key = this.cacheKey(type, name);
803
+ if (this.statCache) {
804
+ const cached = this.statCache.get(key);
805
+ if (cached !== void 0) return cached;
806
+ }
816
807
  try {
817
- const row = await this.driver.findOne(this.tableName, {
818
- object: this.tableName,
808
+ const row = await this._findOne(this.tableName, {
819
809
  where: this.baseFilter(type, name)
820
810
  });
821
- if (!row) return null;
811
+ if (!row) {
812
+ this.statCache?.set(key, null);
813
+ return null;
814
+ }
822
815
  const record = this.rowToRecord(row);
823
816
  const metadataStr = typeof row.metadata === "string" ? row.metadata : JSON.stringify(row.metadata);
824
- return {
817
+ const stats = {
825
818
  size: metadataStr.length,
826
819
  mtime: record.updatedAt ?? record.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
827
820
  format: "json",
828
821
  etag: record.checksum
829
822
  };
823
+ this.statCache?.set(key, stats);
824
+ return stats;
830
825
  } catch {
831
826
  return null;
832
827
  }
833
828
  }
834
829
  async list(type) {
835
830
  await this.ensureSchema();
831
+ if (this.listCache) {
832
+ const cached = this.listCache.get(type);
833
+ if (cached !== void 0) return cached;
834
+ }
836
835
  try {
837
- const rows = await this.driver.find(this.tableName, {
838
- object: this.tableName,
836
+ const rows = await this._find(this.tableName, {
839
837
  where: this.baseFilter(type),
840
838
  fields: ["name"]
841
839
  });
842
- return rows.map((row) => row.name).filter((name) => typeof name === "string");
840
+ const names = rows.map((row) => row.name).filter((name) => typeof name === "string");
841
+ this.listCache?.set(type, names);
842
+ return names;
843
843
  } catch {
844
844
  return [];
845
845
  }
@@ -851,8 +851,7 @@ var DatabaseLoader = class {
851
851
  async getHistoryRecord(type, name, version) {
852
852
  if (!this.trackHistory) return null;
853
853
  await this.ensureHistorySchema();
854
- const metadataRow = await this.driver.findOne(this.tableName, {
855
- object: this.tableName,
854
+ const metadataRow = await this._findOne(this.tableName, {
856
855
  where: this.baseFilter(type, name)
857
856
  });
858
857
  if (!metadataRow) return null;
@@ -860,11 +859,11 @@ var DatabaseLoader = class {
860
859
  metadata_id: metadataRow.id,
861
860
  version
862
861
  };
863
- if (this.tenantId) {
864
- filter.tenant_id = this.tenantId;
862
+ if (this.organizationId) {
863
+ filter.organization_id = this.organizationId;
865
864
  }
866
- const row = await this.driver.findOne(this.historyTableName, {
867
- object: this.historyTableName,
865
+ filter.project_id = this.projectId ?? null;
866
+ const row = await this._findOne(this.historyTableName, {
868
867
  where: filter
869
868
  });
870
869
  if (!row) return null;
@@ -879,11 +878,80 @@ var DatabaseLoader = class {
879
878
  checksum: row.checksum,
880
879
  previousChecksum: row.previous_checksum,
881
880
  changeNote: row.change_note,
882
- tenantId: row.tenant_id,
881
+ organizationId: row.organization_id,
882
+ projectId: row.project_id,
883
883
  recordedBy: row.recorded_by,
884
884
  recordedAt: row.recorded_at
885
885
  };
886
886
  }
887
+ /**
888
+ * Query history records with pagination and filtering.
889
+ * Encapsulates history table queries so MetadataManager doesn't need
890
+ * direct driver access.
891
+ */
892
+ async queryHistory(type, name, options) {
893
+ if (!this.trackHistory) {
894
+ return { records: [], total: 0, hasMore: false };
895
+ }
896
+ await this.ensureSchema();
897
+ await this.ensureHistorySchema();
898
+ const filter = { type, name };
899
+ if (this.organizationId) filter.organization_id = this.organizationId;
900
+ filter.project_id = this.projectId ?? null;
901
+ const metadataRecord = await this._findOne(this.tableName, { where: filter });
902
+ if (!metadataRecord) {
903
+ return { records: [], total: 0, hasMore: false };
904
+ }
905
+ const historyFilter = {
906
+ metadata_id: metadataRecord.id
907
+ };
908
+ if (this.organizationId) historyFilter.organization_id = this.organizationId;
909
+ historyFilter.project_id = this.projectId ?? null;
910
+ if (options?.operationType) historyFilter.operation_type = options.operationType;
911
+ if (options?.since) historyFilter.recorded_at = { $gte: options.since };
912
+ if (options?.until) {
913
+ if (historyFilter.recorded_at) {
914
+ historyFilter.recorded_at.$lte = options.until;
915
+ } else {
916
+ historyFilter.recorded_at = { $lte: options.until };
917
+ }
918
+ }
919
+ const limit = options?.limit ?? 50;
920
+ const offset = options?.offset ?? 0;
921
+ const historyRecords = await this._find(this.historyTableName, {
922
+ where: historyFilter,
923
+ orderBy: [
924
+ { field: "recorded_at", order: "desc" },
925
+ { field: "version", order: "desc" }
926
+ ],
927
+ limit: limit + 1,
928
+ offset
929
+ });
930
+ const hasMore = historyRecords.length > limit;
931
+ const records = historyRecords.slice(0, limit);
932
+ const total = await this._count(this.historyTableName, { where: historyFilter });
933
+ const includeMetadata = options?.includeMetadata !== false;
934
+ const result = records.map((row) => {
935
+ const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
936
+ return {
937
+ id: row.id,
938
+ metadataId: row.metadata_id,
939
+ name: row.name,
940
+ type: row.type,
941
+ version: row.version,
942
+ operationType: row.operation_type,
943
+ metadata: includeMetadata ? parsedMetadata : null,
944
+ checksum: row.checksum,
945
+ previousChecksum: row.previous_checksum,
946
+ changeNote: row.change_note,
947
+ organizationId: row.organization_id,
948
+ projectId: row.project_id,
949
+ recordedBy: row.recorded_by,
950
+ recordedAt: row.recorded_at
951
+ };
952
+ });
953
+ return { records: result, total, hasMore };
954
+ }
887
955
  /**
888
956
  * Perform a rollback: persist `restoredData` as the new current state and record a
889
957
  * single 'revert' history entry (instead of the usual 'update' entry that `save()`
@@ -896,8 +964,7 @@ var DatabaseLoader = class {
896
964
  const now = (/* @__PURE__ */ new Date()).toISOString();
897
965
  const metadataJson = JSON.stringify(restoredData);
898
966
  const newChecksum = await calculateChecksum(restoredData);
899
- const existing = await this.driver.findOne(this.tableName, {
900
- object: this.tableName,
967
+ const existing = await this._findOne(this.tableName, {
901
968
  where: this.baseFilter(type, name)
902
969
  });
903
970
  if (!existing) {
@@ -905,13 +972,14 @@ var DatabaseLoader = class {
905
972
  }
906
973
  const previousChecksum = existing.checksum;
907
974
  const newVersion = (existing.version ?? 0) + 1;
908
- await this.driver.update(this.tableName, existing.id, {
975
+ await this._update(this.tableName, existing.id, {
909
976
  metadata: metadataJson,
910
977
  version: newVersion,
911
978
  checksum: newChecksum,
912
979
  updated_at: now,
913
980
  state: "active"
914
981
  });
982
+ this.invalidate(type, name);
915
983
  await this.createHistoryRecord(
916
984
  existing.id,
917
985
  type,
@@ -931,13 +999,13 @@ var DatabaseLoader = class {
931
999
  const metadataJson = JSON.stringify(data);
932
1000
  const newChecksum = await calculateChecksum(data);
933
1001
  try {
934
- const existing = await this.driver.findOne(this.tableName, {
935
- object: this.tableName,
1002
+ const existing = await this._findOne(this.tableName, {
936
1003
  where: this.baseFilter(type, name)
937
1004
  });
938
1005
  if (existing) {
939
1006
  const previousChecksum = existing.checksum;
940
1007
  if (newChecksum === previousChecksum) {
1008
+ this.loadCache?.set(this.cacheKey(type, name), data);
941
1009
  return {
942
1010
  success: true,
943
1011
  path: `datasource://${this.tableName}/${type}/${name}`,
@@ -946,13 +1014,14 @@ var DatabaseLoader = class {
946
1014
  };
947
1015
  }
948
1016
  const version = (existing.version ?? 0) + 1;
949
- await this.driver.update(this.tableName, existing.id, {
1017
+ await this._update(this.tableName, existing.id, {
950
1018
  metadata: metadataJson,
951
1019
  version,
952
1020
  checksum: newChecksum,
953
1021
  updated_at: now,
954
1022
  state: "active"
955
1023
  });
1024
+ this.invalidate(type, name);
956
1025
  await this.createHistoryRecord(
957
1026
  existing.id,
958
1027
  type,
@@ -970,7 +1039,7 @@ var DatabaseLoader = class {
970
1039
  };
971
1040
  } else {
972
1041
  const id = generateId();
973
- await this.driver.create(this.tableName, {
1042
+ await this._create(this.tableName, {
974
1043
  id,
975
1044
  name,
976
1045
  type,
@@ -982,10 +1051,12 @@ var DatabaseLoader = class {
982
1051
  state: "active",
983
1052
  version: 1,
984
1053
  source: "database",
985
- ...this.tenantId ? { tenant_id: this.tenantId } : {},
1054
+ ...this.organizationId ? { organization_id: this.organizationId } : {},
1055
+ ...this.projectId !== void 0 ? { project_id: this.projectId } : { project_id: null },
986
1056
  created_at: now,
987
1057
  updated_at: now
988
1058
  });
1059
+ this.invalidate(type, name);
989
1060
  await this.createHistoryRecord(
990
1061
  id,
991
1062
  type,
@@ -1012,14 +1083,14 @@ var DatabaseLoader = class {
1012
1083
  */
1013
1084
  async delete(type, name) {
1014
1085
  await this.ensureSchema();
1015
- const existing = await this.driver.findOne(this.tableName, {
1016
- object: this.tableName,
1086
+ const existing = await this._findOne(this.tableName, {
1017
1087
  where: this.baseFilter(type, name)
1018
1088
  });
1019
1089
  if (!existing) {
1020
1090
  return;
1021
1091
  }
1022
- await this.driver.delete(this.tableName, existing.id);
1092
+ await this._delete(this.tableName, existing.id);
1093
+ this.invalidate(type, name);
1023
1094
  }
1024
1095
  };
1025
1096
  function generateId() {
@@ -1030,7 +1101,7 @@ function generateId() {
1030
1101
  }
1031
1102
 
1032
1103
  // src/metadata-manager.ts
1033
- var MetadataManager = class {
1104
+ var _MetadataManager = class _MetadataManager {
1034
1105
  constructor(config) {
1035
1106
  this.loaders = /* @__PURE__ */ new Map();
1036
1107
  this.watchCallbacks = /* @__PURE__ */ new Map();
@@ -1042,6 +1113,18 @@ var MetadataManager = class {
1042
1113
  this.typeRegistry = [];
1043
1114
  // Dependency tracking: "type:name" -> dependencies
1044
1115
  this.dependencies = /* @__PURE__ */ new Map();
1116
+ // Short-lived cache for list() results. Built primarily to break the
1117
+ // deadlock that occurs when security/permission middleware calls
1118
+ // `list('permission')` from inside a user-initiated DB transaction: the
1119
+ // DatabaseLoader's `engine.find('sys_metadata', ...)` would then try to
1120
+ // acquire a fresh knex connection while the transaction is still holding
1121
+ // SQLite's single connection — knex waits the full `acquireConnectionTimeout`
1122
+ // (60s) before returning []. The cache absorbs the repeated lookups so the
1123
+ // loader is only hit once per TTL window.
1124
+ //
1125
+ // Invalidated on every `register()` / `unregister()` to keep CRUD writes
1126
+ // visible to subsequent reads.
1127
+ this.listCache = /* @__PURE__ */ new Map();
1045
1128
  this.config = config;
1046
1129
  this.logger = (0, import_core.createLogger)({ level: "info", format: "pretty" });
1047
1130
  this.serializers = /* @__PURE__ */ new Map();
@@ -1076,16 +1159,57 @@ var MetadataManager = class {
1076
1159
  * Can be called at any time to enable database storage (e.g. after kernel resolves the driver).
1077
1160
  *
1078
1161
  * @param driver - An IDataDriver instance for database operations
1079
- */
1080
- setDatabaseDriver(driver) {
1162
+ * @param organizationId - Organization ID for multi-tenant isolation
1163
+ * @param projectId - Project ID (undefined = platform-global)
1164
+ */
1165
+ setDatabaseDriver(driver, organizationId, projectId) {
1166
+ if (projectId !== void 0) {
1167
+ this.logger.info("Project kernel \u2014 skipping DatabaseLoader for sys_metadata (control-plane only)", {
1168
+ organizationId,
1169
+ projectId
1170
+ });
1171
+ return;
1172
+ }
1081
1173
  const tableName = this.config.tableName ?? "sys_metadata";
1082
1174
  const dbLoader = new DatabaseLoader({
1083
1175
  driver,
1084
- tableName
1176
+ tableName,
1177
+ organizationId,
1178
+ projectId,
1179
+ cache: this.config.cache?.databaseLoader
1085
1180
  });
1086
1181
  this.registerLoader(dbLoader);
1087
1182
  this.logger.info("DatabaseLoader configured", { datasource: this.config.datasource, tableName });
1088
1183
  }
1184
+ /**
1185
+ * Configure and register a DatabaseLoader backed by an IDataEngine (ObjectQL).
1186
+ * The engine handles datasource routing automatically — sys_metadata will
1187
+ * be routed to the correct driver via the standard namespace mapping.
1188
+ * No manual driver resolution needed.
1189
+ *
1190
+ * @param engine - An IDataEngine instance (typically the ObjectQL service)
1191
+ * @param organizationId - Organization ID for multi-tenant isolation
1192
+ * @param projectId - Project ID (undefined = platform-global)
1193
+ */
1194
+ setDataEngine(engine, organizationId, projectId) {
1195
+ if (projectId !== void 0) {
1196
+ this.logger.info("Project kernel \u2014 skipping DatabaseLoader for sys_metadata (control-plane only)", {
1197
+ organizationId,
1198
+ projectId
1199
+ });
1200
+ return;
1201
+ }
1202
+ const tableName = this.config.tableName ?? "sys_metadata";
1203
+ const dbLoader = new DatabaseLoader({
1204
+ engine,
1205
+ tableName,
1206
+ organizationId,
1207
+ projectId,
1208
+ cache: this.config.cache?.databaseLoader
1209
+ });
1210
+ this.registerLoader(dbLoader);
1211
+ this.logger.info("DatabaseLoader configured via DataEngine", { tableName });
1212
+ }
1089
1213
  /**
1090
1214
  * Set the realtime service for publishing metadata change events.
1091
1215
  * Should be called after kernel resolves the realtime service.
@@ -1113,10 +1237,19 @@ var MetadataManager = class {
1113
1237
  * should not be written to during runtime registration.
1114
1238
  */
1115
1239
  async register(type, name, data) {
1240
+ if (this.config.persistence?.writable === false) {
1241
+ const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
1242
+ if (this.config.validation?.throwOnError) {
1243
+ throw new Error(msg);
1244
+ }
1245
+ this.logger.warn(msg);
1246
+ return;
1247
+ }
1116
1248
  if (!this.registry.has(type)) {
1117
1249
  this.registry.set(type, /* @__PURE__ */ new Map());
1118
1250
  }
1119
1251
  this.registry.get(type).set(name, data);
1252
+ this.invalidateListCache(type);
1120
1253
  for (const loader of this.loaders.values()) {
1121
1254
  if (loader.save && loader.contract.protocol === "datasource:" && loader.contract.capabilities.write) {
1122
1255
  await loader.save(type, name, data);
@@ -1158,6 +1291,10 @@ var MetadataManager = class {
1158
1291
  * List all metadata items of a given type
1159
1292
  */
1160
1293
  async list(type) {
1294
+ const cached = this.listCache.get(type);
1295
+ if (cached && Date.now() - cached.ts < _MetadataManager.LIST_CACHE_TTL_MS) {
1296
+ return cached.items;
1297
+ }
1161
1298
  const items = /* @__PURE__ */ new Map();
1162
1299
  const typeStore = this.registry.get(type);
1163
1300
  if (typeStore) {
@@ -1178,7 +1315,16 @@ var MetadataManager = class {
1178
1315
  this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
1179
1316
  }
1180
1317
  }
1181
- return Array.from(items.values());
1318
+ const result = Array.from(items.values());
1319
+ this.cacheListResult(type, result);
1320
+ return result;
1321
+ }
1322
+ cacheListResult(type, items) {
1323
+ this.listCache.set(type, { ts: Date.now(), items });
1324
+ }
1325
+ /** Internal helper: drop the cached `list()` result for a type. */
1326
+ invalidateListCache(type) {
1327
+ this.listCache.delete(type);
1182
1328
  }
1183
1329
  /**
1184
1330
  * Unregister/remove a metadata item by type and name.
@@ -1192,6 +1338,7 @@ var MetadataManager = class {
1192
1338
  this.registry.delete(type);
1193
1339
  }
1194
1340
  }
1341
+ this.invalidateListCache(type);
1195
1342
  for (const loader of this.loaders.values()) {
1196
1343
  if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
1197
1344
  if (typeof loader.delete === "function") {
@@ -1606,6 +1753,14 @@ var MetadataManager = class {
1606
1753
  * Save/update an overlay for a metadata item
1607
1754
  */
1608
1755
  async saveOverlay(overlay) {
1756
+ if (this.config.persistence?.overlayWritable === false) {
1757
+ const msg = `MetadataManager overlays are read-only (persistence.overlayWritable=false); refusing to save overlay for ${overlay.baseType}/${overlay.baseName}`;
1758
+ if (this.config.validation?.throwOnError) {
1759
+ throw new Error(msg);
1760
+ }
1761
+ this.logger.warn(msg);
1762
+ return;
1763
+ }
1609
1764
  const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
1610
1765
  this.overlays.set(key, overlay);
1611
1766
  }
@@ -1999,84 +2154,14 @@ var MetadataManager = class {
1999
2154
  if (!dbLoader) {
2000
2155
  throw new Error("History tracking requires a database loader to be configured");
2001
2156
  }
2002
- const driver = dbLoader.driver;
2003
- const tableName = dbLoader.tableName;
2004
- const historyTableName = dbLoader.historyTableName;
2005
- const tenantId = dbLoader.tenantId;
2006
- const filter = { type, name };
2007
- if (tenantId) {
2008
- filter.tenant_id = tenantId;
2009
- }
2010
- const metadataRecord = await driver.findOne(tableName, {
2011
- object: tableName,
2012
- where: filter
2013
- });
2014
- if (!metadataRecord) {
2015
- return {
2016
- records: [],
2017
- total: 0,
2018
- hasMore: false
2019
- };
2020
- }
2021
- const historyFilter = {
2022
- metadata_id: metadataRecord.id
2023
- };
2024
- if (tenantId) {
2025
- historyFilter.tenant_id = tenantId;
2026
- }
2027
- if (options?.operationType) {
2028
- historyFilter.operation_type = options.operationType;
2029
- }
2030
- if (options?.since) {
2031
- historyFilter.recorded_at = { $gte: options.since };
2032
- }
2033
- if (options?.until) {
2034
- if (historyFilter.recorded_at) {
2035
- historyFilter.recorded_at.$lte = options.until;
2036
- } else {
2037
- historyFilter.recorded_at = { $lte: options.until };
2038
- }
2039
- }
2040
- const limit = options?.limit ?? 50;
2041
- const offset = options?.offset ?? 0;
2042
- const historyRecords = await driver.find(historyTableName, {
2043
- object: historyTableName,
2044
- where: historyFilter,
2045
- orderBy: [{ field: "recorded_at", order: "desc" }],
2046
- limit: limit + 1,
2047
- // Fetch one extra to determine hasMore
2048
- offset
2049
- });
2050
- const hasMore = historyRecords.length > limit;
2051
- const records = historyRecords.slice(0, limit);
2052
- const total = await driver.count(historyTableName, {
2053
- object: historyTableName,
2054
- where: historyFilter
2055
- });
2056
- const includeMetadata = options?.includeMetadata !== false;
2057
- const historyResult = records.map((row) => {
2058
- const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
2059
- return {
2060
- id: row.id,
2061
- metadataId: row.metadata_id,
2062
- name: row.name,
2063
- type: row.type,
2064
- version: row.version,
2065
- operationType: row.operation_type,
2066
- metadata: includeMetadata ? parsedMetadata : null,
2067
- checksum: row.checksum,
2068
- previousChecksum: row.previous_checksum,
2069
- changeNote: row.change_note,
2070
- tenantId: row.tenant_id,
2071
- recordedBy: row.recorded_by,
2072
- recordedAt: row.recorded_at
2073
- };
2157
+ return dbLoader.queryHistory(type, name, {
2158
+ operationType: options?.operationType,
2159
+ since: options?.since,
2160
+ until: options?.until,
2161
+ limit: options?.limit,
2162
+ offset: options?.offset,
2163
+ includeMetadata: options?.includeMetadata
2074
2164
  });
2075
- return {
2076
- records: historyResult,
2077
- total,
2078
- hasMore
2079
- };
2080
2165
  }
2081
2166
  /**
2082
2167
  * Rollback a metadata item to a specific version.
@@ -2145,6 +2230,12 @@ var MetadataManager = class {
2145
2230
  };
2146
2231
  }
2147
2232
  };
2233
+ _MetadataManager.LIST_CACHE_TTL_MS = 3e4;
2234
+ var MetadataManager = _MetadataManager;
2235
+
2236
+ // src/plugin.ts
2237
+ var import_promises = require("fs/promises");
2238
+ var import_node_crypto2 = require("crypto");
2148
2239
 
2149
2240
  // src/node-metadata-manager.ts
2150
2241
  var path2 = __toESM(require("path"), 1);
@@ -2262,7 +2353,7 @@ var FilesystemLoader = class {
2262
2353
  );
2263
2354
  for (const pattern of globPatterns) {
2264
2355
  const files = await (0, import_glob.glob)(pattern, {
2265
- ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
2356
+ ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
2266
2357
  nodir: true
2267
2358
  });
2268
2359
  for (const file of files) {
@@ -2551,124 +2642,6 @@ var NodeMetadataManager = class extends MetadataManager {
2551
2642
  }
2552
2643
  };
2553
2644
 
2554
- // src/plugin.ts
2555
- var import_kernel = require("@objectstack/spec/kernel");
2556
- var MetadataPlugin = class {
2557
- constructor(options = {}) {
2558
- this.name = "com.objectstack.metadata";
2559
- this.type = "standard";
2560
- this.version = "1.0.0";
2561
- this.init = async (ctx) => {
2562
- ctx.logger.info("Initializing Metadata Manager", {
2563
- root: this.options.rootDir || process.cwd(),
2564
- watch: this.options.watch
2565
- });
2566
- ctx.registerService("metadata", this.manager);
2567
- console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
2568
- try {
2569
- ctx.getService("manifest").register({
2570
- id: "com.objectstack.metadata",
2571
- name: "Metadata",
2572
- version: "1.0.0",
2573
- type: "plugin",
2574
- namespace: "sys",
2575
- objects: [SysMetadataObject]
2576
- });
2577
- } catch {
2578
- }
2579
- ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
2580
- mode: "file-system",
2581
- features: ["watch", "persistence", "multi-format", "query", "overlay", "type-registry"]
2582
- });
2583
- };
2584
- this.start = async (ctx) => {
2585
- ctx.logger.info("Loading metadata from file system...");
2586
- const sortedTypes = [...import_kernel.DEFAULT_METADATA_TYPE_REGISTRY].sort((a, b) => a.loadOrder - b.loadOrder);
2587
- let totalLoaded = 0;
2588
- for (const entry of sortedTypes) {
2589
- try {
2590
- const items = await this.manager.loadMany(entry.type, {
2591
- recursive: true
2592
- });
2593
- if (items.length > 0) {
2594
- for (const item of items) {
2595
- const meta = item;
2596
- if (meta?.name) {
2597
- await this.manager.register(entry.type, meta.name, item);
2598
- }
2599
- }
2600
- ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`);
2601
- totalLoaded += items.length;
2602
- }
2603
- } catch (e) {
2604
- ctx.logger.debug(`No ${entry.type} metadata found`, { error: e.message });
2605
- }
2606
- }
2607
- ctx.logger.info("Metadata loading complete", {
2608
- totalItems: totalLoaded,
2609
- registeredTypes: sortedTypes.length
2610
- });
2611
- let driverBridged = false;
2612
- try {
2613
- const ql = ctx.getService("objectql");
2614
- if (ql) {
2615
- const tableName = this.manager["config"]?.tableName ?? "sys_metadata";
2616
- const driver = ql.getDriverForObject?.(tableName);
2617
- if (driver) {
2618
- ctx.logger.info("[MetadataPlugin] Bridging driver to MetadataManager via ObjectQL routing", {
2619
- tableName,
2620
- driver: driver.name
2621
- });
2622
- this.manager.setDatabaseDriver(driver);
2623
- driverBridged = true;
2624
- } else {
2625
- ctx.logger.debug("[MetadataPlugin] ObjectQL could not resolve driver for metadata table", { tableName });
2626
- }
2627
- }
2628
- } catch {
2629
- }
2630
- if (!driverBridged) {
2631
- try {
2632
- const services = ctx.getServices();
2633
- for (const [serviceName, service] of services) {
2634
- if (serviceName.startsWith("driver.") && service) {
2635
- ctx.logger.info("[MetadataPlugin] Bridging driver to MetadataManager (fallback: first driver)", {
2636
- driverService: serviceName
2637
- });
2638
- this.manager.setDatabaseDriver(service);
2639
- break;
2640
- }
2641
- }
2642
- } catch (e) {
2643
- ctx.logger.debug("[MetadataPlugin] No driver service found", { error: e.message });
2644
- }
2645
- }
2646
- try {
2647
- const realtimeService = ctx.getService("realtime");
2648
- if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
2649
- ctx.logger.info("[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing");
2650
- this.manager.setRealtimeService(realtimeService);
2651
- }
2652
- } catch (e) {
2653
- ctx.logger.debug("[MetadataPlugin] No realtime service found \u2014 metadata events will not be published", {
2654
- error: e.message
2655
- });
2656
- }
2657
- };
2658
- this.options = {
2659
- watch: true,
2660
- ...options
2661
- };
2662
- const rootDir = this.options.rootDir || process.cwd();
2663
- this.manager = new NodeMetadataManager({
2664
- rootDir,
2665
- watch: this.options.watch ?? true,
2666
- formats: ["yaml", "json", "typescript", "javascript"]
2667
- });
2668
- this.manager.setTypeRegistry(import_kernel.DEFAULT_METADATA_TYPE_REGISTRY);
2669
- }
2670
- };
2671
-
2672
2645
  // src/loaders/memory-loader.ts
2673
2646
  var MemoryLoader = class {
2674
2647
  constructor() {
@@ -2747,6 +2720,286 @@ var MemoryLoader = class {
2747
2720
  }
2748
2721
  };
2749
2722
 
2723
+ // src/plugin.ts
2724
+ var import_kernel = require("@objectstack/spec/kernel");
2725
+ var import_metadata2 = require("@objectstack/platform-objects/metadata");
2726
+ var queryableMetadataObjects = [
2727
+ import_metadata2.SysMetadataObject,
2728
+ import_metadata2.SysMetadataHistoryObject
2729
+ ];
2730
+ var ARTIFACT_FIELD_TO_TYPE = {
2731
+ objects: "object",
2732
+ objectExtensions: "object_extension",
2733
+ apps: "app",
2734
+ views: "view",
2735
+ pages: "page",
2736
+ dashboards: "dashboard",
2737
+ reports: "report",
2738
+ actions: "action",
2739
+ themes: "theme",
2740
+ workflows: "workflow",
2741
+ approvals: "approval",
2742
+ flows: "flow",
2743
+ roles: "role",
2744
+ permissions: "permission",
2745
+ sharingRules: "sharing_rule",
2746
+ policies: "policy",
2747
+ apis: "api",
2748
+ webhooks: "webhook",
2749
+ agents: "agent",
2750
+ skills: "skill",
2751
+ ragPipelines: "rag_pipeline",
2752
+ hooks: "hook",
2753
+ mappings: "mapping",
2754
+ analyticsCubes: "analytics_cube",
2755
+ connectors: "connector",
2756
+ data: "dataset"
2757
+ };
2758
+ var MetadataPlugin = class {
2759
+ constructor(options = {}) {
2760
+ this.name = "com.objectstack.metadata";
2761
+ this.type = "standard";
2762
+ this.version = "1.0.0";
2763
+ this.init = async (ctx) => {
2764
+ ctx.logger.info("Initializing Metadata Manager", {
2765
+ root: this.options.rootDir || process.cwd(),
2766
+ watch: this.options.watch,
2767
+ artifactSource: this.options.artifactSource?.mode
2768
+ });
2769
+ ctx.registerService("metadata", this.manager);
2770
+ console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
2771
+ const registerSysObjects = this.options.registerSystemObjects !== false;
2772
+ if (registerSysObjects) {
2773
+ try {
2774
+ const manifestService = ctx.getService("manifest");
2775
+ manifestService.register({
2776
+ id: "com.objectstack.metadata-objects",
2777
+ name: "Metadata Platform Objects",
2778
+ version: "1.0.0",
2779
+ type: "plugin",
2780
+ scope: "system",
2781
+ defaultDatasource: "cloud",
2782
+ objects: queryableMetadataObjects
2783
+ });
2784
+ ctx.logger.info("Registered system metadata objects", {
2785
+ queryable: queryableMetadataObjects.map((object) => object.name)
2786
+ });
2787
+ } catch {
2788
+ }
2789
+ }
2790
+ ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
2791
+ mode: this.options.artifactSource?.mode ?? "file-system",
2792
+ features: ["watch", "multi-format", "query", "overlay", "type-registry"]
2793
+ });
2794
+ };
2795
+ this.start = async (ctx) => {
2796
+ const src = this.options.artifactSource;
2797
+ const mode = this.options.config?.bootstrap ?? "eager";
2798
+ ctx.logger.info("[MetadataPlugin] Bootstrapping metadata", {
2799
+ bootstrap: mode,
2800
+ artifactSource: src?.mode ?? "none"
2801
+ });
2802
+ if (mode === "artifact-only") {
2803
+ if (src?.mode === "local-file") {
2804
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
2805
+ } else if (src?.mode === "artifact-api") {
2806
+ await this._loadFromArtifactApi(ctx, src);
2807
+ } else {
2808
+ throw new Error("[MetadataPlugin] bootstrap=artifact-only requires options.artifactSource to be set");
2809
+ }
2810
+ } else if (mode === "lazy") {
2811
+ if (src?.mode === "local-file") {
2812
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
2813
+ } else if (src?.mode === "artifact-api") {
2814
+ await this._loadFromArtifactApi(ctx, src);
2815
+ } else {
2816
+ ctx.logger.info("[MetadataPlugin] lazy bootstrap \u2014 skipping filesystem priming; metadata loads on demand");
2817
+ }
2818
+ } else {
2819
+ if (src?.mode === "local-file") {
2820
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
2821
+ } else if (src?.mode === "artifact-api") {
2822
+ await this._loadFromArtifactApi(ctx, src);
2823
+ } else {
2824
+ await this._loadFromFileSystem(ctx);
2825
+ }
2826
+ }
2827
+ try {
2828
+ const realtimeService = ctx.getService("realtime");
2829
+ if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
2830
+ ctx.logger.info("[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing");
2831
+ this.manager.setRealtimeService(realtimeService);
2832
+ }
2833
+ } catch (e) {
2834
+ ctx.logger.debug("[MetadataPlugin] No realtime service found \u2014 metadata events will not be published", {
2835
+ error: e.message
2836
+ });
2837
+ }
2838
+ };
2839
+ this.options = {
2840
+ watch: true,
2841
+ ...options
2842
+ };
2843
+ const rootDir = this.options.rootDir || process.cwd();
2844
+ const bootstrapMode = this.options.config?.bootstrap ?? "eager";
2845
+ const effectiveWatch = bootstrapMode === "artifact-only" ? false : this.options.watch ?? true;
2846
+ this.manager = new NodeMetadataManager({
2847
+ rootDir,
2848
+ watch: effectiveWatch,
2849
+ formats: ["yaml", "json", "typescript", "javascript"]
2850
+ });
2851
+ this.manager.setTypeRegistry(import_kernel.DEFAULT_METADATA_TYPE_REGISTRY);
2852
+ }
2853
+ /**
2854
+ * Fetch JSON content from a URL with configurable timeout.
2855
+ */
2856
+ async _fetchJson(url, fetchTimeoutMs, token) {
2857
+ const envTimeout = Number(process.env.OS_ARTIFACT_FETCH_TIMEOUT_MS);
2858
+ const timeoutMs = fetchTimeoutMs ?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : void 0) ?? 6e4;
2859
+ const controller = new AbortController();
2860
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
2861
+ try {
2862
+ const headers = { Accept: "application/json, */*;q=0.5" };
2863
+ if (token) headers.Authorization = `Bearer ${token}`;
2864
+ const res = await fetch(url, { redirect: "follow", signal: controller.signal, headers });
2865
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
2866
+ const content = await res.text();
2867
+ return JSON.parse(content);
2868
+ } catch (e) {
2869
+ if (e?.name === "AbortError") {
2870
+ throw new Error(
2871
+ `fetch timed out after ${timeoutMs}ms \u2014 set artifactSource.fetchTimeoutMs or OS_ARTIFACT_FETCH_TIMEOUT_MS to extend it (0 disables)`
2872
+ );
2873
+ }
2874
+ throw e;
2875
+ } finally {
2876
+ if (timer) clearTimeout(timer);
2877
+ }
2878
+ }
2879
+ /**
2880
+ * Parse raw artifact JSON (envelope or bare definition) and register all
2881
+ * metadata items into the MetadataManager.
2882
+ */
2883
+ async _parseAndRegisterArtifact(ctx, raw, label) {
2884
+ const { ProjectArtifactSchema } = await import("@objectstack/spec/cloud");
2885
+ const { ObjectStackDefinitionSchema } = await import("@objectstack/spec");
2886
+ let metadata;
2887
+ const obj = raw;
2888
+ if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== void 0) {
2889
+ const artifact = ProjectArtifactSchema.parse(obj);
2890
+ metadata = artifact.metadata;
2891
+ } else if (obj?.success && obj?.data?.metadata) {
2892
+ const artifact = ProjectArtifactSchema.parse(obj.data);
2893
+ metadata = artifact.metadata;
2894
+ } else {
2895
+ const def = ObjectStackDefinitionSchema.parse(obj);
2896
+ const canonical = JSON.stringify(def, Object.keys(def).sort());
2897
+ const checksum = (0, import_node_crypto2.createHash)("sha256").update(canonical).digest("hex");
2898
+ const projectId = this.options.projectId ?? "proj_local";
2899
+ ProjectArtifactSchema.parse({
2900
+ schemaVersion: "0.1",
2901
+ projectId,
2902
+ commitId: "local-dev",
2903
+ checksum,
2904
+ metadata: def
2905
+ });
2906
+ metadata = def;
2907
+ }
2908
+ const memLoader = new MemoryLoader();
2909
+ const manifestPackageId = metadata?.manifest?.id ?? metadata?.id ?? void 0;
2910
+ let totalRegistered = 0;
2911
+ for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
2912
+ const items = metadata[field];
2913
+ if (!Array.isArray(items) || items.length === 0) continue;
2914
+ for (const item of items) {
2915
+ const name = item?.name;
2916
+ if (!name) continue;
2917
+ if (manifestPackageId && item._packageId === void 0) {
2918
+ item._packageId = manifestPackageId;
2919
+ }
2920
+ await memLoader.save(metaType, name, item);
2921
+ await this.manager.register(metaType, name, item);
2922
+ totalRegistered++;
2923
+ }
2924
+ }
2925
+ this.manager.registerLoader(memLoader);
2926
+ ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
2927
+ return totalRegistered;
2928
+ }
2929
+ async _loadFromLocalFile(ctx, filePath, fetchTimeoutMs) {
2930
+ const isUrl = /^https?:\/\//i.test(filePath);
2931
+ ctx.logger.info(
2932
+ `[MetadataPlugin] Loading metadata from ${isUrl ? "remote URL" : "local artifact file"}`,
2933
+ { path: filePath }
2934
+ );
2935
+ let raw;
2936
+ try {
2937
+ if (isUrl) {
2938
+ raw = await this._fetchJson(filePath, fetchTimeoutMs);
2939
+ } else {
2940
+ const content = await (0, import_promises.readFile)(filePath, "utf8");
2941
+ raw = JSON.parse(content);
2942
+ }
2943
+ } catch (e) {
2944
+ throw new Error(`[MetadataPlugin] Cannot read artifact ${isUrl ? "URL" : "file"} at "${filePath}": ${e.message}`);
2945
+ }
2946
+ await this._parseAndRegisterArtifact(ctx, raw, filePath);
2947
+ }
2948
+ /**
2949
+ * P2: Load metadata from the cloud artifact API endpoint.
2950
+ */
2951
+ async _loadFromArtifactApi(ctx, src) {
2952
+ const projectId = this.options.projectId;
2953
+ if (!projectId) {
2954
+ throw new Error("[MetadataPlugin] artifact-api source requires options.projectId to be set");
2955
+ }
2956
+ let artifactUrl = src.url.replace(/\/+$/, "");
2957
+ if (!/\/api\/v\d+\/cloud\/projects\//i.test(artifactUrl)) {
2958
+ artifactUrl = `${artifactUrl}/api/v1/cloud/projects/${projectId}/artifact`;
2959
+ }
2960
+ if (src.commitId) {
2961
+ artifactUrl += `${artifactUrl.includes("?") ? "&" : "?"}commit=${encodeURIComponent(src.commitId)}`;
2962
+ }
2963
+ ctx.logger.info("[MetadataPlugin] Loading metadata from artifact API", { url: artifactUrl });
2964
+ let raw;
2965
+ try {
2966
+ raw = await this._fetchJson(artifactUrl, src.fetchTimeoutMs, src.token);
2967
+ } catch (e) {
2968
+ throw new Error(`[MetadataPlugin] Cannot load artifact from API "${artifactUrl}": ${e.message}`);
2969
+ }
2970
+ await this._parseAndRegisterArtifact(ctx, raw, artifactUrl);
2971
+ }
2972
+ async _loadFromFileSystem(ctx) {
2973
+ ctx.logger.info("Loading metadata from file system...");
2974
+ const sortedTypes = [...import_kernel.DEFAULT_METADATA_TYPE_REGISTRY].sort((a, b) => a.loadOrder - b.loadOrder);
2975
+ let totalLoaded = 0;
2976
+ for (const entry of sortedTypes) {
2977
+ try {
2978
+ const items = await this.manager.loadMany(entry.type, {
2979
+ recursive: true,
2980
+ patterns: entry.filePatterns
2981
+ });
2982
+ if (items.length > 0) {
2983
+ for (const item of items) {
2984
+ const meta = item;
2985
+ if (meta?.name) {
2986
+ await this.manager.register(entry.type, meta.name, item);
2987
+ }
2988
+ }
2989
+ ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`);
2990
+ totalLoaded += items.length;
2991
+ }
2992
+ } catch (e) {
2993
+ ctx.logger.debug(`No ${entry.type} metadata found`, { error: e.message });
2994
+ }
2995
+ }
2996
+ ctx.logger.info("Metadata loading complete", {
2997
+ totalItems: totalLoaded,
2998
+ registeredTypes: sortedTypes.length
2999
+ });
3000
+ }
3001
+ };
3002
+
2750
3003
  // src/loaders/remote-loader.ts
2751
3004
  var RemoteLoader = class {
2752
3005
  constructor(baseUrl, authToken) {
@@ -2844,6 +3097,9 @@ var RemoteLoader = class {
2844
3097
  }
2845
3098
  };
2846
3099
 
3100
+ // src/index.ts
3101
+ var import_metadata3 = require("@objectstack/platform-objects/metadata");
3102
+
2847
3103
  // src/routes/history-routes.ts
2848
3104
  function registerMetadataHistoryRoutes(app, metadataService) {
2849
3105
  app.get("/api/v1/metadata/:type/:name/history", async (c) => {
@@ -2999,7 +3255,8 @@ var HistoryCleanupManager = class {
2999
3255
  async runCleanup() {
3000
3256
  const driver = this.dbLoader.driver;
3001
3257
  const historyTableName = this.dbLoader.historyTableName;
3002
- const tenantId = this.dbLoader.tenantId;
3258
+ const organizationId = this.dbLoader.organizationId;
3259
+ const projectId = this.dbLoader.projectId;
3003
3260
  let deleted = 0;
3004
3261
  let errors = 0;
3005
3262
  try {
@@ -3010,8 +3267,11 @@ var HistoryCleanupManager = class {
3010
3267
  const filter = {
3011
3268
  recorded_at: { $lt: cutoffISO }
3012
3269
  };
3013
- if (tenantId) {
3014
- filter.tenant_id = tenantId;
3270
+ if (organizationId) {
3271
+ filter.organization_id = organizationId;
3272
+ }
3273
+ if (projectId !== void 0) {
3274
+ filter.project_id = projectId;
3015
3275
  }
3016
3276
  try {
3017
3277
  const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
@@ -3023,9 +3283,12 @@ var HistoryCleanupManager = class {
3023
3283
  }
3024
3284
  if (this.policy.maxVersions) {
3025
3285
  try {
3286
+ const baseWhere = {};
3287
+ if (organizationId) baseWhere.organization_id = organizationId;
3288
+ if (projectId !== void 0) baseWhere.project_id = projectId;
3026
3289
  const metadataIds = await driver.find(historyTableName, {
3027
3290
  object: historyTableName,
3028
- where: tenantId ? { tenant_id: tenantId } : {},
3291
+ where: baseWhere,
3029
3292
  fields: ["metadata_id"]
3030
3293
  });
3031
3294
  const uniqueIds = /* @__PURE__ */ new Set();
@@ -3035,10 +3298,7 @@ var HistoryCleanupManager = class {
3035
3298
  }
3036
3299
  }
3037
3300
  for (const metadataId of uniqueIds) {
3038
- const filter = { metadata_id: metadataId };
3039
- if (tenantId) {
3040
- filter.tenant_id = tenantId;
3041
- }
3301
+ const filter = { metadata_id: metadataId, ...baseWhere };
3042
3302
  try {
3043
3303
  const historyRecords = await driver.find(historyTableName, {
3044
3304
  object: historyTableName,
@@ -3112,20 +3372,22 @@ var HistoryCleanupManager = class {
3112
3372
  async getCleanupStats() {
3113
3373
  const driver = this.dbLoader.driver;
3114
3374
  const historyTableName = this.dbLoader.historyTableName;
3115
- const tenantId = this.dbLoader.tenantId;
3375
+ const organizationId = this.dbLoader.organizationId;
3376
+ const projectId = this.dbLoader.projectId;
3116
3377
  let recordsByAge = 0;
3117
3378
  let recordsByCount = 0;
3118
3379
  try {
3380
+ const baseWhere = {};
3381
+ if (organizationId) baseWhere.organization_id = organizationId;
3382
+ if (projectId !== void 0) baseWhere.project_id = projectId;
3119
3383
  if (this.policy.maxAgeDays) {
3120
3384
  const cutoffDate = /* @__PURE__ */ new Date();
3121
3385
  cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
3122
3386
  const cutoffISO = cutoffDate.toISOString();
3123
3387
  const filter = {
3124
- recorded_at: { $lt: cutoffISO }
3388
+ recorded_at: { $lt: cutoffISO },
3389
+ ...baseWhere
3125
3390
  };
3126
- if (tenantId) {
3127
- filter.tenant_id = tenantId;
3128
- }
3129
3391
  recordsByAge = await driver.count(historyTableName, {
3130
3392
  object: historyTableName,
3131
3393
  where: filter
@@ -3134,7 +3396,7 @@ var HistoryCleanupManager = class {
3134
3396
  if (this.policy.maxVersions) {
3135
3397
  const metadataIds = await driver.find(historyTableName, {
3136
3398
  object: historyTableName,
3137
- where: tenantId ? { tenant_id: tenantId } : {},
3399
+ where: baseWhere,
3138
3400
  fields: ["metadata_id"]
3139
3401
  });
3140
3402
  const uniqueIds = /* @__PURE__ */ new Set();
@@ -3144,10 +3406,7 @@ var HistoryCleanupManager = class {
3144
3406
  }
3145
3407
  }
3146
3408
  for (const metadataId of uniqueIds) {
3147
- const filter = { metadata_id: metadataId };
3148
- if (tenantId) {
3149
- filter.tenant_id = tenantId;
3150
- }
3409
+ const filter = { metadata_id: metadataId, ...baseWhere };
3151
3410
  const count = await driver.count(historyTableName, {
3152
3411
  object: historyTableName,
3153
3412
  where: filter