@objectstack/metadata 17.0.0-rc.0 → 17.0.0-rc.2

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
@@ -214,6 +214,7 @@ module.exports = __toCommonJS(node_exports);
214
214
 
215
215
  // src/metadata-manager.ts
216
216
  var import_kernel = require("@objectstack/spec/kernel");
217
+ var import_api = require("@objectstack/spec/api");
217
218
  var import_core = require("@objectstack/core");
218
219
 
219
220
  // src/serializers/json-serializer.ts
@@ -385,6 +386,8 @@ export default metadata;
385
386
 
386
387
  // src/loaders/database-loader.ts
387
388
  var import_metadata_core = require("@objectstack/metadata-core");
389
+ var import_spec = require("@objectstack/spec");
390
+ var import_shared = require("@objectstack/spec/shared");
388
391
 
389
392
  // src/utils/metadata-history-utils.ts
390
393
  async function calculateChecksum(metadata) {
@@ -550,6 +553,70 @@ var LRUCache = class {
550
553
  }
551
554
  };
552
555
 
556
+ // src/utils/schema-sync-errors.ts
557
+ var ALREADY_EXISTS = {
558
+ codes: /* @__PURE__ */ new Set([
559
+ // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
560
+ "42P07",
561
+ // duplicate_table
562
+ "42701",
563
+ // duplicate_column
564
+ "42710",
565
+ // duplicate_object — index / constraint already exists
566
+ // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)
567
+ "ER_TABLE_EXISTS_ERROR",
568
+ // 1050
569
+ "ER_DUP_FIELDNAME",
570
+ // 1060
571
+ "ER_DUP_KEYNAME"
572
+ // 1061
573
+ ]),
574
+ errnos: /* @__PURE__ */ new Set([1050, 1060, 1061]),
575
+ /**
576
+ * Message fallback for drivers that carry no machine-readable code —
577
+ * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for
578
+ * every DDL failure, so the message is the only signal available:
579
+ * - `table sys_metadata already exists`
580
+ * - `duplicate column name: environment_id`
581
+ * - `index idx_x already exists`
582
+ * Postgres phrases its own as `relation "x" already exists` /
583
+ * `column "x" of relation "y" already exists`, which matches the same test.
584
+ */
585
+ message: /already exists|duplicate column name|duplicate key name/i
586
+ };
587
+ var MISSING_TABLE = {
588
+ codes: /* @__PURE__ */ new Set([
589
+ "42P01",
590
+ // PostgreSQL undefined_table
591
+ "ER_NO_SUCH_TABLE"
592
+ // MySQL / MariaDB 1146
593
+ ]),
594
+ errnos: /* @__PURE__ */ new Set([1146]),
595
+ /**
596
+ * - SQLite / libsql: `no such table: sys_metadata_history`
597
+ * - PostgreSQL: `relation "sys_metadata_history" does not exist`
598
+ * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
599
+ */
600
+ message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
601
+ };
602
+ var MAX_CAUSE_DEPTH = 4;
603
+ function matchesDriverError(error, signature, depth) {
604
+ if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
605
+ if (typeof error === "string") return signature.message.test(error);
606
+ if (typeof error !== "object") return false;
607
+ const err = error;
608
+ if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
609
+ if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
610
+ if (typeof err.message === "string" && signature.message.test(err.message)) return true;
611
+ return matchesDriverError(err.cause, signature, depth + 1);
612
+ }
613
+ function isSchemaAlreadyExistsError(error, depth = 0) {
614
+ return matchesDriverError(error, ALREADY_EXISTS, depth);
615
+ }
616
+ function isMissingTableError(error, depth = 0) {
617
+ return matchesDriverError(error, MISSING_TABLE, depth);
618
+ }
619
+
553
620
  // src/migrations/add-sys-metadata-overlay-index.ts
554
621
  var INDEX_NAME = "idx_sys_metadata_overlay_active";
555
622
  var TABLE = "sys_metadata";
@@ -660,6 +727,25 @@ var DatabaseLoader = class {
660
727
  };
661
728
  this.schemaReady = false;
662
729
  this.historySchemaReady = false;
730
+ /**
731
+ * Whether the loud "DDL failed" report has already been printed for the
732
+ * metadata table / history table respectively. AGENTS.md → "Degradation log
733
+ * levels": say it **once**, at the first degradation, not once per retry.
734
+ */
735
+ this.schemaFailureReported = false;
736
+ this.historySchemaFailureReported = false;
737
+ /**
738
+ * Same once-only discipline for the #4825 seam: the history table is readable
739
+ * or it is not, and repeating the report per skipped write turns a real
740
+ * degradation into noise people learn to skim.
741
+ */
742
+ this.historySeqFailureReported = false;
743
+ /**
744
+ * Once-per-process dedupe for stored-row conversion notices — `load` /
745
+ * `loadMany` are hot read paths (cached, but re-hit on every TTL expiry),
746
+ * so a legacy row must warn once, not once per cache miss.
747
+ */
748
+ this.storedConversionWarned = /* @__PURE__ */ new Set();
663
749
  if (!options.driver && !options.engine) {
664
750
  throw new Error("DatabaseLoader requires either a driver or engine");
665
751
  }
@@ -763,6 +849,24 @@ var DatabaseLoader = class {
763
849
  * Reads `MAX(event_seq) + 1` for the configured `organization_id`.
764
850
  * Legacy path — not transactional, so concurrent writes can collide.
765
851
  * The canonical (transactional) producer is `SysMetadataRepository`.
852
+ *
853
+ * #4825 (same shape as #4728, rule from #4632) — discriminate by error TYPE.
854
+ * This used to `catch { return 1 }`, with a comment that named BOTH reasons a
855
+ * read can fail and then answered both the same way. Exactly one of them is
856
+ * benign: the history table has not been provisioned, so there is no row to
857
+ * be inconsistent with and 1 genuinely IS the next number. Every other reason
858
+ * — connection drop, timeout, insufficient privileges — means the rows are
859
+ * still there and simply were not seen, and answering 1 against a table with
860
+ * N rows **collides with existing rows**: the insert succeeds, the log stays
861
+ * empty, and `event_seq` (the ordering key that history listing and rollback
862
+ * targeting both stand on) is silently wrong from then on. Note this is the
863
+ * costlier half of the #4728 family — not bytes that never landed, but bytes
864
+ * that landed *wrong*, which no retry and no restart repairs.
865
+ *
866
+ * @throws The underlying driver error, unchanged, for every non-benign read
867
+ * failure. Deliberate: a sequence number this method cannot derive
868
+ * from data it actually read is not a number it may invent. The
869
+ * caller ({@link createHistoryRecord}) owns the consequence.
766
870
  */
767
871
  async nextEventSeq() {
768
872
  const where = this.organizationId ? { organization_id: this.organizationId } : {};
@@ -774,8 +878,9 @@ var DatabaseLoader = class {
774
878
  if (v > max) max = v;
775
879
  }
776
880
  return max + 1;
777
- } catch {
778
- return 1;
881
+ } catch (error) {
882
+ if (isMissingTableError(error)) return 1;
883
+ throw error;
779
884
  }
780
885
  }
781
886
  /**
@@ -812,17 +917,32 @@ var DatabaseLoader = class {
812
917
  ...import_metadata_core.SysMetadataObject,
813
918
  name: this.tableName
814
919
  });
815
- this.schemaReady = true;
816
- try {
817
- await migrateProjectIdToEnvironmentId(this.driver);
818
- } catch {
819
- }
820
- try {
821
- await addSysMetadataOverlayIndex(this.driver);
822
- } catch {
920
+ } catch (error) {
921
+ if (!isSchemaAlreadyExistsError(error)) {
922
+ if (!this.schemaFailureReported) {
923
+ this.schemaFailureReported = true;
924
+ console.error(
925
+ `[Metadata] DDL for the metadata table \`${this.tableName}\` FAILED \u2014 its table/columns were NOT created or altered. Every metadata write from here on (Studio saves, app installs, org overlays) targets storage that may not exist: writes will error out, or silently drop columns on a lenient driver, while the server keeps reporting healthy. This is NOT the benign "already exists" case \u2014 check the datasource/driver error below (insufficient privileges, datasource not connected, incompatible column type), fix it and restart. Schema sync is retried on the next metadata operation, so a transient cause recovers on its own.`,
926
+ error
927
+ );
928
+ }
929
+ return;
823
930
  }
931
+ }
932
+ if (this.schemaFailureReported) {
933
+ this.schemaFailureReported = false;
934
+ console.info(
935
+ `[Metadata] DDL for the metadata table \`${this.tableName}\` succeeded on retry \u2014 metadata writes are durable again.`
936
+ );
937
+ }
938
+ this.schemaReady = true;
939
+ try {
940
+ await migrateProjectIdToEnvironmentId(this.driver);
941
+ } catch {
942
+ }
943
+ try {
944
+ await addSysMetadataOverlayIndex(this.driver);
824
945
  } catch {
825
- this.schemaReady = true;
826
946
  }
827
947
  }
828
948
  /**
@@ -840,9 +960,25 @@ var DatabaseLoader = class {
840
960
  ...import_metadata_core.SysMetadataHistoryObject,
841
961
  name: this.historyTableName
842
962
  });
963
+ if (this.historySchemaFailureReported) {
964
+ this.historySchemaFailureReported = false;
965
+ console.info(
966
+ `[Metadata] DDL for the metadata history table \`${this.historyTableName}\` succeeded on retry \u2014 change history is being recorded again.`
967
+ );
968
+ }
843
969
  this.historySchemaReady = true;
844
970
  } catch (error) {
845
- console.error("Failed to ensure history schema, will retry on next operation:", error);
971
+ if (isSchemaAlreadyExistsError(error)) {
972
+ this.historySchemaReady = true;
973
+ return;
974
+ }
975
+ if (!this.historySchemaFailureReported) {
976
+ this.historySchemaFailureReported = true;
977
+ console.error(
978
+ `[Metadata] DDL for the metadata history table \`${this.historyTableName}\` FAILED \u2014 its table/columns were NOT created. Metadata change history (versions, diffs, rollback) will NOT be persisted while every metadata write keeps succeeding, so the audit trail silently ends here. Fix the datasource/driver error below and restart; the sync is retried on the next metadata operation.`,
979
+ error
980
+ );
981
+ }
846
982
  }
847
983
  }
848
984
  /**
@@ -883,7 +1019,25 @@ var DatabaseLoader = class {
883
1019
  }
884
1020
  const historyId = generateId();
885
1021
  const metadataJson = JSON.stringify(metadata);
886
- const eventSeq = await this.nextEventSeq();
1022
+ let eventSeq;
1023
+ try {
1024
+ eventSeq = await this.nextEventSeq();
1025
+ } catch (error) {
1026
+ if (!this.historySeqFailureReported) {
1027
+ this.historySeqFailureReported = true;
1028
+ console.error(
1029
+ `[Metadata] Could not read \`${this.historyTableName}\` to determine the next \`event_seq\` \u2014 the history entry for ${type}/${name} was NOT written, and further entries are being skipped while this persists. The metadata write itself SUCCEEDED, so the server keeps looking healthy while its change history silently develops holes: version timelines and rollback targets will be incomplete. The entry is skipped deliberately \u2014 numbering it from 1 (what this code did before #4825) would collide with existing rows and make \`event_seq\` ordering wrong rather than merely incomplete, which nothing detects and no restart repairs. Fix the datasource/driver error below (connection, timeout, privileges); the next metadata write retries and reports recovery.`,
1030
+ error
1031
+ );
1032
+ }
1033
+ return;
1034
+ }
1035
+ if (this.historySeqFailureReported) {
1036
+ this.historySeqFailureReported = false;
1037
+ console.info(
1038
+ `[Metadata] \`${this.historyTableName}\` is readable again \u2014 \`event_seq\` numbering recovered and change history is being recorded again. Entries skipped during the outage are not backfilled.`
1039
+ );
1040
+ }
887
1041
  const historyRecord = {
888
1042
  id: historyId,
889
1043
  name,
@@ -920,13 +1074,33 @@ var DatabaseLoader = class {
920
1074
  }
921
1075
  }
922
1076
  /**
923
- * Convert a database row to a metadata payload.
924
- * Parses the JSON `metadata` column back into an object.
1077
+ * Convert a LIVE database row to a metadata payload.
1078
+ *
1079
+ * Parses the JSON `metadata` column back into an object, then replays the
1080
+ * full ADR-0087 conversion chain over it (#3903): rows written under a past
1081
+ * protocol are served canonical, exactly like the metadata-protocol's
1082
+ * `sys_metadata` seams. History rows do NOT pass through here — history
1083
+ * readers parse inline and stay verbatim, as a record of what was written.
1084
+ *
1085
+ * `flow` is skipped for the same reason the protocol skips it: flow-node
1086
+ * conversions need the automation engine's live executor registry for their
1087
+ * open-namespace conflict guard; flows canonicalize at `registerFlow`.
925
1088
  */
926
1089
  rowToData(row) {
927
1090
  if (!row || !row.metadata) return null;
928
1091
  const payload = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
929
- return payload;
1092
+ const singular = import_shared.PLURAL_TO_SINGULAR[row.type] ?? row.type;
1093
+ if (singular === "flow") return payload;
1094
+ return (0, import_spec.applyConversionsToStoredItem)(singular, payload, {
1095
+ onNotice: (n) => {
1096
+ const key = `${n.conversionId}|${singular}|${String(row.name ?? "")}`;
1097
+ if (this.storedConversionWarned.has(key)) return;
1098
+ this.storedConversionWarned.add(key);
1099
+ console.warn(
1100
+ `[DatabaseLoader] stored ${singular}/${String(row.name ?? "<unnamed>")} carries a pre-protocol shape; ${n.message}`
1101
+ );
1102
+ }
1103
+ });
930
1104
  }
931
1105
  /**
932
1106
  * Convert a database row to a MetadataRecord-like object.
@@ -1320,6 +1494,17 @@ function generateId() {
1320
1494
  }
1321
1495
 
1322
1496
  // src/metadata-manager.ts
1497
+ function generateEventUuid() {
1498
+ const c = globalThis.crypto;
1499
+ if (c && typeof c.randomUUID === "function") {
1500
+ return c.randomUUID();
1501
+ }
1502
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
1503
+ const r = Math.random() * 16 | 0;
1504
+ const v = ch === "x" ? r : r & 3 | 8;
1505
+ return v.toString(16);
1506
+ });
1507
+ }
1323
1508
  var _MetadataManager = class _MetadataManager {
1324
1509
  constructor(config) {
1325
1510
  this.loaders = /* @__PURE__ */ new Map();
@@ -1440,6 +1625,60 @@ var _MetadataManager = class _MetadataManager {
1440
1625
  this.realtimeService = service;
1441
1626
  this.logger.info("RealtimeService configured for metadata events");
1442
1627
  }
1628
+ /**
1629
+ * Publish a realtime {@link RealtimeMetadataEvent} for a metadata write
1630
+ * (#4602 — contract-first).
1631
+ *
1632
+ * What reaches a `subscribeMetadata` callback must BE the spec's
1633
+ * `MetadataEvent` (`@objectstack/spec/api`): `id` (uuid) at the top level,
1634
+ * flattened `metadataType`/`name`/`definition`, `userId` when the write
1635
+ * carried an actor. The transport keeps its `RealtimeEventPayload`
1636
+ * envelope — `payload` carries the complete `MetadataEvent`, and the client
1637
+ * SDK unwraps + validates it at the boundary.
1638
+ *
1639
+ * Two loud-by-design gates:
1640
+ * - `MetadataEventType` is a CLOSED enum. A metadata type outside it has
1641
+ * no declared realtime event contract, so we skip publishing (debug log)
1642
+ * instead of emitting an event every compliant consumer must reject.
1643
+ * Declared = enforced; widening coverage means widening the spec enum,
1644
+ * not producing off-contract events.
1645
+ * - The event body is `MetadataEventSchema.parse`d before publish, so a
1646
+ * malformed producer fails here (warn log, event not published) rather
1647
+ * than delivering a lie downstream.
1648
+ */
1649
+ async publishRealtimeMetadataEvent(action, type, name, opts = {}) {
1650
+ if (!this.realtimeService) return;
1651
+ const eventType = `metadata.${type}.${action}`;
1652
+ if (!import_api.MetadataEventType.options.includes(eventType)) {
1653
+ this.logger.debug(
1654
+ `Metadata type '${type}' has no declared realtime event type (MetadataEventType) \u2014 skipping publish`,
1655
+ { eventType, name }
1656
+ );
1657
+ return;
1658
+ }
1659
+ try {
1660
+ const event = import_api.MetadataEventSchema.parse({
1661
+ id: generateEventUuid(),
1662
+ type: eventType,
1663
+ metadataType: type,
1664
+ name,
1665
+ ...typeof opts.packageId === "string" ? { packageId: opts.packageId } : {},
1666
+ ...opts.definition !== void 0 ? { definition: opts.definition } : {},
1667
+ ...opts.userId ? { userId: opts.userId } : {},
1668
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1669
+ });
1670
+ const envelope = {
1671
+ type: event.type,
1672
+ object: type,
1673
+ payload: { ...event },
1674
+ timestamp: event.timestamp
1675
+ };
1676
+ await this.realtimeService.publish(envelope);
1677
+ this.logger.debug(`Published ${eventType} event`, { name });
1678
+ } catch (error) {
1679
+ this.logger.warn(`Failed to publish metadata event`, { type, name, error });
1680
+ }
1681
+ }
1443
1682
  /**
1444
1683
  * Register a new metadata loader (data source)
1445
1684
  */
@@ -1483,25 +1722,11 @@ var _MetadataManager = class _MetadataManager {
1483
1722
  await loader.save(type, name, data);
1484
1723
  }
1485
1724
  }
1486
- if (this.realtimeService) {
1487
- const event = {
1488
- type: `metadata.${type}.created`,
1489
- object: type,
1490
- payload: {
1491
- metadataType: type,
1492
- name,
1493
- definition: data,
1494
- packageId: data?.packageId
1495
- },
1496
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1497
- };
1498
- try {
1499
- await this.realtimeService.publish(event);
1500
- this.logger.debug(`Published metadata.${type}.created event`, { name });
1501
- } catch (error) {
1502
- this.logger.warn(`Failed to publish metadata event`, { type, name, error });
1503
- }
1504
- }
1725
+ await this.publishRealtimeMetadataEvent(existed ? "updated" : "created", type, name, {
1726
+ definition: data,
1727
+ packageId: data?.packageId,
1728
+ userId: options?.userId
1729
+ });
1505
1730
  if (options?.notify !== false) {
1506
1731
  this.notifyWatchers(type, {
1507
1732
  type: existed ? "changed" : "added",
@@ -1615,23 +1840,9 @@ var _MetadataManager = class _MetadataManager {
1615
1840
  }
1616
1841
  }
1617
1842
  }
1618
- if (this.realtimeService) {
1619
- const event = {
1620
- type: `metadata.${type}.deleted`,
1621
- object: type,
1622
- payload: {
1623
- metadataType: type,
1624
- name
1625
- },
1626
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1627
- };
1628
- try {
1629
- await this.realtimeService.publish(event);
1630
- this.logger.debug(`Published metadata.${type}.deleted event`, { name });
1631
- } catch (error) {
1632
- this.logger.warn(`Failed to publish metadata event`, { type, name, error });
1633
- }
1634
- }
1843
+ await this.publishRealtimeMetadataEvent("deleted", type, name, {
1844
+ userId: options?.userId
1845
+ });
1635
1846
  if (options?.notify !== false) {
1636
1847
  this.notifyWatchers(type, {
1637
1848
  type: "deleted",
@@ -2341,19 +2552,44 @@ var _MetadataManager = class _MetadataManager {
2341
2552
  /**
2342
2553
  * Load a single metadata item from loaders.
2343
2554
  * Iterates through registered loaders until found.
2555
+ *
2556
+ * Returns `null` both when no loader HAS the item and when every loader
2557
+ * FAILED — see {@link loadDiagnosed} when the caller must tell those apart.
2344
2558
  */
2345
2559
  async load(type, name, options) {
2560
+ return (await this.loadDiagnosed(type, name, options)).data;
2561
+ }
2562
+ /**
2563
+ * `load`, plus whether the answer can be trusted as complete.
2564
+ *
2565
+ * [ADR-0110 D3] A miss and an outage are different facts with opposite
2566
+ * security meanings, and plain `load` cannot express the difference: a
2567
+ * loader that throws is warn-logged and skipped, so a database the metadata
2568
+ * plane cannot reach returns the same `null` as a name that was never
2569
+ * declared. Callers that gate on a declaration MUST NOT read that `null` as
2570
+ * "the author declared no gate" — an availability failure would silently
2571
+ * widen access (the REST `/actions` route's fail-open branch, #3935).
2572
+ *
2573
+ * `degraded` is true when at least one loader threw AND no loader answered
2574
+ * with the item. The posture is deliberately conservative: with a loader
2575
+ * down we cannot prove the item is absent, so we decline to claim it is.
2576
+ * A clean miss (every loader answered, none had it) is NOT degraded.
2577
+ */
2578
+ async loadDiagnosed(type, name, options) {
2579
+ const errors = [];
2346
2580
  for (const loader of this.loaders.values()) {
2347
2581
  try {
2348
2582
  const result = await loader.load(type, name, options);
2349
2583
  if (result.data) {
2350
- return result.data;
2584
+ return { data: result.data, degraded: false, errors };
2351
2585
  }
2352
2586
  } catch (e) {
2587
+ const message = e instanceof Error ? e.message : String(e);
2588
+ errors.push(`${loader.contract.name}: ${message}`);
2353
2589
  this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
2354
2590
  }
2355
2591
  }
2356
- return null;
2592
+ return { data: null, degraded: errors.length > 0, errors };
2357
2593
  }
2358
2594
  /**
2359
2595
  * Load multiple metadata items from loaders.
@@ -3240,10 +3476,10 @@ var MemoryLoader = class {
3240
3476
 
3241
3477
  // src/plugin.ts
3242
3478
  var import_kernel2 = require("@objectstack/spec/kernel");
3243
- var import_shared = require("@objectstack/spec/shared");
3479
+ var import_shared2 = require("@objectstack/spec/shared");
3244
3480
  var import_metadata_core2 = require("@objectstack/metadata-core");
3245
- var import_spec = require("@objectstack/spec");
3246
3481
  var import_spec2 = require("@objectstack/spec");
3482
+ var import_spec3 = require("@objectstack/spec");
3247
3483
  var queryableMetadataObjects = [
3248
3484
  import_metadata_core2.SysMetadataObject,
3249
3485
  import_metadata_core2.SysMetadataHistoryObject,
@@ -3295,6 +3531,20 @@ var MetadataPlugin = class {
3295
3531
  this.name = "com.objectstack.metadata";
3296
3532
  this.type = "standard";
3297
3533
  this.version = "1.0.0";
3534
+ /**
3535
+ * Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the
3536
+ * kernel name this plugin when a consumer requires `metadata` before it
3537
+ * initializes.
3538
+ */
3539
+ this.providesServices = ["metadata"];
3540
+ /**
3541
+ * init() registers the metadata system objects through the `manifest`
3542
+ * service ObjectQLPlugin provides — order-if-present so that
3543
+ * registration is deterministic instead of "whichever init ran first"
3544
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
3545
+ * degrades on purpose (objects are discovered via the legacy fallback).
3546
+ */
3547
+ this.optionalDependencies = ["com.objectstack.engine.objectql"];
3298
3548
  this.init = async (ctx) => {
3299
3549
  ctx.logger.info("Initializing Metadata Manager", {
3300
3550
  root: this.options.rootDir || process.cwd(),
@@ -3334,27 +3584,27 @@ var MetadataPlugin = class {
3334
3584
  bootstrap: mode,
3335
3585
  artifactSource: src?.mode ?? "none"
3336
3586
  });
3587
+ if (src && src.mode !== "local-file") {
3588
+ const bad = src.mode;
3589
+ throw new Error(
3590
+ `[MetadataPlugin] artifactSource.mode '${bad}' is not supported` + (bad === "artifact-api" ? " \u2014 the 'artifact-api' source was removed (#4246). Load the same artifact with { mode: 'local-file', path: '<http(s) URL>' } (e.g. the control plane's /pub/v1/environments/:id/artifact route), or install packages into a running runtime via @objectstack/cloud-connection." : ". The only artifact source is { mode: 'local-file', path }.")
3591
+ );
3592
+ }
3337
3593
  if (mode === "artifact-only") {
3338
- if (src?.mode === "local-file") {
3594
+ if (src) {
3339
3595
  await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
3340
- } else if (src?.mode === "artifact-api") {
3341
- await this._loadFromArtifactApi(ctx, src);
3342
3596
  } else {
3343
3597
  throw new Error("[MetadataPlugin] bootstrap=artifact-only requires options.artifactSource to be set");
3344
3598
  }
3345
3599
  } else if (mode === "lazy") {
3346
- if (src?.mode === "local-file") {
3347
- await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
3348
- } else if (src?.mode === "artifact-api") {
3349
- await this._loadFromArtifactApi(ctx, src);
3600
+ if (src) {
3601
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
3350
3602
  } else {
3351
3603
  ctx.logger.info("[MetadataPlugin] lazy bootstrap \u2014 skipping filesystem priming; metadata loads on demand");
3352
3604
  }
3353
3605
  } else {
3354
- if (src?.mode === "local-file") {
3355
- await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
3356
- } else if (src?.mode === "artifact-api") {
3357
- await this._loadFromArtifactApi(ctx, src);
3606
+ if (src) {
3607
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
3358
3608
  } else {
3359
3609
  await this._loadFromFileSystem(ctx);
3360
3610
  }
@@ -3396,7 +3646,14 @@ var MetadataPlugin = class {
3396
3646
  });
3397
3647
  }
3398
3648
  try {
3399
- const httpServer = ctx.getService("http-server") ?? ctx.getService("http.server");
3649
+ const readServer = (name) => {
3650
+ try {
3651
+ return ctx.getService(name);
3652
+ } catch {
3653
+ return void 0;
3654
+ }
3655
+ };
3656
+ const httpServer = readServer("http.server") ?? readServer("http-server");
3400
3657
  if (httpServer && typeof httpServer.getRawApp === "function") {
3401
3658
  const { registerMetadataHmrRoutes: registerMetadataHmrRoutes2 } = await Promise.resolve().then(() => (init_hmr_routes(), hmr_routes_exports));
3402
3659
  const hub = registerMetadataHmrRoutes2(httpServer.getRawApp(), this.manager);
@@ -3509,14 +3766,13 @@ var MetadataPlugin = class {
3509
3766
  /**
3510
3767
  * Fetch JSON content from a URL with configurable timeout.
3511
3768
  */
3512
- async _fetchJson(url, fetchTimeoutMs, token) {
3769
+ async _fetchJson(url, fetchTimeoutMs) {
3513
3770
  const envTimeout = Number(process.env.OS_ARTIFACT_FETCH_TIMEOUT_MS);
3514
3771
  const timeoutMs = fetchTimeoutMs ?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : void 0) ?? 6e4;
3515
3772
  const controller = new AbortController();
3516
3773
  const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
3517
3774
  try {
3518
3775
  const headers = { Accept: "application/json, */*;q=0.5" };
3519
- if (token) headers.Authorization = `Bearer ${token}`;
3520
3776
  const res = await fetch(url, { redirect: "follow", signal: controller.signal, headers });
3521
3777
  if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
3522
3778
  const content = await res.text();
@@ -3578,21 +3834,21 @@ var MetadataPlugin = class {
3578
3834
  const items = metadata[field];
3579
3835
  if (!Array.isArray(items) || items.length === 0) continue;
3580
3836
  for (const item of items) {
3581
- if (metaType === "view" && (0, import_spec2.isAggregatedViewContainer)(item)) {
3837
+ if (metaType === "view" && (0, import_spec3.isAggregatedViewContainer)(item)) {
3582
3838
  const viewObject = item?.list?.data?.object ?? item?.form?.data?.object;
3583
3839
  if (!viewObject) continue;
3584
- (0, import_shared.applyProtection)(item, {
3840
+ (0, import_shared2.applyProtection)(item, {
3585
3841
  packageId: manifestPackageId,
3586
3842
  packageVersion: manifestVersion
3587
3843
  });
3588
3844
  await memLoader.save("view", viewObject, item);
3589
3845
  await this.manager.register("view", viewObject, item, { notify: false });
3590
3846
  totalRegistered++;
3591
- for (const vi of (0, import_spec2.expandViewContainer)(viewObject, item)) {
3847
+ for (const vi of (0, import_spec3.expandViewContainer)(viewObject, item)) {
3592
3848
  for (const w of vi._diagnostics?.warnings ?? []) {
3593
3849
  ctx.logger.warn(`[MetadataPlugin] View expansion warning for '${vi.name}': ${w.message}`);
3594
3850
  }
3595
- (0, import_shared.applyProtection)(vi, {
3851
+ (0, import_shared2.applyProtection)(vi, {
3596
3852
  packageId: manifestPackageId,
3597
3853
  packageVersion: manifestVersion
3598
3854
  });
@@ -3609,7 +3865,7 @@ var MetadataPlugin = class {
3609
3865
  }
3610
3866
  }
3611
3867
  if (!name) continue;
3612
- (0, import_shared.applyProtection)(item, {
3868
+ (0, import_shared2.applyProtection)(item, {
3613
3869
  packageId: manifestPackageId,
3614
3870
  packageVersion: manifestVersion
3615
3871
  });
@@ -3636,14 +3892,26 @@ var MetadataPlugin = class {
3636
3892
  * logged but never blocks the reload.
3637
3893
  */
3638
3894
  async _reloadAndAnnounce(ctx, src, changed) {
3639
- await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
3895
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
3640
3896
  try {
3641
3897
  await ctx.trigger("metadata:reloaded", { changed, metadata: this.lastParsedMetadata });
3642
3898
  } catch (e) {
3643
3899
  ctx.logger.warn("[MetadataPlugin] metadata:reloaded subscriber failed", { error: e?.message });
3644
3900
  }
3645
3901
  }
3646
- async _loadFromLocalFile(ctx, filePath, fetchTimeoutMs) {
3902
+ /**
3903
+ * @param opts.optional When true, a LOCAL artifact file that does not exist
3904
+ * is "nothing compiled yet" rather than a fault: log and return, leaving
3905
+ * the manager empty and the artifact watcher armed so the first
3906
+ * `os compile` hydrates the running server (#4085). Callers pass it for
3907
+ * the `eager` / `lazy` bootstrap modes — the development-platform paths,
3908
+ * where an app is optional. `artifact-only` (sealed runtime) does NOT:
3909
+ * there the artifact IS the deployment, so its absence must fail loudly
3910
+ * instead of silently serving an empty runtime. Only ENOENT is tolerated;
3911
+ * a present-but-unreadable artifact (malformed JSON, bad permissions) and
3912
+ * every remote-URL failure stay fatal.
3913
+ */
3914
+ async _loadFromLocalFile(ctx, filePath, fetchTimeoutMs, opts = {}) {
3647
3915
  const isUrl = /^https?:\/\//i.test(filePath);
3648
3916
  ctx.logger.info(
3649
3917
  `[MetadataPlugin] Loading metadata from ${isUrl ? "remote URL" : "local artifact file"}`,
@@ -3658,34 +3926,17 @@ var MetadataPlugin = class {
3658
3926
  raw = JSON.parse(content);
3659
3927
  }
3660
3928
  } catch (e) {
3929
+ if (opts.optional && !isUrl && e?.code === "ENOENT") {
3930
+ ctx.logger.info(
3931
+ "[MetadataPlugin] no compiled artifact yet \u2014 starting with no artifact metadata",
3932
+ { path: filePath }
3933
+ );
3934
+ return;
3935
+ }
3661
3936
  throw new Error(`[MetadataPlugin] Cannot read artifact ${isUrl ? "URL" : "file"} at "${filePath}": ${e.message}`);
3662
3937
  }
3663
3938
  await this._parseAndRegisterArtifact(ctx, raw, filePath);
3664
3939
  }
3665
- /**
3666
- * P2: Load metadata from the cloud artifact API endpoint.
3667
- */
3668
- async _loadFromArtifactApi(ctx, src) {
3669
- const environmentId = this.options.environmentId;
3670
- if (!environmentId) {
3671
- throw new Error("[MetadataPlugin] artifact-api source requires options.environmentId to be set");
3672
- }
3673
- let artifactUrl = src.url.replace(/\/+$/, "");
3674
- if (!/\/api\/v\d+\/cloud\/projects\//i.test(artifactUrl)) {
3675
- artifactUrl = `${artifactUrl}/api/v1/cloud/environments/${environmentId}/artifact`;
3676
- }
3677
- if (src.commitId) {
3678
- artifactUrl += `${artifactUrl.includes("?") ? "&" : "?"}commit=${encodeURIComponent(src.commitId)}`;
3679
- }
3680
- ctx.logger.info("[MetadataPlugin] Loading metadata from artifact API", { url: artifactUrl });
3681
- let raw;
3682
- try {
3683
- raw = await this._fetchJson(artifactUrl, src.fetchTimeoutMs, src.token);
3684
- } catch (e) {
3685
- throw new Error(`[MetadataPlugin] Cannot load artifact from API "${artifactUrl}": ${e.message}`);
3686
- }
3687
- await this._parseAndRegisterArtifact(ctx, raw, artifactUrl);
3688
- }
3689
3940
  async _loadFromFileSystem(ctx) {
3690
3941
  ctx.logger.info("Loading metadata from file system...");
3691
3942
  const sortedTypes = [...import_kernel2.DEFAULT_METADATA_TYPE_REGISTRY].sort((a, b) => a.loadOrder - b.loadOrder);
@@ -3700,7 +3951,7 @@ var MetadataPlugin = class {
3700
3951
  for (const item of items) {
3701
3952
  const meta = item;
3702
3953
  if (meta?.name) {
3703
- (0, import_shared.applyProtection)(meta, {
3954
+ (0, import_shared2.applyProtection)(meta, {
3704
3955
  packageId: this.options.packageId
3705
3956
  });
3706
3957
  await this.manager.register(entry.type, meta.name, item, { notify: false });