@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.js CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  RESERVED_NAMESPACES: () => RESERVED_NAMESPACES,
32
32
  SchemaRegistry: () => SchemaRegistry,
33
33
  ScopedContext: () => ScopedContext,
34
+ SysMetadataRepository: () => SysMetadataRepository,
34
35
  ValidationError: () => ValidationError,
35
36
  applyInMemoryAggregation: () => applyInMemoryAggregation,
36
37
  applySystemFields: () => applySystemFields,
@@ -628,6 +629,34 @@ var SchemaRegistry = class {
628
629
  // ==========================================
629
630
  // Reset (for testing)
630
631
  // ==========================================
632
+ /**
633
+ * Invalidate the merged-schema cache for a single FQN (or short name).
634
+ *
635
+ * Call this from event-driven consumers (ADR-0008 M0 PR-7) when an
636
+ * upstream metadata change makes the cached merged definition stale.
637
+ * The contributor list is preserved — only the cached merge result is
638
+ * dropped, so the next `resolveObject(fqn)` recomputes from scratch.
639
+ *
640
+ * Accepts either an FQN (`acme__contact`) or a bare short name
641
+ * (`contact`); for the latter, all entries whose suffix matches the
642
+ * name are invalidated.
643
+ */
644
+ invalidate(fqnOrName) {
645
+ if (this.mergedObjectCache.has(fqnOrName)) {
646
+ this.mergedObjectCache.delete(fqnOrName);
647
+ return;
648
+ }
649
+ const suffix = `__${fqnOrName}`;
650
+ for (const fqn of Array.from(this.mergedObjectCache.keys())) {
651
+ if (fqn === fqnOrName || fqn.endsWith(suffix)) {
652
+ this.mergedObjectCache.delete(fqn);
653
+ }
654
+ }
655
+ }
656
+ /** Drop every entry from the merged-schema cache. */
657
+ invalidateAll() {
658
+ this.mergedObjectCache.clear();
659
+ }
631
660
  /**
632
661
  * Clear all registry state. Use only for testing.
633
662
  */
@@ -640,11 +669,518 @@ var SchemaRegistry = class {
640
669
  }
641
670
  };
642
671
 
672
+ // src/sys-metadata-repository.ts
673
+ var import_metadata_core = require("@objectstack/metadata-core");
674
+ var import_kernel2 = require("@objectstack/spec/kernel");
675
+ var OVERLAY_ALLOWED_TYPES = new Set(
676
+ import_kernel2.DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)
677
+ );
678
+ var SysMetadataRepository = class {
679
+ constructor(opts) {
680
+ /**
681
+ * Local seq counter for in-memory watch() event broadcasts. Mirrors
682
+ * the durable `event_seq` we write into `sys_metadata_history` on
683
+ * each successful put/delete — assigned AFTER the transaction commits
684
+ * so we never broadcast events that got rolled back.
685
+ */
686
+ this.seqCounter = 0;
687
+ this.watchers = /* @__PURE__ */ new Set();
688
+ this.closed = false;
689
+ /** Table name for the durable event log. */
690
+ this.historyTable = "sys_metadata_history";
691
+ this.engine = opts.engine;
692
+ this.organizationId = opts.organizationId ?? null;
693
+ this.orgLabel = opts.orgLabel ?? (opts.organizationId ?? "system");
694
+ }
695
+ /**
696
+ * Run `cb` inside `engine.transaction(...)` if the engine supports it,
697
+ * otherwise fall through to a direct call. Matches the real
698
+ * `ObjectQL.transaction` semantics — in-memory drivers (and our test
699
+ * fakes) get no rollback, which is acceptable because production
700
+ * always runs on a SQL driver with real ACID.
701
+ */
702
+ async withTxn(cb) {
703
+ if (typeof this.engine.transaction === "function") {
704
+ return this.engine.transaction(cb);
705
+ }
706
+ return cb(void 0);
707
+ }
708
+ /**
709
+ * Read the current overlay row. Returns null if no row exists —
710
+ * callers (e.g. LayeredRepository) fall through to lower layers.
711
+ */
712
+ async get(ref) {
713
+ this.assertOpen();
714
+ const row = await this.engine.findOne("sys_metadata", {
715
+ where: this.whereFor(ref)
716
+ });
717
+ if (!row) return null;
718
+ return this.rowToItem(ref, row);
719
+ }
720
+ /**
721
+ * Resolve a historical version by content hash (ADR-0009).
722
+ *
723
+ * Looks up `sys_metadata_history` by `(organization_id, type, name,
724
+ * checksum)`. Returns null if no row matches. `executionPinned` types
725
+ * are guaranteed to find their body here because history GC skips
726
+ * them.
727
+ */
728
+ async getByHash(ref, hash) {
729
+ this.assertOpen();
730
+ const full = this.fullRef(ref);
731
+ const row = await this.engine.findOne(this.historyTable, {
732
+ where: {
733
+ organization_id: this.organizationId,
734
+ type: full.type,
735
+ name: full.name,
736
+ checksum: hash
737
+ }
738
+ });
739
+ if (!row) return null;
740
+ const rawBody = row.metadata;
741
+ if (rawBody === null || rawBody === void 0) {
742
+ return null;
743
+ }
744
+ const body = typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody;
745
+ return {
746
+ ref: { ...full, version: void 0 },
747
+ body,
748
+ hash,
749
+ parentHash: row.previous_checksum ?? null,
750
+ authoredBy: row.recorded_by ?? "unknown",
751
+ authoredAt: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
752
+ message: row.change_note ?? void 0,
753
+ seq: row.event_seq ?? 0
754
+ };
755
+ }
756
+ async put(ref, spec, opts) {
757
+ this.assertOpen();
758
+ this.assertAllowed(ref.type);
759
+ const body = spec ?? {};
760
+ const hash = (0, import_metadata_core.hashSpec)(body);
761
+ const result = await this.withTxn(async (ctx) => {
762
+ const existing = await this.engine.findOne("sys_metadata", {
763
+ where: this.whereFor(ref),
764
+ context: ctx
765
+ });
766
+ const existingHash = existing?.checksum ?? null;
767
+ if (opts.parentVersion !== existingHash) {
768
+ throw new import_metadata_core.ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
769
+ }
770
+ if (existing && existingHash === hash) {
771
+ const item2 = this.rowToItem(ref, existing);
772
+ return { skipped: true, version: hash, seq: item2.seq, item: item2 };
773
+ }
774
+ const now = (/* @__PURE__ */ new Date()).toISOString();
775
+ const op = existing ? "update" : "create";
776
+ const version = await this.nextItemVersion(ref, ctx);
777
+ const eventSeq = await this.nextEventSeq(ctx);
778
+ const parentRowData = {
779
+ type: ref.type,
780
+ name: ref.name,
781
+ organization_id: this.organizationId,
782
+ metadata: JSON.stringify(body),
783
+ checksum: hash,
784
+ state: "active",
785
+ version,
786
+ updated_at: now
787
+ };
788
+ if (existing) {
789
+ const existingId = existing.id;
790
+ if (existingId === void 0) {
791
+ throw new Error(
792
+ `SysMetadataRepository.put: existing row for ${ref.type}/${ref.name} has no id column`
793
+ );
794
+ }
795
+ await this.engine.update("sys_metadata", parentRowData, {
796
+ where: { id: existingId },
797
+ context: ctx
798
+ });
799
+ } else {
800
+ parentRowData.created_at = now;
801
+ await this.engine.insert("sys_metadata", parentRowData, { context: ctx });
802
+ }
803
+ await this.engine.insert(
804
+ this.historyTable,
805
+ {
806
+ id: this.uuid(),
807
+ event_seq: eventSeq,
808
+ type: ref.type,
809
+ name: ref.name,
810
+ version,
811
+ operation_type: op,
812
+ metadata: JSON.stringify(body),
813
+ checksum: hash,
814
+ previous_checksum: existingHash,
815
+ change_note: opts.message,
816
+ source: opts.source ?? "sys-metadata-repo",
817
+ organization_id: this.organizationId,
818
+ recorded_by: opts.actor,
819
+ recorded_at: now
820
+ },
821
+ { context: ctx }
822
+ );
823
+ const item = {
824
+ ref: this.fullRef(ref),
825
+ body,
826
+ hash,
827
+ parentHash: existingHash,
828
+ authoredBy: opts.actor,
829
+ authoredAt: now,
830
+ message: opts.message,
831
+ seq: eventSeq
832
+ };
833
+ return {
834
+ skipped: false,
835
+ version: hash,
836
+ seq: eventSeq,
837
+ item,
838
+ op,
839
+ existingHash,
840
+ now,
841
+ source: opts.source ?? "sys-metadata-repo",
842
+ message: opts.message,
843
+ actor: opts.actor
844
+ };
845
+ });
846
+ if (result.skipped) {
847
+ return { version: result.version, seq: result.seq, item: result.item };
848
+ }
849
+ this.seqCounter = result.seq;
850
+ this.broadcast({
851
+ seq: result.seq,
852
+ op: result.op,
853
+ ref: this.fullRef(ref),
854
+ hash: result.version,
855
+ parentHash: result.existingHash,
856
+ actor: result.actor,
857
+ message: result.message,
858
+ ts: result.now,
859
+ source: result.source
860
+ });
861
+ return { version: result.version, seq: result.seq, item: result.item };
862
+ }
863
+ async delete(ref, opts) {
864
+ this.assertOpen();
865
+ this.assertAllowed(ref.type);
866
+ const result = await this.withTxn(async (ctx) => {
867
+ const existing = await this.engine.findOne("sys_metadata", {
868
+ where: this.whereFor(ref),
869
+ context: ctx
870
+ });
871
+ if (!existing) {
872
+ throw new import_metadata_core.ConflictError(this.fullRef(ref), opts.parentVersion, null);
873
+ }
874
+ const existingHash = existing.checksum ?? null;
875
+ if (opts.parentVersion !== existingHash) {
876
+ throw new import_metadata_core.ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
877
+ }
878
+ const existingId = existing.id;
879
+ if (existingId === void 0) {
880
+ throw new Error(
881
+ `SysMetadataRepository.delete: existing row for ${ref.type}/${ref.name} has no id column`
882
+ );
883
+ }
884
+ const now = (/* @__PURE__ */ new Date()).toISOString();
885
+ const version = await this.nextItemVersion(ref, ctx);
886
+ const eventSeq = await this.nextEventSeq(ctx);
887
+ await this.engine.delete("sys_metadata", {
888
+ where: { id: existingId },
889
+ context: ctx
890
+ });
891
+ await this.engine.insert(
892
+ this.historyTable,
893
+ {
894
+ id: this.uuid(),
895
+ event_seq: eventSeq,
896
+ type: ref.type,
897
+ name: ref.name,
898
+ version,
899
+ operation_type: "delete",
900
+ metadata: null,
901
+ checksum: null,
902
+ previous_checksum: existingHash,
903
+ change_note: opts.message,
904
+ source: opts.source ?? "sys-metadata-repo",
905
+ organization_id: this.organizationId,
906
+ recorded_by: opts.actor,
907
+ recorded_at: now
908
+ },
909
+ { context: ctx }
910
+ );
911
+ return {
912
+ eventSeq,
913
+ existingHash,
914
+ now,
915
+ source: opts.source ?? "sys-metadata-repo",
916
+ message: opts.message,
917
+ actor: opts.actor
918
+ };
919
+ });
920
+ this.seqCounter = result.eventSeq;
921
+ this.broadcast({
922
+ seq: result.eventSeq,
923
+ op: "delete",
924
+ ref: this.fullRef(ref),
925
+ hash: null,
926
+ parentHash: result.existingHash,
927
+ actor: result.actor,
928
+ message: result.message,
929
+ ts: result.now,
930
+ source: result.source
931
+ });
932
+ return { seq: result.eventSeq };
933
+ }
934
+ async *list(filter) {
935
+ this.assertOpen();
936
+ const where = {
937
+ organization_id: this.organizationId,
938
+ state: "active"
939
+ };
940
+ if (filter.type) where.type = filter.type;
941
+ const rows = await this.engine.find("sys_metadata", {
942
+ where,
943
+ limit: filter.limit
944
+ });
945
+ for (const row of rows) {
946
+ if (filter.nameContains && !String(row.name).includes(filter.nameContains)) continue;
947
+ const item = this.rowToItem(
948
+ { ...this.fullRef({ type: row.type, name: row.name }) },
949
+ row
950
+ );
951
+ const { body, ...header } = item;
952
+ yield header;
953
+ }
954
+ }
955
+ /**
956
+ * Yield every history event for `(org, type?, name?)` from the
957
+ * durable log, ordered by per-(type,name) `version` ascending. When
958
+ * `filter.type`/`filter.name` are unset the consumer gets the full
959
+ * org-scoped event stream — still ordered by version within each
960
+ * (type,name) bucket, then by `recorded_at` across buckets (we sort
961
+ * client-side because the test engine doesn't honor `orderBy`).
962
+ */
963
+ async *history(ref, opts) {
964
+ this.assertOpen();
965
+ const full = this.fullRef(ref);
966
+ const where = {
967
+ organization_id: this.organizationId,
968
+ type: full.type,
969
+ name: full.name
970
+ };
971
+ const rows = await this.engine.find(this.historyTable, { where });
972
+ rows.sort((a, b) => {
973
+ const va = typeof a.event_seq === "number" ? a.event_seq : 0;
974
+ const vb = typeof b.event_seq === "number" ? b.event_seq : 0;
975
+ return va - vb;
976
+ });
977
+ let yielded = 0;
978
+ for (const row of rows) {
979
+ if (opts?.sinceSeq !== void 0 && (row.event_seq ?? 0) <= opts.sinceSeq) continue;
980
+ if (opts?.limit !== void 0 && yielded >= opts.limit) break;
981
+ yielded++;
982
+ yield {
983
+ seq: row.event_seq ?? 0,
984
+ op: row.operation_type ?? "update",
985
+ ref: full,
986
+ hash: row.checksum ?? null,
987
+ parentHash: row.previous_checksum ?? null,
988
+ actor: row.recorded_by ?? "unknown",
989
+ message: row.change_note ?? void 0,
990
+ ts: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
991
+ source: row.source ?? "sys-metadata-repo"
992
+ };
993
+ }
994
+ }
995
+ /**
996
+ * Live event stream. Fires for every successful put/delete on THIS
997
+ * instance — cross-replica fan-out is M1. Manual AsyncIterator (not
998
+ * an async generator) so we can deterministically tear down via
999
+ * `iter.return()`, matching the pattern used by InMemoryRepository.
1000
+ */
1001
+ watch(filter, since) {
1002
+ const self = this;
1003
+ return {
1004
+ [Symbol.asyncIterator]: () => {
1005
+ const queue = [];
1006
+ let pendingResolve = null;
1007
+ let stopped = false;
1008
+ const dispatch = (evt) => {
1009
+ if (stopped) return;
1010
+ if (!self.matchesFilter(evt, filter)) return;
1011
+ if (since !== void 0 && evt.seq <= since) return;
1012
+ if (pendingResolve) {
1013
+ const r = pendingResolve;
1014
+ pendingResolve = null;
1015
+ r({ value: evt, done: false });
1016
+ } else {
1017
+ queue.push(evt);
1018
+ }
1019
+ };
1020
+ self.watchers.add(dispatch);
1021
+ return {
1022
+ next() {
1023
+ if (stopped) return Promise.resolve({ value: void 0, done: true });
1024
+ const buffered = queue.shift();
1025
+ if (buffered) return Promise.resolve({ value: buffered, done: false });
1026
+ return new Promise((resolve) => {
1027
+ pendingResolve = resolve;
1028
+ });
1029
+ },
1030
+ return() {
1031
+ stopped = true;
1032
+ self.watchers.delete(dispatch);
1033
+ if (pendingResolve) {
1034
+ const r = pendingResolve;
1035
+ pendingResolve = null;
1036
+ r({ value: void 0, done: true });
1037
+ }
1038
+ return Promise.resolve({ value: void 0, done: true });
1039
+ }
1040
+ };
1041
+ }
1042
+ };
1043
+ }
1044
+ /** Shut down all watch iterators. */
1045
+ close() {
1046
+ this.closed = true;
1047
+ const snapshot = Array.from(this.watchers);
1048
+ for (const w of snapshot) {
1049
+ try {
1050
+ w({
1051
+ seq: -1,
1052
+ op: "delete",
1053
+ ref: { org: "", type: "view", name: "_close" },
1054
+ hash: null,
1055
+ parentHash: null,
1056
+ actor: "system",
1057
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1058
+ source: "sys-metadata-repo-close"
1059
+ });
1060
+ } catch {
1061
+ }
1062
+ }
1063
+ this.watchers.clear();
1064
+ }
1065
+ // ── helpers ─────────────────────────────────────────────────────────
1066
+ assertOpen() {
1067
+ if (this.closed) throw new Error("SysMetadataRepository is closed");
1068
+ }
1069
+ assertAllowed(type) {
1070
+ if (!OVERLAY_ALLOWED_TYPES.has(type)) {
1071
+ const err = new Error(
1072
+ `[not_overridable] '${type}' is not allowOrgOverride in the registry. Allowed: ${Array.from(OVERLAY_ALLOWED_TYPES).join(", ")}.`
1073
+ );
1074
+ err.code = "not_overridable";
1075
+ err.status = 403;
1076
+ throw err;
1077
+ }
1078
+ }
1079
+ whereFor(ref) {
1080
+ return {
1081
+ type: ref.type,
1082
+ name: ref.name,
1083
+ organization_id: this.organizationId,
1084
+ state: "active"
1085
+ };
1086
+ }
1087
+ fullRef(ref) {
1088
+ return {
1089
+ org: this.orgLabel,
1090
+ type: ref.type,
1091
+ name: ref.name
1092
+ };
1093
+ }
1094
+ rowToItem(ref, row) {
1095
+ const body = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata ?? {};
1096
+ const hash = row.checksum ?? (0, import_metadata_core.hashSpec)(body);
1097
+ return {
1098
+ ref: this.fullRef(ref),
1099
+ body,
1100
+ hash,
1101
+ parentHash: null,
1102
+ authoredBy: row.updated_by ?? row.created_by ?? "unknown",
1103
+ authoredAt: row.updated_at ?? row.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1104
+ message: void 0,
1105
+ seq: this.seqCounter
1106
+ };
1107
+ }
1108
+ broadcast(evt) {
1109
+ for (const w of Array.from(this.watchers)) {
1110
+ try {
1111
+ w(evt);
1112
+ } catch {
1113
+ }
1114
+ }
1115
+ }
1116
+ matchesFilter(evt, filter) {
1117
+ if (filter.type && evt.ref.type !== filter.type) return false;
1118
+ if (filter.name && evt.ref.name !== filter.name) return false;
1119
+ if (filter.org && evt.ref.org !== filter.org) return false;
1120
+ return true;
1121
+ }
1122
+ /**
1123
+ * Per-org monotonic event sequence. Reads `MAX(event_seq) + 1` from
1124
+ * `sys_metadata_history` scoped by `organization_id`. MUST be called
1125
+ * inside a transaction (the only caller is the put/delete txn body) —
1126
+ * concurrent writers in the same org race otherwise.
1127
+ */
1128
+ async nextEventSeq(ctx) {
1129
+ try {
1130
+ const rows = await this.engine.find(this.historyTable, {
1131
+ where: { organization_id: this.organizationId },
1132
+ context: ctx
1133
+ });
1134
+ let max = 0;
1135
+ for (const row of rows) {
1136
+ const v = typeof row.event_seq === "number" ? row.event_seq : 0;
1137
+ if (v > max) max = v;
1138
+ }
1139
+ return max + 1;
1140
+ } catch {
1141
+ return 1;
1142
+ }
1143
+ }
1144
+ /**
1145
+ * Per-(org,type,name) lineage counter. Reads from history (not from
1146
+ * `sys_metadata.version`) so delete + recreate continues incrementing
1147
+ * instead of restarting at 1.
1148
+ */
1149
+ async nextItemVersion(ref, ctx) {
1150
+ try {
1151
+ const rows = await this.engine.find(this.historyTable, {
1152
+ where: {
1153
+ organization_id: this.organizationId,
1154
+ type: ref.type,
1155
+ name: ref.name
1156
+ },
1157
+ context: ctx
1158
+ });
1159
+ let max = 0;
1160
+ for (const row of rows) {
1161
+ const v = typeof row.version === "number" ? row.version : 0;
1162
+ if (v > max) max = v;
1163
+ }
1164
+ return max + 1;
1165
+ } catch {
1166
+ return 1;
1167
+ }
1168
+ }
1169
+ /** Lightweight UUID-ish id for history rows; sufficient for an audit log. */
1170
+ uuid() {
1171
+ if (typeof globalThis.crypto?.randomUUID === "function") {
1172
+ return globalThis.crypto.randomUUID();
1173
+ }
1174
+ return `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1175
+ }
1176
+ };
1177
+
643
1178
  // src/protocol.ts
1179
+ var import_metadata_core2 = require("@objectstack/metadata-core");
644
1180
  var import_data2 = require("@objectstack/spec/data");
645
1181
  var import_shared = require("@objectstack/spec/shared");
646
1182
  var import_ui2 = require("@objectstack/spec/ui");
647
- var import_kernel2 = require("@objectstack/spec/kernel");
1183
+ var import_kernel3 = require("@objectstack/spec/kernel");
648
1184
  var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
649
1185
  function resolveOverlaySchema(type, item) {
650
1186
  const singular = import_shared.PLURAL_TO_SINGULAR[type] ?? type;
@@ -706,6 +1242,13 @@ var SERVICE_CONFIG = {
706
1242
  };
707
1243
  var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
708
1244
  constructor(engine, getServicesRegistry, getFeedService, projectId) {
1245
+ /**
1246
+ * Lazily-instantiated SysMetadataRepository per organization. Keyed by
1247
+ * `${organizationId ?? '__env__'}`. Repositories are stateful — they
1248
+ * carry the per-org `seqCounter` and watch subscribers — so we cache
1249
+ * them rather than constructing one per call.
1250
+ */
1251
+ this.overlayRepos = /* @__PURE__ */ new Map();
709
1252
  /**
710
1253
  * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
711
1254
  * on `sys_metadata`. ADR-0005: scopes overlays by
@@ -721,6 +1264,24 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
721
1264
  this.getFeedService = getFeedService;
722
1265
  this.projectId = projectId;
723
1266
  }
1267
+ /**
1268
+ * Lazily obtain a SysMetadataRepository for the given organization.
1269
+ * Env-wide overlays (organizationId == null) share a singleton under
1270
+ * the `__env__` key.
1271
+ */
1272
+ getOverlayRepo(organizationId) {
1273
+ const key = organizationId ?? "__env__";
1274
+ let repo = this.overlayRepos.get(key);
1275
+ if (!repo) {
1276
+ repo = new SysMetadataRepository({
1277
+ engine: this.engine,
1278
+ organizationId,
1279
+ orgLabel: organizationId ?? "env"
1280
+ });
1281
+ this.overlayRepos.set(key, repo);
1282
+ }
1283
+ return repo;
1284
+ }
724
1285
  async ensureOverlayIndex() {
725
1286
  if (this.overlayIndexEnsured) return;
726
1287
  this.overlayIndexEnsured = true;
@@ -1010,23 +1571,34 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1010
1571
  }
1011
1572
  } catch {
1012
1573
  }
1013
- if (item === void 0) {
1014
- item = this.engine.registry.getItem(request.type, request.name);
1015
- if (item === void 0) {
1016
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
1017
- if (alt) item = this.engine.registry.getItem(alt, request.name);
1018
- }
1019
- }
1020
1574
  if (item === void 0) {
1021
1575
  try {
1022
1576
  const services = this.getServicesRegistry?.();
1023
1577
  const metadataService = services?.get("metadata");
1024
1578
  if (metadataService && typeof metadataService.get === "function") {
1025
- item = await metadataService.get(request.type, request.name);
1579
+ const fromService = await metadataService.get(request.type, request.name);
1580
+ if (fromService !== void 0 && fromService !== null) {
1581
+ item = fromService;
1582
+ } else {
1583
+ const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
1584
+ if (alt) {
1585
+ const altFromService = await metadataService.get(alt, request.name);
1586
+ if (altFromService !== void 0 && altFromService !== null) {
1587
+ item = altFromService;
1588
+ }
1589
+ }
1590
+ }
1026
1591
  }
1027
1592
  } catch {
1028
1593
  }
1029
1594
  }
1595
+ if (item === void 0) {
1596
+ item = this.engine.registry.getItem(request.type, request.name);
1597
+ if (item === void 0) {
1598
+ const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
1599
+ if (alt) item = this.engine.registry.getItem(alt, request.name);
1600
+ }
1601
+ }
1030
1602
  return {
1031
1603
  type: request.type,
1032
1604
  name: request.name,
@@ -1909,6 +2481,27 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1909
2481
  const singular = import_shared.PLURAL_TO_SINGULAR[type] ?? type;
1910
2482
  return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
1911
2483
  }
2484
+ /**
2485
+ * Mirror an object-type overlay write into the in-memory engine
2486
+ * registry so subsequent CRUD finds the new schema. Idempotent and
2487
+ * safe to call after a successful persistence call. For the legacy
2488
+ * write path this is invoked BEFORE persistence (historical behavior
2489
+ * preserved); for the PR-10d.3 repository path it is invoked only
2490
+ * AFTER `put()` resolves successfully, so a failed write — DB error,
2491
+ * optimistic-lock conflict, validation failure — never leaks a
2492
+ * stale schema into the registry.
2493
+ */
2494
+ applyObjectRegistryMutation(request) {
2495
+ if (request.type !== "object" && request.type !== "objects") return;
2496
+ this.engine.registry.registerItem(request.type, request.item, "name");
2497
+ try {
2498
+ this.engine.registry.registerObject(request.item, "sys_metadata");
2499
+ } catch (err) {
2500
+ console.warn(
2501
+ `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
2502
+ );
2503
+ }
2504
+ }
1912
2505
  async saveMetaItem(request) {
1913
2506
  if (!request.item) {
1914
2507
  throw new Error("Item data is required");
@@ -1943,17 +2536,51 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1943
2536
  }
1944
2537
  }
1945
2538
  }
1946
- if (request.type === "object" || request.type === "objects") {
1947
- this.engine.registry.registerItem(request.type, request.item, "name");
2539
+ await this.ensureOverlayIndex();
2540
+ const singularTypeForRepo = import_shared.PLURAL_TO_SINGULAR[request.type] ?? request.type;
2541
+ if (_ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo)) {
2542
+ const orgId = request.organizationId ?? null;
2543
+ const repo = this.getOverlayRepo(orgId);
2544
+ const ref = {
2545
+ type: singularTypeForRepo,
2546
+ name: request.name,
2547
+ org: orgId ?? "env"
2548
+ };
2549
+ let parentVersion;
2550
+ if (request.parentVersion !== void 0) {
2551
+ parentVersion = request.parentVersion;
2552
+ } else {
2553
+ const current = await repo.get(ref);
2554
+ parentVersion = current?.hash ?? null;
2555
+ }
1948
2556
  try {
1949
- this.engine.registry.registerObject(request.item, "sys_metadata");
2557
+ const result = await repo.put(ref, request.item, {
2558
+ parentVersion,
2559
+ actor: request.actor ?? "system",
2560
+ source: "protocol.saveMetaItem"
2561
+ });
2562
+ this.applyObjectRegistryMutation(request);
2563
+ return {
2564
+ success: true,
2565
+ version: result.version,
2566
+ seq: result.seq,
2567
+ 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}]`
2568
+ };
1950
2569
  } catch (err) {
1951
- console.warn(
1952
- `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1953
- );
2570
+ if (err instanceof import_metadata_core2.ConflictError) {
2571
+ const conflict = new Error(
2572
+ `[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
2573
+ );
2574
+ conflict.code = "metadata_conflict";
2575
+ conflict.status = 409;
2576
+ conflict.expectedParent = err.expectedParent;
2577
+ conflict.actualHead = err.actualHead;
2578
+ throw conflict;
2579
+ }
2580
+ throw err;
1954
2581
  }
1955
2582
  }
1956
- await this.ensureOverlayIndex();
2583
+ this.applyObjectRegistryMutation(request);
1957
2584
  try {
1958
2585
  const now = (/* @__PURE__ */ new Date()).toISOString();
1959
2586
  const orgId = request.organizationId ?? null;
@@ -2010,6 +2637,35 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2010
2637
  throw err;
2011
2638
  }
2012
2639
  }
2640
+ /**
2641
+ * Yield the durable change-log for a single metadata item — every
2642
+ * put/delete recorded in `sys_metadata_history` for `(org, type, name)`,
2643
+ * in event_seq order. Powers the Studio "History" tab and any
2644
+ * client-side audit timeline.
2645
+ *
2646
+ * Returns `[]` for non-overlay-allowed types (the legacy raw-engine
2647
+ * path doesn't record history) instead of throwing — callers can treat
2648
+ * "no history" uniformly.
2649
+ */
2650
+ async historyMetaItem(request) {
2651
+ const singularType = import_shared.PLURAL_TO_SINGULAR[request.type] ?? request.type;
2652
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType)) {
2653
+ return { events: [] };
2654
+ }
2655
+ const orgId = request.organizationId ?? null;
2656
+ const repo = this.getOverlayRepo(orgId);
2657
+ const ref = {
2658
+ type: singularType,
2659
+ name: request.name,
2660
+ org: orgId ?? "env"
2661
+ };
2662
+ const events = [];
2663
+ const opts = {};
2664
+ if (request.sinceSeq !== void 0) opts.sinceSeq = request.sinceSeq;
2665
+ if (request.limit !== void 0) opts.limit = request.limit;
2666
+ for await (const ev of repo.history(ref, opts)) events.push(ev);
2667
+ return { events };
2668
+ }
2013
2669
  /**
2014
2670
  * Remove a customization overlay row for the given metadata item, so the
2015
2671
  * next read falls through to the artifact-loaded default. Implements the
@@ -2025,6 +2681,66 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2025
2681
  err.status = 403;
2026
2682
  throw err;
2027
2683
  }
2684
+ const singularTypeForRepo = import_shared.PLURAL_TO_SINGULAR[request.type] ?? request.type;
2685
+ const useRepoPath = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
2686
+ if (useRepoPath) {
2687
+ const orgId = request.organizationId ?? null;
2688
+ const repo = this.getOverlayRepo(orgId);
2689
+ const ref = {
2690
+ type: singularTypeForRepo,
2691
+ name: request.name,
2692
+ org: orgId ?? "env"
2693
+ };
2694
+ try {
2695
+ const current = await repo.get(ref);
2696
+ if (!current) {
2697
+ return {
2698
+ success: true,
2699
+ reset: false,
2700
+ message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
2701
+ };
2702
+ }
2703
+ const parentVersion = request.parentVersion !== void 0 ? request.parentVersion ?? current.hash : current.hash;
2704
+ const result = await repo.delete(ref, {
2705
+ parentVersion,
2706
+ actor: request.actor ?? "system",
2707
+ source: "protocol.deleteMetaItem"
2708
+ });
2709
+ if (this.projectId === void 0) {
2710
+ try {
2711
+ const services = this.getServicesRegistry?.();
2712
+ const metadataService = services?.get("metadata");
2713
+ if (metadataService && typeof metadataService.get === "function") {
2714
+ const artifactItem = await metadataService.get(request.type, request.name);
2715
+ if (artifactItem !== void 0) {
2716
+ this.engine.registry.registerItem(request.type, artifactItem, "name");
2717
+ }
2718
+ }
2719
+ } catch {
2720
+ }
2721
+ }
2722
+ return {
2723
+ success: true,
2724
+ reset: true,
2725
+ seq: result.seq,
2726
+ message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`
2727
+ };
2728
+ } catch (err) {
2729
+ if (err instanceof import_metadata_core2.ConflictError) {
2730
+ const conflict = new Error(
2731
+ `[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
2732
+ );
2733
+ conflict.code = "metadata_conflict";
2734
+ conflict.status = 409;
2735
+ conflict.expectedParent = err.expectedParent;
2736
+ conflict.actualHead = err.actualHead;
2737
+ throw conflict;
2738
+ }
2739
+ const e = new Error(`Failed to delete customization overlay: ${err.message ?? err}`);
2740
+ e.status = err?.status ?? 500;
2741
+ throw e;
2742
+ }
2743
+ }
2028
2744
  const scopedWhere = {
2029
2745
  type: request.type,
2030
2746
  name: request.name,
@@ -2248,7 +2964,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2248
2964
  */
2249
2965
  _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
2250
2966
  const out = /* @__PURE__ */ new Set();
2251
- for (const entry of import_kernel2.DEFAULT_METADATA_TYPE_REGISTRY) {
2967
+ for (const entry of import_kernel3.DEFAULT_METADATA_TYPE_REGISTRY) {
2252
2968
  if (!entry.allowOrgOverride) continue;
2253
2969
  out.add(entry.type);
2254
2970
  const plural = import_shared.SINGULAR_TO_PLURAL[entry.type];
@@ -2259,7 +2975,7 @@ _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
2259
2975
  var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
2260
2976
 
2261
2977
  // src/engine.ts
2262
- var import_kernel3 = require("@objectstack/spec/kernel");
2978
+ var import_kernel4 = require("@objectstack/spec/kernel");
2263
2979
  var import_core = require("@objectstack/core");
2264
2980
  var import_system = require("@objectstack/spec/system");
2265
2981
  var import_shared2 = require("@objectstack/spec/shared");
@@ -4547,7 +5263,7 @@ var _ObjectQL = class _ObjectQL {
4547
5263
  */
4548
5264
  createContext(ctx) {
4549
5265
  return new ScopedContext(
4550
- import_kernel3.ExecutionContextSchema.parse(ctx),
5266
+ import_kernel4.ExecutionContextSchema.parse(ctx),
4551
5267
  this
4552
5268
  );
4553
5269
  }
@@ -4835,6 +5551,8 @@ var ObjectQLPlugin = class {
4835
5551
  */
4836
5552
  this.startupTimeout = 12e4;
4837
5553
  this.skipSchemaSync = false;
5554
+ /** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
5555
+ this.metadataUnsubscribes = [];
4838
5556
  this.init = async (ctx) => {
4839
5557
  if (!this.ql) {
4840
5558
  const hostCtx = { ...this.hostContext, logger: ctx.logger };
@@ -4881,6 +5599,9 @@ var ObjectQLPlugin = class {
4881
5599
  if (metadataService && typeof metadataService.loadMany === "function" && this.ql) {
4882
5600
  await this.loadMetadataFromService(metadataService, ctx);
4883
5601
  }
5602
+ if (metadataService && typeof metadataService.subscribe === "function" && this.ql) {
5603
+ this.subscribeToMetadataEvents(metadataService, ctx);
5604
+ }
4884
5605
  } catch (e) {
4885
5606
  ctx.logger.debug("No external metadata service to sync from");
4886
5607
  }
@@ -4934,6 +5655,16 @@ var ObjectQLPlugin = class {
4934
5655
  objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
4935
5656
  });
4936
5657
  };
5658
+ this.stop = async (ctx) => {
5659
+ for (const unsub of this.metadataUnsubscribes) {
5660
+ try {
5661
+ unsub();
5662
+ } catch (e) {
5663
+ ctx.logger.debug("[ObjectQLPlugin] metadata-event unsubscribe failed", { error: e?.message });
5664
+ }
5665
+ }
5666
+ this.metadataUnsubscribes = [];
5667
+ };
4937
5668
  if (qlOrOptions instanceof ObjectQL) {
4938
5669
  this.ql = qlOrOptions;
4939
5670
  this.hostContext = hostContext;
@@ -4950,6 +5681,62 @@ var ObjectQLPlugin = class {
4950
5681
  }
4951
5682
  this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
4952
5683
  }
5684
+ /**
5685
+ * Subscribe to `object` metadata events from the metadata service and
5686
+ * invalidate the SchemaRegistry merge cache on each event (ADR-0008
5687
+ * PR-7). For create/update we also re-load the affected object from
5688
+ * the metadata service so subsequent reads see the new definition;
5689
+ * for delete we unregister it from every contributing package.
5690
+ *
5691
+ * Events are filtered to the canonical `object` type — view/dashboard
5692
+ * /flow edits go through their own consumers (Studio SSE, REST cache).
5693
+ *
5694
+ * Stored unsubscribe handle is invoked from {@link stop}.
5695
+ */
5696
+ subscribeToMetadataEvents(metadataService, ctx) {
5697
+ const handler = async (evt) => {
5698
+ if (!this.ql) return;
5699
+ const name = evt?.name ?? "";
5700
+ if (!name) return;
5701
+ const eventType = evt?.type === "added" || evt?.type === "changed" || evt?.type === "deleted" ? evt.type : "changed";
5702
+ try {
5703
+ this.ql.registry.invalidate(name);
5704
+ if (eventType === "deleted") {
5705
+ ctx.logger.info("[ObjectQLPlugin] object metadata deleted \u2014 registry invalidated", { name });
5706
+ return;
5707
+ }
5708
+ const fresh = typeof metadataService.get === "function" ? await metadataService.get("object", name) : void 0;
5709
+ if (fresh && typeof fresh === "object") {
5710
+ const packageId = fresh._packageId ?? "metadata-service";
5711
+ const namespace = fresh.namespace;
5712
+ this.ql.registry.registerObject(
5713
+ fresh,
5714
+ packageId,
5715
+ namespace,
5716
+ "own"
5717
+ );
5718
+ ctx.logger.info("[ObjectQLPlugin] object metadata updated \u2014 registry refreshed", {
5719
+ name,
5720
+ packageId
5721
+ });
5722
+ } else {
5723
+ ctx.logger.debug("[ObjectQLPlugin] object event received but metadata service has no fresh body", { name });
5724
+ }
5725
+ } catch (e) {
5726
+ ctx.logger.warn("[ObjectQLPlugin] metadata event handler failed", {
5727
+ name,
5728
+ error: e?.message
5729
+ });
5730
+ }
5731
+ };
5732
+ const unsub = metadataService.subscribe("object", handler);
5733
+ if (typeof unsub === "function") {
5734
+ this.metadataUnsubscribes.push(unsub);
5735
+ } else if (unsub && typeof unsub.unsubscribe === "function") {
5736
+ this.metadataUnsubscribes.push(() => unsub.unsubscribe());
5737
+ }
5738
+ ctx.logger.info("[ObjectQLPlugin] subscribed to object metadata events (ADR-0008 PR-7)");
5739
+ }
4953
5740
  /**
4954
5741
  * Register built-in audit hooks for auto-stamping created_by/updated_by
4955
5742
  * and fetching previousData for update/delete operations. These are
@@ -5448,6 +6235,7 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
5448
6235
  RESERVED_NAMESPACES,
5449
6236
  SchemaRegistry,
5450
6237
  ScopedContext,
6238
+ SysMetadataRepository,
5451
6239
  ValidationError,
5452
6240
  applyInMemoryAggregation,
5453
6241
  applySystemFields,