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

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
@@ -385,6 +385,8 @@ export default metadata;
385
385
 
386
386
  // src/loaders/database-loader.ts
387
387
  var import_metadata_core = require("@objectstack/metadata-core");
388
+ var import_spec = require("@objectstack/spec");
389
+ var import_shared = require("@objectstack/spec/shared");
388
390
 
389
391
  // src/utils/metadata-history-utils.ts
390
392
  async function calculateChecksum(metadata) {
@@ -660,6 +662,12 @@ var DatabaseLoader = class {
660
662
  };
661
663
  this.schemaReady = false;
662
664
  this.historySchemaReady = false;
665
+ /**
666
+ * Once-per-process dedupe for stored-row conversion notices — `load` /
667
+ * `loadMany` are hot read paths (cached, but re-hit on every TTL expiry),
668
+ * so a legacy row must warn once, not once per cache miss.
669
+ */
670
+ this.storedConversionWarned = /* @__PURE__ */ new Set();
663
671
  if (!options.driver && !options.engine) {
664
672
  throw new Error("DatabaseLoader requires either a driver or engine");
665
673
  }
@@ -920,13 +928,33 @@ var DatabaseLoader = class {
920
928
  }
921
929
  }
922
930
  /**
923
- * Convert a database row to a metadata payload.
924
- * Parses the JSON `metadata` column back into an object.
931
+ * Convert a LIVE database row to a metadata payload.
932
+ *
933
+ * Parses the JSON `metadata` column back into an object, then replays the
934
+ * full ADR-0087 conversion chain over it (#3903): rows written under a past
935
+ * protocol are served canonical, exactly like the metadata-protocol's
936
+ * `sys_metadata` seams. History rows do NOT pass through here — history
937
+ * readers parse inline and stay verbatim, as a record of what was written.
938
+ *
939
+ * `flow` is skipped for the same reason the protocol skips it: flow-node
940
+ * conversions need the automation engine's live executor registry for their
941
+ * open-namespace conflict guard; flows canonicalize at `registerFlow`.
925
942
  */
926
943
  rowToData(row) {
927
944
  if (!row || !row.metadata) return null;
928
945
  const payload = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
929
- return payload;
946
+ const singular = import_shared.PLURAL_TO_SINGULAR[row.type] ?? row.type;
947
+ if (singular === "flow") return payload;
948
+ return (0, import_spec.applyConversionsToStoredItem)(singular, payload, {
949
+ onNotice: (n) => {
950
+ const key = `${n.conversionId}|${singular}|${String(row.name ?? "")}`;
951
+ if (this.storedConversionWarned.has(key)) return;
952
+ this.storedConversionWarned.add(key);
953
+ console.warn(
954
+ `[DatabaseLoader] stored ${singular}/${String(row.name ?? "<unnamed>")} carries a pre-protocol shape; ${n.message}`
955
+ );
956
+ }
957
+ });
930
958
  }
931
959
  /**
932
960
  * Convert a database row to a MetadataRecord-like object.
@@ -2341,19 +2369,44 @@ var _MetadataManager = class _MetadataManager {
2341
2369
  /**
2342
2370
  * Load a single metadata item from loaders.
2343
2371
  * Iterates through registered loaders until found.
2372
+ *
2373
+ * Returns `null` both when no loader HAS the item and when every loader
2374
+ * FAILED — see {@link loadDiagnosed} when the caller must tell those apart.
2344
2375
  */
2345
2376
  async load(type, name, options) {
2377
+ return (await this.loadDiagnosed(type, name, options)).data;
2378
+ }
2379
+ /**
2380
+ * `load`, plus whether the answer can be trusted as complete.
2381
+ *
2382
+ * [ADR-0110 D3] A miss and an outage are different facts with opposite
2383
+ * security meanings, and plain `load` cannot express the difference: a
2384
+ * loader that throws is warn-logged and skipped, so a database the metadata
2385
+ * plane cannot reach returns the same `null` as a name that was never
2386
+ * declared. Callers that gate on a declaration MUST NOT read that `null` as
2387
+ * "the author declared no gate" — an availability failure would silently
2388
+ * widen access (the REST `/actions` route's fail-open branch, #3935).
2389
+ *
2390
+ * `degraded` is true when at least one loader threw AND no loader answered
2391
+ * with the item. The posture is deliberately conservative: with a loader
2392
+ * down we cannot prove the item is absent, so we decline to claim it is.
2393
+ * A clean miss (every loader answered, none had it) is NOT degraded.
2394
+ */
2395
+ async loadDiagnosed(type, name, options) {
2396
+ const errors = [];
2346
2397
  for (const loader of this.loaders.values()) {
2347
2398
  try {
2348
2399
  const result = await loader.load(type, name, options);
2349
2400
  if (result.data) {
2350
- return result.data;
2401
+ return { data: result.data, degraded: false, errors };
2351
2402
  }
2352
2403
  } catch (e) {
2404
+ const message = e instanceof Error ? e.message : String(e);
2405
+ errors.push(`${loader.contract.name}: ${message}`);
2353
2406
  this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
2354
2407
  }
2355
2408
  }
2356
- return null;
2409
+ return { data: null, degraded: errors.length > 0, errors };
2357
2410
  }
2358
2411
  /**
2359
2412
  * Load multiple metadata items from loaders.
@@ -3240,10 +3293,10 @@ var MemoryLoader = class {
3240
3293
 
3241
3294
  // src/plugin.ts
3242
3295
  var import_kernel2 = require("@objectstack/spec/kernel");
3243
- var import_shared = require("@objectstack/spec/shared");
3296
+ var import_shared2 = require("@objectstack/spec/shared");
3244
3297
  var import_metadata_core2 = require("@objectstack/metadata-core");
3245
- var import_spec = require("@objectstack/spec");
3246
3298
  var import_spec2 = require("@objectstack/spec");
3299
+ var import_spec3 = require("@objectstack/spec");
3247
3300
  var queryableMetadataObjects = [
3248
3301
  import_metadata_core2.SysMetadataObject,
3249
3302
  import_metadata_core2.SysMetadataHistoryObject,
@@ -3295,6 +3348,12 @@ var MetadataPlugin = class {
3295
3348
  this.name = "com.objectstack.metadata";
3296
3349
  this.type = "standard";
3297
3350
  this.version = "1.0.0";
3351
+ /**
3352
+ * Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the
3353
+ * kernel name this plugin when a consumer requires `metadata` before it
3354
+ * initializes.
3355
+ */
3356
+ this.providesServices = ["metadata"];
3298
3357
  this.init = async (ctx) => {
3299
3358
  ctx.logger.info("Initializing Metadata Manager", {
3300
3359
  root: this.options.rootDir || process.cwd(),
@@ -3334,27 +3393,27 @@ var MetadataPlugin = class {
3334
3393
  bootstrap: mode,
3335
3394
  artifactSource: src?.mode ?? "none"
3336
3395
  });
3396
+ if (src && src.mode !== "local-file") {
3397
+ const bad = src.mode;
3398
+ throw new Error(
3399
+ `[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 }.")
3400
+ );
3401
+ }
3337
3402
  if (mode === "artifact-only") {
3338
- if (src?.mode === "local-file") {
3403
+ if (src) {
3339
3404
  await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
3340
- } else if (src?.mode === "artifact-api") {
3341
- await this._loadFromArtifactApi(ctx, src);
3342
3405
  } else {
3343
3406
  throw new Error("[MetadataPlugin] bootstrap=artifact-only requires options.artifactSource to be set");
3344
3407
  }
3345
3408
  } 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);
3409
+ if (src) {
3410
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
3350
3411
  } else {
3351
3412
  ctx.logger.info("[MetadataPlugin] lazy bootstrap \u2014 skipping filesystem priming; metadata loads on demand");
3352
3413
  }
3353
3414
  } 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);
3415
+ if (src) {
3416
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
3358
3417
  } else {
3359
3418
  await this._loadFromFileSystem(ctx);
3360
3419
  }
@@ -3396,7 +3455,14 @@ var MetadataPlugin = class {
3396
3455
  });
3397
3456
  }
3398
3457
  try {
3399
- const httpServer = ctx.getService("http-server") ?? ctx.getService("http.server");
3458
+ const readServer = (name) => {
3459
+ try {
3460
+ return ctx.getService(name);
3461
+ } catch {
3462
+ return void 0;
3463
+ }
3464
+ };
3465
+ const httpServer = readServer("http.server") ?? readServer("http-server");
3400
3466
  if (httpServer && typeof httpServer.getRawApp === "function") {
3401
3467
  const { registerMetadataHmrRoutes: registerMetadataHmrRoutes2 } = await Promise.resolve().then(() => (init_hmr_routes(), hmr_routes_exports));
3402
3468
  const hub = registerMetadataHmrRoutes2(httpServer.getRawApp(), this.manager);
@@ -3509,14 +3575,13 @@ var MetadataPlugin = class {
3509
3575
  /**
3510
3576
  * Fetch JSON content from a URL with configurable timeout.
3511
3577
  */
3512
- async _fetchJson(url, fetchTimeoutMs, token) {
3578
+ async _fetchJson(url, fetchTimeoutMs) {
3513
3579
  const envTimeout = Number(process.env.OS_ARTIFACT_FETCH_TIMEOUT_MS);
3514
3580
  const timeoutMs = fetchTimeoutMs ?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : void 0) ?? 6e4;
3515
3581
  const controller = new AbortController();
3516
3582
  const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
3517
3583
  try {
3518
3584
  const headers = { Accept: "application/json, */*;q=0.5" };
3519
- if (token) headers.Authorization = `Bearer ${token}`;
3520
3585
  const res = await fetch(url, { redirect: "follow", signal: controller.signal, headers });
3521
3586
  if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
3522
3587
  const content = await res.text();
@@ -3578,21 +3643,21 @@ var MetadataPlugin = class {
3578
3643
  const items = metadata[field];
3579
3644
  if (!Array.isArray(items) || items.length === 0) continue;
3580
3645
  for (const item of items) {
3581
- if (metaType === "view" && (0, import_spec2.isAggregatedViewContainer)(item)) {
3646
+ if (metaType === "view" && (0, import_spec3.isAggregatedViewContainer)(item)) {
3582
3647
  const viewObject = item?.list?.data?.object ?? item?.form?.data?.object;
3583
3648
  if (!viewObject) continue;
3584
- (0, import_shared.applyProtection)(item, {
3649
+ (0, import_shared2.applyProtection)(item, {
3585
3650
  packageId: manifestPackageId,
3586
3651
  packageVersion: manifestVersion
3587
3652
  });
3588
3653
  await memLoader.save("view", viewObject, item);
3589
3654
  await this.manager.register("view", viewObject, item, { notify: false });
3590
3655
  totalRegistered++;
3591
- for (const vi of (0, import_spec2.expandViewContainer)(viewObject, item)) {
3656
+ for (const vi of (0, import_spec3.expandViewContainer)(viewObject, item)) {
3592
3657
  for (const w of vi._diagnostics?.warnings ?? []) {
3593
3658
  ctx.logger.warn(`[MetadataPlugin] View expansion warning for '${vi.name}': ${w.message}`);
3594
3659
  }
3595
- (0, import_shared.applyProtection)(vi, {
3660
+ (0, import_shared2.applyProtection)(vi, {
3596
3661
  packageId: manifestPackageId,
3597
3662
  packageVersion: manifestVersion
3598
3663
  });
@@ -3609,7 +3674,7 @@ var MetadataPlugin = class {
3609
3674
  }
3610
3675
  }
3611
3676
  if (!name) continue;
3612
- (0, import_shared.applyProtection)(item, {
3677
+ (0, import_shared2.applyProtection)(item, {
3613
3678
  packageId: manifestPackageId,
3614
3679
  packageVersion: manifestVersion
3615
3680
  });
@@ -3636,14 +3701,26 @@ var MetadataPlugin = class {
3636
3701
  * logged but never blocks the reload.
3637
3702
  */
3638
3703
  async _reloadAndAnnounce(ctx, src, changed) {
3639
- await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
3704
+ await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
3640
3705
  try {
3641
3706
  await ctx.trigger("metadata:reloaded", { changed, metadata: this.lastParsedMetadata });
3642
3707
  } catch (e) {
3643
3708
  ctx.logger.warn("[MetadataPlugin] metadata:reloaded subscriber failed", { error: e?.message });
3644
3709
  }
3645
3710
  }
3646
- async _loadFromLocalFile(ctx, filePath, fetchTimeoutMs) {
3711
+ /**
3712
+ * @param opts.optional When true, a LOCAL artifact file that does not exist
3713
+ * is "nothing compiled yet" rather than a fault: log and return, leaving
3714
+ * the manager empty and the artifact watcher armed so the first
3715
+ * `os compile` hydrates the running server (#4085). Callers pass it for
3716
+ * the `eager` / `lazy` bootstrap modes — the development-platform paths,
3717
+ * where an app is optional. `artifact-only` (sealed runtime) does NOT:
3718
+ * there the artifact IS the deployment, so its absence must fail loudly
3719
+ * instead of silently serving an empty runtime. Only ENOENT is tolerated;
3720
+ * a present-but-unreadable artifact (malformed JSON, bad permissions) and
3721
+ * every remote-URL failure stay fatal.
3722
+ */
3723
+ async _loadFromLocalFile(ctx, filePath, fetchTimeoutMs, opts = {}) {
3647
3724
  const isUrl = /^https?:\/\//i.test(filePath);
3648
3725
  ctx.logger.info(
3649
3726
  `[MetadataPlugin] Loading metadata from ${isUrl ? "remote URL" : "local artifact file"}`,
@@ -3658,34 +3735,17 @@ var MetadataPlugin = class {
3658
3735
  raw = JSON.parse(content);
3659
3736
  }
3660
3737
  } catch (e) {
3738
+ if (opts.optional && !isUrl && e?.code === "ENOENT") {
3739
+ ctx.logger.info(
3740
+ "[MetadataPlugin] no compiled artifact yet \u2014 starting with no artifact metadata",
3741
+ { path: filePath }
3742
+ );
3743
+ return;
3744
+ }
3661
3745
  throw new Error(`[MetadataPlugin] Cannot read artifact ${isUrl ? "URL" : "file"} at "${filePath}": ${e.message}`);
3662
3746
  }
3663
3747
  await this._parseAndRegisterArtifact(ctx, raw, filePath);
3664
3748
  }
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
3749
  async _loadFromFileSystem(ctx) {
3690
3750
  ctx.logger.info("Loading metadata from file system...");
3691
3751
  const sortedTypes = [...import_kernel2.DEFAULT_METADATA_TYPE_REGISTRY].sort((a, b) => a.loadOrder - b.loadOrder);
@@ -3700,7 +3760,7 @@ var MetadataPlugin = class {
3700
3760
  for (const item of items) {
3701
3761
  const meta = item;
3702
3762
  if (meta?.name) {
3703
- (0, import_shared.applyProtection)(meta, {
3763
+ (0, import_shared2.applyProtection)(meta, {
3704
3764
  packageId: this.options.packageId
3705
3765
  });
3706
3766
  await this.manager.register(entry.type, meta.name, item, { notify: false });