@object-ui/core 0.3.1 → 2.0.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.
Files changed (118) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +11 -0
  3. package/dist/actions/ActionRunner.d.ts +228 -4
  4. package/dist/actions/ActionRunner.js +397 -45
  5. package/dist/actions/TransactionManager.d.ts +193 -0
  6. package/dist/actions/TransactionManager.js +410 -0
  7. package/dist/actions/index.d.ts +2 -1
  8. package/dist/actions/index.js +2 -1
  9. package/dist/adapters/ApiDataSource.d.ts +69 -0
  10. package/dist/adapters/ApiDataSource.js +293 -0
  11. package/dist/adapters/ValueDataSource.d.ts +55 -0
  12. package/dist/adapters/ValueDataSource.js +287 -0
  13. package/dist/adapters/index.d.ts +3 -0
  14. package/dist/adapters/index.js +5 -2
  15. package/dist/adapters/resolveDataSource.d.ts +40 -0
  16. package/dist/adapters/resolveDataSource.js +59 -0
  17. package/dist/data-scope/DataScopeManager.d.ts +127 -0
  18. package/dist/data-scope/DataScopeManager.js +229 -0
  19. package/dist/data-scope/index.d.ts +10 -0
  20. package/dist/data-scope/index.js +10 -0
  21. package/dist/evaluator/ExpressionCache.d.ts +101 -0
  22. package/dist/evaluator/ExpressionCache.js +135 -0
  23. package/dist/evaluator/ExpressionEvaluator.d.ts +30 -2
  24. package/dist/evaluator/ExpressionEvaluator.js +60 -16
  25. package/dist/evaluator/FormulaFunctions.d.ts +58 -0
  26. package/dist/evaluator/FormulaFunctions.js +350 -0
  27. package/dist/evaluator/index.d.ts +4 -2
  28. package/dist/evaluator/index.js +4 -2
  29. package/dist/index.d.ts +14 -7
  30. package/dist/index.js +13 -9
  31. package/dist/query/index.d.ts +6 -0
  32. package/dist/query/index.js +6 -0
  33. package/dist/query/query-ast.d.ts +32 -0
  34. package/dist/query/query-ast.js +268 -0
  35. package/dist/registry/PluginScopeImpl.d.ts +80 -0
  36. package/dist/registry/PluginScopeImpl.js +243 -0
  37. package/dist/registry/PluginSystem.d.ts +66 -0
  38. package/dist/registry/PluginSystem.js +142 -0
  39. package/dist/registry/Registry.d.ts +83 -4
  40. package/dist/registry/Registry.js +113 -7
  41. package/dist/registry/WidgetRegistry.d.ts +120 -0
  42. package/dist/registry/WidgetRegistry.js +275 -0
  43. package/dist/theme/ThemeEngine.d.ts +82 -0
  44. package/dist/theme/ThemeEngine.js +400 -0
  45. package/dist/theme/index.d.ts +8 -0
  46. package/dist/theme/index.js +8 -0
  47. package/dist/validation/index.d.ts +9 -0
  48. package/dist/validation/index.js +9 -0
  49. package/dist/validation/validation-engine.d.ts +88 -0
  50. package/dist/validation/validation-engine.js +428 -0
  51. package/dist/validation/validators/index.d.ts +16 -0
  52. package/dist/validation/validators/index.js +16 -0
  53. package/dist/validation/validators/object-validation-engine.d.ts +118 -0
  54. package/dist/validation/validators/object-validation-engine.js +538 -0
  55. package/package.json +14 -5
  56. package/src/actions/ActionRunner.ts +577 -55
  57. package/src/actions/TransactionManager.ts +521 -0
  58. package/src/actions/__tests__/ActionRunner.params.test.ts +134 -0
  59. package/src/actions/__tests__/ActionRunner.test.ts +711 -0
  60. package/src/actions/__tests__/TransactionManager.test.ts +447 -0
  61. package/src/actions/index.ts +2 -1
  62. package/src/adapters/ApiDataSource.ts +349 -0
  63. package/src/adapters/ValueDataSource.ts +332 -0
  64. package/src/adapters/__tests__/ApiDataSource.test.ts +418 -0
  65. package/src/adapters/__tests__/ValueDataSource.test.ts +325 -0
  66. package/src/adapters/__tests__/resolveDataSource.test.ts +144 -0
  67. package/src/adapters/index.ts +6 -1
  68. package/src/adapters/resolveDataSource.ts +79 -0
  69. package/src/builder/__tests__/schema-builder.test.ts +235 -0
  70. package/src/data-scope/DataScopeManager.ts +269 -0
  71. package/src/data-scope/__tests__/DataScopeManager.test.ts +211 -0
  72. package/src/data-scope/index.ts +16 -0
  73. package/src/evaluator/ExpressionCache.ts +192 -0
  74. package/src/evaluator/ExpressionEvaluator.ts +61 -16
  75. package/src/evaluator/FormulaFunctions.ts +398 -0
  76. package/src/evaluator/__tests__/ExpressionCache.test.ts +135 -0
  77. package/src/evaluator/__tests__/ExpressionContext.test.ts +110 -0
  78. package/src/evaluator/__tests__/FormulaFunctions.test.ts +447 -0
  79. package/src/evaluator/index.ts +4 -2
  80. package/src/index.ts +14 -10
  81. package/src/query/__tests__/query-ast.test.ts +211 -0
  82. package/src/query/__tests__/window-functions.test.ts +275 -0
  83. package/src/query/index.ts +7 -0
  84. package/src/query/query-ast.ts +341 -0
  85. package/src/registry/PluginScopeImpl.ts +259 -0
  86. package/src/registry/PluginSystem.ts +161 -0
  87. package/src/registry/Registry.ts +136 -8
  88. package/src/registry/WidgetRegistry.ts +316 -0
  89. package/src/registry/__tests__/PluginSystem.test.ts +226 -0
  90. package/src/registry/__tests__/Registry.test.ts +293 -0
  91. package/src/registry/__tests__/WidgetRegistry.test.ts +321 -0
  92. package/src/registry/__tests__/plugin-scope-integration.test.ts +283 -0
  93. package/src/theme/ThemeEngine.ts +452 -0
  94. package/src/theme/__tests__/ThemeEngine.test.ts +606 -0
  95. package/src/theme/index.ts +22 -0
  96. package/src/validation/__tests__/object-validation-engine.test.ts +567 -0
  97. package/src/validation/__tests__/schema-validator.test.ts +118 -0
  98. package/src/validation/__tests__/validation-engine.test.ts +102 -0
  99. package/src/validation/index.ts +10 -0
  100. package/src/validation/validation-engine.ts +520 -0
  101. package/src/validation/validators/index.ts +25 -0
  102. package/src/validation/validators/object-validation-engine.ts +722 -0
  103. package/tsconfig.tsbuildinfo +1 -1
  104. package/vitest.config.ts +2 -0
  105. package/src/adapters/index.d.ts +0 -8
  106. package/src/adapters/index.js +0 -10
  107. package/src/builder/schema-builder.d.ts +0 -294
  108. package/src/builder/schema-builder.js +0 -503
  109. package/src/index.d.ts +0 -13
  110. package/src/index.js +0 -16
  111. package/src/registry/Registry.d.ts +0 -56
  112. package/src/registry/Registry.js +0 -43
  113. package/src/types/index.d.ts +0 -19
  114. package/src/types/index.js +0 -8
  115. package/src/utils/filter-converter.d.ts +0 -57
  116. package/src/utils/filter-converter.js +0 -100
  117. package/src/validation/schema-validator.d.ts +0 -94
  118. package/src/validation/schema-validator.js +0 -278
@@ -0,0 +1,349 @@
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
+ } from '@object-ui/types';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Configuration
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface ApiDataSourceConfig {
25
+ /** HttpRequest config for read operations (find, findOne) */
26
+ read?: HttpRequest;
27
+ /** HttpRequest config for write operations (create, update, delete) */
28
+ write?: HttpRequest;
29
+ /** Custom fetch implementation (defaults to globalThis.fetch) */
30
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
31
+ /** Default headers applied to all requests */
32
+ defaultHeaders?: Record<string, string>;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Build a full URL with query params */
40
+ function buildUrl(
41
+ base: string,
42
+ pathSuffix?: string,
43
+ queryParams?: Record<string, unknown>,
44
+ ): string {
45
+ let url = base;
46
+ if (pathSuffix) {
47
+ url = url.replace(/\/+$/, '') + '/' + pathSuffix.replace(/^\/+/, '');
48
+ }
49
+
50
+ if (queryParams && Object.keys(queryParams).length > 0) {
51
+ const search = new URLSearchParams();
52
+ for (const [key, value] of Object.entries(queryParams)) {
53
+ if (value !== undefined && value !== null) {
54
+ search.set(key, String(value));
55
+ }
56
+ }
57
+ const qs = search.toString();
58
+ if (qs) {
59
+ url += (url.includes('?') ? '&' : '?') + qs;
60
+ }
61
+ }
62
+
63
+ return url;
64
+ }
65
+
66
+ /** Convert QueryParams to flat query string params */
67
+ function queryParamsToRecord(params?: QueryParams): Record<string, unknown> {
68
+ if (!params) return {};
69
+
70
+ const out: Record<string, unknown> = {};
71
+
72
+ if (params.$select?.length) {
73
+ out.$select = params.$select.join(',');
74
+ }
75
+ if (params.$filter && Object.keys(params.$filter).length > 0) {
76
+ out.$filter = JSON.stringify(params.$filter);
77
+ }
78
+ if (params.$orderby) {
79
+ if (Array.isArray(params.$orderby)) {
80
+ if (typeof params.$orderby[0] === 'string') {
81
+ out.$orderby = (params.$orderby as string[]).join(',');
82
+ } else {
83
+ out.$orderby = (params.$orderby as Array<{ field: string; order?: string }>)
84
+ .map((s) => `${s.field} ${s.order || 'asc'}`)
85
+ .join(',');
86
+ }
87
+ } else {
88
+ out.$orderby = Object.entries(params.$orderby)
89
+ .map(([field, order]) => `${field} ${order}`)
90
+ .join(',');
91
+ }
92
+ }
93
+ if (params.$skip !== undefined) out.$skip = params.$skip;
94
+ if (params.$top !== undefined) out.$top = params.$top;
95
+ if (params.$expand?.length) out.$expand = params.$expand.join(',');
96
+ if (params.$search) out.$search = params.$search;
97
+ if (params.$count) out.$count = 'true';
98
+
99
+ return out;
100
+ }
101
+
102
+ /** Merge two header objects, giving priority to the second */
103
+ function mergeHeaders(
104
+ base?: Record<string, string>,
105
+ override?: Record<string, string>,
106
+ ): Record<string, string> {
107
+ return { ...base, ...override };
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // ApiDataSource
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * ApiDataSource — a DataSource adapter for raw HTTP APIs.
116
+ *
117
+ * Used when `ViewData.provider === 'api'`. The read and write HttpRequest
118
+ * configs define the endpoints; all CRUD methods map onto HTTP verbs.
119
+ *
120
+ * Read operations use the `read` config, write operations use the `write` config.
121
+ * Both fall back to each other when one is not provided.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const ds = new ApiDataSource({
126
+ * read: { url: '/api/contacts', method: 'GET' },
127
+ * write: { url: '/api/contacts', method: 'POST' },
128
+ * });
129
+ *
130
+ * const result = await ds.find('contacts', { $top: 10 });
131
+ * ```
132
+ */
133
+ export class ApiDataSource<T = any> implements DataSource<T> {
134
+ private readConfig: HttpRequest | undefined;
135
+ private writeConfig: HttpRequest | undefined;
136
+ private fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
137
+ private defaultHeaders: Record<string, string>;
138
+
139
+ constructor(config: ApiDataSourceConfig) {
140
+ this.readConfig = config.read;
141
+ this.writeConfig = config.write;
142
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
143
+ this.defaultHeaders = config.defaultHeaders ?? {};
144
+ }
145
+
146
+ // -----------------------------------------------------------------------
147
+ // Internal request executor
148
+ // -----------------------------------------------------------------------
149
+
150
+ private async request<R = any>(
151
+ base: HttpRequest | undefined,
152
+ options: {
153
+ pathSuffix?: string;
154
+ method?: HttpMethod;
155
+ queryParams?: Record<string, unknown>;
156
+ body?: unknown;
157
+ headers?: Record<string, string>;
158
+ } = {},
159
+ ): Promise<R> {
160
+ if (!base) {
161
+ throw new Error(
162
+ 'ApiDataSource: No HTTP configuration provided for this operation. ' +
163
+ 'Ensure the ViewData has read/write configs.',
164
+ );
165
+ }
166
+
167
+ const method = options.method ?? base.method ?? 'GET';
168
+
169
+ // Merge query params: base.params + extra queryParams
170
+ const allQuery = {
171
+ ...(base.params as Record<string, unknown> | undefined),
172
+ ...options.queryParams,
173
+ };
174
+
175
+ const url = buildUrl(base.url, options.pathSuffix, allQuery);
176
+
177
+ const headers = mergeHeaders(
178
+ mergeHeaders(this.defaultHeaders, base.headers),
179
+ options.headers,
180
+ );
181
+
182
+ const init: RequestInit = {
183
+ method,
184
+ headers,
185
+ };
186
+
187
+ // Attach body for non-GET methods
188
+ if (options.body !== undefined && method !== 'GET') {
189
+ if (
190
+ options.body instanceof FormData ||
191
+ options.body instanceof Blob
192
+ ) {
193
+ init.body = options.body as FormData | Blob;
194
+ } else if (typeof options.body === 'string') {
195
+ init.body = options.body;
196
+ if (!headers['Content-Type']) {
197
+ headers['Content-Type'] = 'text/plain';
198
+ }
199
+ } else {
200
+ init.body = JSON.stringify(options.body);
201
+ if (!headers['Content-Type']) {
202
+ headers['Content-Type'] = 'application/json';
203
+ }
204
+ }
205
+ }
206
+
207
+ const response = await this.fetchFn(url, init);
208
+
209
+ if (!response.ok) {
210
+ const text = await response.text().catch(() => '');
211
+ throw new Error(
212
+ `ApiDataSource: HTTP ${response.status} ${response.statusText} — ${text}`,
213
+ );
214
+ }
215
+
216
+ // Try to parse as JSON; fall back to empty object
217
+ const contentType = response.headers.get('content-type') ?? '';
218
+ if (contentType.includes('application/json')) {
219
+ return response.json();
220
+ }
221
+
222
+ // Non-JSON response — return text wrapped in an object
223
+ const text = await response.text();
224
+ return text as unknown as R;
225
+ }
226
+
227
+ // -----------------------------------------------------------------------
228
+ // DataSource interface
229
+ // -----------------------------------------------------------------------
230
+
231
+ async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
232
+ const queryParams = queryParamsToRecord(params);
233
+ const raw = await this.request<any>(this.readConfig, {
234
+ method: 'GET',
235
+ queryParams,
236
+ });
237
+
238
+ // Normalize: the API might return an array, an object with `data`, or a QueryResult
239
+ return this.normalizeQueryResult(raw);
240
+ }
241
+
242
+ async findOne(_resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
243
+ try {
244
+ const queryParams = queryParamsToRecord(params);
245
+ const raw = await this.request<T>(this.readConfig, {
246
+ pathSuffix: String(id),
247
+ method: 'GET',
248
+ queryParams,
249
+ });
250
+ return raw ?? null;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ async create(_resource: string, data: Partial<T>): Promise<T> {
257
+ const config = this.writeConfig ?? this.readConfig;
258
+ return this.request<T>(config, {
259
+ method: 'POST',
260
+ body: data,
261
+ });
262
+ }
263
+
264
+ async update(_resource: string, id: string | number, data: Partial<T>): Promise<T> {
265
+ const config = this.writeConfig ?? this.readConfig;
266
+ return this.request<T>(config, {
267
+ pathSuffix: String(id),
268
+ method: 'PATCH',
269
+ body: data,
270
+ });
271
+ }
272
+
273
+ async delete(_resource: string, id: string | number): Promise<boolean> {
274
+ const config = this.writeConfig ?? this.readConfig;
275
+ try {
276
+ await this.request(config, {
277
+ pathSuffix: String(id),
278
+ method: 'DELETE',
279
+ });
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ async getObjectSchema(_objectName: string): Promise<any> {
287
+ // Generic API endpoints typically don't expose schema metadata.
288
+ // Return a minimal stub so schema-dependent components don't crash.
289
+ return { name: _objectName, fields: {} };
290
+ }
291
+
292
+ async getView(_objectName: string, _viewId: string): Promise<any | null> {
293
+ return null;
294
+ }
295
+
296
+ async getApp(_appId: string): Promise<any | null> {
297
+ return null;
298
+ }
299
+
300
+ // -----------------------------------------------------------------------
301
+ // Helpers
302
+ // -----------------------------------------------------------------------
303
+
304
+ /**
305
+ * Normalize various API response shapes into a QueryResult.
306
+ *
307
+ * Supported shapes:
308
+ * - `T[]` → wrap in QueryResult
309
+ * - `{ data: T[] }` → extract data
310
+ * - `{ items: T[] }` → extract items
311
+ * - `{ results: T[] }` → extract results
312
+ * - `{ records: T[] }` → extract records (Salesforce-style)
313
+ * - `{ value: T[] }` → extract value (OData-style)
314
+ * - Full QueryResult (has data + totalCount) → return as-is
315
+ */
316
+ private normalizeQueryResult(raw: any): QueryResult<T> {
317
+ if (Array.isArray(raw)) {
318
+ return { data: raw, total: raw.length };
319
+ }
320
+
321
+ if (raw && typeof raw === 'object') {
322
+ // Already a QueryResult
323
+ if (Array.isArray(raw.data) && ('total' in raw || 'totalCount' in raw)) {
324
+ return {
325
+ data: raw.data,
326
+ total: raw.total ?? raw.totalCount ?? raw.data.length,
327
+ hasMore: raw.hasMore,
328
+ cursor: raw.cursor,
329
+ };
330
+ }
331
+
332
+ // Common envelope patterns
333
+ for (const key of ['data', 'items', 'results', 'records', 'value']) {
334
+ if (Array.isArray(raw[key])) {
335
+ return {
336
+ data: raw[key],
337
+ total: raw.total ?? raw.totalCount ?? raw.count ?? raw[key].length,
338
+ hasMore: raw.hasMore ?? raw.hasNextPage,
339
+ };
340
+ }
341
+ }
342
+
343
+ // Single-object response — wrap as array
344
+ return { data: [raw as T], total: 1 };
345
+ }
346
+
347
+ return { data: [], total: 0 };
348
+ }
349
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * ObjectUI — ValueDataSource
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: 'value'` ViewData mode.
9
+ * Operates entirely on an in-memory array — no network requests.
10
+ */
11
+
12
+ import type {
13
+ DataSource,
14
+ QueryParams,
15
+ QueryResult,
16
+ } from '@object-ui/types';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Configuration
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface ValueDataSourceConfig<T = any> {
23
+ /** The static data array */
24
+ items: T[];
25
+ /** Optional ID field name for findOne/update/delete (defaults to '_id' then 'id') */
26
+ idField?: string;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Resolve the ID of a record given possible field names */
34
+ function getRecordId(record: any, idField?: string): string | number | undefined {
35
+ if (idField) return record[idField];
36
+ return record._id ?? record.id;
37
+ }
38
+
39
+ /**
40
+ * Simple in-memory filter evaluation.
41
+ * Supports flat key-value equality and basic operators ($gt, $gte, $lt, $lte, $ne, $in).
42
+ */
43
+ function matchesFilter(record: any, filter: Record<string, any>): boolean {
44
+ for (const [key, condition] of Object.entries(filter)) {
45
+ const value = record[key];
46
+
47
+ if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
48
+ // Operator-based filter
49
+ for (const [op, target] of Object.entries(condition)) {
50
+ switch (op) {
51
+ case '$gt':
52
+ if (!(value > (target as any))) return false;
53
+ break;
54
+ case '$gte':
55
+ if (!(value >= (target as any))) return false;
56
+ break;
57
+ case '$lt':
58
+ if (!(value < (target as any))) return false;
59
+ break;
60
+ case '$lte':
61
+ if (!(value <= (target as any))) return false;
62
+ break;
63
+ case '$ne':
64
+ if (value === target) return false;
65
+ break;
66
+ case '$in':
67
+ if (!Array.isArray(target) || !target.includes(value)) return false;
68
+ break;
69
+ case '$contains':
70
+ if (typeof value !== 'string' || !value.toLowerCase().includes(String(target).toLowerCase())) return false;
71
+ break;
72
+ default:
73
+ break;
74
+ }
75
+ }
76
+ } else {
77
+ // Simple equality
78
+ if (value !== condition) return false;
79
+ }
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /** Apply sort ordering to an array (returns a new sorted array) */
85
+ function applySort<T>(
86
+ data: T[],
87
+ orderby?: QueryParams['$orderby'],
88
+ ): T[] {
89
+ if (!orderby) return data;
90
+
91
+ // Normalize to array of { field, order }
92
+ let sorts: Array<{ field: string; order: 'asc' | 'desc' }> = [];
93
+
94
+ if (Array.isArray(orderby)) {
95
+ sorts = orderby.map((item) => {
96
+ if (typeof item === 'string') {
97
+ if (item.startsWith('-')) {
98
+ return { field: item.slice(1), order: 'desc' as const };
99
+ }
100
+ return { field: item, order: 'asc' as const };
101
+ }
102
+ return { field: item.field, order: (item.order ?? 'asc') as 'asc' | 'desc' };
103
+ });
104
+ } else if (typeof orderby === 'object') {
105
+ sorts = Object.entries(orderby).map(([field, order]) => ({
106
+ field,
107
+ order: order as 'asc' | 'desc',
108
+ }));
109
+ }
110
+
111
+ if (sorts.length === 0) return data;
112
+
113
+ return [...data].sort((a: any, b: any) => {
114
+ for (const { field, order } of sorts) {
115
+ const av = a[field];
116
+ const bv = b[field];
117
+ if (av === bv) continue;
118
+ if (av == null) return order === 'asc' ? -1 : 1;
119
+ if (bv == null) return order === 'asc' ? 1 : -1;
120
+ const cmp = av < bv ? -1 : 1;
121
+ return order === 'asc' ? cmp : -cmp;
122
+ }
123
+ return 0;
124
+ });
125
+ }
126
+
127
+ /** Pick specific fields from a record */
128
+ function selectFields<T>(record: T, fields?: string[]): T {
129
+ if (!fields || fields.length === 0) return record;
130
+ const out: any = {};
131
+ for (const f of fields) {
132
+ if (f in (record as any)) {
133
+ out[f] = (record as any)[f];
134
+ }
135
+ }
136
+ return out as T;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // ValueDataSource
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * ValueDataSource — an in-memory DataSource backed by a static array.
145
+ *
146
+ * Used when `ViewData.provider === 'value'`. All operations are synchronous
147
+ * (but wrapped in Promises to satisfy the DataSource interface). Supports
148
+ * basic filter, sort, pagination, and CRUD operations.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * const ds = new ValueDataSource({
153
+ * items: [
154
+ * { id: '1', name: 'Alice', age: 30 },
155
+ * { id: '2', name: 'Bob', age: 25 },
156
+ * ],
157
+ * });
158
+ *
159
+ * const result = await ds.find('users', { $filter: { age: { $gt: 26 } } });
160
+ * // result.data === [{ id: '1', name: 'Alice', age: 30 }]
161
+ * ```
162
+ */
163
+ export class ValueDataSource<T = any> implements DataSource<T> {
164
+ private items: T[];
165
+ private idField: string | undefined;
166
+
167
+ constructor(config: ValueDataSourceConfig<T>) {
168
+ // Deep clone to prevent external mutation
169
+ this.items = JSON.parse(JSON.stringify(config.items));
170
+ this.idField = config.idField;
171
+ }
172
+
173
+ // -----------------------------------------------------------------------
174
+ // DataSource interface
175
+ // -----------------------------------------------------------------------
176
+
177
+ async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
178
+ let result = [...this.items];
179
+
180
+ // Filter
181
+ if (params?.$filter && Object.keys(params.$filter).length > 0) {
182
+ result = result.filter((r) => matchesFilter(r, params.$filter!));
183
+ }
184
+
185
+ // Search (simple text search across all string fields)
186
+ if (params?.$search) {
187
+ const q = params.$search.toLowerCase();
188
+ result = result.filter((r) =>
189
+ Object.values(r as any).some(
190
+ (v) => typeof v === 'string' && v.toLowerCase().includes(q),
191
+ ),
192
+ );
193
+ }
194
+
195
+ const totalCount = result.length;
196
+
197
+ // Sort
198
+ result = applySort(result, params?.$orderby);
199
+
200
+ // Pagination
201
+ const skip = params?.$skip ?? 0;
202
+ const top = params?.$top;
203
+ if (skip > 0) result = result.slice(skip);
204
+ if (top !== undefined) result = result.slice(0, top);
205
+
206
+ // Select
207
+ if (params?.$select?.length) {
208
+ result = result.map((r) => selectFields(r, params.$select));
209
+ }
210
+
211
+ return {
212
+ data: result,
213
+ total: totalCount,
214
+ hasMore: skip + (top ?? result.length) < totalCount,
215
+ };
216
+ }
217
+
218
+ async findOne(
219
+ _resource: string,
220
+ id: string | number,
221
+ params?: QueryParams,
222
+ ): Promise<T | null> {
223
+ const record = this.items.find(
224
+ (r) => String(getRecordId(r, this.idField)) === String(id),
225
+ );
226
+ if (!record) return null;
227
+
228
+ if (params?.$select?.length) {
229
+ return selectFields(record, params.$select);
230
+ }
231
+ return { ...record };
232
+ }
233
+
234
+ async create(_resource: string, data: Partial<T>): Promise<T> {
235
+ const record = { ...data } as T;
236
+ // Auto-generate an ID if missing
237
+ if (!getRecordId(record, this.idField)) {
238
+ const field = this.idField ?? '_id';
239
+ (record as any)[field] = `auto_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
240
+ }
241
+ this.items.push(record);
242
+ return { ...record };
243
+ }
244
+
245
+ async update(
246
+ _resource: string,
247
+ id: string | number,
248
+ data: Partial<T>,
249
+ ): Promise<T> {
250
+ const index = this.items.findIndex(
251
+ (r) => String(getRecordId(r, this.idField)) === String(id),
252
+ );
253
+ if (index === -1) {
254
+ throw new Error(`ValueDataSource: Record with id "${id}" not found`);
255
+ }
256
+ this.items[index] = { ...this.items[index], ...data };
257
+ return { ...this.items[index] };
258
+ }
259
+
260
+ async delete(_resource: string, id: string | number): Promise<boolean> {
261
+ const index = this.items.findIndex(
262
+ (r) => String(getRecordId(r, this.idField)) === String(id),
263
+ );
264
+ if (index === -1) return false;
265
+ this.items.splice(index, 1);
266
+ return true;
267
+ }
268
+
269
+ async bulk(
270
+ _resource: string,
271
+ operation: 'create' | 'update' | 'delete',
272
+ data: Partial<T>[],
273
+ ): Promise<T[]> {
274
+ const results: T[] = [];
275
+ for (const item of data) {
276
+ switch (operation) {
277
+ case 'create':
278
+ results.push(await this.create(_resource, item));
279
+ break;
280
+ case 'update': {
281
+ const id = getRecordId(item, this.idField);
282
+ if (id !== undefined) {
283
+ results.push(await this.update(_resource, id, item));
284
+ }
285
+ break;
286
+ }
287
+ case 'delete': {
288
+ const id = getRecordId(item, this.idField);
289
+ if (id !== undefined) {
290
+ await this.delete(_resource, id);
291
+ }
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ return results;
297
+ }
298
+
299
+ async getObjectSchema(_objectName: string): Promise<any> {
300
+ // Infer a minimal schema from the first item
301
+ if (this.items.length === 0) return { name: _objectName, fields: {} };
302
+
303
+ const sample = this.items[0];
304
+ const fields: Record<string, any> = {};
305
+ for (const [key, value] of Object.entries(sample as any)) {
306
+ fields[key] = { type: typeof value };
307
+ }
308
+ return { name: _objectName, fields };
309
+ }
310
+
311
+ async getView(_objectName: string, _viewId: string): Promise<any | null> {
312
+ return null;
313
+ }
314
+
315
+ async getApp(_appId: string): Promise<any | null> {
316
+ return null;
317
+ }
318
+
319
+ // -----------------------------------------------------------------------
320
+ // Extra utilities
321
+ // -----------------------------------------------------------------------
322
+
323
+ /** Get the current number of items */
324
+ get count(): number {
325
+ return this.items.length;
326
+ }
327
+
328
+ /** Get a snapshot of all items (cloned) */
329
+ getAll(): T[] {
330
+ return JSON.parse(JSON.stringify(this.items));
331
+ }
332
+ }