@object-ui/core 3.3.0 → 3.3.1

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 (99) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +20 -1
  3. package/dist/actions/ActionRunner.d.ts +9 -0
  4. package/dist/actions/ActionRunner.js +41 -4
  5. package/dist/adapters/ValueDataSource.js +3 -1
  6. package/dist/utils/filter-converter.js +25 -5
  7. package/package.json +32 -8
  8. package/.turbo/turbo-build.log +0 -4
  9. package/src/__benchmarks__/core.bench.ts +0 -64
  10. package/src/__tests__/protocols/DndProtocol.test.ts +0 -186
  11. package/src/__tests__/protocols/KeyboardProtocol.test.ts +0 -177
  12. package/src/__tests__/protocols/NotificationProtocol.test.ts +0 -142
  13. package/src/__tests__/protocols/ResponsiveProtocol.test.ts +0 -176
  14. package/src/__tests__/protocols/SharingProtocol.test.ts +0 -188
  15. package/src/actions/ActionEngine.ts +0 -268
  16. package/src/actions/ActionRunner.ts +0 -717
  17. package/src/actions/TransactionManager.ts +0 -521
  18. package/src/actions/UndoManager.ts +0 -215
  19. package/src/actions/__tests__/ActionEngine.test.ts +0 -206
  20. package/src/actions/__tests__/ActionRunner.params.test.ts +0 -134
  21. package/src/actions/__tests__/ActionRunner.test.ts +0 -711
  22. package/src/actions/__tests__/TransactionManager.test.ts +0 -447
  23. package/src/actions/__tests__/UndoManager.test.ts +0 -320
  24. package/src/actions/index.ts +0 -12
  25. package/src/adapters/ApiDataSource.ts +0 -376
  26. package/src/adapters/README.md +0 -180
  27. package/src/adapters/ValueDataSource.ts +0 -459
  28. package/src/adapters/__tests__/ApiDataSource.test.ts +0 -418
  29. package/src/adapters/__tests__/ValueDataSource.test.ts +0 -571
  30. package/src/adapters/__tests__/resolveDataSource.test.ts +0 -144
  31. package/src/adapters/index.ts +0 -15
  32. package/src/adapters/resolveDataSource.ts +0 -79
  33. package/src/builder/__tests__/schema-builder.test.ts +0 -235
  34. package/src/builder/schema-builder.ts +0 -584
  35. package/src/data-scope/DataScopeManager.ts +0 -269
  36. package/src/data-scope/ViewDataProvider.ts +0 -282
  37. package/src/data-scope/__tests__/DataScopeManager.test.ts +0 -211
  38. package/src/data-scope/__tests__/ViewDataProvider.test.ts +0 -270
  39. package/src/data-scope/index.ts +0 -24
  40. package/src/errors/__tests__/errors.test.ts +0 -292
  41. package/src/errors/index.ts +0 -269
  42. package/src/evaluator/ExpressionCache.ts +0 -206
  43. package/src/evaluator/ExpressionContext.ts +0 -118
  44. package/src/evaluator/ExpressionEvaluator.ts +0 -315
  45. package/src/evaluator/FormulaFunctions.ts +0 -398
  46. package/src/evaluator/SafeExpressionParser.ts +0 -893
  47. package/src/evaluator/__tests__/ExpressionCache.test.ts +0 -135
  48. package/src/evaluator/__tests__/ExpressionContext.test.ts +0 -110
  49. package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +0 -558
  50. package/src/evaluator/__tests__/FormulaFunctions.test.ts +0 -447
  51. package/src/evaluator/index.ts +0 -13
  52. package/src/index.ts +0 -38
  53. package/src/protocols/DndProtocol.ts +0 -168
  54. package/src/protocols/KeyboardProtocol.ts +0 -181
  55. package/src/protocols/NotificationProtocol.ts +0 -150
  56. package/src/protocols/ResponsiveProtocol.ts +0 -210
  57. package/src/protocols/SharingProtocol.ts +0 -185
  58. package/src/protocols/index.ts +0 -13
  59. package/src/query/__tests__/query-ast.test.ts +0 -211
  60. package/src/query/__tests__/window-functions.test.ts +0 -275
  61. package/src/query/index.ts +0 -7
  62. package/src/query/query-ast.ts +0 -341
  63. package/src/registry/PluginScopeImpl.ts +0 -259
  64. package/src/registry/PluginSystem.ts +0 -206
  65. package/src/registry/Registry.ts +0 -219
  66. package/src/registry/WidgetRegistry.ts +0 -316
  67. package/src/registry/__tests__/PluginSystem.test.ts +0 -309
  68. package/src/registry/__tests__/Registry.test.ts +0 -293
  69. package/src/registry/__tests__/WidgetRegistry.test.ts +0 -321
  70. package/src/registry/__tests__/plugin-scope-integration.test.ts +0 -283
  71. package/src/theme/ThemeEngine.ts +0 -530
  72. package/src/theme/__tests__/ThemeEngine.test.ts +0 -668
  73. package/src/theme/index.ts +0 -24
  74. package/src/types/index.ts +0 -21
  75. package/src/utils/__tests__/debug-collector.test.ts +0 -102
  76. package/src/utils/__tests__/debug.test.ts +0 -134
  77. package/src/utils/__tests__/expand-fields.test.ts +0 -120
  78. package/src/utils/__tests__/extract-records.test.ts +0 -50
  79. package/src/utils/__tests__/filter-converter.test.ts +0 -118
  80. package/src/utils/__tests__/merge-views-into-objects.test.ts +0 -110
  81. package/src/utils/__tests__/normalize-quick-filter.test.ts +0 -123
  82. package/src/utils/debug-collector.ts +0 -100
  83. package/src/utils/debug.ts +0 -148
  84. package/src/utils/expand-fields.ts +0 -76
  85. package/src/utils/extract-records.ts +0 -33
  86. package/src/utils/filter-converter.ts +0 -133
  87. package/src/utils/merge-views-into-objects.ts +0 -36
  88. package/src/utils/normalize-quick-filter.ts +0 -78
  89. package/src/validation/__tests__/object-validation-engine.test.ts +0 -567
  90. package/src/validation/__tests__/schema-validator.test.ts +0 -118
  91. package/src/validation/__tests__/validation-engine.test.ts +0 -102
  92. package/src/validation/index.ts +0 -10
  93. package/src/validation/schema-validator.ts +0 -344
  94. package/src/validation/validation-engine.ts +0 -528
  95. package/src/validation/validators/index.ts +0 -25
  96. package/src/validation/validators/object-validation-engine.ts +0 -722
  97. package/tsconfig.json +0 -15
  98. package/tsconfig.tsbuildinfo +0 -1
  99. package/vitest.config.ts +0 -2
@@ -1,320 +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 { UndoManager, type UndoableOperation } from '../UndoManager';
11
-
12
- function makeOp(id: string, type: 'create' | 'update' | 'delete' = 'update'): UndoableOperation {
13
- return {
14
- id,
15
- type,
16
- objectName: 'Account',
17
- recordId: `rec-${id}`,
18
- timestamp: Date.now(),
19
- description: `op-${id}`,
20
- undoData: { prev: id },
21
- redoData: { next: id },
22
- };
23
- }
24
-
25
- describe('UndoManager', () => {
26
- let manager: UndoManager;
27
-
28
- beforeEach(() => {
29
- manager = new UndoManager({ maxHistory: 5 });
30
- });
31
-
32
- // ---------------------------------------------------------------------------
33
- // Basic push / pop (existing behaviour)
34
- // ---------------------------------------------------------------------------
35
- describe('basic push/pop', () => {
36
- it('pushes and peeks', () => {
37
- const op = makeOp('1');
38
- manager.push(op);
39
- expect(manager.canUndo).toBe(true);
40
- expect(manager.peekUndo()).toEqual(op);
41
- expect(manager.undoCount).toBe(1);
42
- });
43
-
44
- it('popUndo moves to redo stack', () => {
45
- const op = makeOp('1');
46
- manager.push(op);
47
- const popped = manager.popUndo();
48
- expect(popped).toEqual(op);
49
- expect(manager.canUndo).toBe(false);
50
- expect(manager.canRedo).toBe(true);
51
- expect(manager.peekRedo()).toEqual(op);
52
- });
53
-
54
- it('popRedo moves back to undo stack', () => {
55
- manager.push(makeOp('1'));
56
- manager.popUndo();
57
- const redone = manager.popRedo();
58
- expect(redone?.id).toBe('1');
59
- expect(manager.canUndo).toBe(true);
60
- expect(manager.canRedo).toBe(false);
61
- });
62
-
63
- it('push clears redo stack', () => {
64
- manager.push(makeOp('1'));
65
- manager.popUndo();
66
- expect(manager.canRedo).toBe(true);
67
- manager.push(makeOp('2'));
68
- expect(manager.canRedo).toBe(false);
69
- });
70
-
71
- it('trims beyond maxHistory', () => {
72
- for (let i = 0; i < 7; i++) manager.push(makeOp(String(i)));
73
- expect(manager.undoCount).toBe(5);
74
- // Oldest operations should have been shifted out
75
- expect(manager.getHistory()[0].id).toBe('2');
76
- });
77
-
78
- it('clear removes everything', () => {
79
- manager.push(makeOp('1'));
80
- manager.clear();
81
- expect(manager.canUndo).toBe(false);
82
- expect(manager.canRedo).toBe(false);
83
- });
84
-
85
- it('subscribe notifies on changes', () => {
86
- const listener = vi.fn();
87
- const unsub = manager.subscribe(listener);
88
- manager.push(makeOp('1'));
89
- expect(listener).toHaveBeenCalledTimes(1);
90
- unsub();
91
- manager.push(makeOp('2'));
92
- expect(listener).toHaveBeenCalledTimes(1);
93
- });
94
- });
95
-
96
- // ---------------------------------------------------------------------------
97
- // pushBatch
98
- // ---------------------------------------------------------------------------
99
- describe('pushBatch', () => {
100
- it('pushes multiple operations atomically', () => {
101
- const ops = [makeOp('a'), makeOp('b'), makeOp('c')];
102
- manager.pushBatch(ops);
103
- expect(manager.undoCount).toBe(3);
104
- expect(manager.getHistory().map((o) => o.id)).toEqual(['a', 'b', 'c']);
105
- });
106
-
107
- it('clears redo stack', () => {
108
- manager.push(makeOp('1'));
109
- manager.popUndo();
110
- expect(manager.canRedo).toBe(true);
111
- manager.pushBatch([makeOp('x')]);
112
- expect(manager.canRedo).toBe(false);
113
- });
114
-
115
- it('notifies listeners exactly once', () => {
116
- const listener = vi.fn();
117
- manager.subscribe(listener);
118
- manager.pushBatch([makeOp('a'), makeOp('b')]);
119
- expect(listener).toHaveBeenCalledTimes(1);
120
- });
121
-
122
- it('is a no-op for empty array', () => {
123
- const listener = vi.fn();
124
- manager.subscribe(listener);
125
- manager.pushBatch([]);
126
- expect(listener).not.toHaveBeenCalled();
127
- expect(manager.undoCount).toBe(0);
128
- });
129
-
130
- it('trims to maxHistory when batch exceeds limit', () => {
131
- const ops = Array.from({ length: 8 }, (_, i) => makeOp(String(i)));
132
- manager.pushBatch(ops);
133
- expect(manager.undoCount).toBe(5);
134
- // Oldest should be trimmed
135
- expect(manager.getHistory()[0].id).toBe('3');
136
- expect(manager.getHistory()[4].id).toBe('7');
137
- });
138
- });
139
-
140
- // ---------------------------------------------------------------------------
141
- // popUndoBatch / popRedoBatch
142
- // ---------------------------------------------------------------------------
143
- describe('popUndoBatch', () => {
144
- it('pops multiple operations from undo to redo', () => {
145
- manager.pushBatch([makeOp('a'), makeOp('b'), makeOp('c')]);
146
- const popped = manager.popUndoBatch(2);
147
- expect(popped.map((o) => o.id)).toEqual(['b', 'c']);
148
- expect(manager.undoCount).toBe(1);
149
- expect(manager.redoCount).toBe(2);
150
- });
151
-
152
- it('clamps to available stack size', () => {
153
- manager.push(makeOp('1'));
154
- const popped = manager.popUndoBatch(10);
155
- expect(popped).toHaveLength(1);
156
- expect(manager.undoCount).toBe(0);
157
- });
158
-
159
- it('returns empty array when nothing to undo', () => {
160
- expect(manager.popUndoBatch(3)).toEqual([]);
161
- });
162
-
163
- it('notifies listeners once', () => {
164
- manager.pushBatch([makeOp('a'), makeOp('b')]);
165
- const listener = vi.fn();
166
- manager.subscribe(listener);
167
- manager.popUndoBatch(2);
168
- expect(listener).toHaveBeenCalledTimes(1);
169
- });
170
- });
171
-
172
- describe('popRedoBatch', () => {
173
- it('pops multiple operations from redo to undo', () => {
174
- manager.pushBatch([makeOp('a'), makeOp('b'), makeOp('c')]);
175
- manager.popUndoBatch(3);
176
- expect(manager.redoCount).toBe(3);
177
- const redone = manager.popRedoBatch(2);
178
- expect(redone.map((o) => o.id)).toEqual(['b', 'c']);
179
- expect(manager.undoCount).toBe(2);
180
- expect(manager.redoCount).toBe(1);
181
- });
182
-
183
- it('clamps to available stack size', () => {
184
- manager.push(makeOp('1'));
185
- manager.popUndo();
186
- const redone = manager.popRedoBatch(5);
187
- expect(redone).toHaveLength(1);
188
- });
189
-
190
- it('returns empty array when nothing to redo', () => {
191
- expect(manager.popRedoBatch(3)).toEqual([]);
192
- });
193
- });
194
-
195
- // ---------------------------------------------------------------------------
196
- // getHistory / getRedoHistory
197
- // ---------------------------------------------------------------------------
198
- describe('getHistory / getRedoHistory', () => {
199
- it('getHistory returns shallow copy of undo stack', () => {
200
- manager.push(makeOp('1'));
201
- manager.push(makeOp('2'));
202
- const history = manager.getHistory();
203
- expect(history).toHaveLength(2);
204
- // Mutating the copy must not affect the manager
205
- history.pop();
206
- expect(manager.undoCount).toBe(2);
207
- });
208
-
209
- it('getRedoHistory returns shallow copy of redo stack', () => {
210
- manager.push(makeOp('1'));
211
- manager.push(makeOp('2'));
212
- manager.popUndo();
213
- const redoHistory = manager.getRedoHistory();
214
- expect(redoHistory).toHaveLength(1);
215
- expect(redoHistory[0].id).toBe('2');
216
- // Mutating the copy must not affect the manager
217
- redoHistory.pop();
218
- expect(manager.redoCount).toBe(1);
219
- });
220
- });
221
-
222
- // ---------------------------------------------------------------------------
223
- // saveToStorage / loadFromStorage
224
- // ---------------------------------------------------------------------------
225
- describe('persistence', () => {
226
- let storage: Record<string, string>;
227
-
228
- beforeEach(() => {
229
- storage = {};
230
- vi.stubGlobal('localStorage', {
231
- getItem: vi.fn((key: string) => storage[key] ?? null),
232
- setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
233
- removeItem: vi.fn((key: string) => { delete storage[key]; }),
234
- });
235
- });
236
-
237
- it('round-trips undo/redo stacks through localStorage', () => {
238
- manager.push(makeOp('1'));
239
- manager.push(makeOp('2'));
240
- manager.popUndo(); // '2' goes to redo
241
-
242
- manager.saveToStorage();
243
-
244
- const fresh = new UndoManager({ maxHistory: 5 });
245
- fresh.loadFromStorage();
246
-
247
- expect(fresh.undoCount).toBe(1);
248
- expect(fresh.redoCount).toBe(1);
249
- expect(fresh.peekUndo()?.id).toBe('1');
250
- expect(fresh.peekRedo()?.id).toBe('2');
251
- });
252
-
253
- it('preserves all UndoableOperation fields', () => {
254
- const op = makeOp('full');
255
- manager.push(op);
256
- manager.saveToStorage();
257
-
258
- const fresh = new UndoManager();
259
- fresh.loadFromStorage();
260
- const restored = fresh.peekUndo()!;
261
- expect(restored.id).toBe(op.id);
262
- expect(restored.type).toBe(op.type);
263
- expect(restored.objectName).toBe(op.objectName);
264
- expect(restored.recordId).toBe(op.recordId);
265
- expect(restored.timestamp).toBe(op.timestamp);
266
- expect(restored.description).toBe(op.description);
267
- expect(restored.undoData).toEqual(op.undoData);
268
- expect(restored.redoData).toEqual(op.redoData);
269
- });
270
-
271
- it('is a no-op when localStorage has no data', () => {
272
- const fresh = new UndoManager();
273
- fresh.loadFromStorage();
274
- expect(fresh.undoCount).toBe(0);
275
- expect(fresh.redoCount).toBe(0);
276
- });
277
-
278
- it('handles corrupt localStorage data gracefully', () => {
279
- storage['objectui:undo-history'] = 'not-valid-json!!!';
280
- const fresh = new UndoManager();
281
- expect(() => fresh.loadFromStorage()).not.toThrow();
282
- expect(fresh.undoCount).toBe(0);
283
- });
284
-
285
- it('notifies listeners after loading', () => {
286
- manager.push(makeOp('1'));
287
- manager.saveToStorage();
288
-
289
- const fresh = new UndoManager();
290
- const listener = vi.fn();
291
- fresh.subscribe(listener);
292
- fresh.loadFromStorage();
293
- expect(listener).toHaveBeenCalledTimes(1);
294
- });
295
- });
296
-
297
- // ---------------------------------------------------------------------------
298
- // Boundary: batch larger than maxHistory
299
- // ---------------------------------------------------------------------------
300
- describe('boundary: batch larger than maxHistory', () => {
301
- it('trims oldest entries when batch overflows maxHistory', () => {
302
- // maxHistory is 5
303
- manager.push(makeOp('existing'));
304
- const batch = Array.from({ length: 6 }, (_, i) => makeOp(`batch-${i}`));
305
- manager.pushBatch(batch);
306
- expect(manager.undoCount).toBe(5);
307
- // 1 existing + 6 batch = 7 total, trimmed to last 5
308
- const ids = manager.getHistory().map((o) => o.id);
309
- expect(ids).toEqual(['batch-1', 'batch-2', 'batch-3', 'batch-4', 'batch-5']);
310
- });
311
-
312
- it('works when batch itself equals maxHistory', () => {
313
- const batch = Array.from({ length: 5 }, (_, i) => makeOp(`b-${i}`));
314
- manager.pushBatch(batch);
315
- expect(manager.undoCount).toBe(5);
316
- expect(manager.getHistory()[0].id).toBe('b-0');
317
- expect(manager.getHistory()[4].id).toBe('b-4');
318
- });
319
- });
320
- });
@@ -1,12 +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
- export * from './ActionRunner.js';
10
- export * from './ActionEngine.js';
11
- export * from './TransactionManager.js';
12
- export * from './UndoManager.js';
@@ -1,376 +0,0 @@
1
- /**
2
- * ObjectUI — ApiDataSource
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
- * A DataSource adapter for the `provider: 'api'` ViewData mode.
9
- * Makes raw HTTP requests using the HttpRequest configs from ViewData.
10
- */
11
-
12
- import type {
13
- DataSource,
14
- QueryParams,
15
- QueryResult,
16
- HttpRequest,
17
- HttpMethod,
18
- AggregateParams,
19
- AggregateResult,
20
- } from '@object-ui/types';
21
-
22
- // ---------------------------------------------------------------------------
23
- // Configuration
24
- // ---------------------------------------------------------------------------
25
-
26
- export interface ApiDataSourceConfig {
27
- /** HttpRequest config for read operations (find, findOne) */
28
- read?: HttpRequest;
29
- /** HttpRequest config for write operations (create, update, delete) */
30
- write?: HttpRequest;
31
- /** Custom fetch implementation (defaults to globalThis.fetch) */
32
- fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
33
- /** Default headers applied to all requests */
34
- defaultHeaders?: Record<string, string>;
35
- }
36
-
37
- // ---------------------------------------------------------------------------
38
- // Helpers
39
- // ---------------------------------------------------------------------------
40
-
41
- /** Build a full URL with query params */
42
- function buildUrl(
43
- base: string,
44
- pathSuffix?: string,
45
- queryParams?: Record<string, unknown>,
46
- ): string {
47
- let url = base;
48
- if (pathSuffix) {
49
- url = url.replace(/\/+$/, '') + '/' + pathSuffix.replace(/^\/+/, '');
50
- }
51
-
52
- if (queryParams && Object.keys(queryParams).length > 0) {
53
- const search = new URLSearchParams();
54
- for (const [key, value] of Object.entries(queryParams)) {
55
- if (value !== undefined && value !== null) {
56
- search.set(key, String(value));
57
- }
58
- }
59
- const qs = search.toString();
60
- if (qs) {
61
- url += (url.includes('?') ? '&' : '?') + qs;
62
- }
63
- }
64
-
65
- return url;
66
- }
67
-
68
- /** Convert QueryParams to flat query string params */
69
- function queryParamsToRecord(params?: QueryParams): Record<string, unknown> {
70
- if (!params) return {};
71
-
72
- const out: Record<string, unknown> = {};
73
-
74
- if (params.$select?.length) {
75
- out.$select = params.$select.join(',');
76
- }
77
- if (params.$filter && Object.keys(params.$filter).length > 0) {
78
- out.$filter = JSON.stringify(params.$filter);
79
- }
80
- if (params.$orderby) {
81
- if (Array.isArray(params.$orderby)) {
82
- if (typeof params.$orderby[0] === 'string') {
83
- out.$orderby = (params.$orderby as string[]).join(',');
84
- } else {
85
- out.$orderby = (params.$orderby as Array<{ field: string; order?: string }>)
86
- .map((s) => `${s.field} ${s.order || 'asc'}`)
87
- .join(',');
88
- }
89
- } else {
90
- out.$orderby = Object.entries(params.$orderby)
91
- .map(([field, order]) => `${field} ${order}`)
92
- .join(',');
93
- }
94
- }
95
- if (params.$skip !== undefined) out.$skip = params.$skip;
96
- if (params.$top !== undefined) out.$top = params.$top;
97
- if (params.$expand?.length) out.$expand = params.$expand.join(',');
98
- if (params.$search) out.$search = params.$search;
99
- if (params.$count) out.$count = 'true';
100
-
101
- return out;
102
- }
103
-
104
- /** Merge two header objects, giving priority to the second */
105
- function mergeHeaders(
106
- base?: Record<string, string>,
107
- override?: Record<string, string>,
108
- ): Record<string, string> {
109
- return { ...base, ...override };
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // ApiDataSource
114
- // ---------------------------------------------------------------------------
115
-
116
- /**
117
- * ApiDataSource — a DataSource adapter for raw HTTP APIs.
118
- *
119
- * Used when `ViewData.provider === 'api'`. The read and write HttpRequest
120
- * configs define the endpoints; all CRUD methods map onto HTTP verbs.
121
- *
122
- * Read operations use the `read` config, write operations use the `write` config.
123
- * Both fall back to each other when one is not provided.
124
- *
125
- * @example
126
- * ```ts
127
- * const ds = new ApiDataSource({
128
- * read: { url: '/api/contacts', method: 'GET' },
129
- * write: { url: '/api/contacts', method: 'POST' },
130
- * });
131
- *
132
- * const result = await ds.find('contacts', { $top: 10 });
133
- * ```
134
- */
135
- export class ApiDataSource<T = any> implements DataSource<T> {
136
- private readConfig: HttpRequest | undefined;
137
- private writeConfig: HttpRequest | undefined;
138
- private fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
139
- private defaultHeaders: Record<string, string>;
140
-
141
- constructor(config: ApiDataSourceConfig) {
142
- this.readConfig = config.read;
143
- this.writeConfig = config.write;
144
- this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
145
- this.defaultHeaders = config.defaultHeaders ?? {};
146
- }
147
-
148
- // -----------------------------------------------------------------------
149
- // Internal request executor
150
- // -----------------------------------------------------------------------
151
-
152
- private async request<R = any>(
153
- base: HttpRequest | undefined,
154
- options: {
155
- pathSuffix?: string;
156
- method?: HttpMethod;
157
- queryParams?: Record<string, unknown>;
158
- body?: unknown;
159
- headers?: Record<string, string>;
160
- } = {},
161
- ): Promise<R> {
162
- if (!base) {
163
- throw new Error(
164
- 'ApiDataSource: No HTTP configuration provided for this operation. ' +
165
- 'Ensure the ViewData has read/write configs.',
166
- );
167
- }
168
-
169
- const method = options.method ?? base.method ?? 'GET';
170
-
171
- // Merge query params: base.params + extra queryParams
172
- const allQuery = {
173
- ...(base.params as Record<string, unknown> | undefined),
174
- ...options.queryParams,
175
- };
176
-
177
- const url = buildUrl(base.url, options.pathSuffix, allQuery);
178
-
179
- const headers = mergeHeaders(
180
- mergeHeaders(this.defaultHeaders, base.headers),
181
- options.headers,
182
- );
183
-
184
- const init: RequestInit = {
185
- method,
186
- headers,
187
- };
188
-
189
- // Attach body for non-GET methods
190
- if (options.body !== undefined && method !== 'GET') {
191
- if (
192
- options.body instanceof FormData ||
193
- options.body instanceof Blob
194
- ) {
195
- init.body = options.body as FormData | Blob;
196
- } else if (typeof options.body === 'string') {
197
- init.body = options.body;
198
- if (!headers['Content-Type']) {
199
- headers['Content-Type'] = 'text/plain';
200
- }
201
- } else {
202
- init.body = JSON.stringify(options.body);
203
- if (!headers['Content-Type']) {
204
- headers['Content-Type'] = 'application/json';
205
- }
206
- }
207
- }
208
-
209
- const response = await this.fetchFn(url, init);
210
-
211
- if (!response.ok) {
212
- const text = await response.text().catch(() => '');
213
- throw new Error(
214
- `ApiDataSource: HTTP ${response.status} ${response.statusText} — ${text}`,
215
- );
216
- }
217
-
218
- // Try to parse as JSON; fall back to empty object
219
- const contentType = response.headers.get('content-type') ?? '';
220
- if (contentType.includes('application/json')) {
221
- return response.json();
222
- }
223
-
224
- // Non-JSON response — return text wrapped in an object
225
- const text = await response.text();
226
- return text as unknown as R;
227
- }
228
-
229
- // -----------------------------------------------------------------------
230
- // DataSource interface
231
- // -----------------------------------------------------------------------
232
-
233
- async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
234
- const queryParams = queryParamsToRecord(params);
235
- const raw = await this.request<any>(this.readConfig, {
236
- method: 'GET',
237
- queryParams,
238
- });
239
-
240
- // Normalize: the API might return an array, an object with `data`, or a QueryResult
241
- return this.normalizeQueryResult(raw);
242
- }
243
-
244
- async findOne(_resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
245
- try {
246
- const queryParams = queryParamsToRecord(params);
247
- const raw = await this.request<T>(this.readConfig, {
248
- pathSuffix: String(id),
249
- method: 'GET',
250
- queryParams,
251
- });
252
- return raw ?? null;
253
- } catch {
254
- return null;
255
- }
256
- }
257
-
258
- async create(_resource: string, data: Partial<T>): Promise<T> {
259
- const config = this.writeConfig ?? this.readConfig;
260
- return this.request<T>(config, {
261
- method: 'POST',
262
- body: data,
263
- });
264
- }
265
-
266
- async update(_resource: string, id: string | number, data: Partial<T>): Promise<T> {
267
- const config = this.writeConfig ?? this.readConfig;
268
- return this.request<T>(config, {
269
- pathSuffix: String(id),
270
- method: 'PATCH',
271
- body: data,
272
- });
273
- }
274
-
275
- async delete(_resource: string, id: string | number): Promise<boolean> {
276
- const config = this.writeConfig ?? this.readConfig;
277
- try {
278
- await this.request(config, {
279
- pathSuffix: String(id),
280
- method: 'DELETE',
281
- });
282
- return true;
283
- } catch {
284
- return false;
285
- }
286
- }
287
-
288
- async getObjectSchema(_objectName: string): Promise<any> {
289
- // Generic API endpoints typically don't expose schema metadata.
290
- // Return a minimal stub so schema-dependent components don't crash.
291
- return { name: _objectName, fields: {} };
292
- }
293
-
294
- async getView(_objectName: string, _viewId: string): Promise<any | null> {
295
- return null;
296
- }
297
-
298
- async getApp(_appId: string): Promise<any | null> {
299
- return null;
300
- }
301
-
302
- async aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]> {
303
- const queryParams: Record<string, unknown> = {
304
- field: params.field,
305
- function: params.function,
306
- groupBy: params.groupBy,
307
- };
308
- if (params.filter) {
309
- queryParams.filter = typeof params.filter === 'string'
310
- ? params.filter
311
- : JSON.stringify(params.filter);
312
- }
313
-
314
- const raw = await this.request<any>(this.readConfig, {
315
- pathSuffix: 'aggregate',
316
- method: 'GET',
317
- queryParams,
318
- });
319
-
320
- // Normalize: the API might return an array or an object with data/results
321
- if (Array.isArray(raw)) return raw;
322
- if (raw?.data && Array.isArray(raw.data)) return raw.data;
323
- if (raw?.results && Array.isArray(raw.results)) return raw.results;
324
- return [];
325
- }
326
-
327
- // -----------------------------------------------------------------------
328
- // Helpers
329
- // -----------------------------------------------------------------------
330
-
331
- /**
332
- * Normalize various API response shapes into a QueryResult.
333
- *
334
- * Supported shapes:
335
- * - `T[]` → wrap in QueryResult
336
- * - `{ data: T[] }` → extract data
337
- * - `{ items: T[] }` → extract items
338
- * - `{ results: T[] }` → extract results
339
- * - `{ records: T[] }` → extract records (Salesforce-style)
340
- * - `{ value: T[] }` → extract value (OData-style)
341
- * - Full QueryResult (has data + totalCount) → return as-is
342
- */
343
- private normalizeQueryResult(raw: any): QueryResult<T> {
344
- if (Array.isArray(raw)) {
345
- return { data: raw, total: raw.length };
346
- }
347
-
348
- if (raw && typeof raw === 'object') {
349
- // Already a QueryResult
350
- if (Array.isArray(raw.data) && ('total' in raw || 'totalCount' in raw)) {
351
- return {
352
- data: raw.data,
353
- total: raw.total ?? raw.totalCount ?? raw.data.length,
354
- hasMore: raw.hasMore,
355
- cursor: raw.cursor,
356
- };
357
- }
358
-
359
- // Common envelope patterns
360
- for (const key of ['data', 'items', 'results', 'records', 'value']) {
361
- if (Array.isArray(raw[key])) {
362
- return {
363
- data: raw[key],
364
- total: raw.total ?? raw.totalCount ?? raw.count ?? raw[key].length,
365
- hasMore: raw.hasMore ?? raw.hasNextPage,
366
- };
367
- }
368
- }
369
-
370
- // Single-object response — wrap as array
371
- return { data: [raw as T], total: 1 };
372
- }
373
-
374
- return { data: [], total: 0 };
375
- }
376
- }