@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/CHANGELOG.md +3594 -0
- package/dist/index.cjs +353 -102
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +146 -17
- package/dist/index.d.ts +146 -17
- package/dist/index.js +348 -94
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +353 -102
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/node.js +348 -94
- package/dist/node.js.map +1 -1
- package/package.json +12 -10
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 });
|