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