@object-ui/data-objectstack 17.2.0 → 17.5.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 +772 -0
- package/README.md +3 -11
- package/dist/index.cjs +1232 -699
- package/dist/index.d.cts +1367 -906
- package/dist/index.d.ts +1367 -906
- package/dist/index.js +1222 -696
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +5 -5
- package/src/apiAccessDenied.test.ts +233 -0
- package/src/appAccessProbe.test.ts +147 -0
- package/src/cloud-surface-retired-4152.pin.test.ts +171 -0
- package/src/deleteView.homes.test.ts +389 -0
- package/src/deleteViewContract.types.test.ts +246 -0
- package/src/filter-operator-ast-parity.test.ts +75 -11
- package/src/httpFailureLogging.test.ts +109 -0
- package/src/index.ts +967 -100
- package/src/listViewOverrides.test.ts +241 -0
- package/src/metadata-client.get-envelope.test.ts +171 -0
- package/src/metadata-client.saveAdvisories.test.ts +226 -0
- package/src/metadata-client.ts +208 -11
- package/src/onMutation.test.ts +29 -2
- package/src/onSaveAdvisory.test.ts +311 -0
- package/src/onWriteWarning.test.ts +101 -0
- package/src/queryDataset.test.ts +411 -0
- package/src/spec-symbol-batch6.test.ts +25 -2
- package/src/updateView.draft.test.ts +242 -0
- package/src/v3-compat.test.ts +10 -21
- package/src/viewCacheInvalidation.pin.test.ts +260 -0
- package/src/viewInvalidationSeam.pin.test.ts +162 -0
- package/src/cloud.ts +0 -109
package/src/metadata-client.ts
CHANGED
|
@@ -32,6 +32,84 @@
|
|
|
32
32
|
* type, mirroring the framework's "single Zod source per type" rule.
|
|
33
33
|
*/
|
|
34
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
|
+
|
|
35
113
|
export interface MetadataClientConfig {
|
|
36
114
|
/** Base URL of the ObjectStack server (no trailing slash needed). */
|
|
37
115
|
baseUrl: string;
|
|
@@ -55,6 +133,22 @@ export interface MetadataClientConfig {
|
|
|
55
133
|
* nothing the preview shows is live until Publish.
|
|
56
134
|
*/
|
|
57
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;
|
|
58
152
|
}
|
|
59
153
|
|
|
60
154
|
export interface MetadataListOptions {
|
|
@@ -367,6 +461,32 @@ async function parseError(res: Response): Promise<MetadataError> {
|
|
|
367
461
|
return err;
|
|
368
462
|
}
|
|
369
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
|
+
|
|
370
490
|
/**
|
|
371
491
|
* MetadataClient — read/write protocol metadata via the framework REST API.
|
|
372
492
|
*
|
|
@@ -384,12 +504,15 @@ export class MetadataClient {
|
|
|
384
504
|
private readonly headers: Record<string, string>;
|
|
385
505
|
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
386
506
|
readonly previewDrafts: boolean;
|
|
507
|
+
/** #4133 — sink for post-save advisory findings; see the config field. */
|
|
508
|
+
private readonly onSaveAdvisory: MetadataSaveAdvisoryListener | undefined;
|
|
387
509
|
|
|
388
510
|
constructor(config: MetadataClientConfig) {
|
|
389
511
|
this.base = buildBase(config);
|
|
390
512
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
391
513
|
this.headers = { Accept: 'application/json', ...(config.headers ?? {}) };
|
|
392
514
|
this.previewDrafts = config.previewDrafts === true;
|
|
515
|
+
this.onSaveAdvisory = config.onSaveAdvisory;
|
|
393
516
|
}
|
|
394
517
|
|
|
395
518
|
/** Update the client's environment scope at runtime. */
|
|
@@ -402,6 +525,11 @@ export class MetadataClient {
|
|
|
402
525
|
fetch: this.fetchImpl,
|
|
403
526
|
headers: this.headers,
|
|
404
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 } : {}),
|
|
405
533
|
});
|
|
406
534
|
}
|
|
407
535
|
|
|
@@ -423,6 +551,10 @@ export class MetadataClient {
|
|
|
423
551
|
fetch: this.fetchImpl,
|
|
424
552
|
headers: this.headers,
|
|
425
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 } : {}),
|
|
426
558
|
});
|
|
427
559
|
}
|
|
428
560
|
|
|
@@ -488,11 +620,16 @@ export class MetadataClient {
|
|
|
488
620
|
}
|
|
489
621
|
|
|
490
622
|
/**
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
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.
|
|
494
631
|
*/
|
|
495
|
-
async
|
|
632
|
+
private async readItemResponse<T = unknown>(
|
|
496
633
|
type: string,
|
|
497
634
|
name: string,
|
|
498
635
|
options: MetadataGetOptions = {},
|
|
@@ -511,24 +648,63 @@ export class MetadataClient {
|
|
|
511
648
|
return (await res.json()) as T;
|
|
512
649
|
}
|
|
513
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
|
+
|
|
514
685
|
/**
|
|
515
686
|
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
516
687
|
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
517
688
|
* back to the published overlay or the artifact registry — a `null`
|
|
518
689
|
* unambiguously means "nothing to publish".
|
|
519
690
|
*
|
|
520
|
-
* Note: the
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
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.
|
|
525
698
|
*/
|
|
526
699
|
async getDraft<T = unknown>(
|
|
527
700
|
type: string,
|
|
528
701
|
name: string,
|
|
529
702
|
options: { packageId?: string } = {},
|
|
530
703
|
): Promise<T | null> {
|
|
531
|
-
return this.
|
|
704
|
+
return this.readItemResponse<T>(type, name, {
|
|
705
|
+
state: 'draft',
|
|
706
|
+
...(options.packageId ? { packageId: options.packageId } : {}),
|
|
707
|
+
});
|
|
532
708
|
}
|
|
533
709
|
|
|
534
710
|
/**
|
|
@@ -570,7 +746,28 @@ export class MetadataClient {
|
|
|
570
746
|
body: JSON.stringify(item),
|
|
571
747
|
});
|
|
572
748
|
if (!res.ok) throw await parseError(res);
|
|
573
|
-
|
|
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;
|
|
574
771
|
}
|
|
575
772
|
|
|
576
773
|
/**
|
package/src/onMutation.test.ts
CHANGED
|
@@ -78,10 +78,18 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
it('emits a delete event only when the server confirms deletion', async () => {
|
|
81
|
+
// `success`, not `deleted` (objectstack#5638). This mock is why the bug it
|
|
82
|
+
// fixed was invisible here: `DeleteDataResult` DECLARED a `deleted` key that
|
|
83
|
+
// no schema has ever declared and no server path has ever returned, and this
|
|
84
|
+
// fixture faithfully reproduced the declaration rather than the wire. So the
|
|
85
|
+
// adapter read `result.deleted`, got `undefined` against a real server —
|
|
86
|
+
// emitting no event and resolving `undefined` from a `Promise<boolean>` —
|
|
87
|
+
// while this test stayed green against the fiction. Pinning the shape the
|
|
88
|
+
// server actually sends is what makes the assertion mean anything.
|
|
81
89
|
const del = vi
|
|
82
90
|
.fn()
|
|
83
|
-
.mockResolvedValueOnce({
|
|
84
|
-
.mockResolvedValueOnce({
|
|
91
|
+
.mockResolvedValueOnce({ object: 'showcase_project', id: 'r1', success: true })
|
|
92
|
+
.mockResolvedValueOnce({ object: 'showcase_project', id: 'r2', success: false });
|
|
85
93
|
const ds = makeDS({ delete: del });
|
|
86
94
|
const events: DataSourceMutationEvent[] = [];
|
|
87
95
|
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
@@ -94,6 +102,25 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
94
102
|
]);
|
|
95
103
|
});
|
|
96
104
|
|
|
105
|
+
// REVERSE PIN for objectui#3412 / objectstack#5638. The bug was not that the
|
|
106
|
+
// guard was missing — it was that the guard read a key the wire never carries,
|
|
107
|
+
// so it was dead while looking alive. A response spelling the old `deleted`
|
|
108
|
+
// must therefore emit NOTHING: if someone re-introduces a tolerant
|
|
109
|
+
// `result.success ?? result.deleted` read, this goes red. Reading both keys is
|
|
110
|
+
// exactly what contract-first forbids (AGENTS.md #0.1) and what objectstack#5638's
|
|
111
|
+
// own triage ruled out — the producer has one shape, so the consumer gets one.
|
|
112
|
+
it('emits nothing for a response carrying the retired `deleted` key', async () => {
|
|
113
|
+
const del = vi.fn().mockResolvedValue({ object: 'showcase_project', id: 'r1', deleted: true });
|
|
114
|
+
const ds = makeDS({ delete: del });
|
|
115
|
+
const events: DataSourceMutationEvent[] = [];
|
|
116
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
117
|
+
|
|
118
|
+
const returned = await ds.delete('showcase_project', 'r1');
|
|
119
|
+
|
|
120
|
+
expect(events).toEqual([]);
|
|
121
|
+
expect(returned).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
97
124
|
it('emits a single bulk event per bulkUpdate call (not per id)', async () => {
|
|
98
125
|
const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
|
|
99
126
|
const ds = makeDS({ updateMany });
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
* The SECOND client class's metadata save door must surface the runtime
|
|
11
|
+
* authoring gate's advisory findings (objectui#4237; backend objectstack#7435).
|
|
12
|
+
*
|
|
13
|
+
* `ObjectStackClient.meta.saveItem` writes through the same
|
|
14
|
+
* `PUT /api/v1/meta/:type/:name` as `MetadataClient.save`, but it is a
|
|
15
|
+
* different class and #4133/#4236 only covered the other one — every caller
|
|
16
|
+
* here awaited the call and discarded the response, so an `advisories[]` the
|
|
17
|
+
* server attached was parsed off the wire and dropped. These pins are the
|
|
18
|
+
* red-first evidence: with `installSaveAdvisoryInterceptor()` reverted, every
|
|
19
|
+
* "emits" case below fails because no event is ever produced.
|
|
20
|
+
*
|
|
21
|
+
* ## Why these drive a REAL SDK client through a fake `fetch`
|
|
22
|
+
*
|
|
23
|
+
* The sibling `onWriteWarning.test.ts` replaces `ds.client` with a stub, which
|
|
24
|
+
* is right for the data door. It would be wrong here: the whole premise of this
|
|
25
|
+
* fix is a measurement about the wire — that the SDK's `unwrapResponse` hands
|
|
26
|
+
* the save body back VERBATIM (it strips its `{ success, data }` envelope only
|
|
27
|
+
* when the body has a `data` key, and this one does not), so `advisories` is
|
|
28
|
+
* still at the top level where the shared `readSaveAdvisories` looks for it.
|
|
29
|
+
* Stubbing `meta` would assume exactly the thing under test. Driving the real
|
|
30
|
+
* client keeps that measured on every run.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
34
|
+
import { ObjectStackAdapter } from './index';
|
|
35
|
+
import type {
|
|
36
|
+
MetadataSaveAdvisoryEvent,
|
|
37
|
+
RuntimeAuthoringIssue,
|
|
38
|
+
WriteWarningEvent,
|
|
39
|
+
} from './index';
|
|
40
|
+
|
|
41
|
+
/** The measured `nightly_purge` finding, in the spec's D3 shape. */
|
|
42
|
+
const PURGE_ADVISORY: RuntimeAuthoringIssue = {
|
|
43
|
+
severity: 'warning',
|
|
44
|
+
rule: 'flow/delete-without-filter',
|
|
45
|
+
where: 'flow "nightly_purge" · node "purge old rows"',
|
|
46
|
+
path: 'flows[0].nodes[2].config.filters',
|
|
47
|
+
message: 'this delete_record node sets multi: true with no filter, so it deletes every row',
|
|
48
|
+
hint: 'add a filter, or set multi: false to delete a single record',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The clean part of a real save response. `success`/`version`/`seq`/`state` are
|
|
53
|
+
* REQUIRED by `SaveMetaItemResponseSchema` (#5745) — spelled out here because
|
|
54
|
+
* their presence is what makes the `unwrapResponse` measurement above
|
|
55
|
+
* meaningful: a `success` boolean with NO sibling `data` key.
|
|
56
|
+
*/
|
|
57
|
+
const CLEAN_BODY = { success: true, version: 'v2', seq: 4, state: 'active' as const };
|
|
58
|
+
|
|
59
|
+
/** A `fetch` that answers every metadata PUT with `body`, and records the calls. */
|
|
60
|
+
function fetchAnswering(body: unknown) {
|
|
61
|
+
return vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
|
62
|
+
new Response(JSON.stringify(body), {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A connected adapter whose client talks to `fetchImpl`. `connected` is forced
|
|
71
|
+
* rather than discovered, exactly as the sibling write-warning suite does — the
|
|
72
|
+
* save door needs no discovery (`getRoute('metadata')` falls back to
|
|
73
|
+
* `/api/v1/meta`), and skipping it keeps the module-level discovery cache out
|
|
74
|
+
* of these tests.
|
|
75
|
+
*/
|
|
76
|
+
function makeAdapter(body: unknown) {
|
|
77
|
+
const fetchImpl = fetchAnswering(body);
|
|
78
|
+
const ds: any = new ObjectStackAdapter({
|
|
79
|
+
baseUrl: 'http://test.local',
|
|
80
|
+
fetch: fetchImpl as unknown as typeof fetch,
|
|
81
|
+
});
|
|
82
|
+
ds.connected = true;
|
|
83
|
+
ds.connectionState = 'connected';
|
|
84
|
+
return { ds, fetchImpl };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('ObjectStackAdapter.onSaveAdvisory — the second client class (#4237)', () => {
|
|
88
|
+
it('emits the findings a successful save returned', async () => {
|
|
89
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
90
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
91
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
92
|
+
|
|
93
|
+
await ds.getClient().meta.saveItem('flow', 'nightly_purge', { name: 'nightly_purge' });
|
|
94
|
+
|
|
95
|
+
expect(events).toEqual([
|
|
96
|
+
{
|
|
97
|
+
type: 'flow',
|
|
98
|
+
name: 'nightly_purge',
|
|
99
|
+
mode: 'publish',
|
|
100
|
+
advisories: [PURGE_ADVISORY],
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('carries rule, message and hint through verbatim — they are server prose', async () => {
|
|
106
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
107
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
108
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
109
|
+
|
|
110
|
+
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
111
|
+
|
|
112
|
+
const finding = events[0]!.advisories[0]!;
|
|
113
|
+
expect(finding.rule).toBe('flow/delete-without-filter');
|
|
114
|
+
expect(finding.message).toBe(PURGE_ADVISORY.message);
|
|
115
|
+
expect(finding.hint).toBe(PURGE_ADVISORY.hint);
|
|
116
|
+
// Never `error` on this channel — an error-severity finding is a 422.
|
|
117
|
+
expect(finding.severity).toBe('warning');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The response-shape measurement the fix rests on, asserted rather than
|
|
122
|
+
* assumed: this body is handed back whole. If a future SDK started wrapping
|
|
123
|
+
* the save response in `{ success, data }`, THIS is the case that goes red
|
|
124
|
+
* and says so, instead of the advisory channel quietly emitting nothing.
|
|
125
|
+
*/
|
|
126
|
+
it('hands the caller the save response unchanged — the SDK does not unwrap this body', async () => {
|
|
127
|
+
const body = { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] };
|
|
128
|
+
const { ds } = makeAdapter(body);
|
|
129
|
+
|
|
130
|
+
const result = await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual(body);
|
|
133
|
+
expect(result.version).toBe('v2');
|
|
134
|
+
expect(result.advisories).toEqual([PURGE_ADVISORY]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('says nothing on a clean save — the server omits the key entirely', async () => {
|
|
138
|
+
const { ds } = makeAdapter(CLEAN_BODY);
|
|
139
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
140
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
141
|
+
|
|
142
|
+
await ds.getClient().meta.saveItem('object', 'account', { name: 'account' });
|
|
143
|
+
|
|
144
|
+
expect(events).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('says nothing when the array is present but empty', async () => {
|
|
148
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [] });
|
|
149
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
150
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
151
|
+
|
|
152
|
+
await ds.getClient().meta.saveItem('object', 'account', {});
|
|
153
|
+
|
|
154
|
+
expect(events).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('drops half-shaped findings rather than rendering blanks at the author', async () => {
|
|
158
|
+
const { ds } = makeAdapter({
|
|
159
|
+
...CLEAN_BODY,
|
|
160
|
+
advisories: [PURGE_ADVISORY, { rule: 'only-a-rule' }, null, 'string'],
|
|
161
|
+
});
|
|
162
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
163
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
164
|
+
|
|
165
|
+
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
166
|
+
|
|
167
|
+
// The SAME reader `MetadataClient.save` uses — one reader, two call sites.
|
|
168
|
+
expect(events[0]!.advisories).toEqual([PURGE_ADVISORY]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('a throwing listener never fails a save the server already committed', async () => {
|
|
172
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
173
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
174
|
+
const seen: MetadataSaveAdvisoryEvent[] = [];
|
|
175
|
+
ds.onSaveAdvisory(() => {
|
|
176
|
+
throw new Error('renderer exploded');
|
|
177
|
+
});
|
|
178
|
+
// Registered second: a throwing listener must not starve the others.
|
|
179
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => seen.push(e));
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
ds.getClient().meta.saveItem('flow', 'nightly_purge', {}),
|
|
183
|
+
).resolves.toBeTruthy();
|
|
184
|
+
expect(seen).toHaveLength(1);
|
|
185
|
+
warn.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('unsubscribes', async () => {
|
|
189
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
190
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
191
|
+
const off = ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
192
|
+
|
|
193
|
+
await ds.getClient().meta.saveItem('flow', 'a', {});
|
|
194
|
+
off();
|
|
195
|
+
await ds.getClient().meta.saveItem('flow', 'b', {});
|
|
196
|
+
|
|
197
|
+
expect(events).toHaveLength(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Control for the #4133/#4236 sibling channel: a metadata save is NOT a
|
|
202
|
+
* record write, and must not appear on the dropped-fields channel. This is
|
|
203
|
+
* what makes the "existing `onWriteWarning` consumers unchanged" claim
|
|
204
|
+
* checkable rather than asserted.
|
|
205
|
+
*/
|
|
206
|
+
it('does not leak onto the write-warning channel', async () => {
|
|
207
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
208
|
+
const warnings: WriteWarningEvent[] = [];
|
|
209
|
+
ds.onWriteWarning((e: WriteWarningEvent) => warnings.push(e));
|
|
210
|
+
|
|
211
|
+
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
212
|
+
|
|
213
|
+
expect(warnings).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Draft-door honesty (D1), carried over from #4236's finding.
|
|
219
|
+
*
|
|
220
|
+
* Drafts are never gated — the framework returns at
|
|
221
|
+
* `runtime-authoring-gate.ts`'s early return:
|
|
222
|
+
*
|
|
223
|
+
* // D1 — drafts are never gated. Publishing one runs this same function.
|
|
224
|
+
* if (args.state !== 'active') return null;
|
|
225
|
+
*
|
|
226
|
+
* so a draft save produces no findings at all rather than producing some that
|
|
227
|
+
* get withheld. Unlike the other client class, THIS one has no draft door to
|
|
228
|
+
* begin with: the SDK's `saveItem(type, name, item)` takes no mode, so every
|
|
229
|
+
* caller enumerated on #4237 writes the active door and the gate does run for
|
|
230
|
+
* all of them. `mode` is therefore read off the response's own `state`.
|
|
231
|
+
*/
|
|
232
|
+
describe('save-advisory mode reflects the door the server reports (D1)', () => {
|
|
233
|
+
it('a draft-state response with no findings emits nothing — the gate never ran', async () => {
|
|
234
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, state: 'draft' });
|
|
235
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
236
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
237
|
+
|
|
238
|
+
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
239
|
+
|
|
240
|
+
expect(events).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('labels the mode from the response state when a draft does somehow advise', async () => {
|
|
244
|
+
// Not reachable through today's server (see D1 above), but the event must
|
|
245
|
+
// tell the truth about which door it came through rather than hard-coding
|
|
246
|
+
// one, so the field is pinned on its own.
|
|
247
|
+
const { ds } = makeAdapter({
|
|
248
|
+
...CLEAN_BODY,
|
|
249
|
+
state: 'draft',
|
|
250
|
+
advisories: [PURGE_ADVISORY],
|
|
251
|
+
});
|
|
252
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
253
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
254
|
+
|
|
255
|
+
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
256
|
+
|
|
257
|
+
expect(events[0]!.mode).toBe('draft');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Caller coverage. The point of putting ONE emitter at the client seam is that
|
|
263
|
+
* the enumerated call sites need no edit — so the pins that matter are the ones
|
|
264
|
+
* that never mention the interceptor.
|
|
265
|
+
*/
|
|
266
|
+
describe('the enumerated callers are covered without a per-site edit', () => {
|
|
267
|
+
it("covers the adapter's own view save path (updateViewConfig)", async () => {
|
|
268
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
269
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
270
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
271
|
+
|
|
272
|
+
await ds.updateViewConfig('lead', 'all_leads', { object: 'lead' });
|
|
273
|
+
|
|
274
|
+
expect(events).toHaveLength(1);
|
|
275
|
+
expect(events[0]!.type).toBe('view');
|
|
276
|
+
expect(events[0]!.name).toBe('all_leads');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("covers the adapter's own dashboard save path (updateDashboard)", async () => {
|
|
280
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
281
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
282
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
283
|
+
|
|
284
|
+
await ds.updateDashboard('crm_overview_dashboard', { widgets: [] });
|
|
285
|
+
|
|
286
|
+
expect(events).toHaveLength(1);
|
|
287
|
+
expect(events[0]!.type).toBe('dashboard');
|
|
288
|
+
expect(events[0]!.name).toBe('crm_overview_dashboard');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `getClient()` hands back the adapter's own long-lived instance, which is
|
|
293
|
+
* how every caller OUTSIDE this package reaches the save door
|
|
294
|
+
* (`MetadataService`, `useNavigationSync`, plugin-designer's app wizard).
|
|
295
|
+
* If that ever started returning a fresh or wrapped client, the interceptor
|
|
296
|
+
* would stop covering them — so the identity is pinned here.
|
|
297
|
+
*/
|
|
298
|
+
it('getClient() is the intercepted instance every external caller uses', async () => {
|
|
299
|
+
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
300
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
301
|
+
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
302
|
+
|
|
303
|
+
expect(ds.getClient()).toBe(ds.getClient());
|
|
304
|
+
|
|
305
|
+
// The `saveItem('app', …)` shape `useNavigationSync` and the plugin-designer
|
|
306
|
+
// app wizard both use.
|
|
307
|
+
await ds.getClient().meta.saveItem('app', 'crm', { name: 'crm' });
|
|
308
|
+
|
|
309
|
+
expect(events[0]).toMatchObject({ type: 'app', name: 'crm' });
|
|
310
|
+
});
|
|
311
|
+
});
|