@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/index.cjs +262 -357
  3. package/dist/index.d.cts +385 -448
  4. package/dist/index.d.ts +385 -448
  5. package/dist/index.js +258 -350
  6. package/package.json +5 -6
  7. package/dist/tsconfig.tsbuildinfo +0 -1
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/apiAccessDenied.test.ts +0 -233
  10. package/src/appAccessProbe.test.ts +0 -147
  11. package/src/bulkDelete.test.ts +0 -75
  12. package/src/bulkUpdate.test.ts +0 -93
  13. package/src/cache/MetadataCache.test.ts +0 -477
  14. package/src/cache/MetadataCache.ts +0 -289
  15. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  16. package/src/connection.test.ts +0 -188
  17. package/src/contracts.ts +0 -115
  18. package/src/deleteView.homes.test.ts +0 -389
  19. package/src/deleteViewContract.types.test.ts +0 -246
  20. package/src/errors.test.ts +0 -426
  21. package/src/errors.ts +0 -293
  22. package/src/exportDownload.test.ts +0 -141
  23. package/src/filter-entry-translation.test.ts +0 -360
  24. package/src/filter-operator-ast-parity.test.ts +0 -135
  25. package/src/getObjectSchema.test.ts +0 -145
  26. package/src/httpFailureLogging.test.ts +0 -109
  27. package/src/importJob.test.ts +0 -71
  28. package/src/index.ts +0 -4442
  29. package/src/integration.ts +0 -200
  30. package/src/listViewOverrides.test.ts +0 -241
  31. package/src/listViews.test.ts +0 -175
  32. package/src/metadata-client.get-envelope.test.ts +0 -171
  33. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  34. package/src/metadata-client.test.ts +0 -303
  35. package/src/metadata-client.ts +0 -1046
  36. package/src/occ.test.ts +0 -101
  37. package/src/onMutation.test.ts +0 -589
  38. package/src/onSaveAdvisory.test.ts +0 -311
  39. package/src/onWriteWarning.test.ts +0 -273
  40. package/src/orderby-serialization.test.ts +0 -115
  41. package/src/queryDataset.test.ts +0 -543
  42. package/src/searchAll.test.ts +0 -120
  43. package/src/security.ts +0 -243
  44. package/src/spec-symbol-batch6.test.ts +0 -326
  45. package/src/studio.ts +0 -152
  46. package/src/updateView.draft.test.ts +0 -242
  47. package/src/userState.test.ts +0 -315
  48. package/src/userState.ts +0 -238
  49. package/src/v3-compat.test.ts +0 -229
  50. package/src/validation-error.test.ts +0 -94
  51. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  52. package/src/viewInvalidationSeam.pin.test.ts +0 -162
@@ -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
- });