@object-ui/data-objectstack 6.2.3 → 7.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/CHANGELOG.md +133 -0
- package/dist/index.cjs +344 -15
- package/dist/index.d.cts +368 -5
- package/dist/index.d.ts +368 -5
- package/dist/index.js +344 -15
- package/package.json +5 -5
- package/src/index.ts +216 -8
- package/src/listViews.test.ts +78 -0
- package/src/metadata-client.test.ts +141 -0
- package/src/metadata-client.ts +469 -11
- package/src/queryDataset.test.ts +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
1
|
# @object-ui/data-objectstack
|
|
2
2
|
|
|
3
|
+
## 7.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 30ee761: feat(studio): surface pending drafts on the package detail (ADR-0033)
|
|
8
|
+
|
|
9
|
+
After an AI builds an app, its objects/views land as drafts bound to the app package — but Studio's active-only browsers hid them, so the package looked empty and there was no obvious way to find what to review/publish.
|
|
10
|
+
|
|
11
|
+
- `MetadataClient.listDrafts({ packageId?, type? })` calls the new `GET /api/v1/meta/_drafts` endpoint, returning pending draft headers (with `packageId`).
|
|
12
|
+
- The package detail sheet (PackagesPage) now shows a **Pending changes** section listing each drafted item, each linking to the existing per-item review/diff (`?review=1`) so the user can publish it. A just-built app package is no longer shown as empty.
|
|
13
|
+
|
|
14
|
+
- 053c948: feat: ADR-0047 — interface pages, visualization switcher, and Airtable-parity filters
|
|
15
|
+
|
|
16
|
+
End-user interface/list pages reach full rendering and authoring parity:
|
|
17
|
+
|
|
18
|
+
- **Spec tabs + visualization switcher** — `ObjectView` now forwards
|
|
19
|
+
`viewDef.tabs` (stored/served but never rendered) and `viewDef.appearance`
|
|
20
|
+
(`allowedVisualizations` whitelist), turning on the dormant `ViewSwitcher` when
|
|
21
|
+
more than one type is whitelisted; effective options = author whitelist ∩
|
|
22
|
+
capability-resolvable types (kanban needs `groupBy`, calendar a date field, …).
|
|
23
|
+
`ListView` accepts the canonical `ViewFilterRule[]` tab-filter shape.
|
|
24
|
+
- **User filters** — render only when `userFilters` is explicitly configured;
|
|
25
|
+
selections (dropdown values + active tab) mirror into `uf_*` URL params and
|
|
26
|
+
restore on load, so filtered lists survive reload and are shareable.
|
|
27
|
+
- **Toolbar polish** — the visualization switcher becomes a compact right-side
|
|
28
|
+
"Grid ▾" dropdown inside the tool cluster (no extra row); filter tabs and
|
|
29
|
+
dropdown filters are mutually exclusive.
|
|
30
|
+
- **Studio authoring** — a usable, schema-driven interface-page inspector
|
|
31
|
+
(collapsible sections honoured, array-of-enum → multi-select, a None/Tabs/
|
|
32
|
+
Dropdown `filter-mode` selector where None maps to ABSENCE of `userFilters`),
|
|
33
|
+
and the Design/Preview tabs render the live list via `InterfaceListPage`
|
|
34
|
+
(including a non-empty grid when the source view is hollow).
|
|
35
|
+
|
|
36
|
+
- 5332639: feat(app-shell): render full object forms (incl. master-detail) in screen-flow wizard steps
|
|
37
|
+
|
|
38
|
+
`FlowRunner` now renders an `object-form` screen step: when the paused screen
|
|
39
|
+
carries `kind: 'object-form'`, it mounts the real `<ObjectForm>` for the named
|
|
40
|
+
object (auto-routing to `MasterDetailForm` for inline child collections),
|
|
41
|
+
prefilled from the step's `defaults`. The form persists itself (atomic
|
|
42
|
+
master-detail batch), then resumes the run with the saved record id bound to the
|
|
43
|
+
step's `idVariable`. `dataSource`/`objects` are threaded through all three
|
|
44
|
+
`FlowRunner` mount points.
|
|
45
|
+
|
|
46
|
+
Also fixes three pre-existing bugs this surfaced (each affects normal forms too):
|
|
47
|
+
|
|
48
|
+
- **plugin-form**: `ObjectForm` now forwards `initialValues`/`initialData` when
|
|
49
|
+
routing to `MasterDetailForm`, so prefilled header values are no longer
|
|
50
|
+
dropped on master-detail create forms.
|
|
51
|
+
- **fields**: `PercentField` treated values as `0–1` fractions (`value × 100`),
|
|
52
|
+
so a `0–100` field (e.g. `probability` default `50`) rendered as `5000%` —
|
|
53
|
+
exceeding `max=100`, which makes HTML5 constraint validation mark the field
|
|
54
|
+
`:invalid` and silently block the whole form's submit. It now treats a field
|
|
55
|
+
declaring `max > 1` as the `0–100` whole-number convention, matching the
|
|
56
|
+
read-side formatter.
|
|
57
|
+
- **data-objectstack**: `ObjectStackAdapter.batchTransaction` now sends
|
|
58
|
+
`credentials: 'include'`, so master-detail batch saves authenticate under the
|
|
59
|
+
console's cookie session (previously every batch save 401'd).
|
|
60
|
+
|
|
61
|
+
- d16566f: Atomic master-detail create via the cross-object transactional batch endpoint (ObjectStack #1604).
|
|
62
|
+
|
|
63
|
+
When the server exposes the transactional batch endpoint, a NEW parent record and its child line items are now persisted in ONE server transaction — commit all or roll back all — instead of the previous client-orchestrated "create parent → create children → best-effort cleanup on failure" sequence.
|
|
64
|
+
|
|
65
|
+
**`@object-ui/data-objectstack` — `ObjectStackAdapter.batchTransaction(operations)`**
|
|
66
|
+
|
|
67
|
+
- New method posting `{ operations }` to `POST /api/v1/batch`. Operations run in one server transaction. A field value of `{ $ref: <earlier op index> }` resolves to that op's generated id, so a child can reference its parent created earlier in the same batch (master-detail FK). Throws `ObjectStackError('BATCH_ERROR')` on a non-2xx response.
|
|
68
|
+
|
|
69
|
+
**`@object-ui/plugin-form`**
|
|
70
|
+
|
|
71
|
+
- `MasterDetailForm` now detects `dataSource.batchTransaction` and, on a NEW parent, builds one atomic batch (parent at index 0, each child FK set to `{ $ref: 0 }`) via the new pure helper `buildMasterDetailBatch`. Client-side total rollups are merged into the parent payload before the batch. Edit mode and adapters without `batchTransaction` keep the existing client-orchestrated path.
|
|
72
|
+
- `ObjectForm` gained a `submitHandler` hook: when supplied, the form validates and hands the collected values to the host instead of calling `dataSource.create` / `dataSource.update`. `MasterDetailForm` uses it to own the atomic parent+children write while the parent fields are still rendered by `ObjectForm`.
|
|
73
|
+
|
|
74
|
+
**`@object-ui/types`**
|
|
75
|
+
|
|
76
|
+
- `ObjectFormSchema.submitHandler?: (values) => any | Promise<any>` — typed override for host-owned persistence.
|
|
77
|
+
|
|
78
|
+
Pairs with the framework-side ambient-transaction fix (ObjectQL `AsyncLocalStorage` transaction propagation) and the `/api/v1/batch` endpoint added in `@objectstack/rest`.
|
|
79
|
+
|
|
80
|
+
### Patch Changes
|
|
81
|
+
|
|
82
|
+
- b99d9bd: ADR-0048: package-scope the Studio metadata editor read. Two installed packages
|
|
83
|
+
may ship metadata with the same `type`/`name`; the editor now resolves the right
|
|
84
|
+
one instead of first-match.
|
|
85
|
+
|
|
86
|
+
- `MetadataClient`: `layered()` and `getDraft()` accept `{ packageId }`, and
|
|
87
|
+
`get()` emits the `package` query param (→ server prefer-local, `?package=`).
|
|
88
|
+
- `ResourceListPage`: each item's edit link carries its owning package
|
|
89
|
+
(`?package=<row._packageId>`), so even the unscoped "all" list disambiguates;
|
|
90
|
+
falls back to the workspace suffix for runtime/overlay-only rows.
|
|
91
|
+
- `ResourceEditPage`: reads `?package=` and scopes the layered + draft read to
|
|
92
|
+
that package. (The route's `:appName` is the Studio app, not the edited item's
|
|
93
|
+
owner, so the scope must come from the URL, not the active app.)
|
|
94
|
+
|
|
95
|
+
- a58c6b8: fix(datasource): exclude form-family views from `listViews()`
|
|
96
|
+
|
|
97
|
+
`OBJECTSTACKDataSource.listViews(objectName)` feeds the object list-view
|
|
98
|
+
switcher (`ObjectView` → `ViewTabBar`), but returned **every** view bound to
|
|
99
|
+
the object — including form-family ones. With the backend now exposing each
|
|
100
|
+
view as an independent **ViewItem** carrying a `viewKind` discriminant
|
|
101
|
+
(ADR-0017, "Object has-many View"), a form view such as `crm_activity.default`
|
|
102
|
+
(expanded from `formViews.default`) leaked in as a spurious switcher tab and,
|
|
103
|
+
when opened, fell back to the default grid.
|
|
104
|
+
|
|
105
|
+
`listViews()` now filters out `viewKind` `form`/`detail` items so only
|
|
106
|
+
list-family views reach the switcher. Bare view specs without a `viewKind`
|
|
107
|
+
(legacy artifacts and user-saved views) are still treated as list views.
|
|
108
|
+
|
|
109
|
+
- Updated dependencies [5976ba3]
|
|
110
|
+
- Updated dependencies [eaccefd]
|
|
111
|
+
- Updated dependencies [f7f325d]
|
|
112
|
+
- Updated dependencies [c12986e]
|
|
113
|
+
- Updated dependencies [71d7ce0]
|
|
114
|
+
- Updated dependencies [053c948]
|
|
115
|
+
- Updated dependencies [ddbe4a2]
|
|
116
|
+
- Updated dependencies [2d47e94]
|
|
117
|
+
- Updated dependencies [9049bbe]
|
|
118
|
+
- Updated dependencies [cb2fdb1]
|
|
119
|
+
- Updated dependencies [c3749eb]
|
|
120
|
+
- Updated dependencies [6cfa330]
|
|
121
|
+
- Updated dependencies [ad8ade6]
|
|
122
|
+
- Updated dependencies [d54346c]
|
|
123
|
+
- Updated dependencies [3870c20]
|
|
124
|
+
- Updated dependencies [b88c560]
|
|
125
|
+
- Updated dependencies [d16566f]
|
|
126
|
+
- Updated dependencies [1394e34]
|
|
127
|
+
- Updated dependencies [300d755]
|
|
128
|
+
- Updated dependencies [4eb9cb6]
|
|
129
|
+
- Updated dependencies [7c239fd]
|
|
130
|
+
- Updated dependencies [858ad94]
|
|
131
|
+
- Updated dependencies [2270239]
|
|
132
|
+
- Updated dependencies [8d1195d]
|
|
133
|
+
- @object-ui/core@7.0.0
|
|
134
|
+
- @object-ui/types@7.0.0
|
|
135
|
+
|
|
3
136
|
## 6.2.3
|
|
4
137
|
|
|
5
138
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -814,9 +814,12 @@ var MetadataClient = class _MetadataClient {
|
|
|
814
814
|
__publicField(this, "base");
|
|
815
815
|
__publicField(this, "fetchImpl");
|
|
816
816
|
__publicField(this, "headers");
|
|
817
|
+
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
818
|
+
__publicField(this, "previewDrafts");
|
|
817
819
|
this.base = buildBase(config);
|
|
818
820
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
819
821
|
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
822
|
+
this.previewDrafts = config.previewDrafts === true;
|
|
820
823
|
}
|
|
821
824
|
/** Update the client's environment scope at runtime. */
|
|
822
825
|
withEnvironment(environmentId) {
|
|
@@ -824,7 +827,24 @@ var MetadataClient = class _MetadataClient {
|
|
|
824
827
|
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
825
828
|
environmentId,
|
|
826
829
|
fetch: this.fetchImpl,
|
|
827
|
-
headers: this.headers
|
|
830
|
+
headers: this.headers,
|
|
831
|
+
previewDrafts: this.previewDrafts
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Derive a client whose reads render the draft-overlaid world (or not).
|
|
836
|
+
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
837
|
+
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
838
|
+
*/
|
|
839
|
+
withPreviewDrafts(previewDrafts) {
|
|
840
|
+
if (previewDrafts === this.previewDrafts) return this;
|
|
841
|
+
const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
|
|
842
|
+
return new _MetadataClient({
|
|
843
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
844
|
+
...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
|
|
845
|
+
fetch: this.fetchImpl,
|
|
846
|
+
headers: this.headers,
|
|
847
|
+
previewDrafts
|
|
828
848
|
});
|
|
829
849
|
}
|
|
830
850
|
/** List all registered metadata types (returns the registry rows). */
|
|
@@ -842,7 +862,10 @@ var MetadataClient = class _MetadataClient {
|
|
|
842
862
|
}
|
|
843
863
|
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
844
864
|
async list(type, options = {}) {
|
|
845
|
-
const
|
|
865
|
+
const params = [];
|
|
866
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
867
|
+
if (this.previewDrafts) params.push("preview=draft");
|
|
868
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
846
869
|
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
847
870
|
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
848
871
|
if (!res.ok) throw await parseError(res);
|
|
@@ -853,25 +876,70 @@ var MetadataClient = class _MetadataClient {
|
|
|
853
876
|
}
|
|
854
877
|
return [];
|
|
855
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* List pending DRAFT items (ADR-0033) — what an AI authored but nobody
|
|
881
|
+
* published yet. `list()` only sees published/active metadata, so a
|
|
882
|
+
* just-built app package looks empty there; this surfaces the drafts so the
|
|
883
|
+
* console can show a "pending changes" view and draft-aware package contents.
|
|
884
|
+
* Optionally narrow by `packageId` and/or `type`. Returns light headers
|
|
885
|
+
* (no body) carrying `packageId` for grouping.
|
|
886
|
+
*/
|
|
887
|
+
async listDrafts(options = {}) {
|
|
888
|
+
const params = [];
|
|
889
|
+
if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
|
|
890
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
891
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
892
|
+
const url = `${this.base}/_drafts${qs}`;
|
|
893
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
894
|
+
if (!res.ok) throw await parseError(res);
|
|
895
|
+
const data = await res.json();
|
|
896
|
+
if (Array.isArray(data)) return data;
|
|
897
|
+
return data?.drafts ?? data?.data?.drafts ?? [];
|
|
898
|
+
}
|
|
856
899
|
/**
|
|
857
900
|
* Get a single metadata item. Returns the unwrapped item content
|
|
858
901
|
* (matching the framework REST handler which calls `res.json(item)`).
|
|
859
902
|
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
860
903
|
*/
|
|
861
|
-
async get(type, name) {
|
|
862
|
-
const
|
|
904
|
+
async get(type, name, options = {}) {
|
|
905
|
+
const params = [];
|
|
906
|
+
if (options.state === "draft") params.push("state=draft");
|
|
907
|
+
else if (this.previewDrafts) params.push("preview=draft");
|
|
908
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
909
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
910
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
863
911
|
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
864
912
|
if (res.status === 404) return null;
|
|
865
913
|
if (!res.ok) throw await parseError(res);
|
|
866
914
|
return await res.json();
|
|
867
915
|
}
|
|
916
|
+
/**
|
|
917
|
+
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
918
|
+
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
919
|
+
* back to the published overlay or the artifact registry — a `null`
|
|
920
|
+
* unambiguously means "nothing to publish".
|
|
921
|
+
*
|
|
922
|
+
* Note: the framework wraps draft responses in an envelope
|
|
923
|
+
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
924
|
+
* read `.item` to get the body. The legacy `get()` returns the
|
|
925
|
+
* unwrapped body, so this method preserves that asymmetry by
|
|
926
|
+
* returning whatever the server sent.
|
|
927
|
+
*/
|
|
928
|
+
async getDraft(type, name, options = {}) {
|
|
929
|
+
return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
|
|
930
|
+
}
|
|
868
931
|
/**
|
|
869
932
|
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
870
933
|
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
871
|
-
* envelopes; we send bare for consistency.
|
|
934
|
+
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
935
|
+
* stage the change without publishing (Studio's "Save" button).
|
|
872
936
|
*/
|
|
873
937
|
async save(type, name, item, options = {}) {
|
|
874
|
-
const
|
|
938
|
+
const params = [];
|
|
939
|
+
if (options.force) params.push("force=true");
|
|
940
|
+
if (options.mode === "draft") params.push("mode=draft");
|
|
941
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
942
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
875
943
|
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
876
944
|
const headers = {
|
|
877
945
|
...this.headers,
|
|
@@ -887,13 +955,38 @@ var MetadataClient = class _MetadataClient {
|
|
|
887
955
|
if (!res.ok) throw await parseError(res);
|
|
888
956
|
return await res.json();
|
|
889
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* Publish a single pending draft BY REFERENCE — promotes the draft row for
|
|
960
|
+
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
961
|
+
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
962
|
+
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
963
|
+
* exact set returned by {@link listDrafts} without needing a package.
|
|
964
|
+
*
|
|
965
|
+
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
966
|
+
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
967
|
+
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
968
|
+
* rather than assume the data went live.
|
|
969
|
+
*/
|
|
970
|
+
async publishDraft(type, name) {
|
|
971
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
972
|
+
const res = await this.fetchImpl(url, {
|
|
973
|
+
method: "POST",
|
|
974
|
+
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
975
|
+
body: "{}"
|
|
976
|
+
});
|
|
977
|
+
if (!res.ok) throw await parseError(res);
|
|
978
|
+
const body = await res.json().catch(() => ({}));
|
|
979
|
+
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
980
|
+
return inner;
|
|
981
|
+
}
|
|
890
982
|
/**
|
|
891
983
|
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
892
984
|
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
893
985
|
* customisation, if any), and `effective` (what the runtime sees).
|
|
894
986
|
*/
|
|
895
|
-
async layered(type, name) {
|
|
896
|
-
const
|
|
987
|
+
async layered(type, name, options = {}) {
|
|
988
|
+
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
|
|
989
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
|
|
897
990
|
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
898
991
|
if (res.status === 404) {
|
|
899
992
|
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
@@ -913,7 +1006,50 @@ var MetadataClient = class _MetadataClient {
|
|
|
913
1006
|
code: body.code ?? null,
|
|
914
1007
|
overlay: body.overlay ?? null,
|
|
915
1008
|
overlayScope: body.overlayScope ?? null,
|
|
916
|
-
effective: body.effective ?? null
|
|
1009
|
+
effective: body.effective ?? null,
|
|
1010
|
+
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
1011
|
+
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
1012
|
+
// the editor can render lock affordances without a second round trip.
|
|
1013
|
+
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
1014
|
+
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
1015
|
+
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
1016
|
+
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
1017
|
+
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
1018
|
+
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
1019
|
+
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
1020
|
+
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
1021
|
+
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
1022
|
+
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Cross-type sweep of load-time validation results — calls
|
|
1027
|
+
* `GET /meta/diagnostics`. Returns every entry the framework
|
|
1028
|
+
* considers invalid (or, when `severity: 'warning'`, also entries
|
|
1029
|
+
* with only warnings).
|
|
1030
|
+
*
|
|
1031
|
+
* Used by the Studio's governance overview page and by the
|
|
1032
|
+
* directory page to show "N invalid" badges per metadata type.
|
|
1033
|
+
*/
|
|
1034
|
+
async diagnostics(options = {}) {
|
|
1035
|
+
const params = [];
|
|
1036
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
1037
|
+
if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
|
|
1038
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
1039
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
1040
|
+
const url = `${this.base}/diagnostics${qs}`;
|
|
1041
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1042
|
+
if (res.status === 404) {
|
|
1043
|
+
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
1044
|
+
}
|
|
1045
|
+
if (!res.ok) throw await parseError(res);
|
|
1046
|
+
const data = await res.json();
|
|
1047
|
+
return {
|
|
1048
|
+
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
1049
|
+
total: typeof data.total === "number" ? data.total : 0,
|
|
1050
|
+
scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
|
|
1051
|
+
scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
|
|
1052
|
+
stats: data.stats && typeof data.stats === "object" ? data.stats : {}
|
|
917
1053
|
};
|
|
918
1054
|
}
|
|
919
1055
|
/**
|
|
@@ -932,9 +1068,12 @@ var MetadataClient = class _MetadataClient {
|
|
|
932
1068
|
/**
|
|
933
1069
|
* Reset a metadata customization overlay back to the artifact default.
|
|
934
1070
|
* Idempotent: returns the result even when no overlay row existed.
|
|
1071
|
+
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
1072
|
+
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
935
1073
|
*/
|
|
936
1074
|
async reset(type, name, options = {}) {
|
|
937
|
-
const
|
|
1075
|
+
const qs = options.state === "draft" ? "?state=draft" : "";
|
|
1076
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
938
1077
|
const headers = { ...this.headers };
|
|
939
1078
|
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
940
1079
|
if (options.actor) headers["X-Actor"] = options.actor;
|
|
@@ -942,6 +1081,75 @@ var MetadataClient = class _MetadataClient {
|
|
|
942
1081
|
if (!res.ok) throw await parseError(res);
|
|
943
1082
|
return await res.json();
|
|
944
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Promote the pending draft of an item to the active overlay
|
|
1086
|
+
* (POST `/meta/:type/:name/publish`). Returns
|
|
1087
|
+
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
1088
|
+
* nothing is pending and `409 metadata_conflict` if the published
|
|
1089
|
+
* overlay moved while the draft was sitting.
|
|
1090
|
+
*/
|
|
1091
|
+
async publish(type, name, options = {}) {
|
|
1092
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
1093
|
+
const headers = {
|
|
1094
|
+
...this.headers,
|
|
1095
|
+
"Content-Type": "application/json"
|
|
1096
|
+
};
|
|
1097
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1098
|
+
const res = await this.fetchImpl(url, {
|
|
1099
|
+
method: "POST",
|
|
1100
|
+
headers,
|
|
1101
|
+
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
1102
|
+
});
|
|
1103
|
+
if (!res.ok) throw await parseError(res);
|
|
1104
|
+
return await res.json();
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
1108
|
+
* The server reads the history row at `toVersion`, writes its body back
|
|
1109
|
+
* as the active overlay with `operation_type='revert'`. Returns
|
|
1110
|
+
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
1111
|
+
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
1112
|
+
* when the target version is a delete tombstone.
|
|
1113
|
+
*/
|
|
1114
|
+
async rollback(type, name, toVersion, options = {}) {
|
|
1115
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
|
|
1116
|
+
const headers = {
|
|
1117
|
+
...this.headers,
|
|
1118
|
+
"Content-Type": "application/json"
|
|
1119
|
+
};
|
|
1120
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1121
|
+
const res = await this.fetchImpl(url, {
|
|
1122
|
+
method: "POST",
|
|
1123
|
+
headers,
|
|
1124
|
+
body: JSON.stringify({
|
|
1125
|
+
toVersion,
|
|
1126
|
+
...options.message ? { message: options.message } : {}
|
|
1127
|
+
})
|
|
1128
|
+
});
|
|
1129
|
+
if (!res.ok) throw await parseError(res);
|
|
1130
|
+
return await res.json();
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Compute a structured top-level key diff between two history versions
|
|
1134
|
+
* (GET `/meta/:type/:name/diff?from=&to=`). Returns
|
|
1135
|
+
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
1136
|
+
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
1137
|
+
* pre-rollback previews.
|
|
1138
|
+
*/
|
|
1139
|
+
async diff(type, name, fromVersion, toVersion) {
|
|
1140
|
+
const params = new URLSearchParams();
|
|
1141
|
+
if (fromVersion !== void 0) params.set("from", String(fromVersion));
|
|
1142
|
+
if (toVersion !== void 0) params.set("to", String(toVersion));
|
|
1143
|
+
const qs = params.toString();
|
|
1144
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
|
|
1145
|
+
const res = await this.fetchImpl(url, {
|
|
1146
|
+
method: "GET",
|
|
1147
|
+
headers: this.headers,
|
|
1148
|
+
cache: "no-store"
|
|
1149
|
+
});
|
|
1150
|
+
if (!res.ok) throw await parseError(res);
|
|
1151
|
+
return await res.json();
|
|
1152
|
+
}
|
|
945
1153
|
/** Fetch the durable history (change log) for one metadata item. */
|
|
946
1154
|
async history(type, name, options = {}) {
|
|
947
1155
|
const params = new URLSearchParams();
|
|
@@ -953,6 +1161,22 @@ var MetadataClient = class _MetadataClient {
|
|
|
953
1161
|
if (!res.ok) throw await parseError(res);
|
|
954
1162
|
return await res.json();
|
|
955
1163
|
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Fetch the protection-audit trail for one metadata item
|
|
1166
|
+
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
1167
|
+
* delete/reset attempt — allowed, denied, or forced — so the
|
|
1168
|
+
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
1169
|
+
* and whether a lock blocked it.
|
|
1170
|
+
*/
|
|
1171
|
+
async audit(type, name, options = {}) {
|
|
1172
|
+
const params = new URLSearchParams();
|
|
1173
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
1174
|
+
const qs = params.toString();
|
|
1175
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
|
|
1176
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1177
|
+
if (!res.ok) throw await parseError(res);
|
|
1178
|
+
return await res.json();
|
|
1179
|
+
}
|
|
956
1180
|
};
|
|
957
1181
|
|
|
958
1182
|
// src/security.ts
|
|
@@ -1437,7 +1661,7 @@ var ObjectStackAdapter = class {
|
|
|
1437
1661
|
if (existing) return existing;
|
|
1438
1662
|
const promise = (async () => {
|
|
1439
1663
|
await this.connect();
|
|
1440
|
-
if (params?.$expand && params.$expand.length > 0) {
|
|
1664
|
+
if (params?.$expand && params.$expand.length > 0 || params?.$search != null && String(params.$search).trim() !== "") {
|
|
1441
1665
|
const result = await this.rawFindWithPopulate(resource, params);
|
|
1442
1666
|
return this.normalizeQueryResult(result, params);
|
|
1443
1667
|
}
|
|
@@ -1635,6 +1859,34 @@ var ObjectStackAdapter = class {
|
|
|
1635
1859
|
* @param data - Array of records to process
|
|
1636
1860
|
* @returns Promise resolving to array of results
|
|
1637
1861
|
*/
|
|
1862
|
+
/**
|
|
1863
|
+
* Cross-object transactional batch (ObjectStack #1604). Runs the operations
|
|
1864
|
+
* in ONE server transaction — commit all or roll back all. A field value of
|
|
1865
|
+
* `{ $ref: <earlier op index> }` resolves to that op's created id, so a child
|
|
1866
|
+
* can reference its parent created earlier in the same batch (master-detail).
|
|
1867
|
+
*/
|
|
1868
|
+
async batchTransaction(operations) {
|
|
1869
|
+
const url = `${this.baseUrl}/api/v1/batch`;
|
|
1870
|
+
const response = await fetch(url, {
|
|
1871
|
+
method: "POST",
|
|
1872
|
+
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
|
1873
|
+
// Send the session cookie too: in the browser console auth is cookie-based
|
|
1874
|
+
// (no bearer `token`), so without `credentials: 'include'` this raw fetch
|
|
1875
|
+
// is unauthenticated — every master-detail batch save would 401. Bearer
|
|
1876
|
+
// (server-to-server) and cookie (console) auth now both work.
|
|
1877
|
+
credentials: "include",
|
|
1878
|
+
body: JSON.stringify({ operations })
|
|
1879
|
+
});
|
|
1880
|
+
if (!response.ok) {
|
|
1881
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
1882
|
+
throw new ObjectStackError(
|
|
1883
|
+
error.error || error.message || `Batch failed with status ${response.status}`,
|
|
1884
|
+
"BATCH_ERROR",
|
|
1885
|
+
response.status
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
return response.json();
|
|
1889
|
+
}
|
|
1638
1890
|
async bulk(resource, operation, data) {
|
|
1639
1891
|
await this.connect();
|
|
1640
1892
|
if (!data || data.length === 0) {
|
|
@@ -1772,13 +2024,14 @@ var ObjectStackAdapter = class {
|
|
|
1772
2024
|
const resultObj = result;
|
|
1773
2025
|
const records = resultObj.records || resultObj.value || [];
|
|
1774
2026
|
const total = resultObj.total ?? resultObj.count ?? records.length;
|
|
2027
|
+
const hasMore = typeof resultObj.hasMore === "boolean" ? resultObj.hasMore : params?.$top ? records.length === params.$top : false;
|
|
1775
2028
|
return {
|
|
1776
2029
|
data: records,
|
|
1777
2030
|
total,
|
|
1778
2031
|
// Calculate page number safely
|
|
1779
2032
|
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
|
|
1780
2033
|
pageSize: params?.$top,
|
|
1781
|
-
hasMore
|
|
2034
|
+
hasMore
|
|
1782
2035
|
};
|
|
1783
2036
|
}
|
|
1784
2037
|
/**
|
|
@@ -1798,6 +2051,12 @@ var ObjectStackAdapter = class {
|
|
|
1798
2051
|
if (params.$skip !== void 0) {
|
|
1799
2052
|
queryParams.set("skip", String(params.$skip));
|
|
1800
2053
|
}
|
|
2054
|
+
if (params.$search != null && String(params.$search).trim() !== "") {
|
|
2055
|
+
queryParams.set("search", String(params.$search).trim());
|
|
2056
|
+
}
|
|
2057
|
+
if (params.$searchFields && params.$searchFields.length > 0) {
|
|
2058
|
+
queryParams.set("searchFields", params.$searchFields.join(","));
|
|
2059
|
+
}
|
|
1801
2060
|
if (params.$select && params.$select.length > 0) {
|
|
1802
2061
|
const selectFields = params.$select.includes("id") ? params.$select : ["id", ...params.$select];
|
|
1803
2062
|
queryParams.set("select", selectFields.join(","));
|
|
@@ -1898,6 +2157,12 @@ var ObjectStackAdapter = class {
|
|
|
1898
2157
|
if (params.$top !== void 0) {
|
|
1899
2158
|
options.top = params.$top;
|
|
1900
2159
|
}
|
|
2160
|
+
if (params.$search != null && String(params.$search).trim() !== "") {
|
|
2161
|
+
options.search = String(params.$search).trim();
|
|
2162
|
+
}
|
|
2163
|
+
if (params.$searchFields && params.$searchFields.length > 0) {
|
|
2164
|
+
options.searchFields = params.$searchFields;
|
|
2165
|
+
}
|
|
1901
2166
|
return options;
|
|
1902
2167
|
}
|
|
1903
2168
|
/**
|
|
@@ -2004,7 +2269,7 @@ var ObjectStackAdapter = class {
|
|
|
2004
2269
|
try {
|
|
2005
2270
|
const cacheKey = `view:${objectName}:${viewId}`;
|
|
2006
2271
|
return await this.metadataCache.get(cacheKey, async () => {
|
|
2007
|
-
const result = await this.client.meta.getItem(
|
|
2272
|
+
const result = await this.client.meta.getItem("view", viewId);
|
|
2008
2273
|
if (result && result.item) return result.item;
|
|
2009
2274
|
return result ?? null;
|
|
2010
2275
|
});
|
|
@@ -2063,12 +2328,26 @@ var ObjectStackAdapter = class {
|
|
|
2063
2328
|
try {
|
|
2064
2329
|
const result = await this.client.meta.getItems("view");
|
|
2065
2330
|
const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
2331
|
+
const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
|
|
2066
2332
|
return items.filter((v) => {
|
|
2067
2333
|
if (!v) return false;
|
|
2068
2334
|
const spec = v.list ?? v;
|
|
2069
2335
|
const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
|
|
2070
|
-
|
|
2071
|
-
|
|
2336
|
+
if (obj !== objectName) return false;
|
|
2337
|
+
const viewKind = v.viewKind ?? spec?.viewKind;
|
|
2338
|
+
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
2339
|
+
}).map((v) => {
|
|
2340
|
+
const spec = v.list ?? v;
|
|
2341
|
+
if (spec && spec.config && typeof spec.config === "object") {
|
|
2342
|
+
return {
|
|
2343
|
+
...spec.config,
|
|
2344
|
+
name: spec.name ?? spec.config.name,
|
|
2345
|
+
label: spec.label ?? spec.config.label,
|
|
2346
|
+
isDefault: !!spec.isDefault
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
return spec;
|
|
2350
|
+
});
|
|
2072
2351
|
} catch (err) {
|
|
2073
2352
|
console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
|
|
2074
2353
|
return [];
|
|
@@ -2285,6 +2564,56 @@ var ObjectStackAdapter = class {
|
|
|
2285
2564
|
return this.aggregateClientSide(records, params);
|
|
2286
2565
|
}
|
|
2287
2566
|
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
|
|
2569
|
+
*
|
|
2570
|
+
* Posts to `POST /api/v1/analytics/dataset/query` (see `@objectstack/rest`
|
|
2571
|
+
* `registerAnalyticsEndpoints`). Accepts either a saved dataset name or an
|
|
2572
|
+
* inline draft definition — the inline form is what the Studio dataset
|
|
2573
|
+
* editor sends to preview an unsaved draft. The adapter's bearer token is
|
|
2574
|
+
* forwarded so tenant/RLS scoping (ADR-0021 D-C) is enforced server-side.
|
|
2575
|
+
*
|
|
2576
|
+
* Unlike {@link aggregate}, this does NOT fall back to client-side
|
|
2577
|
+
* aggregation: cross-object joins can only run on the server, so a failure
|
|
2578
|
+
* is surfaced to the caller (the preview panel shows the error) rather than
|
|
2579
|
+
* silently returning wrong numbers.
|
|
2580
|
+
*
|
|
2581
|
+
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
|
|
2582
|
+
* @param selection - Dimension/measure names to project + runtime directives.
|
|
2583
|
+
*/
|
|
2584
|
+
async queryDataset(dataset, selection) {
|
|
2585
|
+
await this.connect();
|
|
2586
|
+
const base = (this.baseUrl || "").replace(/\/$/, "");
|
|
2587
|
+
const url = `${base}/api/v1/analytics/dataset/query`;
|
|
2588
|
+
const previewDrafts = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("preview") === "draft";
|
|
2589
|
+
const requestBody = typeof dataset === "string" ? { datasetName: dataset, selection, ...previewDrafts ? { previewDrafts: true } : {} } : { dataset, selection, ...previewDrafts ? { previewDrafts: true } : {} };
|
|
2590
|
+
const res = await this.fetchImpl(url, {
|
|
2591
|
+
method: "POST",
|
|
2592
|
+
headers: {
|
|
2593
|
+
"Content-Type": "application/json",
|
|
2594
|
+
...this.token ? { Authorization: `Bearer ${this.token}` } : {}
|
|
2595
|
+
},
|
|
2596
|
+
body: JSON.stringify(requestBody)
|
|
2597
|
+
});
|
|
2598
|
+
if (!res.ok) {
|
|
2599
|
+
let detail = "";
|
|
2600
|
+
try {
|
|
2601
|
+
const errBody = await res.json();
|
|
2602
|
+
detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
|
|
2603
|
+
} catch {
|
|
2604
|
+
}
|
|
2605
|
+
throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
2606
|
+
}
|
|
2607
|
+
const payload = await res.json();
|
|
2608
|
+
const data = payload && typeof payload === "object" && "success" in payload && "data" in payload ? payload.data : payload;
|
|
2609
|
+
const rows = Array.isArray(data?.rows) ? data.rows : Array.isArray(data) ? data : [];
|
|
2610
|
+
const fields = Array.isArray(data?.fields) ? data.fields : [];
|
|
2611
|
+
const object = typeof data?.object === "string" ? data.object : void 0;
|
|
2612
|
+
const dimensionFields = data?.dimensionFields && typeof data.dimensionFields === "object" ? data.dimensionFields : void 0;
|
|
2613
|
+
const drillRawRows = Array.isArray(data?.drillRawRows) ? data.drillRawRows : void 0;
|
|
2614
|
+
const totals = Array.isArray(data?.totals) ? data.totals : void 0;
|
|
2615
|
+
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
2616
|
+
}
|
|
2288
2617
|
/** Client-side aggregation fallback */
|
|
2289
2618
|
aggregateClientSide(records, params) {
|
|
2290
2619
|
const { field, function: aggFn, groupBy } = params;
|