@object-ui/data-objectstack 17.5.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.
- package/CHANGELOG.md +219 -0
- package/dist/index.cjs +262 -357
- package/dist/index.d.cts +385 -448
- package/dist/index.d.ts +385 -448
- package/dist/index.js +258 -350
- package/package.json +5 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/v3-compat.test.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v3.0.0 compatibility tests for @objectstack dependencies.
|
|
3
|
-
* Validates that all integration modules work correctly with the spec.
|
|
4
|
-
*/
|
|
5
|
-
import { describe, it, expect } from 'vitest';
|
|
6
|
-
import { validatePluginContract, generateContractManifest } from './contracts';
|
|
7
|
-
import { IntegrationManager } from './integration';
|
|
8
|
-
import { SecurityManager } from './security';
|
|
9
|
-
import { createDefaultCanvasConfig, snapToGrid, calculateAutoLayout } from './studio';
|
|
10
|
-
|
|
11
|
-
// The `Cloud namespace (replacing Hub)` block that used to open this file is
|
|
12
|
-
// gone with its subject (objectui#4152). Its three cases constructed
|
|
13
|
-
// `CloudOperations` against a client stub of `{}` and asserted that the results
|
|
14
|
-
// had the right KEYS — which is precisely how the surface stayed green for as
|
|
15
|
-
// long as it did: every method optional-chained into a `cloud` namespace no
|
|
16
|
-
// released `@objectstack/client` has ever exported, so what the assertions were
|
|
17
|
-
// reading was the fallback literal, not a deployment. The replacement is a
|
|
18
|
-
// negative pin next door, `cloud-surface-retired-4152.pin.test.ts`, which fails
|
|
19
|
-
// if the exports come back.
|
|
20
|
-
|
|
21
|
-
describe('v3.0.0 Compatibility', () => {
|
|
22
|
-
describe('Contracts module', () => {
|
|
23
|
-
it('should validate a valid plugin contract', () => {
|
|
24
|
-
const contract = {
|
|
25
|
-
name: 'my-plugin',
|
|
26
|
-
version: '1.0.0',
|
|
27
|
-
exports: [{ name: 'MyComponent', type: 'component' as const }],
|
|
28
|
-
permissions: ['data.read'],
|
|
29
|
-
};
|
|
30
|
-
const result = validatePluginContract(contract);
|
|
31
|
-
expect(result.valid).toBe(true);
|
|
32
|
-
expect(result.errors).toHaveLength(0);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should reject invalid plugin contract', () => {
|
|
36
|
-
const contract = {
|
|
37
|
-
name: '',
|
|
38
|
-
version: 'invalid',
|
|
39
|
-
exports: [],
|
|
40
|
-
};
|
|
41
|
-
const result = validatePluginContract(contract);
|
|
42
|
-
expect(result.valid).toBe(false);
|
|
43
|
-
expect(result.errors.length).toBeGreaterThan(0);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('should generate contract manifest', () => {
|
|
47
|
-
const contract = {
|
|
48
|
-
name: 'test-plugin',
|
|
49
|
-
version: '2.0.0',
|
|
50
|
-
exports: [{ name: 'GridView', type: 'component' as const, description: 'A grid view' }],
|
|
51
|
-
permissions: ['data.read', 'data.write'],
|
|
52
|
-
};
|
|
53
|
-
const manifest = generateContractManifest(contract);
|
|
54
|
-
expect(manifest.$schema).toBeDefined();
|
|
55
|
-
expect(manifest.name).toBe('test-plugin');
|
|
56
|
-
expect(manifest.version).toBe('2.0.0');
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('Integration module', () => {
|
|
61
|
-
it('should register and retrieve integrations', () => {
|
|
62
|
-
const manager = new IntegrationManager();
|
|
63
|
-
manager.register('slack-1', {
|
|
64
|
-
provider: 'slack',
|
|
65
|
-
enabled: true,
|
|
66
|
-
config: { webhookUrl: 'https://hooks.slack.com/test' },
|
|
67
|
-
triggers: [{ event: 'record.created' }],
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const all = manager.getAll();
|
|
71
|
-
expect(all.size).toBe(1);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('should filter integrations by event', () => {
|
|
75
|
-
const manager = new IntegrationManager();
|
|
76
|
-
manager.register('webhook-1', {
|
|
77
|
-
provider: 'webhook',
|
|
78
|
-
enabled: true,
|
|
79
|
-
config: { url: 'https://example.com/hook', method: 'POST' },
|
|
80
|
-
triggers: [{ event: 'record.created' }],
|
|
81
|
-
});
|
|
82
|
-
manager.register('webhook-2', {
|
|
83
|
-
provider: 'webhook',
|
|
84
|
-
enabled: true,
|
|
85
|
-
config: { url: 'https://example.com/hook2', method: 'POST' },
|
|
86
|
-
triggers: [{ event: 'record.deleted' }],
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const createMatches = manager.getForEvent('record.created');
|
|
90
|
-
expect(createMatches).toHaveLength(1);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('should unregister integrations', () => {
|
|
94
|
-
const manager = new IntegrationManager();
|
|
95
|
-
manager.register('test', {
|
|
96
|
-
provider: 'webhook',
|
|
97
|
-
enabled: true,
|
|
98
|
-
config: { url: 'https://example.com', method: 'POST' },
|
|
99
|
-
});
|
|
100
|
-
manager.unregister('test');
|
|
101
|
-
expect(manager.getAll().size).toBe(0);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('Security module', () => {
|
|
106
|
-
it('should generate CSP header', () => {
|
|
107
|
-
const manager = new SecurityManager({
|
|
108
|
-
csp: {
|
|
109
|
-
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
110
|
-
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
111
|
-
imgSrc: ["'self'", 'data:', 'https:'],
|
|
112
|
-
connectSrc: ["'self'", 'https://api.example.com'],
|
|
113
|
-
fontSrc: ["'self'"],
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
const header = manager.generateCSPHeader();
|
|
118
|
-
expect(header).toContain("script-src 'self' 'unsafe-inline'");
|
|
119
|
-
expect(header).toContain("connect-src 'self' https://api.example.com");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('should record and filter audit logs', () => {
|
|
123
|
-
const manager = new SecurityManager({
|
|
124
|
-
auditLog: {
|
|
125
|
-
enabled: true,
|
|
126
|
-
events: ['data.create', 'data.read', 'auth.login'],
|
|
127
|
-
destination: 'console',
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
manager.recordAudit({ event: 'data.create', userId: 'user-1', resource: 'accounts' });
|
|
132
|
-
manager.recordAudit({ event: 'auth.login', userId: 'user-2' });
|
|
133
|
-
manager.recordAudit({ event: 'data.create', userId: 'user-1', resource: 'contacts' });
|
|
134
|
-
|
|
135
|
-
const allLogs = manager.getAuditLog();
|
|
136
|
-
expect(allLogs).toHaveLength(3);
|
|
137
|
-
|
|
138
|
-
const userLogs = manager.getAuditLog({ userId: 'user-1' });
|
|
139
|
-
expect(userLogs).toHaveLength(2);
|
|
140
|
-
|
|
141
|
-
const loginLogs = manager.getAuditLog({ event: 'auth.login' });
|
|
142
|
-
expect(loginLogs).toHaveLength(1);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('should mask record data', () => {
|
|
146
|
-
const manager = new SecurityManager({
|
|
147
|
-
dataMasking: {
|
|
148
|
-
rules: [
|
|
149
|
-
{ field: 'ssn', strategy: 'partial', visibleChars: 4 },
|
|
150
|
-
{ field: 'password', strategy: 'redact' },
|
|
151
|
-
{ field: 'email', strategy: 'full' },
|
|
152
|
-
{ field: 'phone', strategy: 'partial', visibleChars: 3, exemptRoles: ['admin'] },
|
|
153
|
-
],
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const record = {
|
|
158
|
-
ssn: '123-45-6789',
|
|
159
|
-
password: 'secret123',
|
|
160
|
-
email: 'john@example.com',
|
|
161
|
-
phone: '555-1234',
|
|
162
|
-
name: 'John',
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const masked = manager.maskRecord(record);
|
|
166
|
-
expect(masked.ssn).toBe('123-*******');
|
|
167
|
-
expect(masked.password).toBe('[REDACTED]');
|
|
168
|
-
expect(masked.email).toBe('****************');
|
|
169
|
-
expect(masked.name).toBe('John'); // Not masked
|
|
170
|
-
|
|
171
|
-
// Admin sees phone unmasked
|
|
172
|
-
const adminMasked = manager.maskRecord(record, ['admin']);
|
|
173
|
-
expect(adminMasked.phone).toBe('555-1234');
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
describe('Studio module', () => {
|
|
178
|
-
it('should create default canvas config', () => {
|
|
179
|
-
const config = createDefaultCanvasConfig();
|
|
180
|
-
expect(config.width).toBe(1200);
|
|
181
|
-
expect(config.height).toBe(800);
|
|
182
|
-
expect(config.snapToGrid).toBe(true);
|
|
183
|
-
expect(config.zoom.min).toBe(0.25);
|
|
184
|
-
expect(config.zoom.max).toBe(3);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('should create canvas config with overrides', () => {
|
|
188
|
-
const config = createDefaultCanvasConfig({ width: 1600, showMinimap: true });
|
|
189
|
-
expect(config.width).toBe(1600);
|
|
190
|
-
expect(config.showMinimap).toBe(true);
|
|
191
|
-
expect(config.height).toBe(800); // Default
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('should snap positions to grid', () => {
|
|
195
|
-
expect(snapToGrid(13, 27, 8)).toEqual({ x: 16, y: 24 });
|
|
196
|
-
expect(snapToGrid(0, 0, 8)).toEqual({ x: 0, y: 0 });
|
|
197
|
-
expect(snapToGrid(4, 4, 8)).toEqual({ x: 8, y: 8 });
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('should calculate auto-layout positions', () => {
|
|
201
|
-
const items = [
|
|
202
|
-
{ id: '1', width: 200, height: 150 },
|
|
203
|
-
{ id: '2', width: 200, height: 100 },
|
|
204
|
-
{ id: '3', width: 200, height: 200 },
|
|
205
|
-
];
|
|
206
|
-
const positions = calculateAutoLayout(items, 1200);
|
|
207
|
-
expect(positions).toHaveLength(3);
|
|
208
|
-
expect(positions[0].x).toBe(40); // padding
|
|
209
|
-
expect(positions[0].y).toBe(40); // padding
|
|
210
|
-
expect(positions[1].x).toBeGreaterThan(positions[0].x);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe('PaginatedResult API (records/total/hasMore)', () => {
|
|
215
|
-
it('should support v3.0.0 PaginatedResult fields', () => {
|
|
216
|
-
// Verify the QueryResult type supports records/total/hasMore
|
|
217
|
-
const result = {
|
|
218
|
-
data: [{ id: '1' }],
|
|
219
|
-
total: 10,
|
|
220
|
-
page: 1,
|
|
221
|
-
pageSize: 5,
|
|
222
|
-
hasMore: true,
|
|
223
|
-
};
|
|
224
|
-
expect(result.data).toHaveLength(1);
|
|
225
|
-
expect(result.total).toBe(10);
|
|
226
|
-
expect(result.hasMore).toBe(true);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
});
|
|
@@ -1,94 +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 } from 'vitest';
|
|
10
|
-
|
|
11
|
-
import { normaliseClientError } from './index';
|
|
12
|
-
import { DataApiValidationError } from './errors';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The server has always sent per-field rejection detail; the adapter used to
|
|
16
|
-
* hand callers the raw client error, so `fields[]` had no typed home and every
|
|
17
|
-
* form fell back to one undirected toast.
|
|
18
|
-
*/
|
|
19
|
-
describe('normaliseClientError — VALIDATION_FAILED', () => {
|
|
20
|
-
// `@objectstack/rest` serves a thrown validation failure as 400 with `fields[]`
|
|
21
|
-
// verbatim; `@objectstack/client` sets `error.details` to the parsed body's
|
|
22
|
-
// `details` OR — as here, since the envelope has no such key — the whole body.
|
|
23
|
-
const upstream = () =>
|
|
24
|
-
Object.assign(new Error('Name is required; Stage is not a valid option'), {
|
|
25
|
-
code: 'VALIDATION_FAILED',
|
|
26
|
-
httpStatus: 400,
|
|
27
|
-
details: {
|
|
28
|
-
error: 'Name is required; Stage is not a valid option',
|
|
29
|
-
code: 'VALIDATION_FAILED',
|
|
30
|
-
fields: [
|
|
31
|
-
{ field: 'name', code: 'required', message: 'Name is required' },
|
|
32
|
-
{ field: 'stage', code: 'invalid_option', message: 'Stage is not a valid option' },
|
|
33
|
-
],
|
|
34
|
-
object: 'crm_opportunity',
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('wraps it into a typed DataApiValidationError', () => {
|
|
39
|
-
const normalised = normaliseClientError(upstream());
|
|
40
|
-
expect(normalised).toBeInstanceOf(DataApiValidationError);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('carries every field entry through', () => {
|
|
44
|
-
const normalised = normaliseClientError(upstream()) as DataApiValidationError;
|
|
45
|
-
expect(normalised.validationErrors).toEqual([
|
|
46
|
-
{ field: 'name', message: 'Name is required' },
|
|
47
|
-
{ field: 'stage', message: 'Stage is not a valid option' },
|
|
48
|
-
]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('keeps the server message verbatim — it names every field', () => {
|
|
52
|
-
const normalised = normaliseClientError(upstream()) as DataApiValidationError;
|
|
53
|
-
expect(normalised.message).toBe('Name is required; Stage is not a valid option');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('exposes the first offending field on `.field`', () => {
|
|
57
|
-
const normalised = normaliseClientError(upstream()) as DataApiValidationError;
|
|
58
|
-
expect(normalised.field).toBe('name');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('falls back to the machine code when an entry has no message', () => {
|
|
62
|
-
const err = Object.assign(new Error('Validation failed'), {
|
|
63
|
-
code: 'VALIDATION_FAILED',
|
|
64
|
-
details: { fields: [{ field: 'name', code: 'required' }] },
|
|
65
|
-
});
|
|
66
|
-
const normalised = normaliseClientError(err) as DataApiValidationError;
|
|
67
|
-
expect(normalised.validationErrors).toEqual([{ field: 'name', message: 'required' }]);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('drops entries with no field rather than inventing one', () => {
|
|
71
|
-
const err = Object.assign(new Error('Validation failed'), {
|
|
72
|
-
code: 'VALIDATION_FAILED',
|
|
73
|
-
details: { fields: [{ message: 'something is wrong' }] },
|
|
74
|
-
});
|
|
75
|
-
const normalised = normaliseClientError(err) as DataApiValidationError;
|
|
76
|
-
expect(normalised.validationErrors).toEqual([]);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('still wraps when the error carries `fields` directly (hand-rolled shape)', () => {
|
|
80
|
-
// The server duck-types on `code`/`name`, so a hook throwing the bare shape
|
|
81
|
-
// is served identically — the client must not be pickier than the server.
|
|
82
|
-
const err = Object.assign(new Error('nope'), {
|
|
83
|
-
name: 'ValidationError',
|
|
84
|
-
fields: [{ field: 'amount', message: 'Amount must be positive' }],
|
|
85
|
-
});
|
|
86
|
-
const normalised = normaliseClientError(err) as DataApiValidationError;
|
|
87
|
-
expect(normalised.validationErrors).toEqual([{ field: 'amount', message: 'Amount must be positive' }]);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('leaves an unrelated 400 untouched', () => {
|
|
91
|
-
const err = Object.assign(new Error('bad request'), { code: 'BAD_REQUEST', httpStatus: 400 });
|
|
92
|
-
expect(normaliseClientError(err)).toBe(err);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
@@ -1,260 +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
|
-
* View metadata cache keys — invalidation matches the read keys (objectui#3778).
|
|
14
|
-
*
|
|
15
|
-
* `ObjectStackAdapter` caches exactly two view-shaped reads:
|
|
16
|
-
*
|
|
17
|
-
* | reader | cache key |
|
|
18
|
-
* |---------------------|------------------------------|
|
|
19
|
-
* | `getView` | `view:{object}:{viewId}` |
|
|
20
|
-
* | `listViewOverrides` | `view-overrides:{object}` |
|
|
21
|
-
*
|
|
22
|
-
* `listViews` is **not** one of them: it fetches `meta.getItems('view')`
|
|
23
|
-
* directly on every call, with no `metadataCache.get` wrapper. Five write
|
|
24
|
-
* paths nevertheless used to invalidate a `views:{object}` key that no read
|
|
25
|
-
* path has ever populated — five permanent no-ops. They are gone; these pins
|
|
26
|
-
* keep both halves of that honest:
|
|
27
|
-
*
|
|
28
|
-
* 1. the surviving invalidations still name the keys that DO have readers, so
|
|
29
|
-
* the deletion cannot be mistaken for "cache invalidation was dropped"; and
|
|
30
|
-
* 2. `listViews` keeps its uncached behavior — the deletion changed no
|
|
31
|
-
* request count, which is what makes it a pure dead-code removal rather
|
|
32
|
-
* than a caching change. (Whether `listViews` SHOULD be cached is a
|
|
33
|
-
* separate product question, deliberately not settled here.)
|
|
34
|
-
*
|
|
35
|
-
* ## objectui#4363 — every write path names BOTH keys
|
|
36
|
-
*
|
|
37
|
-
* Removing the dead key made the surviving asymmetry visible: only
|
|
38
|
-
* `updateViewConfig` invalidated `view-overrides:{object}`, so `createView` /
|
|
39
|
-
* `updateView` / `deleteView` left the batch map stale for the cache's
|
|
40
|
-
* 5-minute TTL. It does not self-heal — `loadViewOverrides` (app-shell
|
|
41
|
-
* `ObjectView`) treats a RESOLVED map as authoritative and deliberately does
|
|
42
|
-
* not re-probe per view (#3774), so the per-view `getView` fallback that would
|
|
43
|
-
* have masked a stale map is by design unreachable.
|
|
44
|
-
*
|
|
45
|
-
* So the rule these pins now enforce is uniform and per-METHOD, not
|
|
46
|
-
* per-branch: **a write to a view row invalidates the per-view key and the
|
|
47
|
-
* object's override map.** Four paths (five call sites — `updateView` has a
|
|
48
|
-
* draft half and a published half) emit the same ordered pair. The two sweep
|
|
49
|
-
* pins are untouched controls: `listViews` stays uncached, and no path names a
|
|
50
|
-
* `views:` key.
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
interface Harness {
|
|
54
|
-
ds: any;
|
|
55
|
-
/** Every key passed to `metadataCache.invalidate`, in order. */
|
|
56
|
-
invalidated: string[];
|
|
57
|
-
/** Every key passed to `metadataCache.get`, in order. */
|
|
58
|
-
cacheReads: string[];
|
|
59
|
-
getItems: ReturnType<typeof vi.fn>;
|
|
60
|
-
saveItem: ReturnType<typeof vi.fn>;
|
|
61
|
-
deleteItem: ReturnType<typeof vi.fn>;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Adapter with a recording metadata cache.
|
|
66
|
-
*
|
|
67
|
-
* @param opts.items what `client.meta.getItems('view')` returns
|
|
68
|
-
* @param opts.published what `client.meta.getItem` answers (body, or an
|
|
69
|
-
* Error to throw — a 404 means "no published overlay")
|
|
70
|
-
* @param opts.draft body served at `GET /meta/view/:name?state=draft`
|
|
71
|
-
* (`null` → 404, i.e. nothing pending)
|
|
72
|
-
*/
|
|
73
|
-
function makeDS(opts: {
|
|
74
|
-
items?: any[];
|
|
75
|
-
published?: any | Error;
|
|
76
|
-
draft?: any | null;
|
|
77
|
-
} = {}): Harness {
|
|
78
|
-
const invalidated: string[] = [];
|
|
79
|
-
const cacheReads: string[] = [];
|
|
80
|
-
|
|
81
|
-
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
82
|
-
const url = String(input);
|
|
83
|
-
const json = (body: unknown, status = 200) =>
|
|
84
|
-
new Response(JSON.stringify(body), {
|
|
85
|
-
status,
|
|
86
|
-
headers: { 'Content-Type': 'application/json' },
|
|
87
|
-
});
|
|
88
|
-
if (url.includes('/meta/view/')) {
|
|
89
|
-
if ((init?.method ?? 'GET') === 'PUT') return json({ success: true, version: 2 });
|
|
90
|
-
// #4479 — `deleteView` addresses BOTH homes over this wire, so the
|
|
91
|
-
// harness has to answer DELETE. The framework never 404s a missing
|
|
92
|
-
// home: it reports one with a 200 carrying `reset:false`. Modelled
|
|
93
|
-
// faithfully (a home "has a row" exactly when this harness was told to
|
|
94
|
-
// serve one) so the pins below stay pins on INVALIDATION rather than
|
|
95
|
-
// becoming accidental assertions about the transport.
|
|
96
|
-
if ((init?.method ?? 'GET') === 'DELETE') {
|
|
97
|
-
const hasRow = url.includes('state=draft')
|
|
98
|
-
? opts.draft != null
|
|
99
|
-
: opts.published != null && !(opts.published instanceof Error);
|
|
100
|
-
return json({ success: true, reset: hasRow });
|
|
101
|
-
}
|
|
102
|
-
if (url.includes('state=draft')) {
|
|
103
|
-
if (opts.draft == null) return json({ error: 'not found' }, 404);
|
|
104
|
-
return json({ type: 'view', name: opts.draft.name, item: opts.draft });
|
|
105
|
-
}
|
|
106
|
-
return json({ error: 'not found' }, 404);
|
|
107
|
-
}
|
|
108
|
-
return json({ success: true, data: { capabilities: {}, routes: {} } });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
112
|
-
ds.connected = true;
|
|
113
|
-
ds.connectionState = 'connected';
|
|
114
|
-
|
|
115
|
-
// Recording stand-in for the real MetadataCache: `get` records the key and
|
|
116
|
-
// always misses (runs the loader), `invalidate` records the key.
|
|
117
|
-
ds.metadataCache = {
|
|
118
|
-
get: async (key: string, loader: () => Promise<any>) => {
|
|
119
|
-
cacheReads.push(key);
|
|
120
|
-
return loader();
|
|
121
|
-
},
|
|
122
|
-
invalidate: (key: string) => {
|
|
123
|
-
invalidated.push(key);
|
|
124
|
-
},
|
|
125
|
-
getCachedSync: () => undefined,
|
|
126
|
-
getStats: () => ({}),
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const getItems = vi.fn(async () => ({ items: opts.items ?? [] }));
|
|
130
|
-
const saveItem = vi.fn(async () => ({ success: true }));
|
|
131
|
-
const deleteItem = vi.fn(async () => ({ deleted: true }));
|
|
132
|
-
ds.client = {
|
|
133
|
-
meta: {
|
|
134
|
-
getItems,
|
|
135
|
-
saveItem,
|
|
136
|
-
deleteItem,
|
|
137
|
-
getItem: vi.fn(async () => {
|
|
138
|
-
if (opts.published instanceof Error) throw opts.published;
|
|
139
|
-
return { item: opts.published ?? { name: 'v1', object: 'account' } };
|
|
140
|
-
}),
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
return { ds, invalidated, cacheReads, getItems, saveItem, deleteItem };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** A published read that 404s, decorated the way the SDK client decorates. */
|
|
148
|
-
function notFound(): Error {
|
|
149
|
-
return Object.assign(new Error('Metadata item not found'), { httpStatus: 404 });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const VIEW = {
|
|
153
|
-
name: 'account.all',
|
|
154
|
-
object: 'account',
|
|
155
|
-
viewKind: 'list',
|
|
156
|
-
label: 'All Accounts',
|
|
157
|
-
config: { type: 'grid', data: { object: 'account' } },
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
describe('view metadata cache — invalidation names only keys with readers (#3778)', () => {
|
|
161
|
-
it('listViews reads the transport on every call and consults no cache key', async () => {
|
|
162
|
-
const { ds, getItems, cacheReads } = makeDS({ items: [VIEW] });
|
|
163
|
-
|
|
164
|
-
await ds.listViews('account');
|
|
165
|
-
await ds.listViews('account');
|
|
166
|
-
|
|
167
|
-
// Uncached: two calls, two round trips. This is the behavior the removed
|
|
168
|
-
// `views:{object}` invalidations pretended to manage.
|
|
169
|
-
expect(getItems).toHaveBeenCalledTimes(2);
|
|
170
|
-
expect(getItems).toHaveBeenCalledWith('view');
|
|
171
|
-
expect(cacheReads).toEqual([]);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('updateViewConfig invalidates exactly the two keys that have readers', async () => {
|
|
175
|
-
const { ds, invalidated } = makeDS();
|
|
176
|
-
|
|
177
|
-
await ds.updateViewConfig('account', 'v1', { label: 'Renamed' });
|
|
178
|
-
|
|
179
|
-
// `view:{object}:{viewId}` → getView; `view-overrides:{object}` →
|
|
180
|
-
// listViewOverrides. Nothing else is read back under a view-shaped key.
|
|
181
|
-
expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('listViewOverrides reads back under the key the write paths invalidate', async () => {
|
|
185
|
-
// The pairing, not the string: every assertion below names
|
|
186
|
-
// `view-overrides:account` because THIS is the key the batch reader caches
|
|
187
|
-
// under. An invalidation that named anything else would be the `views:`
|
|
188
|
-
// mistake again, one rename later.
|
|
189
|
-
const { ds, cacheReads } = makeDS({ items: [VIEW] });
|
|
190
|
-
|
|
191
|
-
await ds.listViewOverrides('account');
|
|
192
|
-
|
|
193
|
-
expect(cacheReads).toEqual(['view-overrides:account']);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('createView invalidates the per-view key and the override map (#4363)', async () => {
|
|
197
|
-
const { ds, invalidated } = makeDS();
|
|
198
|
-
|
|
199
|
-
await ds.createView('account', { name: 'account.mine', object: 'account' });
|
|
200
|
-
|
|
201
|
-
// A created view is a new row in the batch map; nothing else notices.
|
|
202
|
-
// `saveItem` is an upsert, so the per-view key is named too — an explicit
|
|
203
|
-
// `name` that already exists overwrites a row `getView` may hold cached.
|
|
204
|
-
expect(invalidated).toEqual(['view:account:account.mine', 'view-overrides:account']);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('updateView (published overlay) invalidates both keys (#4363)', async () => {
|
|
208
|
-
const { ds, invalidated } = makeDS({ published: { name: 'v1', object: 'account' } });
|
|
209
|
-
|
|
210
|
-
await ds.updateView('account', 'v1', { label: 'Renamed' });
|
|
211
|
-
|
|
212
|
-
expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('updateView (pending draft) invalidates both keys (#4363)', async () => {
|
|
216
|
-
const { ds, invalidated } = makeDS({ draft: VIEW, published: notFound() });
|
|
217
|
-
|
|
218
|
-
await ds.updateView('account', VIEW.name, { label: 'Renamed' });
|
|
219
|
-
|
|
220
|
-
// Deliberate over-invalidation on this half: both readers enumerate
|
|
221
|
-
// PUBLISHED rows, so a draft write stales neither — as was already true of
|
|
222
|
-
// the per-view line this pairs with. Pinned so the uniform per-method rule
|
|
223
|
-
// is a decision on the record, not an oversight the next reader "fixes".
|
|
224
|
-
expect(invalidated).toEqual([
|
|
225
|
-
`view:account:${VIEW.name}`,
|
|
226
|
-
'view-overrides:account',
|
|
227
|
-
]);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('deleteView invalidates both keys (#4363)', async () => {
|
|
231
|
-
const { ds, invalidated } = makeDS();
|
|
232
|
-
|
|
233
|
-
await ds.deleteView('account', 'v1');
|
|
234
|
-
|
|
235
|
-
// The deleted row leaves the override map too — a ghost entry there is what
|
|
236
|
-
// the object page would keep applying for the rest of the TTL.
|
|
237
|
-
expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('no write path invalidates a `views:` key — nothing populates one', async () => {
|
|
241
|
-
// createView is the path whose ONLY invalidation was the dead key. #3778
|
|
242
|
-
// asserted "no `views:` key" here rather than "no invalidation at all",
|
|
243
|
-
// deliberately leaving the override-map question to its own card — and
|
|
244
|
-
// #4363 answered it: createView now names both live keys (pinned above).
|
|
245
|
-
// The ASSERTION is unchanged, which is the point of the slot: the dead key
|
|
246
|
-
// stays dead however the live key set grows.
|
|
247
|
-
const created = makeDS();
|
|
248
|
-
await created.ds.createView('account', { name: 'account.mine', object: 'account' });
|
|
249
|
-
|
|
250
|
-
const config = makeDS();
|
|
251
|
-
await config.ds.updateViewConfig('account', 'v1', { label: 'Renamed' });
|
|
252
|
-
|
|
253
|
-
const removed = makeDS();
|
|
254
|
-
await removed.ds.deleteView('account', 'v1');
|
|
255
|
-
|
|
256
|
-
for (const { invalidated } of [created, config, removed]) {
|
|
257
|
-
expect(invalidated.filter((k) => k.startsWith('views:'))).toEqual([]);
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
});
|