@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "16.1.0",
3
+ "version": "17.2.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,9 +22,10 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@objectstack/client": "^16.0.0-rc.0",
26
- "@object-ui/core": "16.1.0",
27
- "@object-ui/types": "16.1.0"
25
+ "@objectstack/client": "^17.0.0-rc.1",
26
+ "@objectstack/spec": "^17.0.0-rc.1",
27
+ "@object-ui/core": "17.2.0",
28
+ "@object-ui/types": "17.2.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "tsup": "^8.5.1",
@@ -0,0 +1,177 @@
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
+ * `aggregate()` against an analytics endpoint that cannot answer.
11
+ *
12
+ * WHY THIS EXISTS. `@objectstack/client`'s `analytics.query()` returns
13
+ * `res.json()` WITHOUT checking `res.ok`, so a non-2xx never throws — the error
14
+ * body arrives where rows were expected. The adapter's row parser matched none
15
+ * of its shapes, produced `[]`, and returned it as a RESULT: the `catch` that
16
+ * promises a client-side fallback never ran, and a KPI on a deployment without
17
+ * `@objectstack/service-analytics` rendered a confident **zero**.
18
+ *
19
+ * That is the same lie framework#3891 removed from the server side (a degraded
20
+ * shim answering 200 with unscoped numbers), reproduced one layer up. These
21
+ * tests pin the three outcomes:
22
+ * - capability absent (404 / 501) → client-side fallback over a scoped find();
23
+ * - our body is off-contract (400) → THROWS, never silently re-answered;
24
+ * - a normal result → untouched.
25
+ */
26
+
27
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
28
+ import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index';
29
+
30
+ const RECORDS = [
31
+ { id: '1', stage: 'won', amount: 100 },
32
+ { id: '2', stage: 'won', amount: 50 },
33
+ { id: '3', stage: 'lost', amount: 20 },
34
+ ];
35
+
36
+ /**
37
+ * A fetch mock answering discovery, the analytics query (with `analyticsBody`
38
+ * at `analyticsStatus`), and the `/data` read the fallback performs.
39
+ */
40
+ function makeFetch(analyticsStatus: number, analyticsBody: unknown) {
41
+ const calls: string[] = [];
42
+ const fetchImpl = vi.fn(async (url: any, init?: any) => {
43
+ const u = String(url);
44
+ calls.push(`${init?.method ?? 'GET'} ${u}`);
45
+ if (u.includes('/api/v1/discovery')) {
46
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
47
+ }
48
+ if (u.includes('/api/v1/analytics/query')) {
49
+ return {
50
+ ok: analyticsStatus >= 200 && analyticsStatus < 300,
51
+ status: analyticsStatus,
52
+ statusText: 'x',
53
+ json: async () => analyticsBody,
54
+ } as any;
55
+ }
56
+ // The fallback's scoped read.
57
+ return {
58
+ ok: true, status: 200, statusText: 'OK',
59
+ json: async () => ({ success: true, data: { object: 'opportunity', records: RECORDS, total: RECORDS.length } }),
60
+ } as any;
61
+ });
62
+ return { fetchImpl, calls };
63
+ }
64
+
65
+ function makeAdapter(fetchImpl: any) {
66
+ return new ObjectStackAdapter({
67
+ baseUrl: 'http://localhost:3000',
68
+ autoReconnect: false,
69
+ fetch: fetchImpl as any,
70
+ });
71
+ }
72
+
73
+ const SUM_BY_STAGE = { function: 'sum', field: 'amount', groupBy: 'stage' };
74
+
75
+ describe('aggregate() when the analytics capability is absent', () => {
76
+ beforeEach(() => clearSharedDiscoveryCache());
77
+
78
+ it('404 (routes not mounted, framework#4019) falls back instead of returning an empty result', async () => {
79
+ const { fetchImpl, calls } = makeFetch(404, { error: 'Not found' });
80
+ const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
81
+
82
+ // Pre-fix this was `[]` — a chart reading "no data" on a populated table.
83
+ expect(rows.length).toBeGreaterThan(0);
84
+ const won = rows.find((r: any) => r.stage === 'won');
85
+ expect(won?.amount).toBe(150);
86
+ // It really did fall back to a server-side read (not invent numbers).
87
+ expect(calls.some((c) => c.includes('/api/v1/data'))).toBe(true);
88
+ });
89
+
90
+ it('501 NOT_IMPLEMENTED (REST route, no service) falls back the same way', async () => {
91
+ const { fetchImpl } = makeFetch(501, {
92
+ code: 'NOT_IMPLEMENTED',
93
+ message: 'Analytics is not available on this deployment.',
94
+ });
95
+ const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
96
+ expect(rows.find((r: any) => r.stage === 'won')?.amount).toBe(150);
97
+ });
98
+
99
+ it('warns ONCE per adapter, not once per widget', async () => {
100
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
101
+ try {
102
+ const { fetchImpl } = makeFetch(404, { error: 'Not found' });
103
+ const adapter = makeAdapter(fetchImpl);
104
+ await adapter.aggregate('opportunity', SUM_BY_STAGE);
105
+ await adapter.aggregate('opportunity', SUM_BY_STAGE);
106
+ await adapter.aggregate('opportunity', SUM_BY_STAGE);
107
+
108
+ const capabilityWarnings = warn.mock.calls.filter((c) =>
109
+ String(c[0]).includes('analytics capability unavailable'),
110
+ );
111
+ expect(capabilityWarnings).toHaveLength(1);
112
+ expect(String(capabilityWarnings[0][0])).toContain('@objectstack/service-analytics');
113
+ } finally {
114
+ warn.mockRestore();
115
+ }
116
+ });
117
+
118
+ it('forwards the widget filter into the fallback (never aggregates the whole table)', async () => {
119
+ const { fetchImpl, calls } = makeFetch(404, { error: 'Not found' });
120
+ await makeAdapter(fetchImpl).aggregate('opportunity', { ...SUM_BY_STAGE, filter: { stage: 'won' } });
121
+
122
+ const dataCall = calls.find((c) => c.includes('/api/v1/data'));
123
+ expect(dataCall).toBeDefined();
124
+ expect(decodeURIComponent(dataCall!)).toContain('won');
125
+ });
126
+ });
127
+
128
+ describe('aggregate() when the server REJECTS our query body', () => {
129
+ beforeEach(() => clearSharedDiscoveryCache());
130
+
131
+ // framework#4010 validates /analytics/query at the entry. A 400 means the
132
+ // adapter sent an off-contract body — answering it with numbers from the
133
+ // client-side path would bury our own bug behind plausible output, which is
134
+ // exactly the misdirection framework#3878 documented.
135
+ it('400 VALIDATION_FAILED throws — it must NOT be answered by the fallback', async () => {
136
+ const { fetchImpl, calls } = makeFetch(400, {
137
+ success: false,
138
+ error: {
139
+ code: 'VALIDATION_FAILED',
140
+ httpStatus: 400,
141
+ message: 'Invalid AnalyticsQuery body: measures: Invalid input',
142
+ details: { fields: [{ field: 'measures', code: 'invalid_type' }] },
143
+ },
144
+ });
145
+
146
+ const err = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE).catch((e) => e);
147
+
148
+ expect(err).toBeInstanceOf(Error);
149
+ expect((err as { code?: string }).code).toBe('ANALYTICS_QUERY_REJECTED');
150
+ expect(String(err.message)).toContain('measures');
151
+ // No silent second answer from a different code path.
152
+ expect(calls.some((c) => c.includes('/api/v1/data'))).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('aggregate() on a healthy analytics endpoint', () => {
157
+ beforeEach(() => clearSharedDiscoveryCache());
158
+
159
+ it('returns the server rows untouched (no false capability detection)', async () => {
160
+ const { fetchImpl, calls } = makeFetch(200, {
161
+ rows: [{ stage: 'won', amount_sum: 150 }],
162
+ fields: [{ name: 'amount_sum', type: 'number' }],
163
+ });
164
+
165
+ const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
166
+
167
+ // Measure key mapped back to the caller's column (`amount`), rows from the server.
168
+ expect(rows).toEqual([{ stage: 'won', amount: 150 }]);
169
+ expect(calls.some((c) => c.includes('/api/v1/data'))).toBe(false);
170
+ });
171
+
172
+ it('a `{ success, data: { rows } }` envelope is still a result, not an error', async () => {
173
+ const { fetchImpl } = makeFetch(200, { success: true, data: { rows: [{ stage: 'won', amount_sum: 150 }] } });
174
+ const rows = await makeAdapter(fetchImpl).aggregate('opportunity', SUM_BY_STAGE);
175
+ expect(rows).toEqual([{ stage: 'won', amount: 150 }]);
176
+ });
177
+ });
@@ -17,9 +17,18 @@ interface CachedSchema {
17
17
  }
18
18
 
19
19
  /**
20
- * Cache statistics for monitoring
20
+ * Statistics reported by {@link MetadataCache.getStats} for monitoring.
21
+ *
22
+ * NOT the spec's `CacheStats` (`@objectstack/spec/contracts`), whose name this
23
+ * interface wore until objectui#3160 (objectstack#4115 ledger batch 6). That one
24
+ * describes the platform's `ICacheService` — a server-side KV cache measured by
25
+ * `keyCount` and `memoryUsage`. This one describes the browser-side LRU in front
26
+ * of `/api/v1/meta/*`: it is bounded (`size`/`maxSize`), it evicts, it coalesces
27
+ * concurrent fetches onto one in-flight promise, and it reports a `hitRate`.
28
+ * Neither type has a key the other has, so this is a name collision, not a
29
+ * dialect — deriving would have replaced every field.
21
30
  */
22
- export interface CacheStats {
31
+ export interface MetadataCacheStats {
23
32
  size: number;
24
33
  maxSize: number;
25
34
  hits: number;
@@ -218,7 +227,7 @@ export class MetadataCache {
218
227
  *
219
228
  * @returns Cache statistics including hit rate
220
229
  */
221
- getStats(): CacheStats {
230
+ getStats(): MetadataCacheStats {
222
231
  const total = this.stats.hits + this.stats.misses;
223
232
  const hitRate = total > 0 ? this.stats.hits / total : 0;
224
233
 
@@ -13,7 +13,7 @@ import {
13
13
  BulkOperationError,
14
14
  ConnectionError,
15
15
  AuthenticationError,
16
- ValidationError,
16
+ DataApiValidationError,
17
17
  createErrorFromResponse,
18
18
  isObjectStackError,
19
19
  isErrorType,
@@ -189,20 +189,20 @@ describe('Error Classes', () => {
189
189
  });
190
190
  });
191
191
 
192
- describe('ValidationError', () => {
192
+ describe('DataApiValidationError', () => {
193
193
  it('should create validation error', () => {
194
- const error = new ValidationError('Invalid input');
194
+ const error = new DataApiValidationError('Invalid input');
195
195
 
196
196
  expect(error.message).toBe('Invalid input');
197
197
  expect(error.code).toBe('VALIDATION_ERROR');
198
198
  expect(error.statusCode).toBe(400);
199
199
  expect(error.name).toBe('ValidationError');
200
200
  expect(error).toBeInstanceOf(ObjectStackError);
201
- expect(error).toBeInstanceOf(ValidationError);
201
+ expect(error).toBeInstanceOf(DataApiValidationError);
202
202
  });
203
203
 
204
204
  it('should include field information', () => {
205
- const error = new ValidationError('Email is invalid', 'email');
205
+ const error = new DataApiValidationError('Email is invalid', 'email');
206
206
 
207
207
  expect(error.field).toBe('email');
208
208
  expect(error.details).toHaveProperty('field', 'email');
@@ -214,7 +214,7 @@ describe('Error Classes', () => {
214
214
  { field: 'age', message: 'Must be a positive number' },
215
215
  ];
216
216
 
217
- const error = new ValidationError(
217
+ const error = new DataApiValidationError(
218
218
  'Validation failed',
219
219
  undefined,
220
220
  validationErrors
@@ -225,7 +225,7 @@ describe('Error Classes', () => {
225
225
  });
226
226
 
227
227
  it('should return empty array when no validation errors', () => {
228
- const error = new ValidationError('Validation failed');
228
+ const error = new DataApiValidationError('Validation failed');
229
229
 
230
230
  expect(error.getValidationErrors()).toEqual([]);
231
231
  });
@@ -286,7 +286,7 @@ describe('Error Helpers', () => {
286
286
  expect(error.code).toBe('NOT_FOUND');
287
287
  });
288
288
 
289
- it('should create ValidationError for 400 status', () => {
289
+ it('should create DataApiValidationError for 400 status', () => {
290
290
  const response = {
291
291
  status: 400,
292
292
  message: 'Bad request',
@@ -299,9 +299,9 @@ describe('Error Helpers', () => {
299
299
 
300
300
  const error = createErrorFromResponse(response);
301
301
 
302
- expect(error).toBeInstanceOf(ValidationError);
302
+ expect(error).toBeInstanceOf(DataApiValidationError);
303
303
  expect(error.statusCode).toBe(400);
304
- expect((error as ValidationError).validationErrors).toEqual([
304
+ expect((error as DataApiValidationError).validationErrors).toEqual([
305
305
  { field: 'email', message: 'Invalid email' },
306
306
  ]);
307
307
  });
@@ -379,7 +379,7 @@ describe('Error Helpers', () => {
379
379
  const bulkError = new BulkOperationError('create', 0, 1, []);
380
380
  const connError = new ConnectionError('timeout');
381
381
  const authError = new AuthenticationError();
382
- const validError = new ValidationError('invalid');
382
+ const validError = new DataApiValidationError('invalid');
383
383
 
384
384
  expect(isObjectStackError(metadataError)).toBe(true);
385
385
  expect(isObjectStackError(bulkError)).toBe(true);
@@ -409,7 +409,7 @@ describe('Error Helpers', () => {
409
409
 
410
410
  it('should return false for non-matching error type', () => {
411
411
  const error = new MetadataNotFoundError('users');
412
- expect(isErrorType(error, ValidationError)).toBe(false);
412
+ expect(isErrorType(error, DataApiValidationError)).toBe(false);
413
413
  });
414
414
 
415
415
  it('should return true for base class check', () => {
package/src/errors.ts CHANGED
@@ -159,12 +159,24 @@ export class AuthenticationError extends ObjectStackError {
159
159
  }
160
160
 
161
161
  /**
162
- * Error thrown when data validation fails
162
+ * Error thrown when the ObjectStack data API rejects a write as invalid.
163
+ *
164
+ * NOT the spec's `ValidationError` (`@objectstack/spec/kernel`), whose name this
165
+ * class wore until objectui#3160 (objectstack#4115 ledger batch 6). That one is
166
+ * a plain DATA SHAPE — `{ field, message, code? }`, one entry in a plugin
167
+ * manifest's validation report — and `@object-ui/types` re-exports it under that
168
+ * name. This is a runtime `Error` subclass carrying an HTTP status plus a list
169
+ * of such entries, so the two are not even the same KIND of thing.
170
+ *
171
+ * The name follows the convention registered on objectstack#4115 for this
172
+ * family — `<what was validated>Validation<Error|Result>`. `@object-ui/core`
173
+ * took `SchemaNodeValidationError` for its SDUI-tree walk; this one belongs to
174
+ * the data API.
163
175
  */
164
- export class ValidationError extends ObjectStackError {
176
+ export class DataApiValidationError extends ObjectStackError {
165
177
  /**
166
- * Create a new ValidationError
167
- *
178
+ * Create a new DataApiValidationError
179
+ *
168
180
  * @param message - Human-readable error message
169
181
  * @param field - The field that failed validation (optional)
170
182
  * @param validationErrors - Array of validation error details
@@ -186,6 +198,12 @@ export class ValidationError extends ObjectStackError {
186
198
  ...details,
187
199
  }
188
200
  );
201
+ // The RUNTIME name stays `'ValidationError'` on purpose — it is the wire
202
+ // discriminator this adapter shares with `@objectstack/client` and with
203
+ // consumers that sniff `err.name` rather than `instanceof`
204
+ // (`normaliseClientError` here, `@object-ui/react`'s `error-message`).
205
+ // Renaming the TypeScript symbol is a source-level rename; renaming this
206
+ // string would be a behaviour change nobody asked for.
189
207
  this.name = 'ValidationError';
190
208
  }
191
209
 
@@ -232,7 +250,7 @@ export function createErrorFromResponse(response: Record<string, unknown>, conte
232
250
  return new ObjectStackError(message, 'NOT_FOUND', 404, details);
233
251
 
234
252
  case 400:
235
- return new ValidationError(message, undefined, (response?.data as Record<string, unknown>)?.errors as Array<{ field: string; message: string }>, details);
253
+ return new DataApiValidationError(message, undefined, (response?.data as Record<string, unknown>)?.errors as Array<{ field: string; message: string }>, details);
236
254
 
237
255
  case 503:
238
256
  return new ConnectionError(message, (response?.config as Record<string, unknown>)?.url as string, details, 503);
@@ -97,3 +97,45 @@ describe('ObjectStackAdapter.exportDownload', () => {
97
97
  });
98
98
  });
99
99
  });
100
+
101
+ /**
102
+ * `search` — the half of a list this request could not carry.
103
+ *
104
+ * The route mirrored `filter` and `orderby` only, so an export taken while a
105
+ * search was active downloaded the UNSEARCHED superset: more rows than the
106
+ * screen showed, in a file that looks authoritative. Server half:
107
+ * objectstack#4230.
108
+ */
109
+ describe('ObjectStackAdapter.exportDownload — search', () => {
110
+ const paramsFor = async (request: Record<string, unknown>) => {
111
+ const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) => csvResponse());
112
+ await makeDS(fetchImpl).exportDownload('task', request);
113
+ return new URL(fetchImpl.mock.calls[0][0]).searchParams;
114
+ };
115
+
116
+ it('sends the term as `search`', async () => {
117
+ expect((await paramsFor({ search: 'acme' })).get('search')).toBe('acme');
118
+ });
119
+
120
+ it('sends `searchFields` as a comma list alongside the term', async () => {
121
+ const p = await paramsFor({ search: 'acme', searchFields: ['name', 'stage'] });
122
+ expect(p.get('searchFields')).toBe('name,stage');
123
+ });
124
+
125
+ it('omits both when no term is given — `searchFields` alone means nothing', async () => {
126
+ const p = await paramsFor({ searchFields: ['name'] });
127
+ expect(p.get('search')).toBeNull();
128
+ expect(p.get('searchFields')).toBeNull();
129
+ });
130
+
131
+ it('treats a whitespace-only term as absent, and trims a real one', async () => {
132
+ expect((await paramsFor({ search: ' ' })).get('search')).toBeNull();
133
+ expect((await paramsFor({ search: ' acme ' })).get('search')).toBe('acme');
134
+ });
135
+
136
+ it('carries search and filter together — neither replaces the other', async () => {
137
+ const p = await paramsFor({ search: 'acme', filter: [['status', '=', 'open']] });
138
+ expect(p.get('search')).toBe('acme');
139
+ expect(JSON.parse(p.get('filter')!)).toEqual([['status', '=', 'open']]);
140
+ });
141
+ });