@objectstack/metadata 17.1.0 → 17.3.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
@@ -38,9 +38,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
38
38
  // src/routes/hmr-routes.ts
39
39
  var hmr_routes_exports = {};
40
40
  __export(hmr_routes_exports, {
41
+ isDevMetadataEndpointEnabled: () => isDevMetadataEndpointEnabled,
41
42
  registerMetadataHmrRoutes: () => registerMetadataHmrRoutes
42
43
  });
44
+ function isDevMetadataEndpointEnabled(env = process.env) {
45
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "development";
46
+ }
43
47
  function registerMetadataHmrRoutes(app, manager, options = {}) {
48
+ if (!isDevMetadataEndpointEnabled()) return null;
44
49
  const routePath = options.path ?? "/api/v1/dev/metadata-events";
45
50
  const listeners = /* @__PURE__ */ new Set();
46
51
  const broadcast = (evt) => {
@@ -202,11 +207,12 @@ __export(node_exports, {
202
207
  Migration: () => migration_exports,
203
208
  NodeMetadataManager: () => NodeMetadataManager,
204
209
  RemoteLoader: () => RemoteLoader,
205
- SysMetadataHistoryObject: () => import_metadata_core3.SysMetadataHistoryObject,
206
- SysMetadataObject: () => import_metadata_core3.SysMetadataObject,
210
+ SysMetadataHistoryObject: () => import_metadata_core4.SysMetadataHistoryObject,
211
+ SysMetadataObject: () => import_metadata_core4.SysMetadataObject,
207
212
  TypeScriptSerializer: () => TypeScriptSerializer,
208
213
  YAMLSerializer: () => YAMLSerializer,
209
214
  calculateChecksum: () => calculateChecksum,
215
+ deriveViewContainerObject: () => deriveViewContainerObject,
210
216
  generateDiffSummary: () => generateDiffSummary,
211
217
  generateSimpleDiff: () => generateSimpleDiff
212
218
  });
@@ -386,7 +392,7 @@ export default metadata;
386
392
  };
387
393
 
388
394
  // src/loaders/database-loader.ts
389
- var import_metadata_core = require("@objectstack/metadata-core");
395
+ var import_metadata_core2 = require("@objectstack/metadata-core");
390
396
  var import_spec = require("@objectstack/spec");
391
397
  var import_shared = require("@objectstack/spec/shared");
392
398
 
@@ -554,141 +560,49 @@ var LRUCache = class {
554
560
  }
555
561
  };
556
562
 
557
- // src/utils/schema-sync-errors.ts
563
+ // src/loaders/database-loader.ts
558
564
  var import_types = require("@objectstack/types");
559
- var ALREADY_EXISTS = {
560
- codes: /* @__PURE__ */ new Set([
561
- // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
562
- "42P07",
563
- // duplicate_table
564
- "42701",
565
- // duplicate_column
566
- "42710",
567
- // duplicate_object — index / constraint already exists
568
- // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)
569
- "ER_TABLE_EXISTS_ERROR",
570
- // 1050
571
- "ER_DUP_FIELDNAME",
572
- // 1060
573
- "ER_DUP_KEYNAME"
574
- // 1061
575
- ]),
576
- errnos: /* @__PURE__ */ new Set([1050, 1060, 1061]),
577
- /**
578
- * Message fallback for drivers that carry no machine-readable code —
579
- * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for
580
- * every DDL failure, so the message is the only signal available:
581
- * - `table sys_metadata already exists`
582
- * - `duplicate column name: environment_id`
583
- * - `index idx_x already exists`
584
- * Postgres phrases its own as `relation "x" already exists` /
585
- * `column "x" of relation "y" already exists`, which matches the same test.
586
- */
587
- message: /already exists|duplicate column name|duplicate key name/i
588
- };
589
- var MISSING_TABLE = {
590
- codes: /* @__PURE__ */ new Set([
591
- "42P01",
592
- // PostgreSQL undefined_table
593
- "ER_NO_SUCH_TABLE"
594
- // MySQL / MariaDB 1146
595
- ]),
596
- errnos: /* @__PURE__ */ new Set([1146]),
597
- /**
598
- * - SQLite / libsql: `no such table: sys_metadata_history`
599
- * - PostgreSQL: `relation "sys_metadata_history" does not exist`
600
- * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
601
- */
602
- message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
603
- excludes: {
604
- /**
605
- * Exactly the three SQLSTATEs the docblock above already names as
606
- * must-stay-loud neighbours of `does not exist`. They are listed here
607
- * rather than merely trusted to miss the message test, because two of
608
- * them (42703 columns, 42704 constraints/triggers) have a phrasing that
609
- * *does* hit it, and because a code is a fact where prose is a guess.
610
- *
611
- * Postgres-shaped on purpose: measured, neither MySQL
612
- * (`Unknown column 'label' in 'field list'`) nor SQLite
613
- * (`no such column: bogus`, `table t has no column named label`)
614
- * phrases a sub-object failure so that a missing-table phrase falls out
615
- * of it, so there is nothing there to exclude. Adding their codes would
616
- * be surface with no defect behind it.
617
- */
618
- codes: /* @__PURE__ */ new Set([
619
- "42703",
620
- // undefined_column
621
- "42704",
622
- // undefined_object — constraint, trigger, role, type, …
623
- "3D000"
624
- // invalid_catalog_name — `database "x" does not exist`
625
- ]),
626
- /**
627
- * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
628
- * failure about something *inside* a relation, which therefore says the
629
- * relation itself is present. The two in-repo siblings that carry this
630
- * phrase are `mapDataError` (`packages/rest`, #5352) and
631
- * `service-analytics`'s missing-column subtraction (#6035/PR #6346).
632
- *
633
- * [#6615] All three now read one home — `@objectstack/types` — instead
634
- * of three hand-kept copies, so the phrase can no longer be taught to
635
- * the repo a fourth time or drift in one package only. The **width**
636
- * difference that used to justify the copy is preserved and is the
637
- * reason the home exports two functions rather than one: those two
638
- * *extract* the column name to phrase a better error, so a miss costs a
639
- * vaguer message; this one *excludes*, so a miss restores the
640
- * corruption. {@link isRelationSubObjectPhrase} is therefore the wider
641
- * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
642
- * anchors: any sub-object, any quoted identifier, any verdict.
643
- * Over-matching here only ever converts a benign verdict into a loud
644
- * one, which is the direction this whole module already errs in.
645
- */
646
- matchesMessage: import_types.isRelationSubObjectPhrase
565
+
566
+ // src/migrations/driver-exec.ts
567
+ function resolveDriverExec(driver) {
568
+ const candidate = driver;
569
+ if (!candidate) return void 0;
570
+ if (typeof candidate.execute === "function") {
571
+ return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);
647
572
  }
648
- };
649
- var MAX_CAUSE_DEPTH = 4;
650
- function matchesDriverError(error, signature, depth) {
651
- if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
652
- if (typeof error === "string") {
653
- if (signature.excludes?.matchesMessage(error)) return false;
654
- return signature.message.test(error);
655
- }
656
- if (typeof error !== "object") return false;
657
- const err = error;
658
- const excludes = signature.excludes;
659
- if (excludes) {
660
- if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
661
- if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
662
- }
663
- if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
664
- if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
665
- if (typeof err.message === "string" && signature.message.test(err.message)) return true;
666
- return matchesDriverError(err.cause, signature, depth + 1);
667
- }
668
- function isSchemaAlreadyExistsError(error, depth = 0) {
669
- return matchesDriverError(error, ALREADY_EXISTS, depth);
573
+ if (typeof candidate.raw === "function") {
574
+ return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);
575
+ }
576
+ return void 0;
670
577
  }
671
- function isMissingTableError(error, depth = 0) {
672
- return matchesDriverError(error, MISSING_TABLE, depth);
578
+ function driverExecRefusal(helper) {
579
+ return `${helper}: driver must expose an .execute(sql, bindings?) or .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) exposes .execute(), as does its SqliteWasmDriver subclass; cloud-side TursoDriver also conforms.`;
673
580
  }
674
581
 
675
582
  // src/migrations/migrate-project-id-to-environment-id.ts
676
- var AFFECTED_TABLES = [
677
- "sys_metadata",
678
- "sys_metadata_history"
679
- ];
583
+ var import_metadata_core = require("@objectstack/metadata-core");
584
+ var SOURCE_COLUMN = "project_id";
585
+ var TARGET_COLUMN = "environment_id";
586
+ var CANDIDATE_OBJECTS = [import_metadata_core.SysMetadataObject, import_metadata_core.SysMetadataHistoryObject];
587
+ function declaresColumn(object, column) {
588
+ return Object.prototype.hasOwnProperty.call(object.fields ?? {}, column);
589
+ }
590
+ var CANDIDATE_TABLES = CANDIDATE_OBJECTS.map((o) => o.name);
591
+ var AFFECTED_TABLES = CANDIDATE_OBJECTS.filter((o) => declaresColumn(o, TARGET_COLUMN)).map((o) => o.name);
680
592
  async function migrateProjectIdToEnvironmentId(driver) {
681
- const driverAny = driver;
682
- if (typeof driverAny.raw !== "function") {
683
- throw new Error(
684
- "migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms."
685
- );
593
+ const exec = resolveDriverExec(driver);
594
+ if (!exec) {
595
+ throw new Error(driverExecRefusal("migrateProjectIdToEnvironmentId"));
686
596
  }
687
597
  const results = [];
688
- for (const table of AFFECTED_TABLES) {
598
+ for (const table of CANDIDATE_TABLES) {
599
+ if (!AFFECTED_TABLES.includes(table)) {
600
+ results.push({ table, status: "skipped_not_declared" });
601
+ continue;
602
+ }
689
603
  try {
690
- const hasColumn = await _columnExists(driverAny, table, "project_id");
691
- const alreadyMigrated = await _columnExists(driverAny, table, "environment_id");
604
+ const hasColumn = await _columnExists(exec, table, SOURCE_COLUMN);
605
+ const alreadyMigrated = await _columnExists(exec, table, TARGET_COLUMN);
692
606
  if (alreadyMigrated && !hasColumn) {
693
607
  results.push({ table, status: "already_done" });
694
608
  continue;
@@ -697,8 +611,8 @@ async function migrateProjectIdToEnvironmentId(driver) {
697
611
  results.push({ table, status: "table_missing" });
698
612
  continue;
699
613
  }
700
- await driverAny.raw(
701
- `ALTER TABLE "${table}" RENAME COLUMN project_id TO environment_id`
614
+ await exec(
615
+ `ALTER TABLE "${table}" RENAME COLUMN ${SOURCE_COLUMN} TO ${TARGET_COLUMN}`
702
616
  );
703
617
  results.push({ table, status: "renamed" });
704
618
  } catch (err) {
@@ -707,14 +621,14 @@ async function migrateProjectIdToEnvironmentId(driver) {
707
621
  }
708
622
  return results;
709
623
  }
710
- async function _columnExists(driver, table, column) {
624
+ async function _columnExists(exec, table, column) {
711
625
  try {
712
- const rows = await driver.raw(`PRAGMA table_info("${table}")`);
626
+ const rows = await exec(`PRAGMA table_info("${table}")`);
713
627
  if (Array.isArray(rows) && rows.length > 0) {
714
628
  const list2 = Array.isArray(rows[0]) ? rows[0] : rows;
715
629
  return list2.some((r) => r?.name === column);
716
630
  }
717
- const result = await driver.raw(
631
+ const result = await exec(
718
632
  `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
719
633
  [table, column]
720
634
  );
@@ -726,6 +640,16 @@ async function _columnExists(driver, table, column) {
726
640
  }
727
641
 
728
642
  // src/loaders/database-loader.ts
643
+ function canonicalIsoInstant(value) {
644
+ if (value === null || value === void 0) return void 0;
645
+ if (value instanceof Date) return value.toISOString();
646
+ if (typeof value === "string") return value;
647
+ return String(value);
648
+ }
649
+ function isoFromValidDate(value) {
650
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
651
+ return value;
652
+ }
729
653
  var DatabaseLoader = class {
730
654
  constructor(options) {
731
655
  this.contract = {
@@ -862,9 +786,11 @@ var DatabaseLoader = class {
862
786
  }
863
787
  return this.driver.create(table, data);
864
788
  }
789
+ // `null` is the driver path's not-found answer (`IDataDriver.update()`,
790
+ // #13878); both callers here resolve the row first and discard the result.
865
791
  async _update(table, id, data) {
866
792
  if (this.engine) {
867
- return this.engine.update(table, { id, ...data });
793
+ return this.engine.update(table, { ...data, id });
868
794
  }
869
795
  return this.driver.update(table, id, data);
870
796
  }
@@ -909,7 +835,7 @@ var DatabaseLoader = class {
909
835
  }
910
836
  return max + 1;
911
837
  } catch (error) {
912
- if (isMissingTableError(error)) return 1;
838
+ if ((0, import_types.isMissingTableError)(error, this.historyTableName)) return 1;
913
839
  throw error;
914
840
  }
915
841
  }
@@ -947,11 +873,11 @@ var DatabaseLoader = class {
947
873
  }
948
874
  try {
949
875
  await this.driver.syncSchema(this.tableName, {
950
- ...import_metadata_core.SysMetadataObject,
876
+ ...import_metadata_core2.SysMetadataObject,
951
877
  name: this.tableName
952
878
  });
953
879
  } catch (error) {
954
- if (!isSchemaAlreadyExistsError(error)) {
880
+ if (!(0, import_types.isSchemaAlreadyExistsError)(error)) {
955
881
  if (!this.schemaFailureReported) {
956
882
  this.schemaFailureReported = true;
957
883
  console.error(
@@ -986,7 +912,7 @@ var DatabaseLoader = class {
986
912
  }
987
913
  try {
988
914
  await this.driver.syncSchema(this.historyTableName, {
989
- ...import_metadata_core.SysMetadataHistoryObject,
915
+ ...import_metadata_core2.SysMetadataHistoryObject,
990
916
  name: this.historyTableName
991
917
  });
992
918
  if (this.historySchemaFailureReported) {
@@ -997,7 +923,7 @@ var DatabaseLoader = class {
997
923
  }
998
924
  this.historySchemaReady = true;
999
925
  } catch (error) {
1000
- if (isSchemaAlreadyExistsError(error)) {
926
+ if ((0, import_types.isSchemaAlreadyExistsError)(error)) {
1001
927
  this.historySchemaReady = true;
1002
928
  return;
1003
929
  }
@@ -1155,9 +1081,9 @@ var DatabaseLoader = class {
1155
1081
  source: row.source,
1156
1082
  tags: row.tags ? typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags : void 0,
1157
1083
  createdBy: row.created_by,
1158
- createdAt: row.created_at,
1084
+ createdAt: isoFromValidDate(row.created_at),
1159
1085
  updatedBy: row.updated_by,
1160
- updatedAt: row.updated_at
1086
+ updatedAt: isoFromValidDate(row.updated_at)
1161
1087
  };
1162
1088
  }
1163
1089
  // ==========================================
@@ -1208,7 +1134,7 @@ var DatabaseLoader = class {
1208
1134
  * with its empty value.
1209
1135
  */
1210
1136
  rethrowUnlessTableUnprovisioned(error) {
1211
- if (isMissingTableError(error)) return;
1137
+ if ((0, import_types.isMissingTableError)(error, this.tableName)) return;
1212
1138
  throw error;
1213
1139
  }
1214
1140
  // ==========================================
@@ -1258,17 +1184,38 @@ var DatabaseLoader = class {
1258
1184
  };
1259
1185
  }
1260
1186
  }
1261
- async loadMany(type, _options) {
1187
+ /**
1188
+ * The one type-wide read both plural readers share: every row of `type`, each
1189
+ * body paired with the `name` COLUMN it was stored under.
1190
+ *
1191
+ * [#14205] `name` is `null` only for a row whose key column does not hold a
1192
+ * string. Such a row is still a body {@link loadMany} must return — dropping
1193
+ * it would change what consumers see today — but it has no usable identity,
1194
+ * so {@link loadManyKeyed} filters it out rather than invent one.
1195
+ *
1196
+ * One query and one cache entry serve both methods: `loadMany()` used to own
1197
+ * them, and splitting them would have made every keyed `list()` read miss the
1198
+ * cache and re-hit the database.
1199
+ */
1200
+ async readTypeRows(type) {
1262
1201
  await this.ensureSchema();
1263
1202
  if (this.loadManyCache) {
1264
1203
  const cached = this.loadManyCache.get(type);
1265
- if (cached !== void 0) return cached;
1204
+ if (cached !== void 0) {
1205
+ return cached;
1206
+ }
1266
1207
  }
1267
1208
  try {
1268
1209
  const rows = await this._find(this.tableName, {
1269
1210
  where: this.baseFilter(type)
1270
1211
  });
1271
- const result = rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
1212
+ const result = [];
1213
+ for (const row of rows) {
1214
+ const data = this.rowToData(row);
1215
+ if (data === null) continue;
1216
+ const name = row.name;
1217
+ result.push({ name: typeof name === "string" && name !== "" ? name : null, data });
1218
+ }
1272
1219
  this.loadManyCache?.set(type, result);
1273
1220
  return result;
1274
1221
  } catch (error) {
@@ -1276,6 +1223,29 @@ var DatabaseLoader = class {
1276
1223
  return [];
1277
1224
  }
1278
1225
  }
1226
+ async loadMany(type, _options) {
1227
+ return (await this.readTypeRows(type)).map((entry) => entry.data);
1228
+ }
1229
+ /**
1230
+ * [#14205] The keyed half of {@link loadMany} — see
1231
+ * {@link MetadataKeyedItem} for why the row key travels beside the body
1232
+ * instead of inside it.
1233
+ *
1234
+ * `DatabaseLoader` is where the defect was measured: an aggregated view
1235
+ * container is written by `register('view', OBJECT, container)` and stored
1236
+ * verbatim, so its `sys_metadata` row carries the identity in the `name`
1237
+ * COLUMN and the body has none. {@link rowToData} returns that body without
1238
+ * folding the column in — deliberately, and unchanged here.
1239
+ */
1240
+ async loadManyKeyed(type, _options) {
1241
+ const entries = await this.readTypeRows(type);
1242
+ const keyed = [];
1243
+ for (const entry of entries) {
1244
+ if (entry.name === null) continue;
1245
+ keyed.push({ name: entry.name, data: entry.data });
1246
+ }
1247
+ return keyed;
1248
+ }
1279
1249
  async exists(type, name) {
1280
1250
  await this.ensureSchema();
1281
1251
  if (this.loadCache) {
@@ -1311,7 +1281,7 @@ var DatabaseLoader = class {
1311
1281
  const metadataStr = typeof row.metadata === "string" ? row.metadata : JSON.stringify(row.metadata);
1312
1282
  const stats = {
1313
1283
  size: metadataStr.length,
1314
- mtime: record.updatedAt ?? record.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1284
+ mtime: canonicalIsoInstant(record.updatedAt ?? record.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
1315
1285
  format: "json",
1316
1286
  etag: record.checksum
1317
1287
  };
@@ -1372,7 +1342,7 @@ var DatabaseLoader = class {
1372
1342
  changeNote: row.change_note,
1373
1343
  organizationId: row.organization_id,
1374
1344
  recordedBy: row.recorded_by,
1375
- recordedAt: row.recorded_at
1345
+ recordedAt: isoFromValidDate(row.recorded_at)
1376
1346
  };
1377
1347
  }
1378
1348
  /**
@@ -1429,7 +1399,7 @@ var DatabaseLoader = class {
1429
1399
  changeNote: row.change_note,
1430
1400
  organizationId: row.organization_id,
1431
1401
  recordedBy: row.recorded_by,
1432
- recordedAt: row.recorded_at
1402
+ recordedAt: isoFromValidDate(row.recorded_at)
1433
1403
  };
1434
1404
  });
1435
1405
  return { records: result, total, hasMore };
@@ -1725,6 +1695,32 @@ var EndpointMatcher = class {
1725
1695
  }
1726
1696
  };
1727
1697
 
1698
+ // src/view-container-expansion.ts
1699
+ var import_spec2 = require("@objectstack/spec");
1700
+ var import_shared2 = require("@objectstack/spec/shared");
1701
+ function deriveViewContainerObject(container) {
1702
+ if (!container || typeof container !== "object") return void 0;
1703
+ const c = container;
1704
+ const own = typeof c.object === "string" && c.object ? c.object : void 0;
1705
+ const byName = typeof c.name === "string" && c.name ? c.name : void 0;
1706
+ return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName;
1707
+ }
1708
+ function expandRuntimeViewContainer(data) {
1709
+ if (!(0, import_spec2.isAggregatedViewContainer)(data)) return [];
1710
+ const container = data;
1711
+ const viewObject = deriveViewContainerObject(container);
1712
+ if (!viewObject) return [];
1713
+ const out = [];
1714
+ for (const vi of (0, import_spec2.expandViewContainer)(viewObject, container)) {
1715
+ (0, import_shared2.applyProtection)(vi, {
1716
+ packageId: container._packageId,
1717
+ packageVersion: container._packageVersion
1718
+ });
1719
+ out.push(vi);
1720
+ }
1721
+ return out;
1722
+ }
1723
+
1728
1724
  // src/metadata-manager.ts
1729
1725
  var WRITABLE_LOADER_METHODS = ["save", "delete"];
1730
1726
  var WRITABLE_LOADER_METHOD_SIGNATURE = {
@@ -1764,8 +1760,6 @@ var _MetadataManager = class _MetadataManager {
1764
1760
  this.watchCallbacks = /* @__PURE__ */ new Map();
1765
1761
  // In-memory metadata registry: type -> name -> data
1766
1762
  this.registry = /* @__PURE__ */ new Map();
1767
- // Overlay storage: "type:name:scope" -> MetadataOverlay
1768
- this.overlays = /* @__PURE__ */ new Map();
1769
1763
  // Type registry for metadata type info
1770
1764
  this.typeRegistry = [];
1771
1765
  // Dependency tracking: "type:name" -> dependencies
@@ -2340,6 +2334,67 @@ var _MetadataManager = class _MetadataManager {
2340
2334
  * result may be memoized depends on what happened to the read's registration
2341
2335
  * while it ran, which only `list()` can see.
2342
2336
  */
2337
+ /**
2338
+ * Merge one loader's answer for `type` into `items`, under the identity that
2339
+ * loader holds each item by.
2340
+ *
2341
+ * ## [#14205] The identity of a loader-held item is its ROW KEY
2342
+ *
2343
+ * Both plural readers used to key a loader's items by `body.name`, and admit
2344
+ * an item only when the body carried a string one:
2345
+ *
2346
+ * ```ts
2347
+ * if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name))
2348
+ * ```
2349
+ *
2350
+ * A body is not required to name itself. `register(type, name, data)` takes
2351
+ * the key as its ARGUMENT, and `assertMetadataRegisterContract` says in as
2352
+ * many words that "A document with NO `name` of its own is fine — the argument
2353
+ * is the key". An aggregated `defineView` container is exactly that: no own
2354
+ * `name` by design, identity carried in the row's `name` column.
2355
+ *
2356
+ * So the old gate dropped every such item the moment the registry went cold
2357
+ * and only the loader could answer — a persisted view container vanished from
2358
+ * `list('view')` after a restart, and `listDiagnosed()` called the short
2359
+ * answer complete because no loader had thrown. Same gate, same effect, in
2360
+ * `listForIndex()`: a nameless `api` row fell out of the endpoint index, where
2361
+ * a miss reads as "nothing declares this route".
2362
+ *
2363
+ * The repair is to ask the loader for the key instead of guessing it from the
2364
+ * body ({@link MetadataLoader.loadManyKeyed}), and to keep the key BESIDE the
2365
+ * body: nothing is written into a body that deliberately has none, so the
2366
+ * register contract's refusal of a disagreeing `data.name` still means what it
2367
+ * says.
2368
+ *
2369
+ * Nothing consumers see today changes shape. For any item that went through
2370
+ * `register()`, a `data.name` that exists is required to EQUAL the key, so the
2371
+ * keyed merge produces the identical map entry; what is new is only the
2372
+ * entries the old gate refused. The `loadMany()` fallback below is the
2373
+ * pre-#14205 behaviour verbatim, for loaders that cannot produce keys
2374
+ * (`RemoteLoader`'s wire format carries bodies only).
2375
+ *
2376
+ * Read failures are NOT caught here: `readListUncached` warns-and-continues,
2377
+ * `listForIndex` deliberately throws, and that difference is each caller's to
2378
+ * keep.
2379
+ */
2380
+ async admitLoaderItems(loader, type, items) {
2381
+ if (typeof loader.loadManyKeyed === "function") {
2382
+ const keyed = await loader.loadManyKeyed(type);
2383
+ for (const entry of keyed) {
2384
+ if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
2385
+ if (items.has(entry.name)) continue;
2386
+ items.set(entry.name, entry.data);
2387
+ }
2388
+ return;
2389
+ }
2390
+ const loaderItems = await loader.loadMany(type);
2391
+ for (const item of loaderItems) {
2392
+ const itemAny = item;
2393
+ if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2394
+ items.set(itemAny.name, item);
2395
+ }
2396
+ }
2397
+ }
2343
2398
  async readListUncached(type) {
2344
2399
  const items = /* @__PURE__ */ new Map();
2345
2400
  const typeStore = this.registry.get(type);
@@ -2352,13 +2407,7 @@ var _MetadataManager = class _MetadataManager {
2352
2407
  const errors = [];
2353
2408
  for (const loader of this.loaders.values()) {
2354
2409
  try {
2355
- const loaderItems = await loader.loadMany(type);
2356
- for (const item of loaderItems) {
2357
- const itemAny = item;
2358
- if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2359
- items.set(itemAny.name, item);
2360
- }
2361
- }
2410
+ await this.admitLoaderItems(loader, type, items);
2362
2411
  this.reportLoaderReadRecovered(loader.contract.name);
2363
2412
  } catch (e) {
2364
2413
  degraded = true;
@@ -2511,13 +2560,7 @@ var _MetadataManager = class _MetadataManager {
2511
2560
  }
2512
2561
  }
2513
2562
  for (const loader of this.loaders.values()) {
2514
- const loaderItems = await loader.loadMany(type);
2515
- for (const item of loaderItems) {
2516
- const itemAny = item;
2517
- if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2518
- items.set(itemAny.name, item);
2519
- }
2520
- }
2563
+ await this.admitLoaderItems(loader, type, items);
2521
2564
  }
2522
2565
  return Array.from(items.values());
2523
2566
  }
@@ -2762,12 +2805,54 @@ var _MetadataManager = class _MetadataManager {
2762
2805
  * Runtime-authored `shared` / `personal` views (`sys_view_definition`) are
2763
2806
  * merged in by the REST layer; this method returns the `package` layer that
2764
2807
  * was registered from source.
2808
+ *
2809
+ * ## [#13913] Aggregated containers are expanded inline, per read
2810
+ *
2811
+ * `this.list('view')` is `MetadataManager`'s OWN loader-based store — the
2812
+ * in-memory registry plus every registered loader — and is a completely
2813
+ * different store from the `sys_metadata` rows `getMetaItems` reads. #13407
2814
+ * taught `getMetaItems` to expand a runtime-authored aggregated container
2815
+ * inline; this exit never called it and had no equivalent step, so a
2816
+ * container that `GET /meta/view?object=` now serves still answered **empty**
2817
+ * here.
2818
+ *
2819
+ * Merely getting the container into the store would not have helped: the
2820
+ * filter also requires `viewKind`, and a container has none. Loosening that
2821
+ * requirement is NOT the repair — it would answer with the container itself
2822
+ * as a view, the behaviour #7163 ruled wrong — so what is added below is the
2823
+ * container's **expansion**, whose items each carry the `viewKind` + `object`
2824
+ * pair this filter has always tested. The filter itself is untouched: it
2825
+ * reads the top-level `object`, exactly as `ViewSchema.object` declares.
2826
+ *
2827
+ * Registry-free and per-read, mirroring #13407's choice at the other exit and
2828
+ * for the same reason — the registry is process-wide, so a read must not
2829
+ * graft rows into it (see `view-container-expansion.ts`'s header, which also
2830
+ * records why the protocol's copy of this logic cannot be imported).
2831
+ *
2832
+ * Already-present items win: an expansion contributes only names the store
2833
+ * does not already hold, so a container whose expanded ViewItems were
2834
+ * registered by a source registrar (the ObjectQL boot loop, the artifact/HMR
2835
+ * loader) still answers with those registered, fully-enriched items and this
2836
+ * step adds nothing.
2765
2837
  */
2766
2838
  async getViewsByObject(object) {
2767
2839
  const views = await this.list("view");
2768
- return views.filter(
2840
+ const matches = views.filter(
2769
2841
  (v) => v && typeof v === "object" && v.viewKind && v.object === object
2770
- ).sort(
2842
+ );
2843
+ const known = /* @__PURE__ */ new Set();
2844
+ for (const v of views) {
2845
+ if (v && typeof v === "object" && typeof v.name === "string") known.add(v.name);
2846
+ }
2847
+ for (const v of views) {
2848
+ for (const item of expandRuntimeViewContainer(v)) {
2849
+ if (!item.viewKind || item.object !== object) continue;
2850
+ if (known.has(item.name)) continue;
2851
+ known.add(item.name);
2852
+ matches.push(item);
2853
+ }
2854
+ }
2855
+ return matches.sort(
2771
2856
  (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.name).localeCompare(String(b.name))
2772
2857
  );
2773
2858
  }
@@ -3185,66 +3270,18 @@ var _MetadataManager = class _MetadataManager {
3185
3270
  };
3186
3271
  }
3187
3272
  // ==========================================
3188
- // Overlay / Customization Management
3273
+ // Overlay / Customization Management — REMOVED (#13135, ADR-0049)
3189
3274
  // ==========================================
3190
- overlayKey(type, name, scope = "platform") {
3191
- return `${encodeURIComponent(type)}:${encodeURIComponent(name)}:${scope}`;
3192
- }
3193
- /**
3194
- * Get the active overlay for a metadata item
3195
- */
3196
- async getOverlay(type, name, scope) {
3197
- return this.overlays.get(this.overlayKey(type, name, scope ?? "platform"));
3198
- }
3199
- /**
3200
- * Save/update an overlay for a metadata item
3201
- */
3202
- async saveOverlay(overlay) {
3203
- if (this.config.persistence?.overlayWritable === false) {
3204
- const msg = `MetadataManager overlays are read-only (persistence.overlayWritable=false); refusing to save overlay for ${overlay.baseType}/${overlay.baseName}`;
3205
- if (this.config.validation?.throwOnError) {
3206
- throw new Error(msg);
3207
- }
3208
- this.logger.warn(msg);
3209
- return;
3210
- }
3211
- const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
3212
- this.overlays.set(key, overlay);
3213
- }
3214
- /**
3215
- * Remove an overlay, reverting to the base definition
3216
- */
3217
- async removeOverlay(type, name, scope) {
3218
- this.overlays.delete(this.overlayKey(type, name, scope ?? "platform"));
3219
- }
3220
- /**
3221
- * Get the effective (merged) metadata after applying all overlays.
3222
- * Resolution order: system ← merge(platform) ← merge(user)
3223
- */
3224
- async getEffective(type, name, context) {
3225
- const base = await this.get(type, name);
3226
- if (!base) return void 0;
3227
- let effective = { ...base };
3228
- const platformOverlay = await this.getOverlay(type, name, "platform");
3229
- if (platformOverlay?.active && platformOverlay.patch) {
3230
- effective = { ...effective, ...platformOverlay.patch };
3231
- }
3232
- if (context?.userId) {
3233
- const userOverlayKey = this.overlayKey(type, name, "user") + `:${context.userId}`;
3234
- const userOverlay = this.overlays.get(userOverlayKey) ?? await this.getOverlay(type, name, "user");
3235
- if (userOverlay?.active && userOverlay.patch) {
3236
- if (!userOverlay.owner || userOverlay.owner === context.userId) {
3237
- effective = { ...effective, ...userOverlay.patch };
3238
- }
3239
- }
3240
- } else {
3241
- const userOverlay = await this.getOverlay(type, name, "user");
3242
- if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) {
3243
- effective = { ...effective, ...userOverlay.patch };
3244
- }
3245
- }
3246
- return effective;
3247
- }
3275
+ //
3276
+ // The in-memory overlay limb (`getOverlay` / `saveOverlay` / `removeOverlay`
3277
+ // / `getEffective`, keyed `type:name:scope`) implemented the paper
3278
+ // metadata-customization protocol removed from `@objectstack/spec` in the
3279
+ // same change: no route ever served the paper `.../overlay` or
3280
+ // `.../effective` endpoints, and the only callers of these methods were this
3281
+ // package's own unit tests. ADR-0126 supersedes the protocol on the record.
3282
+ // The org-scoped customization that actually ships is ADR-0005's
3283
+ // `sys_metadata` overlay (`getMetaItemLayered` in metadata-protocol), which
3284
+ // never lived here.
3248
3285
  // ==========================================
3249
3286
  // Watch / Subscribe (IMetadataService)
3250
3287
  // ==========================================
@@ -4018,6 +4055,7 @@ var MetadataManager = _MetadataManager;
4018
4055
  // src/plugin.ts
4019
4056
  var import_promises = require("fs/promises");
4020
4057
  var import_node_crypto2 = require("crypto");
4058
+ var import_core2 = require("@objectstack/core");
4021
4059
 
4022
4060
  // src/node-metadata-manager.ts
4023
4061
  var path2 = __toESM(require("path"), 1);
@@ -4028,7 +4066,11 @@ var fs = __toESM(require("fs/promises"), 1);
4028
4066
  var path = __toESM(require("path"), 1);
4029
4067
  var import_glob = require("glob");
4030
4068
  var import_node_crypto = require("crypto");
4031
- var FilesystemLoader = class {
4069
+ function ownNameOf(data) {
4070
+ const own = data?.name;
4071
+ return typeof own === "string" && own !== "" ? own : null;
4072
+ }
4073
+ var _FilesystemLoader = class _FilesystemLoader {
4032
4074
  constructor(rootDir, serializers, logger) {
4033
4075
  this.rootDir = rootDir;
4034
4076
  this.serializers = serializers;
@@ -4126,6 +4168,82 @@ var FilesystemLoader = class {
4126
4168
  }
4127
4169
  }
4128
4170
  async loadMany(type, options) {
4171
+ return (await this.loadManyEntries(type, options)).map((entry) => entry.data);
4172
+ }
4173
+ /**
4174
+ * [#14341] The keyed half of {@link loadMany} — see {@link MetadataKeyedItem}
4175
+ * for why the store's key travels BESIDE the body instead of being folded
4176
+ * into it.
4177
+ *
4178
+ * THE RULE, in one sentence: an item is keyed by this loader's own
4179
+ * name-to-path derivation — {@link nameFromFilename}, the very basename
4180
+ * derivation `list()` reports — ONLY where that derivation is a bijection for
4181
+ * the file (it sits directly under `ROOT/TYPE/` and carries one of the
4182
+ * extensions {@link findFile} tries, so `findFile(type, key)` resolves back to
4183
+ * this same file); every other shape keeps the pre-#14205 behaviour verbatim,
4184
+ * keyed by `body.name` when it has one and dropped when it has none.
4185
+ *
4186
+ * Why the rule stops there (PM ruling on #14341, 2026-09-02, knowingly over
4187
+ * triage's "a nested path keeps whatever `list()` reports for it today"):
4188
+ * `list()` and `findFile()` DISAGREE outside that shape. For
4189
+ * `ROOT/TYPE/crm/account.json`, `list()` reports the bare `account`, but
4190
+ * `findFile()` resolves that name against `ROOT/TYPE/account.json` and finds
4191
+ * nothing — the only name reaching the file is `crm/account`, which nothing
4192
+ * reports. An extension-less file is read by `loadMany()` and reported by
4193
+ * `list()`, and `findFile()` resolves neither. Keying by either side would
4194
+ * mint a name some other door cannot open, and two directories holding the
4195
+ * same basename would collide in silence
4196
+ * (`MetadataManager.admitLoaderItems()` keeps the first and says nothing).
4197
+ * The card's own fence: "keying items under names nothing else uses … is
4198
+ * worse than today's honest drop". So the drop stays exactly where the key is
4199
+ * unsettled, and is pinned as a RECORD in
4200
+ * `filesystem-loader-keyed-items.test.ts`.
4201
+ *
4202
+ * [#14486, partial] `list()` and {@link findFile} have since converged on
4203
+ * {@link resolvableNameForPath} — the derivation this method already used —
4204
+ * so a nested or extension-less file is now neither listed nor resolvable.
4205
+ * What did NOT change is the WALK behind this method: `loadManyEntries()`
4206
+ * still READS those files, so `loadMany()` still returns their bodies and
4207
+ * this method still falls back to `body.name` for them. That half of the
4208
+ * #14486 ruling ("nothing unlisted is returned by `loadMany()` either") is
4209
+ * deliberately NOT taken here: it would invert the three landed #14341 pins
4210
+ * in `filesystem-loader-keyed-items.test.ts:113,167,187` and the
4211
+ * `loadMany()` CONTROL at `:196`, and that file was under a concurrent
4212
+ * claim (PR #14627) when this landed. The remaining divergence — listed ⊂
4213
+ * loaded — is pinned as a RECORD in
4214
+ * `filesystem-loader-list-reachability.test.ts` rather than left implicit.
4215
+ *
4216
+ * One consequence, deliberate: a flat file whose `body.name` DISAGREES with
4217
+ * its basename is now keyed by the BASENAME. That is #14205's rule (identity
4218
+ * is the key the store holds an item under, not `body.name`) applied to this
4219
+ * loader, and it aligns `MetadataManager.list()` with `listNames()` for that
4220
+ * shape.
4221
+ *
4222
+ * The body is handed back by reference, unchanged: nothing is written into a
4223
+ * body that deliberately has no `name`. `limit` bounds the items LOADED,
4224
+ * exactly as `loadMany()` does — an entry the key rule drops has still been
4225
+ * read and still counts against it.
4226
+ */
4227
+ async loadManyKeyed(type, options) {
4228
+ const typeDir = path.join(this.rootDir, type);
4229
+ const keyed = [];
4230
+ for (const entry of await this.loadManyEntries(type, options)) {
4231
+ const name = this.resolvableNameForPath(typeDir, entry.file) ?? ownNameOf(entry.data);
4232
+ if (name) {
4233
+ keyed.push({ name, data: entry.data });
4234
+ }
4235
+ }
4236
+ return keyed;
4237
+ }
4238
+ /**
4239
+ * The single walk behind {@link loadMany} and {@link loadManyKeyed}: one glob,
4240
+ * one serializer pass, one `limit`. Shared so the two can never answer with
4241
+ * different bodies for the same file — {@link MetadataLoader.loadManyKeyed}
4242
+ * requires `data` to be "the same body `loadMany()` would return for the
4243
+ * item", and a second copy of this walk is how that would quietly stop being
4244
+ * true.
4245
+ */
4246
+ async loadManyEntries(type, options) {
4129
4247
  const { patterns = ["**/*"], recursive: _recursive = true, limit } = options || {};
4130
4248
  const typeDir = path.join(this.rootDir, type);
4131
4249
  const items = [];
@@ -4148,7 +4266,7 @@ var FilesystemLoader = class {
4148
4266
  const serializer = this.getSerializer(format);
4149
4267
  if (serializer) {
4150
4268
  const data = serializer.deserialize(content);
4151
- items.push(data);
4269
+ items.push({ file, data });
4152
4270
  }
4153
4271
  } catch (error) {
4154
4272
  this.logger?.warn("Failed to load file", {
@@ -4202,6 +4320,30 @@ var FilesystemLoader = class {
4202
4320
  return null;
4203
4321
  }
4204
4322
  }
4323
+ /**
4324
+ * [#14486] The names this loader can be asked for, and ONLY those: a file
4325
+ * directly under `ROOT/TYPE/` carrying an extension one of this instance's
4326
+ * REGISTERED serializers claims. Every name it reports resolves back through
4327
+ * {@link findFile}, so `listNames()` and `get()` give the same answer.
4328
+ *
4329
+ * It used to report `path.basename(file, ext)` for every file the glob found,
4330
+ * nested or not, extension or not — and {@link findFile} resolves neither
4331
+ * shape. `ROOT/TYPE/crm/account.json` was listed as `account`, which resolves
4332
+ * against `ROOT/TYPE/account.json` and finds nothing; an extension-less
4333
+ * `ROOT/TYPE/noext` was listed as `noext`, which resolves under no appended
4334
+ * extension at all. A name in the list that `get()` answers `null` for is the
4335
+ * silent failure an author (human or AI) reads as their own typo, so they
4336
+ * retry the same word: the list and the door now agree instead.
4337
+ *
4338
+ * Ruling (maintainer, via the director seat on #14486, 2026-09-02): narrow
4339
+ * the list — direction A, over B (reverse-unify: report `crm/account` and
4340
+ * teach `findFile()` path-shaped names), which would have made a slash inside
4341
+ * a metadata name every consumer's permanent obligation with no measured
4342
+ * demand for it. The two-segment layout follows ADR-0008 §10, which
4343
+ * `metadata-fs`'s `parseItemPath()` already enforces for its own store; the
4344
+ * EXTENSION set deliberately does NOT follow §10's `.json`-only rule — see
4345
+ * {@link resolvableExtensions} for why.
4346
+ */
4205
4347
  async list(type) {
4206
4348
  const typeDir = path.join(this.rootDir, type);
4207
4349
  try {
@@ -4210,11 +4352,7 @@ var FilesystemLoader = class {
4210
4352
  ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
4211
4353
  nodir: true
4212
4354
  });
4213
- return files.map((file) => {
4214
- const ext = path.extname(file);
4215
- const basename3 = path.basename(file, ext);
4216
- return basename3;
4217
- });
4355
+ return files.map((file) => this.resolvableNameForPath(typeDir, path.join(typeDir, file))).filter((name) => name !== null);
4218
4356
  } catch (error) {
4219
4357
  this.logger?.error("Failed to list", void 0, {
4220
4358
  type,
@@ -4292,12 +4430,66 @@ var FilesystemLoader = class {
4292
4430
  throw error;
4293
4431
  }
4294
4432
  }
4433
+ /**
4434
+ * [#14486] The extensions a name can be resolved under, for THIS instance:
4435
+ * the ones belonging to the serializer set it was constructed with. Shared by
4436
+ * {@link findFile}, {@link resolvableNameForPath} and therefore {@link list},
4437
+ * so the set a name can be RESOLVED under cannot drift from the set that is
4438
+ * LISTED or the set {@link loadManyKeyed} is willing to KEY by.
4439
+ *
4440
+ * Registered, not hard-coded, and deliberately not ADR-0008 §10's `.json`
4441
+ * only. §10 governs the `metadata-fs` store; applying it verbatim here would
4442
+ * drop `.yaml` and `.ts` metadata out of `listNames()` — a breakage this card
4443
+ * never asked for. Under the manager's DEFAULT format set
4444
+ * (`typescript` / `json` / `yaml`, `metadata-manager.ts`) that leaves `.js`
4445
+ * out, which is the card's row-4 membership mismatch closing for free: a `.js`
4446
+ * file was listed and resolvable while `loadMany()` could never return it and
4447
+ * `load()` threw `No serializer found for format: javascript`. Register
4448
+ * `javascript` and it is listed, resolvable and loadable together.
4449
+ */
4450
+ resolvableExtensions() {
4451
+ const extensions = [];
4452
+ for (const [format, formatExtensions] of _FilesystemLoader.EXTENSIONS_BY_FORMAT) {
4453
+ if (this.serializers.has(format)) {
4454
+ extensions.push(...formatExtensions);
4455
+ }
4456
+ }
4457
+ return extensions;
4458
+ }
4459
+ /**
4460
+ * The metadata name this loader reports for a file: the basename with its
4461
+ * extension stripped. One derivation, shared by {@link list} and
4462
+ * {@link loadManyKeyed}, so the two cannot drift for the shape where they
4463
+ * agree — `dotted.config.json` is `dotted.config` for both.
4464
+ */
4465
+ static nameFromFilename(file) {
4466
+ return path.basename(file, path.extname(file));
4467
+ }
4468
+ /**
4469
+ * The key for a file IF this loader's name-to-path mapping is a bijection for
4470
+ * it: a file directly under `ROOT/TYPE/` carrying an extension
4471
+ * {@link findFile} tries, so `findFile(type, key)` resolves back to this very
4472
+ * file. `null` for every other shape — a nested path, an extension-less file,
4473
+ * an extension spelled in a case `findFile()` does not compose — which is why
4474
+ * {@link loadManyKeyed} falls back to `body.name` there rather than minting a
4475
+ * key no other door can open.
4476
+ */
4477
+ resolvableNameForPath(typeDir, file) {
4478
+ const rel = path.relative(typeDir, file);
4479
+ if (rel === "" || rel.split(path.sep).length !== 1) {
4480
+ return null;
4481
+ }
4482
+ if (!this.resolvableExtensions().includes(path.extname(rel))) {
4483
+ return null;
4484
+ }
4485
+ return _FilesystemLoader.nameFromFilename(rel);
4486
+ }
4295
4487
  /**
4296
4488
  * Find file for a given type and name
4297
4489
  */
4298
4490
  async findFile(type, name) {
4299
4491
  const typeDir = path.join(this.rootDir, type);
4300
- const extensions = [".json", ".yaml", ".yml", ".ts", ".js"];
4492
+ const extensions = this.resolvableExtensions();
4301
4493
  for (const ext of extensions) {
4302
4494
  const filePath = path.join(typeDir, `${name}${ext}`);
4303
4495
  try {
@@ -4343,6 +4535,19 @@ var FilesystemLoader = class {
4343
4535
  return `"${hash}"`;
4344
4536
  }
4345
4537
  };
4538
+ /**
4539
+ * The inverse of {@link detectFormat}: which file extensions carry which
4540
+ * format. Fixed ORDER, because it is also {@link findFile}'s precedence when
4541
+ * two files under one type directory share a stem — registration order must
4542
+ * not be able to change which file `ROOT/TYPE/NAME` opens.
4543
+ */
4544
+ _FilesystemLoader.EXTENSIONS_BY_FORMAT = [
4545
+ ["json", [".json"]],
4546
+ ["yaml", [".yaml", ".yml"]],
4547
+ ["typescript", [".ts"]],
4548
+ ["javascript", [".js"]]
4549
+ ];
4550
+ var FilesystemLoader = _FilesystemLoader;
4346
4551
 
4347
4552
  // src/node-metadata-manager.ts
4348
4553
  var NodeMetadataManager = class extends MetadataManager {
@@ -4467,6 +4672,20 @@ var MemoryLoader = class {
4467
4672
  if (!typeStore) return [];
4468
4673
  return Array.from(typeStore.values());
4469
4674
  }
4675
+ /**
4676
+ * [#14205] The keyed half of {@link loadMany}. The storage map is already
4677
+ * `Type -> Name -> Data`, so the key this loader holds an item under is the
4678
+ * map key — `loadMany()` was simply discarding it, which dropped every
4679
+ * nameless body out of `MetadataManager.list()` and out of the endpoint index.
4680
+ *
4681
+ * The body is handed back by reference, unchanged: the key travels beside it,
4682
+ * never folded into it.
4683
+ */
4684
+ async loadManyKeyed(type, _options) {
4685
+ const typeStore = this.storage.get(type);
4686
+ if (!typeStore) return [];
4687
+ return Array.from(typeStore, ([name, data]) => ({ name, data }));
4688
+ }
4470
4689
  async exists(type, name) {
4471
4690
  return this.storage.get(type)?.has(name) ?? false;
4472
4691
  }
@@ -4513,20 +4732,20 @@ var MemoryLoader = class {
4513
4732
 
4514
4733
  // src/plugin.ts
4515
4734
  var import_kernel2 = require("@objectstack/spec/kernel");
4516
- var import_shared2 = require("@objectstack/spec/shared");
4517
- var import_metadata_core2 = require("@objectstack/metadata-core");
4518
- var import_spec2 = require("@objectstack/spec");
4735
+ var import_shared3 = require("@objectstack/spec/shared");
4736
+ var import_metadata_core3 = require("@objectstack/metadata-core");
4519
4737
  var import_spec3 = require("@objectstack/spec");
4738
+ var import_spec4 = require("@objectstack/spec");
4520
4739
  var queryableMetadataObjects = [
4521
- import_metadata_core2.SysMetadataObject,
4522
- import_metadata_core2.SysMetadataHistoryObject,
4740
+ import_metadata_core3.SysMetadataObject,
4741
+ import_metadata_core3.SysMetadataHistoryObject,
4523
4742
  // ADR-0067 commit log — sibling of sys_metadata_history (see note above).
4524
- import_metadata_core2.SysMetadataCommitObject,
4525
- import_metadata_core2.SysMetadataAuditObject,
4743
+ import_metadata_core3.SysMetadataCommitObject,
4744
+ import_metadata_core3.SysMetadataAuditObject,
4526
4745
  // Runtime view storage (shared / personal). Must always be provisioned so
4527
4746
  // end-user view creation via the generic data API has a place to write —
4528
4747
  // mirroring why sys_metadata is always provisioned for PUT /meta.
4529
- import_metadata_core2.SysViewDefinitionObject
4748
+ import_metadata_core3.SysViewDefinitionObject
4530
4749
  ];
4531
4750
  var REPO_SUBDIR = ".objectstack/metadata";
4532
4751
  var ARTIFACT_FIELD_TO_TYPE = {
@@ -4538,7 +4757,10 @@ var ARTIFACT_FIELD_TO_TYPE = {
4538
4757
  dashboards: "dashboard",
4539
4758
  reports: "report",
4540
4759
  actions: "action",
4541
- themes: "theme",
4760
+ // `themes: 'theme'` removed at #10485 (ADR-0049): the carrier key is
4761
+ // retired, so a parsed artifact can no longer carry the field — and the
4762
+ // ingest half of the dead pipeline (items stored, read by nothing) goes
4763
+ // with the authoring half rather than surviving it as drift.
4542
4764
  workflows: "workflow",
4543
4765
  flows: "flow",
4544
4766
  // ADR-0090 D3: stacks declare `positions` (stack.zod.ts); the retired
@@ -4546,8 +4768,37 @@ var ARTIFACT_FIELD_TO_TYPE = {
4546
4768
  // positions from artifact ingestion.
4547
4769
  positions: "position",
4548
4770
  permissions: "permission",
4771
+ // [ADR-0066 D1] `capabilities` reaches the door at #12892 step 1, the
4772
+ // maintainer's `option 1` ruling ("the door owns the registration
4773
+ // route" for the five artifact security collections). Until #12894
4774
+ // measured it, `AppPlugin`'s `SECURITY_FIELDS` block
4775
+ // (packages/runtime/src/app-plugin.ts) was this collection's SOLE
4776
+ // registrar on an artifact boot — the one security collection the door
4777
+ // could not reach — so a declared capability was registered from bytes
4778
+ // nothing strict-parses, with no schema default and no ADR-0010
4779
+ // provenance. Measured on the two-reader harness, the door's copy adds
4780
+ // exactly four keys the raw copy lacks: `scope` (the schema default)
4781
+ // and `_packageId` / `_packageVersion` / `_provenance`.
4782
+ //
4783
+ // ⚠️ This entry makes the door a SECOND writer, not yet the only one:
4784
+ // `AppPlugin` still registers `capabilities`, and it runs last, so the
4785
+ // raw copy still wins a real artifact boot. Step 2 of the ruling (that
4786
+ // block stops registering these five on the artifact path, after a
4787
+ // census of the non-artifact boot paths) is what makes this the only
4788
+ // copy. Until then the divergence is the interim reality the ruling
4789
+ // explicitly permits, and #12878's pins are what keep it visible.
4790
+ capabilities: "capability",
4549
4791
  sharingRules: "sharing_rule",
4550
- policies: "policy",
4792
+ // `policies: 'policy'` removed at #12894: the stack schema is a
4793
+ // `strictObject` that declares no top-level `policies` key, so a
4794
+ // definition carrying one is refused by the strict parse a few lines
4795
+ // below — the entry could never match, and nothing was ever registered
4796
+ // under `policy` from this map. The word is real, but it lives ONE LEVEL
4797
+ // DOWN: on a permission set it is an alias for `rowLevelSecurity`
4798
+ // (`PERMISSION_SET_KEY_ALIASES`, packages/spec/src/security/permission.zod.ts)
4799
+ // — a key on an ITEM, never a collection. Third retirement of this exact
4800
+ // shape in this map (`themes` and `roles` above); the reasons are kept
4801
+ // in place because the first two are what made this one findable.
4551
4802
  apis: "api",
4552
4803
  webhooks: "webhook",
4553
4804
  agents: "agent",
@@ -4590,14 +4841,29 @@ var MetadataPlugin = class {
4590
4841
  * degrades on purpose (objects are discovered via the legacy fallback).
4591
4842
  */
4592
4843
  this.optionalDependencies = ["com.objectstack.engine.objectql"];
4844
+ /**
4845
+ * Once-per-process dedupe for the summaries the versioned artifact window
4846
+ * emits. The artifact watcher replays `_parseAndRegisterArtifact` on every
4847
+ * file change, so without this a dev loop over a legacy artifact would
4848
+ * re-announce the same finding on every reload — the same shape
4849
+ * `Protocol.storedConversionWarned` guards on the stored-row pass, which
4850
+ * this surfacing is modeled on.
4851
+ *
4852
+ * Two key families share the set, because they share the replay:
4853
+ * `<conversionId>|<label>` for a forward-conversion summary (#12772), and
4854
+ * `unbound-form-predicate-root|<label>` for the unbound-root notice
4855
+ * (#12915) — one line per artifact there, not one per conversion, since
4856
+ * the notice already aggregates every finding it made.
4857
+ */
4858
+ this.artifactConversionWarned = /* @__PURE__ */ new Set();
4593
4859
  this.init = async (ctx) => {
4860
+ this.initCtx = ctx;
4594
4861
  ctx.logger.info("Initializing Metadata Manager", {
4595
4862
  root: this.options.rootDir || process.cwd(),
4596
4863
  watch: this.options.watch,
4597
4864
  artifactSource: this.options.artifactSource?.mode
4598
4865
  });
4599
4866
  ctx.registerService("metadata", this.manager);
4600
- console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
4601
4867
  const registerSysObjects = this.options.registerSystemObjects !== false;
4602
4868
  if (registerSysObjects) {
4603
4869
  try {
@@ -4619,7 +4885,7 @@ var MetadataPlugin = class {
4619
4885
  }
4620
4886
  ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
4621
4887
  mode: this.options.artifactSource?.mode ?? "file-system",
4622
- features: ["watch", "multi-format", "query", "overlay", "type-registry"]
4888
+ features: ["watch", "multi-format", "query", "type-registry"]
4623
4889
  });
4624
4890
  };
4625
4891
  this.start = async (ctx) => {
@@ -4702,7 +4968,7 @@ var MetadataPlugin = class {
4702
4968
  if (httpServer && typeof httpServer.getRawApp === "function") {
4703
4969
  const { registerMetadataHmrRoutes: registerMetadataHmrRoutes2 } = await Promise.resolve().then(() => (init_hmr_routes(), hmr_routes_exports));
4704
4970
  const hub = registerMetadataHmrRoutes2(httpServer.getRawApp(), this.manager);
4705
- hub.setOnPostReload(async (body = {}) => {
4971
+ hub?.setOnPostReload(async (body = {}) => {
4706
4972
  const src3 = this.options.artifactSource;
4707
4973
  if (src3?.mode === "local-file") {
4708
4974
  try {
@@ -4742,7 +5008,7 @@ var MetadataPlugin = class {
4742
5008
  pending = true;
4743
5009
  try {
4744
5010
  await this._reloadAndAnnounce(ctx, src2, [src2.path]);
4745
- hub.broadcastReload("artifact-file-changed", [src2.path]);
5011
+ hub?.broadcastReload("artifact-file-changed", [src2.path]);
4746
5012
  ctx.logger.info("[MetadataPlugin] artifact auto-reloaded (file watcher)", {
4747
5013
  path: src2.path
4748
5014
  });
@@ -4764,7 +5030,13 @@ var MetadataPlugin = class {
4764
5030
  ctx.logger.warn("[MetadataPlugin] artifact watcher failed to start", { error: e?.message });
4765
5031
  }
4766
5032
  }
4767
- console.log("[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events");
5033
+ if (hub) {
5034
+ console.log("[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events");
5035
+ } else {
5036
+ console.log(
5037
+ `[MetadataPlugin] dev metadata-HMR endpoints NOT mounted \u2014 they require NODE_ENV=development (this process: ${process.env.NODE_ENV ? `NODE_ENV=${process.env.NODE_ENV}` : "NODE_ENV unset, treated as production"})`
5038
+ );
5039
+ }
4768
5040
  } else {
4769
5041
  console.log("[MetadataPlugin] HTTP server with getRawApp() not available \u2014 skipping HMR endpoint");
4770
5042
  }
@@ -4772,7 +5044,22 @@ var MetadataPlugin = class {
4772
5044
  console.warn("[MetadataPlugin] Failed to register HMR endpoint", e?.message);
4773
5045
  }
4774
5046
  };
4775
- this.stop = async (ctx) => {
5047
+ /**
5048
+ * Teardown — the kernel's ONLY teardown hook.
5049
+ *
5050
+ * [#10772] This body used to be spelled `stop(ctx)`. `Plugin`
5051
+ * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and
5052
+ * `destroy?()` and no `stop()`, and `ObjectKernel.performShutdown()` /
5053
+ * `LiteKernel.destroy()` walk the plugins in reverse calling
5054
+ * `plugin.destroy()` — so the artifact watcher, the manager and the
5055
+ * repository were all still held after `await kernel.shutdown()` had
5056
+ * RESOLVED. The `start`/`stop` pair read symmetric to a reviewer because
5057
+ * `start()` really is on the interface; only one half was ever called.
5058
+ *
5059
+ * Idempotent: every handle is cleared as it is released, so a second
5060
+ * teardown is a no-op rather than a second close.
5061
+ */
5062
+ this.destroy = async () => {
4776
5063
  if (this.artifactWatcher) {
4777
5064
  try {
4778
5065
  await this.artifactWatcher.close();
@@ -4783,7 +5070,7 @@ var MetadataPlugin = class {
4783
5070
  try {
4784
5071
  await this.manager.dispose();
4785
5072
  } catch (e) {
4786
- ctx.logger.warn("[MetadataPlugin] manager.dispose() failed", { error: e?.message });
5073
+ this.initCtx?.logger?.warn?.("[MetadataPlugin] manager.dispose() failed", { error: e?.message });
4787
5074
  }
4788
5075
  const repo = this.repository;
4789
5076
  if (repo && typeof repo.close === "function") {
@@ -4794,6 +5081,18 @@ var MetadataPlugin = class {
4794
5081
  }
4795
5082
  this.repository = void 0;
4796
5083
  };
5084
+ /**
5085
+ * Retained alias for {@link destroy}. Kept because it is public API of an
5086
+ * exported class: an embedder may have learned to call it directly
5087
+ * precisely BECAUSE the kernel never did, and deleting it would break them.
5088
+ * Still an arrow property, so a detached `const { stop } = plugin` call
5089
+ * keeps working too. The parameter is now optional and ignored —
5090
+ * `destroy()` takes no context, so teardown logs through the context
5091
+ * captured in `init()`.
5092
+ */
5093
+ this.stop = async (_ctx) => {
5094
+ await this.destroy();
5095
+ };
4797
5096
  this.options = {
4798
5097
  ...options,
4799
5098
  watch: options.watch ?? false
@@ -4833,6 +5132,106 @@ var MetadataPlugin = class {
4833
5132
  if (timer) clearTimeout(timer);
4834
5133
  }
4835
5134
  }
5135
+ /**
5136
+ * Versioned ADR-0087 forward conversion at the artifact-ingestion door
5137
+ * (#12772) — runs BEFORE the strict schema parse below, because the parse
5138
+ * is the refusal point.
5139
+ *
5140
+ * A compiled artifact is data at rest with a version stamp: built by
5141
+ * released tooling, then unchanged while the platform moves on. When a
5142
+ * spec release retires an authorable key inside a protocol line (spec
5143
+ * 17.1 → 17.2 retired the `allowRestore`/`allowPurge` permission bits),
5144
+ * every already-built artifact carrying the key becomes unbootable at the
5145
+ * tombstone — with no operator remedy, since `os migrate meta` targets
5146
+ * sources, not built artifacts. The stored-row read path already replays
5147
+ * the conversion chain for exactly this reason
5148
+ * (`applyConversionsToStoredItem`, ADR-0087 addendum); this is the same
5149
+ * policy at the artifact door, **keyed off the artifact's own declared
5150
+ * `engines.protocol` floor**: an artifact authored below the running spec
5151
+ * version converts forward, an artifact authored at the current (or a
5152
+ * newer) surface converts nothing and answers to the strict parse,
5153
+ * tombstones included. The version key is what keeps this a conversion
5154
+ * rather than an amnesty — the retired keys return with the M2 lifecycle
5155
+ * batch (#1883), and artifacts authored against that surface must never
5156
+ * have them stripped by history.
5157
+ *
5158
+ * Notices surface the way the stored-row pass's do — operator-visible and
5159
+ * deduped — as one summary line per conversion per artifact rather than
5160
+ * one per rewritten path (a real 17.1 artifact carried 150 strips of the
5161
+ * same two keys; 150 identical warn lines would bury the boot log).
5162
+ */
5163
+ _convertArtifactForward(ctx, definition, label) {
5164
+ const result = (0, import_metadata_core3.applyArtifactForwardConversions)(definition);
5165
+ this._warnUnboundFormPredicateRoots(ctx, result, label);
5166
+ if (result.notices.length === 0) return result.definition;
5167
+ const byConversion = /* @__PURE__ */ new Map();
5168
+ for (const n of result.notices) {
5169
+ const existing = byConversion.get(n.conversionId);
5170
+ if (existing) existing.count += 1;
5171
+ else byConversion.set(n.conversionId, { count: 1, firstPath: n.path, message: n.message });
5172
+ }
5173
+ for (const [conversionId, agg] of byConversion) {
5174
+ const key = `${conversionId}|${label}`;
5175
+ if (this.artifactConversionWarned.has(key)) continue;
5176
+ this.artifactConversionWarned.add(key);
5177
+ ctx.logger.warn(
5178
+ `[MetadataPlugin] artifact '${label}' predates this runtime's spec (authored engines.protocol floor ${result.authoredFloor ?? "<undeclared>"}, runtime spec ${result.runtimeSpecVersion}) \u2014 converted ${agg.count} site(s) forward via ADR-0087 conversion '${conversionId}' (first at ${agg.firstPath}). ${agg.message} The artifact file itself is unchanged \u2014 rebuild it with current tooling ('os build') to persist the canonical shape.`
5179
+ );
5180
+ }
5181
+ return result.definition;
5182
+ }
5183
+ /**
5184
+ * Operator-facing boot notice for form-view predicates that fault OPEN on
5185
+ * this runtime (#12915 scope C — maintainer ruling 2026-08-28, 「同意C」).
5186
+ *
5187
+ * A form-view predicate binds `record` / `previous` / `parent` (runtime
5188
+ * record forms) or `data` (metadata-editing forms) — and a FIELD-level one
5189
+ * also binds `current_user` and its ADR-0068 aliases (objectui#6010),
5190
+ * which a SECTION-level one does not. The contract states beside that
5191
+ * vocabulary that a bare identifier is UNBOUND and the predicate faults,
5192
+ * and `visibleWhen`'s fault fallback is `true`. On a real
5193
+ * 17.1-built artifact that combination dead-ends record creation in the
5194
+ * console: the conditionally hidden field renders, and its unconditional
5195
+ * `required: true` — authored to be gated by the visibility that no longer
5196
+ * applies — blocks every submit, while the same payload POSTs 201 through
5197
+ * REST. Nothing refused, nothing logged, and only the operator can fix it
5198
+ * (by rebuilding the artifact), so this is the channel the ruling picked:
5199
+ * service startup, server-side, never a console surface — the person at
5200
+ * the form cannot act on "your artifact is stale".
5201
+ *
5202
+ * **Detection only.** No refusal, no rewrite, no behaviour change: the
5203
+ * predicate keeps faulting open exactly as before. Rewriting a bare root to
5204
+ * `record.` is the ADR-0087 conversion (#12915 scope A), deferred by the
5205
+ * same ruling with an explicit start line.
5206
+ *
5207
+ * **Same versioned window as the conversion replay above** — and read off
5208
+ * that pass's own verdict rather than recomputed, so the two can never
5209
+ * disagree about which artifacts are "old". An artifact declaring the
5210
+ * current (or a newer) floor answers to the strict parse and gets nothing
5211
+ * from here even when it does carry bare roots; that boundary is what keeps
5212
+ * a notice about legacy artifacts out of contract territory. An undeclared
5213
+ * range is treated as old data at rest, matching the grandfathering posture
5214
+ * the window already takes (`converted-undeclared`).
5215
+ */
5216
+ _warnUnboundFormPredicateRoots(ctx, result, label) {
5217
+ if (result.verdict !== "converted-forward" && result.verdict !== "converted-undeclared") return;
5218
+ const findings = (0, import_metadata_core3.detectUnboundFormViewPredicateRoots)(result.definition);
5219
+ if (findings.length === 0) return;
5220
+ const key = `unbound-form-predicate-root|${label}`;
5221
+ if (this.artifactConversionWarned.has(key)) return;
5222
+ this.artifactConversionWarned.add(key);
5223
+ const views = [...new Set(findings.map((f) => f.view))];
5224
+ const roots = [...new Set(findings.map((f) => f.root))];
5225
+ const quote = (list) => list.map((v) => `'${v}'`).join(", ");
5226
+ const surfaces = new Set(findings.map((f) => f.surface));
5227
+ const vocabulary = [
5228
+ surfaces.has("field") ? `on a form FIELD: ${quote(import_metadata_core3.BOUND_FORM_FIELD_PREDICATE_ROOTS)}` : null,
5229
+ surfaces.has("section") ? `on a form SECTION: ${quote(import_metadata_core3.BOUND_FORM_VIEW_PREDICATE_ROOTS)}` : null
5230
+ ].filter(Boolean).join("; ");
5231
+ ctx.logger.warn(
5232
+ `[MetadataPlugin] artifact '${label}' predates this runtime's spec (authored engines.protocol floor ${result.authoredFloor ?? "<undeclared>"}, runtime spec ${result.runtimeSpecVersion}) and carries ${findings.length} form-view predicate(s) whose root identifier is NOT bound where it evaluates \u2014 ${quote(roots)} (bound roots ${vocabulary}) \u2014 across ${views.length} view(s): ${views.join(", ")} (first at ${findings[0].path}). Each one faults at evaluation and visibility fails OPEN, so a field the predicate was authored to HIDE renders anyway \u2014 and an unconditional 'required: true' on such a field dead-ends record creation in the console while the REST door still accepts it. Nothing was rewritten and no behaviour changed here; rebuild the artifact with current tooling ('os build') so its predicates carry a bound root ('record.<field>').`
5233
+ );
5234
+ }
4836
5235
  /**
4837
5236
  * Parse raw artifact JSON (envelope or bare definition) and register all
4838
5237
  * metadata items into the MetadataManager.
@@ -4851,16 +5250,22 @@ var MetadataPlugin = class {
4851
5250
  let metadata;
4852
5251
  const obj = raw;
4853
5252
  if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== void 0) {
4854
- const artifact = EnvironmentArtifactSchema.parse(obj);
5253
+ const artifact = EnvironmentArtifactSchema.parse({
5254
+ ...obj,
5255
+ metadata: this._convertArtifactForward(ctx, obj.metadata, label)
5256
+ });
4855
5257
  metadata = artifact.metadata;
4856
5258
  } else if (obj?.success && obj?.data?.metadata) {
4857
- const artifact = EnvironmentArtifactSchema.parse(obj.data);
5259
+ const artifact = EnvironmentArtifactSchema.parse({
5260
+ ...obj.data,
5261
+ metadata: this._convertArtifactForward(ctx, obj.data.metadata, label)
5262
+ });
4858
5263
  metadata = artifact.metadata;
4859
5264
  } else {
4860
- const def = ObjectStackDefinitionSchema.parse(obj);
5265
+ const def = ObjectStackDefinitionSchema.parse(this._convertArtifactForward(ctx, obj, label));
4861
5266
  const canonical = JSON.stringify(def, Object.keys(def).sort());
4862
5267
  const checksum = (0, import_node_crypto2.createHash)("sha256").update(canonical).digest("hex");
4863
- const environmentId = this.options.environmentId ?? "proj_local";
5268
+ const environmentId = this.options.environmentId ?? "env_local";
4864
5269
  EnvironmentArtifactSchema.parse({
4865
5270
  schemaVersion: "0.1",
4866
5271
  environmentId,
@@ -4874,53 +5279,127 @@ var MetadataPlugin = class {
4874
5279
  const memLoader = new MemoryLoader();
4875
5280
  const manifestPackageId = metadata?.manifest?.id ?? metadata?.id ?? void 0;
4876
5281
  const manifestVersion = metadata?.manifest?.version ?? metadata?.version ?? void 0;
5282
+ const carriesPackages = Array.isArray(metadata?.packages);
5283
+ const bodies = (0, import_core2.resolveArtifactPackageOrder)(metadata);
5284
+ const ownedByPackage = /* @__PURE__ */ new Map();
5285
+ const claim = (type, name) => {
5286
+ let names = ownedByPackage.get(type);
5287
+ if (!names) ownedByPackage.set(type, names = /* @__PURE__ */ new Set());
5288
+ names.add(name);
5289
+ };
5290
+ const claimed = (type, name) => ownedByPackage.get(type)?.has(name) === true;
5291
+ let totalRegistered = 0;
5292
+ for (const body of bodies) {
5293
+ totalRegistered += await this._registerArtifactBodyCollections(
5294
+ ctx,
5295
+ memLoader,
5296
+ body,
5297
+ carriesPackages ? {
5298
+ packageId: (0, import_core2.artifactPackageId)(body),
5299
+ packageVersion: body?.version ?? void 0
5300
+ } : { packageId: manifestPackageId, packageVersion: manifestVersion },
5301
+ { claim: carriesPackages ? claim : void 0 }
5302
+ );
5303
+ }
5304
+ if (carriesPackages) {
5305
+ const residual = await this._registerArtifactBodyCollections(
5306
+ ctx,
5307
+ memLoader,
5308
+ metadata,
5309
+ { packageId: manifestPackageId, packageVersion: manifestVersion },
5310
+ { skip: claimed }
5311
+ );
5312
+ totalRegistered += residual;
5313
+ if (residual > 0) {
5314
+ ctx.logger.warn(
5315
+ `[MetadataPlugin] artifact '${label}' carries ${residual} top-level metadata item(s) that none of its ${bodies.length} package bodies declare. They were registered under the artifact's own manifest id ('${manifestPackageId ?? "<none>"}') because no package in the artifact claims them, so every door will report that id as their owner. Rebuild the artifact so each collection it ships is carried by the package that owns it.`
5316
+ );
5317
+ }
5318
+ }
5319
+ this.manager.registerLoader(memLoader);
5320
+ ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
5321
+ return totalRegistered;
5322
+ }
5323
+ /**
5324
+ * Register ONE artifact body's collections into the MetadataManager.
5325
+ *
5326
+ * A "body" is either the whole artifact (the single-package branch, where
5327
+ * the artifact and its one package are the same object) or one entry of
5328
+ * `packages[]` (ADR-0130 D4), which is an assembled
5329
+ * `{ ...manifest, ...collections }` payload carrying the same collection
5330
+ * keys the top level does. The loop is identical for both — that is the
5331
+ * point: there is one ingestion of a collection here, not one per shape.
5332
+ *
5333
+ * @param provenance - The `(packageId, packageVersion)` every item found in
5334
+ * this body is stamped with (ADR-0010 §3.7, via `applyProtection`). It is
5335
+ * the body's OWN identity, never the enclosing artifact's, which is what
5336
+ * makes a multi-package artifact's items agree with the registry and with
5337
+ * `GET /api/v1/packages` about who owns them.
5338
+ * @param slots.claim - Called with every `(type, name)` this pass
5339
+ * registered. Passed when reading package bodies; the residual sweep uses
5340
+ * what it recorded.
5341
+ * @param slots.skip - Consulted before registering each `(type, name)`.
5342
+ * Passed ONLY by the residual sweep, so a package body's copy is never
5343
+ * overwritten by the flattened top-level copy of the same definition —
5344
+ * the overwrite that re-attributed the item to the artifact's manifest.
5345
+ * ⛔ It is never passed while reading the bodies themselves: two items of
5346
+ * one name inside one body still register as they always have (last
5347
+ * wins), because suppressing that would be a behaviour change on the
5348
+ * single-package branch D7 pins.
5349
+ * @returns How many items this body registered.
5350
+ */
5351
+ async _registerArtifactBodyCollections(ctx, memLoader, body, provenance, slots = {}) {
5352
+ const { packageId, packageVersion } = provenance;
4877
5353
  let totalRegistered = 0;
4878
5354
  for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
4879
- const items = metadata[field];
5355
+ const items = body[field];
4880
5356
  if (!Array.isArray(items) || items.length === 0) continue;
4881
5357
  for (const item of items) {
4882
- if (metaType === "view" && (0, import_spec3.isAggregatedViewContainer)(item)) {
4883
- const viewObject = item?.list?.data?.object ?? item?.form?.data?.object;
5358
+ if (metaType === "view" && (0, import_spec4.isAggregatedViewContainer)(item)) {
5359
+ const viewObject = deriveViewContainerObject(item);
4884
5360
  if (!viewObject) continue;
4885
- (0, import_shared2.applyProtection)(item, {
4886
- packageId: manifestPackageId,
4887
- packageVersion: manifestVersion
5361
+ if (slots.skip?.("view", viewObject)) continue;
5362
+ (0, import_shared3.applyProtection)(item, {
5363
+ packageId,
5364
+ packageVersion
4888
5365
  });
4889
5366
  await memLoader.save("view", viewObject, item);
4890
5367
  await this.manager.register("view", viewObject, item, { notify: false });
4891
5368
  totalRegistered++;
4892
- for (const vi of (0, import_spec3.expandViewContainer)(viewObject, item)) {
5369
+ slots.claim?.("view", viewObject);
5370
+ for (const vi of (0, import_spec4.expandViewContainer)(viewObject, item)) {
4893
5371
  for (const w of vi._diagnostics?.warnings ?? []) {
4894
5372
  ctx.logger.warn(`[MetadataPlugin] View expansion warning for '${vi.name}': ${w.message}`);
4895
5373
  }
4896
- (0, import_shared2.applyProtection)(vi, {
4897
- packageId: manifestPackageId,
4898
- packageVersion: manifestVersion
5374
+ (0, import_shared3.applyProtection)(vi, {
5375
+ packageId,
5376
+ packageVersion
4899
5377
  });
4900
5378
  await memLoader.save("view", vi.name, vi);
4901
5379
  await this.manager.register("view", vi.name, vi, { notify: false });
4902
5380
  totalRegistered++;
5381
+ slots.claim?.("view", vi.name);
4903
5382
  }
4904
5383
  continue;
4905
5384
  }
4906
5385
  let name = item?.name;
4907
5386
  if (!name) {
4908
5387
  if (metaType === "view") {
4909
- name = item?.list?.data?.object ?? item?.form?.data?.object;
5388
+ name = deriveViewContainerObject(item);
4910
5389
  }
4911
5390
  }
4912
5391
  if (!name) continue;
4913
- (0, import_shared2.applyProtection)(item, {
4914
- packageId: manifestPackageId,
4915
- packageVersion: manifestVersion
5392
+ if (slots.skip?.(metaType, name)) continue;
5393
+ (0, import_shared3.applyProtection)(item, {
5394
+ packageId,
5395
+ packageVersion
4916
5396
  });
4917
5397
  await memLoader.save(metaType, name, item);
4918
5398
  await this.manager.register(metaType, name, item, { notify: false });
4919
5399
  totalRegistered++;
5400
+ slots.claim?.(metaType, name);
4920
5401
  }
4921
5402
  }
4922
- this.manager.registerLoader(memLoader);
4923
- ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
4924
5403
  return totalRegistered;
4925
5404
  }
4926
5405
  /**
@@ -4996,7 +5475,7 @@ var MetadataPlugin = class {
4996
5475
  for (const item of items) {
4997
5476
  const meta = item;
4998
5477
  if (meta?.name) {
4999
- (0, import_shared2.applyProtection)(meta, {
5478
+ (0, import_shared3.applyProtection)(meta, {
5000
5479
  packageId: this.options.packageId
5001
5480
  });
5002
5481
  await this.manager.register(entry.type, meta.name, item, { notify: false });
@@ -5114,7 +5593,7 @@ var RemoteLoader = class {
5114
5593
  };
5115
5594
 
5116
5595
  // src/index.ts
5117
- var import_metadata_core3 = require("@objectstack/metadata-core");
5596
+ var import_metadata_core4 = require("@objectstack/metadata-core");
5118
5597
 
5119
5598
  // src/utils/history-cleanup.ts
5120
5599
  var import_kernel3 = require("@objectstack/spec/kernel");
@@ -5403,6 +5882,7 @@ var MigrationExecutor = class {
5403
5882
  TypeScriptSerializer,
5404
5883
  YAMLSerializer,
5405
5884
  calculateChecksum,
5885
+ deriveViewContainerObject,
5406
5886
  generateDiffSummary,
5407
5887
  generateSimpleDiff
5408
5888
  });