@object-ui/data-objectstack 17.3.0 → 17.6.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +888 -0
  2. package/README.md +3 -11
  3. package/dist/index.cjs +1511 -1115
  4. package/dist/index.d.cts +1222 -824
  5. package/dist/index.d.ts +1222 -824
  6. package/dist/index.js +1503 -1111
  7. package/package.json +5 -6
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/bulkDelete.test.ts +0 -75
  10. package/src/bulkUpdate.test.ts +0 -93
  11. package/src/cache/MetadataCache.test.ts +0 -477
  12. package/src/cache/MetadataCache.ts +0 -289
  13. package/src/cloud.ts +0 -109
  14. package/src/connection.test.ts +0 -188
  15. package/src/contracts.ts +0 -115
  16. package/src/errors.test.ts +0 -426
  17. package/src/errors.ts +0 -293
  18. package/src/exportDownload.test.ts +0 -141
  19. package/src/filter-entry-translation.test.ts +0 -360
  20. package/src/filter-operator-ast-parity.test.ts +0 -71
  21. package/src/getObjectSchema.test.ts +0 -145
  22. package/src/importJob.test.ts +0 -71
  23. package/src/index.ts +0 -3658
  24. package/src/integration.ts +0 -200
  25. package/src/listViews.test.ts +0 -175
  26. package/src/metadata-client.test.ts +0 -303
  27. package/src/metadata-client.ts +0 -849
  28. package/src/occ.test.ts +0 -101
  29. package/src/onMutation.test.ts +0 -562
  30. package/src/onWriteWarning.test.ts +0 -273
  31. package/src/orderby-serialization.test.ts +0 -115
  32. package/src/queryDataset.test.ts +0 -132
  33. package/src/searchAll.test.ts +0 -120
  34. package/src/security.ts +0 -243
  35. package/src/spec-symbol-batch6.test.ts +0 -306
  36. package/src/studio.ts +0 -152
  37. package/src/userState.test.ts +0 -315
  38. package/src/userState.ts +0 -238
  39. package/src/v3-compat.test.ts +0 -240
  40. package/src/validation-error.test.ts +0 -94
@@ -1,200 +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
- * Integration module for @objectstack/spec v3.0.0
11
- * Provides third-party service connectors for Slack, email, webhooks.
12
- */
13
-
14
- export type IntegrationProvider = 'slack' | 'email' | 'webhook' | 'teams' | 'discord';
15
-
16
- export interface IntegrationConfig {
17
- /** Integration provider type */
18
- provider: IntegrationProvider;
19
- /** Whether this integration is enabled */
20
- enabled: boolean;
21
- /** Provider-specific configuration */
22
- config: Record<string, unknown>;
23
- /** Event triggers */
24
- triggers?: IntegrationTrigger[];
25
- }
26
-
27
- export interface IntegrationTrigger {
28
- /** Event name (e.g. 'record.created', 'record.updated') */
29
- event: string;
30
- /** Filter condition */
31
- filter?: string;
32
- /** Template for the message/payload */
33
- template?: string;
34
- }
35
-
36
- export interface SlackIntegrationConfig extends IntegrationConfig {
37
- provider: 'slack';
38
- config: {
39
- webhookUrl: string;
40
- channel?: string;
41
- username?: string;
42
- iconEmoji?: string;
43
- };
44
- }
45
-
46
- export interface EmailIntegrationConfig extends IntegrationConfig {
47
- provider: 'email';
48
- config: {
49
- smtpHost: string;
50
- smtpPort: number;
51
- secure: boolean;
52
- from: string;
53
- to: string[];
54
- subject?: string;
55
- };
56
- }
57
-
58
- export interface WebhookIntegrationConfig extends IntegrationConfig {
59
- provider: 'webhook';
60
- config: {
61
- url: string;
62
- method: 'GET' | 'POST' | 'PUT' | 'PATCH';
63
- headers?: Record<string, string>;
64
- retryCount?: number;
65
- retryDelay?: number;
66
- };
67
- }
68
-
69
- /**
70
- * Integration service manager.
71
- * Manages third-party service connections and event dispatching.
72
- */
73
- export class IntegrationManager {
74
- private integrations: Map<string, IntegrationConfig> = new Map();
75
-
76
- /**
77
- * Register a new integration.
78
- */
79
- register(id: string, config: IntegrationConfig): void {
80
- this.integrations.set(id, config);
81
- }
82
-
83
- /**
84
- * Remove an integration.
85
- */
86
- unregister(id: string): void {
87
- this.integrations.delete(id);
88
- }
89
-
90
- /**
91
- * Get all registered integrations.
92
- */
93
- getAll(): Map<string, IntegrationConfig> {
94
- return new Map(this.integrations);
95
- }
96
-
97
- /**
98
- * Get integrations that match a specific event.
99
- */
100
- getForEvent(event: string): IntegrationConfig[] {
101
- return Array.from(this.integrations.values()).filter(
102
- (integration) =>
103
- integration.enabled &&
104
- integration.triggers?.some((t) => t.event === event)
105
- );
106
- }
107
-
108
- /**
109
- * Dispatch an event to all matching integrations.
110
- * Returns results for each integration.
111
- */
112
- async dispatch(event: string, payload: Record<string, unknown>): Promise<Array<{ id: string; success: boolean; error?: string }>> {
113
- const matching = this.getForEvent(event);
114
- const results: Array<{ id: string; success: boolean; error?: string }> = [];
115
-
116
- for (const [id, integration] of this.integrations) {
117
- if (!matching.includes(integration)) continue;
118
-
119
- try {
120
- await this.send(integration, payload);
121
- results.push({ id, success: true });
122
- } catch (err) {
123
- results.push({ id, success: false, error: (err as Error).message });
124
- }
125
- }
126
-
127
- return results;
128
- }
129
-
130
- /**
131
- * Send payload to a specific integration.
132
- */
133
- private async send(integration: IntegrationConfig, payload: Record<string, unknown>): Promise<void> {
134
- switch (integration.provider) {
135
- case 'webhook': {
136
- const cfg = integration.config as WebhookIntegrationConfig['config'];
137
- const url = cfg.url;
138
- // Validate URL - only allow http and https protocols
139
- try {
140
- const parsed = new URL(url);
141
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
142
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
143
- }
144
- } catch (e) {
145
- if (e instanceof TypeError) {
146
- // A malformed URL surfaces as its own message; we can't attach `e`
147
- // as the `Error` `cause` because this package targets ES2020, whose
148
- // lib types the 1-arg `Error` constructor only. Scoped disable.
149
- // eslint-disable-next-line preserve-caught-error
150
- throw new Error(`Invalid webhook URL: ${url}`);
151
- }
152
- throw e;
153
- }
154
- await fetch(url, {
155
- method: cfg.method,
156
- headers: {
157
- 'Content-Type': 'application/json',
158
- ...cfg.headers,
159
- },
160
- body: JSON.stringify(payload),
161
- });
162
- break;
163
- }
164
- case 'slack': {
165
- const cfg = integration.config as SlackIntegrationConfig['config'];
166
- const url = cfg.webhookUrl;
167
- // Validate URL - only allow https protocol for Slack webhooks
168
- try {
169
- const parsed = new URL(url);
170
- if (parsed.protocol !== 'https:') {
171
- throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
172
- }
173
- } catch (e) {
174
- if (e instanceof TypeError) {
175
- // A malformed URL surfaces as its own message; we can't attach `e`
176
- // as the `Error` `cause` because this package targets ES2020, whose
177
- // lib types the 1-arg `Error` constructor only. Scoped disable.
178
- // eslint-disable-next-line preserve-caught-error
179
- throw new Error(`Invalid Slack webhook URL: ${url}`);
180
- }
181
- throw e;
182
- }
183
- await fetch(url, {
184
- method: 'POST',
185
- headers: { 'Content-Type': 'application/json' },
186
- body: JSON.stringify({
187
- channel: cfg.channel,
188
- username: cfg.username,
189
- icon_emoji: cfg.iconEmoji,
190
- text: JSON.stringify(payload),
191
- }),
192
- });
193
- break;
194
- }
195
- // Email and other providers would require server-side implementation
196
- default:
197
- break;
198
- }
199
- }
200
- }
@@ -1,175 +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
- import { describe, it, expect, vi } from 'vitest';
10
- import { ObjectStackAdapter } from './index';
11
-
12
- /**
13
- * `listViews(object)` feeds the list-view switcher (ObjectView → ViewTabBar),
14
- * so it must return LIST-family views only. The backend exposes each view as
15
- * an independent ViewItem carrying a `viewKind` discriminant (ADR-0017); a
16
- * form-family view (e.g. `crm_activity.default`) must NOT leak in as a tab.
17
- */
18
- function makeDS(items: any[]) {
19
- const ds: any = new ObjectStackAdapter({
20
- baseUrl: 'http://test.local',
21
- fetch: vi.fn(async () =>
22
- new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
23
- status: 200,
24
- headers: { 'Content-Type': 'application/json' },
25
- })),
26
- });
27
- ds.connected = true;
28
- ds.connectionState = 'connected';
29
- ds.client = { meta: { getItems: vi.fn(async () => ({ items })) } };
30
- return ds;
31
- }
32
-
33
- /**
34
- * A DS whose HTTP layer answers the `?preview=draft` metadata route with a
35
- * pre-overlaid list (the shape the framework already returns: draft wins by
36
- * name, each draft tagged `_draft`). The published `client.meta.getItems` stub
37
- * is present but MUST NOT be used in preview mode.
38
- */
39
- function makeDraftDS(overlaid: any[]) {
40
- const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
41
- const url = String(input);
42
- if (url.includes('/meta/view')) {
43
- return new Response(JSON.stringify({ items: overlaid }), {
44
- status: 200,
45
- headers: { 'Content-Type': 'application/json' },
46
- });
47
- }
48
- return new Response(JSON.stringify({ success: true, data: {} }), {
49
- status: 200,
50
- headers: { 'Content-Type': 'application/json' },
51
- });
52
- });
53
- const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
54
- ds.connected = true;
55
- ds.connectionState = 'connected';
56
- ds.client = { meta: { getItems: vi.fn(async () => ({ items: [] })) } };
57
- return { ds, fetchImpl };
58
- }
59
-
60
- describe('ObjectStackDataSource.listViews', () => {
61
- // The crm_activity view set as the framework expands it.
62
- const listAll = {
63
- name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list', isDefault: true,
64
- label: 'All Activities', config: { type: 'grid', data: { object: 'crm_activity' } },
65
- };
66
- const listCalendar = {
67
- name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
68
- label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
69
- };
70
- const formDefault = {
71
- name: 'crm_activity.default', object: 'crm_activity', viewKind: 'form',
72
- label: 'Activity', config: { type: 'simple' },
73
- };
74
-
75
- it('excludes form-family ViewItems from the list-view switcher', async () => {
76
- const ds = makeDS([listAll, listCalendar, formDefault]);
77
- const views = await ds.listViews('crm_activity');
78
- const names = views.map((v: any) => v.name).sort();
79
- expect(names).toEqual(['crm_activity.all', 'crm_activity.calendar']);
80
- // The form view is the original leak — it must be gone.
81
- expect(views.find((v: any) => v.name === 'crm_activity.default')).toBeUndefined();
82
- });
83
-
84
- it('excludes detail-family views too', async () => {
85
- const detail = { name: 'crm_activity.detail', object: 'crm_activity', viewKind: 'detail', config: {} };
86
- const ds = makeDS([listAll, detail]);
87
- const views = await ds.listViews('crm_activity');
88
- expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
89
- });
90
-
91
- it('filters by object binding', async () => {
92
- const otherList = { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', config: {} };
93
- const ds = makeDS([listAll, otherList]);
94
- const views = await ds.listViews('crm_activity');
95
- expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
96
- });
97
-
98
- it('keeps legacy bare specs without a viewKind (saved/list views)', async () => {
99
- const legacy = { name: 'saved_grid', object: 'crm_activity', type: 'grid' };
100
- const wrapped = { list: { name: 'wrapped_grid', object: 'crm_activity', type: 'grid' } };
101
- const ds = makeDS([legacy, wrapped]);
102
- const views = await ds.listViews('crm_activity');
103
- expect(views.map((v: any) => v.name).sort()).toEqual(['saved_grid', 'wrapped_grid']);
104
- });
105
-
106
- // ── #2767 draft-preview branch ───────────────────────────────────────────
107
- describe('previewDrafts (#2767 P2/P3)', () => {
108
- it('reads the draft-overlaid list in a SINGLE preview=draft request', async () => {
109
- // Server response overlays a draft edit of `all` (draft wins by name) and
110
- // a brand-new draft-only `mine`; `calendar` is untouched/published.
111
- const overlaid = [
112
- {
113
- name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list',
114
- label: 'All (edited)', config: { type: 'grid', data: { object: 'crm_activity' } },
115
- _draft: true,
116
- },
117
- {
118
- name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
119
- label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
120
- },
121
- {
122
- name: 'crm_activity.mine', object: 'crm_activity', viewKind: 'list',
123
- label: 'Mine', config: { type: 'grid', data: { object: 'crm_activity' } },
124
- _draft: true,
125
- },
126
- ];
127
- const { ds, fetchImpl } = makeDraftDS(overlaid);
128
- const views = await ds.listViews('crm_activity', { previewDrafts: true });
129
-
130
- // Exactly one request, and it carries the preview flag.
131
- const metaCalls = fetchImpl.mock.calls.filter(([u]: any[]) => String(u).includes('/meta/view'));
132
- expect(metaCalls).toHaveLength(1);
133
- expect(String(metaCalls[0][0])).toContain('preview=draft');
134
- // Preview mode must NOT also hit the published path (that's the P2 dupe bug).
135
- expect(ds.client.meta.getItems).not.toHaveBeenCalled();
136
-
137
- // One tab per name — the draft edit of `all` does not duplicate it.
138
- expect(views.map((v: any) => v.name).sort()).toEqual([
139
- 'crm_activity.all', 'crm_activity.calendar', 'crm_activity.mine',
140
- ]);
141
- });
142
-
143
- it('flows the _draft provenance flag through for the switcher badge', async () => {
144
- const overlaid = [
145
- {
146
- name: 'crm_activity.mine', object: 'crm_activity', viewKind: 'list',
147
- label: 'Mine', config: { type: 'grid', data: { object: 'crm_activity' } }, _draft: true,
148
- },
149
- {
150
- name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
151
- label: 'Cal', config: { type: 'calendar', data: { object: 'crm_activity' } },
152
- },
153
- ];
154
- const { ds } = makeDraftDS(overlaid);
155
- const views = await ds.listViews('crm_activity', { previewDrafts: true });
156
- expect(views.find((v: any) => v.name === 'crm_activity.mine')?._draft).toBe(true);
157
- expect(views.find((v: any) => v.name === 'crm_activity.calendar')?._draft).toBeUndefined();
158
- });
159
-
160
- it('non-preview mode uses the published getItems path (no draft fetch)', async () => {
161
- const { ds, fetchImpl } = makeDraftDS([]);
162
- ds.client.meta.getItems = vi.fn(async () => ({
163
- items: [{
164
- name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list',
165
- config: { type: 'grid', data: { object: 'crm_activity' } },
166
- }],
167
- }));
168
- const views = await ds.listViews('crm_activity');
169
- expect(ds.client.meta.getItems).toHaveBeenCalledTimes(1);
170
- expect(fetchImpl.mock.calls.filter(([u]: any[]) => String(u).includes('/meta/view'))).toHaveLength(0);
171
- expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
172
- expect(views[0]._draft).toBeUndefined();
173
- });
174
- });
175
- });
@@ -1,303 +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
- import { describe, it, expect, vi, beforeEach } from 'vitest';
10
- import { MetadataClient } from './metadata-client';
11
-
12
- function mockFetch(handler: (url: string, init?: RequestInit) => Promise<Response>): typeof fetch {
13
- return vi.fn(handler) as unknown as typeof fetch;
14
- }
15
-
16
- function jsonResponse(body: unknown, init?: ResponseInit): Response {
17
- return new Response(JSON.stringify(body), {
18
- status: 200,
19
- headers: { 'content-type': 'application/json' },
20
- ...init,
21
- });
22
- }
23
-
24
- describe('MetadataClient', () => {
25
- let urls: string[];
26
- let inits: (RequestInit | undefined)[];
27
-
28
- beforeEach(() => {
29
- urls = [];
30
- inits = [];
31
- });
32
-
33
- function client(baseUrl: string, environmentId?: string) {
34
- return new MetadataClient({
35
- baseUrl,
36
- environmentId,
37
- fetch: mockFetch(async (url, init) => {
38
- urls.push(url);
39
- inits.push(init);
40
- if (init?.method === 'PUT' || init?.method === 'DELETE') {
41
- return jsonResponse({ ok: true });
42
- }
43
- if (url.endsWith('/meta')) return jsonResponse([{ type: 'object' }]);
44
- if (url.match(/\/meta\/object$/)) return jsonResponse([{ name: 'account' }]);
45
- if (url.match(/\/meta\/object\/account$/)) {
46
- return jsonResponse({ name: 'account', label: 'Account' });
47
- }
48
- if (url.match(/\/meta\/object\/missing$/)) {
49
- return new Response('not found', { status: 404 });
50
- }
51
- if (url.match(/history$/)) return jsonResponse({ events: [] });
52
- return jsonResponse({ items: [{ name: 'fallback' }] });
53
- }),
54
- });
55
- }
56
-
57
- it('builds unscoped URLs against the bare baseUrl', async () => {
58
- await client('http://localhost:3000').list('object');
59
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object');
60
- });
61
-
62
- it('save() rejects an empty name before any request (#2767 empty-name 405)', async () => {
63
- const c = client('http://localhost:3000');
64
- await expect(c.save('view', '', { columns: [] })).rejects.toThrow(/name must be non-empty/);
65
- await expect(c.save('view', ' ', { columns: [] })).rejects.toThrow(/name must be non-empty/);
66
- // No malformed `PUT /meta/view/` ever reached the transport.
67
- expect(urls).toEqual([]);
68
- });
69
-
70
- it('builds scoped URLs when environmentId is set', async () => {
71
- await client('http://localhost:3000', 'env_42').list('object');
72
- expect(urls[0]).toBe('http://localhost:3000/api/v1/environments/env_42/meta/object');
73
- });
74
-
75
- it('collapses /api/v1 suffix on baseUrl to avoid duplication', async () => {
76
- await client('http://localhost:3000/api/v1').list('object');
77
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object');
78
- });
79
-
80
- it('returns null on 404 from get()', async () => {
81
- const result = await client('http://localhost:3000').get('object', 'missing');
82
- expect(result).toBeNull();
83
- });
84
-
85
- it('unwraps `items` envelope on list when the server returns one', async () => {
86
- const c = new MetadataClient({
87
- baseUrl: 'http://localhost:3000',
88
- fetch: mockFetch(async () => jsonResponse({ items: [{ name: 'wrapped' }] })),
89
- });
90
- const items = await c.list<{ name: string }>('field');
91
- expect(items).toEqual([{ name: 'wrapped' }]);
92
- });
93
-
94
- it('sends If-Match and X-Actor headers on save when provided', async () => {
95
- await client('http://localhost:3000').save('object', 'account', { foo: 1 }, {
96
- ifMatch: 'sha256:abc',
97
- actor: 'user_1',
98
- });
99
- expect(inits[0]?.method).toBe('PUT');
100
- const headers = inits[0]?.headers as Record<string, string>;
101
- expect(headers['If-Match']).toBe('sha256:abc');
102
- expect(headers['X-Actor']).toBe('user_1');
103
- expect(headers['Content-Type']).toBe('application/json');
104
- expect(inits[0]?.body).toBe(JSON.stringify({ foo: 1 }));
105
- });
106
-
107
- it('encodes type and name path segments', async () => {
108
- await client('http://localhost:3000').get('object', 'has spaces');
109
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/has%20spaces');
110
- });
111
-
112
- it('builds history URL with query params', async () => {
113
- await client('http://localhost:3000').history('view', 'all', { sinceSeq: 10, limit: 5 });
114
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/view/all/history?sinceSeq=10&limit=5');
115
- });
116
-
117
- it('throws MetadataError with status and body on failure', async () => {
118
- const c = new MetadataClient({
119
- baseUrl: 'http://localhost:3000',
120
- fetch: mockFetch(async () =>
121
- new Response(JSON.stringify({ error: 'metadata_conflict', code: 'metadata_conflict' }), {
122
- status: 409,
123
- headers: { 'content-type': 'application/json' },
124
- })),
125
- });
126
- await expect(c.save('object', 'account', {})).rejects.toMatchObject({
127
- status: 409,
128
- code: 'metadata_conflict',
129
- message: 'metadata_conflict',
130
- });
131
- });
132
-
133
- it('extracts message, code + field-anchored issues from the dispatcher error shape', async () => {
134
- // The dispatcher sends `error` as an OBJECT { message, code, details:{ code, issues } }.
135
- // Reading `.message` (not String(object) → "[object Object]") and surfacing
136
- // `details.issues` is what lets the Studio point at the offending field.
137
- const c = new MetadataClient({
138
- baseUrl: 'http://localhost:3000',
139
- fetch: mockFetch(async () =>
140
- new Response(
141
- JSON.stringify({
142
- success: false,
143
- error: {
144
- message: '[invalid_metadata] object/bad failed spec validation: label: Required',
145
- code: 422,
146
- details: {
147
- code: 'invalid_metadata',
148
- issues: [{ path: 'label', message: 'Required', code: 'invalid_type' }],
149
- },
150
- },
151
- }),
152
- { status: 422, headers: { 'content-type': 'application/json' } },
153
- )),
154
- });
155
- await expect(c.save('object', 'bad', {})).rejects.toMatchObject({
156
- status: 422,
157
- code: 'invalid_metadata',
158
- message: '[invalid_metadata] object/bad failed spec validation: label: Required',
159
- issues: [{ path: 'label', message: 'Required', code: 'invalid_type' }],
160
- });
161
- });
162
-
163
- it('listDrafts requests /meta/_drafts with packageId + type and parses {drafts}', async () => {
164
- const seen: string[] = [];
165
- const c = new MetadataClient({
166
- baseUrl: 'http://localhost:3000',
167
- fetch: mockFetch(async (url) => {
168
- seen.push(url);
169
- return jsonResponse({
170
- drafts: [{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' }],
171
- });
172
- }),
173
- });
174
- const out = await c.listDrafts({ packageId: 'app.edu', type: 'object' });
175
- expect(seen[0]).toBe('http://localhost:3000/api/v1/meta/_drafts?packageId=app.edu&type=object');
176
- expect(out).toEqual([
177
- { type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' },
178
- ]);
179
- });
180
-
181
- it('listDrafts tolerates the {data:{drafts}} envelope and a bare array', async () => {
182
- const enveloped = new MetadataClient({
183
- baseUrl: '',
184
- fetch: mockFetch(async () =>
185
- jsonResponse({ data: { drafts: [{ type: 'view', name: 'v', packageId: null, updatedAt: null, updatedBy: null }] } })),
186
- });
187
- expect((await enveloped.listDrafts()).map((d) => d.name)).toEqual(['v']);
188
-
189
- const bare = new MetadataClient({
190
- baseUrl: '',
191
- fetch: mockFetch(async () =>
192
- jsonResponse([{ type: 'object', name: 'b', packageId: null, updatedAt: null, updatedBy: null }])),
193
- });
194
- expect((await bare.listDrafts()).map((d) => d.name)).toEqual(['b']);
195
- });
196
-
197
- it('publishDraft POSTs /meta/:type/:name/publish (promotes a draft by ref, no package needed)', async () => {
198
- const seen: { url: string; method?: string }[] = [];
199
- const c = new MetadataClient({
200
- baseUrl: 'http://localhost:3000',
201
- fetch: mockFetch(async (url, init) => {
202
- seen.push({ url, method: init?.method });
203
- return jsonResponse({ success: true });
204
- }),
205
- });
206
- await c.publishDraft('dashboard', 'sales_dashboard');
207
- expect(seen[0]?.method).toBe('POST');
208
- expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/dashboard/sales_dashboard/publish');
209
- });
210
-
211
- it('publishDraft throws on a non-ok response', async () => {
212
- const c = new MetadataClient({
213
- baseUrl: '',
214
- fetch: mockFetch(async () => new Response('nope', { status: 500 })),
215
- });
216
- await expect(c.publishDraft('view', 'x')).rejects.toBeTruthy();
217
- });
218
-
219
- it('publishDraft returns the result (incl. seedApplied) and unwraps the {data} envelope', async () => {
220
- // Flat shape (rest-server passes protocol result through).
221
- const flat = new MetadataClient({
222
- baseUrl: '',
223
- fetch: mockFetch(async () =>
224
- jsonResponse({ success: true, seedApplied: { success: false, error: 'no readable seed bodies' } })),
225
- });
226
- const r1 = await flat.publishDraft('seed', 'job_sample');
227
- expect(r1.seedApplied).toEqual({ success: false, error: 'no readable seed bodies' });
228
-
229
- // Dispatcher `{ success, data: {...} }` envelope.
230
- const enveloped = new MetadataClient({
231
- baseUrl: '',
232
- fetch: mockFetch(async () =>
233
- jsonResponse({ success: true, data: { success: true, seedApplied: { success: true, inserted: 6, updated: 0 } } })),
234
- });
235
- const r2 = await enveloped.publishDraft('seed', 'candidate_sample');
236
- expect(r2.seedApplied).toEqual({ success: true, inserted: 6, updated: 0 });
237
- });
238
- });
239
-
240
- describe('MetadataClient — ADR-0037 previewDrafts', () => {
241
- function previewClient(opts: { previewDrafts?: boolean; environmentId?: string } = {}) {
242
- const urls: string[] = [];
243
- const c = new MetadataClient({
244
- baseUrl: 'http://localhost:3000',
245
- ...opts,
246
- fetch: mockFetch(async (url) => {
247
- urls.push(url);
248
- return jsonResponse([{ name: 'account' }]);
249
- }),
250
- });
251
- return { c, urls };
252
- }
253
-
254
- it('list() and get() append ?preview=draft when enabled', async () => {
255
- const { c, urls } = previewClient({ previewDrafts: true });
256
- await c.list('object');
257
- await c.get('object', 'account');
258
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object?preview=draft');
259
- expect(urls[1]).toBe('http://localhost:3000/api/v1/meta/object/account?preview=draft');
260
- });
261
-
262
- it('combines preview with the package filter on list()', async () => {
263
- const { c, urls } = previewClient({ previewDrafts: true });
264
- await c.list('object', { packageId: 'app.crm' });
265
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object?package=app.crm&preview=draft');
266
- });
267
-
268
- it('an explicit state=draft read wins over the overlay flag', async () => {
269
- const { c, urls } = previewClient({ previewDrafts: true });
270
- await c.get('object', 'account', { state: 'draft' });
271
- expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/account?state=draft');
272
- });
273
-
274
- it('default client never sends the flag', async () => {
275
- const { c, urls } = previewClient();
276
- await c.list('object');
277
- await c.get('object', 'account');
278
- expect(urls[0]).not.toContain('preview');
279
- expect(urls[1]).not.toContain('preview');
280
- });
281
-
282
- it('withPreviewDrafts derives a preview client and PRESERVES the environment scope', async () => {
283
- const { c } = previewClient({ environmentId: 'env_1' });
284
- const p = c.withPreviewDrafts(true);
285
- expect(p.previewDrafts).toBe(true);
286
- // Same instance back when the flag already matches.
287
- expect(p.withPreviewDrafts(true)).toBe(p);
288
- // Environment-scoped path survives the derivation (regression guard:
289
- // a naive rebuild dropped /environments/:id from the base).
290
- const urls: string[] = [];
291
- const scoped = new MetadataClient({
292
- baseUrl: 'http://localhost:3000',
293
- environmentId: 'env_1',
294
- previewDrafts: false,
295
- fetch: mockFetch(async (url) => {
296
- urls.push(url);
297
- return jsonResponse([]);
298
- }),
299
- }).withPreviewDrafts(true);
300
- await scoped.list('object');
301
- expect(urls[0]).toBe('http://localhost:3000/api/v1/environments/env_1/meta/object?preview=draft');
302
- });
303
- });