@object-ui/core 3.1.5 → 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 (110) hide show
  1. package/CHANGELOG.md +18 -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.d.ts +5 -1
  6. package/dist/adapters/ValueDataSource.js +30 -1
  7. package/dist/errors/index.js +2 -3
  8. package/dist/evaluator/ExpressionCache.d.ts +9 -10
  9. package/dist/evaluator/ExpressionCache.js +29 -8
  10. package/dist/evaluator/SafeExpressionParser.d.ts +131 -0
  11. package/dist/evaluator/SafeExpressionParser.js +851 -0
  12. package/dist/evaluator/index.d.ts +1 -0
  13. package/dist/evaluator/index.js +1 -0
  14. package/dist/protocols/DndProtocol.js +2 -14
  15. package/dist/protocols/KeyboardProtocol.js +1 -4
  16. package/dist/protocols/NotificationProtocol.js +3 -13
  17. package/dist/utils/debug.js +2 -1
  18. package/dist/utils/filter-converter.js +25 -5
  19. package/package.json +33 -9
  20. package/.turbo/turbo-build.log +0 -4
  21. package/src/__benchmarks__/core.bench.ts +0 -64
  22. package/src/__tests__/protocols/DndProtocol.test.ts +0 -186
  23. package/src/__tests__/protocols/KeyboardProtocol.test.ts +0 -177
  24. package/src/__tests__/protocols/NotificationProtocol.test.ts +0 -142
  25. package/src/__tests__/protocols/ResponsiveProtocol.test.ts +0 -176
  26. package/src/__tests__/protocols/SharingProtocol.test.ts +0 -188
  27. package/src/actions/ActionEngine.ts +0 -268
  28. package/src/actions/ActionRunner.ts +0 -717
  29. package/src/actions/TransactionManager.ts +0 -521
  30. package/src/actions/UndoManager.ts +0 -215
  31. package/src/actions/__tests__/ActionEngine.test.ts +0 -206
  32. package/src/actions/__tests__/ActionRunner.params.test.ts +0 -134
  33. package/src/actions/__tests__/ActionRunner.test.ts +0 -711
  34. package/src/actions/__tests__/TransactionManager.test.ts +0 -447
  35. package/src/actions/__tests__/UndoManager.test.ts +0 -320
  36. package/src/actions/index.ts +0 -12
  37. package/src/adapters/ApiDataSource.ts +0 -376
  38. package/src/adapters/README.md +0 -180
  39. package/src/adapters/ValueDataSource.ts +0 -438
  40. package/src/adapters/__tests__/ApiDataSource.test.ts +0 -418
  41. package/src/adapters/__tests__/ValueDataSource.test.ts +0 -472
  42. package/src/adapters/__tests__/resolveDataSource.test.ts +0 -144
  43. package/src/adapters/index.ts +0 -15
  44. package/src/adapters/resolveDataSource.ts +0 -79
  45. package/src/builder/__tests__/schema-builder.test.ts +0 -235
  46. package/src/builder/schema-builder.ts +0 -584
  47. package/src/data-scope/DataScopeManager.ts +0 -269
  48. package/src/data-scope/ViewDataProvider.ts +0 -282
  49. package/src/data-scope/__tests__/DataScopeManager.test.ts +0 -211
  50. package/src/data-scope/__tests__/ViewDataProvider.test.ts +0 -270
  51. package/src/data-scope/index.ts +0 -24
  52. package/src/errors/__tests__/errors.test.ts +0 -292
  53. package/src/errors/index.ts +0 -270
  54. package/src/evaluator/ExpressionCache.ts +0 -192
  55. package/src/evaluator/ExpressionContext.ts +0 -118
  56. package/src/evaluator/ExpressionEvaluator.ts +0 -315
  57. package/src/evaluator/FormulaFunctions.ts +0 -398
  58. package/src/evaluator/__tests__/ExpressionCache.test.ts +0 -135
  59. package/src/evaluator/__tests__/ExpressionContext.test.ts +0 -110
  60. package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +0 -131
  61. package/src/evaluator/__tests__/FormulaFunctions.test.ts +0 -447
  62. package/src/evaluator/index.ts +0 -12
  63. package/src/index.ts +0 -38
  64. package/src/protocols/DndProtocol.ts +0 -184
  65. package/src/protocols/KeyboardProtocol.ts +0 -185
  66. package/src/protocols/NotificationProtocol.ts +0 -159
  67. package/src/protocols/ResponsiveProtocol.ts +0 -210
  68. package/src/protocols/SharingProtocol.ts +0 -185
  69. package/src/protocols/index.ts +0 -13
  70. package/src/query/__tests__/query-ast.test.ts +0 -211
  71. package/src/query/__tests__/window-functions.test.ts +0 -275
  72. package/src/query/index.ts +0 -7
  73. package/src/query/query-ast.ts +0 -341
  74. package/src/registry/PluginScopeImpl.ts +0 -259
  75. package/src/registry/PluginSystem.ts +0 -206
  76. package/src/registry/Registry.ts +0 -219
  77. package/src/registry/WidgetRegistry.ts +0 -316
  78. package/src/registry/__tests__/PluginSystem.test.ts +0 -309
  79. package/src/registry/__tests__/Registry.test.ts +0 -293
  80. package/src/registry/__tests__/WidgetRegistry.test.ts +0 -321
  81. package/src/registry/__tests__/plugin-scope-integration.test.ts +0 -283
  82. package/src/theme/ThemeEngine.ts +0 -530
  83. package/src/theme/__tests__/ThemeEngine.test.ts +0 -668
  84. package/src/theme/index.ts +0 -24
  85. package/src/types/index.ts +0 -21
  86. package/src/utils/__tests__/debug-collector.test.ts +0 -102
  87. package/src/utils/__tests__/debug.test.ts +0 -134
  88. package/src/utils/__tests__/expand-fields.test.ts +0 -120
  89. package/src/utils/__tests__/extract-records.test.ts +0 -50
  90. package/src/utils/__tests__/filter-converter.test.ts +0 -118
  91. package/src/utils/__tests__/merge-views-into-objects.test.ts +0 -110
  92. package/src/utils/__tests__/normalize-quick-filter.test.ts +0 -123
  93. package/src/utils/debug-collector.ts +0 -100
  94. package/src/utils/debug.ts +0 -147
  95. package/src/utils/expand-fields.ts +0 -76
  96. package/src/utils/extract-records.ts +0 -33
  97. package/src/utils/filter-converter.ts +0 -133
  98. package/src/utils/merge-views-into-objects.ts +0 -36
  99. package/src/utils/normalize-quick-filter.ts +0 -78
  100. package/src/validation/__tests__/object-validation-engine.test.ts +0 -567
  101. package/src/validation/__tests__/schema-validator.test.ts +0 -118
  102. package/src/validation/__tests__/validation-engine.test.ts +0 -102
  103. package/src/validation/index.ts +0 -10
  104. package/src/validation/schema-validator.ts +0 -344
  105. package/src/validation/validation-engine.ts +0 -528
  106. package/src/validation/validators/index.ts +0 -25
  107. package/src/validation/validators/object-validation-engine.ts +0 -722
  108. package/tsconfig.json +0 -15
  109. package/tsconfig.tsbuildinfo +0 -1
  110. 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
- }