@object-ui/core 0.5.0 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +11 -0
- package/dist/actions/ActionRunner.d.ts +228 -4
- package/dist/actions/ActionRunner.js +397 -45
- package/dist/actions/TransactionManager.d.ts +193 -0
- package/dist/actions/TransactionManager.js +410 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/adapters/ApiDataSource.d.ts +69 -0
- package/dist/adapters/ApiDataSource.js +293 -0
- package/dist/adapters/ValueDataSource.d.ts +55 -0
- package/dist/adapters/ValueDataSource.js +287 -0
- package/dist/adapters/index.d.ts +3 -0
- package/dist/adapters/index.js +5 -2
- package/dist/adapters/resolveDataSource.d.ts +40 -0
- package/dist/adapters/resolveDataSource.js +59 -0
- package/dist/data-scope/DataScopeManager.d.ts +127 -0
- package/dist/data-scope/DataScopeManager.js +229 -0
- package/dist/data-scope/index.d.ts +10 -0
- package/dist/data-scope/index.js +10 -0
- package/dist/evaluator/ExpressionEvaluator.d.ts +11 -1
- package/dist/evaluator/ExpressionEvaluator.js +32 -8
- package/dist/evaluator/FormulaFunctions.d.ts +58 -0
- package/dist/evaluator/FormulaFunctions.js +350 -0
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -2
- package/dist/query/query-ast.d.ts +2 -2
- package/dist/query/query-ast.js +3 -3
- package/dist/registry/Registry.d.ts +10 -0
- package/dist/registry/Registry.js +2 -1
- package/dist/registry/WidgetRegistry.d.ts +120 -0
- package/dist/registry/WidgetRegistry.js +275 -0
- package/dist/theme/ThemeEngine.d.ts +82 -0
- package/dist/theme/ThemeEngine.js +400 -0
- package/dist/theme/index.d.ts +8 -0
- package/dist/theme/index.js +8 -0
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/validation-engine.d.ts +19 -1
- package/dist/validation/validation-engine.js +67 -2
- package/dist/validation/validators/index.d.ts +1 -1
- package/dist/validation/validators/index.js +1 -1
- package/dist/validation/validators/object-validation-engine.d.ts +2 -2
- package/dist/validation/validators/object-validation-engine.js +1 -1
- package/package.json +4 -3
- package/src/actions/ActionRunner.ts +577 -55
- package/src/actions/TransactionManager.ts +521 -0
- package/src/actions/__tests__/ActionRunner.params.test.ts +134 -0
- package/src/actions/__tests__/ActionRunner.test.ts +711 -0
- package/src/actions/__tests__/TransactionManager.test.ts +447 -0
- package/src/actions/index.ts +1 -0
- package/src/adapters/ApiDataSource.ts +349 -0
- package/src/adapters/ValueDataSource.ts +332 -0
- package/src/adapters/__tests__/ApiDataSource.test.ts +418 -0
- package/src/adapters/__tests__/ValueDataSource.test.ts +325 -0
- package/src/adapters/__tests__/resolveDataSource.test.ts +144 -0
- package/src/adapters/index.ts +6 -1
- package/src/adapters/resolveDataSource.ts +79 -0
- package/src/builder/__tests__/schema-builder.test.ts +235 -0
- package/src/data-scope/DataScopeManager.ts +269 -0
- package/src/data-scope/__tests__/DataScopeManager.test.ts +211 -0
- package/src/data-scope/index.ts +16 -0
- package/src/evaluator/ExpressionEvaluator.ts +34 -8
- package/src/evaluator/FormulaFunctions.ts +398 -0
- package/src/evaluator/__tests__/ExpressionContext.test.ts +110 -0
- package/src/evaluator/__tests__/FormulaFunctions.test.ts +447 -0
- package/src/evaluator/index.ts +1 -0
- package/src/index.ts +4 -3
- package/src/query/__tests__/window-functions.test.ts +1 -1
- package/src/query/query-ast.ts +3 -3
- package/src/registry/Registry.ts +12 -1
- package/src/registry/WidgetRegistry.ts +316 -0
- package/src/registry/__tests__/WidgetRegistry.test.ts +321 -0
- package/src/theme/ThemeEngine.ts +452 -0
- package/src/theme/__tests__/ThemeEngine.test.ts +606 -0
- package/src/theme/index.ts +22 -0
- package/src/validation/__tests__/object-validation-engine.test.ts +1 -1
- package/src/validation/__tests__/schema-validator.test.ts +118 -0
- package/src/validation/index.ts +1 -1
- package/src/validation/validation-engine.ts +61 -2
- package/src/validation/validators/index.ts +1 -1
- package/src/validation/validators/object-validation-engine.ts +2 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|