@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/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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 });
|