@object-ui/data-objectstack 17.5.0 → 17.7.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 +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/metadata-client.ts
DELETED
|
@@ -1,1046 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* MetadataClient
|
|
11
|
-
*
|
|
12
|
-
* Thin, framework-agnostic HTTP client for the ObjectStack metadata
|
|
13
|
-
* API (`/api/v1/meta/*`). Used by the platform Setup app surfaces in
|
|
14
|
-
* `@object-ui/plugin-designer` (Object Manager, Field Designer, etc.)
|
|
15
|
-
* so they can read and write protocol metadata without depending on
|
|
16
|
-
* `ObjectStackAdapter` (which is a generic data-source binding and
|
|
17
|
-
* carries a lot of view/dashboard-specific behaviour we don't need
|
|
18
|
-
* here).
|
|
19
|
-
*
|
|
20
|
-
* Endpoints (see `packages/rest/src/rest-server.ts`):
|
|
21
|
-
* GET /api/v1/meta — list all metadata types
|
|
22
|
-
* GET /api/v1/meta/:type — list items of a type
|
|
23
|
-
* GET /api/v1/meta/:type/:name — get one item (returns the
|
|
24
|
-
* unwrapped item content)
|
|
25
|
-
* PUT /api/v1/meta/:type/:name — save (overlay) one item
|
|
26
|
-
* honours `If-Match` for OCC
|
|
27
|
-
* DELETE /api/v1/meta/:type/:name — reset overlay to artifact
|
|
28
|
-
* GET /api/v1/meta/:type/:name/history — durable change log
|
|
29
|
-
*
|
|
30
|
-
* The client deliberately keeps the response shape opaque (typed as
|
|
31
|
-
* `unknown`/generic `T`) so callers explicitly narrow per-metadata-
|
|
32
|
-
* type, mirroring the framework's "single Zod source per type" rule.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import type { RuntimeAuthoringIssue } from '@objectstack/spec/api';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* One advisory finding the framework's runtime authoring gate produced for a
|
|
39
|
-
* save that SUCCEEDED (objectstack#7435, landed as `89d7b35a7`).
|
|
40
|
-
*
|
|
41
|
-
* Re-exported from `@objectstack/spec` rather than restated: this is the D3
|
|
42
|
-
* finding shape, declared once as `RuntimeAuthoringIssueSchema` and shared with
|
|
43
|
-
* the 422 `issues[]` so the two cannot drift apart. Hand-copying the six keys
|
|
44
|
-
* here is exactly the fork `check:spec-symbol-derivation` exists to reject, and
|
|
45
|
-
* it is the same discipline `DroppedFieldsEvent` already follows one module over.
|
|
46
|
-
*
|
|
47
|
-
* `severity` carries the spec's full `error | warning | info` union. On THIS
|
|
48
|
-
* channel it is never `'error'` — an error-severity finding is a 422 and never
|
|
49
|
-
* reaches a 2xx body — but the union is copied whole rather than narrowed,
|
|
50
|
-
* because a consumer-side re-spelling of a producer's enum is the drift the
|
|
51
|
-
* gate above is about.
|
|
52
|
-
*/
|
|
53
|
-
export type { RuntimeAuthoringIssue };
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Emitted after a {@link MetadataClient.save} whose response carried a
|
|
57
|
-
* non-empty `advisories` array. The save SUCCEEDED — the row persisted and the
|
|
58
|
-
* server returned 200 — so this is advisory, never a failure.
|
|
59
|
-
*
|
|
60
|
-
* Deliberately the same shape of seam as `ObjectStackAdapter.onWriteWarning`
|
|
61
|
-
* (#3431/#3455): a successful write whose response carries something the author
|
|
62
|
-
* needs to be told, surfaced to the shell as an event so the data layer never
|
|
63
|
-
* imports a toaster. The difference is only which door produced it — that one
|
|
64
|
-
* is record CRUD, this one is the metadata save door.
|
|
65
|
-
*/
|
|
66
|
-
export interface MetadataSaveAdvisoryEvent {
|
|
67
|
-
/** Metadata type saved (e.g. `'flow'`). */
|
|
68
|
-
type: string;
|
|
69
|
-
/** Item name saved. */
|
|
70
|
-
name: string;
|
|
71
|
-
/**
|
|
72
|
-
* The save mode the call used. `'draft'` writes are **never gated** — the
|
|
73
|
-
* framework returns at `runtime-authoring-gate.ts`'s D1 early-return
|
|
74
|
-
* (`if (args.state !== 'active') return null`), so a draft save produces no
|
|
75
|
-
* findings at all and this event never fires for one. Carried anyway so a
|
|
76
|
-
* consumer reading the event can tell which door it came through.
|
|
77
|
-
*/
|
|
78
|
-
mode: 'draft' | 'publish';
|
|
79
|
-
/** The findings. Never empty — the event is not emitted otherwise. */
|
|
80
|
-
advisories: RuntimeAuthoringIssue[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Event listener type for save-advisory events. */
|
|
84
|
-
export type MetadataSaveAdvisoryListener = (event: MetadataSaveAdvisoryEvent) => void;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Read the `advisories` array off a save response, defensively.
|
|
88
|
-
*
|
|
89
|
-
* The server omits the key entirely on a clean save, so `undefined` is the
|
|
90
|
-
* common case and means "nothing to say". Anything that is not an array of
|
|
91
|
-
* objects carrying the six required keys is dropped rather than rendered: a
|
|
92
|
-
* half-shaped finding would print blanks at the author, and this channel must
|
|
93
|
-
* never turn a successful save into noise.
|
|
94
|
-
*/
|
|
95
|
-
export function readSaveAdvisories(body: unknown): RuntimeAuthoringIssue[] {
|
|
96
|
-
if (!body || typeof body !== 'object') return [];
|
|
97
|
-
const raw = (body as { advisories?: unknown }).advisories;
|
|
98
|
-
if (!Array.isArray(raw)) return [];
|
|
99
|
-
return raw.filter((f): f is RuntimeAuthoringIssue => {
|
|
100
|
-
if (!f || typeof f !== 'object') return false;
|
|
101
|
-
const c = f as Record<string, unknown>;
|
|
102
|
-
return (
|
|
103
|
-
typeof c.rule === 'string' &&
|
|
104
|
-
typeof c.path === 'string' &&
|
|
105
|
-
typeof c.where === 'string' &&
|
|
106
|
-
typeof c.message === 'string' &&
|
|
107
|
-
typeof c.hint === 'string' &&
|
|
108
|
-
typeof c.severity === 'string'
|
|
109
|
-
);
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface MetadataClientConfig {
|
|
114
|
-
/** Base URL of the ObjectStack server (no trailing slash needed). */
|
|
115
|
-
baseUrl: string;
|
|
116
|
-
/**
|
|
117
|
-
* Optional environment ID to scope reads/writes to a tenant. When
|
|
118
|
-
* provided, requests use the scoped path
|
|
119
|
-
* `/api/v1/environments/:environmentId/meta/...` and overlays are
|
|
120
|
-
* persisted in the environment's own metadata store.
|
|
121
|
-
*/
|
|
122
|
-
environmentId?: string;
|
|
123
|
-
/** Optional custom fetch implementation (defaults to `globalThis.fetch`). */
|
|
124
|
-
fetch?: typeof fetch;
|
|
125
|
-
/** Additional headers (e.g. auth) included on every request. */
|
|
126
|
-
headers?: Record<string, string>;
|
|
127
|
-
/**
|
|
128
|
-
* ADR-0037 Live Canvas — render the world **as-if-published**. When true,
|
|
129
|
-
* `list()` and `get()` append `?preview=draft`, which the framework
|
|
130
|
-
* dispatcher maps to `getMetaItems/getMetaItem({ previewDrafts: true })`:
|
|
131
|
-
* pending ADR-0033 drafts overlay the active registry (draft wins by name,
|
|
132
|
-
* draft-only items surface). Reads only — writes are unaffected, and
|
|
133
|
-
* nothing the preview shows is live until Publish.
|
|
134
|
-
*/
|
|
135
|
-
previewDrafts?: boolean;
|
|
136
|
-
/**
|
|
137
|
-
* Called after a {@link MetadataClient.save} whose 2xx response carried a
|
|
138
|
-
* non-empty `advisories` array (objectstack#7435). The save already
|
|
139
|
-
* succeeded; this is how the shell learns there is something to tell the
|
|
140
|
-
* author instead of the findings being discarded client-side.
|
|
141
|
-
*
|
|
142
|
-
* Set on the CONFIG rather than exposed as a `subscribe()` method on purpose:
|
|
143
|
-
* console metadata clients are minted per-component by `useMetadataClient`,
|
|
144
|
-
* so there is no long-lived instance to subscribe to — but every one of them
|
|
145
|
-
* is built by the single `createConsoleMetadataClient` factory, which is
|
|
146
|
-
* where this gets wired exactly once for every save call site in the app.
|
|
147
|
-
*
|
|
148
|
-
* A throw from this callback is swallowed — an advisory channel must never
|
|
149
|
-
* be able to fail a save that the server already committed.
|
|
150
|
-
*/
|
|
151
|
-
onSaveAdvisory?: MetadataSaveAdvisoryListener;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export interface MetadataListOptions {
|
|
155
|
-
/** Filter by source package id (matches the `package` query param). */
|
|
156
|
-
packageId?: string;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* A pending DRAFT metadata item (ADR-0033), as returned by {@link MetadataClient.listDrafts}.
|
|
161
|
-
* Light header — no body — carrying the owning package so the console can group
|
|
162
|
-
* pending changes by app package.
|
|
163
|
-
*/
|
|
164
|
-
export interface MetadataDraftHeader {
|
|
165
|
-
type: string;
|
|
166
|
-
name: string;
|
|
167
|
-
packageId: string | null;
|
|
168
|
-
updatedAt: string | null;
|
|
169
|
-
updatedBy: string | null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Options for {@link MetadataClient.save} — a WRITE OVER HTTP to
|
|
174
|
-
* `/api/v1/meta/:type/:name`.
|
|
175
|
-
*
|
|
176
|
-
* NOT the spec's `MetadataSaveOptions` (`@objectstack/spec/system` and
|
|
177
|
-
* `/kernel`), whose name this interface wore until objectui#3160
|
|
178
|
-
* (objectstack#4115 ledger batch 6). Both spec copies describe writing a
|
|
179
|
-
* metadata item to a FILE — `format: json|yaml|ts`, `path`, `indent`,
|
|
180
|
-
* `prettify`, `sortKeys`, `backup`, `atomic`, `loader`. Not one of those keys
|
|
181
|
-
* exists here, and not one of these exists there: this is the REST client's
|
|
182
|
-
* request envelope — optimistic concurrency (`ifMatch` → `If-Match`), actor
|
|
183
|
-
* attribution, the destructive-change override, the ADR-0033 draft/publish
|
|
184
|
-
* mode, and the owning package. Same words, different layer.
|
|
185
|
-
*/
|
|
186
|
-
export interface MetadataClientSaveOptions {
|
|
187
|
-
/**
|
|
188
|
-
* Optimistic concurrency token (the `checksum` returned by the last
|
|
189
|
-
* read). When present, sent as the `If-Match` header so concurrent
|
|
190
|
-
* edits get a 409 instead of overwriting each other.
|
|
191
|
-
*/
|
|
192
|
-
ifMatch?: string;
|
|
193
|
-
/** Optional actor id, sent as `X-Actor` for history attribution. */
|
|
194
|
-
actor?: string;
|
|
195
|
-
/**
|
|
196
|
-
* Bypass destructive-change protection (Phase 3a). The server returns
|
|
197
|
-
* `409 destructive_change` with `issues[]` if a write would drop or
|
|
198
|
-
* narrow data; setting `force: true` adds `?force=true` to the request
|
|
199
|
-
* so the operator can confirm and proceed.
|
|
200
|
-
*/
|
|
201
|
-
force?: boolean;
|
|
202
|
-
/**
|
|
203
|
-
* Save mode — `'draft'` writes a pending draft row (invisible to the
|
|
204
|
-
* runtime until `publish()` is called); `'publish'` writes directly
|
|
205
|
-
* to the active overlay. Omit (or set `'publish'`) for the legacy
|
|
206
|
-
* "save = live" behaviour.
|
|
207
|
-
*/
|
|
208
|
-
mode?: 'draft' | 'publish';
|
|
209
|
-
/**
|
|
210
|
-
* Software-package id to bind the saved row to (sent as the `package`
|
|
211
|
-
* query param → `sys_metadata.package_id`). Set when authoring inside a
|
|
212
|
-
* Studio package workspace. Omit for an env-local overlay.
|
|
213
|
-
*/
|
|
214
|
-
packageId?: string;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export interface MetadataGetOptions {
|
|
218
|
-
/**
|
|
219
|
-
* Read a specific overlay state. `'draft'` returns the pending draft
|
|
220
|
-
* body (no fallback to the published overlay or the registry); omit
|
|
221
|
-
* to read the active (published) value.
|
|
222
|
-
*/
|
|
223
|
-
state?: 'active' | 'draft';
|
|
224
|
-
/**
|
|
225
|
-
* Software-package id to scope resolution (sent as the `package` query
|
|
226
|
-
* param → server prefer-local, ADR-0048). Set when reading one item that
|
|
227
|
-
* may collide by name across installed packages (e.g. the Studio editor
|
|
228
|
-
* passes the edited item's owning package). Omit for context-free reads.
|
|
229
|
-
*/
|
|
230
|
-
packageId?: string;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export interface MetadataDeleteOptions extends MetadataClientSaveOptions {
|
|
234
|
-
/**
|
|
235
|
-
* Target state. `'draft'` discards the pending draft (keeps the
|
|
236
|
-
* published overlay intact). Omit to reset the active overlay back
|
|
237
|
-
* to the artifact default (the legacy behaviour).
|
|
238
|
-
*/
|
|
239
|
-
state?: 'active' | 'draft';
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Layered view of a metadata item — Phase 3a `?layers=true`. */
|
|
243
|
-
export interface MetadataLayered<T = unknown> {
|
|
244
|
-
/** Code-level (artifact) item; null if the item only exists as an overlay. */
|
|
245
|
-
code: T | null;
|
|
246
|
-
/** Org/environment overlay (just the saved delta or full overlay row). */
|
|
247
|
-
overlay: T | null;
|
|
248
|
-
/** Overlay scope (`organization` | `environment` | `package` | null). */
|
|
249
|
-
overlayScope: string | null;
|
|
250
|
-
/** Merged effective view — what the runtime actually sees. */
|
|
251
|
-
effective: T | null;
|
|
252
|
-
/**
|
|
253
|
-
* Load-time validation result for `effective` (server-computed via
|
|
254
|
-
* the same Zod registry used at save time). Undefined for types
|
|
255
|
-
* without a registered Zod schema. Surfaced by the Studio as a
|
|
256
|
-
* banner + inline field errors so operators can spot bad metadata
|
|
257
|
-
* without having to hit Save.
|
|
258
|
-
*/
|
|
259
|
-
_diagnostics?: MetadataDiagnostics;
|
|
260
|
-
// ── ADR-0010 Phase 1 — protection envelope ──
|
|
261
|
-
/** 4-state lock: `none` / `no-overlay` / `no-delete` / `full`. */
|
|
262
|
-
lock?: 'none' | 'no-overlay' | 'no-delete' | 'full';
|
|
263
|
-
/** Human-readable reason for the lock (tooltip text). */
|
|
264
|
-
lockReason?: string;
|
|
265
|
-
/** Which layer set the lock: artifact / package / overlay / env-forced. */
|
|
266
|
-
lockSource?: 'artifact' | 'package' | 'overlay' | 'env-forced';
|
|
267
|
-
/** Optional docs URL for the lock reason (rendered as "View docs →"). */
|
|
268
|
-
lockDocsUrl?: string;
|
|
269
|
-
/** Origin of the item: `package` (loader) | `org` (tenant) | `env-forced`. */
|
|
270
|
-
provenance?: 'package' | 'org' | 'env-forced';
|
|
271
|
-
/** Owning package id (denormalised from the loader tag). */
|
|
272
|
-
packageId?: string;
|
|
273
|
-
/** Owning package version. */
|
|
274
|
-
packageVersion?: string;
|
|
275
|
-
/** True when the editor should allow Save (PUT). */
|
|
276
|
-
editable?: boolean;
|
|
277
|
-
/** True when the editor should allow Delete. */
|
|
278
|
-
deletable?: boolean;
|
|
279
|
-
/** True when "Reset to package default" applies (has overlay + artifact). */
|
|
280
|
-
resettable?: boolean;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* One row in the metadata protection-audit trail
|
|
285
|
-
* (ADR-0010 §3.6 / Phase 4.1). Mirrors `sys_metadata_audit` columns
|
|
286
|
-
* the API exposes.
|
|
287
|
-
*/
|
|
288
|
-
export interface MetadataAuditEntry {
|
|
289
|
-
/** Stable audit-row id (uuid). */
|
|
290
|
-
id: unknown;
|
|
291
|
-
/** ISO timestamp when the attempt happened. */
|
|
292
|
-
occurredAt: string;
|
|
293
|
-
/** Who attempted the operation (`x-actor` header or `'system'`). */
|
|
294
|
-
actor: string;
|
|
295
|
-
/** Code path that recorded the row (e.g. `protocol.saveMetaItem`). */
|
|
296
|
-
source: string | null;
|
|
297
|
-
/** Which lifecycle op was attempted. */
|
|
298
|
-
operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset';
|
|
299
|
-
/** Decision: allowed / denied / forced (admin override). */
|
|
300
|
-
outcome: 'allowed' | 'denied' | 'forced';
|
|
301
|
-
/** Machine-readable reason code (`item_locked`, `ok`, …). */
|
|
302
|
-
code: string;
|
|
303
|
-
/** Effective lock at the moment of the attempt. */
|
|
304
|
-
lockState: 'none' | 'no-overlay' | 'no-delete' | 'full' | null;
|
|
305
|
-
/** True when admin forced the write through despite the lock. */
|
|
306
|
-
lockOverridden: boolean;
|
|
307
|
-
/** Request-id for trace correlation (if propagated). */
|
|
308
|
-
requestId: string | null;
|
|
309
|
-
/** Free-text note (often the lock reason). */
|
|
310
|
-
note: string | null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Response shape for `MetadataClient.audit()`. */
|
|
314
|
-
export interface MetadataAuditResponse {
|
|
315
|
-
events: MetadataAuditEntry[];
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Load-time validation envelope attached to metadata items by the
|
|
320
|
-
* framework. Mirrors `MetadataValidationResult` in the kernel spec.
|
|
321
|
-
*/
|
|
322
|
-
export interface MetadataDiagnostics {
|
|
323
|
-
valid: boolean;
|
|
324
|
-
errors?: Array<{ path: string; message: string; code?: string }>;
|
|
325
|
-
warnings?: Array<{ path: string; message: string }>;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/** Options for the cross-type `/meta/diagnostics` sweep call. */
|
|
329
|
-
export interface MetadataDiagnosticsOptions {
|
|
330
|
-
/** Restrict the sweep to a single metadata type (e.g. `'view'`). */
|
|
331
|
-
type?: string;
|
|
332
|
-
/**
|
|
333
|
-
* `'error'` (default) returns only items that fail validation.
|
|
334
|
-
* `'warning'` also includes items whose only diagnostics are warnings.
|
|
335
|
-
*/
|
|
336
|
-
severity?: 'error' | 'warning';
|
|
337
|
-
/** Restrict to items owned by this package id. */
|
|
338
|
-
packageId?: string;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** One row in the `/meta/diagnostics` response. */
|
|
342
|
-
export interface MetadataDiagnosticsEntry {
|
|
343
|
-
type: string;
|
|
344
|
-
name: string;
|
|
345
|
-
diagnostics: MetadataDiagnostics;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Top-level envelope returned by `/meta/diagnostics`. */
|
|
349
|
-
export interface MetadataDiagnosticsSummary {
|
|
350
|
-
entries: MetadataDiagnosticsEntry[];
|
|
351
|
-
/** Number of `entries` returned (post-filter). */
|
|
352
|
-
total: number;
|
|
353
|
-
/** How many metadata types the sweep visited. */
|
|
354
|
-
scannedTypes: number;
|
|
355
|
-
/** How many individual items were validated. */
|
|
356
|
-
scannedItems: number;
|
|
357
|
-
/**
|
|
358
|
-
* Per-type aggregate stats — count of items and the list of
|
|
359
|
-
* packages contributing to each type. Computed in the same sweep
|
|
360
|
-
* so a single call serves both the diagnostics governance page and
|
|
361
|
-
* the directory tile counts / package filter.
|
|
362
|
-
*
|
|
363
|
-
* Optional for backward compatibility with older framework
|
|
364
|
-
* versions that do not yet emit it; clients should fall back to
|
|
365
|
-
* an empty record.
|
|
366
|
-
*/
|
|
367
|
-
stats?: Record<string, { count: number; locked?: number; packages: string[] }>;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Reference back-pointer — Phase 3a `/references`. */
|
|
371
|
-
export interface MetadataReference {
|
|
372
|
-
/** Referencing item's metadata type. */
|
|
373
|
-
fromType: string;
|
|
374
|
-
/** Referencing item's name. */
|
|
375
|
-
fromName: string;
|
|
376
|
-
/** JSON path within the referencing item that holds the reference. */
|
|
377
|
-
path: string;
|
|
378
|
-
/** The actual value seen at `path` (the referenced name). */
|
|
379
|
-
value: string;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export interface MetadataHistoryOptions {
|
|
383
|
-
/** Only return events after this sequence number. */
|
|
384
|
-
sinceSeq?: number;
|
|
385
|
-
/** Limit the number of events returned. */
|
|
386
|
-
limit?: number;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** A single field-anchored spec-validation issue (server `error.details.issues`). */
|
|
390
|
-
export interface MetadataValidationIssue {
|
|
391
|
-
/** Dot-path to the offending field, e.g. `fields.amount.type` (`''` = root). */
|
|
392
|
-
path: string;
|
|
393
|
-
message: string;
|
|
394
|
-
code?: string;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export interface MetadataError extends Error {
|
|
398
|
-
status: number;
|
|
399
|
-
code?: string;
|
|
400
|
-
body?: unknown;
|
|
401
|
-
/** Structured spec-validation issues, when the failure was a validation error. */
|
|
402
|
-
issues?: MetadataValidationIssue[];
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const META_PREFIX = '/meta';
|
|
406
|
-
const API_PREFIX = '/api/v1';
|
|
407
|
-
|
|
408
|
-
function buildBase(config: MetadataClientConfig): string {
|
|
409
|
-
const trimmed = config.baseUrl.replace(/\/+$/, '');
|
|
410
|
-
const scoped = config.environmentId
|
|
411
|
-
? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}`
|
|
412
|
-
: `${API_PREFIX}${META_PREFIX}`;
|
|
413
|
-
// Allow baseUrl to already include `/api/v1`; collapse the duplicate.
|
|
414
|
-
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
415
|
-
const stripped = trimmed.replace(/\/api\/v\d+$/i, '');
|
|
416
|
-
const scopedNoApi = config.environmentId
|
|
417
|
-
? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}`
|
|
418
|
-
: `${API_PREFIX}${META_PREFIX}`;
|
|
419
|
-
return `${stripped}${scopedNoApi}`;
|
|
420
|
-
}
|
|
421
|
-
return `${trimmed}${scoped}`;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async function parseError(res: Response): Promise<MetadataError> {
|
|
425
|
-
let body: unknown;
|
|
426
|
-
try {
|
|
427
|
-
body = await res.json();
|
|
428
|
-
} catch {
|
|
429
|
-
body = await res.text().catch(() => undefined);
|
|
430
|
-
}
|
|
431
|
-
const bodyObj = body && typeof body === 'object' ? (body as Record<string, any>) : undefined;
|
|
432
|
-
// The dispatcher error shape is `{ success:false, error:{ message, code,
|
|
433
|
-
// details:{ code, issues } } }` — `error` is an OBJECT. Older/other surfaces
|
|
434
|
-
// send `error` as a plain string or a top-level `message`. Accept all three
|
|
435
|
-
// (reading `.message` off the object case, not `String(object)` which yields
|
|
436
|
-
// "[object Object]").
|
|
437
|
-
const errNode = bodyObj?.error;
|
|
438
|
-
const message =
|
|
439
|
-
(errNode && typeof errNode === 'object' ? errNode.message : errNode) ??
|
|
440
|
-
bodyObj?.message ??
|
|
441
|
-
`Metadata request failed: ${res.status} ${res.statusText}`;
|
|
442
|
-
const err = new Error(String(message)) as MetadataError;
|
|
443
|
-
err.status = res.status;
|
|
444
|
-
err.body = body;
|
|
445
|
-
// Field-anchored spec-validation issues. Two live server shapes carry them:
|
|
446
|
-
// the REST server puts them at top-level `body.issues` (with `error` a string),
|
|
447
|
-
// the HTTP dispatcher nests them under `error.details.issues` (with `error` an
|
|
448
|
-
// object). Accept both (plus a defensive `error.issues`) so the caller can
|
|
449
|
-
// point at the offending field regardless of which transport served the error.
|
|
450
|
-
const details = errNode && typeof errNode === 'object' ? errNode.details : undefined;
|
|
451
|
-
const issues =
|
|
452
|
-
details?.issues ??
|
|
453
|
-
(errNode && typeof errNode === 'object' ? errNode.issues : undefined) ??
|
|
454
|
-
bodyObj?.issues;
|
|
455
|
-
if (Array.isArray(issues)) err.issues = issues as MetadataValidationIssue[];
|
|
456
|
-
const code =
|
|
457
|
-
details?.code ??
|
|
458
|
-
(errNode && typeof errNode === 'object' ? errNode.code : undefined) ??
|
|
459
|
-
bodyObj?.code;
|
|
460
|
-
if (code != null) err.code = String(code);
|
|
461
|
-
return err;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Is this the single-item response envelope, as opposed to a metadata document?
|
|
466
|
-
*
|
|
467
|
-
* Matched by the PRESENCE of the three keys `GetMetaItemResponseSchema`
|
|
468
|
-
* declares — `type: string`, `name: string`, and an `item` slot — never by
|
|
469
|
-
* guessing from the payload's contents. The distinction matters in both
|
|
470
|
-
* directions:
|
|
471
|
-
*
|
|
472
|
-
* - A metadata document may legitimately carry its own `type` and `name`
|
|
473
|
-
* (a view is `{ name, type: 'grid', … }`). Without an `item` key it is the
|
|
474
|
-
* body, and unwrapping it would destroy it.
|
|
475
|
-
* - A document could carry an `item` property of its own. Without the
|
|
476
|
-
* envelope's identity keys it is likewise left whole.
|
|
477
|
-
*
|
|
478
|
-
* Key COUNT is deliberately not part of the test: a real envelope also spreads
|
|
479
|
-
* `MetadataProtectionEnvelopeFields` (`lock`, `provenance`, …), so it routinely
|
|
480
|
-
* has more than three keys.
|
|
481
|
-
*/
|
|
482
|
-
function isMetaItemEnvelope(
|
|
483
|
-
value: unknown,
|
|
484
|
-
): value is { type: string; name: string; item: unknown } {
|
|
485
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
486
|
-
const env = value as Record<string, unknown>;
|
|
487
|
-
return typeof env.type === 'string' && typeof env.name === 'string' && 'item' in env;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* MetadataClient — read/write protocol metadata via the framework REST API.
|
|
492
|
-
*
|
|
493
|
-
* @example
|
|
494
|
-
* ```ts
|
|
495
|
-
* const client = new MetadataClient({ baseUrl: 'http://localhost:3000' });
|
|
496
|
-
* const objects = await client.list<{ name: string; label?: string }>('object');
|
|
497
|
-
* const account = await client.get<{ fields: Record<string, unknown> }>('object', 'account');
|
|
498
|
-
* await client.save('object', 'account', { ...account, label: 'Customer' });
|
|
499
|
-
* ```
|
|
500
|
-
*/
|
|
501
|
-
export class MetadataClient {
|
|
502
|
-
private readonly base: string;
|
|
503
|
-
private readonly fetchImpl: typeof fetch;
|
|
504
|
-
private readonly headers: Record<string, string>;
|
|
505
|
-
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
506
|
-
readonly previewDrafts: boolean;
|
|
507
|
-
/** #4133 — sink for post-save advisory findings; see the config field. */
|
|
508
|
-
private readonly onSaveAdvisory: MetadataSaveAdvisoryListener | undefined;
|
|
509
|
-
|
|
510
|
-
constructor(config: MetadataClientConfig) {
|
|
511
|
-
this.base = buildBase(config);
|
|
512
|
-
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
513
|
-
this.headers = { Accept: 'application/json', ...(config.headers ?? {}) };
|
|
514
|
-
this.previewDrafts = config.previewDrafts === true;
|
|
515
|
-
this.onSaveAdvisory = config.onSaveAdvisory;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/** Update the client's environment scope at runtime. */
|
|
519
|
-
withEnvironment(environmentId: string | undefined): MetadataClient {
|
|
520
|
-
return new MetadataClient({
|
|
521
|
-
baseUrl: this.base
|
|
522
|
-
.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, '')
|
|
523
|
-
.replace(/\/+$/, ''),
|
|
524
|
-
environmentId,
|
|
525
|
-
fetch: this.fetchImpl,
|
|
526
|
-
headers: this.headers,
|
|
527
|
-
previewDrafts: this.previewDrafts,
|
|
528
|
-
// #4133: the advisory sink must survive the clone. `useMetadataClient`
|
|
529
|
-
// routes EVERY environment-scoped console client through here, so
|
|
530
|
-
// dropping it would silently disable the channel for exactly the
|
|
531
|
-
// multi-environment tenants the gate is loudest for.
|
|
532
|
-
...(this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}),
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Derive a client whose reads render the draft-overlaid world (or not).
|
|
538
|
-
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
539
|
-
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
540
|
-
*/
|
|
541
|
-
withPreviewDrafts(previewDrafts: boolean): MetadataClient {
|
|
542
|
-
if (previewDrafts === this.previewDrafts) return this;
|
|
543
|
-
// Preserve the environment scope baked into the base path (unlike
|
|
544
|
-
// `withEnvironment`, which exists to REPLACE it).
|
|
545
|
-
const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
|
|
546
|
-
return new MetadataClient({
|
|
547
|
-
baseUrl: this.base
|
|
548
|
-
.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, '')
|
|
549
|
-
.replace(/\/+$/, ''),
|
|
550
|
-
...(envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {}),
|
|
551
|
-
fetch: this.fetchImpl,
|
|
552
|
-
headers: this.headers,
|
|
553
|
-
previewDrafts,
|
|
554
|
-
// #4133: carried for the same reason as in `withEnvironment` — preview
|
|
555
|
-
// mode swaps the whole renderer tree's client, and writes are unaffected
|
|
556
|
-
// by ADR-0037, so the save door behind this clone still advises.
|
|
557
|
-
...(this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}),
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/** List all registered metadata types (returns the registry rows). */
|
|
562
|
-
async listTypes(): Promise<unknown[]> {
|
|
563
|
-
const res = await this.fetchImpl(this.base, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
564
|
-
if (!res.ok) throw await parseError(res);
|
|
565
|
-
const data = await res.json();
|
|
566
|
-
if (Array.isArray(data)) return data;
|
|
567
|
-
// Framework REST returns `{ types: string[], entries: TypeEntry[] }`
|
|
568
|
-
// where `entries` is the rich per-type registry row. Older / scoped
|
|
569
|
-
// shapes may use `items`. Prefer `entries` (rich), fall back to
|
|
570
|
-
// `items` (legacy), then synthesize stub entries from `types[]`
|
|
571
|
-
// if neither is present.
|
|
572
|
-
if (data && Array.isArray((data as any).entries)) return (data as any).entries;
|
|
573
|
-
if (data && Array.isArray((data as any).items)) return (data as any).items;
|
|
574
|
-
if (data && Array.isArray((data as any).types)) {
|
|
575
|
-
return (data as any).types.map((t: string) => ({ type: t }));
|
|
576
|
-
}
|
|
577
|
-
return [];
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
581
|
-
async list<T = unknown>(type: string, options: MetadataListOptions = {}): Promise<T[]> {
|
|
582
|
-
const params: string[] = [];
|
|
583
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
584
|
-
if (this.previewDrafts) params.push('preview=draft');
|
|
585
|
-
const qs = params.length ? `?${params.join('&')}` : '';
|
|
586
|
-
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
587
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
588
|
-
if (!res.ok) throw await parseError(res);
|
|
589
|
-
const data = await res.json();
|
|
590
|
-
if (Array.isArray(data)) return data as T[];
|
|
591
|
-
if (data && Array.isArray((data as { items?: unknown[] }).items)) {
|
|
592
|
-
return (data as { items: T[] }).items;
|
|
593
|
-
}
|
|
594
|
-
return [];
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* List pending DRAFT items (ADR-0033) — what an AI authored but nobody
|
|
599
|
-
* published yet. `list()` only sees published/active metadata, so a
|
|
600
|
-
* just-built app package looks empty there; this surfaces the drafts so the
|
|
601
|
-
* console can show a "pending changes" view and draft-aware package contents.
|
|
602
|
-
* Optionally narrow by `packageId` and/or `type`. Returns light headers
|
|
603
|
-
* (no body) carrying `packageId` for grouping.
|
|
604
|
-
*/
|
|
605
|
-
async listDrafts(
|
|
606
|
-
options: { packageId?: string; type?: string } = {},
|
|
607
|
-
): Promise<MetadataDraftHeader[]> {
|
|
608
|
-
const params: string[] = [];
|
|
609
|
-
if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
|
|
610
|
-
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
611
|
-
const qs = params.length ? `?${params.join('&')}` : '';
|
|
612
|
-
const url = `${this.base}/_drafts${qs}`;
|
|
613
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
614
|
-
if (!res.ok) throw await parseError(res);
|
|
615
|
-
const data = (await res.json()) as
|
|
616
|
-
| MetadataDraftHeader[]
|
|
617
|
-
| { drafts?: MetadataDraftHeader[]; data?: { drafts?: MetadataDraftHeader[] } };
|
|
618
|
-
if (Array.isArray(data)) return data;
|
|
619
|
-
return data?.drafts ?? data?.data?.drafts ?? [];
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* Fetch a single metadata item and return the response body EXACTLY as the
|
|
624
|
-
* server sent it — envelope and all.
|
|
625
|
-
*
|
|
626
|
-
* The one transport both {@link get} (which unwraps) and {@link getDraft}
|
|
627
|
-
* (which does not) sit on, so the two can differ in what they hand back
|
|
628
|
-
* without differing in how they ask. Private on purpose: the envelope is a
|
|
629
|
-
* wire detail, and the two published contracts above are the supported ways
|
|
630
|
-
* to read an item.
|
|
631
|
-
*/
|
|
632
|
-
private async readItemResponse<T = unknown>(
|
|
633
|
-
type: string,
|
|
634
|
-
name: string,
|
|
635
|
-
options: MetadataGetOptions = {},
|
|
636
|
-
): Promise<T | null> {
|
|
637
|
-
const params: string[] = [];
|
|
638
|
-
if (options.state === 'draft') params.push('state=draft');
|
|
639
|
-
// `state=draft` reads the raw draft row explicitly; the overlay flag is
|
|
640
|
-
// redundant (and the dispatcher resolves `state` first), so skip it.
|
|
641
|
-
else if (this.previewDrafts) params.push('preview=draft');
|
|
642
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
643
|
-
const qs = params.length ? `?${params.join('&')}` : '';
|
|
644
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
645
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
646
|
-
if (res.status === 404) return null;
|
|
647
|
-
if (!res.ok) throw await parseError(res);
|
|
648
|
-
return (await res.json()) as T;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/**
|
|
652
|
-
* Get a single metadata item. Returns the unwrapped item CONTENT — the
|
|
653
|
-
* metadata document itself, so `obj.fields` / `obj.label` are reachable
|
|
654
|
-
* directly.
|
|
655
|
-
*
|
|
656
|
-
* The framework answers `GET /meta/:type/:name` with the spec-declared
|
|
657
|
-
* envelope `{ type, name, item, …protection fields }`
|
|
658
|
-
* (`GetMetaItemResponseSchema`; objectstack#5563 collapsed this read to that
|
|
659
|
-
* ONE shape), so the envelope is unwrapped here — at the client boundary,
|
|
660
|
-
* once — rather than by each caller.
|
|
661
|
-
*
|
|
662
|
-
* **This method used to return the envelope while promising the body**
|
|
663
|
-
* (objectui#4271). Nothing detected the disagreement, because the test
|
|
664
|
-
* doubles across the repo were written against this docblock: every consumer
|
|
665
|
-
* reading `obj.fields` got `undefined` in production and a field list in
|
|
666
|
-
* unit tests. The visible cost was the entire field half of the permission
|
|
667
|
-
* matrix reporting "No fields registered for this object." for every object,
|
|
668
|
-
* plus dead RLS CEL autocomplete, an inert report drill-down and a designer
|
|
669
|
-
* that saved the envelope back over the object body.
|
|
670
|
-
*
|
|
671
|
-
* A response that is NOT the envelope (an older server answering the bare
|
|
672
|
-
* document, or a body of its own shape) passes through untouched, and `null`
|
|
673
|
-
* still comes back on 404 to keep the call site ergonomic.
|
|
674
|
-
*/
|
|
675
|
-
async get<T = unknown>(
|
|
676
|
-
type: string,
|
|
677
|
-
name: string,
|
|
678
|
-
options: MetadataGetOptions = {},
|
|
679
|
-
): Promise<T | null> {
|
|
680
|
-
const resp = await this.readItemResponse<unknown>(type, name, options);
|
|
681
|
-
if (isMetaItemEnvelope(resp)) return (resp.item ?? null) as T | null;
|
|
682
|
-
return resp as T | null;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
687
|
-
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
688
|
-
* back to the published overlay or the artifact registry — a `null`
|
|
689
|
-
* unambiguously means "nothing to publish".
|
|
690
|
-
*
|
|
691
|
-
* Note: this method hands back the `{ type, name, item }` envelope the
|
|
692
|
-
* framework sends, NOT the body — callers read `.item`. That asymmetry with
|
|
693
|
-
* {@link get} is deliberate and long-standing (the draft envelope's identity
|
|
694
|
-
* and protection carriers are part of what a draft reader inspects), so it
|
|
695
|
-
* is preserved by reading the transport directly instead of going through
|
|
696
|
-
* `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
|
|
697
|
-
* (this package) are the shared helpers for taking the body out.
|
|
698
|
-
*/
|
|
699
|
-
async getDraft<T = unknown>(
|
|
700
|
-
type: string,
|
|
701
|
-
name: string,
|
|
702
|
-
options: { packageId?: string } = {},
|
|
703
|
-
): Promise<T | null> {
|
|
704
|
-
return this.readItemResponse<T>(type, name, {
|
|
705
|
-
state: 'draft',
|
|
706
|
-
...(options.packageId ? { packageId: options.packageId } : {}),
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
712
|
-
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
713
|
-
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
714
|
-
* stage the change without publishing (Studio's "Save" button).
|
|
715
|
-
*/
|
|
716
|
-
async save<T = unknown>(
|
|
717
|
-
type: string,
|
|
718
|
-
name: string,
|
|
719
|
-
item: unknown,
|
|
720
|
-
options: MetadataClientSaveOptions = {},
|
|
721
|
-
): Promise<T> {
|
|
722
|
-
if (!name || !String(name).trim()) {
|
|
723
|
-
// The `PUT /meta/:type/:name` route rejects a missing `:name` segment
|
|
724
|
-
// with 405. Fail with a clear contextual error at the call boundary
|
|
725
|
-
// instead of firing a malformed request at the server (#2767).
|
|
726
|
-
throw new Error(
|
|
727
|
-
`MetadataClient.save: name must be non-empty (type="${type}").` +
|
|
728
|
-
' The PUT /meta/:type/:name route requires a name segment.',
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
const params: string[] = [];
|
|
732
|
-
if (options.force) params.push('force=true');
|
|
733
|
-
if (options.mode === 'draft') params.push('mode=draft');
|
|
734
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
735
|
-
const qs = params.length ? `?${params.join('&')}` : '';
|
|
736
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
737
|
-
const headers: Record<string, string> = {
|
|
738
|
-
...this.headers,
|
|
739
|
-
'Content-Type': 'application/json',
|
|
740
|
-
};
|
|
741
|
-
if (options.ifMatch) headers['If-Match'] = options.ifMatch;
|
|
742
|
-
if (options.actor) headers['X-Actor'] = options.actor;
|
|
743
|
-
const res = await this.fetchImpl(url, {
|
|
744
|
-
method: 'PUT',
|
|
745
|
-
headers,
|
|
746
|
-
body: JSON.stringify(item),
|
|
747
|
-
});
|
|
748
|
-
if (!res.ok) throw await parseError(res);
|
|
749
|
-
const body = await res.json();
|
|
750
|
-
// #4133 — the runtime authoring gate's advisory findings (objectstack#7435).
|
|
751
|
-
// The server emits `advisories` ONLY when non-empty, so a clean save costs
|
|
752
|
-
// nothing here. Everything below is best-effort by construction: the save
|
|
753
|
-
// has already been committed server-side and returning it must not depend
|
|
754
|
-
// on anything the advisory channel does.
|
|
755
|
-
if (this.onSaveAdvisory) {
|
|
756
|
-
try {
|
|
757
|
-
const advisories = readSaveAdvisories(body);
|
|
758
|
-
if (advisories.length > 0) {
|
|
759
|
-
this.onSaveAdvisory({
|
|
760
|
-
type,
|
|
761
|
-
name,
|
|
762
|
-
mode: options.mode === 'draft' ? 'draft' : 'publish',
|
|
763
|
-
advisories,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
} catch {
|
|
767
|
-
/* an advisory must never turn a committed save into a thrown error */
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
return body as T;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Publish a single pending draft BY REFERENCE — promotes the draft row for
|
|
775
|
-
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
776
|
-
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
777
|
-
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
778
|
-
* exact set returned by {@link listDrafts} without needing a package.
|
|
779
|
-
*
|
|
780
|
-
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
781
|
-
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
782
|
-
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
783
|
-
* rather than assume the data went live.
|
|
784
|
-
*/
|
|
785
|
-
async publishDraft(
|
|
786
|
-
type: string,
|
|
787
|
-
name: string,
|
|
788
|
-
): Promise<{
|
|
789
|
-
success?: boolean;
|
|
790
|
-
seedApplied?: { success: boolean; inserted?: number; updated?: number; error?: string; errors?: unknown[] };
|
|
791
|
-
} & Record<string, unknown>> {
|
|
792
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
793
|
-
const res = await this.fetchImpl(url, {
|
|
794
|
-
method: 'POST',
|
|
795
|
-
headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
796
|
-
body: '{}',
|
|
797
|
-
});
|
|
798
|
-
if (!res.ok) throw await parseError(res);
|
|
799
|
-
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
800
|
-
// Tolerate the dispatcher's `{ success, data: {...} }` envelope.
|
|
801
|
-
const inner = (body as any)?.data && typeof (body as any).data === 'object' ? (body as any).data : body;
|
|
802
|
-
return inner as any;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
807
|
-
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
808
|
-
* customisation, if any), and `effective` (what the runtime sees).
|
|
809
|
-
*/
|
|
810
|
-
async layered<T = unknown>(
|
|
811
|
-
type: string,
|
|
812
|
-
name: string,
|
|
813
|
-
options: { packageId?: string } = {},
|
|
814
|
-
): Promise<MetadataLayered<T>> {
|
|
815
|
-
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : '';
|
|
816
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
|
|
817
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
818
|
-
if (res.status === 404) {
|
|
819
|
-
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
820
|
-
}
|
|
821
|
-
if (!res.ok) throw await parseError(res);
|
|
822
|
-
const body = (await res.json()) as MetadataLayered<T> & Record<string, unknown>;
|
|
823
|
-
const hasEnvelope =
|
|
824
|
-
body && (('code' in body) || ('overlay' in body) || ('effective' in body));
|
|
825
|
-
if (!hasEnvelope) {
|
|
826
|
-
return {
|
|
827
|
-
code: null,
|
|
828
|
-
overlay: null,
|
|
829
|
-
overlayScope: null,
|
|
830
|
-
effective: body as unknown as T,
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
return {
|
|
834
|
-
code: body.code ?? null,
|
|
835
|
-
overlay: body.overlay ?? null,
|
|
836
|
-
overlayScope: body.overlayScope ?? null,
|
|
837
|
-
effective: body.effective ?? null,
|
|
838
|
-
...(body._diagnostics ? { _diagnostics: body._diagnostics as MetadataDiagnostics } : {}),
|
|
839
|
-
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
840
|
-
// the editor can render lock affordances without a second round trip.
|
|
841
|
-
...(body.lock !== undefined ? { lock: body.lock as MetadataLayered['lock'] } : {}),
|
|
842
|
-
...(body.lockReason !== undefined ? { lockReason: body.lockReason as string } : {}),
|
|
843
|
-
...(body.lockSource !== undefined ? { lockSource: body.lockSource as MetadataLayered['lockSource'] } : {}),
|
|
844
|
-
...(body.lockDocsUrl !== undefined ? { lockDocsUrl: body.lockDocsUrl as string } : {}),
|
|
845
|
-
...(body.provenance !== undefined ? { provenance: body.provenance as MetadataLayered['provenance'] } : {}),
|
|
846
|
-
...(body.packageId !== undefined ? { packageId: body.packageId as string } : {}),
|
|
847
|
-
...(body.packageVersion !== undefined ? { packageVersion: body.packageVersion as string } : {}),
|
|
848
|
-
...(body.editable !== undefined ? { editable: body.editable as boolean } : {}),
|
|
849
|
-
...(body.deletable !== undefined ? { deletable: body.deletable as boolean } : {}),
|
|
850
|
-
...(body.resettable !== undefined ? { resettable: body.resettable as boolean } : {}),
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Cross-type sweep of load-time validation results — calls
|
|
856
|
-
* `GET /meta/diagnostics`. Returns every entry the framework
|
|
857
|
-
* considers invalid (or, when `severity: 'warning'`, also entries
|
|
858
|
-
* with only warnings).
|
|
859
|
-
*
|
|
860
|
-
* Used by the Studio's governance overview page and by the
|
|
861
|
-
* directory page to show "N invalid" badges per metadata type.
|
|
862
|
-
*/
|
|
863
|
-
async diagnostics(
|
|
864
|
-
options: MetadataDiagnosticsOptions = {},
|
|
865
|
-
): Promise<MetadataDiagnosticsSummary> {
|
|
866
|
-
const params: string[] = [];
|
|
867
|
-
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
868
|
-
if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
|
|
869
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
870
|
-
const qs = params.length ? `?${params.join('&')}` : '';
|
|
871
|
-
const url = `${this.base}/diagnostics${qs}`;
|
|
872
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
873
|
-
if (res.status === 404) {
|
|
874
|
-
// Older server without the sweep endpoint — caller should treat
|
|
875
|
-
// as "no diagnostics available", not as an error.
|
|
876
|
-
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
877
|
-
}
|
|
878
|
-
if (!res.ok) throw await parseError(res);
|
|
879
|
-
const data = (await res.json()) as Partial<MetadataDiagnosticsSummary>;
|
|
880
|
-
return {
|
|
881
|
-
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
882
|
-
total: typeof data.total === 'number' ? data.total : 0,
|
|
883
|
-
scannedTypes: typeof data.scannedTypes === 'number' ? data.scannedTypes : 0,
|
|
884
|
-
scannedItems: typeof data.scannedItems === 'number' ? data.scannedItems : 0,
|
|
885
|
-
stats: data.stats && typeof data.stats === 'object' ? data.stats : {},
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
/**
|
|
890
|
-
* Find every metadata item that references this one (Phase 3a). Useful
|
|
891
|
-
* for pre-delete impact analysis: "Are any views pointing at this
|
|
892
|
-
* object before I drop it?".
|
|
893
|
-
*/
|
|
894
|
-
async references(type: string, name: string): Promise<MetadataReference[]> {
|
|
895
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
896
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
897
|
-
if (res.status === 404) return [];
|
|
898
|
-
if (!res.ok) throw await parseError(res);
|
|
899
|
-
const data = await res.json();
|
|
900
|
-
return Array.isArray(data) ? (data as MetadataReference[]) : (data?.items ?? []);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Reset a metadata customization overlay back to the artifact default.
|
|
905
|
-
* Idempotent: returns the result even when no overlay row existed.
|
|
906
|
-
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
907
|
-
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
908
|
-
*/
|
|
909
|
-
async reset<T = unknown>(
|
|
910
|
-
type: string,
|
|
911
|
-
name: string,
|
|
912
|
-
options: MetadataDeleteOptions = {},
|
|
913
|
-
): Promise<T> {
|
|
914
|
-
const qs = options.state === 'draft' ? '?state=draft' : '';
|
|
915
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
916
|
-
const headers: Record<string, string> = { ...this.headers };
|
|
917
|
-
if (options.ifMatch) headers['If-Match'] = options.ifMatch;
|
|
918
|
-
if (options.actor) headers['X-Actor'] = options.actor;
|
|
919
|
-
const res = await this.fetchImpl(url, { method: 'DELETE', headers });
|
|
920
|
-
if (!res.ok) throw await parseError(res);
|
|
921
|
-
return (await res.json()) as T;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Promote the pending draft of an item to the active overlay
|
|
926
|
-
* (POST `/meta/:type/:name/publish`). Returns
|
|
927
|
-
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
928
|
-
* nothing is pending and `409 metadata_conflict` if the published
|
|
929
|
-
* overlay moved while the draft was sitting.
|
|
930
|
-
*/
|
|
931
|
-
async publish<T = unknown>(
|
|
932
|
-
type: string,
|
|
933
|
-
name: string,
|
|
934
|
-
options: { actor?: string; message?: string } = {},
|
|
935
|
-
): Promise<T> {
|
|
936
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
937
|
-
const headers: Record<string, string> = {
|
|
938
|
-
...this.headers,
|
|
939
|
-
'Content-Type': 'application/json',
|
|
940
|
-
};
|
|
941
|
-
if (options.actor) headers['X-Actor'] = options.actor;
|
|
942
|
-
const res = await this.fetchImpl(url, {
|
|
943
|
-
method: 'POST',
|
|
944
|
-
headers,
|
|
945
|
-
body: JSON.stringify(options.message ? { message: options.message } : {}),
|
|
946
|
-
});
|
|
947
|
-
if (!res.ok) throw await parseError(res);
|
|
948
|
-
return (await res.json()) as T;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
953
|
-
* The server reads the history row at `toVersion`, writes its body back
|
|
954
|
-
* as the active overlay with `operation_type='revert'`. Returns
|
|
955
|
-
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
956
|
-
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
957
|
-
* when the target version is a delete tombstone.
|
|
958
|
-
*/
|
|
959
|
-
async rollback<T = unknown>(
|
|
960
|
-
type: string,
|
|
961
|
-
name: string,
|
|
962
|
-
toVersion: number,
|
|
963
|
-
options: { actor?: string; message?: string } = {},
|
|
964
|
-
): Promise<T> {
|
|
965
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
|
|
966
|
-
const headers: Record<string, string> = {
|
|
967
|
-
...this.headers,
|
|
968
|
-
'Content-Type': 'application/json',
|
|
969
|
-
};
|
|
970
|
-
if (options.actor) headers['X-Actor'] = options.actor;
|
|
971
|
-
const res = await this.fetchImpl(url, {
|
|
972
|
-
method: 'POST',
|
|
973
|
-
headers,
|
|
974
|
-
body: JSON.stringify({
|
|
975
|
-
toVersion,
|
|
976
|
-
...(options.message ? { message: options.message } : {}),
|
|
977
|
-
}),
|
|
978
|
-
});
|
|
979
|
-
if (!res.ok) throw await parseError(res);
|
|
980
|
-
return (await res.json()) as T;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* Compute a structured top-level key diff between two history versions
|
|
985
|
-
* (GET `/meta/:type/:name/diff?from=&to=`). Returns
|
|
986
|
-
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
987
|
-
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
988
|
-
* pre-rollback previews.
|
|
989
|
-
*/
|
|
990
|
-
async diff<T = unknown>(
|
|
991
|
-
type: string,
|
|
992
|
-
name: string,
|
|
993
|
-
fromVersion?: number,
|
|
994
|
-
toVersion?: number,
|
|
995
|
-
): Promise<T> {
|
|
996
|
-
const params = new URLSearchParams();
|
|
997
|
-
if (fromVersion !== undefined) params.set('from', String(fromVersion));
|
|
998
|
-
if (toVersion !== undefined) params.set('to', String(toVersion));
|
|
999
|
-
const qs = params.toString();
|
|
1000
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ''}`;
|
|
1001
|
-
const res = await this.fetchImpl(url, {
|
|
1002
|
-
method: 'GET',
|
|
1003
|
-
headers: this.headers,
|
|
1004
|
-
cache: 'no-store',
|
|
1005
|
-
});
|
|
1006
|
-
if (!res.ok) throw await parseError(res);
|
|
1007
|
-
return (await res.json()) as T;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
/** Fetch the durable history (change log) for one metadata item. */
|
|
1011
|
-
async history<T = unknown>(
|
|
1012
|
-
type: string,
|
|
1013
|
-
name: string,
|
|
1014
|
-
options: MetadataHistoryOptions = {},
|
|
1015
|
-
): Promise<T> {
|
|
1016
|
-
const params = new URLSearchParams();
|
|
1017
|
-
if (options.sinceSeq !== undefined) params.set('sinceSeq', String(options.sinceSeq));
|
|
1018
|
-
if (options.limit !== undefined) params.set('limit', String(options.limit));
|
|
1019
|
-
const qs = params.toString();
|
|
1020
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ''}`;
|
|
1021
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
1022
|
-
if (!res.ok) throw await parseError(res);
|
|
1023
|
-
return (await res.json()) as T;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Fetch the protection-audit trail for one metadata item
|
|
1028
|
-
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
1029
|
-
* delete/reset attempt — allowed, denied, or forced — so the
|
|
1030
|
-
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
1031
|
-
* and whether a lock blocked it.
|
|
1032
|
-
*/
|
|
1033
|
-
async audit(
|
|
1034
|
-
type: string,
|
|
1035
|
-
name: string,
|
|
1036
|
-
options: { limit?: number } = {},
|
|
1037
|
-
): Promise<MetadataAuditResponse> {
|
|
1038
|
-
const params = new URLSearchParams();
|
|
1039
|
-
if (options.limit !== undefined) params.set('limit', String(options.limit));
|
|
1040
|
-
const qs = params.toString();
|
|
1041
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ''}`;
|
|
1042
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
1043
|
-
if (!res.ok) throw await parseError(res);
|
|
1044
|
-
return (await res.json()) as MetadataAuditResponse;
|
|
1045
|
-
}
|
|
1046
|
-
}
|