@objectstack/metadata 17.2.0 → 17.4.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/index.js CHANGED
@@ -16,9 +16,14 @@ var __export = (target, all) => {
16
16
  // src/routes/hmr-routes.ts
17
17
  var hmr_routes_exports = {};
18
18
  __export(hmr_routes_exports, {
19
+ isDevMetadataEndpointEnabled: () => isDevMetadataEndpointEnabled,
19
20
  registerMetadataHmrRoutes: () => registerMetadataHmrRoutes
20
21
  });
22
+ function isDevMetadataEndpointEnabled(env = process.env) {
23
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "development";
24
+ }
21
25
  function registerMetadataHmrRoutes(app, manager, options = {}) {
26
+ if (!isDevMetadataEndpointEnabled()) return null;
22
27
  const routePath = options.path ?? "/api/v1/dev/metadata-events";
23
28
  const listeners = /* @__PURE__ */ new Set();
24
29
  const broadcast = (evt) => {
@@ -351,7 +356,7 @@ export default metadata;
351
356
  };
352
357
 
353
358
  // src/loaders/database-loader.ts
354
- import { SysMetadataObject, SysMetadataHistoryObject } from "@objectstack/metadata-core";
359
+ import { SysMetadataObject as SysMetadataObject2, SysMetadataHistoryObject as SysMetadataHistoryObject2 } from "@objectstack/metadata-core";
355
360
  import { applyConversionsToStoredItem } from "@objectstack/spec";
356
361
  import { PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
357
362
 
@@ -519,141 +524,49 @@ var LRUCache = class {
519
524
  }
520
525
  };
521
526
 
522
- // src/utils/schema-sync-errors.ts
523
- import { isRelationSubObjectPhrase } from "@objectstack/types";
524
- var ALREADY_EXISTS = {
525
- codes: /* @__PURE__ */ new Set([
526
- // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
527
- "42P07",
528
- // duplicate_table
529
- "42701",
530
- // duplicate_column
531
- "42710",
532
- // duplicate_object — index / constraint already exists
533
- // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)
534
- "ER_TABLE_EXISTS_ERROR",
535
- // 1050
536
- "ER_DUP_FIELDNAME",
537
- // 1060
538
- "ER_DUP_KEYNAME"
539
- // 1061
540
- ]),
541
- errnos: /* @__PURE__ */ new Set([1050, 1060, 1061]),
542
- /**
543
- * Message fallback for drivers that carry no machine-readable code —
544
- * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for
545
- * every DDL failure, so the message is the only signal available:
546
- * - `table sys_metadata already exists`
547
- * - `duplicate column name: environment_id`
548
- * - `index idx_x already exists`
549
- * Postgres phrases its own as `relation "x" already exists` /
550
- * `column "x" of relation "y" already exists`, which matches the same test.
551
- */
552
- message: /already exists|duplicate column name|duplicate key name/i
553
- };
554
- var MISSING_TABLE = {
555
- codes: /* @__PURE__ */ new Set([
556
- "42P01",
557
- // PostgreSQL undefined_table
558
- "ER_NO_SUCH_TABLE"
559
- // MySQL / MariaDB 1146
560
- ]),
561
- errnos: /* @__PURE__ */ new Set([1146]),
562
- /**
563
- * - SQLite / libsql: `no such table: sys_metadata_history`
564
- * - PostgreSQL: `relation "sys_metadata_history" does not exist`
565
- * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
566
- */
567
- message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
568
- excludes: {
569
- /**
570
- * Exactly the three SQLSTATEs the docblock above already names as
571
- * must-stay-loud neighbours of `does not exist`. They are listed here
572
- * rather than merely trusted to miss the message test, because two of
573
- * them (42703 columns, 42704 constraints/triggers) have a phrasing that
574
- * *does* hit it, and because a code is a fact where prose is a guess.
575
- *
576
- * Postgres-shaped on purpose: measured, neither MySQL
577
- * (`Unknown column 'label' in 'field list'`) nor SQLite
578
- * (`no such column: bogus`, `table t has no column named label`)
579
- * phrases a sub-object failure so that a missing-table phrase falls out
580
- * of it, so there is nothing there to exclude. Adding their codes would
581
- * be surface with no defect behind it.
582
- */
583
- codes: /* @__PURE__ */ new Set([
584
- "42703",
585
- // undefined_column
586
- "42704",
587
- // undefined_object — constraint, trigger, role, type, …
588
- "3D000"
589
- // invalid_catalog_name — `database "x" does not exist`
590
- ]),
591
- /**
592
- * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
593
- * failure about something *inside* a relation, which therefore says the
594
- * relation itself is present. The two in-repo siblings that carry this
595
- * phrase are `mapDataError` (`packages/rest`, #5352) and
596
- * `service-analytics`'s missing-column subtraction (#6035/PR #6346).
597
- *
598
- * [#6615] All three now read one home — `@objectstack/types` — instead
599
- * of three hand-kept copies, so the phrase can no longer be taught to
600
- * the repo a fourth time or drift in one package only. The **width**
601
- * difference that used to justify the copy is preserved and is the
602
- * reason the home exports two functions rather than one: those two
603
- * *extract* the column name to phrase a better error, so a miss costs a
604
- * vaguer message; this one *excludes*, so a miss restores the
605
- * corruption. {@link isRelationSubObjectPhrase} is therefore the wider
606
- * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
607
- * anchors: any sub-object, any quoted identifier, any verdict.
608
- * Over-matching here only ever converts a benign verdict into a loud
609
- * one, which is the direction this whole module already errs in.
610
- */
611
- matchesMessage: isRelationSubObjectPhrase
527
+ // src/loaders/database-loader.ts
528
+ import { isMissingTableError, isSchemaAlreadyExistsError } from "@objectstack/types";
529
+
530
+ // src/migrations/driver-exec.ts
531
+ function resolveDriverExec(driver) {
532
+ const candidate = driver;
533
+ if (!candidate) return void 0;
534
+ if (typeof candidate.execute === "function") {
535
+ return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);
612
536
  }
613
- };
614
- var MAX_CAUSE_DEPTH = 4;
615
- function matchesDriverError(error, signature, depth) {
616
- if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
617
- if (typeof error === "string") {
618
- if (signature.excludes?.matchesMessage(error)) return false;
619
- return signature.message.test(error);
620
- }
621
- if (typeof error !== "object") return false;
622
- const err = error;
623
- const excludes = signature.excludes;
624
- if (excludes) {
625
- if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
626
- if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
627
- }
628
- if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
629
- if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
630
- if (typeof err.message === "string" && signature.message.test(err.message)) return true;
631
- return matchesDriverError(err.cause, signature, depth + 1);
632
- }
633
- function isSchemaAlreadyExistsError(error, depth = 0) {
634
- return matchesDriverError(error, ALREADY_EXISTS, depth);
537
+ if (typeof candidate.raw === "function") {
538
+ return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);
539
+ }
540
+ return void 0;
635
541
  }
636
- function isMissingTableError(error, depth = 0) {
637
- return matchesDriverError(error, MISSING_TABLE, depth);
542
+ function driverExecRefusal(helper) {
543
+ 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.`;
638
544
  }
639
545
 
640
546
  // src/migrations/migrate-project-id-to-environment-id.ts
641
- var AFFECTED_TABLES = [
642
- "sys_metadata",
643
- "sys_metadata_history"
644
- ];
547
+ import { SysMetadataObject, SysMetadataHistoryObject } from "@objectstack/metadata-core";
548
+ var SOURCE_COLUMN = "project_id";
549
+ var TARGET_COLUMN = "environment_id";
550
+ var CANDIDATE_OBJECTS = [SysMetadataObject, SysMetadataHistoryObject];
551
+ function declaresColumn(object, column) {
552
+ return Object.prototype.hasOwnProperty.call(object.fields ?? {}, column);
553
+ }
554
+ var CANDIDATE_TABLES = CANDIDATE_OBJECTS.map((o) => o.name);
555
+ var AFFECTED_TABLES = CANDIDATE_OBJECTS.filter((o) => declaresColumn(o, TARGET_COLUMN)).map((o) => o.name);
645
556
  async function migrateProjectIdToEnvironmentId(driver) {
646
- const driverAny = driver;
647
- if (typeof driverAny.raw !== "function") {
648
- throw new Error(
649
- "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."
650
- );
557
+ const exec = resolveDriverExec(driver);
558
+ if (!exec) {
559
+ throw new Error(driverExecRefusal("migrateProjectIdToEnvironmentId"));
651
560
  }
652
561
  const results = [];
653
- for (const table of AFFECTED_TABLES) {
562
+ for (const table of CANDIDATE_TABLES) {
563
+ if (!AFFECTED_TABLES.includes(table)) {
564
+ results.push({ table, status: "skipped_not_declared" });
565
+ continue;
566
+ }
654
567
  try {
655
- const hasColumn = await _columnExists(driverAny, table, "project_id");
656
- const alreadyMigrated = await _columnExists(driverAny, table, "environment_id");
568
+ const hasColumn = await _columnExists(exec, table, SOURCE_COLUMN);
569
+ const alreadyMigrated = await _columnExists(exec, table, TARGET_COLUMN);
657
570
  if (alreadyMigrated && !hasColumn) {
658
571
  results.push({ table, status: "already_done" });
659
572
  continue;
@@ -662,8 +575,8 @@ async function migrateProjectIdToEnvironmentId(driver) {
662
575
  results.push({ table, status: "table_missing" });
663
576
  continue;
664
577
  }
665
- await driverAny.raw(
666
- `ALTER TABLE "${table}" RENAME COLUMN project_id TO environment_id`
578
+ await exec(
579
+ `ALTER TABLE "${table}" RENAME COLUMN ${SOURCE_COLUMN} TO ${TARGET_COLUMN}`
667
580
  );
668
581
  results.push({ table, status: "renamed" });
669
582
  } catch (err) {
@@ -672,14 +585,14 @@ async function migrateProjectIdToEnvironmentId(driver) {
672
585
  }
673
586
  return results;
674
587
  }
675
- async function _columnExists(driver, table, column) {
588
+ async function _columnExists(exec, table, column) {
676
589
  try {
677
- const rows = await driver.raw(`PRAGMA table_info("${table}")`);
590
+ const rows = await exec(`PRAGMA table_info("${table}")`);
678
591
  if (Array.isArray(rows) && rows.length > 0) {
679
592
  const list2 = Array.isArray(rows[0]) ? rows[0] : rows;
680
593
  return list2.some((r) => r?.name === column);
681
594
  }
682
- const result = await driver.raw(
595
+ const result = await exec(
683
596
  `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
684
597
  [table, column]
685
598
  );
@@ -691,6 +604,16 @@ async function _columnExists(driver, table, column) {
691
604
  }
692
605
 
693
606
  // src/loaders/database-loader.ts
607
+ function canonicalIsoInstant(value) {
608
+ if (value === null || value === void 0) return void 0;
609
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value.toISOString();
610
+ if (typeof value === "string") return value;
611
+ return String(value);
612
+ }
613
+ function isoFromValidDate(value) {
614
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
615
+ return value;
616
+ }
694
617
  var DatabaseLoader = class {
695
618
  constructor(options) {
696
619
  this.contract = {
@@ -739,7 +662,7 @@ var DatabaseLoader = class {
739
662
  if (cacheEnabled) {
740
663
  const lruOpts = {
741
664
  maxSize: cacheOpts?.maxSize ?? 500,
742
- ttl: cacheOpts?.ttl ?? 6e4
665
+ ttl: cacheOpts?.ttlMs ?? 6e4
743
666
  };
744
667
  this.loadCache = new LRUCache(lruOpts);
745
668
  this.loadManyCache = new LRUCache(lruOpts);
@@ -827,9 +750,11 @@ var DatabaseLoader = class {
827
750
  }
828
751
  return this.driver.create(table, data);
829
752
  }
753
+ // `null` is the driver path's not-found answer (`IDataDriver.update()`,
754
+ // #13878); both callers here resolve the row first and discard the result.
830
755
  async _update(table, id, data) {
831
756
  if (this.engine) {
832
- return this.engine.update(table, { id, ...data });
757
+ return this.engine.update(table, { ...data, id });
833
758
  }
834
759
  return this.driver.update(table, id, data);
835
760
  }
@@ -874,7 +799,7 @@ var DatabaseLoader = class {
874
799
  }
875
800
  return max + 1;
876
801
  } catch (error) {
877
- if (isMissingTableError(error)) return 1;
802
+ if (isMissingTableError(error, this.historyTableName)) return 1;
878
803
  throw error;
879
804
  }
880
805
  }
@@ -912,7 +837,7 @@ var DatabaseLoader = class {
912
837
  }
913
838
  try {
914
839
  await this.driver.syncSchema(this.tableName, {
915
- ...SysMetadataObject,
840
+ ...SysMetadataObject2,
916
841
  name: this.tableName
917
842
  });
918
843
  } catch (error) {
@@ -951,7 +876,7 @@ var DatabaseLoader = class {
951
876
  }
952
877
  try {
953
878
  await this.driver.syncSchema(this.historyTableName, {
954
- ...SysMetadataHistoryObject,
879
+ ...SysMetadataHistoryObject2,
955
880
  name: this.historyTableName
956
881
  });
957
882
  if (this.historySchemaFailureReported) {
@@ -1120,9 +1045,9 @@ var DatabaseLoader = class {
1120
1045
  source: row.source,
1121
1046
  tags: row.tags ? typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags : void 0,
1122
1047
  createdBy: row.created_by,
1123
- createdAt: row.created_at,
1048
+ createdAt: isoFromValidDate(row.created_at),
1124
1049
  updatedBy: row.updated_by,
1125
- updatedAt: row.updated_at
1050
+ updatedAt: isoFromValidDate(row.updated_at)
1126
1051
  };
1127
1052
  }
1128
1053
  // ==========================================
@@ -1173,7 +1098,7 @@ var DatabaseLoader = class {
1173
1098
  * with its empty value.
1174
1099
  */
1175
1100
  rethrowUnlessTableUnprovisioned(error) {
1176
- if (isMissingTableError(error)) return;
1101
+ if (isMissingTableError(error, this.tableName)) return;
1177
1102
  throw error;
1178
1103
  }
1179
1104
  // ==========================================
@@ -1223,17 +1148,38 @@ var DatabaseLoader = class {
1223
1148
  };
1224
1149
  }
1225
1150
  }
1226
- async loadMany(type, _options) {
1151
+ /**
1152
+ * The one type-wide read both plural readers share: every row of `type`, each
1153
+ * body paired with the `name` COLUMN it was stored under.
1154
+ *
1155
+ * [#14205] `name` is `null` only for a row whose key column does not hold a
1156
+ * string. Such a row is still a body {@link loadMany} must return — dropping
1157
+ * it would change what consumers see today — but it has no usable identity,
1158
+ * so {@link loadManyKeyed} filters it out rather than invent one.
1159
+ *
1160
+ * One query and one cache entry serve both methods: `loadMany()` used to own
1161
+ * them, and splitting them would have made every keyed `list()` read miss the
1162
+ * cache and re-hit the database.
1163
+ */
1164
+ async readTypeRows(type) {
1227
1165
  await this.ensureSchema();
1228
1166
  if (this.loadManyCache) {
1229
1167
  const cached = this.loadManyCache.get(type);
1230
- if (cached !== void 0) return cached;
1168
+ if (cached !== void 0) {
1169
+ return cached;
1170
+ }
1231
1171
  }
1232
1172
  try {
1233
1173
  const rows = await this._find(this.tableName, {
1234
1174
  where: this.baseFilter(type)
1235
1175
  });
1236
- const result = rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
1176
+ const result = [];
1177
+ for (const row of rows) {
1178
+ const data = this.rowToData(row);
1179
+ if (data === null) continue;
1180
+ const name = row.name;
1181
+ result.push({ name: typeof name === "string" && name !== "" ? name : null, data });
1182
+ }
1237
1183
  this.loadManyCache?.set(type, result);
1238
1184
  return result;
1239
1185
  } catch (error) {
@@ -1241,6 +1187,29 @@ var DatabaseLoader = class {
1241
1187
  return [];
1242
1188
  }
1243
1189
  }
1190
+ async loadMany(type, _options) {
1191
+ return (await this.readTypeRows(type)).map((entry) => entry.data);
1192
+ }
1193
+ /**
1194
+ * [#14205] The keyed half of {@link loadMany} — see
1195
+ * {@link MetadataKeyedItem} for why the row key travels beside the body
1196
+ * instead of inside it.
1197
+ *
1198
+ * `DatabaseLoader` is where the defect was measured: an aggregated view
1199
+ * container is written by `register('view', OBJECT, container)` and stored
1200
+ * verbatim, so its `sys_metadata` row carries the identity in the `name`
1201
+ * COLUMN and the body has none. {@link rowToData} returns that body without
1202
+ * folding the column in — deliberately, and unchanged here.
1203
+ */
1204
+ async loadManyKeyed(type, _options) {
1205
+ const entries = await this.readTypeRows(type);
1206
+ const keyed = [];
1207
+ for (const entry of entries) {
1208
+ if (entry.name === null) continue;
1209
+ keyed.push({ name: entry.name, data: entry.data });
1210
+ }
1211
+ return keyed;
1212
+ }
1244
1213
  async exists(type, name) {
1245
1214
  await this.ensureSchema();
1246
1215
  if (this.loadCache) {
@@ -1276,7 +1245,7 @@ var DatabaseLoader = class {
1276
1245
  const metadataStr = typeof row.metadata === "string" ? row.metadata : JSON.stringify(row.metadata);
1277
1246
  const stats = {
1278
1247
  size: metadataStr.length,
1279
- mtime: record.updatedAt ?? record.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1248
+ mtime: canonicalIsoInstant(record.updatedAt ?? record.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
1280
1249
  format: "json",
1281
1250
  etag: record.checksum
1282
1251
  };
@@ -1337,7 +1306,7 @@ var DatabaseLoader = class {
1337
1306
  changeNote: row.change_note,
1338
1307
  organizationId: row.organization_id,
1339
1308
  recordedBy: row.recorded_by,
1340
- recordedAt: row.recorded_at
1309
+ recordedAt: isoFromValidDate(row.recorded_at)
1341
1310
  };
1342
1311
  }
1343
1312
  /**
@@ -1394,7 +1363,7 @@ var DatabaseLoader = class {
1394
1363
  changeNote: row.change_note,
1395
1364
  organizationId: row.organization_id,
1396
1365
  recordedBy: row.recorded_by,
1397
- recordedAt: row.recorded_at
1366
+ recordedAt: isoFromValidDate(row.recorded_at)
1398
1367
  };
1399
1368
  });
1400
1369
  return { records: result, total, hasMore };
@@ -1543,6 +1512,33 @@ function generateId() {
1543
1512
  return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
1544
1513
  }
1545
1514
 
1515
+ // src/loaders/ambiguous-metadata-stem.ts
1516
+ var AMBIGUOUS_METADATA_STEM_CODE = "AMBIGUOUS_METADATA_STEM";
1517
+ var AMBIGUOUS_METADATA_STEM_STATUS = 500;
1518
+ var AMBIGUOUS_METADATA_STEM_BRAND = /* @__PURE__ */ Symbol.for("objectstack.metadata.ambiguousStem");
1519
+ var _a, _b;
1520
+ var AmbiguousMetadataStemError = class extends (_b = Error, _a = AMBIGUOUS_METADATA_STEM_BRAND, _b) {
1521
+ constructor(type, stem, paths) {
1522
+ const sorted = [...paths].sort();
1523
+ super(
1524
+ `Ambiguous metadata name \`${stem}\` for type \`${type}\`: ${sorted.length} files resolve to the same name \u2014 ${sorted.map((p) => `\`${p}\``).join(", ")}. Only the first would ever be served (extension precedence: .json, .yaml, .yml, .ts, .js), so the others are listed and unreachable. Delete or rename all but one.`
1525
+ );
1526
+ /** Brand — see the module doc on why this is not `instanceof`. */
1527
+ this[_a] = true;
1528
+ /** ADR-0112 wire code. */
1529
+ this.code = AMBIGUOUS_METADATA_STEM_CODE;
1530
+ /** HTTP status a transport should answer. */
1531
+ this.status = AMBIGUOUS_METADATA_STEM_STATUS;
1532
+ this.name = "AmbiguousMetadataStemError";
1533
+ this.type = type;
1534
+ this.stem = stem;
1535
+ this.paths = sorted;
1536
+ }
1537
+ };
1538
+ function isAmbiguousMetadataStemError(err) {
1539
+ return typeof err === "object" && err !== null && err[AMBIGUOUS_METADATA_STEM_BRAND] === true;
1540
+ }
1541
+
1546
1542
  // src/endpoint-matcher.ts
1547
1543
  import {
1548
1544
  ApiEndpointSchema,
@@ -1694,6 +1690,39 @@ var EndpointMatcher = class {
1694
1690
  }
1695
1691
  };
1696
1692
 
1693
+ // src/view-container-expansion.ts
1694
+ import {
1695
+ expandViewContainer,
1696
+ isAggregatedViewContainer
1697
+ } from "@objectstack/spec";
1698
+ import { applyProtection } from "@objectstack/spec/shared";
1699
+
1700
+ // src/view-container.ts
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
+
1709
+ // src/view-container-expansion.ts
1710
+ function expandRuntimeViewContainer(data) {
1711
+ if (!isAggregatedViewContainer(data)) return [];
1712
+ const container = data;
1713
+ const viewObject = deriveViewContainerObject(container);
1714
+ if (!viewObject) return [];
1715
+ const out = [];
1716
+ for (const vi of expandViewContainer(viewObject, container)) {
1717
+ applyProtection(vi, {
1718
+ packageId: container._packageId,
1719
+ packageVersion: container._packageVersion
1720
+ });
1721
+ out.push(vi);
1722
+ }
1723
+ return out;
1724
+ }
1725
+
1697
1726
  // src/metadata-manager.ts
1698
1727
  var WRITABLE_LOADER_METHODS = ["save", "delete"];
1699
1728
  var WRITABLE_LOADER_METHOD_SIGNATURE = {
@@ -1733,8 +1762,6 @@ var _MetadataManager = class _MetadataManager {
1733
1762
  this.watchCallbacks = /* @__PURE__ */ new Map();
1734
1763
  // In-memory metadata registry: type -> name -> data
1735
1764
  this.registry = /* @__PURE__ */ new Map();
1736
- // Overlay storage: "type:name:scope" -> MetadataOverlay
1737
- this.overlays = /* @__PURE__ */ new Map();
1738
1765
  // Type registry for metadata type info
1739
1766
  this.typeRegistry = [];
1740
1767
  // Dependency tracking: "type:name" -> dependencies
@@ -2309,6 +2336,67 @@ var _MetadataManager = class _MetadataManager {
2309
2336
  * result may be memoized depends on what happened to the read's registration
2310
2337
  * while it ran, which only `list()` can see.
2311
2338
  */
2339
+ /**
2340
+ * Merge one loader's answer for `type` into `items`, under the identity that
2341
+ * loader holds each item by.
2342
+ *
2343
+ * ## [#14205] The identity of a loader-held item is its ROW KEY
2344
+ *
2345
+ * Both plural readers used to key a loader's items by `body.name`, and admit
2346
+ * an item only when the body carried a string one:
2347
+ *
2348
+ * ```ts
2349
+ * if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name))
2350
+ * ```
2351
+ *
2352
+ * A body is not required to name itself. `register(type, name, data)` takes
2353
+ * the key as its ARGUMENT, and `assertMetadataRegisterContract` says in as
2354
+ * many words that "A document with NO `name` of its own is fine — the argument
2355
+ * is the key". An aggregated `defineView` container is exactly that: no own
2356
+ * `name` by design, identity carried in the row's `name` column.
2357
+ *
2358
+ * So the old gate dropped every such item the moment the registry went cold
2359
+ * and only the loader could answer — a persisted view container vanished from
2360
+ * `list('view')` after a restart, and `listDiagnosed()` called the short
2361
+ * answer complete because no loader had thrown. Same gate, same effect, in
2362
+ * `listForIndex()`: a nameless `api` row fell out of the endpoint index, where
2363
+ * a miss reads as "nothing declares this route".
2364
+ *
2365
+ * The repair is to ask the loader for the key instead of guessing it from the
2366
+ * body ({@link MetadataLoader.loadManyKeyed}), and to keep the key BESIDE the
2367
+ * body: nothing is written into a body that deliberately has none, so the
2368
+ * register contract's refusal of a disagreeing `data.name` still means what it
2369
+ * says.
2370
+ *
2371
+ * Nothing consumers see today changes shape. For any item that went through
2372
+ * `register()`, a `data.name` that exists is required to EQUAL the key, so the
2373
+ * keyed merge produces the identical map entry; what is new is only the
2374
+ * entries the old gate refused. The `loadMany()` fallback below is the
2375
+ * pre-#14205 behaviour verbatim, for loaders that cannot produce keys
2376
+ * (`RemoteLoader`'s wire format carries bodies only).
2377
+ *
2378
+ * Read failures are NOT caught here: `readListUncached` warns-and-continues,
2379
+ * `listForIndex` deliberately throws, and that difference is each caller's to
2380
+ * keep.
2381
+ */
2382
+ async admitLoaderItems(loader, type, items) {
2383
+ if (typeof loader.loadManyKeyed === "function") {
2384
+ const keyed = await loader.loadManyKeyed(type);
2385
+ for (const entry of keyed) {
2386
+ if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
2387
+ if (items.has(entry.name)) continue;
2388
+ items.set(entry.name, entry.data);
2389
+ }
2390
+ return;
2391
+ }
2392
+ const loaderItems = await loader.loadMany(type);
2393
+ for (const item of loaderItems) {
2394
+ const itemAny = item;
2395
+ if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2396
+ items.set(itemAny.name, item);
2397
+ }
2398
+ }
2399
+ }
2312
2400
  async readListUncached(type) {
2313
2401
  const items = /* @__PURE__ */ new Map();
2314
2402
  const typeStore = this.registry.get(type);
@@ -2321,15 +2409,12 @@ var _MetadataManager = class _MetadataManager {
2321
2409
  const errors = [];
2322
2410
  for (const loader of this.loaders.values()) {
2323
2411
  try {
2324
- const loaderItems = await loader.loadMany(type);
2325
- for (const item of loaderItems) {
2326
- const itemAny = item;
2327
- if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2328
- items.set(itemAny.name, item);
2329
- }
2330
- }
2412
+ await this.admitLoaderItems(loader, type, items);
2331
2413
  this.reportLoaderReadRecovered(loader.contract.name);
2332
2414
  } catch (e) {
2415
+ if (isAmbiguousMetadataStemError(e)) {
2416
+ throw e;
2417
+ }
2333
2418
  degraded = true;
2334
2419
  errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2335
2420
  this.reportLoaderReadFailure(loader.contract.name, type, e);
@@ -2480,13 +2565,7 @@ var _MetadataManager = class _MetadataManager {
2480
2565
  }
2481
2566
  }
2482
2567
  for (const loader of this.loaders.values()) {
2483
- const loaderItems = await loader.loadMany(type);
2484
- for (const item of loaderItems) {
2485
- const itemAny = item;
2486
- if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2487
- items.set(itemAny.name, item);
2488
- }
2489
- }
2568
+ await this.admitLoaderItems(loader, type, items);
2490
2569
  }
2491
2570
  return Array.from(items.values());
2492
2571
  }
@@ -2673,6 +2752,30 @@ var _MetadataManager = class _MetadataManager {
2673
2752
  }
2674
2753
  /**
2675
2754
  * List all names of metadata items of a given type
2755
+ *
2756
+ * ## [#14423] One loader's fault does not take the whole enumeration down
2757
+ *
2758
+ * This loop used to be bare — `const result = await loader.list(type)` with
2759
+ * no `try`, while the two sibling plural reads (`list()` via
2760
+ * {@link admitLoaderItems}, and {@link loadMany}) have carried a per-loader
2761
+ * `catch` since #5108. That asymmetry is the defect, independent of any one
2762
+ * caller: the SAME storage outage was swallowed by one plural read and
2763
+ * thrown out of the other, so which answer a caller got depended only on
2764
+ * which method it happened to call. A caller reading both — the action
2765
+ * governance audit is one — saw `loadMany` report a short-but-successful
2766
+ * set and `listNames` throw, and had no way to tell that one fact was
2767
+ * behind both.
2768
+ *
2769
+ * Same shape as `loadMany`'s, deliberately, down to the helpers: the outage
2770
+ * is spoken once per loader through {@link reportLoaderReadFailure} and
2771
+ * un-said through {@link reportLoaderReadRecovered}. ⛔ Not a third spelling
2772
+ * for "a loader faulted" — a second vocabulary for one event is how the two
2773
+ * reads drifted apart in the first place.
2774
+ *
2775
+ * The degradation is the same one `list()` documents and is graded the same
2776
+ * way (AGENTS.md → "Degradation log levels"): the caller still gets an
2777
+ * array, nothing 500s, and the set is quietly short — so it is reported at
2778
+ * `error`, by the shared helper, rather than being re-graded here.
2676
2779
  */
2677
2780
  async listNames(type) {
2678
2781
  type = canonicalMetadataServiceType(type);
@@ -2684,8 +2787,16 @@ var _MetadataManager = class _MetadataManager {
2684
2787
  }
2685
2788
  }
2686
2789
  for (const loader of this.loaders.values()) {
2687
- const result = await loader.list(type);
2688
- result.forEach((item) => names.add(item));
2790
+ try {
2791
+ const result = await loader.list(type);
2792
+ result.forEach((item) => names.add(item));
2793
+ this.reportLoaderReadRecovered(loader.contract.name);
2794
+ } catch (e) {
2795
+ if (isAmbiguousMetadataStemError(e)) {
2796
+ throw e;
2797
+ }
2798
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
2799
+ }
2689
2800
  }
2690
2801
  return Array.from(names);
2691
2802
  }
@@ -2731,12 +2842,54 @@ var _MetadataManager = class _MetadataManager {
2731
2842
  * Runtime-authored `shared` / `personal` views (`sys_view_definition`) are
2732
2843
  * merged in by the REST layer; this method returns the `package` layer that
2733
2844
  * was registered from source.
2845
+ *
2846
+ * ## [#13913] Aggregated containers are expanded inline, per read
2847
+ *
2848
+ * `this.list('view')` is `MetadataManager`'s OWN loader-based store — the
2849
+ * in-memory registry plus every registered loader — and is a completely
2850
+ * different store from the `sys_metadata` rows `getMetaItems` reads. #13407
2851
+ * taught `getMetaItems` to expand a runtime-authored aggregated container
2852
+ * inline; this exit never called it and had no equivalent step, so a
2853
+ * container that `GET /meta/view?object=` now serves still answered **empty**
2854
+ * here.
2855
+ *
2856
+ * Merely getting the container into the store would not have helped: the
2857
+ * filter also requires `viewKind`, and a container has none. Loosening that
2858
+ * requirement is NOT the repair — it would answer with the container itself
2859
+ * as a view, the behaviour #7163 ruled wrong — so what is added below is the
2860
+ * container's **expansion**, whose items each carry the `viewKind` + `object`
2861
+ * pair this filter has always tested. The filter itself is untouched: it
2862
+ * reads the top-level `object`, exactly as `ViewSchema.object` declares.
2863
+ *
2864
+ * Registry-free and per-read, mirroring #13407's choice at the other exit and
2865
+ * for the same reason — the registry is process-wide, so a read must not
2866
+ * graft rows into it (see `view-container-expansion.ts`'s header, which also
2867
+ * records why the protocol's copy of this logic cannot be imported).
2868
+ *
2869
+ * Already-present items win: an expansion contributes only names the store
2870
+ * does not already hold, so a container whose expanded ViewItems were
2871
+ * registered by a source registrar (the ObjectQL boot loop, the artifact/HMR
2872
+ * loader) still answers with those registered, fully-enriched items and this
2873
+ * step adds nothing.
2734
2874
  */
2735
2875
  async getViewsByObject(object) {
2736
2876
  const views = await this.list("view");
2737
- return views.filter(
2877
+ const matches = views.filter(
2738
2878
  (v) => v && typeof v === "object" && v.viewKind && v.object === object
2739
- ).sort(
2879
+ );
2880
+ const known = /* @__PURE__ */ new Set();
2881
+ for (const v of views) {
2882
+ if (v && typeof v === "object" && typeof v.name === "string") known.add(v.name);
2883
+ }
2884
+ for (const v of views) {
2885
+ for (const item of expandRuntimeViewContainer(v)) {
2886
+ if (!item.viewKind || item.object !== object) continue;
2887
+ if (known.has(item.name)) continue;
2888
+ known.add(item.name);
2889
+ matches.push(item);
2890
+ }
2891
+ }
2892
+ return matches.sort(
2740
2893
  (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.name).localeCompare(String(b.name))
2741
2894
  );
2742
2895
  }
@@ -3154,66 +3307,18 @@ var _MetadataManager = class _MetadataManager {
3154
3307
  };
3155
3308
  }
3156
3309
  // ==========================================
3157
- // Overlay / Customization Management
3310
+ // Overlay / Customization Management — REMOVED (#13135, ADR-0049)
3158
3311
  // ==========================================
3159
- overlayKey(type, name, scope = "platform") {
3160
- return `${encodeURIComponent(type)}:${encodeURIComponent(name)}:${scope}`;
3161
- }
3162
- /**
3163
- * Get the active overlay for a metadata item
3164
- */
3165
- async getOverlay(type, name, scope) {
3166
- return this.overlays.get(this.overlayKey(type, name, scope ?? "platform"));
3167
- }
3168
- /**
3169
- * Save/update an overlay for a metadata item
3170
- */
3171
- async saveOverlay(overlay) {
3172
- if (this.config.persistence?.overlayWritable === false) {
3173
- const msg = `MetadataManager overlays are read-only (persistence.overlayWritable=false); refusing to save overlay for ${overlay.baseType}/${overlay.baseName}`;
3174
- if (this.config.validation?.throwOnError) {
3175
- throw new Error(msg);
3176
- }
3177
- this.logger.warn(msg);
3178
- return;
3179
- }
3180
- const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
3181
- this.overlays.set(key, overlay);
3182
- }
3183
- /**
3184
- * Remove an overlay, reverting to the base definition
3185
- */
3186
- async removeOverlay(type, name, scope) {
3187
- this.overlays.delete(this.overlayKey(type, name, scope ?? "platform"));
3188
- }
3189
- /**
3190
- * Get the effective (merged) metadata after applying all overlays.
3191
- * Resolution order: system ← merge(platform) ← merge(user)
3192
- */
3193
- async getEffective(type, name, context) {
3194
- const base = await this.get(type, name);
3195
- if (!base) return void 0;
3196
- let effective = { ...base };
3197
- const platformOverlay = await this.getOverlay(type, name, "platform");
3198
- if (platformOverlay?.active && platformOverlay.patch) {
3199
- effective = { ...effective, ...platformOverlay.patch };
3200
- }
3201
- if (context?.userId) {
3202
- const userOverlayKey = this.overlayKey(type, name, "user") + `:${context.userId}`;
3203
- const userOverlay = this.overlays.get(userOverlayKey) ?? await this.getOverlay(type, name, "user");
3204
- if (userOverlay?.active && userOverlay.patch) {
3205
- if (!userOverlay.owner || userOverlay.owner === context.userId) {
3206
- effective = { ...effective, ...userOverlay.patch };
3207
- }
3208
- }
3209
- } else {
3210
- const userOverlay = await this.getOverlay(type, name, "user");
3211
- if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) {
3212
- effective = { ...effective, ...userOverlay.patch };
3213
- }
3214
- }
3215
- return effective;
3216
- }
3312
+ //
3313
+ // The in-memory overlay limb (`getOverlay` / `saveOverlay` / `removeOverlay`
3314
+ // / `getEffective`, keyed `type:name:scope`) implemented the paper
3315
+ // metadata-customization protocol removed from `@objectstack/spec` in the
3316
+ // same change: no route ever served the paper `.../overlay` or
3317
+ // `.../effective` endpoints, and the only callers of these methods were this
3318
+ // package's own unit tests. ADR-0126 supersedes the protocol on the record.
3319
+ // The org-scoped customization that actually ships is ADR-0005's
3320
+ // `sys_metadata` overlay (`getMetaItemLayered` in metadata-protocol), which
3321
+ // never lived here.
3217
3322
  // ==========================================
3218
3323
  // Watch / Subscribe (IMetadataService)
3219
3324
  // ==========================================
@@ -3551,6 +3656,99 @@ var _MetadataManager = class _MetadataManager {
3551
3656
  }
3552
3657
  return results;
3553
3658
  }
3659
+ /**
3660
+ * [#14423] {@link loadMany}, read under the identity the STORE holds each
3661
+ * item by — the keyed plural read, beside the unkeyed one.
3662
+ *
3663
+ * ## Why a second method and not a widened `loadMany`
3664
+ *
3665
+ * `loadMany` keys nothing: it returns bodies, and every consumer that needs
3666
+ * an identity reads `body.name` off them. #14205 already ruled what identity
3667
+ * IS — the key the store holds the item under (`register(type, name, data)`
3668
+ * takes it as the ARGUMENT, and a body is not required to name itself) — so
3669
+ * `body.name` is a guess that happens to be right for most items and drops
3670
+ * the rest ENTIRELY: an item whose body carries no `name` is served by
3671
+ * `load(type, name)` and is not nameable from `loadMany`'s answer at all.
3672
+ *
3673
+ * Widening `loadMany`'s return would fix that and break every consumer of a
3674
+ * published shape (the ones counted on this card all read `body.name` as the
3675
+ * identity). So this is additive: `loadMany`'s return shape is untouched,
3676
+ * and a caller that needs the key asks for the key.
3677
+ *
3678
+ * ## What it reads — the same population `loadMany` reads
3679
+ *
3680
+ * Loaders only, deliberately, so this is `loadMany` keyed and nothing more.
3681
+ * It is NOT `list()`/{@link listNames}, which also merge the in-memory
3682
+ * `register()` registry; a caller wanting that set has those. Reading the
3683
+ * loaders alone is also what makes this the enumerable twin of
3684
+ * {@link loadDiagnosed}, which walks the same loaders by name — that pairing
3685
+ * is the point on the audit side of #14423, where an enumeration and a
3686
+ * by-name read that disagree about a population make one subsystem accuse
3687
+ * another of a defect neither has.
3688
+ *
3689
+ * ## Delegate first, fall back second — and why that order is not a style
3690
+ *
3691
+ * Per loader: {@link MetadataLoader.loadManyKeyed} where the loader offers
3692
+ * one, else its `list()` + a per-name `load()`. Measured, on
3693
+ * `DatabaseLoader`: the keyed method shares `loadMany`'s single query
3694
+ * (`{find:1, findOne:0}` — zero extra cost), while enumerate-then-read-each
3695
+ * on that same loader is a real N+1 (`{find:1, findOne:5}` for five items).
3696
+ * The fallback exists for loaders that cannot produce keys at all
3697
+ * (`RemoteLoader`'s wire format carries bodies only), and it recovers the
3698
+ * nameless item the pre-#14205 `loadMany`-and-key-by-`body.name` fallback
3699
+ * drops — which is why it is `list()` + `load()` and not `loadMany()`.
3700
+ *
3701
+ * ## Failure posture
3702
+ *
3703
+ * Per-loader `try`/`catch`, the same seam and the same helpers as
3704
+ * {@link loadMany} and `list()` — one loader's outage does not take the
3705
+ * enumeration down, and it is reported once through
3706
+ * {@link reportLoaderReadFailure} rather than in a third vocabulary.
3707
+ * Earlier loaders win a key collision, mirroring `list()`.
3708
+ */
3709
+ async loadManyKeyed(type, options) {
3710
+ const items = /* @__PURE__ */ new Map();
3711
+ for (const loader of this.loaders.values()) {
3712
+ try {
3713
+ await this.admitKeyedLoaderItems(loader, type, items, options);
3714
+ this.reportLoaderReadRecovered(loader.contract.name);
3715
+ } catch (e) {
3716
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
3717
+ }
3718
+ }
3719
+ return Array.from(items, ([name, data]) => ({ name, data }));
3720
+ }
3721
+ /**
3722
+ * Merge ONE loader's answer for `type` into `items`, keyed by that loader's
3723
+ * own key for each item — {@link loadManyKeyed}'s per-loader body.
3724
+ *
3725
+ * Distinct from {@link admitLoaderItems} on exactly one axis, and that axis
3726
+ * is the whole of #14423: the fallback for a loader with no
3727
+ * `loadManyKeyed`. `admitLoaderItems` falls back to `loadMany` keyed by
3728
+ * `data.name` — the pre-#14205 behaviour, verbatim, which drops a nameless
3729
+ * body. Here the fallback is `list()` + a per-name `load()`, so a loader
3730
+ * that cannot enumerate keys and bodies together still answers with both.
3731
+ *
3732
+ * Read failures are NOT caught here — the caller owns that verdict, as in
3733
+ * {@link admitLoaderItems}.
3734
+ */
3735
+ async admitKeyedLoaderItems(loader, type, items, options) {
3736
+ if (typeof loader.loadManyKeyed === "function") {
3737
+ const keyed = await loader.loadManyKeyed(type, options);
3738
+ for (const entry of keyed) {
3739
+ if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
3740
+ if (items.has(entry.name)) continue;
3741
+ items.set(entry.name, entry.data);
3742
+ }
3743
+ return;
3744
+ }
3745
+ for (const name of await loader.list(type)) {
3746
+ if (typeof name !== "string" || name === "" || items.has(name)) continue;
3747
+ const result = await loader.load(type, name, options);
3748
+ if (result?.data == null) continue;
3749
+ items.set(name, result.data);
3750
+ }
3751
+ }
3554
3752
  /**
3555
3753
  * Save metadata item to a loader
3556
3754
  */
@@ -3987,6 +4185,7 @@ var MetadataManager = _MetadataManager;
3987
4185
  // src/plugin.ts
3988
4186
  import { readFile as readFile2 } from "fs/promises";
3989
4187
  import { createHash as createHash2 } from "crypto";
4188
+ import { resolveArtifactPackageOrder, artifactPackageId } from "@objectstack/core";
3990
4189
 
3991
4190
  // src/node-metadata-manager.ts
3992
4191
  import * as path2 from "path";
@@ -3997,7 +4196,11 @@ import * as fs from "fs/promises";
3997
4196
  import * as path from "path";
3998
4197
  import { glob } from "glob";
3999
4198
  import { createHash } from "crypto";
4000
- var FilesystemLoader = class {
4199
+ function ownNameOf(data) {
4200
+ const own = data?.name;
4201
+ return typeof own === "string" && own !== "" ? own : null;
4202
+ }
4203
+ var _FilesystemLoader = class _FilesystemLoader {
4001
4204
  constructor(rootDir, serializers, logger) {
4002
4205
  this.rootDir = rootDir;
4003
4206
  this.serializers = serializers;
@@ -4095,6 +4298,82 @@ var FilesystemLoader = class {
4095
4298
  }
4096
4299
  }
4097
4300
  async loadMany(type, options) {
4301
+ return (await this.loadManyEntries(type, options)).map((entry) => entry.data);
4302
+ }
4303
+ /**
4304
+ * [#14341] The keyed half of {@link loadMany} — see {@link MetadataKeyedItem}
4305
+ * for why the store's key travels BESIDE the body instead of being folded
4306
+ * into it.
4307
+ *
4308
+ * THE RULE, in one sentence: an item is keyed by this loader's own
4309
+ * name-to-path derivation — {@link nameFromFilename}, the very basename
4310
+ * derivation `list()` reports — ONLY where that derivation is a bijection for
4311
+ * the file (it sits directly under `ROOT/TYPE/` and carries one of the
4312
+ * extensions {@link findFile} tries, so `findFile(type, key)` resolves back to
4313
+ * this same file); every other shape keeps the pre-#14205 behaviour verbatim,
4314
+ * keyed by `body.name` when it has one and dropped when it has none.
4315
+ *
4316
+ * Why the rule stops there (PM ruling on #14341, 2026-09-02, knowingly over
4317
+ * triage's "a nested path keeps whatever `list()` reports for it today"):
4318
+ * `list()` and `findFile()` DISAGREE outside that shape. For
4319
+ * `ROOT/TYPE/crm/account.json`, `list()` reports the bare `account`, but
4320
+ * `findFile()` resolves that name against `ROOT/TYPE/account.json` and finds
4321
+ * nothing — the only name reaching the file is `crm/account`, which nothing
4322
+ * reports. An extension-less file is read by `loadMany()` and reported by
4323
+ * `list()`, and `findFile()` resolves neither. Keying by either side would
4324
+ * mint a name some other door cannot open, and two directories holding the
4325
+ * same basename would collide in silence
4326
+ * (`MetadataManager.admitLoaderItems()` keeps the first and says nothing).
4327
+ * The card's own fence: "keying items under names nothing else uses … is
4328
+ * worse than today's honest drop". So the drop stays exactly where the key is
4329
+ * unsettled, and is pinned as a RECORD in
4330
+ * `filesystem-loader-keyed-items.test.ts`.
4331
+ *
4332
+ * [#14486, partial] `list()` and {@link findFile} have since converged on
4333
+ * {@link resolvableNameForPath} — the derivation this method already used —
4334
+ * so a nested or extension-less file is now neither listed nor resolvable.
4335
+ * What did NOT change is the WALK behind this method: `loadManyEntries()`
4336
+ * still READS those files, so `loadMany()` still returns their bodies and
4337
+ * this method still falls back to `body.name` for them. That half of the
4338
+ * #14486 ruling ("nothing unlisted is returned by `loadMany()` either") is
4339
+ * deliberately NOT taken here: it would invert the three landed #14341 pins
4340
+ * in `filesystem-loader-keyed-items.test.ts:113,167,187` and the
4341
+ * `loadMany()` CONTROL at `:196`, and that file was under a concurrent
4342
+ * claim (PR #14627) when this landed. The remaining divergence — listed ⊂
4343
+ * loaded — is pinned as a RECORD in
4344
+ * `filesystem-loader-list-reachability.test.ts` rather than left implicit.
4345
+ *
4346
+ * One consequence, deliberate: a flat file whose `body.name` DISAGREES with
4347
+ * its basename is now keyed by the BASENAME. That is #14205's rule (identity
4348
+ * is the key the store holds an item under, not `body.name`) applied to this
4349
+ * loader, and it aligns `MetadataManager.list()` with `listNames()` for that
4350
+ * shape.
4351
+ *
4352
+ * The body is handed back by reference, unchanged: nothing is written into a
4353
+ * body that deliberately has no `name`. `limit` bounds the items LOADED,
4354
+ * exactly as `loadMany()` does — an entry the key rule drops has still been
4355
+ * read and still counts against it.
4356
+ */
4357
+ async loadManyKeyed(type, options) {
4358
+ const typeDir = path.join(this.rootDir, type);
4359
+ const keyed = [];
4360
+ for (const entry of await this.loadManyEntries(type, options)) {
4361
+ const name = this.resolvableNameForPath(typeDir, entry.file) ?? ownNameOf(entry.data);
4362
+ if (name) {
4363
+ keyed.push({ name, data: entry.data });
4364
+ }
4365
+ }
4366
+ return keyed;
4367
+ }
4368
+ /**
4369
+ * The single walk behind {@link loadMany} and {@link loadManyKeyed}: one glob,
4370
+ * one serializer pass, one `limit`. Shared so the two can never answer with
4371
+ * different bodies for the same file — {@link MetadataLoader.loadManyKeyed}
4372
+ * requires `data` to be "the same body `loadMany()` would return for the
4373
+ * item", and a second copy of this walk is how that would quietly stop being
4374
+ * true.
4375
+ */
4376
+ async loadManyEntries(type, options) {
4098
4377
  const { patterns = ["**/*"], recursive: _recursive = true, limit } = options || {};
4099
4378
  const typeDir = path.join(this.rootDir, type);
4100
4379
  const items = [];
@@ -4102,33 +4381,34 @@ var FilesystemLoader = class {
4102
4381
  const globPatterns = patterns.map(
4103
4382
  (pattern) => path.join(typeDir, pattern)
4104
4383
  );
4384
+ const files = [];
4105
4385
  for (const pattern of globPatterns) {
4106
- const files = await glob(pattern, {
4107
- ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
4108
- nodir: true
4109
- });
4110
- for (const file of files) {
4111
- if (limit && items.length >= limit) {
4112
- break;
4113
- }
4114
- try {
4115
- const content = await fs.readFile(file, "utf-8");
4116
- const format = this.detectFormat(file);
4117
- const serializer = this.getSerializer(format);
4118
- if (serializer) {
4119
- const data = serializer.deserialize(content);
4120
- items.push(data);
4121
- }
4122
- } catch (error) {
4123
- this.logger?.warn("Failed to load file", {
4124
- file,
4125
- error: error instanceof Error ? error.message : String(error)
4126
- });
4127
- }
4128
- }
4386
+ files.push(
4387
+ ...await glob(pattern, {
4388
+ ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
4389
+ nodir: true
4390
+ })
4391
+ );
4392
+ }
4393
+ this.resolvableNames(type, typeDir, files);
4394
+ for (const file of files) {
4129
4395
  if (limit && items.length >= limit) {
4130
4396
  break;
4131
4397
  }
4398
+ try {
4399
+ const content = await fs.readFile(file, "utf-8");
4400
+ const format = this.detectFormat(file);
4401
+ const serializer = this.getSerializer(format);
4402
+ if (serializer) {
4403
+ const data = serializer.deserialize(content);
4404
+ items.push({ file, data });
4405
+ }
4406
+ } catch (error) {
4407
+ this.logger?.warn("Failed to load file", {
4408
+ file,
4409
+ error: error instanceof Error ? error.message : String(error)
4410
+ });
4411
+ }
4132
4412
  }
4133
4413
  return items;
4134
4414
  } catch (error) {
@@ -4171,19 +4451,39 @@ var FilesystemLoader = class {
4171
4451
  return null;
4172
4452
  }
4173
4453
  }
4454
+ /**
4455
+ * [#14486] The names this loader can be asked for, and ONLY those: a file
4456
+ * directly under `ROOT/TYPE/` carrying an extension one of this instance's
4457
+ * REGISTERED serializers claims. Every name it reports resolves back through
4458
+ * {@link findFile}, so `listNames()` and `get()` give the same answer.
4459
+ *
4460
+ * It used to report `path.basename(file, ext)` for every file the glob found,
4461
+ * nested or not, extension or not — and {@link findFile} resolves neither
4462
+ * shape. `ROOT/TYPE/crm/account.json` was listed as `account`, which resolves
4463
+ * against `ROOT/TYPE/account.json` and finds nothing; an extension-less
4464
+ * `ROOT/TYPE/noext` was listed as `noext`, which resolves under no appended
4465
+ * extension at all. A name in the list that `get()` answers `null` for is the
4466
+ * silent failure an author (human or AI) reads as their own typo, so they
4467
+ * retry the same word: the list and the door now agree instead.
4468
+ *
4469
+ * Ruling (maintainer, via the director seat on #14486, 2026-09-02): narrow
4470
+ * the list — direction A, over B (reverse-unify: report `crm/account` and
4471
+ * teach `findFile()` path-shaped names), which would have made a slash inside
4472
+ * a metadata name every consumer's permanent obligation with no measured
4473
+ * demand for it. The two-segment layout follows ADR-0008 §10, which
4474
+ * `metadata-fs`'s `parseItemPath()` already enforces for its own store; the
4475
+ * EXTENSION set deliberately does NOT follow §10's `.json`-only rule — see
4476
+ * {@link resolvableExtensions} for why.
4477
+ */
4174
4478
  async list(type) {
4175
4479
  const typeDir = path.join(this.rootDir, type);
4480
+ let files;
4176
4481
  try {
4177
- const files = await glob("**/*", {
4482
+ files = await glob("**/*", {
4178
4483
  cwd: typeDir,
4179
4484
  ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
4180
4485
  nodir: true
4181
4486
  });
4182
- return files.map((file) => {
4183
- const ext = path.extname(file);
4184
- const basename3 = path.basename(file, ext);
4185
- return basename3;
4186
- });
4187
4487
  } catch (error) {
4188
4488
  this.logger?.error("Failed to list", void 0, {
4189
4489
  type,
@@ -4191,6 +4491,7 @@ var FilesystemLoader = class {
4191
4491
  });
4192
4492
  return [];
4193
4493
  }
4494
+ return this.resolvableNames(type, typeDir, files.map((file) => path.join(typeDir, file)));
4194
4495
  }
4195
4496
  async save(type, name, data, options) {
4196
4497
  const startTime = Date.now();
@@ -4261,12 +4562,109 @@ var FilesystemLoader = class {
4261
4562
  throw error;
4262
4563
  }
4263
4564
  }
4565
+ /**
4566
+ * [#14486] The extensions a name can be resolved under, for THIS instance:
4567
+ * the ones belonging to the serializer set it was constructed with. Shared by
4568
+ * {@link findFile}, {@link resolvableNameForPath} and therefore {@link list},
4569
+ * so the set a name can be RESOLVED under cannot drift from the set that is
4570
+ * LISTED or the set {@link loadManyKeyed} is willing to KEY by.
4571
+ *
4572
+ * Registered, not hard-coded, and deliberately not ADR-0008 §10's `.json`
4573
+ * only. §10 governs the `metadata-fs` store; applying it verbatim here would
4574
+ * drop `.yaml` and `.ts` metadata out of `listNames()` — a breakage this card
4575
+ * never asked for. Under the manager's DEFAULT format set
4576
+ * (`typescript` / `json` / `yaml`, `metadata-manager.ts`) that leaves `.js`
4577
+ * out, which is the card's row-4 membership mismatch closing for free: a `.js`
4578
+ * file was listed and resolvable while `loadMany()` could never return it and
4579
+ * `load()` threw `No serializer found for format: javascript`. Register
4580
+ * `javascript` and it is listed, resolvable and loadable together.
4581
+ */
4582
+ resolvableExtensions() {
4583
+ const extensions = [];
4584
+ for (const [format, formatExtensions] of _FilesystemLoader.EXTENSIONS_BY_FORMAT) {
4585
+ if (this.serializers.has(format)) {
4586
+ extensions.push(...formatExtensions);
4587
+ }
4588
+ }
4589
+ return extensions;
4590
+ }
4591
+ /**
4592
+ * The metadata name this loader reports for a file: the basename with its
4593
+ * extension stripped. One derivation, shared by {@link list} and
4594
+ * {@link loadManyKeyed}, so the two cannot drift for the shape where they
4595
+ * agree — `dotted.config.json` is `dotted.config` for both.
4596
+ */
4597
+ static nameFromFilename(file) {
4598
+ return path.basename(file, path.extname(file));
4599
+ }
4600
+ /**
4601
+ * The key for a file IF this loader's name-to-path mapping is a bijection for
4602
+ * it: a file directly under `ROOT/TYPE/` carrying an extension
4603
+ * {@link findFile} tries, so `findFile(type, key)` resolves back to this very
4604
+ * file. `null` for every other shape — a nested path, an extension-less file,
4605
+ * an extension spelled in a case `findFile()` does not compose — which is why
4606
+ * {@link loadManyKeyed} falls back to `body.name` there rather than minting a
4607
+ * key no other door can open.
4608
+ */
4609
+ resolvableNameForPath(typeDir, file) {
4610
+ const rel = path.relative(typeDir, file);
4611
+ if (rel === "" || rel.split(path.sep).length !== 1) {
4612
+ return null;
4613
+ }
4614
+ if (!this.resolvableExtensions().includes(path.extname(rel))) {
4615
+ return null;
4616
+ }
4617
+ return _FilesystemLoader.nameFromFilename(rel);
4618
+ }
4619
+ /**
4620
+ * [#14921] The names this loader reports for `files` — and the ONE place an
4621
+ * ambiguous stem is refused.
4622
+ *
4623
+ * Shared by {@link list} and {@link loadManyEntries} so the two can never
4624
+ * disagree about which trees are admissible: a stem that `list()` refuses
4625
+ * must not still be walked and returned as two bodies by `loadMany()`, which
4626
+ * is exactly the split this card measured.
4627
+ *
4628
+ * Refuses on the FIRST colliding name in sorted order, so a tree holding more
4629
+ * than one collision always names the same one — a refusal that moves
4630
+ * between runs reads as flakiness rather than as the fixed authoring error it
4631
+ * is. Paths are deduplicated because two overlapping `patterns` legitimately
4632
+ * match one file twice, and counting that as a collision would refuse a
4633
+ * perfectly good tree.
4634
+ *
4635
+ * ⛔ Not a precedence resolver. Picking a winner here is what the ruling
4636
+ * declined (option 2, keep the precedence and log): the loser would stay
4637
+ * unreachable and the listed set would stay different from the addressable
4638
+ * one.
4639
+ */
4640
+ resolvableNames(type, typeDir, files) {
4641
+ const byName = /* @__PURE__ */ new Map();
4642
+ for (const file of files) {
4643
+ const name = this.resolvableNameForPath(typeDir, file);
4644
+ if (name === null) {
4645
+ continue;
4646
+ }
4647
+ let paths = byName.get(name);
4648
+ if (!paths) {
4649
+ paths = /* @__PURE__ */ new Set();
4650
+ byName.set(name, paths);
4651
+ }
4652
+ paths.add(file);
4653
+ }
4654
+ for (const name of [...byName.keys()].sort()) {
4655
+ const paths = byName.get(name);
4656
+ if (paths.size > 1) {
4657
+ throw new AmbiguousMetadataStemError(type, name, [...paths]);
4658
+ }
4659
+ }
4660
+ return [...byName.keys()];
4661
+ }
4264
4662
  /**
4265
4663
  * Find file for a given type and name
4266
4664
  */
4267
4665
  async findFile(type, name) {
4268
4666
  const typeDir = path.join(this.rootDir, type);
4269
- const extensions = [".json", ".yaml", ".yml", ".ts", ".js"];
4667
+ const extensions = this.resolvableExtensions();
4270
4668
  for (const ext of extensions) {
4271
4669
  const filePath = path.join(typeDir, `${name}${ext}`);
4272
4670
  try {
@@ -4312,6 +4710,19 @@ var FilesystemLoader = class {
4312
4710
  return `"${hash}"`;
4313
4711
  }
4314
4712
  };
4713
+ /**
4714
+ * The inverse of {@link detectFormat}: which file extensions carry which
4715
+ * format. Fixed ORDER, because it is also {@link findFile}'s precedence when
4716
+ * two files under one type directory share a stem — registration order must
4717
+ * not be able to change which file `ROOT/TYPE/NAME` opens.
4718
+ */
4719
+ _FilesystemLoader.EXTENSIONS_BY_FORMAT = [
4720
+ ["json", [".json"]],
4721
+ ["yaml", [".yaml", ".yml"]],
4722
+ ["typescript", [".ts"]],
4723
+ ["javascript", [".js"]]
4724
+ ];
4725
+ var FilesystemLoader = _FilesystemLoader;
4315
4726
 
4316
4727
  // src/node-metadata-manager.ts
4317
4728
  var NodeMetadataManager = class extends MetadataManager {
@@ -4436,6 +4847,20 @@ var MemoryLoader = class {
4436
4847
  if (!typeStore) return [];
4437
4848
  return Array.from(typeStore.values());
4438
4849
  }
4850
+ /**
4851
+ * [#14205] The keyed half of {@link loadMany}. The storage map is already
4852
+ * `Type -> Name -> Data`, so the key this loader holds an item under is the
4853
+ * map key — `loadMany()` was simply discarding it, which dropped every
4854
+ * nameless body out of `MetadataManager.list()` and out of the endpoint index.
4855
+ *
4856
+ * The body is handed back by reference, unchanged: the key travels beside it,
4857
+ * never folded into it.
4858
+ */
4859
+ async loadManyKeyed(type, _options) {
4860
+ const typeStore = this.storage.get(type);
4861
+ if (!typeStore) return [];
4862
+ return Array.from(typeStore, ([name, data]) => ({ name, data }));
4863
+ }
4439
4864
  async exists(type, name) {
4440
4865
  return this.storage.get(type)?.has(name) ?? false;
4441
4866
  }
@@ -4482,19 +4907,23 @@ var MemoryLoader = class {
4482
4907
 
4483
4908
  // src/plugin.ts
4484
4909
  import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
4485
- import { applyProtection } from "@objectstack/spec/shared";
4910
+ import { applyProtection as applyProtection2 } from "@objectstack/spec/shared";
4486
4911
  import {
4487
- SysMetadataObject as SysMetadataObject2,
4488
- SysMetadataHistoryObject as SysMetadataHistoryObject2,
4912
+ SysMetadataObject as SysMetadataObject3,
4913
+ SysMetadataHistoryObject as SysMetadataHistoryObject3,
4489
4914
  SysMetadataCommitObject,
4490
4915
  SysMetadataAuditObject,
4491
- SysViewDefinitionObject
4916
+ SysViewDefinitionObject,
4917
+ applyArtifactForwardConversions,
4918
+ detectUnboundFormViewPredicateRoots,
4919
+ BOUND_FORM_VIEW_PREDICATE_ROOTS,
4920
+ BOUND_FORM_FIELD_PREDICATE_ROOTS
4492
4921
  } from "@objectstack/metadata-core";
4493
- import { isAggregatedViewContainer, expandViewContainer } from "@objectstack/spec";
4494
4922
  import { isAggregatedViewContainer as isAggregatedViewContainer2, expandViewContainer as expandViewContainer2 } from "@objectstack/spec";
4923
+ import { isAggregatedViewContainer as isAggregatedViewContainer3, expandViewContainer as expandViewContainer3 } from "@objectstack/spec";
4495
4924
  var queryableMetadataObjects = [
4496
- SysMetadataObject2,
4497
- SysMetadataHistoryObject2,
4925
+ SysMetadataObject3,
4926
+ SysMetadataHistoryObject3,
4498
4927
  // ADR-0067 commit log — sibling of sys_metadata_history (see note above).
4499
4928
  SysMetadataCommitObject,
4500
4929
  SysMetadataAuditObject,
@@ -4524,8 +4953,37 @@ var ARTIFACT_FIELD_TO_TYPE = {
4524
4953
  // positions from artifact ingestion.
4525
4954
  positions: "position",
4526
4955
  permissions: "permission",
4956
+ // [ADR-0066 D1] `capabilities` reaches the door at #12892 step 1, the
4957
+ // maintainer's `option 1` ruling ("the door owns the registration
4958
+ // route" for the five artifact security collections). Until #12894
4959
+ // measured it, `AppPlugin`'s `SECURITY_FIELDS` block
4960
+ // (packages/runtime/src/app-plugin.ts) was this collection's SOLE
4961
+ // registrar on an artifact boot — the one security collection the door
4962
+ // could not reach — so a declared capability was registered from bytes
4963
+ // nothing strict-parses, with no schema default and no ADR-0010
4964
+ // provenance. Measured on the two-reader harness, the door's copy adds
4965
+ // exactly four keys the raw copy lacks: `scope` (the schema default)
4966
+ // and `_packageId` / `_packageVersion` / `_provenance`.
4967
+ //
4968
+ // ⚠️ This entry makes the door a SECOND writer, not yet the only one:
4969
+ // `AppPlugin` still registers `capabilities`, and it runs last, so the
4970
+ // raw copy still wins a real artifact boot. Step 2 of the ruling (that
4971
+ // block stops registering these five on the artifact path, after a
4972
+ // census of the non-artifact boot paths) is what makes this the only
4973
+ // copy. Until then the divergence is the interim reality the ruling
4974
+ // explicitly permits, and #12878's pins are what keep it visible.
4975
+ capabilities: "capability",
4527
4976
  sharingRules: "sharing_rule",
4528
- policies: "policy",
4977
+ // `policies: 'policy'` removed at #12894: the stack schema is a
4978
+ // `strictObject` that declares no top-level `policies` key, so a
4979
+ // definition carrying one is refused by the strict parse a few lines
4980
+ // below — the entry could never match, and nothing was ever registered
4981
+ // under `policy` from this map. The word is real, but it lives ONE LEVEL
4982
+ // DOWN: on a permission set it is an alias for `rowLevelSecurity`
4983
+ // (`PERMISSION_SET_KEY_ALIASES`, packages/spec/src/security/permission.zod.ts)
4984
+ // — a key on an ITEM, never a collection. Third retirement of this exact
4985
+ // shape in this map (`themes` and `roles` above); the reasons are kept
4986
+ // in place because the first two are what made this one findable.
4529
4987
  apis: "api",
4530
4988
  webhooks: "webhook",
4531
4989
  agents: "agent",
@@ -4568,6 +5026,21 @@ var MetadataPlugin = class {
4568
5026
  * degrades on purpose (objects are discovered via the legacy fallback).
4569
5027
  */
4570
5028
  this.optionalDependencies = ["com.objectstack.engine.objectql"];
5029
+ /**
5030
+ * Once-per-process dedupe for the summaries the versioned artifact window
5031
+ * emits. The artifact watcher replays `_parseAndRegisterArtifact` on every
5032
+ * file change, so without this a dev loop over a legacy artifact would
5033
+ * re-announce the same finding on every reload — the same shape
5034
+ * `Protocol.storedConversionWarned` guards on the stored-row pass, which
5035
+ * this surfacing is modeled on.
5036
+ *
5037
+ * Two key families share the set, because they share the replay:
5038
+ * `<conversionId>|<label>` for a forward-conversion summary (#12772), and
5039
+ * `unbound-form-predicate-root|<label>` for the unbound-root notice
5040
+ * (#12915) — one line per artifact there, not one per conversion, since
5041
+ * the notice already aggregates every finding it made.
5042
+ */
5043
+ this.artifactConversionWarned = /* @__PURE__ */ new Set();
4571
5044
  this.init = async (ctx) => {
4572
5045
  this.initCtx = ctx;
4573
5046
  ctx.logger.info("Initializing Metadata Manager", {
@@ -4576,7 +5049,6 @@ var MetadataPlugin = class {
4576
5049
  artifactSource: this.options.artifactSource?.mode
4577
5050
  });
4578
5051
  ctx.registerService("metadata", this.manager);
4579
- console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
4580
5052
  const registerSysObjects = this.options.registerSystemObjects !== false;
4581
5053
  if (registerSysObjects) {
4582
5054
  try {
@@ -4598,7 +5070,7 @@ var MetadataPlugin = class {
4598
5070
  }
4599
5071
  ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
4600
5072
  mode: this.options.artifactSource?.mode ?? "file-system",
4601
- features: ["watch", "multi-format", "query", "overlay", "type-registry"]
5073
+ features: ["watch", "multi-format", "query", "type-registry"]
4602
5074
  });
4603
5075
  };
4604
5076
  this.start = async (ctx) => {
@@ -4681,7 +5153,7 @@ var MetadataPlugin = class {
4681
5153
  if (httpServer && typeof httpServer.getRawApp === "function") {
4682
5154
  const { registerMetadataHmrRoutes: registerMetadataHmrRoutes2 } = await Promise.resolve().then(() => (init_hmr_routes(), hmr_routes_exports));
4683
5155
  const hub = registerMetadataHmrRoutes2(httpServer.getRawApp(), this.manager);
4684
- hub.setOnPostReload(async (body = {}) => {
5156
+ hub?.setOnPostReload(async (body = {}) => {
4685
5157
  const src3 = this.options.artifactSource;
4686
5158
  if (src3?.mode === "local-file") {
4687
5159
  try {
@@ -4721,7 +5193,7 @@ var MetadataPlugin = class {
4721
5193
  pending = true;
4722
5194
  try {
4723
5195
  await this._reloadAndAnnounce(ctx, src2, [src2.path]);
4724
- hub.broadcastReload("artifact-file-changed", [src2.path]);
5196
+ hub?.broadcastReload("artifact-file-changed", [src2.path]);
4725
5197
  ctx.logger.info("[MetadataPlugin] artifact auto-reloaded (file watcher)", {
4726
5198
  path: src2.path
4727
5199
  });
@@ -4743,7 +5215,13 @@ var MetadataPlugin = class {
4743
5215
  ctx.logger.warn("[MetadataPlugin] artifact watcher failed to start", { error: e?.message });
4744
5216
  }
4745
5217
  }
4746
- console.log("[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events");
5218
+ if (hub) {
5219
+ console.log("[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events");
5220
+ } else {
5221
+ console.log(
5222
+ `[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"})`
5223
+ );
5224
+ }
4747
5225
  } else {
4748
5226
  console.log("[MetadataPlugin] HTTP server with getRawApp() not available \u2014 skipping HMR endpoint");
4749
5227
  }
@@ -4839,6 +5317,106 @@ var MetadataPlugin = class {
4839
5317
  if (timer) clearTimeout(timer);
4840
5318
  }
4841
5319
  }
5320
+ /**
5321
+ * Versioned ADR-0087 forward conversion at the artifact-ingestion door
5322
+ * (#12772) — runs BEFORE the strict schema parse below, because the parse
5323
+ * is the refusal point.
5324
+ *
5325
+ * A compiled artifact is data at rest with a version stamp: built by
5326
+ * released tooling, then unchanged while the platform moves on. When a
5327
+ * spec release retires an authorable key inside a protocol line (spec
5328
+ * 17.1 → 17.2 retired the `allowRestore`/`allowPurge` permission bits),
5329
+ * every already-built artifact carrying the key becomes unbootable at the
5330
+ * tombstone — with no operator remedy, since `os migrate meta` targets
5331
+ * sources, not built artifacts. The stored-row read path already replays
5332
+ * the conversion chain for exactly this reason
5333
+ * (`applyConversionsToStoredItem`, ADR-0087 addendum); this is the same
5334
+ * policy at the artifact door, **keyed off the artifact's own declared
5335
+ * `engines.protocol` floor**: an artifact authored below the running spec
5336
+ * version converts forward, an artifact authored at the current (or a
5337
+ * newer) surface converts nothing and answers to the strict parse,
5338
+ * tombstones included. The version key is what keeps this a conversion
5339
+ * rather than an amnesty — the retired keys return with the M2 lifecycle
5340
+ * batch (#1883), and artifacts authored against that surface must never
5341
+ * have them stripped by history.
5342
+ *
5343
+ * Notices surface the way the stored-row pass's do — operator-visible and
5344
+ * deduped — as one summary line per conversion per artifact rather than
5345
+ * one per rewritten path (a real 17.1 artifact carried 150 strips of the
5346
+ * same two keys; 150 identical warn lines would bury the boot log).
5347
+ */
5348
+ _convertArtifactForward(ctx, definition, label) {
5349
+ const result = applyArtifactForwardConversions(definition);
5350
+ this._warnUnboundFormPredicateRoots(ctx, result, label);
5351
+ if (result.notices.length === 0) return result.definition;
5352
+ const byConversion = /* @__PURE__ */ new Map();
5353
+ for (const n of result.notices) {
5354
+ const existing = byConversion.get(n.conversionId);
5355
+ if (existing) existing.count += 1;
5356
+ else byConversion.set(n.conversionId, { count: 1, firstPath: n.path, message: n.message });
5357
+ }
5358
+ for (const [conversionId, agg] of byConversion) {
5359
+ const key = `${conversionId}|${label}`;
5360
+ if (this.artifactConversionWarned.has(key)) continue;
5361
+ this.artifactConversionWarned.add(key);
5362
+ ctx.logger.warn(
5363
+ `[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.`
5364
+ );
5365
+ }
5366
+ return result.definition;
5367
+ }
5368
+ /**
5369
+ * Operator-facing boot notice for form-view predicates that fault OPEN on
5370
+ * this runtime (#12915 scope C — maintainer ruling 2026-08-28, 「同意C」).
5371
+ *
5372
+ * A form-view predicate binds `record` / `previous` / `parent` (runtime
5373
+ * record forms) or `data` (metadata-editing forms) — and a FIELD-level one
5374
+ * also binds `current_user` and its ADR-0068 aliases (objectui#6010),
5375
+ * which a SECTION-level one does not. The contract states beside that
5376
+ * vocabulary that a bare identifier is UNBOUND and the predicate faults,
5377
+ * and `visibleWhen`'s fault fallback is `true`. On a real
5378
+ * 17.1-built artifact that combination dead-ends record creation in the
5379
+ * console: the conditionally hidden field renders, and its unconditional
5380
+ * `required: true` — authored to be gated by the visibility that no longer
5381
+ * applies — blocks every submit, while the same payload POSTs 201 through
5382
+ * REST. Nothing refused, nothing logged, and only the operator can fix it
5383
+ * (by rebuilding the artifact), so this is the channel the ruling picked:
5384
+ * service startup, server-side, never a console surface — the person at
5385
+ * the form cannot act on "your artifact is stale".
5386
+ *
5387
+ * **Detection only.** No refusal, no rewrite, no behaviour change: the
5388
+ * predicate keeps faulting open exactly as before. Rewriting a bare root to
5389
+ * `record.` is the ADR-0087 conversion (#12915 scope A), deferred by the
5390
+ * same ruling with an explicit start line.
5391
+ *
5392
+ * **Same versioned window as the conversion replay above** — and read off
5393
+ * that pass's own verdict rather than recomputed, so the two can never
5394
+ * disagree about which artifacts are "old". An artifact declaring the
5395
+ * current (or a newer) floor answers to the strict parse and gets nothing
5396
+ * from here even when it does carry bare roots; that boundary is what keeps
5397
+ * a notice about legacy artifacts out of contract territory. An undeclared
5398
+ * range is treated as old data at rest, matching the grandfathering posture
5399
+ * the window already takes (`converted-undeclared`).
5400
+ */
5401
+ _warnUnboundFormPredicateRoots(ctx, result, label) {
5402
+ if (result.verdict !== "converted-forward" && result.verdict !== "converted-undeclared") return;
5403
+ const findings = detectUnboundFormViewPredicateRoots(result.definition);
5404
+ if (findings.length === 0) return;
5405
+ const key = `unbound-form-predicate-root|${label}`;
5406
+ if (this.artifactConversionWarned.has(key)) return;
5407
+ this.artifactConversionWarned.add(key);
5408
+ const views = [...new Set(findings.map((f) => f.view))];
5409
+ const roots = [...new Set(findings.map((f) => f.root))];
5410
+ const quote = (list) => list.map((v) => `'${v}'`).join(", ");
5411
+ const surfaces = new Set(findings.map((f) => f.surface));
5412
+ const vocabulary = [
5413
+ surfaces.has("field") ? `on a form FIELD: ${quote(BOUND_FORM_FIELD_PREDICATE_ROOTS)}` : null,
5414
+ surfaces.has("section") ? `on a form SECTION: ${quote(BOUND_FORM_VIEW_PREDICATE_ROOTS)}` : null
5415
+ ].filter(Boolean).join("; ");
5416
+ ctx.logger.warn(
5417
+ `[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>').`
5418
+ );
5419
+ }
4842
5420
  /**
4843
5421
  * Parse raw artifact JSON (envelope or bare definition) and register all
4844
5422
  * metadata items into the MetadataManager.
@@ -4857,16 +5435,22 @@ var MetadataPlugin = class {
4857
5435
  let metadata;
4858
5436
  const obj = raw;
4859
5437
  if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== void 0) {
4860
- const artifact = EnvironmentArtifactSchema.parse(obj);
5438
+ const artifact = EnvironmentArtifactSchema.parse({
5439
+ ...obj,
5440
+ metadata: this._convertArtifactForward(ctx, obj.metadata, label)
5441
+ });
4861
5442
  metadata = artifact.metadata;
4862
5443
  } else if (obj?.success && obj?.data?.metadata) {
4863
- const artifact = EnvironmentArtifactSchema.parse(obj.data);
5444
+ const artifact = EnvironmentArtifactSchema.parse({
5445
+ ...obj.data,
5446
+ metadata: this._convertArtifactForward(ctx, obj.data.metadata, label)
5447
+ });
4864
5448
  metadata = artifact.metadata;
4865
5449
  } else {
4866
- const def = ObjectStackDefinitionSchema.parse(obj);
5450
+ const def = ObjectStackDefinitionSchema.parse(this._convertArtifactForward(ctx, obj, label));
4867
5451
  const canonical = JSON.stringify(def, Object.keys(def).sort());
4868
5452
  const checksum = createHash2("sha256").update(canonical).digest("hex");
4869
- const environmentId = this.options.environmentId ?? "proj_local";
5453
+ const environmentId = this.options.environmentId ?? "env_local";
4870
5454
  EnvironmentArtifactSchema.parse({
4871
5455
  schemaVersion: "0.1",
4872
5456
  environmentId,
@@ -4880,53 +5464,127 @@ var MetadataPlugin = class {
4880
5464
  const memLoader = new MemoryLoader();
4881
5465
  const manifestPackageId = metadata?.manifest?.id ?? metadata?.id ?? void 0;
4882
5466
  const manifestVersion = metadata?.manifest?.version ?? metadata?.version ?? void 0;
5467
+ const carriesPackages = Array.isArray(metadata?.packages);
5468
+ const bodies = resolveArtifactPackageOrder(metadata);
5469
+ const ownedByPackage = /* @__PURE__ */ new Map();
5470
+ const claim = (type, name) => {
5471
+ let names = ownedByPackage.get(type);
5472
+ if (!names) ownedByPackage.set(type, names = /* @__PURE__ */ new Set());
5473
+ names.add(name);
5474
+ };
5475
+ const claimed = (type, name) => ownedByPackage.get(type)?.has(name) === true;
5476
+ let totalRegistered = 0;
5477
+ for (const body of bodies) {
5478
+ totalRegistered += await this._registerArtifactBodyCollections(
5479
+ ctx,
5480
+ memLoader,
5481
+ body,
5482
+ carriesPackages ? {
5483
+ packageId: artifactPackageId(body),
5484
+ packageVersion: body?.version ?? void 0
5485
+ } : { packageId: manifestPackageId, packageVersion: manifestVersion },
5486
+ { claim: carriesPackages ? claim : void 0 }
5487
+ );
5488
+ }
5489
+ if (carriesPackages) {
5490
+ const residual = await this._registerArtifactBodyCollections(
5491
+ ctx,
5492
+ memLoader,
5493
+ metadata,
5494
+ { packageId: manifestPackageId, packageVersion: manifestVersion },
5495
+ { skip: claimed }
5496
+ );
5497
+ totalRegistered += residual;
5498
+ if (residual > 0) {
5499
+ ctx.logger.warn(
5500
+ `[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.`
5501
+ );
5502
+ }
5503
+ }
5504
+ this.manager.registerLoader(memLoader);
5505
+ ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
5506
+ return totalRegistered;
5507
+ }
5508
+ /**
5509
+ * Register ONE artifact body's collections into the MetadataManager.
5510
+ *
5511
+ * A "body" is either the whole artifact (the single-package branch, where
5512
+ * the artifact and its one package are the same object) or one entry of
5513
+ * `packages[]` (ADR-0130 D4), which is an assembled
5514
+ * `{ ...manifest, ...collections }` payload carrying the same collection
5515
+ * keys the top level does. The loop is identical for both — that is the
5516
+ * point: there is one ingestion of a collection here, not one per shape.
5517
+ *
5518
+ * @param provenance - The `(packageId, packageVersion)` every item found in
5519
+ * this body is stamped with (ADR-0010 §3.7, via `applyProtection`). It is
5520
+ * the body's OWN identity, never the enclosing artifact's, which is what
5521
+ * makes a multi-package artifact's items agree with the registry and with
5522
+ * `GET /api/v1/packages` about who owns them.
5523
+ * @param slots.claim - Called with every `(type, name)` this pass
5524
+ * registered. Passed when reading package bodies; the residual sweep uses
5525
+ * what it recorded.
5526
+ * @param slots.skip - Consulted before registering each `(type, name)`.
5527
+ * Passed ONLY by the residual sweep, so a package body's copy is never
5528
+ * overwritten by the flattened top-level copy of the same definition —
5529
+ * the overwrite that re-attributed the item to the artifact's manifest.
5530
+ * ⛔ It is never passed while reading the bodies themselves: two items of
5531
+ * one name inside one body still register as they always have (last
5532
+ * wins), because suppressing that would be a behaviour change on the
5533
+ * single-package branch D7 pins.
5534
+ * @returns How many items this body registered.
5535
+ */
5536
+ async _registerArtifactBodyCollections(ctx, memLoader, body, provenance, slots = {}) {
5537
+ const { packageId, packageVersion } = provenance;
4883
5538
  let totalRegistered = 0;
4884
5539
  for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
4885
- const items = metadata[field];
5540
+ const items = body[field];
4886
5541
  if (!Array.isArray(items) || items.length === 0) continue;
4887
5542
  for (const item of items) {
4888
- if (metaType === "view" && isAggregatedViewContainer2(item)) {
4889
- const viewObject = item?.list?.data?.object ?? item?.form?.data?.object;
5543
+ if (metaType === "view" && isAggregatedViewContainer3(item)) {
5544
+ const viewObject = deriveViewContainerObject(item);
4890
5545
  if (!viewObject) continue;
4891
- applyProtection(item, {
4892
- packageId: manifestPackageId,
4893
- packageVersion: manifestVersion
5546
+ if (slots.skip?.("view", viewObject)) continue;
5547
+ applyProtection2(item, {
5548
+ packageId,
5549
+ packageVersion
4894
5550
  });
4895
5551
  await memLoader.save("view", viewObject, item);
4896
5552
  await this.manager.register("view", viewObject, item, { notify: false });
4897
5553
  totalRegistered++;
4898
- for (const vi of expandViewContainer2(viewObject, item)) {
5554
+ slots.claim?.("view", viewObject);
5555
+ for (const vi of expandViewContainer3(viewObject, item)) {
4899
5556
  for (const w of vi._diagnostics?.warnings ?? []) {
4900
5557
  ctx.logger.warn(`[MetadataPlugin] View expansion warning for '${vi.name}': ${w.message}`);
4901
5558
  }
4902
- applyProtection(vi, {
4903
- packageId: manifestPackageId,
4904
- packageVersion: manifestVersion
5559
+ applyProtection2(vi, {
5560
+ packageId,
5561
+ packageVersion
4905
5562
  });
4906
5563
  await memLoader.save("view", vi.name, vi);
4907
5564
  await this.manager.register("view", vi.name, vi, { notify: false });
4908
5565
  totalRegistered++;
5566
+ slots.claim?.("view", vi.name);
4909
5567
  }
4910
5568
  continue;
4911
5569
  }
4912
5570
  let name = item?.name;
4913
5571
  if (!name) {
4914
5572
  if (metaType === "view") {
4915
- name = item?.list?.data?.object ?? item?.form?.data?.object;
5573
+ name = deriveViewContainerObject(item);
4916
5574
  }
4917
5575
  }
4918
5576
  if (!name) continue;
4919
- applyProtection(item, {
4920
- packageId: manifestPackageId,
4921
- packageVersion: manifestVersion
5577
+ if (slots.skip?.(metaType, name)) continue;
5578
+ applyProtection2(item, {
5579
+ packageId,
5580
+ packageVersion
4922
5581
  });
4923
5582
  await memLoader.save(metaType, name, item);
4924
5583
  await this.manager.register(metaType, name, item, { notify: false });
4925
5584
  totalRegistered++;
5585
+ slots.claim?.(metaType, name);
4926
5586
  }
4927
5587
  }
4928
- this.manager.registerLoader(memLoader);
4929
- ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
4930
5588
  return totalRegistered;
4931
5589
  }
4932
5590
  /**
@@ -5002,7 +5660,7 @@ var MetadataPlugin = class {
5002
5660
  for (const item of items) {
5003
5661
  const meta = item;
5004
5662
  if (meta?.name) {
5005
- applyProtection(meta, {
5663
+ applyProtection2(meta, {
5006
5664
  packageId: this.options.packageId
5007
5665
  });
5008
5666
  await this.manager.register(entry.type, meta.name, item, { notify: false });
@@ -5098,9 +5756,43 @@ var RemoteLoader = class {
5098
5756
  format: "json"
5099
5757
  };
5100
5758
  }
5759
+ /**
5760
+ * [#15037] Report only the names that ARE names.
5761
+ *
5762
+ * This read used to be `loadMany<{ name: string }>(type)` mapped straight to
5763
+ * `items.map(i => i.name)`. That type argument is an ASSERTION about bodies
5764
+ * that arrived over HTTP, and nothing checked it: a body with no top-level
5765
+ * `name` yielded `undefined`, which went into an array this signature
5766
+ * declares as `string[]` and reached consumers through
5767
+ * `MetadataManager.listNames()` — a runtime violation of a declared type,
5768
+ * not an untidy entry. A consumer that keys by it, lower-cases it, or feeds
5769
+ * it back to a by-name `load()` gets `undefined` where the type says it
5770
+ * cannot be.
5771
+ *
5772
+ * The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
5773
+ * spelling, one `typeof` filter behind it. Silently dropping is the landed
5774
+ * direction, not a preference — `DatabaseLoader` drops rather than throws,
5775
+ * and `FilesystemLoader`'s narrowing carries a maintainer ruling (via the
5776
+ * director seat on #14486, 2026-09-02) that chose narrowing (A) over
5777
+ * refusing loudly (B), because a name in the list that the door answers
5778
+ * `null` for is the silent failure an author reads as their own typo. An
5779
+ * `undefined` here is the extreme form of that name.
5780
+ *
5781
+ * ⛔ NOT copied from the siblings: `MemoryLoader` answers with its store
5782
+ * keys, and #14205 ruled that identity is the key the store holds an item
5783
+ * under rather than `body.name`. This loader reads over HTTP and holds no
5784
+ * store key, so `body.name` is the only identity it has — the list is
5785
+ * narrowed to agree with the door instead. `loadMany()` is deliberately
5786
+ * untouched: it keys nothing, so a nameless body is still served there.
5787
+ *
5788
+ * The predicate is spelled as a type guard, and the mapped element type left
5789
+ * `unknown`, so `tsc` PROVES the declared `string[]` instead of a cast
5790
+ * asserting it — otherwise the compiler reads the filter as always-true and
5791
+ * a later reader deletes it as dead.
5792
+ */
5101
5793
  async list(type) {
5102
5794
  const items = await this.loadMany(type);
5103
- return items.map((i) => i.name);
5795
+ return items.map((item) => item.name).filter((name) => typeof name === "string");
5104
5796
  }
5105
5797
  async save(type, name, data, _options) {
5106
5798
  const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
@@ -5120,7 +5812,7 @@ var RemoteLoader = class {
5120
5812
  };
5121
5813
 
5122
5814
  // src/index.ts
5123
- import { SysMetadataObject as SysMetadataObject3, SysMetadataHistoryObject as SysMetadataHistoryObject3 } from "@objectstack/metadata-core";
5815
+ import { SysMetadataObject as SysMetadataObject4, SysMetadataHistoryObject as SysMetadataHistoryObject4 } from "@objectstack/metadata-core";
5124
5816
 
5125
5817
  // src/utils/history-cleanup.ts
5126
5818
  import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2 } from "@objectstack/spec/kernel";
@@ -5140,9 +5832,9 @@ var HistoryCleanupManager = class {
5140
5832
  return;
5141
5833
  }
5142
5834
  const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
5143
- void this.runCleanup();
5835
+ void runCleanupAndReport(this);
5144
5836
  this.cleanupTimer = setInterval(() => {
5145
- void this.runCleanup();
5837
+ void runCleanupAndReport(this);
5146
5838
  }, intervalMs);
5147
5839
  }
5148
5840
  /**
@@ -5169,7 +5861,7 @@ var HistoryCleanupManager = class {
5169
5861
  try {
5170
5862
  if (this.policy.maxAgeDays) {
5171
5863
  const cutoffDate = /* @__PURE__ */ new Date();
5172
- cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
5864
+ cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
5173
5865
  const cutoffISO = cutoffDate.toISOString();
5174
5866
  const filter = {
5175
5867
  recorded_at: { $lt: cutoffISO }
@@ -5289,7 +5981,7 @@ var HistoryCleanupManager = class {
5289
5981
  if (organizationId) baseWhere.organization_id = organizationId;
5290
5982
  if (this.policy.maxAgeDays) {
5291
5983
  const cutoffDate = /* @__PURE__ */ new Date();
5292
- cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
5984
+ cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
5293
5985
  const cutoffISO = cutoffDate.toISOString();
5294
5986
  const filter = {
5295
5987
  recorded_at: { $lt: cutoffISO },
@@ -5336,6 +6028,23 @@ var HistoryCleanupManager = class {
5336
6028
  };
5337
6029
  }
5338
6030
  };
6031
+ async function runCleanupAndReport(manager) {
6032
+ let outcome;
6033
+ try {
6034
+ outcome = await manager.runCleanup();
6035
+ } catch (error) {
6036
+ console.error(
6037
+ "History cleanup: the run did not complete, so no history row past the retention policy was deleted and the table keeps growing while the system reports healthy. Fix: the cause below comes from the configured data driver, not from the retention policy; call `runCleanup()` directly to reproduce it. Cause:",
6038
+ error
6039
+ );
6040
+ return;
6041
+ }
6042
+ if (outcome.errors > 0) {
6043
+ console.error(
6044
+ `History cleanup: ${outcome.errors} delete operation(s) failed and ${outcome.deleted} row(s) were deleted. The history rows those deletes were meant to remove are still in the table, nothing retries them, and the table grows past the retention policy while the system keeps reporting healthy. Fix: check the data driver delete path for the metadata history table. The per-failure causes are not carried out of \`runCleanup()\`, so reproduce them against the driver directly.`
6045
+ );
6046
+ }
6047
+ }
5339
6048
 
5340
6049
  // src/migration/index.ts
5341
6050
  var migration_exports = {};
@@ -5393,6 +6102,9 @@ var MigrationExecutor = class {
5393
6102
  }
5394
6103
  };
5395
6104
  export {
6105
+ AMBIGUOUS_METADATA_STEM_CODE,
6106
+ AMBIGUOUS_METADATA_STEM_STATUS,
6107
+ AmbiguousMetadataStemError,
5396
6108
  DatabaseLoader,
5397
6109
  HistoryCleanupManager,
5398
6110
  JSONSerializer,
@@ -5401,12 +6113,14 @@ export {
5401
6113
  MetadataPlugin,
5402
6114
  migration_exports as Migration,
5403
6115
  RemoteLoader,
5404
- SysMetadataHistoryObject3 as SysMetadataHistoryObject,
5405
- SysMetadataObject3 as SysMetadataObject,
6116
+ SysMetadataHistoryObject4 as SysMetadataHistoryObject,
6117
+ SysMetadataObject4 as SysMetadataObject,
5406
6118
  TypeScriptSerializer,
5407
6119
  YAMLSerializer,
5408
6120
  calculateChecksum,
6121
+ deriveViewContainerObject,
5409
6122
  generateDiffSummary,
5410
- generateSimpleDiff
6123
+ generateSimpleDiff,
6124
+ isAmbiguousMetadataStemError
5411
6125
  };
5412
6126
  //# sourceMappingURL=index.js.map