@object-ui/data-objectstack 16.1.0 → 17.2.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.
@@ -0,0 +1,120 @@
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 CHANGED
@@ -11,7 +11,20 @@
11
11
  * Provides advanced security policies: CSP config, audit logging, data masking.
12
12
  */
13
13
 
14
- export interface SecurityPolicy {
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 {
15
28
  /** Content Security Policy configuration */
16
29
  csp?: CSPConfig;
17
30
  /** Audit logging configuration */
@@ -100,10 +113,10 @@ export interface AuditLogEntry {
100
113
  * Handles CSP generation, audit logging, and data masking.
101
114
  */
102
115
  export class SecurityManager {
103
- private policy: SecurityPolicy;
116
+ private policy: SecurityManagerPolicy;
104
117
  private auditLog: AuditLogEntry[] = [];
105
118
 
106
- constructor(policy: SecurityPolicy = {}) {
119
+ constructor(policy: SecurityManagerPolicy = {}) {
107
120
  this.policy = policy;
108
121
  }
109
122
 
@@ -204,14 +217,14 @@ export class SecurityManager {
204
217
  /**
205
218
  * Update the security policy.
206
219
  */
207
- updatePolicy(policy: Partial<SecurityPolicy>): void {
220
+ updatePolicy(policy: Partial<SecurityManagerPolicy>): void {
208
221
  this.policy = { ...this.policy, ...policy };
209
222
  }
210
223
 
211
224
  /**
212
225
  * Get current security policy.
213
226
  */
214
- getPolicy(): SecurityPolicy {
227
+ getPolicy(): SecurityManagerPolicy {
215
228
  return { ...this.policy };
216
229
  }
217
230
  }
@@ -0,0 +1,303 @@
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
+ MetadataSaveOptions as SpecMetadataSaveOptions,
61
+ SecurityPolicy as SpecSecurityPolicy,
62
+ ValidationError as SpecValidationError,
63
+ } from '@objectstack/spec/kernel';
64
+
65
+ const PROBE_OPTIONS: ts.CompilerOptions = {
66
+ noEmit: true,
67
+ skipLibCheck: true,
68
+ strict: false,
69
+ target: ts.ScriptTarget.ESNext,
70
+ module: ts.ModuleKind.ESNext,
71
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
72
+ resolveJsonModule: true,
73
+ };
74
+
75
+ /** This file, as TypeScript's module resolver wants it: an absolute path. */
76
+ const HERE = new URL(import.meta.url).pathname;
77
+
78
+ /** Every name `@objectstack/spec` exports from any subpath — types AND values. */
79
+ function specExportNames(): Set<string> {
80
+ const pkgPath = ts.resolveModuleName('@objectstack/spec/package.json', HERE, PROBE_OPTIONS, ts.sys)
81
+ .resolvedModule?.resolvedFileName;
82
+ if (!pkgPath) {
83
+ throw new Error('cannot resolve @objectstack/spec — run `pnpm install` first');
84
+ }
85
+ const pkgDir = pkgPath.slice(0, pkgPath.lastIndexOf('/'));
86
+ const raw = ts.sys.readFile(pkgPath);
87
+ if (!raw) throw new Error(`cannot read ${pkgPath}`);
88
+ const pkg = JSON.parse(raw) as {
89
+ exports?: Record<string, { import?: { types?: string }; require?: { types?: string } }>;
90
+ };
91
+
92
+ // Read the export map rather than a hand-written subpath list: a list is a
93
+ // copy, and a copy of the spec's shape is what this whole ledger is about.
94
+ const files: string[] = [];
95
+ for (const cond of Object.values(pkg.exports ?? {})) {
96
+ if (typeof cond !== 'object' || cond === null) continue;
97
+ const dts = cond.import?.types ?? cond.require?.types;
98
+ if (dts) files.push(ts.sys.resolvePath(`${pkgDir}/${dts.replace(/^\.\//, '')}`));
99
+ }
100
+
101
+ const program = ts.createProgram(files, PROBE_OPTIONS);
102
+ const checker = program.getTypeChecker();
103
+
104
+ const names = new Set<string>();
105
+ for (const file of files) {
106
+ const sf = program.getSourceFile(file);
107
+ if (!sf) continue;
108
+ const moduleSymbol = checker.getSymbolAtLocation(sf);
109
+ if (!moduleSymbol) continue;
110
+ for (const exported of checker.getExportsOfModule(moduleSymbol)) names.add(exported.getName());
111
+ }
112
+ return names;
113
+ }
114
+
115
+ const SPEC_NAMES = specExportNames();
116
+
117
+ describe('the spec export-name probe itself works', () => {
118
+ it('reads a non-trivial number of names', () => {
119
+ expect(SPEC_NAMES.size).toBeGreaterThan(1000);
120
+ });
121
+
122
+ it('sees TYPE-only exports, not just runtime values', () => {
123
+ // `DroppedFieldsEvent` is a type alias — invisible to a runtime `import()`.
124
+ expect(SPEC_NAMES.has('DroppedFieldsEvent')).toBe(true);
125
+ });
126
+ });
127
+
128
+ const RENAMES: Array<[local: string, formerly: string, specMeaning: string]> = [
129
+ [
130
+ 'MetadataCacheStats',
131
+ 'CacheStats',
132
+ "the platform's ICacheService counters (keyCount / memoryUsage)",
133
+ ],
134
+ [
135
+ 'MetadataClientSaveOptions',
136
+ 'MetadataSaveOptions',
137
+ 'options for writing a metadata item to a FILE (format / path / indent / atomic)',
138
+ ],
139
+ [
140
+ 'SecurityManagerPolicy',
141
+ 'SecurityPolicy',
142
+ 'the package supply-chain policy (autoScan / licences / codeSigning / sandbox)',
143
+ ],
144
+ [
145
+ 'DataApiValidationError',
146
+ 'ValidationError',
147
+ 'a plain { field, message, code? } entry in a validation report',
148
+ ],
149
+ ];
150
+
151
+ describe('renamed local concepts do not collide with a spec export', () => {
152
+ it.each(RENAMES)('the spec does not own `%s`', (local) => {
153
+ expect(
154
+ SPEC_NAMES.has(local),
155
+ `@objectstack/spec now exports \`${local}\`. This package declares its own ` +
156
+ `\`${local}\`, so the rename that fixed objectstack#4115 has re-created the ` +
157
+ `collision under the new name. Rename again — and check the new name here ` +
158
+ `FIRST: objectui#3074 landed a rename straight onto another spec export, and ` +
159
+ `batch 4 burned two obvious candidates (SchemaValidationResult, ` +
160
+ `SchemaValidationReport) the same way.`,
161
+ ).toBe(false);
162
+ });
163
+
164
+ it.each(RENAMES)('the spec still owns `%s` (it means: %s)', (_local, formerly) => {
165
+ expect(
166
+ SPEC_NAMES.has(formerly),
167
+ `@objectstack/spec no longer exports \`${formerly}\`, which is the only reason ` +
168
+ `this package renamed it. Either the spec dropped the name (then the plain ` +
169
+ `name can be taken back) or it moved (then re-read what it means now). A ` +
170
+ `workaround must not outlive its reason.`,
171
+ ).toBe(true);
172
+ });
173
+ });
174
+
175
+ /* -------------------------------------------------------------------------- */
176
+ /* Compile-time pins. A violation is a `tsc` error, not a runtime failure. */
177
+ /* Compiled by this package's own `tsc --noEmit`: unlike its siblings, this */
178
+ /* package's tsconfig.json does NOT exclude test files, so no separate */
179
+ /* tsconfig.typetests.json is needed to make these pins load-bearing. */
180
+ /* -------------------------------------------------------------------------- */
181
+
182
+ type Assert<T extends true> = T;
183
+ type IsAny<T> = 0 extends 1 & T ? true : false;
184
+ /** The `unknown` erasure the `any` probe reports `false` for (objectui#3155). */
185
+ type IsUnknown<T> = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false;
186
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
187
+ ? true
188
+ : false;
189
+ type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
190
+
191
+ describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => {
192
+ it('is pinned at compile time', () => {
193
+ type _SpecNotAny = Assert<Equal<IsAny<SpecDroppedFieldsEvent>, false>>;
194
+ type _SpecNotUnknown = Assert<Equal<IsUnknown<SpecDroppedFieldsEvent>, false>>;
195
+
196
+ type _IsTheSpecType = Assert<Equal<DroppedFieldsEvent, SpecDroppedFieldsEvent>>;
197
+
198
+ // The pin that matters. The deleted copy widened `reason` to bare `string`
199
+ // "for forward-compatibility with reasons added server-side" — which is the
200
+ // AGENTS.md #12 lenient fallback, and it deleted the only signal that would
201
+ // tell AdapterProvider's toast (it branches on `readonly_when`) that a third
202
+ // reason had appeared. `Equal` is used rather than `extends` because
203
+ // `'readonly' | 'readonly_when' extends string` is true in the drifted
204
+ // direction too.
205
+ type _ReasonIsTheEnum = Assert<Equal<DroppedFieldsEvent['reason'], 'readonly' | 'readonly_when'>>;
206
+ type _ReasonIsNotString = Assert<Equal<Equal<DroppedFieldsEvent['reason'], string>, false>>;
207
+
208
+ expect(true).toBe(true);
209
+ });
210
+ });
211
+
212
+ describe('MetadataCacheStats is not the spec ICacheService stats', () => {
213
+ it('is pinned at compile time', () => {
214
+ type _SpecNotAny = Assert<Equal<IsAny<SpecCacheStats>, false>>;
215
+
216
+ // The two agree only on the two counters every cache has. Everything that
217
+ // says what KIND of cache each describes is exclusive to one side: the
218
+ // spec's is a server KV store sized in keys and bytes; this one is a bounded
219
+ // browser LRU that evicts, coalesces in-flight fetches and reports a rate.
220
+ type _Shared = Assert<Equal<Extract<keyof MetadataCacheStats, keyof SpecCacheStats>, 'hits' | 'misses'>>;
221
+ type _SpecOnly = Assert<Equal<Exclude<keyof SpecCacheStats, keyof MetadataCacheStats>, 'keyCount' | 'memoryUsage'>>;
222
+ type _LocalOnly = Assert<
223
+ Equal<
224
+ Exclude<keyof MetadataCacheStats, keyof SpecCacheStats>,
225
+ 'size' | 'maxSize' | 'evictions' | 'coalesced' | 'hitRate'
226
+ >
227
+ >;
228
+
229
+ expect(true).toBe(true);
230
+ });
231
+ });
232
+
233
+ describe('MetadataClientSaveOptions is not the spec file-save options', () => {
234
+ it('is pinned at compile time', () => {
235
+ type _SpecNotAny = Assert<Equal<IsAny<SpecMetadataSaveOptions>, false>>;
236
+
237
+ // Disjoint: the spec's describes serialising an item to disk, this one
238
+ // describes a PUT to /api/v1/meta/*. Not one key is shared, so there was
239
+ // never a subset relationship to derive from.
240
+ type _NoOverlap = Assert<Equal<Extract<keyof MetadataClientSaveOptions, keyof SpecMetadataSaveOptions>, never>>;
241
+
242
+ // The keys that make this the HTTP envelope.
243
+ type _HasIfMatch = Assert<HasKey<MetadataClientSaveOptions, 'ifMatch'>>;
244
+ type _HasDraftMode = Assert<Equal<MetadataClientSaveOptions['mode'], 'draft' | 'publish' | undefined>>;
245
+ // …and the ones that make the spec's the file writer.
246
+ type _SpecHasPath = Assert<HasKey<SpecMetadataSaveOptions, 'path'>>;
247
+ type _SpecHasFormat = Assert<HasKey<SpecMetadataSaveOptions, 'format'>>;
248
+
249
+ expect(true).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe('SecurityManagerPolicy is not the spec package-security policy', () => {
254
+ it('is pinned at compile time', () => {
255
+ type _SpecNotAny = Assert<Equal<IsAny<SpecSecurityPolicy>, false>>;
256
+
257
+ // Also fully disjoint. The spec's governs installing a plugin (scan
258
+ // schedule, licence allowlists, code signing, sandbox limits); this one
259
+ // governs what the browser adapter emits and hides (CSP header, audit log,
260
+ // field masking).
261
+ type _NoOverlap = Assert<Equal<Extract<keyof SecurityManagerPolicy, keyof SpecSecurityPolicy>, never>>;
262
+ type _SpecIsIdentified = Assert<Equal<SpecSecurityPolicy['id'], string>>;
263
+ type _LocalIsAllOptional = Assert<
264
+ Equal<
265
+ undefined extends SecurityManagerPolicy['csp' | 'auditLog' | 'dataMasking'] ? true : false,
266
+ true
267
+ >
268
+ >;
269
+
270
+ expect(true).toBe(true);
271
+ });
272
+ });
273
+
274
+ describe('DataApiValidationError is an Error, the spec ValidationError is a record', () => {
275
+ it('is pinned at compile time', () => {
276
+ type _SpecNotAny = Assert<Equal<IsAny<SpecValidationError>, false>>;
277
+
278
+ // The spec's is one entry in a validation report. `@object-ui/types`
279
+ // re-exports it under that name, so the two were importable side by side.
280
+ type _SpecShape = Assert<Equal<Exclude<keyof SpecValidationError, 'field' | 'message' | 'code'>, never>>;
281
+
282
+ // This one is a thrown runtime object: it has a `stack`, which no data
283
+ // record does, and it carries the whole list the spec type is one item of.
284
+ type _WeAreAnError = Assert<HasKey<DataApiValidationError, 'stack'>>;
285
+ type _WeCarryTheList = Assert<
286
+ Equal<DataApiValidationError['validationErrors'], Array<{ field: string; message: string }> | undefined>
287
+ >;
288
+
289
+ expect(true).toBe(true);
290
+ });
291
+
292
+ it('keeps `name === "ValidationError"` — the RUNTIME name is a wire contract', () => {
293
+ // Deliberately NOT renamed with the class. `normaliseClientError` in
294
+ // ./index.ts sniffs `e.name === 'ValidationError'` on errors thrown by
295
+ // `@objectstack/client`, and `@object-ui/react`'s `error-message` does the
296
+ // same on the way out. Renaming the TypeScript symbol is a source change;
297
+ // renaming this string would be a silent behaviour change.
298
+ expect(new DataApiValidationError('nope').name).toBe('ValidationError');
299
+ expect(new DataApiValidationError('nope')).toBeInstanceOf(Error);
300
+ expect(new DataApiValidationError('nope').code).toBe('VALIDATION_ERROR');
301
+ expect(new DataApiValidationError('nope').statusCode).toBe(400);
302
+ });
303
+ });
@@ -144,7 +144,6 @@ describe('createObjectStackUserStateAdapter', () => {
144
144
  user_id: 'u1',
145
145
  key: 'ui.favorites',
146
146
  value: [{ id: 'a' }],
147
- updated_at: expect.any(String),
148
147
  });
149
148
  });
150
149
 
@@ -165,11 +164,35 @@ describe('createObjectStackUserStateAdapter', () => {
165
164
 
166
165
  expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-99', {
167
166
  value: [{ id: 'x' }],
168
- updated_at: expect.any(String),
169
167
  });
170
168
  expect(ds.create).not.toHaveBeenCalled();
171
169
  });
172
170
 
171
+ // #3794 — `updated_at` is server-managed. A caller-supplied value is
172
+ // stripped (framework #2948) and reported back as a dropped field, which
173
+ // the console turns into a "Some fields were not saved" toast. Stamping it
174
+ // here fired that warning on every recents/favorites write, about a field
175
+ // no user ever touched — noise on the exact surface that is supposed to
176
+ // tell a user their edit did not land.
177
+ it('never sends the server-managed updated_at column', async () => {
178
+ const ds = mockDataSource({
179
+ find: vi.fn().mockResolvedValue({ data: [] }) as any,
180
+ create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
181
+ });
182
+ const adapter = createObjectStackUserStateAdapter({
183
+ dataSource: ds,
184
+ userId: 'u',
185
+ key: 'ui.recent',
186
+ });
187
+
188
+ await adapter.save([{ id: 'a' } as any]);
189
+ await adapter.save([{ id: 'b' } as any]);
190
+
191
+ for (const call of [...(ds.create as any).mock.calls, ...(ds.update as any).mock.calls]) {
192
+ expect(call[call.length - 1]).not.toHaveProperty('updated_at');
193
+ }
194
+ });
195
+
173
196
  it('uses the cached row id from a previous load on subsequent saves', async () => {
174
197
  const ds = mockDataSource({
175
198
  find: vi.fn().mockResolvedValue({
@@ -242,7 +265,6 @@ describe('createObjectStackUserStateAdapter', () => {
242
265
  expect(ds.create).toHaveBeenCalledTimes(1);
243
266
  expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
244
267
  value: [{ id: 'q' }],
245
- updated_at: expect.any(String),
246
268
  });
247
269
  });
248
270
 
@@ -270,7 +292,6 @@ describe('createObjectStackUserStateAdapter', () => {
270
292
  expect(ds.create).toHaveBeenCalledTimes(1);
271
293
  expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
272
294
  value: [{ id: 'a' }, { id: 'b' }],
273
- updated_at: expect.any(String),
274
295
  });
275
296
  });
276
297
 
package/src/userState.ts CHANGED
@@ -159,13 +159,18 @@ export function createObjectStackUserStateAdapter<T = unknown>(
159
159
  // (a concurrent writer created the row, or the backend dropped our find
160
160
  // predicate), recover by re-finding and updating rather than surfacing the
161
161
  // failed insert.
162
+ //
163
+ // Deliberately does NOT stamp `updated_at`: the column is server-managed, and
164
+ // a non-system caller's write to it is stripped (framework #2948) and reported
165
+ // back as a dropped field — which the console surfaces as a "Some fields were
166
+ // not saved" toast (#3431). Sending it made every recents/favorites write pop
167
+ // a scary warning about a field the user never touched, drowning the real
168
+ // signal the toast exists for (#3794). The server stamps it either way.
162
169
  const upsert = async (items: T[]): Promise<void> => {
163
- const now = new Date().toISOString();
164
-
165
170
  // Fast path: we already know the row id from a previous load/save.
166
171
  if (cachedRowId !== null) {
167
172
  try {
168
- await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
173
+ await dataSource.update(resource, cachedRowId, { value: items });
169
174
  return;
170
175
  } catch (updateError) {
171
176
  // Row may have been deleted server-side — fall through to find/insert.
@@ -177,7 +182,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
177
182
  const existing = await findExisting();
178
183
  if (existing && existing.id !== undefined && existing.id !== null) {
179
184
  cachedRowId = existing.id;
180
- await dataSource.update(resource, existing.id, { value: items, updated_at: now });
185
+ await dataSource.update(resource, existing.id, { value: items });
181
186
  return;
182
187
  }
183
188
 
@@ -186,7 +191,6 @@ export function createObjectStackUserStateAdapter<T = unknown>(
186
191
  user_id: userId,
187
192
  key,
188
193
  value: items,
189
- updated_at: now,
190
194
  });
191
195
  const newId = (created as UserPreferenceRecord | undefined)?.id;
192
196
  if (newId !== undefined && newId !== null) cachedRowId = newId;
@@ -196,7 +200,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
196
200
  const recovered = await findExisting();
197
201
  if (recovered && recovered.id !== undefined && recovered.id !== null) {
198
202
  cachedRowId = recovered.id;
199
- await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
203
+ await dataSource.update(resource, recovered.id, { value: items });
200
204
  return;
201
205
  }
202
206
  throw createError;
@@ -0,0 +1,94 @@
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
+ });