@object-ui/data-objectstack 6.2.2 → 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.
@@ -46,6 +46,15 @@ export interface MetadataClientConfig {
46
46
  fetch?: typeof fetch;
47
47
  /** Additional headers (e.g. auth) included on every request. */
48
48
  headers?: Record<string, string>;
49
+ /**
50
+ * ADR-0037 Live Canvas — render the world **as-if-published**. When true,
51
+ * `list()` and `get()` append `?preview=draft`, which the framework
52
+ * dispatcher maps to `getMetaItems/getMetaItem({ previewDrafts: true })`:
53
+ * pending ADR-0033 drafts overlay the active registry (draft wins by name,
54
+ * draft-only items surface). Reads only — writes are unaffected, and
55
+ * nothing the preview shows is live until Publish.
56
+ */
57
+ previewDrafts?: boolean;
49
58
  }
50
59
 
51
60
  export interface MetadataListOptions {
@@ -53,6 +62,19 @@ export interface MetadataListOptions {
53
62
  packageId?: string;
54
63
  }
55
64
 
65
+ /**
66
+ * A pending DRAFT metadata item (ADR-0033), as returned by {@link MetadataClient.listDrafts}.
67
+ * Light header — no body — carrying the owning package so the console can group
68
+ * pending changes by app package.
69
+ */
70
+ export interface MetadataDraftHeader {
71
+ type: string;
72
+ name: string;
73
+ packageId: string | null;
74
+ updatedAt: string | null;
75
+ updatedBy: string | null;
76
+ }
77
+
56
78
  export interface MetadataSaveOptions {
57
79
  /**
58
80
  * Optimistic concurrency token (the `checksum` returned by the last
@@ -69,6 +91,44 @@ export interface MetadataSaveOptions {
69
91
  * so the operator can confirm and proceed.
70
92
  */
71
93
  force?: boolean;
94
+ /**
95
+ * Save mode — `'draft'` writes a pending draft row (invisible to the
96
+ * runtime until `publish()` is called); `'publish'` writes directly
97
+ * to the active overlay. Omit (or set `'publish'`) for the legacy
98
+ * "save = live" behaviour.
99
+ */
100
+ mode?: 'draft' | 'publish';
101
+ /**
102
+ * Software-package id to bind the saved row to (sent as the `package`
103
+ * query param → `sys_metadata.package_id`). Set when authoring inside a
104
+ * Studio package workspace. Omit for an env-local overlay.
105
+ */
106
+ packageId?: string;
107
+ }
108
+
109
+ export interface MetadataGetOptions {
110
+ /**
111
+ * Read a specific overlay state. `'draft'` returns the pending draft
112
+ * body (no fallback to the published overlay or the registry); omit
113
+ * to read the active (published) value.
114
+ */
115
+ state?: 'active' | 'draft';
116
+ /**
117
+ * Software-package id to scope resolution (sent as the `package` query
118
+ * param → server prefer-local, ADR-0048). Set when reading one item that
119
+ * may collide by name across installed packages (e.g. the Studio editor
120
+ * passes the edited item's owning package). Omit for context-free reads.
121
+ */
122
+ packageId?: string;
123
+ }
124
+
125
+ export interface MetadataDeleteOptions extends MetadataSaveOptions {
126
+ /**
127
+ * Target state. `'draft'` discards the pending draft (keeps the
128
+ * published overlay intact). Omit to reset the active overlay back
129
+ * to the artifact default (the legacy behaviour).
130
+ */
131
+ state?: 'active' | 'draft';
72
132
  }
73
133
 
74
134
  /** Layered view of a metadata item — Phase 3a `?layers=true`. */
@@ -81,6 +141,122 @@ export interface MetadataLayered<T = unknown> {
81
141
  overlayScope: string | null;
82
142
  /** Merged effective view — what the runtime actually sees. */
83
143
  effective: T | null;
144
+ /**
145
+ * Load-time validation result for `effective` (server-computed via
146
+ * the same Zod registry used at save time). Undefined for types
147
+ * without a registered Zod schema. Surfaced by the Studio as a
148
+ * banner + inline field errors so operators can spot bad metadata
149
+ * without having to hit Save.
150
+ */
151
+ _diagnostics?: MetadataDiagnostics;
152
+ // ── ADR-0010 Phase 1 — protection envelope ──
153
+ /** 4-state lock: `none` / `no-overlay` / `no-delete` / `full`. */
154
+ lock?: 'none' | 'no-overlay' | 'no-delete' | 'full';
155
+ /** Human-readable reason for the lock (tooltip text). */
156
+ lockReason?: string;
157
+ /** Which layer set the lock: artifact / package / overlay / env-forced. */
158
+ lockSource?: 'artifact' | 'package' | 'overlay' | 'env-forced';
159
+ /** Optional docs URL for the lock reason (rendered as "View docs →"). */
160
+ lockDocsUrl?: string;
161
+ /** Origin of the item: `package` (loader) | `org` (tenant) | `env-forced`. */
162
+ provenance?: 'package' | 'org' | 'env-forced';
163
+ /** Owning package id (denormalised from the loader tag). */
164
+ packageId?: string;
165
+ /** Owning package version. */
166
+ packageVersion?: string;
167
+ /** True when the editor should allow Save (PUT). */
168
+ editable?: boolean;
169
+ /** True when the editor should allow Delete. */
170
+ deletable?: boolean;
171
+ /** True when "Reset to package default" applies (has overlay + artifact). */
172
+ resettable?: boolean;
173
+ }
174
+
175
+ /**
176
+ * One row in the metadata protection-audit trail
177
+ * (ADR-0010 §3.6 / Phase 4.1). Mirrors `sys_metadata_audit` columns
178
+ * the API exposes.
179
+ */
180
+ export interface MetadataAuditEntry {
181
+ /** Stable audit-row id (uuid). */
182
+ id: unknown;
183
+ /** ISO timestamp when the attempt happened. */
184
+ occurredAt: string;
185
+ /** Who attempted the operation (`x-actor` header or `'system'`). */
186
+ actor: string;
187
+ /** Code path that recorded the row (e.g. `protocol.saveMetaItem`). */
188
+ source: string | null;
189
+ /** Which lifecycle op was attempted. */
190
+ operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset';
191
+ /** Decision: allowed / denied / forced (admin override). */
192
+ outcome: 'allowed' | 'denied' | 'forced';
193
+ /** Machine-readable reason code (`item_locked`, `ok`, …). */
194
+ code: string;
195
+ /** Effective lock at the moment of the attempt. */
196
+ lockState: 'none' | 'no-overlay' | 'no-delete' | 'full' | null;
197
+ /** True when admin forced the write through despite the lock. */
198
+ lockOverridden: boolean;
199
+ /** Request-id for trace correlation (if propagated). */
200
+ requestId: string | null;
201
+ /** Free-text note (often the lock reason). */
202
+ note: string | null;
203
+ }
204
+
205
+ /** Response shape for `MetadataClient.audit()`. */
206
+ export interface MetadataAuditResponse {
207
+ events: MetadataAuditEntry[];
208
+ }
209
+
210
+ /**
211
+ * Load-time validation envelope attached to metadata items by the
212
+ * framework. Mirrors `MetadataValidationResult` in the kernel spec.
213
+ */
214
+ export interface MetadataDiagnostics {
215
+ valid: boolean;
216
+ errors?: Array<{ path: string; message: string; code?: string }>;
217
+ warnings?: Array<{ path: string; message: string }>;
218
+ }
219
+
220
+ /** Options for the cross-type `/meta/diagnostics` sweep call. */
221
+ export interface MetadataDiagnosticsOptions {
222
+ /** Restrict the sweep to a single metadata type (e.g. `'view'`). */
223
+ type?: string;
224
+ /**
225
+ * `'error'` (default) returns only items that fail validation.
226
+ * `'warning'` also includes items whose only diagnostics are warnings.
227
+ */
228
+ severity?: 'error' | 'warning';
229
+ /** Restrict to items owned by this package id. */
230
+ packageId?: string;
231
+ }
232
+
233
+ /** One row in the `/meta/diagnostics` response. */
234
+ export interface MetadataDiagnosticsEntry {
235
+ type: string;
236
+ name: string;
237
+ diagnostics: MetadataDiagnostics;
238
+ }
239
+
240
+ /** Top-level envelope returned by `/meta/diagnostics`. */
241
+ export interface MetadataDiagnosticsSummary {
242
+ entries: MetadataDiagnosticsEntry[];
243
+ /** Number of `entries` returned (post-filter). */
244
+ total: number;
245
+ /** How many metadata types the sweep visited. */
246
+ scannedTypes: number;
247
+ /** How many individual items were validated. */
248
+ scannedItems: number;
249
+ /**
250
+ * Per-type aggregate stats — count of items and the list of
251
+ * packages contributing to each type. Computed in the same sweep
252
+ * so a single call serves both the diagnostics governance page and
253
+ * the directory tile counts / package filter.
254
+ *
255
+ * Optional for backward compatibility with older framework
256
+ * versions that do not yet emit it; clients should fall back to
257
+ * an empty record.
258
+ */
259
+ stats?: Record<string, { count: number; locked?: number; packages: string[] }>;
84
260
  }
85
261
 
86
262
  /** Reference back-pointer — Phase 3a `/references`. */
@@ -162,11 +338,14 @@ export class MetadataClient {
162
338
  private readonly base: string;
163
339
  private readonly fetchImpl: typeof fetch;
164
340
  private readonly headers: Record<string, string>;
341
+ /** ADR-0037: when true, reads render the draft-overlaid world. */
342
+ readonly previewDrafts: boolean;
165
343
 
166
344
  constructor(config: MetadataClientConfig) {
167
345
  this.base = buildBase(config);
168
346
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
169
347
  this.headers = { Accept: 'application/json', ...(config.headers ?? {}) };
348
+ this.previewDrafts = config.previewDrafts === true;
170
349
  }
171
350
 
172
351
  /** Update the client's environment scope at runtime. */
@@ -178,6 +357,28 @@ export class MetadataClient {
178
357
  environmentId,
179
358
  fetch: this.fetchImpl,
180
359
  headers: this.headers,
360
+ previewDrafts: this.previewDrafts,
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Derive a client whose reads render the draft-overlaid world (or not).
366
+ * Same base/fetch/headers; used by the app-shell to switch the whole
367
+ * renderer tree into ADR-0037 preview mode off one URL flag.
368
+ */
369
+ withPreviewDrafts(previewDrafts: boolean): MetadataClient {
370
+ if (previewDrafts === this.previewDrafts) return this;
371
+ // Preserve the environment scope baked into the base path (unlike
372
+ // `withEnvironment`, which exists to REPLACE it).
373
+ const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
374
+ return new MetadataClient({
375
+ baseUrl: this.base
376
+ .replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, '')
377
+ .replace(/\/+$/, ''),
378
+ ...(envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {}),
379
+ fetch: this.fetchImpl,
380
+ headers: this.headers,
381
+ previewDrafts,
181
382
  });
182
383
  }
183
384
 
@@ -202,9 +403,10 @@ export class MetadataClient {
202
403
 
203
404
  /** List items of a metadata type (e.g. `object`, `field`, `view`). */
204
405
  async list<T = unknown>(type: string, options: MetadataListOptions = {}): Promise<T[]> {
205
- const qs = options.packageId
206
- ? `?package=${encodeURIComponent(options.packageId)}`
207
- : '';
406
+ const params: string[] = [];
407
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
408
+ if (this.previewDrafts) params.push('preview=draft');
409
+ const qs = params.length ? `?${params.join('&')}` : '';
208
410
  const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
209
411
  const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
210
412
  if (!res.ok) throw await parseError(res);
@@ -216,23 +418,80 @@ export class MetadataClient {
216
418
  return [];
217
419
  }
218
420
 
421
+ /**
422
+ * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
423
+ * published yet. `list()` only sees published/active metadata, so a
424
+ * just-built app package looks empty there; this surfaces the drafts so the
425
+ * console can show a "pending changes" view and draft-aware package contents.
426
+ * Optionally narrow by `packageId` and/or `type`. Returns light headers
427
+ * (no body) carrying `packageId` for grouping.
428
+ */
429
+ async listDrafts(
430
+ options: { packageId?: string; type?: string } = {},
431
+ ): Promise<MetadataDraftHeader[]> {
432
+ const params: string[] = [];
433
+ if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
434
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
435
+ const qs = params.length ? `?${params.join('&')}` : '';
436
+ const url = `${this.base}/_drafts${qs}`;
437
+ const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
438
+ if (!res.ok) throw await parseError(res);
439
+ const data = (await res.json()) as
440
+ | MetadataDraftHeader[]
441
+ | { drafts?: MetadataDraftHeader[]; data?: { drafts?: MetadataDraftHeader[] } };
442
+ if (Array.isArray(data)) return data;
443
+ return data?.drafts ?? data?.data?.drafts ?? [];
444
+ }
445
+
219
446
  /**
220
447
  * Get a single metadata item. Returns the unwrapped item content
221
448
  * (matching the framework REST handler which calls `res.json(item)`).
222
449
  * Returns `null` on 404 to keep the call-site ergonomic.
223
450
  */
224
- async get<T = unknown>(type: string, name: string): Promise<T | null> {
225
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
451
+ async get<T = unknown>(
452
+ type: string,
453
+ name: string,
454
+ options: MetadataGetOptions = {},
455
+ ): Promise<T | null> {
456
+ const params: string[] = [];
457
+ if (options.state === 'draft') params.push('state=draft');
458
+ // `state=draft` reads the raw draft row explicitly; the overlay flag is
459
+ // redundant (and the dispatcher resolves `state` first), so skip it.
460
+ else if (this.previewDrafts) params.push('preview=draft');
461
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
462
+ const qs = params.length ? `?${params.join('&')}` : '';
463
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
226
464
  const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
227
465
  if (res.status === 404) return null;
228
466
  if (!res.ok) throw await parseError(res);
229
467
  return (await res.json()) as T;
230
468
  }
231
469
 
470
+ /**
471
+ * Read the pending draft body for an item (`?state=draft`). Returns
472
+ * `null` when there is no draft pending. Draft reads do NOT fall
473
+ * back to the published overlay or the artifact registry — a `null`
474
+ * unambiguously means "nothing to publish".
475
+ *
476
+ * Note: the framework wraps draft responses in an envelope
477
+ * `{ type, name, item }` (matching `getMetaItem`); callers should
478
+ * read `.item` to get the body. The legacy `get()` returns the
479
+ * unwrapped body, so this method preserves that asymmetry by
480
+ * returning whatever the server sent.
481
+ */
482
+ async getDraft<T = unknown>(
483
+ type: string,
484
+ name: string,
485
+ options: { packageId?: string } = {},
486
+ ): Promise<T | null> {
487
+ return this.get<T>(type, name, { state: 'draft', ...(options.packageId ? { packageId: options.packageId } : {}) });
488
+ }
489
+
232
490
  /**
233
491
  * Save (PUT) a metadata item. The framework accepts both the bare
234
492
  * item payload and the `{ item: ... }` / `{ metadata: ... }`
235
- * envelopes; we send bare for consistency.
493
+ * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
494
+ * stage the change without publishing (Studio's "Save" button).
236
495
  */
237
496
  async save<T = unknown>(
238
497
  type: string,
@@ -240,7 +499,11 @@ export class MetadataClient {
240
499
  item: unknown,
241
500
  options: MetadataSaveOptions = {},
242
501
  ): Promise<T> {
243
- const qs = options.force ? '?force=true' : '';
502
+ const params: string[] = [];
503
+ if (options.force) params.push('force=true');
504
+ if (options.mode === 'draft') params.push('mode=draft');
505
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
506
+ const qs = params.length ? `?${params.join('&')}` : '';
244
507
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
245
508
  const headers: Record<string, string> = {
246
509
  ...this.headers,
@@ -257,13 +520,50 @@ export class MetadataClient {
257
520
  return (await res.json()) as T;
258
521
  }
259
522
 
523
+ /**
524
+ * Publish a single pending draft BY REFERENCE — promotes the draft row for
525
+ * `(type, name)` into the active overlay and drops the draft. Works for ANY
526
+ * draft, including ones with no `packageId` binding (which the package-scoped
527
+ * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
528
+ * exact set returned by {@link listDrafts} without needing a package.
529
+ *
530
+ * Returns the server result. For `seed` drafts the protocol also materializes
531
+ * the rows and reports under `seedApplied` — a data problem never fails the
532
+ * publish, so callers should check `seedApplied?.success` and warn the user
533
+ * rather than assume the data went live.
534
+ */
535
+ async publishDraft(
536
+ type: string,
537
+ name: string,
538
+ ): Promise<{
539
+ success?: boolean;
540
+ seedApplied?: { success: boolean; inserted?: number; updated?: number; error?: string; errors?: unknown[] };
541
+ } & Record<string, unknown>> {
542
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
543
+ const res = await this.fetchImpl(url, {
544
+ method: 'POST',
545
+ headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'application/json' },
546
+ body: '{}',
547
+ });
548
+ if (!res.ok) throw await parseError(res);
549
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
550
+ // Tolerate the dispatcher's `{ success, data: {...} }` envelope.
551
+ const inner = (body as any)?.data && typeof (body as any).data === 'object' ? (body as any).data : body;
552
+ return inner as any;
553
+ }
554
+
260
555
  /**
261
556
  * Get the 3-state layered view of a metadata item (Phase 3a). Returns
262
557
  * `code` (the artifact / fallback default), `overlay` (the saved
263
558
  * customisation, if any), and `effective` (what the runtime sees).
264
559
  */
265
- async layered<T = unknown>(type: string, name: string): Promise<MetadataLayered<T>> {
266
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true`;
560
+ async layered<T = unknown>(
561
+ type: string,
562
+ name: string,
563
+ options: { packageId?: string } = {},
564
+ ): Promise<MetadataLayered<T>> {
565
+ const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : '';
566
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
267
567
  const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
268
568
  if (res.status === 404) {
269
569
  return { code: null, overlay: null, overlayScope: null, effective: null };
@@ -285,6 +585,54 @@ export class MetadataClient {
285
585
  overlay: body.overlay ?? null,
286
586
  overlayScope: body.overlayScope ?? null,
287
587
  effective: body.effective ?? null,
588
+ ...(body._diagnostics ? { _diagnostics: body._diagnostics as MetadataDiagnostics } : {}),
589
+ // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
590
+ // the editor can render lock affordances without a second round trip.
591
+ ...(body.lock !== undefined ? { lock: body.lock as MetadataLayered['lock'] } : {}),
592
+ ...(body.lockReason !== undefined ? { lockReason: body.lockReason as string } : {}),
593
+ ...(body.lockSource !== undefined ? { lockSource: body.lockSource as MetadataLayered['lockSource'] } : {}),
594
+ ...(body.lockDocsUrl !== undefined ? { lockDocsUrl: body.lockDocsUrl as string } : {}),
595
+ ...(body.provenance !== undefined ? { provenance: body.provenance as MetadataLayered['provenance'] } : {}),
596
+ ...(body.packageId !== undefined ? { packageId: body.packageId as string } : {}),
597
+ ...(body.packageVersion !== undefined ? { packageVersion: body.packageVersion as string } : {}),
598
+ ...(body.editable !== undefined ? { editable: body.editable as boolean } : {}),
599
+ ...(body.deletable !== undefined ? { deletable: body.deletable as boolean } : {}),
600
+ ...(body.resettable !== undefined ? { resettable: body.resettable as boolean } : {}),
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Cross-type sweep of load-time validation results — calls
606
+ * `GET /meta/diagnostics`. Returns every entry the framework
607
+ * considers invalid (or, when `severity: 'warning'`, also entries
608
+ * with only warnings).
609
+ *
610
+ * Used by the Studio's governance overview page and by the
611
+ * directory page to show "N invalid" badges per metadata type.
612
+ */
613
+ async diagnostics(
614
+ options: MetadataDiagnosticsOptions = {},
615
+ ): Promise<MetadataDiagnosticsSummary> {
616
+ const params: string[] = [];
617
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
618
+ if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
619
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
620
+ const qs = params.length ? `?${params.join('&')}` : '';
621
+ const url = `${this.base}/diagnostics${qs}`;
622
+ const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
623
+ if (res.status === 404) {
624
+ // Older server without the sweep endpoint — caller should treat
625
+ // as "no diagnostics available", not as an error.
626
+ return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
627
+ }
628
+ if (!res.ok) throw await parseError(res);
629
+ const data = (await res.json()) as Partial<MetadataDiagnosticsSummary>;
630
+ return {
631
+ entries: Array.isArray(data.entries) ? data.entries : [],
632
+ total: typeof data.total === 'number' ? data.total : 0,
633
+ scannedTypes: typeof data.scannedTypes === 'number' ? data.scannedTypes : 0,
634
+ scannedItems: typeof data.scannedItems === 'number' ? data.scannedItems : 0,
635
+ stats: data.stats && typeof data.stats === 'object' ? data.stats : {},
288
636
  };
289
637
  }
290
638
 
@@ -305,13 +653,16 @@ export class MetadataClient {
305
653
  /**
306
654
  * Reset a metadata customization overlay back to the artifact default.
307
655
  * Idempotent: returns the result even when no overlay row existed.
656
+ * Pass `state: 'draft'` to discard the pending draft only (keeps the
657
+ * published overlay intact) — useful for a Studio "Discard draft" button.
308
658
  */
309
659
  async reset<T = unknown>(
310
660
  type: string,
311
661
  name: string,
312
- options: MetadataSaveOptions = {},
662
+ options: MetadataDeleteOptions = {},
313
663
  ): Promise<T> {
314
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
664
+ const qs = options.state === 'draft' ? '?state=draft' : '';
665
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
315
666
  const headers: Record<string, string> = { ...this.headers };
316
667
  if (options.ifMatch) headers['If-Match'] = options.ifMatch;
317
668
  if (options.actor) headers['X-Actor'] = options.actor;
@@ -320,6 +671,92 @@ export class MetadataClient {
320
671
  return (await res.json()) as T;
321
672
  }
322
673
 
674
+ /**
675
+ * Promote the pending draft of an item to the active overlay
676
+ * (POST `/meta/:type/:name/publish`). Returns
677
+ * `{ success, version, seq, message }`. Throws a `404 no_draft` if
678
+ * nothing is pending and `409 metadata_conflict` if the published
679
+ * overlay moved while the draft was sitting.
680
+ */
681
+ async publish<T = unknown>(
682
+ type: string,
683
+ name: string,
684
+ options: { actor?: string; message?: string } = {},
685
+ ): Promise<T> {
686
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
687
+ const headers: Record<string, string> = {
688
+ ...this.headers,
689
+ 'Content-Type': 'application/json',
690
+ };
691
+ if (options.actor) headers['X-Actor'] = options.actor;
692
+ const res = await this.fetchImpl(url, {
693
+ method: 'POST',
694
+ headers,
695
+ body: JSON.stringify(options.message ? { message: options.message } : {}),
696
+ });
697
+ if (!res.ok) throw await parseError(res);
698
+ return (await res.json()) as T;
699
+ }
700
+
701
+ /**
702
+ * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
703
+ * The server reads the history row at `toVersion`, writes its body back
704
+ * as the active overlay with `operation_type='revert'`. Returns
705
+ * `{ success, version, seq, restoredFromVersion, message }`. Throws
706
+ * `404 version_not_found` for unknown versions and `409 version_not_restorable`
707
+ * when the target version is a delete tombstone.
708
+ */
709
+ async rollback<T = unknown>(
710
+ type: string,
711
+ name: string,
712
+ toVersion: number,
713
+ options: { actor?: string; message?: string } = {},
714
+ ): Promise<T> {
715
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
716
+ const headers: Record<string, string> = {
717
+ ...this.headers,
718
+ 'Content-Type': 'application/json',
719
+ };
720
+ if (options.actor) headers['X-Actor'] = options.actor;
721
+ const res = await this.fetchImpl(url, {
722
+ method: 'POST',
723
+ headers,
724
+ body: JSON.stringify({
725
+ toVersion,
726
+ ...(options.message ? { message: options.message } : {}),
727
+ }),
728
+ });
729
+ if (!res.ok) throw await parseError(res);
730
+ return (await res.json()) as T;
731
+ }
732
+
733
+ /**
734
+ * Compute a structured top-level key diff between two history versions
735
+ * (GET `/meta/:type/:name/diff?from=&to=`). Returns
736
+ * `{ added, removed, changed }` where each entry carries a `path` and
737
+ * the relevant value(s). Useful for "what changed in this draft?" and
738
+ * pre-rollback previews.
739
+ */
740
+ async diff<T = unknown>(
741
+ type: string,
742
+ name: string,
743
+ fromVersion?: number,
744
+ toVersion?: number,
745
+ ): Promise<T> {
746
+ const params = new URLSearchParams();
747
+ if (fromVersion !== undefined) params.set('from', String(fromVersion));
748
+ if (toVersion !== undefined) params.set('to', String(toVersion));
749
+ const qs = params.toString();
750
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ''}`;
751
+ const res = await this.fetchImpl(url, {
752
+ method: 'GET',
753
+ headers: this.headers,
754
+ cache: 'no-store',
755
+ });
756
+ if (!res.ok) throw await parseError(res);
757
+ return (await res.json()) as T;
758
+ }
759
+
323
760
  /** Fetch the durable history (change log) for one metadata item. */
324
761
  async history<T = unknown>(
325
762
  type: string,
@@ -335,4 +772,25 @@ export class MetadataClient {
335
772
  if (!res.ok) throw await parseError(res);
336
773
  return (await res.json()) as T;
337
774
  }
775
+
776
+ /**
777
+ * Fetch the protection-audit trail for one metadata item
778
+ * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
779
+ * delete/reset attempt — allowed, denied, or forced — so the
780
+ * Studio "审计日志 / Audit log" tab can render who tried what
781
+ * and whether a lock blocked it.
782
+ */
783
+ async audit(
784
+ type: string,
785
+ name: string,
786
+ options: { limit?: number } = {},
787
+ ): Promise<MetadataAuditResponse> {
788
+ const params = new URLSearchParams();
789
+ if (options.limit !== undefined) params.set('limit', String(options.limit));
790
+ const qs = params.toString();
791
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ''}`;
792
+ const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
793
+ if (!res.ok) throw await parseError(res);
794
+ return (await res.json()) as MetadataAuditResponse;
795
+ }
338
796
  }
@@ -0,0 +1,83 @@
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
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index';
11
+
12
+ /** A fetch mock that answers discovery + records the dataset-query POST. */
13
+ function makeFetch(datasetResponse: { ok: boolean; status?: number; body: unknown }) {
14
+ const calls: Array<{ url: string; init?: any }> = [];
15
+ const fetchImpl = vi.fn(async (url: any, init?: any) => {
16
+ const u = String(url);
17
+ calls.push({ url: u, init });
18
+ if (u.includes('/api/v1/discovery')) {
19
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
20
+ }
21
+ if (u.includes('/api/v1/analytics/dataset/query')) {
22
+ return {
23
+ ok: datasetResponse.ok,
24
+ status: datasetResponse.status ?? (datasetResponse.ok ? 200 : 400),
25
+ statusText: datasetResponse.ok ? 'OK' : 'Bad Request',
26
+ json: async () => datasetResponse.body,
27
+ } as any;
28
+ }
29
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
30
+ });
31
+ return { fetchImpl, calls };
32
+ }
33
+
34
+ const inlineDataset = {
35
+ name: 'sales', label: 'Sales', object: 'opportunity', include: ['account'],
36
+ dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
37
+ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
38
+ };
39
+ const selection = { dimensions: ['region'], measures: ['revenue'] };
40
+
41
+ describe('ObjectStackAdapter.queryDataset', () => {
42
+ beforeEach(() => clearSharedDiscoveryCache());
43
+
44
+ it('POSTs the inline dataset + selection and returns rows/fields', async () => {
45
+ const { fetchImpl, calls } = makeFetch({
46
+ ok: true,
47
+ body: { rows: [{ region: 'NA', revenue: 100 }], fields: [{ name: 'revenue', type: 'number' }] },
48
+ });
49
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', token: 'tok_123', autoReconnect: false, fetch: fetchImpl as any });
50
+
51
+ const result = await adapter.queryDataset(inlineDataset as any, selection);
52
+
53
+ expect(result.rows).toEqual([{ region: 'NA', revenue: 100 }]);
54
+ expect(result.fields).toEqual([{ name: 'revenue', type: 'number' }]);
55
+
56
+ const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
57
+ expect(post.url).toBe('http://localhost:3000/api/v1/analytics/dataset/query');
58
+ expect(post.init.method).toBe('POST');
59
+ expect(post.init.headers.Authorization).toBe('Bearer tok_123');
60
+ expect(JSON.parse(post.init.body)).toEqual({ dataset: inlineDataset, selection });
61
+ });
62
+
63
+ it('sends datasetName when given a string', async () => {
64
+ const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
65
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
66
+ await adapter.queryDataset('sales', selection);
67
+ const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
68
+ expect(JSON.parse(post.init.body)).toEqual({ datasetName: 'sales', selection });
69
+ });
70
+
71
+ it('unwraps a { success, data } envelope', async () => {
72
+ const { fetchImpl } = makeFetch({ ok: true, body: { success: true, data: { rows: [{ x: 1 }], fields: [] } } });
73
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
74
+ const result = await adapter.queryDataset(inlineDataset as any, selection);
75
+ expect(result.rows).toEqual([{ x: 1 }]);
76
+ });
77
+
78
+ it('throws (no silent fallback) with the server message on a 4xx', async () => {
79
+ const { fetchImpl } = makeFetch({ ok: false, status: 400, body: { code: 'DATASET_INVALID', message: 'relationship not declared' } });
80
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
81
+ await expect(adapter.queryDataset(inlineDataset as any, selection)).rejects.toThrow(/relationship not declared/);
82
+ });
83
+ });