@objectstack/objectql 4.1.1 → 5.0.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.d.mts +237 -1
- package/dist/index.d.ts +237 -1
- package/dist/index.js +833 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +830 -17
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
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,487 @@ 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
|
+
async put(ref, spec, opts) {
|
|
671
|
+
this.assertOpen();
|
|
672
|
+
this.assertAllowed(ref.type);
|
|
673
|
+
const body = spec ?? {};
|
|
674
|
+
const hash = hashSpec(body);
|
|
675
|
+
const result = await this.withTxn(async (ctx) => {
|
|
676
|
+
const existing = await this.engine.findOne("sys_metadata", {
|
|
677
|
+
where: this.whereFor(ref),
|
|
678
|
+
context: ctx
|
|
679
|
+
});
|
|
680
|
+
const existingHash = existing?.checksum ?? null;
|
|
681
|
+
if (opts.parentVersion !== existingHash) {
|
|
682
|
+
throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
|
|
683
|
+
}
|
|
684
|
+
if (existing && existingHash === hash) {
|
|
685
|
+
const item2 = this.rowToItem(ref, existing);
|
|
686
|
+
return { skipped: true, version: hash, seq: item2.seq, item: item2 };
|
|
687
|
+
}
|
|
688
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
689
|
+
const op = existing ? "update" : "create";
|
|
690
|
+
const version = await this.nextItemVersion(ref, ctx);
|
|
691
|
+
const eventSeq = await this.nextEventSeq(ctx);
|
|
692
|
+
const parentRowData = {
|
|
693
|
+
type: ref.type,
|
|
694
|
+
name: ref.name,
|
|
695
|
+
organization_id: this.organizationId,
|
|
696
|
+
metadata: JSON.stringify(body),
|
|
697
|
+
checksum: hash,
|
|
698
|
+
state: "active",
|
|
699
|
+
version,
|
|
700
|
+
updated_at: now
|
|
701
|
+
};
|
|
702
|
+
let parentId;
|
|
703
|
+
if (existing) {
|
|
704
|
+
const existingId = existing.id;
|
|
705
|
+
if (existingId === void 0) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`SysMetadataRepository.put: existing row for ${ref.type}/${ref.name} has no id column`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
parentId = existingId;
|
|
711
|
+
await this.engine.update("sys_metadata", parentRowData, {
|
|
712
|
+
where: { id: existingId },
|
|
713
|
+
context: ctx
|
|
714
|
+
});
|
|
715
|
+
} else {
|
|
716
|
+
parentRowData.created_at = now;
|
|
717
|
+
const inserted = await this.engine.insert("sys_metadata", parentRowData, { context: ctx });
|
|
718
|
+
parentId = inserted.id;
|
|
719
|
+
}
|
|
720
|
+
await this.engine.insert(
|
|
721
|
+
this.historyTable,
|
|
722
|
+
{
|
|
723
|
+
id: this.uuid(),
|
|
724
|
+
event_seq: eventSeq,
|
|
725
|
+
metadata_id: parentId,
|
|
726
|
+
type: ref.type,
|
|
727
|
+
name: ref.name,
|
|
728
|
+
version,
|
|
729
|
+
operation_type: op,
|
|
730
|
+
metadata: JSON.stringify(body),
|
|
731
|
+
checksum: hash,
|
|
732
|
+
previous_checksum: existingHash,
|
|
733
|
+
change_note: opts.message,
|
|
734
|
+
source: opts.source ?? "sys-metadata-repo",
|
|
735
|
+
organization_id: this.organizationId,
|
|
736
|
+
recorded_by: opts.actor,
|
|
737
|
+
recorded_at: now
|
|
738
|
+
},
|
|
739
|
+
{ context: ctx }
|
|
740
|
+
);
|
|
741
|
+
const item = {
|
|
742
|
+
ref: this.fullRef(ref),
|
|
743
|
+
body,
|
|
744
|
+
hash,
|
|
745
|
+
parentHash: existingHash,
|
|
746
|
+
authoredBy: opts.actor,
|
|
747
|
+
authoredAt: now,
|
|
748
|
+
message: opts.message,
|
|
749
|
+
seq: eventSeq
|
|
750
|
+
};
|
|
751
|
+
return {
|
|
752
|
+
skipped: false,
|
|
753
|
+
version: hash,
|
|
754
|
+
seq: eventSeq,
|
|
755
|
+
item,
|
|
756
|
+
op,
|
|
757
|
+
existingHash,
|
|
758
|
+
now,
|
|
759
|
+
source: opts.source ?? "sys-metadata-repo",
|
|
760
|
+
message: opts.message,
|
|
761
|
+
actor: opts.actor
|
|
762
|
+
};
|
|
763
|
+
});
|
|
764
|
+
if (result.skipped) {
|
|
765
|
+
return { version: result.version, seq: result.seq, item: result.item };
|
|
766
|
+
}
|
|
767
|
+
this.seqCounter = result.seq;
|
|
768
|
+
this.broadcast({
|
|
769
|
+
seq: result.seq,
|
|
770
|
+
op: result.op,
|
|
771
|
+
ref: this.fullRef(ref),
|
|
772
|
+
hash: result.version,
|
|
773
|
+
parentHash: result.existingHash,
|
|
774
|
+
actor: result.actor,
|
|
775
|
+
message: result.message,
|
|
776
|
+
ts: result.now,
|
|
777
|
+
source: result.source
|
|
778
|
+
});
|
|
779
|
+
return { version: result.version, seq: result.seq, item: result.item };
|
|
780
|
+
}
|
|
781
|
+
async delete(ref, opts) {
|
|
782
|
+
this.assertOpen();
|
|
783
|
+
this.assertAllowed(ref.type);
|
|
784
|
+
const result = await this.withTxn(async (ctx) => {
|
|
785
|
+
const existing = await this.engine.findOne("sys_metadata", {
|
|
786
|
+
where: this.whereFor(ref),
|
|
787
|
+
context: ctx
|
|
788
|
+
});
|
|
789
|
+
if (!existing) {
|
|
790
|
+
throw new ConflictError(this.fullRef(ref), opts.parentVersion, null);
|
|
791
|
+
}
|
|
792
|
+
const existingHash = existing.checksum ?? null;
|
|
793
|
+
if (opts.parentVersion !== existingHash) {
|
|
794
|
+
throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
|
|
795
|
+
}
|
|
796
|
+
const existingId = existing.id;
|
|
797
|
+
if (existingId === void 0) {
|
|
798
|
+
throw new Error(
|
|
799
|
+
`SysMetadataRepository.delete: existing row for ${ref.type}/${ref.name} has no id column`
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
803
|
+
const version = await this.nextItemVersion(ref, ctx);
|
|
804
|
+
const eventSeq = await this.nextEventSeq(ctx);
|
|
805
|
+
await this.engine.delete("sys_metadata", {
|
|
806
|
+
where: { id: existingId },
|
|
807
|
+
context: ctx
|
|
808
|
+
});
|
|
809
|
+
await this.engine.insert(
|
|
810
|
+
this.historyTable,
|
|
811
|
+
{
|
|
812
|
+
id: this.uuid(),
|
|
813
|
+
event_seq: eventSeq,
|
|
814
|
+
metadata_id: existingId,
|
|
815
|
+
type: ref.type,
|
|
816
|
+
name: ref.name,
|
|
817
|
+
version,
|
|
818
|
+
operation_type: "delete",
|
|
819
|
+
metadata: null,
|
|
820
|
+
checksum: null,
|
|
821
|
+
previous_checksum: existingHash,
|
|
822
|
+
change_note: opts.message,
|
|
823
|
+
source: opts.source ?? "sys-metadata-repo",
|
|
824
|
+
organization_id: this.organizationId,
|
|
825
|
+
recorded_by: opts.actor,
|
|
826
|
+
recorded_at: now
|
|
827
|
+
},
|
|
828
|
+
{ context: ctx }
|
|
829
|
+
);
|
|
830
|
+
return {
|
|
831
|
+
eventSeq,
|
|
832
|
+
existingHash,
|
|
833
|
+
now,
|
|
834
|
+
source: opts.source ?? "sys-metadata-repo",
|
|
835
|
+
message: opts.message,
|
|
836
|
+
actor: opts.actor
|
|
837
|
+
};
|
|
838
|
+
});
|
|
839
|
+
this.seqCounter = result.eventSeq;
|
|
840
|
+
this.broadcast({
|
|
841
|
+
seq: result.eventSeq,
|
|
842
|
+
op: "delete",
|
|
843
|
+
ref: this.fullRef(ref),
|
|
844
|
+
hash: null,
|
|
845
|
+
parentHash: result.existingHash,
|
|
846
|
+
actor: result.actor,
|
|
847
|
+
message: result.message,
|
|
848
|
+
ts: result.now,
|
|
849
|
+
source: result.source
|
|
850
|
+
});
|
|
851
|
+
return { seq: result.eventSeq };
|
|
852
|
+
}
|
|
853
|
+
async *list(filter) {
|
|
854
|
+
this.assertOpen();
|
|
855
|
+
const where = {
|
|
856
|
+
organization_id: this.organizationId,
|
|
857
|
+
state: "active"
|
|
858
|
+
};
|
|
859
|
+
if (filter.type) where.type = filter.type;
|
|
860
|
+
const rows = await this.engine.find("sys_metadata", {
|
|
861
|
+
where,
|
|
862
|
+
limit: filter.limit
|
|
863
|
+
});
|
|
864
|
+
for (const row of rows) {
|
|
865
|
+
if (filter.nameContains && !String(row.name).includes(filter.nameContains)) continue;
|
|
866
|
+
const item = this.rowToItem(
|
|
867
|
+
{ ...this.fullRef({ type: row.type, name: row.name }) },
|
|
868
|
+
row
|
|
869
|
+
);
|
|
870
|
+
const { body, ...header } = item;
|
|
871
|
+
yield header;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Yield every history event for `(org, type?, name?)` from the
|
|
876
|
+
* durable log, ordered by per-(type,name) `version` ascending. When
|
|
877
|
+
* `filter.type`/`filter.name` are unset the consumer gets the full
|
|
878
|
+
* org-scoped event stream — still ordered by version within each
|
|
879
|
+
* (type,name) bucket, then by `recorded_at` across buckets (we sort
|
|
880
|
+
* client-side because the test engine doesn't honor `orderBy`).
|
|
881
|
+
*/
|
|
882
|
+
async *history(ref, opts) {
|
|
883
|
+
this.assertOpen();
|
|
884
|
+
const full = this.fullRef(ref);
|
|
885
|
+
const where = {
|
|
886
|
+
organization_id: this.organizationId,
|
|
887
|
+
type: full.type,
|
|
888
|
+
name: full.name
|
|
889
|
+
};
|
|
890
|
+
const rows = await this.engine.find(this.historyTable, { where });
|
|
891
|
+
rows.sort((a, b) => {
|
|
892
|
+
const va = typeof a.event_seq === "number" ? a.event_seq : 0;
|
|
893
|
+
const vb = typeof b.event_seq === "number" ? b.event_seq : 0;
|
|
894
|
+
return va - vb;
|
|
895
|
+
});
|
|
896
|
+
let yielded = 0;
|
|
897
|
+
for (const row of rows) {
|
|
898
|
+
if (opts?.sinceSeq !== void 0 && (row.event_seq ?? 0) <= opts.sinceSeq) continue;
|
|
899
|
+
if (opts?.limit !== void 0 && yielded >= opts.limit) break;
|
|
900
|
+
yielded++;
|
|
901
|
+
yield {
|
|
902
|
+
seq: row.event_seq ?? 0,
|
|
903
|
+
op: row.operation_type ?? "update",
|
|
904
|
+
ref: full,
|
|
905
|
+
hash: row.checksum ?? null,
|
|
906
|
+
parentHash: row.previous_checksum ?? null,
|
|
907
|
+
actor: row.recorded_by ?? "unknown",
|
|
908
|
+
message: row.change_note ?? void 0,
|
|
909
|
+
ts: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
910
|
+
source: row.source ?? "sys-metadata-repo"
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Live event stream. Fires for every successful put/delete on THIS
|
|
916
|
+
* instance — cross-replica fan-out is M1. Manual AsyncIterator (not
|
|
917
|
+
* an async generator) so we can deterministically tear down via
|
|
918
|
+
* `iter.return()`, matching the pattern used by InMemoryRepository.
|
|
919
|
+
*/
|
|
920
|
+
watch(filter, since) {
|
|
921
|
+
const self = this;
|
|
922
|
+
return {
|
|
923
|
+
[Symbol.asyncIterator]: () => {
|
|
924
|
+
const queue = [];
|
|
925
|
+
let pendingResolve = null;
|
|
926
|
+
let stopped = false;
|
|
927
|
+
const dispatch = (evt) => {
|
|
928
|
+
if (stopped) return;
|
|
929
|
+
if (!self.matchesFilter(evt, filter)) return;
|
|
930
|
+
if (since !== void 0 && evt.seq <= since) return;
|
|
931
|
+
if (pendingResolve) {
|
|
932
|
+
const r = pendingResolve;
|
|
933
|
+
pendingResolve = null;
|
|
934
|
+
r({ value: evt, done: false });
|
|
935
|
+
} else {
|
|
936
|
+
queue.push(evt);
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
self.watchers.add(dispatch);
|
|
940
|
+
return {
|
|
941
|
+
next() {
|
|
942
|
+
if (stopped) return Promise.resolve({ value: void 0, done: true });
|
|
943
|
+
const buffered = queue.shift();
|
|
944
|
+
if (buffered) return Promise.resolve({ value: buffered, done: false });
|
|
945
|
+
return new Promise((resolve) => {
|
|
946
|
+
pendingResolve = resolve;
|
|
947
|
+
});
|
|
948
|
+
},
|
|
949
|
+
return() {
|
|
950
|
+
stopped = true;
|
|
951
|
+
self.watchers.delete(dispatch);
|
|
952
|
+
if (pendingResolve) {
|
|
953
|
+
const r = pendingResolve;
|
|
954
|
+
pendingResolve = null;
|
|
955
|
+
r({ value: void 0, done: true });
|
|
956
|
+
}
|
|
957
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** Shut down all watch iterators. */
|
|
964
|
+
close() {
|
|
965
|
+
this.closed = true;
|
|
966
|
+
const snapshot = Array.from(this.watchers);
|
|
967
|
+
for (const w of snapshot) {
|
|
968
|
+
try {
|
|
969
|
+
w({
|
|
970
|
+
seq: -1,
|
|
971
|
+
op: "delete",
|
|
972
|
+
ref: { org: "", type: "view", name: "_close" },
|
|
973
|
+
hash: null,
|
|
974
|
+
parentHash: null,
|
|
975
|
+
actor: "system",
|
|
976
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
977
|
+
source: "sys-metadata-repo-close"
|
|
978
|
+
});
|
|
979
|
+
} catch {
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
this.watchers.clear();
|
|
983
|
+
}
|
|
984
|
+
// ── helpers ─────────────────────────────────────────────────────────
|
|
985
|
+
assertOpen() {
|
|
986
|
+
if (this.closed) throw new Error("SysMetadataRepository is closed");
|
|
987
|
+
}
|
|
988
|
+
assertAllowed(type) {
|
|
989
|
+
if (!OVERLAY_ALLOWED_TYPES.has(type)) {
|
|
990
|
+
const err = new Error(
|
|
991
|
+
`[not_overridable] '${type}' is not allowOrgOverride in the registry. Allowed: ${Array.from(OVERLAY_ALLOWED_TYPES).join(", ")}.`
|
|
992
|
+
);
|
|
993
|
+
err.code = "not_overridable";
|
|
994
|
+
err.status = 403;
|
|
995
|
+
throw err;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
whereFor(ref) {
|
|
999
|
+
return {
|
|
1000
|
+
type: ref.type,
|
|
1001
|
+
name: ref.name,
|
|
1002
|
+
organization_id: this.organizationId,
|
|
1003
|
+
state: "active"
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
fullRef(ref) {
|
|
1007
|
+
return {
|
|
1008
|
+
org: this.orgLabel,
|
|
1009
|
+
type: ref.type,
|
|
1010
|
+
name: ref.name
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
rowToItem(ref, row) {
|
|
1014
|
+
const body = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata ?? {};
|
|
1015
|
+
const hash = row.checksum ?? hashSpec(body);
|
|
1016
|
+
return {
|
|
1017
|
+
ref: this.fullRef(ref),
|
|
1018
|
+
body,
|
|
1019
|
+
hash,
|
|
1020
|
+
parentHash: null,
|
|
1021
|
+
authoredBy: row.updated_by ?? row.created_by ?? "unknown",
|
|
1022
|
+
authoredAt: row.updated_at ?? row.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1023
|
+
message: void 0,
|
|
1024
|
+
seq: this.seqCounter
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
broadcast(evt) {
|
|
1028
|
+
for (const w of Array.from(this.watchers)) {
|
|
1029
|
+
try {
|
|
1030
|
+
w(evt);
|
|
1031
|
+
} catch {
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
matchesFilter(evt, filter) {
|
|
1036
|
+
if (filter.type && evt.ref.type !== filter.type) return false;
|
|
1037
|
+
if (filter.name && evt.ref.name !== filter.name) return false;
|
|
1038
|
+
if (filter.org && evt.ref.org !== filter.org) return false;
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Per-org monotonic event sequence. Reads `MAX(event_seq) + 1` from
|
|
1043
|
+
* `sys_metadata_history` scoped by `organization_id`. MUST be called
|
|
1044
|
+
* inside a transaction (the only caller is the put/delete txn body) —
|
|
1045
|
+
* concurrent writers in the same org race otherwise.
|
|
1046
|
+
*/
|
|
1047
|
+
async nextEventSeq(ctx) {
|
|
1048
|
+
try {
|
|
1049
|
+
const rows = await this.engine.find(this.historyTable, {
|
|
1050
|
+
where: { organization_id: this.organizationId },
|
|
1051
|
+
context: ctx
|
|
1052
|
+
});
|
|
1053
|
+
let max = 0;
|
|
1054
|
+
for (const row of rows) {
|
|
1055
|
+
const v = typeof row.event_seq === "number" ? row.event_seq : 0;
|
|
1056
|
+
if (v > max) max = v;
|
|
1057
|
+
}
|
|
1058
|
+
return max + 1;
|
|
1059
|
+
} catch {
|
|
1060
|
+
return 1;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Per-(org,type,name) lineage counter. Reads from history (not from
|
|
1065
|
+
* `sys_metadata.version`) so delete + recreate continues incrementing
|
|
1066
|
+
* instead of restarting at 1.
|
|
1067
|
+
*/
|
|
1068
|
+
async nextItemVersion(ref, ctx) {
|
|
1069
|
+
try {
|
|
1070
|
+
const rows = await this.engine.find(this.historyTable, {
|
|
1071
|
+
where: {
|
|
1072
|
+
organization_id: this.organizationId,
|
|
1073
|
+
type: ref.type,
|
|
1074
|
+
name: ref.name
|
|
1075
|
+
},
|
|
1076
|
+
context: ctx
|
|
1077
|
+
});
|
|
1078
|
+
let max = 0;
|
|
1079
|
+
for (const row of rows) {
|
|
1080
|
+
const v = typeof row.version === "number" ? row.version : 0;
|
|
1081
|
+
if (v > max) max = v;
|
|
1082
|
+
}
|
|
1083
|
+
return max + 1;
|
|
1084
|
+
} catch {
|
|
1085
|
+
return 1;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/** Lightweight UUID-ish id for history rows; sufficient for an audit log. */
|
|
1089
|
+
uuid() {
|
|
1090
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
1091
|
+
return globalThis.crypto.randomUUID();
|
|
1092
|
+
}
|
|
1093
|
+
return `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
|
|
594
1097
|
// src/protocol.ts
|
|
1098
|
+
import { ConflictError as ConflictError2 } from "@objectstack/metadata-core";
|
|
595
1099
|
import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
|
|
596
1100
|
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from "@objectstack/spec/shared";
|
|
597
1101
|
import { ListViewSchema, FormViewSchema, DashboardSchema } from "@objectstack/spec/ui";
|
|
598
|
-
import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
|
|
1102
|
+
import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2 } from "@objectstack/spec/kernel";
|
|
599
1103
|
var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
|
|
600
1104
|
function resolveOverlaySchema(type, item) {
|
|
601
1105
|
const singular = PLURAL_TO_SINGULAR[type] ?? type;
|
|
@@ -619,6 +1123,25 @@ function simpleHash(str) {
|
|
|
619
1123
|
}
|
|
620
1124
|
return Math.abs(hash).toString(16);
|
|
621
1125
|
}
|
|
1126
|
+
var ConcurrentUpdateError = class extends Error {
|
|
1127
|
+
constructor(opts) {
|
|
1128
|
+
super(opts.message ?? "Record was modified by another user");
|
|
1129
|
+
this.code = "CONCURRENT_UPDATE";
|
|
1130
|
+
this.status = 409;
|
|
1131
|
+
this.name = "ConcurrentUpdateError";
|
|
1132
|
+
this.currentVersion = opts.currentVersion;
|
|
1133
|
+
this.currentRecord = opts.currentRecord;
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
function normaliseVersionToken(v) {
|
|
1137
|
+
if (v === null || v === void 0) return null;
|
|
1138
|
+
const s = String(v).trim();
|
|
1139
|
+
if (!s) return null;
|
|
1140
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
|
1141
|
+
return s.slice(1, -1);
|
|
1142
|
+
}
|
|
1143
|
+
return s;
|
|
1144
|
+
}
|
|
622
1145
|
var SERVICE_CONFIG = {
|
|
623
1146
|
auth: { route: "/api/v1/auth", plugin: "plugin-auth" },
|
|
624
1147
|
automation: { route: "/api/v1/automation", plugin: "plugin-automation" },
|
|
@@ -638,6 +1161,13 @@ var SERVICE_CONFIG = {
|
|
|
638
1161
|
};
|
|
639
1162
|
var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
|
|
640
1163
|
constructor(engine, getServicesRegistry, getFeedService, projectId) {
|
|
1164
|
+
/**
|
|
1165
|
+
* Lazily-instantiated SysMetadataRepository per organization. Keyed by
|
|
1166
|
+
* `${organizationId ?? '__env__'}`. Repositories are stateful — they
|
|
1167
|
+
* carry the per-org `seqCounter` and watch subscribers — so we cache
|
|
1168
|
+
* them rather than constructing one per call.
|
|
1169
|
+
*/
|
|
1170
|
+
this.overlayRepos = /* @__PURE__ */ new Map();
|
|
641
1171
|
/**
|
|
642
1172
|
* One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
|
|
643
1173
|
* on `sys_metadata`. ADR-0005: scopes overlays by
|
|
@@ -653,6 +1183,24 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
653
1183
|
this.getFeedService = getFeedService;
|
|
654
1184
|
this.projectId = projectId;
|
|
655
1185
|
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Lazily obtain a SysMetadataRepository for the given organization.
|
|
1188
|
+
* Env-wide overlays (organizationId == null) share a singleton under
|
|
1189
|
+
* the `__env__` key.
|
|
1190
|
+
*/
|
|
1191
|
+
getOverlayRepo(organizationId) {
|
|
1192
|
+
const key = organizationId ?? "__env__";
|
|
1193
|
+
let repo = this.overlayRepos.get(key);
|
|
1194
|
+
if (!repo) {
|
|
1195
|
+
repo = new SysMetadataRepository({
|
|
1196
|
+
engine: this.engine,
|
|
1197
|
+
organizationId,
|
|
1198
|
+
orgLabel: organizationId ?? "env"
|
|
1199
|
+
});
|
|
1200
|
+
this.overlayRepos.set(key, repo);
|
|
1201
|
+
}
|
|
1202
|
+
return repo;
|
|
1203
|
+
}
|
|
656
1204
|
async ensureOverlayIndex() {
|
|
657
1205
|
if (this.overlayIndexEnsured) return;
|
|
658
1206
|
this.overlayIndexEnsured = true;
|
|
@@ -942,23 +1490,34 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
942
1490
|
}
|
|
943
1491
|
} catch {
|
|
944
1492
|
}
|
|
945
|
-
if (item === void 0) {
|
|
946
|
-
item = this.engine.registry.getItem(request.type, request.name);
|
|
947
|
-
if (item === void 0) {
|
|
948
|
-
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
949
|
-
if (alt) item = this.engine.registry.getItem(alt, request.name);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
1493
|
if (item === void 0) {
|
|
953
1494
|
try {
|
|
954
1495
|
const services = this.getServicesRegistry?.();
|
|
955
1496
|
const metadataService = services?.get("metadata");
|
|
956
1497
|
if (metadataService && typeof metadataService.get === "function") {
|
|
957
|
-
|
|
1498
|
+
const fromService = await metadataService.get(request.type, request.name);
|
|
1499
|
+
if (fromService !== void 0 && fromService !== null) {
|
|
1500
|
+
item = fromService;
|
|
1501
|
+
} else {
|
|
1502
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
1503
|
+
if (alt) {
|
|
1504
|
+
const altFromService = await metadataService.get(alt, request.name);
|
|
1505
|
+
if (altFromService !== void 0 && altFromService !== null) {
|
|
1506
|
+
item = altFromService;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
958
1510
|
}
|
|
959
1511
|
} catch {
|
|
960
1512
|
}
|
|
961
1513
|
}
|
|
1514
|
+
if (item === void 0) {
|
|
1515
|
+
item = this.engine.registry.getItem(request.type, request.name);
|
|
1516
|
+
if (item === void 0) {
|
|
1517
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
1518
|
+
if (alt) item = this.engine.registry.getItem(alt, request.name);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
962
1521
|
return {
|
|
963
1522
|
type: request.type,
|
|
964
1523
|
name: request.name,
|
|
@@ -1212,6 +1771,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1212
1771
|
};
|
|
1213
1772
|
}
|
|
1214
1773
|
async updateData(request) {
|
|
1774
|
+
await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
|
|
1215
1775
|
const opts = { where: { id: request.id } };
|
|
1216
1776
|
if (request.context !== void 0) opts.context = request.context;
|
|
1217
1777
|
const result = await this.engine.update(request.object, request.data, opts);
|
|
@@ -1222,6 +1782,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1222
1782
|
};
|
|
1223
1783
|
}
|
|
1224
1784
|
async deleteData(request) {
|
|
1785
|
+
await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
|
|
1225
1786
|
const opts = { where: { id: request.id } };
|
|
1226
1787
|
if (request.context !== void 0) opts.context = request.context;
|
|
1227
1788
|
await this.engine.delete(request.object, opts);
|
|
@@ -1231,6 +1792,42 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1231
1792
|
success: true
|
|
1232
1793
|
};
|
|
1233
1794
|
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Optimistic Concurrency Control gate shared by updateData/deleteData.
|
|
1797
|
+
*
|
|
1798
|
+
* When the caller passes a non-empty `expectedVersion` token (typically
|
|
1799
|
+
* the `updated_at` value they read), this fetches the current record
|
|
1800
|
+
* and compares its `updated_at` against the token. Mismatch → throw
|
|
1801
|
+
* `ConcurrentUpdateError` which the REST layer maps to 409.
|
|
1802
|
+
*
|
|
1803
|
+
* Behaviour:
|
|
1804
|
+
* - Empty/missing token → no check (opt-in semantics; existing callers
|
|
1805
|
+
* that haven't yet adopted OCC are unaffected).
|
|
1806
|
+
* - Record not found → no check; downstream `engine.update` will
|
|
1807
|
+
* surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not
|
|
1808
|
+
* treat "missing record" as a concurrency conflict.
|
|
1809
|
+
* - Record has no `updated_at` field (timestamps disabled) → no check.
|
|
1810
|
+
* Logging would be noisy here; OCC is opt-in and the absence of a
|
|
1811
|
+
* version column is an explicit "this object doesn't support OCC"
|
|
1812
|
+
* signal.
|
|
1813
|
+
*/
|
|
1814
|
+
async assertVersionMatch(object, id, expectedVersion, context) {
|
|
1815
|
+
const expected = normaliseVersionToken(expectedVersion);
|
|
1816
|
+
if (!expected) return;
|
|
1817
|
+
const findOpts = { where: { id } };
|
|
1818
|
+
if (context !== void 0) findOpts.context = context;
|
|
1819
|
+
const current = await this.engine.findOne(object, findOpts);
|
|
1820
|
+
if (!current) return;
|
|
1821
|
+
const currentVersion = normaliseVersionToken(current.updated_at);
|
|
1822
|
+
if (!currentVersion) return;
|
|
1823
|
+
if (currentVersion !== expected) {
|
|
1824
|
+
throw new ConcurrentUpdateError({
|
|
1825
|
+
currentVersion,
|
|
1826
|
+
currentRecord: current,
|
|
1827
|
+
message: `Record ${object}/${id} was modified by another user (current version ${currentVersion}, expected ${expected})`
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1234
1831
|
// ==========================================
|
|
1235
1832
|
// Global Search (M10.5)
|
|
1236
1833
|
// ==========================================
|
|
@@ -1803,6 +2400,27 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1803
2400
|
const singular = PLURAL_TO_SINGULAR[type] ?? type;
|
|
1804
2401
|
return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
|
|
1805
2402
|
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Mirror an object-type overlay write into the in-memory engine
|
|
2405
|
+
* registry so subsequent CRUD finds the new schema. Idempotent and
|
|
2406
|
+
* safe to call after a successful persistence call. For the legacy
|
|
2407
|
+
* write path this is invoked BEFORE persistence (historical behavior
|
|
2408
|
+
* preserved); for the PR-10d.3 repository path it is invoked only
|
|
2409
|
+
* AFTER `put()` resolves successfully, so a failed write — DB error,
|
|
2410
|
+
* optimistic-lock conflict, validation failure — never leaks a
|
|
2411
|
+
* stale schema into the registry.
|
|
2412
|
+
*/
|
|
2413
|
+
applyObjectRegistryMutation(request) {
|
|
2414
|
+
if (request.type !== "object" && request.type !== "objects") return;
|
|
2415
|
+
this.engine.registry.registerItem(request.type, request.item, "name");
|
|
2416
|
+
try {
|
|
2417
|
+
this.engine.registry.registerObject(request.item, "sys_metadata");
|
|
2418
|
+
} catch (err) {
|
|
2419
|
+
console.warn(
|
|
2420
|
+
`[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
1806
2424
|
async saveMetaItem(request) {
|
|
1807
2425
|
if (!request.item) {
|
|
1808
2426
|
throw new Error("Item data is required");
|
|
@@ -1837,17 +2455,51 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1837
2455
|
}
|
|
1838
2456
|
}
|
|
1839
2457
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
2458
|
+
await this.ensureOverlayIndex();
|
|
2459
|
+
const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type;
|
|
2460
|
+
if (_ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo)) {
|
|
2461
|
+
const orgId = request.organizationId ?? null;
|
|
2462
|
+
const repo = this.getOverlayRepo(orgId);
|
|
2463
|
+
const ref = {
|
|
2464
|
+
type: singularTypeForRepo,
|
|
2465
|
+
name: request.name,
|
|
2466
|
+
org: orgId ?? "env"
|
|
2467
|
+
};
|
|
2468
|
+
let parentVersion;
|
|
2469
|
+
if (request.parentVersion !== void 0) {
|
|
2470
|
+
parentVersion = request.parentVersion;
|
|
2471
|
+
} else {
|
|
2472
|
+
const current = await repo.get(ref);
|
|
2473
|
+
parentVersion = current?.hash ?? null;
|
|
2474
|
+
}
|
|
1842
2475
|
try {
|
|
1843
|
-
|
|
2476
|
+
const result = await repo.put(ref, request.item, {
|
|
2477
|
+
parentVersion,
|
|
2478
|
+
actor: request.actor ?? "system",
|
|
2479
|
+
source: "protocol.saveMetaItem"
|
|
2480
|
+
});
|
|
2481
|
+
this.applyObjectRegistryMutation(request);
|
|
2482
|
+
return {
|
|
2483
|
+
success: true,
|
|
2484
|
+
version: result.version,
|
|
2485
|
+
seq: result.seq,
|
|
2486
|
+
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}]`
|
|
2487
|
+
};
|
|
1844
2488
|
} catch (err) {
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
2489
|
+
if (err instanceof ConflictError2) {
|
|
2490
|
+
const conflict = new Error(
|
|
2491
|
+
`[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
|
|
2492
|
+
);
|
|
2493
|
+
conflict.code = "metadata_conflict";
|
|
2494
|
+
conflict.status = 409;
|
|
2495
|
+
conflict.expectedParent = err.expectedParent;
|
|
2496
|
+
conflict.actualHead = err.actualHead;
|
|
2497
|
+
throw conflict;
|
|
2498
|
+
}
|
|
2499
|
+
throw err;
|
|
1848
2500
|
}
|
|
1849
2501
|
}
|
|
1850
|
-
|
|
2502
|
+
this.applyObjectRegistryMutation(request);
|
|
1851
2503
|
try {
|
|
1852
2504
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1853
2505
|
const orgId = request.organizationId ?? null;
|
|
@@ -1904,6 +2556,35 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1904
2556
|
throw err;
|
|
1905
2557
|
}
|
|
1906
2558
|
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Yield the durable change-log for a single metadata item — every
|
|
2561
|
+
* put/delete recorded in `sys_metadata_history` for `(org, type, name)`,
|
|
2562
|
+
* in event_seq order. Powers the Studio "History" tab and any
|
|
2563
|
+
* client-side audit timeline.
|
|
2564
|
+
*
|
|
2565
|
+
* Returns `[]` for non-overlay-allowed types (the legacy raw-engine
|
|
2566
|
+
* path doesn't record history) instead of throwing — callers can treat
|
|
2567
|
+
* "no history" uniformly.
|
|
2568
|
+
*/
|
|
2569
|
+
async historyMetaItem(request) {
|
|
2570
|
+
const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
|
|
2571
|
+
if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType)) {
|
|
2572
|
+
return { events: [] };
|
|
2573
|
+
}
|
|
2574
|
+
const orgId = request.organizationId ?? null;
|
|
2575
|
+
const repo = this.getOverlayRepo(orgId);
|
|
2576
|
+
const ref = {
|
|
2577
|
+
type: singularType,
|
|
2578
|
+
name: request.name,
|
|
2579
|
+
org: orgId ?? "env"
|
|
2580
|
+
};
|
|
2581
|
+
const events = [];
|
|
2582
|
+
const opts = {};
|
|
2583
|
+
if (request.sinceSeq !== void 0) opts.sinceSeq = request.sinceSeq;
|
|
2584
|
+
if (request.limit !== void 0) opts.limit = request.limit;
|
|
2585
|
+
for await (const ev of repo.history(ref, opts)) events.push(ev);
|
|
2586
|
+
return { events };
|
|
2587
|
+
}
|
|
1907
2588
|
/**
|
|
1908
2589
|
* Remove a customization overlay row for the given metadata item, so the
|
|
1909
2590
|
* next read falls through to the artifact-loaded default. Implements the
|
|
@@ -1919,6 +2600,66 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
1919
2600
|
err.status = 403;
|
|
1920
2601
|
throw err;
|
|
1921
2602
|
}
|
|
2603
|
+
const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type;
|
|
2604
|
+
const useRepoPath = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
|
|
2605
|
+
if (useRepoPath) {
|
|
2606
|
+
const orgId = request.organizationId ?? null;
|
|
2607
|
+
const repo = this.getOverlayRepo(orgId);
|
|
2608
|
+
const ref = {
|
|
2609
|
+
type: singularTypeForRepo,
|
|
2610
|
+
name: request.name,
|
|
2611
|
+
org: orgId ?? "env"
|
|
2612
|
+
};
|
|
2613
|
+
try {
|
|
2614
|
+
const current = await repo.get(ref);
|
|
2615
|
+
if (!current) {
|
|
2616
|
+
return {
|
|
2617
|
+
success: true,
|
|
2618
|
+
reset: false,
|
|
2619
|
+
message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
const parentVersion = request.parentVersion !== void 0 ? request.parentVersion ?? current.hash : current.hash;
|
|
2623
|
+
const result = await repo.delete(ref, {
|
|
2624
|
+
parentVersion,
|
|
2625
|
+
actor: request.actor ?? "system",
|
|
2626
|
+
source: "protocol.deleteMetaItem"
|
|
2627
|
+
});
|
|
2628
|
+
if (this.projectId === void 0) {
|
|
2629
|
+
try {
|
|
2630
|
+
const services = this.getServicesRegistry?.();
|
|
2631
|
+
const metadataService = services?.get("metadata");
|
|
2632
|
+
if (metadataService && typeof metadataService.get === "function") {
|
|
2633
|
+
const artifactItem = await metadataService.get(request.type, request.name);
|
|
2634
|
+
if (artifactItem !== void 0) {
|
|
2635
|
+
this.engine.registry.registerItem(request.type, artifactItem, "name");
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
} catch {
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
return {
|
|
2642
|
+
success: true,
|
|
2643
|
+
reset: true,
|
|
2644
|
+
seq: result.seq,
|
|
2645
|
+
message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`
|
|
2646
|
+
};
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
if (err instanceof ConflictError2) {
|
|
2649
|
+
const conflict = new Error(
|
|
2650
|
+
`[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
|
|
2651
|
+
);
|
|
2652
|
+
conflict.code = "metadata_conflict";
|
|
2653
|
+
conflict.status = 409;
|
|
2654
|
+
conflict.expectedParent = err.expectedParent;
|
|
2655
|
+
conflict.actualHead = err.actualHead;
|
|
2656
|
+
throw conflict;
|
|
2657
|
+
}
|
|
2658
|
+
const e = new Error(`Failed to delete customization overlay: ${err.message ?? err}`);
|
|
2659
|
+
e.status = err?.status ?? 500;
|
|
2660
|
+
throw e;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
1922
2663
|
const scopedWhere = {
|
|
1923
2664
|
type: request.type,
|
|
1924
2665
|
name: request.name,
|
|
@@ -2142,7 +2883,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2142
2883
|
*/
|
|
2143
2884
|
_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
|
|
2144
2885
|
const out = /* @__PURE__ */ new Set();
|
|
2145
|
-
for (const entry of
|
|
2886
|
+
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY2) {
|
|
2146
2887
|
if (!entry.allowOrgOverride) continue;
|
|
2147
2888
|
out.add(entry.type);
|
|
2148
2889
|
const plural = SINGULAR_TO_PLURAL[entry.type];
|
|
@@ -4729,6 +5470,8 @@ var ObjectQLPlugin = class {
|
|
|
4729
5470
|
*/
|
|
4730
5471
|
this.startupTimeout = 12e4;
|
|
4731
5472
|
this.skipSchemaSync = false;
|
|
5473
|
+
/** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
|
|
5474
|
+
this.metadataUnsubscribes = [];
|
|
4732
5475
|
this.init = async (ctx) => {
|
|
4733
5476
|
if (!this.ql) {
|
|
4734
5477
|
const hostCtx = { ...this.hostContext, logger: ctx.logger };
|
|
@@ -4775,6 +5518,9 @@ var ObjectQLPlugin = class {
|
|
|
4775
5518
|
if (metadataService && typeof metadataService.loadMany === "function" && this.ql) {
|
|
4776
5519
|
await this.loadMetadataFromService(metadataService, ctx);
|
|
4777
5520
|
}
|
|
5521
|
+
if (metadataService && typeof metadataService.subscribe === "function" && this.ql) {
|
|
5522
|
+
this.subscribeToMetadataEvents(metadataService, ctx);
|
|
5523
|
+
}
|
|
4778
5524
|
} catch (e) {
|
|
4779
5525
|
ctx.logger.debug("No external metadata service to sync from");
|
|
4780
5526
|
}
|
|
@@ -4828,6 +5574,16 @@ var ObjectQLPlugin = class {
|
|
|
4828
5574
|
objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
|
|
4829
5575
|
});
|
|
4830
5576
|
};
|
|
5577
|
+
this.stop = async (ctx) => {
|
|
5578
|
+
for (const unsub of this.metadataUnsubscribes) {
|
|
5579
|
+
try {
|
|
5580
|
+
unsub();
|
|
5581
|
+
} catch (e) {
|
|
5582
|
+
ctx.logger.debug("[ObjectQLPlugin] metadata-event unsubscribe failed", { error: e?.message });
|
|
5583
|
+
}
|
|
5584
|
+
}
|
|
5585
|
+
this.metadataUnsubscribes = [];
|
|
5586
|
+
};
|
|
4831
5587
|
if (qlOrOptions instanceof ObjectQL) {
|
|
4832
5588
|
this.ql = qlOrOptions;
|
|
4833
5589
|
this.hostContext = hostContext;
|
|
@@ -4844,6 +5600,62 @@ var ObjectQLPlugin = class {
|
|
|
4844
5600
|
}
|
|
4845
5601
|
this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
|
|
4846
5602
|
}
|
|
5603
|
+
/**
|
|
5604
|
+
* Subscribe to `object` metadata events from the metadata service and
|
|
5605
|
+
* invalidate the SchemaRegistry merge cache on each event (ADR-0008
|
|
5606
|
+
* PR-7). For create/update we also re-load the affected object from
|
|
5607
|
+
* the metadata service so subsequent reads see the new definition;
|
|
5608
|
+
* for delete we unregister it from every contributing package.
|
|
5609
|
+
*
|
|
5610
|
+
* Events are filtered to the canonical `object` type — view/dashboard
|
|
5611
|
+
* /flow edits go through their own consumers (Studio SSE, REST cache).
|
|
5612
|
+
*
|
|
5613
|
+
* Stored unsubscribe handle is invoked from {@link stop}.
|
|
5614
|
+
*/
|
|
5615
|
+
subscribeToMetadataEvents(metadataService, ctx) {
|
|
5616
|
+
const handler = async (evt) => {
|
|
5617
|
+
if (!this.ql) return;
|
|
5618
|
+
const name = evt?.name ?? "";
|
|
5619
|
+
if (!name) return;
|
|
5620
|
+
const eventType = evt?.type === "added" || evt?.type === "changed" || evt?.type === "deleted" ? evt.type : "changed";
|
|
5621
|
+
try {
|
|
5622
|
+
this.ql.registry.invalidate(name);
|
|
5623
|
+
if (eventType === "deleted") {
|
|
5624
|
+
ctx.logger.info("[ObjectQLPlugin] object metadata deleted \u2014 registry invalidated", { name });
|
|
5625
|
+
return;
|
|
5626
|
+
}
|
|
5627
|
+
const fresh = typeof metadataService.get === "function" ? await metadataService.get("object", name) : void 0;
|
|
5628
|
+
if (fresh && typeof fresh === "object") {
|
|
5629
|
+
const packageId = fresh._packageId ?? "metadata-service";
|
|
5630
|
+
const namespace = fresh.namespace;
|
|
5631
|
+
this.ql.registry.registerObject(
|
|
5632
|
+
fresh,
|
|
5633
|
+
packageId,
|
|
5634
|
+
namespace,
|
|
5635
|
+
"own"
|
|
5636
|
+
);
|
|
5637
|
+
ctx.logger.info("[ObjectQLPlugin] object metadata updated \u2014 registry refreshed", {
|
|
5638
|
+
name,
|
|
5639
|
+
packageId
|
|
5640
|
+
});
|
|
5641
|
+
} else {
|
|
5642
|
+
ctx.logger.debug("[ObjectQLPlugin] object event received but metadata service has no fresh body", { name });
|
|
5643
|
+
}
|
|
5644
|
+
} catch (e) {
|
|
5645
|
+
ctx.logger.warn("[ObjectQLPlugin] metadata event handler failed", {
|
|
5646
|
+
name,
|
|
5647
|
+
error: e?.message
|
|
5648
|
+
});
|
|
5649
|
+
}
|
|
5650
|
+
};
|
|
5651
|
+
const unsub = metadataService.subscribe("object", handler);
|
|
5652
|
+
if (typeof unsub === "function") {
|
|
5653
|
+
this.metadataUnsubscribes.push(unsub);
|
|
5654
|
+
} else if (unsub && typeof unsub.unsubscribe === "function") {
|
|
5655
|
+
this.metadataUnsubscribes.push(() => unsub.unsubscribe());
|
|
5656
|
+
}
|
|
5657
|
+
ctx.logger.info("[ObjectQLPlugin] subscribed to object metadata events (ADR-0008 PR-7)");
|
|
5658
|
+
}
|
|
4847
5659
|
/**
|
|
4848
5660
|
* Register built-in audit hooks for auto-stamping created_by/updated_by
|
|
4849
5661
|
* and fetching previousData for update/delete operations. These are
|
|
@@ -5341,6 +6153,7 @@ export {
|
|
|
5341
6153
|
RESERVED_NAMESPACES,
|
|
5342
6154
|
SchemaRegistry,
|
|
5343
6155
|
ScopedContext,
|
|
6156
|
+
SysMetadataRepository,
|
|
5344
6157
|
ValidationError,
|
|
5345
6158
|
applyInMemoryAggregation,
|
|
5346
6159
|
applySystemFields,
|