@vidyano-labs/virtual-service 0.1.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 (4) hide show
  1. package/README.md +1116 -0
  2. package/index.d.ts +559 -0
  3. package/index.js +2008 -0
  4. package/package.json +24 -0
package/index.js ADDED
@@ -0,0 +1,2008 @@
1
+ import { DataType, ServiceHooks, Service } from '@vidyano/core';
2
+
3
+ /**
4
+ * Value conversion utilities for the virtual service.
5
+ * Wraps core DataType conversions to return JavaScript primitives instead of BigNumber.
6
+ *
7
+ * @example
8
+ * // Convert from service string to primitive
9
+ * fromServiceValue("100.50", "Decimal") // => 100.5 (number)
10
+ * fromServiceValue("True", "Boolean") // => true (boolean)
11
+ * fromServiceValue("15-01-2024 10:30:00", "DateTime") // => Date object
12
+ *
13
+ * @example
14
+ * // Convert from primitive to service string
15
+ * toServiceValue(100.5, "Decimal") // => "100.5"
16
+ * toServiceValue(true, "Boolean") // => "True"
17
+ * toServiceValue(new Date(2024, 0, 15), "Date") // => "15-01-2024 00:00:00"
18
+ */
19
+ /**
20
+ * Converts a service string value to a primitive JavaScript type.
21
+ * Unlike DataType.fromServiceString, this returns number instead of BigNumber
22
+ * for numeric types (Decimal, Double, Int64, etc.).
23
+ */
24
+ function fromServiceValue(value, type) {
25
+ const result = DataType.fromServiceString(value, type);
26
+ // Check for BigNumber (has toNumber method) and convert to number primitive
27
+ if (result && typeof result.toNumber === "function")
28
+ return result.toNumber();
29
+ return result;
30
+ }
31
+ /**
32
+ * Converts a primitive JavaScript value to a service string.
33
+ */
34
+ function toServiceValue(value, type) {
35
+ return DataType.toServiceString(value, type);
36
+ }
37
+
38
+ /**
39
+ * Registry for managing PersistentObject configurations and instances
40
+ */
41
+ class VirtualPersistentObjectRegistry {
42
+ #configs = new Map();
43
+ #actionHandlers;
44
+ #validator;
45
+ #queryRegistry;
46
+ #actionsRegistry;
47
+ constructor(validator, actionHandlers, queryRegistry, actionsRegistry) {
48
+ this.#validator = validator;
49
+ this.#actionHandlers = actionHandlers;
50
+ this.#queryRegistry = queryRegistry;
51
+ this.#actionsRegistry = actionsRegistry;
52
+ }
53
+ /**
54
+ * Registers a PersistentObject configuration
55
+ */
56
+ register(config) {
57
+ this.#configs.set(config.type, config);
58
+ }
59
+ /**
60
+ * Gets a registered PersistentObject configuration
61
+ */
62
+ getConfig(type) {
63
+ return this.#configs.get(type);
64
+ }
65
+ /**
66
+ * Gets or creates a PersistentObject DTO
67
+ */
68
+ async getPersistentObject(type, objectId, isNew, parent = null) {
69
+ const config = this.#configs.get(type);
70
+ if (!config)
71
+ throw new Error(`PersistentObject type "${type}" is not registered`);
72
+ // Create new instance
73
+ let po = await buildPersistentObjectDto(config, this.#queryRegistry, objectId, isNew);
74
+ // Auto-add actions based on overridden lifecycle methods
75
+ if (this.#actionsRegistry.isMethodOverridden(config.type, "onSave")) {
76
+ if (!po.actions.includes("Save"))
77
+ po.actions.push("Save");
78
+ }
79
+ // Call lifecycle hooks
80
+ const conversionContext = this.#createConversionContext();
81
+ // Always call onConstruct
82
+ this.#actionsRegistry.executeConstruct(po, conversionContext);
83
+ // Call onLoad only for existing objects
84
+ if (!isNew)
85
+ po = await this.#actionsRegistry.executeLoad(po, parent, conversionContext);
86
+ return po;
87
+ }
88
+ /**
89
+ * Creates a new PersistentObject with the New action lifecycle
90
+ * @param type - The PersistentObject type
91
+ * @param parent - The parent PersistentObject (or null)
92
+ * @param query - The Query from which New was invoked (or null)
93
+ * @param parameters - Additional parameters (or null)
94
+ * @returns The new PersistentObject DTO
95
+ */
96
+ async createNewPersistentObject(type, parent, query, parameters) {
97
+ const config = this.#configs.get(type);
98
+ if (!config)
99
+ throw new Error(`PersistentObject type "${type}" is not registered`);
100
+ // Create new instance with a generated ID
101
+ const objectId = crypto.randomUUID();
102
+ let po = await buildPersistentObjectDto(config, this.#queryRegistry, objectId, true);
103
+ // Auto-add actions based on overridden lifecycle methods
104
+ if (this.#actionsRegistry.isMethodOverridden(config.type, "onSave")) {
105
+ if (!po.actions.includes("Save"))
106
+ po.actions.push("Save");
107
+ }
108
+ // Call lifecycle hooks
109
+ const conversionContext = this.#createConversionContext();
110
+ // Always call onConstruct first
111
+ this.#actionsRegistry.executeConstruct(po, conversionContext);
112
+ // Then call onNew for new objects
113
+ po = await this.#actionsRegistry.executeNew(po, parent, query, parameters, conversionContext);
114
+ return po;
115
+ }
116
+ /**
117
+ * Executes an action on a PersistentObject
118
+ */
119
+ async executeAction(request) {
120
+ let parent = request.parent;
121
+ if (!parent)
122
+ throw new Error("ExecuteAction requires a parent PersistentObject");
123
+ const type = parent.type;
124
+ const actionName = request.action.split(".").pop();
125
+ // Handle refresh action
126
+ if (actionName === "Refresh")
127
+ return this.#handleRefresh(request);
128
+ // Validate before Save action
129
+ if (actionName === "Save") {
130
+ const config = this.#configs.get(type);
131
+ try {
132
+ const validationFailed = this.#validateAttributes(parent, config);
133
+ if (validationFailed) {
134
+ return {
135
+ result: parent
136
+ };
137
+ }
138
+ }
139
+ catch (error) {
140
+ parent.notification = error instanceof Error ? error.message : String(error);
141
+ parent.notificationType = "Error";
142
+ return {
143
+ result: parent
144
+ };
145
+ }
146
+ // Call onSave from actions registry
147
+ const conversionContext = this.#createConversionContext();
148
+ parent = await this.#actionsRegistry.executeSave(parent, conversionContext);
149
+ return {
150
+ result: parent
151
+ };
152
+ }
153
+ // Get custom action handler
154
+ const handler = this.#actionHandlers.get(actionName);
155
+ if (!handler)
156
+ throw new Error(`Action "${actionName}" is not registered`);
157
+ // Create action context
158
+ const context = this.#createActionContext(parent);
159
+ // Build unified action args for PersistentObject actions
160
+ const args = {
161
+ parent: parent,
162
+ query: undefined,
163
+ selectedItems: undefined,
164
+ parameters: request.parameters,
165
+ context: context
166
+ };
167
+ // Execute handler and get result
168
+ const result = await handler(args);
169
+ // Handle both old and new API formats for backwards compatibility
170
+ // Old API: handler returns { result: PersistentObjectDto }
171
+ // New API: handler returns PersistentObjectDto | null
172
+ let finalResult;
173
+ if (result && typeof result === "object" && "result" in result) {
174
+ // Old API format - extract the result property
175
+ finalResult = result.result || parent;
176
+ }
177
+ else {
178
+ // New API format - use directly
179
+ finalResult = result || parent;
180
+ }
181
+ // Return response with result
182
+ return {
183
+ result: finalResult
184
+ };
185
+ }
186
+ /**
187
+ * Handles the PersistentObject.Refresh action
188
+ */
189
+ async #handleRefresh(request) {
190
+ let parent = request.parent;
191
+ const type = parent.type;
192
+ const config = this.#configs.get(type);
193
+ if (!config)
194
+ throw new Error(`PersistentObject type "${type}" is not registered`);
195
+ // Get the attribute that triggered the refresh
196
+ const refreshedAttributeId = request.parameters?.RefreshedPersistentObjectAttributeId;
197
+ const triggeredAttribute = refreshedAttributeId
198
+ ? parent.attributes?.find(a => a.id === refreshedAttributeId)
199
+ : undefined;
200
+ // Call onRefresh from actions registry
201
+ const conversionContext = this.#createConversionContext();
202
+ parent = await this.#actionsRegistry.executeRefresh(parent, triggeredAttribute, conversionContext);
203
+ return {
204
+ result: parent
205
+ };
206
+ }
207
+ /**
208
+ * Validates all attributes on a PersistentObject
209
+ * @returns true if validation failed, false if all valid
210
+ */
211
+ #validateAttributes(po, config) {
212
+ if (!po.attributes || !config)
213
+ return false;
214
+ let hasErrors = false;
215
+ for (const attr of po.attributes) {
216
+ // Clear previous validation errors
217
+ attr.validationError = undefined;
218
+ // Find the attribute config to get rules and isRequired
219
+ const attrConfig = config.attributes.find(a => a.name === attr.name);
220
+ if (!attrConfig)
221
+ continue;
222
+ // Create a DTO with the rules and isRequired from config
223
+ const attrWithRules = {
224
+ ...attr,
225
+ rules: attrConfig.rules,
226
+ isRequired: attrConfig.isRequired || false
227
+ };
228
+ // Validate the attribute
229
+ const error = this.#validator.validateAttribute(attrWithRules);
230
+ if (error) {
231
+ attr.validationError = error;
232
+ hasErrors = true;
233
+ }
234
+ }
235
+ return hasErrors;
236
+ }
237
+ /**
238
+ * Creates an action context for custom action handlers
239
+ */
240
+ #createActionContext(po) {
241
+ return {
242
+ getAttribute: (name) => {
243
+ return po.attributes?.find(a => a.name === name);
244
+ },
245
+ getAttributeValue: (name) => {
246
+ const attr = po.attributes?.find(a => a.name === name);
247
+ if (!attr)
248
+ return undefined;
249
+ return fromServiceValue(attr.value, attr.type);
250
+ },
251
+ setAttributeValue: (name, value) => {
252
+ const attr = po.attributes?.find(a => a.name === name);
253
+ if (attr) {
254
+ attr.value = toServiceValue(value, attr.type);
255
+ attr.isValueChanged = true;
256
+ }
257
+ },
258
+ getConvertedValue: (attr) => {
259
+ return fromServiceValue(attr.value, attr.type);
260
+ },
261
+ setConvertedValue: (attr, value) => {
262
+ attr.value = toServiceValue(value, attr.type);
263
+ attr.isValueChanged = true;
264
+ },
265
+ setValidationError: (name, error) => {
266
+ const attr = po.attributes?.find(a => a.name === name);
267
+ if (attr)
268
+ attr.validationError = error;
269
+ },
270
+ clearValidationError: (name) => {
271
+ const attr = po.attributes?.find(a => a.name === name);
272
+ if (attr)
273
+ attr.validationError = undefined;
274
+ },
275
+ setNotification: (message, type, duration) => {
276
+ po.notification = message;
277
+ po.notificationType = type;
278
+ po.notificationDuration = duration;
279
+ }
280
+ };
281
+ }
282
+ /**
283
+ * Creates a conversion context for type conversions
284
+ */
285
+ #createConversionContext() {
286
+ return {
287
+ getConvertedValue: (attr) => {
288
+ return fromServiceValue(attr.value, attr.type);
289
+ },
290
+ setConvertedValue: (attr, value) => {
291
+ attr.value = toServiceValue(value, attr.type);
292
+ attr.isValueChanged = true;
293
+ }
294
+ };
295
+ }
296
+ }
297
+ /**
298
+ * Builds a PersistentObjectDto from configuration
299
+ */
300
+ async function buildPersistentObjectDto(config, queryRegistry, objectId, isNew) {
301
+ const id = crypto.randomUUID();
302
+ const fullTypeName = `MockNamespace.${config.type}`;
303
+ // Build attributes using existing DTO type
304
+ const attributes = await Promise.all(config.attributes
305
+ .map((attr, index) => buildAttributeDto(attr, index, queryRegistry)));
306
+ // Build tabs using existing DTO type
307
+ const tabs = {};
308
+ if (config.tabs) {
309
+ Object.entries(config.tabs).forEach(([key, tab]) => {
310
+ tabs[key] = {
311
+ name: tab.name || key,
312
+ columnCount: tab.columnCount || 0,
313
+ id: tab.id,
314
+ layout: tab.layout
315
+ };
316
+ });
317
+ }
318
+ else {
319
+ // Default tab
320
+ tabs[""] = { name: "", columnCount: 0 };
321
+ }
322
+ // Build action names (reference by name)
323
+ const actions = config.actions || [];
324
+ // Build queries (detail queries)
325
+ const queries = [];
326
+ if (config.queries) {
327
+ for (const queryName of config.queries) {
328
+ const queryDto = await queryRegistry.getQuery(queryName);
329
+ queries.push(queryDto);
330
+ }
331
+ }
332
+ // Return proper PersistentObjectDto
333
+ return {
334
+ id,
335
+ objectId,
336
+ type: config.type,
337
+ fullTypeName,
338
+ label: config.label || config.type,
339
+ isNew,
340
+ isReadOnly: false,
341
+ stateBehavior: config.stateBehavior || "StayInEdit",
342
+ attributes,
343
+ actions,
344
+ tabs,
345
+ queries
346
+ };
347
+ }
348
+ /**
349
+ * Builds a PersistentObjectAttributeDto from configuration
350
+ */
351
+ async function buildAttributeDto(config, index, queryRegistry) {
352
+ const baseDto = {
353
+ id: config.id || crypto.randomUUID(),
354
+ name: config.name,
355
+ type: config.type || "String",
356
+ label: config.label || config.name,
357
+ value: config.value,
358
+ isRequired: config.isRequired || false,
359
+ isReadOnly: config.isReadOnly || false,
360
+ rules: config.rules,
361
+ visibility: config.visibility || "Always",
362
+ group: config.group || "",
363
+ tab: config.tab || "",
364
+ triggersRefresh: config.triggersRefresh || false,
365
+ options: config.options,
366
+ typeHints: config.typeHints,
367
+ column: config.column,
368
+ columnSpan: config.columnSpan || 4,
369
+ offset: config.offset ?? index
370
+ };
371
+ // Handle Reference attributes with lookup
372
+ if (config.lookup) {
373
+ if (!queryRegistry)
374
+ throw new Error(`Cannot build reference attribute "${config.name}" without queryRegistry`);
375
+ const lookupQuery = await queryRegistry.getQuery(config.lookup);
376
+ // Determine displayAttribute - use provided or first visible column from lookup query
377
+ let displayAttribute = config.displayAttribute;
378
+ if (!displayAttribute && lookupQuery.columns && lookupQuery.columns.length > 0) {
379
+ // Find first non-Id visible column, or use first column
380
+ const firstVisibleColumn = lookupQuery.columns.find(c => !c.isHidden && c.name !== "Id");
381
+ displayAttribute = firstVisibleColumn?.name || lookupQuery.columns[0].name;
382
+ }
383
+ if (!displayAttribute)
384
+ displayAttribute = "Id";
385
+ // Build reference attribute DTO
386
+ const refDto = {
387
+ ...baseDto,
388
+ type: "Reference",
389
+ lookup: lookupQuery,
390
+ objectId: config.value || null,
391
+ displayAttribute,
392
+ canAddNewReference: config.canAddNewReference ?? false,
393
+ selectInPlace: config.selectInPlace ?? false
394
+ };
395
+ return refDto;
396
+ }
397
+ return baseDto;
398
+ }
399
+
400
+ /**
401
+ * Registry for managing Query configurations
402
+ */
403
+ class VirtualQueryRegistry {
404
+ #configs = new Map();
405
+ #actionsRegistry;
406
+ constructor(actionsRegistry) {
407
+ this.#actionsRegistry = actionsRegistry || null;
408
+ }
409
+ /**
410
+ * Checks if a query is registered
411
+ * @param name - The query name
412
+ */
413
+ hasQuery(name) {
414
+ return this.#configs.has(name);
415
+ }
416
+ /**
417
+ * Registers a Query configuration
418
+ */
419
+ register(config, persistentObjectConfig) {
420
+ // Validate required fields
421
+ if (!config.name || config.name.trim() === "")
422
+ throw new Error("Query configuration must have a 'name' property");
423
+ if (!config.persistentObject || config.persistentObject.trim() === "")
424
+ throw new Error("Query configuration must have a 'persistentObject' property");
425
+ // Derive columns from PersistentObject configuration
426
+ const columns = VirtualQueryRegistry.#deriveColumnsFromPersistentObject(persistentObjectConfig);
427
+ this.#configs.set(config.name, {
428
+ config,
429
+ columns,
430
+ persistentObjectConfig,
431
+ data: config.data || []
432
+ });
433
+ }
434
+ /**
435
+ * Derives query columns from PersistentObject attributes
436
+ */
437
+ static #deriveColumnsFromPersistentObject(poConfig) {
438
+ return poConfig.attributes.map((attr, index) => {
439
+ const offset = attr.offset ?? index;
440
+ return {
441
+ id: attr.name,
442
+ name: attr.name,
443
+ label: attr.label || this.#humanize(attr.name),
444
+ type: attr.type || "String",
445
+ offset,
446
+ isHidden: this.#mapVisibilityToIsHidden(attr.visibility),
447
+ isSensitive: false,
448
+ // Query capabilities - following plan specifications
449
+ canFilter: false, // Not supported in mock implementation
450
+ canSort: attr.canSort ?? true, // Defaults to true
451
+ canGroupBy: false, // Not supported in mock implementation
452
+ canListDistincts: false, // Not supported (no filtering)
453
+ // Filter arrays (empty for mock)
454
+ includes: [],
455
+ excludes: []
456
+ // Note: typeHints are not stored on columns, only on QueryResultItemValueDto
457
+ };
458
+ });
459
+ }
460
+ /**
461
+ * Maps attribute visibility to column isHidden property
462
+ */
463
+ static #mapVisibilityToIsHidden(visibility) {
464
+ if (!visibility || visibility === "Always" || visibility === "Read" || visibility === "Query")
465
+ return false;
466
+ if (visibility === "New" || visibility === "Never")
467
+ return true;
468
+ // Handle compound visibility values
469
+ if (visibility === "Read, Query" || visibility === "Query, New")
470
+ return false;
471
+ if (visibility === "Read, New")
472
+ return true;
473
+ return false;
474
+ }
475
+ /**
476
+ * Converts camelCase or PascalCase string to human-readable format
477
+ */
478
+ static #humanize(name) {
479
+ if (!name)
480
+ return name;
481
+ // Insert space before capital letters (except first) and between number/letter transitions
482
+ return name
483
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
484
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
485
+ .replace(/([a-zA-Z])(\d)/g, '$1 $2')
486
+ .replace(/(\d)([a-zA-Z])/g, '$1 $2')
487
+ .trim();
488
+ }
489
+ /**
490
+ * Gets a QueryDto by name
491
+ * @param name - The query name
492
+ * @param parent - The parent PersistentObject (for detail queries) or null
493
+ */
494
+ async getQuery(name, parent) {
495
+ const entry = this.#configs.get(name);
496
+ if (!entry)
497
+ throw new Error(`Query '${name}' is not registered`);
498
+ const { config, columns, persistentObjectConfig } = entry;
499
+ // Build default actions
500
+ const actions = ["RefreshQuery"];
501
+ // Auto-add New if onNew is overridden for the PersistentObject type
502
+ if (this.#actionsRegistry?.isMethodOverridden(persistentObjectConfig.type, "onNew")) {
503
+ if (!actions.includes("New"))
504
+ actions.push("New");
505
+ }
506
+ // Auto-add Delete if onDelete is overridden for the PersistentObject type
507
+ if (this.#actionsRegistry?.isMethodOverridden(persistentObjectConfig.type, "onDelete")) {
508
+ if (!actions.includes("Delete"))
509
+ actions.push("Delete");
510
+ }
511
+ // Add custom query-level actions
512
+ if (config.actions) {
513
+ for (const actionName of config.actions)
514
+ if (!actions.includes(actionName))
515
+ actions.push(actionName);
516
+ }
517
+ // Add custom item-level actions
518
+ if (config.itemActions) {
519
+ for (const actionName of config.itemActions)
520
+ if (!actions.includes(actionName))
521
+ actions.push(actionName);
522
+ }
523
+ // Build the query DTO first (needed for onConstructQuery and executeQuery)
524
+ const queryDto = buildQueryDto(config, columns, actions, persistentObjectConfig);
525
+ // Call onConstructQuery lifecycle hook
526
+ if (this.#actionsRegistry)
527
+ this.#actionsRegistry.executeConstructQuery(queryDto, parent || null);
528
+ // Handle autoQuery flag - execute query to get initial results
529
+ if (config.autoQuery !== false && this.#actionsRegistry) {
530
+ const result = await this.executeQuery(queryDto, parent || null);
531
+ queryDto.result = result;
532
+ }
533
+ return queryDto;
534
+ }
535
+ /**
536
+ * Executes a query by delegating to the actions registry's onExecuteQuery
537
+ * @param query - The query DTO with textSearch, sortOptions, skip, top set
538
+ * @param parent - The parent PersistentObject (for detail queries) or null
539
+ */
540
+ async executeQuery(query, parent) {
541
+ if (!this.#actionsRegistry)
542
+ throw new Error("Actions registry is required for query execution");
543
+ const entry = this.#configs.get(query.name);
544
+ if (!entry)
545
+ throw new Error(`Query '${query.name}' is not registered`);
546
+ const { columns, data } = entry;
547
+ // Use registered columns (with isHidden) instead of request columns
548
+ const queryWithColumns = { ...query, columns };
549
+ // Delegate to actions registry to get items, passing default data from config
550
+ const result = await this.#actionsRegistry.executeQuery(queryWithColumns, parent, data);
551
+ // Convert data rows to QueryResultItemDto
552
+ const items = result.items.map(row => buildQueryResultItemDto(row, columns));
553
+ // Build and return QueryResultDto
554
+ return buildQueryResultDto(items, columns, result.totalItems, undefined, // No continuation - use skip/top instead
555
+ query.pageSize, query.skip, query.sortOptions);
556
+ }
557
+ }
558
+ /**
559
+ * Builds a QueryDto from configuration
560
+ */
561
+ function buildQueryDto(config, columns, actions, persistentObjectConfig) {
562
+ // Build minimal PersistentObjectDto for the query
563
+ const persistentObject = {
564
+ type: persistentObjectConfig.type,
565
+ id: crypto.randomUUID(),
566
+ objectId: null,
567
+ label: persistentObjectConfig.type,
568
+ fullTypeName: `Mock.${persistentObjectConfig.type}`,
569
+ isNew: false,
570
+ isReadOnly: true,
571
+ attributes: [],
572
+ actions: [],
573
+ tabs: {},
574
+ queries: []
575
+ };
576
+ return {
577
+ id: crypto.randomUUID(),
578
+ name: config.name,
579
+ label: config.label || config.name,
580
+ columns,
581
+ actions,
582
+ allowTextSearch: config.allowTextSearch ?? true,
583
+ disableBulkEdit: config.disableBulkEdit ?? false,
584
+ autoQuery: config.autoQuery ?? true,
585
+ pageSize: config.pageSize || 20,
586
+ persistentObject,
587
+ sortOptions: "",
588
+ textSearch: "",
589
+ skip: 0,
590
+ top: config.pageSize || 20,
591
+ totalItems: -1,
592
+ canRead: true,
593
+ canReorder: false,
594
+ enableSelectAll: true
595
+ };
596
+ }
597
+ /**
598
+ * Builds a QueryResultDto from result data
599
+ */
600
+ function buildQueryResultDto(items, columns, totalItems, continuation, pageSize, skip, sortOptions) {
601
+ return {
602
+ items,
603
+ columns,
604
+ totalItems,
605
+ continuation,
606
+ pageSize,
607
+ skip,
608
+ sortOptions: sortOptions || "",
609
+ charts: []
610
+ };
611
+ }
612
+ /**
613
+ * Builds a QueryResultItemDto from a data row
614
+ */
615
+ function buildQueryResultItemDto(data, columns) {
616
+ // Extract or generate ID (check both lowercase "id" and capitalized "Id")
617
+ let id;
618
+ if (data["id"] != null && (typeof data["id"] === "string" || typeof data["id"] === "number"))
619
+ id = String(data["id"]);
620
+ else if (data["Id"] != null && (typeof data["Id"] === "string" || typeof data["Id"] === "number"))
621
+ id = String(data["Id"]);
622
+ else
623
+ id = crypto.randomUUID();
624
+ // Build values array
625
+ const values = columns.map(column => {
626
+ return buildQueryResultItemValueDto(column.name, data[column.name], column.type, data[column.name + "Id"], data[column.name + "$typeHints"]);
627
+ });
628
+ // Extract typeHints and tag from row if present
629
+ const typeHints = data.$typeHints;
630
+ const tag = data.$tag;
631
+ return {
632
+ id,
633
+ values,
634
+ typeHints,
635
+ tag
636
+ };
637
+ }
638
+ /**
639
+ * Builds a QueryResultItemValueDto from a single value
640
+ * Converts JavaScript primitives to service string format
641
+ */
642
+ function buildQueryResultItemValueDto(key, rawValue, type, objectId, typeHints) {
643
+ // Convert to service string format using DataType.toServiceString
644
+ const value = rawValue == null
645
+ ? null
646
+ : DataType.toServiceString(rawValue, type);
647
+ return {
648
+ key,
649
+ value,
650
+ objectId,
651
+ typeHints
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Base class for PersistentObject lifecycle methods
657
+ * Extend this class to override lifecycle hooks like onLoad, onSave, onRefresh, etc.
658
+ * All methods have default implementations, so you only need to override what you need.
659
+ */
660
+ class VirtualPersistentObjectActions {
661
+ /**
662
+ * Called every time a PersistentObject DTO is created (both new and existing objects)
663
+ * Use this to set metadata on attributes that can only be known at runtime
664
+ * @param obj - The PersistentObject DTO being constructed
665
+ */
666
+ onConstruct(obj) {
667
+ // Default implementation: do nothing
668
+ }
669
+ /**
670
+ * Called when loading an existing PersistentObject by ID
671
+ * Use this to load entity data and populate attribute values
672
+ * @param obj - The constructed PersistentObject DTO (after onConstruct)
673
+ * @param parent - The parent PersistentObject if loaded in a master-detail context, null otherwise
674
+ * @returns The PersistentObject with loaded data
675
+ */
676
+ async onLoad(obj, parent) {
677
+ // Default implementation: return obj as-is
678
+ return obj;
679
+ }
680
+ /**
681
+ * Called when creating a new PersistentObject via the "New" action
682
+ * Use this to create a fresh entity instance with default values
683
+ * @param obj - The constructed PersistentObject DTO (after onConstruct)
684
+ * @param parent - The parent PersistentObject if creating from a detail query, null otherwise
685
+ * @param query - The Query from which the New action was invoked, null if not from a query
686
+ * @param parameters - Optional parameters including "MenuOption" if NewOptions exist
687
+ * @returns The PersistentObject with default values set
688
+ */
689
+ async onNew(obj, parent, query, parameters) {
690
+ // Default implementation: return obj as-is
691
+ return obj;
692
+ }
693
+ /**
694
+ * Called when an attribute with triggersRefresh: true is changed
695
+ * Use this to update other attributes based on the changed attribute
696
+ * @param obj - The PersistentObject DTO in edit mode
697
+ * @param attribute - The attribute that triggered the refresh (the one with triggersRefresh=true that was changed), wrapped with getValue/setValue
698
+ * @returns The PersistentObject with refreshed attributes
699
+ */
700
+ async onRefresh(obj, attribute) {
701
+ // Default implementation: return obj as-is
702
+ return obj;
703
+ }
704
+ /**
705
+ * Called when the Save action is executed
706
+ * Orchestrates the save process by calling saveNew or saveExisting based on obj.isNew
707
+ * @param obj - The PersistentObject DTO to save
708
+ * @returns The saved PersistentObject
709
+ */
710
+ async onSave(obj) {
711
+ // Default implementation: delegate to saveNew or saveExisting based on isNew flag
712
+ if (obj.isNew)
713
+ return await this.saveNew(obj);
714
+ else
715
+ return await this.saveExisting(obj);
716
+ }
717
+ /**
718
+ * Called by onSave when obj.isNew === true
719
+ * Use this to save a new entity to the data store
720
+ * @param obj - The new PersistentObject DTO to save
721
+ * @returns The saved PersistentObject
722
+ */
723
+ async saveNew(obj) {
724
+ // Default implementation: return obj as-is (no persistence in mock)
725
+ return obj;
726
+ }
727
+ /**
728
+ * Called by onSave when obj.isNew === false
729
+ * Use this to update an existing entity in the data store
730
+ * @param obj - The existing PersistentObject DTO to save
731
+ * @returns The saved PersistentObject
732
+ */
733
+ async saveExisting(obj) {
734
+ // Default implementation: return obj as-is (no persistence in mock)
735
+ return obj;
736
+ }
737
+ /**
738
+ * Called when a reference attribute is changed (via changeReference() on a PersistentObjectAttributeWithReference)
739
+ * The base implementation sets objectId, value, and isValueChanged on the reference attribute.
740
+ * Override this to add custom logic after the reference is set.
741
+ * @param parent - The PersistentObject that contains the reference attribute
742
+ * @param referenceAttribute - The reference attribute that was changed
743
+ * @param query - The query that was used to select the reference
744
+ * @param selectedItem - The QueryResultItem that was selected, or null if reference was cleared
745
+ */
746
+ async onSelectReference(_parent, referenceAttribute, _query, selectedItem) {
747
+ const refAttr = referenceAttribute;
748
+ if (selectedItem == null) {
749
+ // Clear the reference
750
+ refAttr.objectId = null;
751
+ refAttr.value = null;
752
+ }
753
+ else {
754
+ // Set the reference to the selected item
755
+ refAttr.objectId = selectedItem.id;
756
+ // Get the display value from the item using displayAttribute
757
+ const displayAttribute = refAttr.displayAttribute;
758
+ const displayValue = selectedItem.values?.find((v) => v.key === displayAttribute);
759
+ refAttr.value = displayValue?.value || selectedItem.id;
760
+ }
761
+ refAttr.isValueChanged = true;
762
+ }
763
+ /**
764
+ * Called when the Delete action is executed on selected items in a Query
765
+ * Use this to handle deletion of entities
766
+ * @param parent - The parent PersistentObject if deleting from a detail query, null for top-level queries
767
+ * @param query - The query from which items are being deleted
768
+ * @param selectedItems - The QueryResultItems that are selected for deletion
769
+ */
770
+ async onDelete(parent, query, selectedItems) {
771
+ // Default implementation: do nothing (no persistence in mock)
772
+ }
773
+ /**
774
+ * Called when a Query of this PersistentObject type is constructed
775
+ * Use this to set metadata on query columns that can only be known at runtime
776
+ * @param query - The Query DTO being constructed
777
+ * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
778
+ */
779
+ onConstructQuery(query, parent) {
780
+ // Default implementation: do nothing
781
+ }
782
+ /**
783
+ * Called when a Query is executed.
784
+ * The base implementation calls getEntities() and applies text search, sorting, and pagination.
785
+ * @param query - The Query DTO with textSearch, sortOptions, skip, top properties set
786
+ * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
787
+ * @param data - Default data from the query config (used if getEntities is not overridden)
788
+ * @returns The items matching the query criteria and the total count before pagination
789
+ */
790
+ async onExecuteQuery(query, parent, data) {
791
+ // Get all items from subclass or use provided data
792
+ let result = await this.getEntities(query, parent, data);
793
+ // Apply text search if provided
794
+ if (query.textSearch && query.textSearch.trim() !== "") {
795
+ const searchLower = query.textSearch.toLowerCase();
796
+ const stringColumns = query.columns?.filter(c => c.type === "String" && !c.isHidden) || [];
797
+ result = result.filter(row => {
798
+ for (const column of stringColumns) {
799
+ const value = String(row[column.name] || "").toLowerCase();
800
+ if (value.includes(searchLower))
801
+ return true;
802
+ }
803
+ return false;
804
+ });
805
+ }
806
+ // Apply sorting if provided
807
+ if (query.sortOptions && query.sortOptions.trim() !== "") {
808
+ const sortParts = query.sortOptions.split(";").map(s => s.trim()).filter(Boolean);
809
+ const sortOptions = sortParts.map(part => {
810
+ const [name, dir] = part.split(/\s+/);
811
+ return { name, direction: dir?.toUpperCase() === "DESC" ? "DESC" : "ASC" };
812
+ });
813
+ result = [...result].sort((a, b) => {
814
+ for (const { name, direction } of sortOptions) {
815
+ const aVal = a[name];
816
+ const bVal = b[name];
817
+ // Null handling
818
+ if (aVal == null && bVal == null)
819
+ continue;
820
+ if (aVal == null)
821
+ return direction === "ASC" ? -1 : 1;
822
+ if (bVal == null)
823
+ return direction === "ASC" ? 1 : -1;
824
+ // Type-aware comparison
825
+ let cmp = 0;
826
+ if (typeof aVal === "boolean")
827
+ cmp = aVal === bVal ? 0 : (aVal ? 1 : -1);
828
+ else if (aVal instanceof Date && bVal instanceof Date)
829
+ cmp = aVal.getTime() - bVal.getTime();
830
+ else if (typeof aVal === "number" && typeof bVal === "number")
831
+ cmp = aVal - bVal;
832
+ else
833
+ cmp = String(aVal).toLowerCase().localeCompare(String(bVal).toLowerCase());
834
+ if (cmp !== 0)
835
+ return direction === "ASC" ? cmp : -cmp;
836
+ }
837
+ return 0;
838
+ });
839
+ }
840
+ // Apply pagination
841
+ const skip = query.skip || 0;
842
+ const top = query.top || query.pageSize || 20;
843
+ const items = result.slice(skip, skip + top);
844
+ return {
845
+ items,
846
+ totalItems: result.length
847
+ };
848
+ }
849
+ /**
850
+ * Override this to provide the full list of items for a query.
851
+ * The base onExecuteQuery will handle text search, sorting, and pagination automatically.
852
+ * @param query - The Query DTO with textSearch, sortOptions properties set
853
+ * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
854
+ * @param data - Default data from the query config
855
+ * @returns All items matching the query criteria (before pagination)
856
+ */
857
+ async getEntities(_query, _parent, data) {
858
+ // Default implementation: return provided data (from query config)
859
+ return [...data];
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Creates a VirtualPersistentObjectAttribute by wrapping an attribute DTO with a Proxy
865
+ * The Proxy intercepts property access to provide getValue/setValue methods
866
+ * @param attr - The PersistentObjectAttributeDto to wrap
867
+ * @param conversionContext - The conversion context for type conversions
868
+ * @returns A VirtualPersistentObjectAttribute that combines DTO properties with helper methods
869
+ */
870
+ function createVirtualPersistentObjectAttribute(attr, conversionContext) {
871
+ const helpers = {
872
+ getValue() {
873
+ return conversionContext.getConvertedValue(attr);
874
+ },
875
+ setValue(value) {
876
+ conversionContext.setConvertedValue(attr, value);
877
+ },
878
+ setValidationError(error) {
879
+ attr.validationError = error;
880
+ },
881
+ clearValidationError() {
882
+ attr.validationError = undefined;
883
+ }
884
+ };
885
+ return new Proxy(attr, {
886
+ get(target, prop) {
887
+ if (prop in helpers)
888
+ return helpers[prop];
889
+ return target[prop];
890
+ },
891
+ set(target, prop, value) {
892
+ target[prop] = value;
893
+ return true;
894
+ }
895
+ });
896
+ }
897
+ /**
898
+ * Creates a VirtualPersistentObject by wrapping a DTO with a Proxy
899
+ * The Proxy intercepts property access to provide helper methods while keeping the underlying DTO unchanged
900
+ * @param dto - The PersistentObjectDto to wrap
901
+ * @param conversionContext - The conversion context for type conversions
902
+ * @returns A VirtualPersistentObject that combines DTO properties with helper methods
903
+ */
904
+ function createVirtualPersistentObject(dto, conversionContext) {
905
+ // Helper methods - logic is inlined here, only using conversionContext for type conversion
906
+ const helpers = {
907
+ getAttribute(name) {
908
+ const attr = dto.attributes?.find(a => a.name === name);
909
+ if (!attr)
910
+ return undefined;
911
+ return createVirtualPersistentObjectAttribute(attr, conversionContext);
912
+ },
913
+ getAttributeValue(name) {
914
+ const attr = dto.attributes?.find(a => a.name === name);
915
+ if (!attr)
916
+ return undefined;
917
+ return conversionContext.getConvertedValue(attr);
918
+ },
919
+ setAttributeValue(name, value) {
920
+ const attr = dto.attributes?.find(a => a.name === name);
921
+ if (attr)
922
+ conversionContext.setConvertedValue(attr, value);
923
+ },
924
+ setValidationError(name, error) {
925
+ const attr = dto.attributes?.find(a => a.name === name);
926
+ if (attr)
927
+ attr.validationError = error;
928
+ },
929
+ clearValidationError(name) {
930
+ const attr = dto.attributes?.find(a => a.name === name);
931
+ if (attr)
932
+ attr.validationError = undefined;
933
+ },
934
+ setNotification(message, type, duration) {
935
+ dto.notification = message;
936
+ dto.notificationType = type;
937
+ dto.notificationDuration = duration;
938
+ }
939
+ };
940
+ // Create a Proxy that intercepts property access
941
+ return new Proxy(dto, {
942
+ get(target, prop) {
943
+ // If accessing a helper method, return it
944
+ if (prop in helpers)
945
+ return helpers[prop];
946
+ // Otherwise, return the DTO property
947
+ return target[prop];
948
+ },
949
+ set(target, prop, value) {
950
+ // Allow setting DTO properties directly
951
+ target[prop] = value;
952
+ return true;
953
+ }
954
+ });
955
+ }
956
+ /**
957
+ * Unwraps a VirtualPersistentObject to get the underlying DTO
958
+ * Since the Proxy wraps the DTO, we can safely cast it back
959
+ * @param wrapped - The VirtualPersistentObject to unwrap
960
+ * @returns The underlying PersistentObjectDto
961
+ */
962
+ function unwrapVirtualPersistentObject(wrapped) {
963
+ // The wrapped object is a Proxy around the DTO
964
+ // We can return it as-is since the DTO is the target of the Proxy
965
+ return wrapped;
966
+ }
967
+
968
+ /**
969
+ * Registry for managing VirtualPersistentObjectActions classes
970
+ * Provides methods to register actions classes and execute lifecycle hooks
971
+ */
972
+ class VirtualPersistentObjectActionsRegistry {
973
+ #actionsClasses = new Map();
974
+ #overrideInfo = new Map();
975
+ /**
976
+ * Registers a VirtualPersistentObjectActions class for a specific type
977
+ * @param type - The PersistentObject type name
978
+ * @param ActionsClass - The VirtualPersistentObjectActions class constructor
979
+ */
980
+ register(type, ActionsClass) {
981
+ this.#actionsClasses.set(type, ActionsClass);
982
+ // Detect overridden methods
983
+ const overriddenMethods = this.#detectOverriddenMethods(ActionsClass);
984
+ this.#overrideInfo.set(type, { overriddenMethods });
985
+ }
986
+ /**
987
+ * Detects which lifecycle methods are overridden in the given class
988
+ * @param ActionsClass - The VirtualPersistentObjectActions class constructor
989
+ * @returns Set of overridden method names
990
+ */
991
+ #detectOverriddenMethods(ActionsClass) {
992
+ const overridden = new Set();
993
+ const baseProto = VirtualPersistentObjectActions.prototype;
994
+ const classProto = ActionsClass.prototype;
995
+ const methodsToCheck = [
996
+ "onSave", "onNew", "onDelete", "onRefresh",
997
+ "onLoad", "onConstruct", "onConstructQuery", "onSelectReference", "onExecuteQuery", "getEntities"
998
+ ];
999
+ for (const methodName of methodsToCheck) {
1000
+ if (classProto[methodName] !== baseProto[methodName])
1001
+ overridden.add(methodName);
1002
+ }
1003
+ return overridden;
1004
+ }
1005
+ /**
1006
+ * Checks if a specific lifecycle method is overridden for a type
1007
+ * @param type - The PersistentObject type name
1008
+ * @param methodName - The lifecycle method name
1009
+ * @returns true if the method is overridden, false otherwise
1010
+ */
1011
+ isMethodOverridden(type, methodName) {
1012
+ const info = this.#overrideInfo.get(type);
1013
+ if (!info)
1014
+ return false;
1015
+ return info.overriddenMethods.has(methodName);
1016
+ }
1017
+ /**
1018
+ * Checks if a type has registered actions
1019
+ * @param type - The PersistentObject type name
1020
+ * @returns true if the type has registered actions, false otherwise
1021
+ */
1022
+ has(type) {
1023
+ return this.#actionsClasses.has(type);
1024
+ }
1025
+ /**
1026
+ * Creates a new instance of the actions class for a type
1027
+ * Returns a default VirtualPersistentObjectActions if no custom actions are registered
1028
+ * @param type - The PersistentObject type name
1029
+ * @returns A new instance of the VirtualPersistentObjectActions class
1030
+ */
1031
+ createInstance(type) {
1032
+ const ActionsClass = this.#actionsClasses.get(type);
1033
+ if (!ActionsClass)
1034
+ return new VirtualPersistentObjectActions();
1035
+ return new ActionsClass();
1036
+ }
1037
+ /**
1038
+ * Executes onConstruct lifecycle hook
1039
+ * @param dto - The PersistentObject DTO
1040
+ * @param conversionContext - The conversion context for type conversions
1041
+ */
1042
+ executeConstruct(dto, conversionContext) {
1043
+ const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1044
+ const instance = this.createInstance(dto.type);
1045
+ instance.onConstruct(wrappedObj);
1046
+ }
1047
+ /**
1048
+ * Executes onLoad lifecycle hook
1049
+ * @param dto - The PersistentObject DTO
1050
+ * @param parent - The parent DTO (or null)
1051
+ * @param conversionContext - The conversion context for type conversions
1052
+ * @returns The updated DTO
1053
+ */
1054
+ async executeLoad(dto, parent, conversionContext) {
1055
+ const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1056
+ // Wrap parent if provided
1057
+ let wrappedParent = null;
1058
+ if (parent) {
1059
+ const parentContext = this.#createConversionContext();
1060
+ wrappedParent = createVirtualPersistentObject(parent, parentContext);
1061
+ }
1062
+ const instance = this.createInstance(dto.type);
1063
+ const result = await instance.onLoad(wrappedObj, wrappedParent);
1064
+ return unwrapVirtualPersistentObject(result);
1065
+ }
1066
+ /**
1067
+ * Executes onNew lifecycle hook
1068
+ * @param dto - The PersistentObject DTO
1069
+ * @param parent - The parent DTO (or null)
1070
+ * @param query - The query DTO (or null)
1071
+ * @param parameters - The parameters (or null)
1072
+ * @param conversionContext - The conversion context for type conversions
1073
+ * @returns The updated DTO
1074
+ */
1075
+ async executeNew(dto, parent, query, parameters, conversionContext) {
1076
+ const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1077
+ // Wrap parent if provided
1078
+ let wrappedParent = null;
1079
+ if (parent) {
1080
+ const parentContext = this.#createConversionContext();
1081
+ wrappedParent = createVirtualPersistentObject(parent, parentContext);
1082
+ }
1083
+ const instance = this.createInstance(dto.type);
1084
+ const result = await instance.onNew(wrappedObj, wrappedParent, query, parameters);
1085
+ return unwrapVirtualPersistentObject(result);
1086
+ }
1087
+ /**
1088
+ * Executes onRefresh lifecycle hook
1089
+ * @param dto - The PersistentObject DTO
1090
+ * @param attribute - The attribute that triggered the refresh (or undefined)
1091
+ * @param conversionContext - The conversion context for type conversions
1092
+ * @returns The updated DTO
1093
+ */
1094
+ async executeRefresh(dto, attribute, conversionContext) {
1095
+ const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1096
+ // Wrap attribute if provided
1097
+ let wrappedAttribute;
1098
+ if (attribute)
1099
+ wrappedAttribute = createVirtualPersistentObjectAttribute(attribute, conversionContext);
1100
+ const instance = this.createInstance(dto.type);
1101
+ const result = await instance.onRefresh(wrappedObj, wrappedAttribute);
1102
+ return unwrapVirtualPersistentObject(result);
1103
+ }
1104
+ /**
1105
+ * Executes onSave lifecycle hook
1106
+ * @param dto - The PersistentObject DTO
1107
+ * @param conversionContext - The conversion context for type conversions
1108
+ * @returns The updated DTO
1109
+ */
1110
+ async executeSave(dto, conversionContext) {
1111
+ const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1112
+ const instance = this.createInstance(dto.type);
1113
+ const result = await instance.onSave(wrappedObj);
1114
+ return unwrapVirtualPersistentObject(result);
1115
+ }
1116
+ /**
1117
+ * Executes onSelectReference lifecycle hook
1118
+ * @param parent - The parent DTO
1119
+ * @param referenceAttribute - The reference attribute DTO
1120
+ * @param query - The query DTO
1121
+ * @param selectedItem - The selected item (or null)
1122
+ * @param conversionContext - The conversion context for type conversions
1123
+ */
1124
+ async executeSelectReference(parent, referenceAttribute, query, selectedItem, conversionContext) {
1125
+ const wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1126
+ const instance = this.createInstance(parent.type);
1127
+ await instance.onSelectReference(wrappedParent, referenceAttribute, query, selectedItem);
1128
+ }
1129
+ /**
1130
+ * Executes onDelete lifecycle hook
1131
+ * @param parent - The parent DTO (or null)
1132
+ * @param query - The query DTO
1133
+ * @param selectedItems - The selected items
1134
+ */
1135
+ async executeDelete(parent, query, selectedItems) {
1136
+ // Wrap parent if provided
1137
+ let wrappedParent = null;
1138
+ if (parent) {
1139
+ const conversionContext = this.#createConversionContext();
1140
+ wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1141
+ }
1142
+ // Get type from query's persistentObject
1143
+ const type = query.persistentObject?.type;
1144
+ if (!type)
1145
+ throw new Error("Query does not have a persistentObject type");
1146
+ const instance = this.createInstance(type);
1147
+ await instance.onDelete(wrappedParent, query, selectedItems);
1148
+ }
1149
+ /**
1150
+ * Executes onConstructQuery lifecycle hook
1151
+ * @param query - The query DTO
1152
+ * @param parent - The parent DTO (or null)
1153
+ */
1154
+ executeConstructQuery(query, parent) {
1155
+ // Wrap parent if provided
1156
+ let wrappedParent = null;
1157
+ if (parent) {
1158
+ const conversionContext = this.#createConversionContext();
1159
+ wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1160
+ }
1161
+ // Get type from query's persistentObject
1162
+ const type = query.persistentObject?.type;
1163
+ if (!type)
1164
+ throw new Error("Query does not have a persistentObject type");
1165
+ const instance = this.createInstance(type);
1166
+ instance.onConstructQuery(query, wrappedParent);
1167
+ }
1168
+ /**
1169
+ * Executes onExecuteQuery lifecycle hook
1170
+ * @param query - The query DTO with textSearch, sortOptions, skip, top set
1171
+ * @param parent - The parent DTO (or null)
1172
+ * @param data - Default data from the query config (used if getEntities is not overridden)
1173
+ * @returns The query execution result with items and totalItems
1174
+ */
1175
+ async executeQuery(query, parent, data) {
1176
+ // Wrap parent if provided
1177
+ let wrappedParent = null;
1178
+ if (parent) {
1179
+ const conversionContext = this.#createConversionContext();
1180
+ wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1181
+ }
1182
+ // Get type from query's persistentObject
1183
+ const type = query.persistentObject?.type;
1184
+ if (!type)
1185
+ throw new Error("Query does not have a persistentObject type");
1186
+ const instance = this.createInstance(type);
1187
+ return await instance.onExecuteQuery(query, wrappedParent, data);
1188
+ }
1189
+ /**
1190
+ * Creates a conversion context for type conversions
1191
+ * @returns A ConversionContext with type conversion methods
1192
+ */
1193
+ #createConversionContext() {
1194
+ return {
1195
+ getConvertedValue: (attr) => {
1196
+ return fromServiceValue(attr.value, attr.type);
1197
+ },
1198
+ setConvertedValue: (attr, value) => {
1199
+ attr.value = toServiceValue(value, attr.type);
1200
+ attr.isValueChanged = true;
1201
+ }
1202
+ };
1203
+ }
1204
+ }
1205
+
1206
+ /**
1207
+ * Business rule validator that supports built-in and custom rules
1208
+ */
1209
+ class BusinessRuleValidator {
1210
+ #builtInRules = new Map();
1211
+ #customRules = new Map();
1212
+ constructor() {
1213
+ // Register all built-in rules
1214
+ this.#builtInRules.set("IsBase64", this.#validateIsBase64.bind(this));
1215
+ this.#builtInRules.set("IsEmail", this.#validateIsEmail.bind(this));
1216
+ this.#builtInRules.set("IsRegex", this.#validateIsRegex.bind(this));
1217
+ this.#builtInRules.set("IsUrl", this.#validateIsUrl.bind(this));
1218
+ this.#builtInRules.set("IsWord", this.#validateIsWord.bind(this));
1219
+ this.#builtInRules.set("MaxLength", this.#validateMaxLength.bind(this));
1220
+ this.#builtInRules.set("MaxValue", this.#validateMaxValue.bind(this));
1221
+ this.#builtInRules.set("MinLength", this.#validateMinLength.bind(this));
1222
+ this.#builtInRules.set("MinValue", this.#validateMinValue.bind(this));
1223
+ this.#builtInRules.set("NotEmpty", this.#validateNotEmpty.bind(this));
1224
+ this.#builtInRules.set("Required", this.#validateNotEmpty.bind(this)); // Required is same as NotEmpty
1225
+ }
1226
+ /**
1227
+ * Register a custom business rule
1228
+ * @throws Error if attempting to override a built-in rule
1229
+ */
1230
+ registerCustomRule(name, validator) {
1231
+ if (this.#builtInRules.has(name))
1232
+ throw new Error(`Cannot override built-in rule: ${name}`);
1233
+ this.#customRules.set(name, validator);
1234
+ }
1235
+ /**
1236
+ * Validate an attribute against its rules
1237
+ * @returns Error message if validation fails, null if valid
1238
+ */
1239
+ validateAttribute(attr) {
1240
+ // Check isRequired first
1241
+ if (attr.isRequired) {
1242
+ try {
1243
+ this.#validateNotEmpty(attr.value);
1244
+ }
1245
+ catch (error) {
1246
+ return error instanceof Error ? error.message : String(error);
1247
+ }
1248
+ }
1249
+ // Parse and validate rules string
1250
+ if (!attr.rules)
1251
+ return null;
1252
+ const rules = this.parseRules(attr.rules);
1253
+ for (const rule of rules) {
1254
+ const validator = this.#builtInRules.get(rule.name) || this.#customRules.get(rule.name);
1255
+ if (!validator)
1256
+ throw new Error(`Unknown business rule: ${rule.name}`);
1257
+ try {
1258
+ validator(attr.value, ...rule.params);
1259
+ }
1260
+ catch (error) {
1261
+ return error instanceof Error ? error.message : String(error);
1262
+ }
1263
+ }
1264
+ return null;
1265
+ }
1266
+ /**
1267
+ * Parse a rules string into individual rules with parameters
1268
+ * Example: "NotEmpty; MaxLength(40); IsEmail" -> [
1269
+ * { name: "NotEmpty", params: [] },
1270
+ * { name: "MaxLength", params: [40] },
1271
+ * { name: "IsEmail", params: [] }
1272
+ * ]
1273
+ */
1274
+ parseRules(rulesString) {
1275
+ if (!rulesString?.trim())
1276
+ return [];
1277
+ return rulesString
1278
+ .split(";")
1279
+ .map(rule => rule.trim())
1280
+ .filter(rule => rule.length > 0)
1281
+ .map(rule => this.#parseRule(rule));
1282
+ }
1283
+ #parseRule(ruleString) {
1284
+ const match = ruleString.match(/^(\w+)(?:\(([^)]*)\))?$/);
1285
+ if (!match)
1286
+ throw new Error(`Invalid rule format: ${ruleString}`);
1287
+ const name = match[1];
1288
+ const paramsString = match[2];
1289
+ if (!paramsString) {
1290
+ return { name, params: [] };
1291
+ }
1292
+ // Parse parameters (can be comma-separated)
1293
+ const params = paramsString
1294
+ .split(",")
1295
+ .map(p => p.trim())
1296
+ .map(p => {
1297
+ // Try to parse as number
1298
+ const num = Number(p);
1299
+ if (p !== "" && !isNaN(num))
1300
+ return num;
1301
+ // Try to parse as boolean
1302
+ if (p === "true")
1303
+ return true;
1304
+ if (p === "false")
1305
+ return false;
1306
+ // Otherwise return as string (remove quotes if present)
1307
+ return p.replace(/^["']|["']$/g, "");
1308
+ });
1309
+ return { name, params };
1310
+ }
1311
+ // Built-in validators - throw errors instead of returning strings
1312
+ #validateIsBase64(value) {
1313
+ if (value == null || value === "")
1314
+ return;
1315
+ const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1316
+ if (!base64Regex.test(String(value)))
1317
+ throw new Error("Value must be a valid base64 string");
1318
+ }
1319
+ #validateIsEmail(value) {
1320
+ if (value == null || value === "")
1321
+ return;
1322
+ // Only allow ASCII characters in email addresses
1323
+ const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
1324
+ if (!emailRegex.test(String(value)))
1325
+ throw new Error("Email format is invalid");
1326
+ }
1327
+ #validateIsRegex(value) {
1328
+ if (value == null || value === "")
1329
+ return;
1330
+ try {
1331
+ new RegExp(String(value));
1332
+ }
1333
+ catch {
1334
+ throw new Error("Value must be a valid regular expression");
1335
+ }
1336
+ }
1337
+ #validateIsUrl(value) {
1338
+ if (value == null || value === "")
1339
+ return;
1340
+ try {
1341
+ new URL(String(value));
1342
+ }
1343
+ catch {
1344
+ throw new Error("Value must be a valid URL");
1345
+ }
1346
+ }
1347
+ #validateIsWord(value) {
1348
+ if (value == null || value === "")
1349
+ return;
1350
+ const wordRegex = /^\w+$/;
1351
+ if (!wordRegex.test(String(value)))
1352
+ throw new Error("Value must contain only word characters");
1353
+ }
1354
+ #validateMaxLength(value, maxLength) {
1355
+ if (value == null || value === "")
1356
+ return;
1357
+ const length = String(value).length;
1358
+ if (length > maxLength)
1359
+ throw new Error(`Maximum length is ${maxLength} characters`);
1360
+ }
1361
+ #validateMaxValue(value, maximum) {
1362
+ if (value == null || value === "")
1363
+ return;
1364
+ const num = Number(value);
1365
+ if (isNaN(num))
1366
+ throw new Error("Value must be a number");
1367
+ if (num > maximum)
1368
+ throw new Error(`Maximum value is ${maximum}`);
1369
+ }
1370
+ #validateMinLength(value, minLength) {
1371
+ if (value == null || value === "")
1372
+ return;
1373
+ const length = String(value).length;
1374
+ if (length < minLength)
1375
+ throw new Error(`Minimum length is ${minLength} characters`);
1376
+ }
1377
+ #validateMinValue(value, minimum) {
1378
+ if (value == null || value === "")
1379
+ return;
1380
+ const num = Number(value);
1381
+ if (isNaN(num))
1382
+ throw new Error("Value must be a number");
1383
+ if (num < minimum)
1384
+ throw new Error(`Minimum value is ${minimum}`);
1385
+ }
1386
+ #validateNotEmpty(value) {
1387
+ if (value == null || value === "" || (typeof value === "string" && value.trim() === ""))
1388
+ throw new Error("This field is required");
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * Virtual implementation of ServiceHooks for testing without a backend
1394
+ */
1395
+ class VirtualServiceHooks extends ServiceHooks {
1396
+ #persistentObjectRegistry;
1397
+ #queryRegistry;
1398
+ #actionDefinitions = new Map();
1399
+ #actionHandlers = new Map();
1400
+ #validator;
1401
+ #persistentObjectActionsRegistry;
1402
+ #builtInActions = new Set(["New", "Delete", "SelectReference", "RefreshQuery", "Edit", "CancelEdit", "Save", "EndEdit"]);
1403
+ constructor() {
1404
+ super();
1405
+ this.#validator = new BusinessRuleValidator();
1406
+ this.#persistentObjectActionsRegistry = new VirtualPersistentObjectActionsRegistry();
1407
+ this.#queryRegistry = new VirtualQueryRegistry(this.#persistentObjectActionsRegistry);
1408
+ this.#persistentObjectRegistry = new VirtualPersistentObjectRegistry(this.#validator, this.#actionHandlers, this.#queryRegistry, this.#persistentObjectActionsRegistry);
1409
+ // Register default action definitions (these are built-in actions without custom handlers)
1410
+ this.#actionDefinitions.set("AddReference", { name: "AddReference", displayName: "Add", isPinned: false });
1411
+ this.#actionDefinitions.set("BulkEdit", { name: "BulkEdit", displayName: "Edit", isPinned: false });
1412
+ this.#actionDefinitions.set("CancelEdit", { name: "CancelEdit", displayName: "Cancel", isPinned: false });
1413
+ this.#actionDefinitions.set("CancelSave", { name: "CancelSave", displayName: "Cancel", isPinned: false });
1414
+ this.#actionDefinitions.set("Delete", { name: "Delete", displayName: "Delete", isPinned: false });
1415
+ this.#actionDefinitions.set("Edit", { name: "Edit", displayName: "Edit", isPinned: false });
1416
+ this.#actionDefinitions.set("EndEdit", { name: "EndEdit", displayName: "Save", isPinned: false });
1417
+ this.#actionDefinitions.set("Filter", { name: "Filter", displayName: "", isPinned: false });
1418
+ this.#actionDefinitions.set("New", { name: "New", displayName: "New", isPinned: false });
1419
+ this.#actionDefinitions.set("RefreshQuery", { name: "RefreshQuery", displayName: "", isPinned: false });
1420
+ this.#actionDefinitions.set("Remove", { name: "Remove", displayName: "Remove", isPinned: false });
1421
+ this.#actionDefinitions.set("Save", { name: "Save", displayName: "Save", isPinned: false });
1422
+ this.#actionDefinitions.set("SelectReference", { name: "SelectReference", displayName: "Select", isPinned: false });
1423
+ }
1424
+ /**
1425
+ * Intercepts fetch requests and routes them to virtual handlers
1426
+ */
1427
+ async onFetch(request) {
1428
+ const url = new URL(request.url);
1429
+ const pathname = url.pathname;
1430
+ const method = pathname.includes("GetClientData") ? "GetClientData" : pathname.split('/').pop();
1431
+ const body = request.method === "POST" ? await request.clone().json() : null;
1432
+ let result;
1433
+ try {
1434
+ // Route to appropriate handler based on service method
1435
+ switch (method) {
1436
+ case "GetClientData":
1437
+ result = this.#handleGetClientData();
1438
+ break;
1439
+ case "GetApplication":
1440
+ result = this.#handleGetApplication();
1441
+ break;
1442
+ case "GetQuery":
1443
+ result = await this.#handleGetQuery(body);
1444
+ break;
1445
+ case "ExecuteQuery":
1446
+ result = await this.#handleExecuteQuery(body);
1447
+ break;
1448
+ case "GetPersistentObject":
1449
+ result = await this.#handleGetPersistentObject(body);
1450
+ break;
1451
+ case "ExecuteAction":
1452
+ result = await this.#handleExecuteAction(body);
1453
+ break;
1454
+ default:
1455
+ throw new Error(`Virtual handler not implemented for method: ${method}`);
1456
+ }
1457
+ return new Response(JSON.stringify(result), {
1458
+ headers: { "content-type": "application/json" }
1459
+ });
1460
+ }
1461
+ catch (error) {
1462
+ const errorResponse = {
1463
+ exception: error instanceof Error ? error.message : String(error)
1464
+ };
1465
+ // Return 200 OK with exception in JSON body (like real service does)
1466
+ return new Response(JSON.stringify(errorResponse), {
1467
+ status: 200,
1468
+ headers: { "content-type": "application/json" }
1469
+ });
1470
+ }
1471
+ }
1472
+ /**
1473
+ * Registers a PersistentObject configuration
1474
+ */
1475
+ registerPersistentObject(config) {
1476
+ // Validate configuration
1477
+ if (!config.type)
1478
+ throw new Error("VirtualPersistentObjectConfig.type is required");
1479
+ if (!config.attributes || config.attributes.length === 0)
1480
+ throw new Error("VirtualPersistentObjectConfig.attributes must have at least one attribute");
1481
+ // Validate that referenced actions are registered (skip built-in actions)
1482
+ if (config.actions) {
1483
+ config.actions.forEach(actionName => {
1484
+ if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1485
+ throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
1486
+ });
1487
+ }
1488
+ // Validate that referenced queries are registered
1489
+ if (config.queries) {
1490
+ config.queries.forEach(queryName => {
1491
+ if (!this.#queryRegistry.hasQuery(queryName))
1492
+ throw new Error(`Query "${queryName}" is not registered. Call registerQuery first.`);
1493
+ });
1494
+ }
1495
+ // Validate that lookup queries for reference attributes are registered
1496
+ if (config.attributes) {
1497
+ config.attributes.forEach(attr => {
1498
+ if (attr.lookup) {
1499
+ if (!this.#queryRegistry.hasQuery(attr.lookup))
1500
+ throw new Error(`Lookup query "${attr.lookup}" for attribute "${attr.name}" is not registered. Call registerQuery first.`);
1501
+ }
1502
+ });
1503
+ }
1504
+ // Register with registry
1505
+ this.#persistentObjectRegistry.register(config);
1506
+ }
1507
+ /**
1508
+ * Registers a Query configuration
1509
+ */
1510
+ registerQuery(config) {
1511
+ // Validate configuration
1512
+ if (!config.name)
1513
+ throw new Error("VirtualQueryConfig.name is required");
1514
+ if (!config.persistentObject)
1515
+ throw new Error("VirtualQueryConfig.persistentObject is required");
1516
+ // Verify PersistentObject is already registered
1517
+ const persistentObjectConfig = this.#persistentObjectRegistry.getConfig(config.persistentObject);
1518
+ if (!persistentObjectConfig)
1519
+ throw new Error(`PersistentObject type '${config.persistentObject}' must be registered before creating a query. Call registerPersistentObject first.`);
1520
+ // Validate that referenced actions are registered (skip built-in actions)
1521
+ if (config.actions) {
1522
+ config.actions.forEach(actionName => {
1523
+ if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1524
+ throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
1525
+ });
1526
+ }
1527
+ if (config.itemActions) {
1528
+ config.itemActions.forEach(actionName => {
1529
+ if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1530
+ throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
1531
+ });
1532
+ }
1533
+ // Register with query registry
1534
+ this.#queryRegistry.register(config, persistentObjectConfig);
1535
+ }
1536
+ /**
1537
+ * Registers a custom action that can be used on PersistentObjects and Queries
1538
+ * @param config - The action configuration with handler
1539
+ */
1540
+ registerAction(config) {
1541
+ // Validate configuration
1542
+ if (!config.name)
1543
+ throw new Error("ActionConfig.name is required");
1544
+ if (!config.handler)
1545
+ throw new Error("ActionConfig.handler is required");
1546
+ // Register action definition
1547
+ this.#actionDefinitions.set(config.name, {
1548
+ name: config.name,
1549
+ displayName: config.displayName || config.name,
1550
+ isPinned: config.isPinned || false
1551
+ });
1552
+ // Register action handler
1553
+ this.#actionHandlers.set(config.name, config.handler);
1554
+ }
1555
+ /**
1556
+ * Registers a custom business rule for validation
1557
+ * @param name - The rule name (cannot override built-in rules)
1558
+ * @param validator - The validation function
1559
+ */
1560
+ registerBusinessRule(name, validator) {
1561
+ this.#validator.registerCustomRule(name, validator);
1562
+ }
1563
+ /**
1564
+ * Registers a VirtualPersistentObjectActions class for a specific type
1565
+ * @param type - The PersistentObject type name
1566
+ * @param ActionsClass - The VirtualPersistentObjectActions class constructor
1567
+ */
1568
+ registerPersistentObjectActions(type, ActionsClass) {
1569
+ this.#persistentObjectActionsRegistry.register(type, ActionsClass);
1570
+ }
1571
+ /**
1572
+ * Handles GetClientData requests
1573
+ */
1574
+ #handleGetClientData() {
1575
+ return {
1576
+ defaultUser: "VirtualUser",
1577
+ exception: null,
1578
+ languages: {
1579
+ "en": {
1580
+ name: "English",
1581
+ isDefault: true,
1582
+ messages: {
1583
+ "OK": "OK",
1584
+ "Cancel": "Cancel",
1585
+ "Save": "Save"
1586
+ }
1587
+ }
1588
+ },
1589
+ providers: {
1590
+ "Vidyano": {
1591
+ parameters: {
1592
+ label: "Vidyano",
1593
+ description: "Vidyano Provider",
1594
+ requestUri: null,
1595
+ signOutUri: null
1596
+ }
1597
+ }
1598
+ },
1599
+ windowsAuthentication: false
1600
+ };
1601
+ }
1602
+ /**
1603
+ * Handles GetApplication requests
1604
+ */
1605
+ #handleGetApplication() {
1606
+ // Build action definition items
1607
+ const actionItems = Array.from(this.#actionDefinitions.values()).map(action => ({
1608
+ id: crypto.randomUUID(),
1609
+ values: [
1610
+ { key: "Name", value: action.name },
1611
+ { key: "DisplayName", value: action.displayName },
1612
+ { key: "IsPinned", value: String(action.isPinned) }
1613
+ ]
1614
+ }));
1615
+ // Create a minimal application PersistentObject with required attributes
1616
+ const appPo = {
1617
+ id: crypto.randomUUID(),
1618
+ objectId: "Application",
1619
+ type: "Application",
1620
+ fullTypeName: "Vidyano.Application",
1621
+ label: "Virtual Application",
1622
+ isNew: false,
1623
+ isReadOnly: true,
1624
+ stateBehavior: "StayInEdit",
1625
+ attributes: [
1626
+ { name: "UserId", type: "String", value: "VirtualUserId", id: crypto.randomUUID(), offset: 0, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1627
+ { name: "FriendlyUserName", type: "String", value: "Virtual User", id: crypto.randomUUID(), offset: 1, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1628
+ { name: "FeedbackId", type: "String", value: null, id: crypto.randomUUID(), offset: 2, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1629
+ { name: "UserSettingsId", type: "String", value: null, id: crypto.randomUUID(), offset: 3, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1630
+ { name: "GlobalSearchId", type: "String", value: null, id: crypto.randomUUID(), offset: 4, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1631
+ { name: "AnalyticsKey", type: "String", value: null, id: crypto.randomUUID(), offset: 5, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1632
+ { name: "Routes", type: "String", value: JSON.stringify({ programUnits: {}, persistentObjects: {}, queries: {}, persistentObjectKeys: [], queryKeys: [] }), id: crypto.randomUUID(), offset: 6, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1633
+ { name: "UserSettings", type: "String", value: "{}", id: crypto.randomUUID(), offset: 7, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1634
+ { name: "CanProfile", type: "Boolean", value: "False", id: crypto.randomUUID(), offset: 8, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
1635
+ { name: "ProgramUnits", type: "String", value: JSON.stringify({ hasManagement: false, units: [] }), id: crypto.randomUUID(), offset: 9, label: "", visibility: "Always", toolTip: "", group: "", tab: "" }
1636
+ ],
1637
+ actions: [],
1638
+ tabs: {},
1639
+ queries: [
1640
+ // Empty Resources query
1641
+ {
1642
+ id: crypto.randomUUID(),
1643
+ name: "Resources",
1644
+ label: "Resources",
1645
+ columns: [],
1646
+ persistentObject: {
1647
+ type: "Resource",
1648
+ id: crypto.randomUUID(),
1649
+ objectId: null,
1650
+ label: "Resource",
1651
+ isReadOnly: true,
1652
+ attributes: [],
1653
+ actions: [],
1654
+ tabs: {},
1655
+ queries: [],
1656
+ fullTypeName: "Vidyano.Resource"
1657
+ },
1658
+ result: {
1659
+ charts: [],
1660
+ columns: [],
1661
+ items: [],
1662
+ sortOptions: ""
1663
+ }
1664
+ },
1665
+ // Actions query with registered actions
1666
+ {
1667
+ id: crypto.randomUUID(),
1668
+ name: "Actions",
1669
+ label: "Actions",
1670
+ columns: [
1671
+ { id: "Name", name: "Name", label: "Name", type: "String", offset: 0, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
1672
+ { id: "DisplayName", name: "DisplayName", label: "Display Name", type: "String", offset: 1, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
1673
+ { id: "IsPinned", name: "IsPinned", label: "Is Pinned", type: "Boolean", offset: 2, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] }
1674
+ ],
1675
+ persistentObject: {
1676
+ type: "ActionDefinition",
1677
+ id: crypto.randomUUID(),
1678
+ objectId: null,
1679
+ label: "Action Definition",
1680
+ isReadOnly: true,
1681
+ attributes: [],
1682
+ actions: [],
1683
+ tabs: {},
1684
+ queries: [],
1685
+ fullTypeName: "Vidyano.ActionDefinition"
1686
+ },
1687
+ result: {
1688
+ charts: [],
1689
+ columns: [
1690
+ { id: "Name", name: "Name", label: "Name", type: "String", offset: 0, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
1691
+ { id: "DisplayName", name: "DisplayName", label: "Display Name", type: "String", offset: 1, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
1692
+ { id: "IsPinned", name: "IsPinned", label: "Is Pinned", type: "Boolean", offset: 2, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] }
1693
+ ],
1694
+ items: actionItems,
1695
+ sortOptions: ""
1696
+ }
1697
+ }
1698
+ ]
1699
+ };
1700
+ return {
1701
+ application: appPo,
1702
+ userCultureInfo: "en-US",
1703
+ userLanguage: "en",
1704
+ userName: "VirtualUser",
1705
+ hasSensitive: false
1706
+ };
1707
+ }
1708
+ /**
1709
+ * Handles GetQuery requests
1710
+ */
1711
+ async #handleGetQuery(request) {
1712
+ const queryName = request.id;
1713
+ const queryDto = await this.#queryRegistry.getQuery(queryName);
1714
+ return {
1715
+ query: queryDto
1716
+ };
1717
+ }
1718
+ /**
1719
+ * Handles ExecuteQuery requests
1720
+ */
1721
+ async #handleExecuteQuery(request) {
1722
+ const query = request.query;
1723
+ const result = await this.#queryRegistry.executeQuery(query, request.parent || null);
1724
+ return {
1725
+ result
1726
+ };
1727
+ }
1728
+ /**
1729
+ * Handles GetPersistentObject requests
1730
+ */
1731
+ async #handleGetPersistentObject(request) {
1732
+ const type = request.persistentObjectTypeId;
1733
+ const objectId = request.objectId || crypto.randomUUID();
1734
+ const isNew = request.isNew || false;
1735
+ const parent = request.parent || null;
1736
+ const po = await this.#persistentObjectRegistry.getPersistentObject(type, objectId, isNew, parent);
1737
+ return {
1738
+ result: po
1739
+ };
1740
+ }
1741
+ /**
1742
+ * Handles ExecuteAction requests
1743
+ */
1744
+ async #handleExecuteAction(request) {
1745
+ const actionName = request.action.split(".").pop();
1746
+ // Check if this is a query action
1747
+ const queryActionRequest = request;
1748
+ if (queryActionRequest.query) {
1749
+ // Query action - can have parent or not
1750
+ const queryDto = await this.#queryRegistry.getQuery(queryActionRequest.query.name);
1751
+ return await this.#executeQueryAction(request, queryDto, actionName);
1752
+ }
1753
+ // PersistentObject action - must have parent
1754
+ return await this.#persistentObjectRegistry.executeAction(request);
1755
+ }
1756
+ /**
1757
+ * Executes an action from a query context
1758
+ */
1759
+ async #executeQueryAction(request, query, actionName) {
1760
+ // Note: query.persistentObject is the TEMPLATE/SCHEMA, not the parent!
1761
+ const parent = request.parent;
1762
+ // Handle built-in New action
1763
+ if (actionName === "New") {
1764
+ const type = query.persistentObject?.type;
1765
+ if (!type)
1766
+ throw new Error("Query does not have a persistentObject type");
1767
+ // Create a new PersistentObject with the full lifecycle
1768
+ const newPo = await this.#persistentObjectRegistry.createNewPersistentObject(type, parent, query, request.parameters || null);
1769
+ return {
1770
+ result: newPo
1771
+ };
1772
+ }
1773
+ // Handle built-in SelectReference action
1774
+ if (actionName === "SelectReference") {
1775
+ if (!parent)
1776
+ throw new Error("SelectReference requires a parent PersistentObject");
1777
+ const attributeId = request.parameters?.PersistentObjectAttributeId;
1778
+ if (!attributeId)
1779
+ throw new Error("SelectReference requires PersistentObjectAttributeId parameter");
1780
+ // Find the reference attribute
1781
+ const refAttr = parent.attributes?.find(a => a.id === attributeId);
1782
+ if (!refAttr)
1783
+ throw new Error(`Attribute with id "${attributeId}" not found`);
1784
+ // Get selected items from the request
1785
+ const queryActionRequest = request;
1786
+ const selectedItems = queryActionRequest.selectedItems || [];
1787
+ const selectedItem = selectedItems.length > 0 ? selectedItems[0] : null;
1788
+ // Call onSelectReference - base implementation sets objectId/value
1789
+ const conversionContext = this.#createConversionContext();
1790
+ await this.#persistentObjectActionsRegistry.executeSelectReference(parent, refAttr, query, selectedItem, conversionContext);
1791
+ return {
1792
+ result: parent
1793
+ };
1794
+ }
1795
+ // Handle built-in Delete action
1796
+ if (actionName === "Delete") {
1797
+ const type = query.persistentObject?.type;
1798
+ if (!type)
1799
+ throw new Error("Query does not have a persistentObject type");
1800
+ // Get selected items from the request
1801
+ const queryActionRequest = request;
1802
+ const selectedItems = queryActionRequest.selectedItems || [];
1803
+ if (selectedItems.length === 0)
1804
+ throw new Error("Delete requires at least one selected item");
1805
+ // Call onDelete lifecycle hook
1806
+ await this.#persistentObjectActionsRegistry.executeDelete(parent || null, query, selectedItems);
1807
+ // Return parent or empty result
1808
+ return {
1809
+ result: parent || null
1810
+ };
1811
+ }
1812
+ // Get action handler
1813
+ const handler = this.#actionHandlers.get(actionName);
1814
+ if (!handler)
1815
+ throw new Error(`Action "${actionName}" is not registered`);
1816
+ // Create action context - use parent if provided, otherwise fall back to query's template PO
1817
+ const contextPo = parent || query.persistentObject;
1818
+ const context = this.#createActionContext(contextPo);
1819
+ // Build unified action args
1820
+ const queryActionRequest = request;
1821
+ const args = {
1822
+ parent: parent ?? null,
1823
+ query: query,
1824
+ selectedItems: queryActionRequest.selectedItems,
1825
+ parameters: request.parameters,
1826
+ context: context
1827
+ };
1828
+ // Execute handler
1829
+ const result = await handler(args);
1830
+ // Handle result - use parent if available, otherwise use template
1831
+ const finalResult = this.#normalizeActionResult(result, contextPo);
1832
+ return {
1833
+ result: finalResult
1834
+ };
1835
+ }
1836
+ /**
1837
+ * Creates an action context for attribute manipulation
1838
+ */
1839
+ #createActionContext(po) {
1840
+ return {
1841
+ getAttribute: (name) => {
1842
+ return po.attributes?.find(a => a.name === name);
1843
+ },
1844
+ getAttributeValue: (name) => {
1845
+ const attr = po.attributes?.find(a => a.name === name);
1846
+ if (!attr)
1847
+ return undefined;
1848
+ return fromServiceValue(attr.value, attr.type);
1849
+ },
1850
+ setAttributeValue: (name, value) => {
1851
+ const attr = po.attributes?.find(a => a.name === name);
1852
+ if (attr) {
1853
+ attr.value = toServiceValue(value, attr.type);
1854
+ attr.isValueChanged = true;
1855
+ }
1856
+ },
1857
+ getConvertedValue: (attr) => {
1858
+ return fromServiceValue(attr.value, attr.type);
1859
+ },
1860
+ setConvertedValue: (attr, value) => {
1861
+ attr.value = toServiceValue(value, attr.type);
1862
+ attr.isValueChanged = true;
1863
+ },
1864
+ setValidationError: (name, error) => {
1865
+ const attr = po.attributes?.find(a => a.name === name);
1866
+ if (attr)
1867
+ attr.validationError = error;
1868
+ },
1869
+ clearValidationError: (name) => {
1870
+ const attr = po.attributes?.find(a => a.name === name);
1871
+ if (attr)
1872
+ attr.validationError = undefined;
1873
+ },
1874
+ setNotification: (message, type, duration) => {
1875
+ po.notification = message;
1876
+ po.notificationType = type;
1877
+ po.notificationDuration = duration;
1878
+ }
1879
+ };
1880
+ }
1881
+ /**
1882
+ * Creates a conversion context for type conversions
1883
+ */
1884
+ #createConversionContext() {
1885
+ return {
1886
+ getConvertedValue: (attr) => {
1887
+ return fromServiceValue(attr.value, attr.type);
1888
+ },
1889
+ setConvertedValue: (attr, value) => {
1890
+ attr.value = toServiceValue(value, attr.type);
1891
+ attr.isValueChanged = true;
1892
+ }
1893
+ };
1894
+ }
1895
+ /**
1896
+ * Normalizes action result (handles both old and new API formats)
1897
+ */
1898
+ #normalizeActionResult(result, defaultParent) {
1899
+ // Handle both old and new API formats for backwards compatibility
1900
+ // Old API: handler returns { result: PersistentObjectDto }
1901
+ // New API: handler returns PersistentObjectDto | null
1902
+ if (result && typeof result === "object" && "result" in result) {
1903
+ // Old API format - extract the result property
1904
+ return result.result || defaultParent;
1905
+ }
1906
+ // New API format - use directly
1907
+ return result || defaultParent;
1908
+ }
1909
+ }
1910
+
1911
+ /**
1912
+ * A virtual service for testing without a backend.
1913
+ *
1914
+ * Register PersistentObjects, Queries, Actions, and BusinessRules before calling initialize().
1915
+ * Once initialize() is called, no more registrations are allowed.
1916
+ *
1917
+ * @example
1918
+ * ```typescript
1919
+ * const service = new VirtualService();
1920
+ * service.registerPersistentObject({ type: "Person", attributes: [...] });
1921
+ * service.registerQuery({ name: "AllPersons", persistentObject: "Person" });
1922
+ * await service.initialize();
1923
+ *
1924
+ * // Now use the service
1925
+ * const query = await service.getQuery("AllPersons");
1926
+ * ```
1927
+ */
1928
+ class VirtualService extends Service {
1929
+ #isInitialized = false;
1930
+ /**
1931
+ * Creates a new VirtualService instance.
1932
+ * @param hooks - Optional custom VirtualServiceHooks. If not provided, a default instance is created.
1933
+ */
1934
+ constructor(hooks) {
1935
+ super("http://virtual.local", hooks ?? new VirtualServiceHooks(), true);
1936
+ }
1937
+ /**
1938
+ * Gets the VirtualServiceHooks instance.
1939
+ */
1940
+ get virtualHooks() {
1941
+ return this.hooks;
1942
+ }
1943
+ async initialize(arg) {
1944
+ this.#isInitialized = true;
1945
+ return super.initialize(arg);
1946
+ }
1947
+ /**
1948
+ * Registers a PersistentObject configuration.
1949
+ * Must be called before initialize().
1950
+ * @param config - The PersistentObject configuration.
1951
+ * @throws Error if called after initialize().
1952
+ */
1953
+ registerPersistentObject(config) {
1954
+ this.#ensureNotInitialized();
1955
+ this.virtualHooks.registerPersistentObject(config);
1956
+ }
1957
+ /**
1958
+ * Registers a Query configuration.
1959
+ * Must be called before initialize().
1960
+ * @param config - The Query configuration.
1961
+ * @throws Error if called after initialize().
1962
+ */
1963
+ registerQuery(config) {
1964
+ this.#ensureNotInitialized();
1965
+ this.virtualHooks.registerQuery(config);
1966
+ }
1967
+ /**
1968
+ * Registers a custom action that can be used on PersistentObjects and Queries.
1969
+ * Must be called before initialize().
1970
+ * @param config - The action configuration with handler.
1971
+ * @throws Error if called after initialize().
1972
+ */
1973
+ registerAction(config) {
1974
+ this.#ensureNotInitialized();
1975
+ this.virtualHooks.registerAction(config);
1976
+ }
1977
+ /**
1978
+ * Registers a custom business rule for validation.
1979
+ * Must be called before initialize().
1980
+ * @param name - The rule name (cannot override built-in rules).
1981
+ * @param validator - The validation function.
1982
+ * @throws Error if called after initialize().
1983
+ */
1984
+ registerBusinessRule(name, validator) {
1985
+ this.#ensureNotInitialized();
1986
+ this.virtualHooks.registerBusinessRule(name, validator);
1987
+ }
1988
+ /**
1989
+ * Registers a VirtualPersistentObjectActions class for a specific type.
1990
+ * Must be called before initialize().
1991
+ * @param type - The PersistentObject type name.
1992
+ * @param ActionsClass - The VirtualPersistentObjectActions class constructor.
1993
+ * @throws Error if called after initialize().
1994
+ */
1995
+ registerPersistentObjectActions(type, ActionsClass) {
1996
+ this.#ensureNotInitialized();
1997
+ this.virtualHooks.registerPersistentObjectActions(type, ActionsClass);
1998
+ }
1999
+ /**
2000
+ * Throws an error if the service has already been initialized.
2001
+ */
2002
+ #ensureNotInitialized() {
2003
+ if (this.#isInitialized)
2004
+ throw new Error("Cannot register after initialize() has been called");
2005
+ }
2006
+ }
2007
+
2008
+ export { VirtualPersistentObjectActions, VirtualService, VirtualServiceHooks };