@objectstack/objectql 4.2.0 → 5.1.0

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/index.mjs CHANGED
@@ -579,6 +579,34 @@ var SchemaRegistry = class {
579
579
  // ==========================================
580
580
  // Reset (for testing)
581
581
  // ==========================================
582
+ /**
583
+ * Invalidate the merged-schema cache for a single FQN (or short name).
584
+ *
585
+ * Call this from event-driven consumers (ADR-0008 M0 PR-7) when an
586
+ * upstream metadata change makes the cached merged definition stale.
587
+ * The contributor list is preserved — only the cached merge result is
588
+ * dropped, so the next `resolveObject(fqn)` recomputes from scratch.
589
+ *
590
+ * Accepts either an FQN (`acme__contact`) or a bare short name
591
+ * (`contact`); for the latter, all entries whose suffix matches the
592
+ * name are invalidated.
593
+ */
594
+ invalidate(fqnOrName) {
595
+ if (this.mergedObjectCache.has(fqnOrName)) {
596
+ this.mergedObjectCache.delete(fqnOrName);
597
+ return;
598
+ }
599
+ const suffix = `__${fqnOrName}`;
600
+ for (const fqn of Array.from(this.mergedObjectCache.keys())) {
601
+ if (fqn === fqnOrName || fqn.endsWith(suffix)) {
602
+ this.mergedObjectCache.delete(fqn);
603
+ }
604
+ }
605
+ }
606
+ /** Drop every entry from the merged-schema cache. */
607
+ invalidateAll() {
608
+ this.mergedObjectCache.clear();
609
+ }
582
610
  /**
583
611
  * Clear all registry state. Use only for testing.
584
612
  */
@@ -591,11 +619,518 @@ var SchemaRegistry = class {
591
619
  }
592
620
  };
593
621
 
622
+ // src/sys-metadata-repository.ts
623
+ import { hashSpec, ConflictError } from "@objectstack/metadata-core";
624
+ import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
625
+ var OVERLAY_ALLOWED_TYPES = new Set(
626
+ DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)
627
+ );
628
+ var SysMetadataRepository = class {
629
+ constructor(opts) {
630
+ /**
631
+ * Local seq counter for in-memory watch() event broadcasts. Mirrors
632
+ * the durable `event_seq` we write into `sys_metadata_history` on
633
+ * each successful put/delete — assigned AFTER the transaction commits
634
+ * so we never broadcast events that got rolled back.
635
+ */
636
+ this.seqCounter = 0;
637
+ this.watchers = /* @__PURE__ */ new Set();
638
+ this.closed = false;
639
+ /** Table name for the durable event log. */
640
+ this.historyTable = "sys_metadata_history";
641
+ this.engine = opts.engine;
642
+ this.organizationId = opts.organizationId ?? null;
643
+ this.orgLabel = opts.orgLabel ?? (opts.organizationId ?? "system");
644
+ }
645
+ /**
646
+ * Run `cb` inside `engine.transaction(...)` if the engine supports it,
647
+ * otherwise fall through to a direct call. Matches the real
648
+ * `ObjectQL.transaction` semantics — in-memory drivers (and our test
649
+ * fakes) get no rollback, which is acceptable because production
650
+ * always runs on a SQL driver with real ACID.
651
+ */
652
+ async withTxn(cb) {
653
+ if (typeof this.engine.transaction === "function") {
654
+ return this.engine.transaction(cb);
655
+ }
656
+ return cb(void 0);
657
+ }
658
+ /**
659
+ * Read the current overlay row. Returns null if no row exists —
660
+ * callers (e.g. LayeredRepository) fall through to lower layers.
661
+ */
662
+ async get(ref) {
663
+ this.assertOpen();
664
+ const row = await this.engine.findOne("sys_metadata", {
665
+ where: this.whereFor(ref)
666
+ });
667
+ if (!row) return null;
668
+ return this.rowToItem(ref, row);
669
+ }
670
+ /**
671
+ * Resolve a historical version by content hash (ADR-0009).
672
+ *
673
+ * Looks up `sys_metadata_history` by `(organization_id, type, name,
674
+ * checksum)`. Returns null if no row matches. `executionPinned` types
675
+ * are guaranteed to find their body here because history GC skips
676
+ * them.
677
+ */
678
+ async getByHash(ref, hash) {
679
+ this.assertOpen();
680
+ const full = this.fullRef(ref);
681
+ const row = await this.engine.findOne(this.historyTable, {
682
+ where: {
683
+ organization_id: this.organizationId,
684
+ type: full.type,
685
+ name: full.name,
686
+ checksum: hash
687
+ }
688
+ });
689
+ if (!row) return null;
690
+ const rawBody = row.metadata;
691
+ if (rawBody === null || rawBody === void 0) {
692
+ return null;
693
+ }
694
+ const body = typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody;
695
+ return {
696
+ ref: { ...full, version: void 0 },
697
+ body,
698
+ hash,
699
+ parentHash: row.previous_checksum ?? null,
700
+ authoredBy: row.recorded_by ?? "unknown",
701
+ authoredAt: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
702
+ message: row.change_note ?? void 0,
703
+ seq: row.event_seq ?? 0
704
+ };
705
+ }
706
+ async put(ref, spec, opts) {
707
+ this.assertOpen();
708
+ this.assertAllowed(ref.type);
709
+ const body = spec ?? {};
710
+ const hash = hashSpec(body);
711
+ const result = await this.withTxn(async (ctx) => {
712
+ const existing = await this.engine.findOne("sys_metadata", {
713
+ where: this.whereFor(ref),
714
+ context: ctx
715
+ });
716
+ const existingHash = existing?.checksum ?? null;
717
+ if (opts.parentVersion !== existingHash) {
718
+ throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
719
+ }
720
+ if (existing && existingHash === hash) {
721
+ const item2 = this.rowToItem(ref, existing);
722
+ return { skipped: true, version: hash, seq: item2.seq, item: item2 };
723
+ }
724
+ const now = (/* @__PURE__ */ new Date()).toISOString();
725
+ const op = existing ? "update" : "create";
726
+ const version = await this.nextItemVersion(ref, ctx);
727
+ const eventSeq = await this.nextEventSeq(ctx);
728
+ const parentRowData = {
729
+ type: ref.type,
730
+ name: ref.name,
731
+ organization_id: this.organizationId,
732
+ metadata: JSON.stringify(body),
733
+ checksum: hash,
734
+ state: "active",
735
+ version,
736
+ updated_at: now
737
+ };
738
+ if (existing) {
739
+ const existingId = existing.id;
740
+ if (existingId === void 0) {
741
+ throw new Error(
742
+ `SysMetadataRepository.put: existing row for ${ref.type}/${ref.name} has no id column`
743
+ );
744
+ }
745
+ await this.engine.update("sys_metadata", parentRowData, {
746
+ where: { id: existingId },
747
+ context: ctx
748
+ });
749
+ } else {
750
+ parentRowData.created_at = now;
751
+ await this.engine.insert("sys_metadata", parentRowData, { context: ctx });
752
+ }
753
+ await this.engine.insert(
754
+ this.historyTable,
755
+ {
756
+ id: this.uuid(),
757
+ event_seq: eventSeq,
758
+ type: ref.type,
759
+ name: ref.name,
760
+ version,
761
+ operation_type: op,
762
+ metadata: JSON.stringify(body),
763
+ checksum: hash,
764
+ previous_checksum: existingHash,
765
+ change_note: opts.message,
766
+ source: opts.source ?? "sys-metadata-repo",
767
+ organization_id: this.organizationId,
768
+ recorded_by: opts.actor,
769
+ recorded_at: now
770
+ },
771
+ { context: ctx }
772
+ );
773
+ const item = {
774
+ ref: this.fullRef(ref),
775
+ body,
776
+ hash,
777
+ parentHash: existingHash,
778
+ authoredBy: opts.actor,
779
+ authoredAt: now,
780
+ message: opts.message,
781
+ seq: eventSeq
782
+ };
783
+ return {
784
+ skipped: false,
785
+ version: hash,
786
+ seq: eventSeq,
787
+ item,
788
+ op,
789
+ existingHash,
790
+ now,
791
+ source: opts.source ?? "sys-metadata-repo",
792
+ message: opts.message,
793
+ actor: opts.actor
794
+ };
795
+ });
796
+ if (result.skipped) {
797
+ return { version: result.version, seq: result.seq, item: result.item };
798
+ }
799
+ this.seqCounter = result.seq;
800
+ this.broadcast({
801
+ seq: result.seq,
802
+ op: result.op,
803
+ ref: this.fullRef(ref),
804
+ hash: result.version,
805
+ parentHash: result.existingHash,
806
+ actor: result.actor,
807
+ message: result.message,
808
+ ts: result.now,
809
+ source: result.source
810
+ });
811
+ return { version: result.version, seq: result.seq, item: result.item };
812
+ }
813
+ async delete(ref, opts) {
814
+ this.assertOpen();
815
+ this.assertAllowed(ref.type);
816
+ const result = await this.withTxn(async (ctx) => {
817
+ const existing = await this.engine.findOne("sys_metadata", {
818
+ where: this.whereFor(ref),
819
+ context: ctx
820
+ });
821
+ if (!existing) {
822
+ throw new ConflictError(this.fullRef(ref), opts.parentVersion, null);
823
+ }
824
+ const existingHash = existing.checksum ?? null;
825
+ if (opts.parentVersion !== existingHash) {
826
+ throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
827
+ }
828
+ const existingId = existing.id;
829
+ if (existingId === void 0) {
830
+ throw new Error(
831
+ `SysMetadataRepository.delete: existing row for ${ref.type}/${ref.name} has no id column`
832
+ );
833
+ }
834
+ const now = (/* @__PURE__ */ new Date()).toISOString();
835
+ const version = await this.nextItemVersion(ref, ctx);
836
+ const eventSeq = await this.nextEventSeq(ctx);
837
+ await this.engine.delete("sys_metadata", {
838
+ where: { id: existingId },
839
+ context: ctx
840
+ });
841
+ await this.engine.insert(
842
+ this.historyTable,
843
+ {
844
+ id: this.uuid(),
845
+ event_seq: eventSeq,
846
+ type: ref.type,
847
+ name: ref.name,
848
+ version,
849
+ operation_type: "delete",
850
+ metadata: null,
851
+ checksum: null,
852
+ previous_checksum: existingHash,
853
+ change_note: opts.message,
854
+ source: opts.source ?? "sys-metadata-repo",
855
+ organization_id: this.organizationId,
856
+ recorded_by: opts.actor,
857
+ recorded_at: now
858
+ },
859
+ { context: ctx }
860
+ );
861
+ return {
862
+ eventSeq,
863
+ existingHash,
864
+ now,
865
+ source: opts.source ?? "sys-metadata-repo",
866
+ message: opts.message,
867
+ actor: opts.actor
868
+ };
869
+ });
870
+ this.seqCounter = result.eventSeq;
871
+ this.broadcast({
872
+ seq: result.eventSeq,
873
+ op: "delete",
874
+ ref: this.fullRef(ref),
875
+ hash: null,
876
+ parentHash: result.existingHash,
877
+ actor: result.actor,
878
+ message: result.message,
879
+ ts: result.now,
880
+ source: result.source
881
+ });
882
+ return { seq: result.eventSeq };
883
+ }
884
+ async *list(filter) {
885
+ this.assertOpen();
886
+ const where = {
887
+ organization_id: this.organizationId,
888
+ state: "active"
889
+ };
890
+ if (filter.type) where.type = filter.type;
891
+ const rows = await this.engine.find("sys_metadata", {
892
+ where,
893
+ limit: filter.limit
894
+ });
895
+ for (const row of rows) {
896
+ if (filter.nameContains && !String(row.name).includes(filter.nameContains)) continue;
897
+ const item = this.rowToItem(
898
+ { ...this.fullRef({ type: row.type, name: row.name }) },
899
+ row
900
+ );
901
+ const { body, ...header } = item;
902
+ yield header;
903
+ }
904
+ }
905
+ /**
906
+ * Yield every history event for `(org, type?, name?)` from the
907
+ * durable log, ordered by per-(type,name) `version` ascending. When
908
+ * `filter.type`/`filter.name` are unset the consumer gets the full
909
+ * org-scoped event stream — still ordered by version within each
910
+ * (type,name) bucket, then by `recorded_at` across buckets (we sort
911
+ * client-side because the test engine doesn't honor `orderBy`).
912
+ */
913
+ async *history(ref, opts) {
914
+ this.assertOpen();
915
+ const full = this.fullRef(ref);
916
+ const where = {
917
+ organization_id: this.organizationId,
918
+ type: full.type,
919
+ name: full.name
920
+ };
921
+ const rows = await this.engine.find(this.historyTable, { where });
922
+ rows.sort((a, b) => {
923
+ const va = typeof a.event_seq === "number" ? a.event_seq : 0;
924
+ const vb = typeof b.event_seq === "number" ? b.event_seq : 0;
925
+ return va - vb;
926
+ });
927
+ let yielded = 0;
928
+ for (const row of rows) {
929
+ if (opts?.sinceSeq !== void 0 && (row.event_seq ?? 0) <= opts.sinceSeq) continue;
930
+ if (opts?.limit !== void 0 && yielded >= opts.limit) break;
931
+ yielded++;
932
+ yield {
933
+ seq: row.event_seq ?? 0,
934
+ op: row.operation_type ?? "update",
935
+ ref: full,
936
+ hash: row.checksum ?? null,
937
+ parentHash: row.previous_checksum ?? null,
938
+ actor: row.recorded_by ?? "unknown",
939
+ message: row.change_note ?? void 0,
940
+ ts: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
941
+ source: row.source ?? "sys-metadata-repo"
942
+ };
943
+ }
944
+ }
945
+ /**
946
+ * Live event stream. Fires for every successful put/delete on THIS
947
+ * instance — cross-replica fan-out is M1. Manual AsyncIterator (not
948
+ * an async generator) so we can deterministically tear down via
949
+ * `iter.return()`, matching the pattern used by InMemoryRepository.
950
+ */
951
+ watch(filter, since) {
952
+ const self = this;
953
+ return {
954
+ [Symbol.asyncIterator]: () => {
955
+ const queue = [];
956
+ let pendingResolve = null;
957
+ let stopped = false;
958
+ const dispatch = (evt) => {
959
+ if (stopped) return;
960
+ if (!self.matchesFilter(evt, filter)) return;
961
+ if (since !== void 0 && evt.seq <= since) return;
962
+ if (pendingResolve) {
963
+ const r = pendingResolve;
964
+ pendingResolve = null;
965
+ r({ value: evt, done: false });
966
+ } else {
967
+ queue.push(evt);
968
+ }
969
+ };
970
+ self.watchers.add(dispatch);
971
+ return {
972
+ next() {
973
+ if (stopped) return Promise.resolve({ value: void 0, done: true });
974
+ const buffered = queue.shift();
975
+ if (buffered) return Promise.resolve({ value: buffered, done: false });
976
+ return new Promise((resolve) => {
977
+ pendingResolve = resolve;
978
+ });
979
+ },
980
+ return() {
981
+ stopped = true;
982
+ self.watchers.delete(dispatch);
983
+ if (pendingResolve) {
984
+ const r = pendingResolve;
985
+ pendingResolve = null;
986
+ r({ value: void 0, done: true });
987
+ }
988
+ return Promise.resolve({ value: void 0, done: true });
989
+ }
990
+ };
991
+ }
992
+ };
993
+ }
994
+ /** Shut down all watch iterators. */
995
+ close() {
996
+ this.closed = true;
997
+ const snapshot = Array.from(this.watchers);
998
+ for (const w of snapshot) {
999
+ try {
1000
+ w({
1001
+ seq: -1,
1002
+ op: "delete",
1003
+ ref: { org: "", type: "view", name: "_close" },
1004
+ hash: null,
1005
+ parentHash: null,
1006
+ actor: "system",
1007
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1008
+ source: "sys-metadata-repo-close"
1009
+ });
1010
+ } catch {
1011
+ }
1012
+ }
1013
+ this.watchers.clear();
1014
+ }
1015
+ // ── helpers ─────────────────────────────────────────────────────────
1016
+ assertOpen() {
1017
+ if (this.closed) throw new Error("SysMetadataRepository is closed");
1018
+ }
1019
+ assertAllowed(type) {
1020
+ if (!OVERLAY_ALLOWED_TYPES.has(type)) {
1021
+ const err = new Error(
1022
+ `[not_overridable] '${type}' is not allowOrgOverride in the registry. Allowed: ${Array.from(OVERLAY_ALLOWED_TYPES).join(", ")}.`
1023
+ );
1024
+ err.code = "not_overridable";
1025
+ err.status = 403;
1026
+ throw err;
1027
+ }
1028
+ }
1029
+ whereFor(ref) {
1030
+ return {
1031
+ type: ref.type,
1032
+ name: ref.name,
1033
+ organization_id: this.organizationId,
1034
+ state: "active"
1035
+ };
1036
+ }
1037
+ fullRef(ref) {
1038
+ return {
1039
+ org: this.orgLabel,
1040
+ type: ref.type,
1041
+ name: ref.name
1042
+ };
1043
+ }
1044
+ rowToItem(ref, row) {
1045
+ const body = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata ?? {};
1046
+ const hash = row.checksum ?? hashSpec(body);
1047
+ return {
1048
+ ref: this.fullRef(ref),
1049
+ body,
1050
+ hash,
1051
+ parentHash: null,
1052
+ authoredBy: row.updated_by ?? row.created_by ?? "unknown",
1053
+ authoredAt: row.updated_at ?? row.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1054
+ message: void 0,
1055
+ seq: this.seqCounter
1056
+ };
1057
+ }
1058
+ broadcast(evt) {
1059
+ for (const w of Array.from(this.watchers)) {
1060
+ try {
1061
+ w(evt);
1062
+ } catch {
1063
+ }
1064
+ }
1065
+ }
1066
+ matchesFilter(evt, filter) {
1067
+ if (filter.type && evt.ref.type !== filter.type) return false;
1068
+ if (filter.name && evt.ref.name !== filter.name) return false;
1069
+ if (filter.org && evt.ref.org !== filter.org) return false;
1070
+ return true;
1071
+ }
1072
+ /**
1073
+ * Per-org monotonic event sequence. Reads `MAX(event_seq) + 1` from
1074
+ * `sys_metadata_history` scoped by `organization_id`. MUST be called
1075
+ * inside a transaction (the only caller is the put/delete txn body) —
1076
+ * concurrent writers in the same org race otherwise.
1077
+ */
1078
+ async nextEventSeq(ctx) {
1079
+ try {
1080
+ const rows = await this.engine.find(this.historyTable, {
1081
+ where: { organization_id: this.organizationId },
1082
+ context: ctx
1083
+ });
1084
+ let max = 0;
1085
+ for (const row of rows) {
1086
+ const v = typeof row.event_seq === "number" ? row.event_seq : 0;
1087
+ if (v > max) max = v;
1088
+ }
1089
+ return max + 1;
1090
+ } catch {
1091
+ return 1;
1092
+ }
1093
+ }
1094
+ /**
1095
+ * Per-(org,type,name) lineage counter. Reads from history (not from
1096
+ * `sys_metadata.version`) so delete + recreate continues incrementing
1097
+ * instead of restarting at 1.
1098
+ */
1099
+ async nextItemVersion(ref, ctx) {
1100
+ try {
1101
+ const rows = await this.engine.find(this.historyTable, {
1102
+ where: {
1103
+ organization_id: this.organizationId,
1104
+ type: ref.type,
1105
+ name: ref.name
1106
+ },
1107
+ context: ctx
1108
+ });
1109
+ let max = 0;
1110
+ for (const row of rows) {
1111
+ const v = typeof row.version === "number" ? row.version : 0;
1112
+ if (v > max) max = v;
1113
+ }
1114
+ return max + 1;
1115
+ } catch {
1116
+ return 1;
1117
+ }
1118
+ }
1119
+ /** Lightweight UUID-ish id for history rows; sufficient for an audit log. */
1120
+ uuid() {
1121
+ if (typeof globalThis.crypto?.randomUUID === "function") {
1122
+ return globalThis.crypto.randomUUID();
1123
+ }
1124
+ return `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1125
+ }
1126
+ };
1127
+
594
1128
  // src/protocol.ts
1129
+ import { ConflictError as ConflictError2 } from "@objectstack/metadata-core";
595
1130
  import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
596
1131
  import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from "@objectstack/spec/shared";
597
1132
  import { ListViewSchema, FormViewSchema, DashboardSchema } from "@objectstack/spec/ui";
598
- import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
1133
+ import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2 } from "@objectstack/spec/kernel";
599
1134
  var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
600
1135
  function resolveOverlaySchema(type, item) {
601
1136
  const singular = PLURAL_TO_SINGULAR[type] ?? type;
@@ -657,6 +1192,13 @@ var SERVICE_CONFIG = {
657
1192
  };
658
1193
  var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
659
1194
  constructor(engine, getServicesRegistry, getFeedService, projectId) {
1195
+ /**
1196
+ * Lazily-instantiated SysMetadataRepository per organization. Keyed by
1197
+ * `${organizationId ?? '__env__'}`. Repositories are stateful — they
1198
+ * carry the per-org `seqCounter` and watch subscribers — so we cache
1199
+ * them rather than constructing one per call.
1200
+ */
1201
+ this.overlayRepos = /* @__PURE__ */ new Map();
660
1202
  /**
661
1203
  * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
662
1204
  * on `sys_metadata`. ADR-0005: scopes overlays by
@@ -672,6 +1214,24 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
672
1214
  this.getFeedService = getFeedService;
673
1215
  this.projectId = projectId;
674
1216
  }
1217
+ /**
1218
+ * Lazily obtain a SysMetadataRepository for the given organization.
1219
+ * Env-wide overlays (organizationId == null) share a singleton under
1220
+ * the `__env__` key.
1221
+ */
1222
+ getOverlayRepo(organizationId) {
1223
+ const key = organizationId ?? "__env__";
1224
+ let repo = this.overlayRepos.get(key);
1225
+ if (!repo) {
1226
+ repo = new SysMetadataRepository({
1227
+ engine: this.engine,
1228
+ organizationId,
1229
+ orgLabel: organizationId ?? "env"
1230
+ });
1231
+ this.overlayRepos.set(key, repo);
1232
+ }
1233
+ return repo;
1234
+ }
675
1235
  async ensureOverlayIndex() {
676
1236
  if (this.overlayIndexEnsured) return;
677
1237
  this.overlayIndexEnsured = true;
@@ -961,23 +1521,34 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
961
1521
  }
962
1522
  } catch {
963
1523
  }
964
- if (item === void 0) {
965
- item = this.engine.registry.getItem(request.type, request.name);
966
- if (item === void 0) {
967
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
968
- if (alt) item = this.engine.registry.getItem(alt, request.name);
969
- }
970
- }
971
1524
  if (item === void 0) {
972
1525
  try {
973
1526
  const services = this.getServicesRegistry?.();
974
1527
  const metadataService = services?.get("metadata");
975
1528
  if (metadataService && typeof metadataService.get === "function") {
976
- item = await metadataService.get(request.type, request.name);
1529
+ const fromService = await metadataService.get(request.type, request.name);
1530
+ if (fromService !== void 0 && fromService !== null) {
1531
+ item = fromService;
1532
+ } else {
1533
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
1534
+ if (alt) {
1535
+ const altFromService = await metadataService.get(alt, request.name);
1536
+ if (altFromService !== void 0 && altFromService !== null) {
1537
+ item = altFromService;
1538
+ }
1539
+ }
1540
+ }
977
1541
  }
978
1542
  } catch {
979
1543
  }
980
1544
  }
1545
+ if (item === void 0) {
1546
+ item = this.engine.registry.getItem(request.type, request.name);
1547
+ if (item === void 0) {
1548
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
1549
+ if (alt) item = this.engine.registry.getItem(alt, request.name);
1550
+ }
1551
+ }
981
1552
  return {
982
1553
  type: request.type,
983
1554
  name: request.name,
@@ -1860,6 +2431,27 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1860
2431
  const singular = PLURAL_TO_SINGULAR[type] ?? type;
1861
2432
  return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
1862
2433
  }
2434
+ /**
2435
+ * Mirror an object-type overlay write into the in-memory engine
2436
+ * registry so subsequent CRUD finds the new schema. Idempotent and
2437
+ * safe to call after a successful persistence call. For the legacy
2438
+ * write path this is invoked BEFORE persistence (historical behavior
2439
+ * preserved); for the PR-10d.3 repository path it is invoked only
2440
+ * AFTER `put()` resolves successfully, so a failed write — DB error,
2441
+ * optimistic-lock conflict, validation failure — never leaks a
2442
+ * stale schema into the registry.
2443
+ */
2444
+ applyObjectRegistryMutation(request) {
2445
+ if (request.type !== "object" && request.type !== "objects") return;
2446
+ this.engine.registry.registerItem(request.type, request.item, "name");
2447
+ try {
2448
+ this.engine.registry.registerObject(request.item, "sys_metadata");
2449
+ } catch (err) {
2450
+ console.warn(
2451
+ `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
2452
+ );
2453
+ }
2454
+ }
1863
2455
  async saveMetaItem(request) {
1864
2456
  if (!request.item) {
1865
2457
  throw new Error("Item data is required");
@@ -1894,17 +2486,51 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1894
2486
  }
1895
2487
  }
1896
2488
  }
1897
- if (request.type === "object" || request.type === "objects") {
1898
- this.engine.registry.registerItem(request.type, request.item, "name");
2489
+ await this.ensureOverlayIndex();
2490
+ const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type;
2491
+ if (_ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo)) {
2492
+ const orgId = request.organizationId ?? null;
2493
+ const repo = this.getOverlayRepo(orgId);
2494
+ const ref = {
2495
+ type: singularTypeForRepo,
2496
+ name: request.name,
2497
+ org: orgId ?? "env"
2498
+ };
2499
+ let parentVersion;
2500
+ if (request.parentVersion !== void 0) {
2501
+ parentVersion = request.parentVersion;
2502
+ } else {
2503
+ const current = await repo.get(ref);
2504
+ parentVersion = current?.hash ?? null;
2505
+ }
1899
2506
  try {
1900
- this.engine.registry.registerObject(request.item, "sys_metadata");
2507
+ const result = await repo.put(ref, request.item, {
2508
+ parentVersion,
2509
+ actor: request.actor ?? "system",
2510
+ source: "protocol.saveMetaItem"
2511
+ });
2512
+ this.applyObjectRegistryMutation(request);
2513
+ return {
2514
+ success: true,
2515
+ version: result.version,
2516
+ seq: result.seq,
2517
+ message: orgId ? `Saved customization overlay (org=${orgId}) \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]` : `Saved customization overlay (env-wide) \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
2518
+ };
1901
2519
  } catch (err) {
1902
- console.warn(
1903
- `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1904
- );
2520
+ if (err instanceof ConflictError2) {
2521
+ const conflict = new Error(
2522
+ `[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
2523
+ );
2524
+ conflict.code = "metadata_conflict";
2525
+ conflict.status = 409;
2526
+ conflict.expectedParent = err.expectedParent;
2527
+ conflict.actualHead = err.actualHead;
2528
+ throw conflict;
2529
+ }
2530
+ throw err;
1905
2531
  }
1906
2532
  }
1907
- await this.ensureOverlayIndex();
2533
+ this.applyObjectRegistryMutation(request);
1908
2534
  try {
1909
2535
  const now = (/* @__PURE__ */ new Date()).toISOString();
1910
2536
  const orgId = request.organizationId ?? null;
@@ -1961,6 +2587,35 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1961
2587
  throw err;
1962
2588
  }
1963
2589
  }
2590
+ /**
2591
+ * Yield the durable change-log for a single metadata item — every
2592
+ * put/delete recorded in `sys_metadata_history` for `(org, type, name)`,
2593
+ * in event_seq order. Powers the Studio "History" tab and any
2594
+ * client-side audit timeline.
2595
+ *
2596
+ * Returns `[]` for non-overlay-allowed types (the legacy raw-engine
2597
+ * path doesn't record history) instead of throwing — callers can treat
2598
+ * "no history" uniformly.
2599
+ */
2600
+ async historyMetaItem(request) {
2601
+ const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
2602
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType)) {
2603
+ return { events: [] };
2604
+ }
2605
+ const orgId = request.organizationId ?? null;
2606
+ const repo = this.getOverlayRepo(orgId);
2607
+ const ref = {
2608
+ type: singularType,
2609
+ name: request.name,
2610
+ org: orgId ?? "env"
2611
+ };
2612
+ const events = [];
2613
+ const opts = {};
2614
+ if (request.sinceSeq !== void 0) opts.sinceSeq = request.sinceSeq;
2615
+ if (request.limit !== void 0) opts.limit = request.limit;
2616
+ for await (const ev of repo.history(ref, opts)) events.push(ev);
2617
+ return { events };
2618
+ }
1964
2619
  /**
1965
2620
  * Remove a customization overlay row for the given metadata item, so the
1966
2621
  * next read falls through to the artifact-loaded default. Implements the
@@ -1976,6 +2631,66 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1976
2631
  err.status = 403;
1977
2632
  throw err;
1978
2633
  }
2634
+ const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type;
2635
+ const useRepoPath = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
2636
+ if (useRepoPath) {
2637
+ const orgId = request.organizationId ?? null;
2638
+ const repo = this.getOverlayRepo(orgId);
2639
+ const ref = {
2640
+ type: singularTypeForRepo,
2641
+ name: request.name,
2642
+ org: orgId ?? "env"
2643
+ };
2644
+ try {
2645
+ const current = await repo.get(ref);
2646
+ if (!current) {
2647
+ return {
2648
+ success: true,
2649
+ reset: false,
2650
+ message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
2651
+ };
2652
+ }
2653
+ const parentVersion = request.parentVersion !== void 0 ? request.parentVersion ?? current.hash : current.hash;
2654
+ const result = await repo.delete(ref, {
2655
+ parentVersion,
2656
+ actor: request.actor ?? "system",
2657
+ source: "protocol.deleteMetaItem"
2658
+ });
2659
+ if (this.projectId === void 0) {
2660
+ try {
2661
+ const services = this.getServicesRegistry?.();
2662
+ const metadataService = services?.get("metadata");
2663
+ if (metadataService && typeof metadataService.get === "function") {
2664
+ const artifactItem = await metadataService.get(request.type, request.name);
2665
+ if (artifactItem !== void 0) {
2666
+ this.engine.registry.registerItem(request.type, artifactItem, "name");
2667
+ }
2668
+ }
2669
+ } catch {
2670
+ }
2671
+ }
2672
+ return {
2673
+ success: true,
2674
+ reset: true,
2675
+ seq: result.seq,
2676
+ message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`
2677
+ };
2678
+ } catch (err) {
2679
+ if (err instanceof ConflictError2) {
2680
+ const conflict = new Error(
2681
+ `[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
2682
+ );
2683
+ conflict.code = "metadata_conflict";
2684
+ conflict.status = 409;
2685
+ conflict.expectedParent = err.expectedParent;
2686
+ conflict.actualHead = err.actualHead;
2687
+ throw conflict;
2688
+ }
2689
+ const e = new Error(`Failed to delete customization overlay: ${err.message ?? err}`);
2690
+ e.status = err?.status ?? 500;
2691
+ throw e;
2692
+ }
2693
+ }
1979
2694
  const scopedWhere = {
1980
2695
  type: request.type,
1981
2696
  name: request.name,
@@ -2199,7 +2914,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2199
2914
  */
2200
2915
  _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
2201
2916
  const out = /* @__PURE__ */ new Set();
2202
- for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
2917
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY2) {
2203
2918
  if (!entry.allowOrgOverride) continue;
2204
2919
  out.add(entry.type);
2205
2920
  const plural = SINGULAR_TO_PLURAL[entry.type];
@@ -4786,6 +5501,8 @@ var ObjectQLPlugin = class {
4786
5501
  */
4787
5502
  this.startupTimeout = 12e4;
4788
5503
  this.skipSchemaSync = false;
5504
+ /** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
5505
+ this.metadataUnsubscribes = [];
4789
5506
  this.init = async (ctx) => {
4790
5507
  if (!this.ql) {
4791
5508
  const hostCtx = { ...this.hostContext, logger: ctx.logger };
@@ -4832,6 +5549,9 @@ var ObjectQLPlugin = class {
4832
5549
  if (metadataService && typeof metadataService.loadMany === "function" && this.ql) {
4833
5550
  await this.loadMetadataFromService(metadataService, ctx);
4834
5551
  }
5552
+ if (metadataService && typeof metadataService.subscribe === "function" && this.ql) {
5553
+ this.subscribeToMetadataEvents(metadataService, ctx);
5554
+ }
4835
5555
  } catch (e) {
4836
5556
  ctx.logger.debug("No external metadata service to sync from");
4837
5557
  }
@@ -4885,6 +5605,16 @@ var ObjectQLPlugin = class {
4885
5605
  objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
4886
5606
  });
4887
5607
  };
5608
+ this.stop = async (ctx) => {
5609
+ for (const unsub of this.metadataUnsubscribes) {
5610
+ try {
5611
+ unsub();
5612
+ } catch (e) {
5613
+ ctx.logger.debug("[ObjectQLPlugin] metadata-event unsubscribe failed", { error: e?.message });
5614
+ }
5615
+ }
5616
+ this.metadataUnsubscribes = [];
5617
+ };
4888
5618
  if (qlOrOptions instanceof ObjectQL) {
4889
5619
  this.ql = qlOrOptions;
4890
5620
  this.hostContext = hostContext;
@@ -4901,6 +5631,62 @@ var ObjectQLPlugin = class {
4901
5631
  }
4902
5632
  this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
4903
5633
  }
5634
+ /**
5635
+ * Subscribe to `object` metadata events from the metadata service and
5636
+ * invalidate the SchemaRegistry merge cache on each event (ADR-0008
5637
+ * PR-7). For create/update we also re-load the affected object from
5638
+ * the metadata service so subsequent reads see the new definition;
5639
+ * for delete we unregister it from every contributing package.
5640
+ *
5641
+ * Events are filtered to the canonical `object` type — view/dashboard
5642
+ * /flow edits go through their own consumers (Studio SSE, REST cache).
5643
+ *
5644
+ * Stored unsubscribe handle is invoked from {@link stop}.
5645
+ */
5646
+ subscribeToMetadataEvents(metadataService, ctx) {
5647
+ const handler = async (evt) => {
5648
+ if (!this.ql) return;
5649
+ const name = evt?.name ?? "";
5650
+ if (!name) return;
5651
+ const eventType = evt?.type === "added" || evt?.type === "changed" || evt?.type === "deleted" ? evt.type : "changed";
5652
+ try {
5653
+ this.ql.registry.invalidate(name);
5654
+ if (eventType === "deleted") {
5655
+ ctx.logger.info("[ObjectQLPlugin] object metadata deleted \u2014 registry invalidated", { name });
5656
+ return;
5657
+ }
5658
+ const fresh = typeof metadataService.get === "function" ? await metadataService.get("object", name) : void 0;
5659
+ if (fresh && typeof fresh === "object") {
5660
+ const packageId = fresh._packageId ?? "metadata-service";
5661
+ const namespace = fresh.namespace;
5662
+ this.ql.registry.registerObject(
5663
+ fresh,
5664
+ packageId,
5665
+ namespace,
5666
+ "own"
5667
+ );
5668
+ ctx.logger.info("[ObjectQLPlugin] object metadata updated \u2014 registry refreshed", {
5669
+ name,
5670
+ packageId
5671
+ });
5672
+ } else {
5673
+ ctx.logger.debug("[ObjectQLPlugin] object event received but metadata service has no fresh body", { name });
5674
+ }
5675
+ } catch (e) {
5676
+ ctx.logger.warn("[ObjectQLPlugin] metadata event handler failed", {
5677
+ name,
5678
+ error: e?.message
5679
+ });
5680
+ }
5681
+ };
5682
+ const unsub = metadataService.subscribe("object", handler);
5683
+ if (typeof unsub === "function") {
5684
+ this.metadataUnsubscribes.push(unsub);
5685
+ } else if (unsub && typeof unsub.unsubscribe === "function") {
5686
+ this.metadataUnsubscribes.push(() => unsub.unsubscribe());
5687
+ }
5688
+ ctx.logger.info("[ObjectQLPlugin] subscribed to object metadata events (ADR-0008 PR-7)");
5689
+ }
4904
5690
  /**
4905
5691
  * Register built-in audit hooks for auto-stamping created_by/updated_by
4906
5692
  * and fetching previousData for update/delete operations. These are
@@ -5398,6 +6184,7 @@ export {
5398
6184
  RESERVED_NAMESPACES,
5399
6185
  SchemaRegistry,
5400
6186
  ScopedContext,
6187
+ SysMetadataRepository,
5401
6188
  ValidationError,
5402
6189
  applyInMemoryAggregation,
5403
6190
  applySystemFields,