@object-ui/data-objectstack 17.5.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/searchAll.test.ts
DELETED
|
@@ -1,120 +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, beforeEach, vi } from 'vitest';
|
|
10
|
-
import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index';
|
|
11
|
-
|
|
12
|
-
/** A fetch mock that answers discovery + records the global-search GET. */
|
|
13
|
-
function makeFetch(searchResponse: { ok: boolean; status?: number; body: unknown }) {
|
|
14
|
-
const calls: Array<{ url: string; init?: any }> = [];
|
|
15
|
-
const fetchImpl = vi.fn(async (url: any, init?: any) => {
|
|
16
|
-
const u = String(url);
|
|
17
|
-
calls.push({ url: u, init });
|
|
18
|
-
if (u.includes('/api/v1/discovery')) {
|
|
19
|
-
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
|
|
20
|
-
}
|
|
21
|
-
if (u.includes('/api/v1/search')) {
|
|
22
|
-
return {
|
|
23
|
-
ok: searchResponse.ok,
|
|
24
|
-
status: searchResponse.status ?? (searchResponse.ok ? 200 : 400),
|
|
25
|
-
statusText: searchResponse.ok ? 'OK' : 'Error',
|
|
26
|
-
json: async () => searchResponse.body,
|
|
27
|
-
} as any;
|
|
28
|
-
}
|
|
29
|
-
return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
|
|
30
|
-
});
|
|
31
|
-
return { fetchImpl, calls };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const WAYNE_HITS = {
|
|
35
|
-
query: 'Wayne',
|
|
36
|
-
hits: [
|
|
37
|
-
{ object: 'crm_account', id: 'a1', title: 'Wayne Enterprises', snippet: 'Wayne Enterprises', record: { id: 'a1', account_number: 'ACC-000005' } },
|
|
38
|
-
{ object: 'crm_opportunity', id: 'o1', title: 'Wayne Q1 Expansion', record: { id: 'o1' } },
|
|
39
|
-
],
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
describe('ObjectStackAdapter.searchAll', () => {
|
|
43
|
-
beforeEach(() => clearSharedDiscoveryCache());
|
|
44
|
-
|
|
45
|
-
it('GETs /api/v1/search?q= and returns normalized ranked hits', async () => {
|
|
46
|
-
const { fetchImpl, calls } = makeFetch({ ok: true, body: WAYNE_HITS });
|
|
47
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', token: 'tok_123', autoReconnect: false, fetch: fetchImpl as any });
|
|
48
|
-
|
|
49
|
-
const result = await adapter.searchAll('Wayne');
|
|
50
|
-
|
|
51
|
-
expect(result.query).toBe('Wayne');
|
|
52
|
-
expect(result.hits.map((h) => `${h.object}:${h.id}`)).toEqual(['crm_account:a1', 'crm_opportunity:o1']);
|
|
53
|
-
expect(result.hits[0].title).toBe('Wayne Enterprises');
|
|
54
|
-
expect(result.hits[0].record).toEqual({ id: 'a1', account_number: 'ACC-000005' });
|
|
55
|
-
|
|
56
|
-
const get = calls.find((c) => c.url.includes('/api/v1/search'))!;
|
|
57
|
-
const url = new URL(get.url);
|
|
58
|
-
expect(url.pathname).toBe('/api/v1/search');
|
|
59
|
-
expect(url.searchParams.get('q')).toBe('Wayne');
|
|
60
|
-
expect(get.init.method).toBe('GET');
|
|
61
|
-
expect(get.init.headers.Authorization).toBe('Bearer tok_123');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('trims the query and forwards limit + objects scoping', async () => {
|
|
65
|
-
const { fetchImpl, calls } = makeFetch({ ok: true, body: { query: 'Wayne', hits: [] } });
|
|
66
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
67
|
-
|
|
68
|
-
await adapter.searchAll(' Wayne ', { limit: 12, objects: ['crm_account', 'crm_opportunity'] });
|
|
69
|
-
|
|
70
|
-
const url = new URL(calls.find((c) => c.url.includes('/api/v1/search'))!.url);
|
|
71
|
-
expect(url.searchParams.get('q')).toBe('Wayne');
|
|
72
|
-
expect(url.searchParams.get('limit')).toBe('12');
|
|
73
|
-
expect(url.searchParams.get('objects')).toBe('crm_account,crm_opportunity');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('short-circuits an empty query without hitting the network', async () => {
|
|
77
|
-
const { fetchImpl, calls } = makeFetch({ ok: true, body: { query: '', hits: [] } });
|
|
78
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
79
|
-
|
|
80
|
-
const result = await adapter.searchAll(' ');
|
|
81
|
-
|
|
82
|
-
expect(result).toEqual({ query: '', hits: [] });
|
|
83
|
-
expect(calls.some((c) => c.url.includes('/api/v1/search'))).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('unwraps a { success, data } envelope', async () => {
|
|
87
|
-
const { fetchImpl } = makeFetch({ ok: true, body: { success: true, data: WAYNE_HITS } });
|
|
88
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
89
|
-
const result = await adapter.searchAll('Wayne');
|
|
90
|
-
expect(result.hits).toHaveLength(2);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('treats a 404 (search plugin absent) as an empty result, not an error', async () => {
|
|
94
|
-
const { fetchImpl } = makeFetch({ ok: false, status: 404, body: { message: 'not found' } });
|
|
95
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
96
|
-
await expect(adapter.searchAll('Wayne')).resolves.toEqual({ query: 'Wayne', hits: [] });
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('throws with the server message on a non-404 error', async () => {
|
|
100
|
-
const { fetchImpl } = makeFetch({ ok: false, status: 500, body: { error: { message: 'index unavailable' } } });
|
|
101
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
102
|
-
await expect(adapter.searchAll('Wayne')).rejects.toThrow(/index unavailable/);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('drops malformed hits (missing object or id)', async () => {
|
|
106
|
-
const { fetchImpl } = makeFetch({
|
|
107
|
-
ok: true,
|
|
108
|
-
body: { query: 'x', hits: [
|
|
109
|
-
{ object: 'crm_account', id: 'a1', title: 'Keep' },
|
|
110
|
-
{ id: 'no_object' },
|
|
111
|
-
{ object: 'crm_lead' },
|
|
112
|
-
null,
|
|
113
|
-
{ object: 'crm_contact', record: { id: 'c9' } },
|
|
114
|
-
] },
|
|
115
|
-
});
|
|
116
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
117
|
-
const result = await adapter.searchAll('x');
|
|
118
|
-
expect(result.hits.map((h) => `${h.object}:${h.id}`)).toEqual(['crm_account:a1', 'crm_contact:c9']);
|
|
119
|
-
});
|
|
120
|
-
});
|
package/src/security.ts
DELETED
|
@@ -1,243 +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
|
-
* Security module integration for @objectstack/spec v3.0.0
|
|
11
|
-
* Provides advanced security policies: CSP config, audit logging, data masking.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Configuration for {@link SecurityManager} — this adapter's browser-side
|
|
16
|
-
* security posture: the CSP header it generates, the in-memory audit log it
|
|
17
|
-
* keeps, and the field-masking rules it applies before a record is rendered.
|
|
18
|
-
*
|
|
19
|
-
* NOT the spec's `SecurityPolicy` (`@objectstack/spec/kernel`), whose name this
|
|
20
|
-
* interface wore until objectui#3160 (objectstack#4115 ledger batch 6). That one
|
|
21
|
-
* is the PACKAGE SUPPLY-CHAIN policy — `{ id, name, autoScan, thresholds,
|
|
22
|
-
* allowedLicenses, prohibitedLicenses, codeSigning, sandbox }` — the rules a
|
|
23
|
-
* marketplace applies when scanning a plugin before install. It shares no key
|
|
24
|
-
* with this one, so there was nothing to derive; the collision is the word
|
|
25
|
-
* "security", not the concept.
|
|
26
|
-
*/
|
|
27
|
-
export interface SecurityManagerPolicy {
|
|
28
|
-
/** Content Security Policy configuration */
|
|
29
|
-
csp?: CSPConfig;
|
|
30
|
-
/** Audit logging configuration */
|
|
31
|
-
auditLog?: AuditLogConfig;
|
|
32
|
-
/** Data masking rules */
|
|
33
|
-
dataMasking?: DataMaskingConfig;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface CSPConfig {
|
|
37
|
-
/** Script sources */
|
|
38
|
-
scriptSrc: string[];
|
|
39
|
-
/** Style sources */
|
|
40
|
-
styleSrc: string[];
|
|
41
|
-
/** Image sources */
|
|
42
|
-
imgSrc: string[];
|
|
43
|
-
/** Connect sources (APIs, WebSockets) */
|
|
44
|
-
connectSrc: string[];
|
|
45
|
-
/** Font sources */
|
|
46
|
-
fontSrc: string[];
|
|
47
|
-
/** Frame sources */
|
|
48
|
-
frameSrc?: string[];
|
|
49
|
-
/** Report URI for violations */
|
|
50
|
-
reportUri?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface AuditLogConfig {
|
|
54
|
-
/** Whether audit logging is enabled */
|
|
55
|
-
enabled: boolean;
|
|
56
|
-
/** Events to audit */
|
|
57
|
-
events: AuditEventType[];
|
|
58
|
-
/** Log retention days */
|
|
59
|
-
retentionDays?: number;
|
|
60
|
-
/** External log destination */
|
|
61
|
-
destination?: 'console' | 'server' | 'both';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type AuditEventType =
|
|
65
|
-
| 'auth.login'
|
|
66
|
-
| 'auth.logout'
|
|
67
|
-
| 'auth.failed'
|
|
68
|
-
| 'data.create'
|
|
69
|
-
| 'data.read'
|
|
70
|
-
| 'data.update'
|
|
71
|
-
| 'data.delete'
|
|
72
|
-
| 'admin.config'
|
|
73
|
-
| 'admin.user'
|
|
74
|
-
| 'admin.role';
|
|
75
|
-
|
|
76
|
-
export interface DataMaskingConfig {
|
|
77
|
-
/** Fields to mask */
|
|
78
|
-
rules: DataMaskingRule[];
|
|
79
|
-
/** Default masking character */
|
|
80
|
-
maskChar?: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface DataMaskingRule {
|
|
84
|
-
/** Field name or pattern */
|
|
85
|
-
field: string;
|
|
86
|
-
/** Masking strategy */
|
|
87
|
-
strategy: 'full' | 'partial' | 'hash' | 'redact';
|
|
88
|
-
/** Number of visible characters (for partial masking) */
|
|
89
|
-
visibleChars?: number;
|
|
90
|
-
/** Roles that can see unmasked data */
|
|
91
|
-
exemptRoles?: string[];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface AuditLogEntry {
|
|
95
|
-
/** Event timestamp */
|
|
96
|
-
timestamp: string;
|
|
97
|
-
/** Event type */
|
|
98
|
-
event: AuditEventType;
|
|
99
|
-
/** User who triggered the event */
|
|
100
|
-
userId: string;
|
|
101
|
-
/** Target resource */
|
|
102
|
-
resource?: string;
|
|
103
|
-
/** Record ID */
|
|
104
|
-
recordId?: string;
|
|
105
|
-
/** Additional details */
|
|
106
|
-
details?: Record<string, unknown>;
|
|
107
|
-
/** IP address */
|
|
108
|
-
ipAddress?: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Security policy manager.
|
|
113
|
-
* Handles CSP generation, audit logging, and data masking.
|
|
114
|
-
*/
|
|
115
|
-
export class SecurityManager {
|
|
116
|
-
private policy: SecurityManagerPolicy;
|
|
117
|
-
private auditLog: AuditLogEntry[] = [];
|
|
118
|
-
|
|
119
|
-
constructor(policy: SecurityManagerPolicy = {}) {
|
|
120
|
-
this.policy = policy;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Generate a CSP header string from the configuration.
|
|
125
|
-
*/
|
|
126
|
-
generateCSPHeader(): string {
|
|
127
|
-
const csp = this.policy.csp;
|
|
128
|
-
if (!csp) return '';
|
|
129
|
-
|
|
130
|
-
const directives: string[] = [];
|
|
131
|
-
|
|
132
|
-
if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(' ')}`);
|
|
133
|
-
if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(' ')}`);
|
|
134
|
-
if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(' ')}`);
|
|
135
|
-
if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(' ')}`);
|
|
136
|
-
if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(' ')}`);
|
|
137
|
-
if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(' ')}`);
|
|
138
|
-
if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
|
|
139
|
-
|
|
140
|
-
return directives.join('; ');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Record an audit log entry.
|
|
145
|
-
*/
|
|
146
|
-
recordAudit(entry: Omit<AuditLogEntry, 'timestamp'>): void {
|
|
147
|
-
if (!this.policy.auditLog?.enabled) return;
|
|
148
|
-
if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
|
|
149
|
-
|
|
150
|
-
const fullEntry: AuditLogEntry = {
|
|
151
|
-
...entry,
|
|
152
|
-
timestamp: new Date().toISOString(),
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
this.auditLog.push(fullEntry);
|
|
156
|
-
|
|
157
|
-
const dest = this.policy.auditLog.destination ?? 'console';
|
|
158
|
-
if (dest === 'console' || dest === 'both') {
|
|
159
|
-
console.info('[AUDIT]', fullEntry.event, fullEntry.userId, fullEntry.resource ?? '');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Get audit log entries.
|
|
165
|
-
*/
|
|
166
|
-
getAuditLog(filter?: { event?: AuditEventType; userId?: string; since?: string }): AuditLogEntry[] {
|
|
167
|
-
let entries = [...this.auditLog];
|
|
168
|
-
if (filter?.event) entries = entries.filter(e => e.event === filter.event);
|
|
169
|
-
if (filter?.userId) entries = entries.filter(e => e.userId === filter.userId);
|
|
170
|
-
if (filter?.since) entries = entries.filter(e => e.timestamp >= filter.since!);
|
|
171
|
-
return entries;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Apply data masking to a record.
|
|
176
|
-
*/
|
|
177
|
-
maskRecord(record: Record<string, unknown>, userRoles: string[] = []): Record<string, unknown> {
|
|
178
|
-
if (!this.policy.dataMasking?.rules?.length) return record;
|
|
179
|
-
|
|
180
|
-
const masked = { ...record };
|
|
181
|
-
const maskChar = this.policy.dataMasking.maskChar ?? '*';
|
|
182
|
-
|
|
183
|
-
for (const rule of this.policy.dataMasking.rules) {
|
|
184
|
-
if (!(rule.field in masked) || masked[rule.field] == null) continue;
|
|
185
|
-
|
|
186
|
-
// Check role exemptions
|
|
187
|
-
if (rule.exemptRoles?.some(role => userRoles.includes(role))) continue;
|
|
188
|
-
|
|
189
|
-
const value = String(masked[rule.field]);
|
|
190
|
-
|
|
191
|
-
switch (rule.strategy) {
|
|
192
|
-
case 'full':
|
|
193
|
-
masked[rule.field] = maskChar.repeat(value.length);
|
|
194
|
-
break;
|
|
195
|
-
case 'partial': {
|
|
196
|
-
const visible = rule.visibleChars ?? 4;
|
|
197
|
-
// Values shorter than visibleChars are fully masked for security
|
|
198
|
-
if (value.length <= visible) {
|
|
199
|
-
masked[rule.field] = maskChar.repeat(value.length);
|
|
200
|
-
} else {
|
|
201
|
-
masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
|
|
202
|
-
}
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
case 'hash':
|
|
206
|
-
masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
|
|
207
|
-
break;
|
|
208
|
-
case 'redact':
|
|
209
|
-
masked[rule.field] = '[REDACTED]';
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return masked;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Update the security policy.
|
|
219
|
-
*/
|
|
220
|
-
updatePolicy(policy: Partial<SecurityManagerPolicy>): void {
|
|
221
|
-
this.policy = { ...this.policy, ...policy };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Get current security policy.
|
|
226
|
-
*/
|
|
227
|
-
getPolicy(): SecurityManagerPolicy {
|
|
228
|
-
return { ...this.policy };
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Simple hash function for data masking (not cryptographic).
|
|
234
|
-
*/
|
|
235
|
-
function simpleHash(str: string): string {
|
|
236
|
-
let hash = 0;
|
|
237
|
-
for (let i = 0; i < str.length; i++) {
|
|
238
|
-
const char = str.charCodeAt(i);
|
|
239
|
-
hash = ((hash << 5) - hash) + char;
|
|
240
|
-
hash = hash & hash; // Convert to 32-bit integer
|
|
241
|
-
}
|
|
242
|
-
return Math.abs(hash).toString(36);
|
|
243
|
-
}
|
|
@@ -1,326 +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
|
-
* `@object-ui/data-objectstack` ↔ `@objectstack/spec` symbol-collision guards
|
|
11
|
-
* (objectui#3160, objectstack#4115 ledger batch 6).
|
|
12
|
-
*
|
|
13
|
-
* Five symbols here wore names the spec already owns. One really WAS the spec's
|
|
14
|
-
* and now carries its binding (`DroppedFieldsEvent`); the other four model
|
|
15
|
-
* something the spec has never modelled under that name and were renamed:
|
|
16
|
-
*
|
|
17
|
-
* CacheStats → MetadataCacheStats
|
|
18
|
-
* MetadataSaveOptions → MetadataClientSaveOptions
|
|
19
|
-
* SecurityPolicy → SecurityManagerPolicy
|
|
20
|
-
* ValidationError → DataApiValidationError
|
|
21
|
-
*
|
|
22
|
-
* The batch's own triage note said "data-objectstack is a direct client of the
|
|
23
|
-
* spec protocol, so `SecurityPolicy` / `DroppedFieldsEvent` are probably hand
|
|
24
|
-
* copies — derive them first". Half of that held: `DroppedFieldsEvent` was
|
|
25
|
-
* exactly a hand copy, and `SecurityPolicy` turned out to share not one key with
|
|
26
|
-
* the spec's. That asymmetry is why every assertion below is per SYMBOL and
|
|
27
|
-
* never per cluster.
|
|
28
|
-
*
|
|
29
|
-
* ## Why the spec's names are read through the compiler, not `import * as`
|
|
30
|
-
*
|
|
31
|
-
* A runtime namespace import sees VALUES only, and every name checked here is a
|
|
32
|
-
* TYPE; a tripwire built on `Object.keys(await import(…))` would pass while
|
|
33
|
-
* proving nothing. So this reads each subpath's `.d.ts` through the TypeScript
|
|
34
|
-
* checker, exactly as `scripts/check-spec-symbol-derivation.mjs` does.
|
|
35
|
-
*
|
|
36
|
-
* ## …and why through `ts.sys` rather than `node:fs`
|
|
37
|
-
*
|
|
38
|
-
* Unlike the sibling batches' parity tests, this package's `tsconfig.json`
|
|
39
|
-
* compiles its whole test tree (it is not in `check-type-check-coverage.mjs`'s
|
|
40
|
-
* TEST_DEBT), so the pins below are already checked by `tsc --noEmit` with no
|
|
41
|
-
* separate `tsconfig.typetests.json`. Keeping that property means NOT pulling
|
|
42
|
-
* `@types/node` into a browser-side adapter's type environment just to read a
|
|
43
|
-
* file — so the probe uses the TypeScript compiler's own host, which is typed
|
|
44
|
-
* by the `typescript` dependency this file already has, and resolves the spec
|
|
45
|
-
* through TS's module resolver instead of hard-coding its subpath list.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
import { describe, it, expect } from 'vitest';
|
|
49
|
-
import ts from 'typescript';
|
|
50
|
-
|
|
51
|
-
import { DataApiValidationError } from './errors';
|
|
52
|
-
import type { MetadataCacheStats } from './cache/MetadataCache';
|
|
53
|
-
import type { MetadataClientSaveOptions } from './metadata-client';
|
|
54
|
-
import type { SecurityManagerPolicy } from './security';
|
|
55
|
-
import type { DroppedFieldsEvent } from './index';
|
|
56
|
-
|
|
57
|
-
import type { DroppedFieldsEvent as SpecDroppedFieldsEvent } from '@objectstack/spec/data';
|
|
58
|
-
import type { CacheStats as SpecCacheStats } from '@objectstack/spec/contracts';
|
|
59
|
-
import type {
|
|
60
|
-
SecurityPolicy as SpecSecurityPolicy,
|
|
61
|
-
ValidationError as SpecValidationError,
|
|
62
|
-
} from '@objectstack/spec/kernel';
|
|
63
|
-
// `MetadataSaveOptions` moved `./kernel` → `./system` in spec 17.0.0-rc.2.
|
|
64
|
-
// The name still belongs to the spec and still means the FILE writer, so the
|
|
65
|
-
// rename this batch recorded stands — only the entry point moved.
|
|
66
|
-
import type { MetadataSaveOptions as SpecMetadataSaveOptions } from '@objectstack/spec/system';
|
|
67
|
-
|
|
68
|
-
const PROBE_OPTIONS: ts.CompilerOptions = {
|
|
69
|
-
noEmit: true,
|
|
70
|
-
skipLibCheck: true,
|
|
71
|
-
strict: false,
|
|
72
|
-
target: ts.ScriptTarget.ESNext,
|
|
73
|
-
module: ts.ModuleKind.ESNext,
|
|
74
|
-
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
75
|
-
resolveJsonModule: true,
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/** This file, as TypeScript's module resolver wants it: an absolute path. */
|
|
79
|
-
const HERE = new URL(import.meta.url).pathname;
|
|
80
|
-
|
|
81
|
-
/** Every name `@objectstack/spec` exports from any subpath — types AND values. */
|
|
82
|
-
function specExportNames(): Set<string> {
|
|
83
|
-
const pkgPath = ts.resolveModuleName('@objectstack/spec/package.json', HERE, PROBE_OPTIONS, ts.sys)
|
|
84
|
-
.resolvedModule?.resolvedFileName;
|
|
85
|
-
if (!pkgPath) {
|
|
86
|
-
throw new Error('cannot resolve @objectstack/spec — run `pnpm install` first');
|
|
87
|
-
}
|
|
88
|
-
const pkgDir = pkgPath.slice(0, pkgPath.lastIndexOf('/'));
|
|
89
|
-
const raw = ts.sys.readFile(pkgPath);
|
|
90
|
-
if (!raw) throw new Error(`cannot read ${pkgPath}`);
|
|
91
|
-
const pkg = JSON.parse(raw) as {
|
|
92
|
-
exports?: Record<string, { import?: { types?: string }; require?: { types?: string } }>;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// Read the export map rather than a hand-written subpath list: a list is a
|
|
96
|
-
// copy, and a copy of the spec's shape is what this whole ledger is about.
|
|
97
|
-
const files: string[] = [];
|
|
98
|
-
for (const cond of Object.values(pkg.exports ?? {})) {
|
|
99
|
-
if (typeof cond !== 'object' || cond === null) continue;
|
|
100
|
-
const dts = cond.import?.types ?? cond.require?.types;
|
|
101
|
-
if (dts) files.push(ts.sys.resolvePath(`${pkgDir}/${dts.replace(/^\.\//, '')}`));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const program = ts.createProgram(files, PROBE_OPTIONS);
|
|
105
|
-
const checker = program.getTypeChecker();
|
|
106
|
-
|
|
107
|
-
const names = new Set<string>();
|
|
108
|
-
for (const file of files) {
|
|
109
|
-
const sf = program.getSourceFile(file);
|
|
110
|
-
if (!sf) continue;
|
|
111
|
-
const moduleSymbol = checker.getSymbolAtLocation(sf);
|
|
112
|
-
if (!moduleSymbol) continue;
|
|
113
|
-
for (const exported of checker.getExportsOfModule(moduleSymbol)) names.add(exported.getName());
|
|
114
|
-
}
|
|
115
|
-
return names;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const SPEC_NAMES = specExportNames();
|
|
119
|
-
|
|
120
|
-
describe('the spec export-name probe itself works', () => {
|
|
121
|
-
it('reads a non-trivial number of names', () => {
|
|
122
|
-
expect(SPEC_NAMES.size).toBeGreaterThan(1000);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('sees TYPE-only exports, not just runtime values', () => {
|
|
126
|
-
// `DroppedFieldsEvent` is a type alias — invisible to a runtime `import()`.
|
|
127
|
-
expect(SPEC_NAMES.has('DroppedFieldsEvent')).toBe(true);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const RENAMES: Array<[local: string, formerly: string, specMeaning: string]> = [
|
|
132
|
-
[
|
|
133
|
-
'MetadataCacheStats',
|
|
134
|
-
'CacheStats',
|
|
135
|
-
"the platform's ICacheService counters (keyCount / memoryUsage)",
|
|
136
|
-
],
|
|
137
|
-
[
|
|
138
|
-
'MetadataClientSaveOptions',
|
|
139
|
-
'MetadataSaveOptions',
|
|
140
|
-
'options for writing a metadata item to a FILE (format / path / indent / atomic)',
|
|
141
|
-
],
|
|
142
|
-
[
|
|
143
|
-
'SecurityManagerPolicy',
|
|
144
|
-
'SecurityPolicy',
|
|
145
|
-
'the package supply-chain policy (autoScan / licences / codeSigning / sandbox)',
|
|
146
|
-
],
|
|
147
|
-
[
|
|
148
|
-
'DataApiValidationError',
|
|
149
|
-
'ValidationError',
|
|
150
|
-
'a plain { field, message, code? } entry in a validation report',
|
|
151
|
-
],
|
|
152
|
-
];
|
|
153
|
-
|
|
154
|
-
describe('renamed local concepts do not collide with a spec export', () => {
|
|
155
|
-
it.each(RENAMES)('the spec does not own `%s`', (local) => {
|
|
156
|
-
expect(
|
|
157
|
-
SPEC_NAMES.has(local),
|
|
158
|
-
`@objectstack/spec now exports \`${local}\`. This package declares its own ` +
|
|
159
|
-
`\`${local}\`, so the rename that fixed objectstack#4115 has re-created the ` +
|
|
160
|
-
`collision under the new name. Rename again — and check the new name here ` +
|
|
161
|
-
`FIRST: objectui#3074 landed a rename straight onto another spec export, and ` +
|
|
162
|
-
`batch 4 burned two obvious candidates (SchemaValidationResult, ` +
|
|
163
|
-
`SchemaValidationReport) the same way.`,
|
|
164
|
-
).toBe(false);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it.each(RENAMES)('the spec still owns `%s` (it means: %s)', (_local, formerly) => {
|
|
168
|
-
expect(
|
|
169
|
-
SPEC_NAMES.has(formerly),
|
|
170
|
-
`@objectstack/spec no longer exports \`${formerly}\`, which is the only reason ` +
|
|
171
|
-
`this package renamed it. Either the spec dropped the name (then the plain ` +
|
|
172
|
-
`name can be taken back) or it moved (then re-read what it means now). A ` +
|
|
173
|
-
`workaround must not outlive its reason.`,
|
|
174
|
-
).toBe(true);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
/* -------------------------------------------------------------------------- */
|
|
179
|
-
/* Compile-time pins. A violation is a `tsc` error, not a runtime failure. */
|
|
180
|
-
/* Compiled by this package's own `tsc --noEmit`: unlike its siblings, this */
|
|
181
|
-
/* package's tsconfig.json does NOT exclude test files, so no separate */
|
|
182
|
-
/* tsconfig.typetests.json is needed to make these pins load-bearing. */
|
|
183
|
-
/* -------------------------------------------------------------------------- */
|
|
184
|
-
|
|
185
|
-
type Assert<T extends true> = T;
|
|
186
|
-
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
187
|
-
/** The `unknown` erasure the `any` probe reports `false` for (objectui#3155). */
|
|
188
|
-
type IsUnknown<T> = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false;
|
|
189
|
-
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
190
|
-
? true
|
|
191
|
-
: false;
|
|
192
|
-
type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
|
|
193
|
-
|
|
194
|
-
describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => {
|
|
195
|
-
it('is pinned at compile time', () => {
|
|
196
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecDroppedFieldsEvent>, false>>;
|
|
197
|
-
type _SpecNotUnknown = Assert<Equal<IsUnknown<SpecDroppedFieldsEvent>, false>>;
|
|
198
|
-
|
|
199
|
-
type _IsTheSpecType = Assert<Equal<DroppedFieldsEvent, SpecDroppedFieldsEvent>>;
|
|
200
|
-
|
|
201
|
-
// The pin that matters. The deleted copy widened `reason` to bare `string`
|
|
202
|
-
// "for forward-compatibility with reasons added server-side" — which is the
|
|
203
|
-
// AGENTS.md #12 lenient fallback, and it deleted the only signal that would
|
|
204
|
-
// tell AdapterProvider's toast (it branches on `readonly_when`) that a third
|
|
205
|
-
// reason had appeared. `Equal` is used rather than `extends` because
|
|
206
|
-
// `'readonly' | 'readonly_when' extends string` is true in the drifted
|
|
207
|
-
// direction too.
|
|
208
|
-
//
|
|
209
|
-
// THIS PIN FIRED, and its firing is the whole point (objectui#4167).
|
|
210
|
-
// `@objectstack/spec` 17.0.0-rc.6 added a third arm, `primary_key`, and this
|
|
211
|
-
// assertion is what reported it — a `tsc` error naming the exact widening,
|
|
212
|
-
// on a bump whose build and full vitest run were both green. Under the
|
|
213
|
-
// deleted hand copy's bare `string` the arm would have arrived silently, and
|
|
214
|
-
// `writeWarningToast` would have narrated it as "Read-only, so it did not
|
|
215
|
-
// take effect" with nobody the wiser.
|
|
216
|
-
//
|
|
217
|
-
// The member list is updated here; the CONSUMER gap is objectui#3935, which
|
|
218
|
-
// was filed BEFORE this arm shipped and named this exact moment as its
|
|
219
|
-
// activation condition ("the moment `.objectui-sha`'s spec pin moves past
|
|
220
|
-
// that change, a user who triggers the primary-key strip is told the field
|
|
221
|
-
// is read-only"). rc.6 is that moment, so #3935 stops being a prediction.
|
|
222
|
-
// Not fixed here: `emitWriteWarning`'s binary ternary needs replacing with
|
|
223
|
-
// an exhaustive map plus a third wording in both locale tables, which is a
|
|
224
|
-
// UX and i18n read of its own rather than bump adaptation. #3935 owns it and
|
|
225
|
-
// already prescribes the shape.
|
|
226
|
-
type _ReasonIsTheEnum = Assert<
|
|
227
|
-
Equal<DroppedFieldsEvent['reason'], 'readonly' | 'readonly_when' | 'primary_key'>
|
|
228
|
-
>;
|
|
229
|
-
type _ReasonIsNotString = Assert<Equal<Equal<DroppedFieldsEvent['reason'], string>, false>>;
|
|
230
|
-
|
|
231
|
-
expect(true).toBe(true);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
describe('MetadataCacheStats is not the spec ICacheService stats', () => {
|
|
236
|
-
it('is pinned at compile time', () => {
|
|
237
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecCacheStats>, false>>;
|
|
238
|
-
|
|
239
|
-
// The two agree only on the two counters every cache has. Everything that
|
|
240
|
-
// says what KIND of cache each describes is exclusive to one side: the
|
|
241
|
-
// spec's is a server KV store sized in keys and bytes; this one is a bounded
|
|
242
|
-
// browser LRU that evicts, coalesces in-flight fetches and reports a rate.
|
|
243
|
-
type _Shared = Assert<Equal<Extract<keyof MetadataCacheStats, keyof SpecCacheStats>, 'hits' | 'misses'>>;
|
|
244
|
-
type _SpecOnly = Assert<Equal<Exclude<keyof SpecCacheStats, keyof MetadataCacheStats>, 'keyCount' | 'memoryUsage'>>;
|
|
245
|
-
type _LocalOnly = Assert<
|
|
246
|
-
Equal<
|
|
247
|
-
Exclude<keyof MetadataCacheStats, keyof SpecCacheStats>,
|
|
248
|
-
'size' | 'maxSize' | 'evictions' | 'coalesced' | 'hitRate'
|
|
249
|
-
>
|
|
250
|
-
>;
|
|
251
|
-
|
|
252
|
-
expect(true).toBe(true);
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe('MetadataClientSaveOptions is not the spec file-save options', () => {
|
|
257
|
-
it('is pinned at compile time', () => {
|
|
258
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecMetadataSaveOptions>, false>>;
|
|
259
|
-
|
|
260
|
-
// Disjoint: the spec's describes serialising an item to disk, this one
|
|
261
|
-
// describes a PUT to /api/v1/meta/*. Not one key is shared, so there was
|
|
262
|
-
// never a subset relationship to derive from.
|
|
263
|
-
type _NoOverlap = Assert<Equal<Extract<keyof MetadataClientSaveOptions, keyof SpecMetadataSaveOptions>, never>>;
|
|
264
|
-
|
|
265
|
-
// The keys that make this the HTTP envelope.
|
|
266
|
-
type _HasIfMatch = Assert<HasKey<MetadataClientSaveOptions, 'ifMatch'>>;
|
|
267
|
-
type _HasDraftMode = Assert<Equal<MetadataClientSaveOptions['mode'], 'draft' | 'publish' | undefined>>;
|
|
268
|
-
// …and the ones that make the spec's the file writer.
|
|
269
|
-
type _SpecHasPath = Assert<HasKey<SpecMetadataSaveOptions, 'path'>>;
|
|
270
|
-
type _SpecHasFormat = Assert<HasKey<SpecMetadataSaveOptions, 'format'>>;
|
|
271
|
-
|
|
272
|
-
expect(true).toBe(true);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
describe('SecurityManagerPolicy is not the spec package-security policy', () => {
|
|
277
|
-
it('is pinned at compile time', () => {
|
|
278
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecSecurityPolicy>, false>>;
|
|
279
|
-
|
|
280
|
-
// Also fully disjoint. The spec's governs installing a plugin (scan
|
|
281
|
-
// schedule, licence allowlists, code signing, sandbox limits); this one
|
|
282
|
-
// governs what the browser adapter emits and hides (CSP header, audit log,
|
|
283
|
-
// field masking).
|
|
284
|
-
type _NoOverlap = Assert<Equal<Extract<keyof SecurityManagerPolicy, keyof SpecSecurityPolicy>, never>>;
|
|
285
|
-
type _SpecIsIdentified = Assert<Equal<SpecSecurityPolicy['id'], string>>;
|
|
286
|
-
type _LocalIsAllOptional = Assert<
|
|
287
|
-
Equal<
|
|
288
|
-
undefined extends SecurityManagerPolicy['csp' | 'auditLog' | 'dataMasking'] ? true : false,
|
|
289
|
-
true
|
|
290
|
-
>
|
|
291
|
-
>;
|
|
292
|
-
|
|
293
|
-
expect(true).toBe(true);
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
describe('DataApiValidationError is an Error, the spec ValidationError is a record', () => {
|
|
298
|
-
it('is pinned at compile time', () => {
|
|
299
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecValidationError>, false>>;
|
|
300
|
-
|
|
301
|
-
// The spec's is one entry in a validation report. `@object-ui/types`
|
|
302
|
-
// re-exports it under that name, so the two were importable side by side.
|
|
303
|
-
type _SpecShape = Assert<Equal<Exclude<keyof SpecValidationError, 'field' | 'message' | 'code'>, never>>;
|
|
304
|
-
|
|
305
|
-
// This one is a thrown runtime object: it has a `stack`, which no data
|
|
306
|
-
// record does, and it carries the whole list the spec type is one item of.
|
|
307
|
-
type _WeAreAnError = Assert<HasKey<DataApiValidationError, 'stack'>>;
|
|
308
|
-
type _WeCarryTheList = Assert<
|
|
309
|
-
Equal<DataApiValidationError['validationErrors'], Array<{ field: string; message: string }> | undefined>
|
|
310
|
-
>;
|
|
311
|
-
|
|
312
|
-
expect(true).toBe(true);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('keeps `name === "ValidationError"` — the RUNTIME name is a wire contract', () => {
|
|
316
|
-
// Deliberately NOT renamed with the class. `normaliseClientError` in
|
|
317
|
-
// ./index.ts sniffs `e.name === 'ValidationError'` on errors thrown by
|
|
318
|
-
// `@objectstack/client`, and `@object-ui/react`'s `error-message` does the
|
|
319
|
-
// same on the way out. Renaming the TypeScript symbol is a source change;
|
|
320
|
-
// renaming this string would be a silent behaviour change.
|
|
321
|
-
expect(new DataApiValidationError('nope').name).toBe('ValidationError');
|
|
322
|
-
expect(new DataApiValidationError('nope')).toBeInstanceOf(Error);
|
|
323
|
-
expect(new DataApiValidationError('nope').code).toBe('VALIDATION_ERROR');
|
|
324
|
-
expect(new DataApiValidationError('nope').statusCode).toBe(400);
|
|
325
|
-
});
|
|
326
|
-
});
|