@object-ui/core 3.3.0 → 3.3.2

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