@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/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
@@ -552,6 +553,70 @@ var LRUCache = class {
552
553
  }
553
554
  };
554
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
+
555
620
  // src/migrations/add-sys-metadata-overlay-index.ts
556
621
  var INDEX_NAME = "idx_sys_metadata_overlay_active";
557
622
  var TABLE = "sys_metadata";
@@ -662,6 +727,19 @@ var DatabaseLoader = class {
662
727
  };
663
728
  this.schemaReady = false;
664
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;
665
743
  /**
666
744
  * Once-per-process dedupe for stored-row conversion notices — `load` /
667
745
  * `loadMany` are hot read paths (cached, but re-hit on every TTL expiry),
@@ -771,6 +849,24 @@ var DatabaseLoader = class {
771
849
  * Reads `MAX(event_seq) + 1` for the configured `organization_id`.
772
850
  * Legacy path — not transactional, so concurrent writes can collide.
773
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.
774
870
  */
775
871
  async nextEventSeq() {
776
872
  const where = this.organizationId ? { organization_id: this.organizationId } : {};
@@ -782,8 +878,9 @@ var DatabaseLoader = class {
782
878
  if (v > max) max = v;
783
879
  }
784
880
  return max + 1;
785
- } catch {
786
- return 1;
881
+ } catch (error) {
882
+ if (isMissingTableError(error)) return 1;
883
+ throw error;
787
884
  }
788
885
  }
789
886
  /**
@@ -820,17 +917,32 @@ var DatabaseLoader = class {
820
917
  ...import_metadata_core.SysMetadataObject,
821
918
  name: this.tableName
822
919
  });
823
- this.schemaReady = true;
824
- try {
825
- await migrateProjectIdToEnvironmentId(this.driver);
826
- } catch {
827
- }
828
- try {
829
- await addSysMetadataOverlayIndex(this.driver);
830
- } catch {
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;
831
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);
832
945
  } catch {
833
- this.schemaReady = true;
834
946
  }
835
947
  }
836
948
  /**
@@ -848,9 +960,25 @@ var DatabaseLoader = class {
848
960
  ...import_metadata_core.SysMetadataHistoryObject,
849
961
  name: this.historyTableName
850
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
+ }
851
969
  this.historySchemaReady = true;
852
970
  } catch (error) {
853
- console.error("Failed to ensure history schema, will retry on next operation:", error);
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
+ }
854
982
  }
855
983
  }
856
984
  /**
@@ -891,7 +1019,25 @@ var DatabaseLoader = class {
891
1019
  }
892
1020
  const historyId = generateId();
893
1021
  const metadataJson = JSON.stringify(metadata);
894
- const eventSeq = await this.nextEventSeq();
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
+ }
895
1041
  const historyRecord = {
896
1042
  id: historyId,
897
1043
  name,
@@ -1348,6 +1494,17 @@ function generateId() {
1348
1494
  }
1349
1495
 
1350
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
+ }
1351
1508
  var _MetadataManager = class _MetadataManager {
1352
1509
  constructor(config) {
1353
1510
  this.loaders = /* @__PURE__ */ new Map();
@@ -1468,6 +1625,60 @@ var _MetadataManager = class _MetadataManager {
1468
1625
  this.realtimeService = service;
1469
1626
  this.logger.info("RealtimeService configured for metadata events");
1470
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
+ }
1471
1682
  /**
1472
1683
  * Register a new metadata loader (data source)
1473
1684
  */
@@ -1511,25 +1722,11 @@ var _MetadataManager = class _MetadataManager {
1511
1722
  await loader.save(type, name, data);
1512
1723
  }
1513
1724
  }
1514
- if (this.realtimeService) {
1515
- const event = {
1516
- type: `metadata.${type}.created`,
1517
- object: type,
1518
- payload: {
1519
- metadataType: type,
1520
- name,
1521
- definition: data,
1522
- packageId: data?.packageId
1523
- },
1524
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1525
- };
1526
- try {
1527
- await this.realtimeService.publish(event);
1528
- this.logger.debug(`Published metadata.${type}.created event`, { name });
1529
- } catch (error) {
1530
- this.logger.warn(`Failed to publish metadata event`, { type, name, error });
1531
- }
1532
- }
1725
+ await this.publishRealtimeMetadataEvent(existed ? "updated" : "created", type, name, {
1726
+ definition: data,
1727
+ packageId: data?.packageId,
1728
+ userId: options?.userId
1729
+ });
1533
1730
  if (options?.notify !== false) {
1534
1731
  this.notifyWatchers(type, {
1535
1732
  type: existed ? "changed" : "added",
@@ -1643,23 +1840,9 @@ var _MetadataManager = class _MetadataManager {
1643
1840
  }
1644
1841
  }
1645
1842
  }
1646
- if (this.realtimeService) {
1647
- const event = {
1648
- type: `metadata.${type}.deleted`,
1649
- object: type,
1650
- payload: {
1651
- metadataType: type,
1652
- name
1653
- },
1654
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1655
- };
1656
- try {
1657
- await this.realtimeService.publish(event);
1658
- this.logger.debug(`Published metadata.${type}.deleted event`, { name });
1659
- } catch (error) {
1660
- this.logger.warn(`Failed to publish metadata event`, { type, name, error });
1661
- }
1662
- }
1843
+ await this.publishRealtimeMetadataEvent("deleted", type, name, {
1844
+ userId: options?.userId
1845
+ });
1663
1846
  if (options?.notify !== false) {
1664
1847
  this.notifyWatchers(type, {
1665
1848
  type: "deleted",
@@ -3354,6 +3537,14 @@ var MetadataPlugin = class {
3354
3537
  * initializes.
3355
3538
  */
3356
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"];
3357
3548
  this.init = async (ctx) => {
3358
3549
  ctx.logger.info("Initializing Metadata Manager", {
3359
3550
  root: this.options.rootDir || process.cwd(),