@objectstack/metadata 17.0.0-rc.1 → 17.0.0-rc.3
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 +328 -0
- package/dist/index.cjs +240 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -2
- package/dist/index.d.ts +63 -2
- package/dist/index.js +243 -49
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +240 -49
- 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 +243 -49
- package/dist/node.js.map +1 -1
- package/package.json +9 -9
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
|
|
@@ -550,6 +551,70 @@ var LRUCache = class {
|
|
|
550
551
|
}
|
|
551
552
|
};
|
|
552
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
|
+
|
|
553
618
|
// src/migrations/add-sys-metadata-overlay-index.ts
|
|
554
619
|
var INDEX_NAME = "idx_sys_metadata_overlay_active";
|
|
555
620
|
var TABLE = "sys_metadata";
|
|
@@ -660,6 +725,19 @@ var DatabaseLoader = class {
|
|
|
660
725
|
};
|
|
661
726
|
this.schemaReady = false;
|
|
662
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;
|
|
663
741
|
/**
|
|
664
742
|
* Once-per-process dedupe for stored-row conversion notices — `load` /
|
|
665
743
|
* `loadMany` are hot read paths (cached, but re-hit on every TTL expiry),
|
|
@@ -769,6 +847,24 @@ var DatabaseLoader = class {
|
|
|
769
847
|
* Reads `MAX(event_seq) + 1` for the configured `organization_id`.
|
|
770
848
|
* Legacy path — not transactional, so concurrent writes can collide.
|
|
771
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.
|
|
772
868
|
*/
|
|
773
869
|
async nextEventSeq() {
|
|
774
870
|
const where = this.organizationId ? { organization_id: this.organizationId } : {};
|
|
@@ -780,8 +876,9 @@ var DatabaseLoader = class {
|
|
|
780
876
|
if (v > max) max = v;
|
|
781
877
|
}
|
|
782
878
|
return max + 1;
|
|
783
|
-
} catch {
|
|
784
|
-
return 1;
|
|
879
|
+
} catch (error) {
|
|
880
|
+
if (isMissingTableError(error)) return 1;
|
|
881
|
+
throw error;
|
|
785
882
|
}
|
|
786
883
|
}
|
|
787
884
|
/**
|
|
@@ -818,17 +915,32 @@ var DatabaseLoader = class {
|
|
|
818
915
|
...import_metadata_core.SysMetadataObject,
|
|
819
916
|
name: this.tableName
|
|
820
917
|
});
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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;
|
|
829
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);
|
|
830
943
|
} catch {
|
|
831
|
-
this.schemaReady = true;
|
|
832
944
|
}
|
|
833
945
|
}
|
|
834
946
|
/**
|
|
@@ -846,9 +958,25 @@ var DatabaseLoader = class {
|
|
|
846
958
|
...import_metadata_core.SysMetadataHistoryObject,
|
|
847
959
|
name: this.historyTableName
|
|
848
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
|
+
}
|
|
849
967
|
this.historySchemaReady = true;
|
|
850
968
|
} catch (error) {
|
|
851
|
-
|
|
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
|
+
}
|
|
852
980
|
}
|
|
853
981
|
}
|
|
854
982
|
/**
|
|
@@ -889,7 +1017,25 @@ var DatabaseLoader = class {
|
|
|
889
1017
|
}
|
|
890
1018
|
const historyId = generateId();
|
|
891
1019
|
const metadataJson = JSON.stringify(metadata);
|
|
892
|
-
|
|
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
|
+
}
|
|
893
1039
|
const historyRecord = {
|
|
894
1040
|
id: historyId,
|
|
895
1041
|
name,
|
|
@@ -1346,6 +1492,17 @@ function generateId() {
|
|
|
1346
1492
|
}
|
|
1347
1493
|
|
|
1348
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
|
+
}
|
|
1349
1506
|
var _MetadataManager = class _MetadataManager {
|
|
1350
1507
|
constructor(config) {
|
|
1351
1508
|
this.loaders = /* @__PURE__ */ new Map();
|
|
@@ -1466,6 +1623,60 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1466
1623
|
this.realtimeService = service;
|
|
1467
1624
|
this.logger.info("RealtimeService configured for metadata events");
|
|
1468
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
|
+
}
|
|
1469
1680
|
/**
|
|
1470
1681
|
* Register a new metadata loader (data source)
|
|
1471
1682
|
*/
|
|
@@ -1509,25 +1720,11 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1509
1720
|
await loader.save(type, name, data);
|
|
1510
1721
|
}
|
|
1511
1722
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
metadataType: type,
|
|
1518
|
-
name,
|
|
1519
|
-
definition: data,
|
|
1520
|
-
packageId: data?.packageId
|
|
1521
|
-
},
|
|
1522
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1523
|
-
};
|
|
1524
|
-
try {
|
|
1525
|
-
await this.realtimeService.publish(event);
|
|
1526
|
-
this.logger.debug(`Published metadata.${type}.created event`, { name });
|
|
1527
|
-
} catch (error) {
|
|
1528
|
-
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1723
|
+
await this.publishRealtimeMetadataEvent(existed ? "updated" : "created", type, name, {
|
|
1724
|
+
definition: data,
|
|
1725
|
+
packageId: data?.packageId,
|
|
1726
|
+
userId: options?.userId
|
|
1727
|
+
});
|
|
1531
1728
|
if (options?.notify !== false) {
|
|
1532
1729
|
this.notifyWatchers(type, {
|
|
1533
1730
|
type: existed ? "changed" : "added",
|
|
@@ -1641,23 +1838,9 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1641
1838
|
}
|
|
1642
1839
|
}
|
|
1643
1840
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
object: type,
|
|
1648
|
-
payload: {
|
|
1649
|
-
metadataType: type,
|
|
1650
|
-
name
|
|
1651
|
-
},
|
|
1652
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1653
|
-
};
|
|
1654
|
-
try {
|
|
1655
|
-
await this.realtimeService.publish(event);
|
|
1656
|
-
this.logger.debug(`Published metadata.${type}.deleted event`, { name });
|
|
1657
|
-
} catch (error) {
|
|
1658
|
-
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1841
|
+
await this.publishRealtimeMetadataEvent("deleted", type, name, {
|
|
1842
|
+
userId: options?.userId
|
|
1843
|
+
});
|
|
1661
1844
|
if (options?.notify !== false) {
|
|
1662
1845
|
this.notifyWatchers(type, {
|
|
1663
1846
|
type: "deleted",
|
|
@@ -3352,6 +3535,14 @@ var MetadataPlugin = class {
|
|
|
3352
3535
|
* initializes.
|
|
3353
3536
|
*/
|
|
3354
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"];
|
|
3355
3546
|
this.init = async (ctx) => {
|
|
3356
3547
|
ctx.logger.info("Initializing Metadata Manager", {
|
|
3357
3548
|
root: this.options.rootDir || process.cwd(),
|