@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/README.md ADDED
@@ -0,0 +1,1116 @@
1
+ # @vidyano-labs/virtual-service
2
+
3
+ A virtual service implementation for testing Vidyano applications without requiring a backend server. Perfect for unit tests, integration tests, and rapid prototyping.
4
+
5
+ > **Note:** The virtual service uses the exact same DTO types as the real backend, ensuring your tests accurately reflect production behavior.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @vidyano-labs/virtual-service
11
+ ```
12
+
13
+ ## Peer Dependencies
14
+
15
+ This package requires `@vidyano/core` as a peer dependency:
16
+
17
+ ```bash
18
+ npm install @vidyano/core
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { VirtualService } from "@vidyano-labs/virtual-service";
25
+
26
+ // Create virtual service
27
+ const service = new VirtualService();
28
+
29
+ // Register a mock persistent object type
30
+ service.registerPersistentObject({
31
+ type: "Person",
32
+ label: "Person",
33
+ attributes: [
34
+ { name: "FirstName", type: "String", value: "John" },
35
+ { name: "LastName", type: "String", value: "Smith" },
36
+ { name: "Email", type: "String", rules: "IsEmail" }
37
+ ]
38
+ });
39
+
40
+ // Initialize (must be called after all registrations)
41
+ await service.initialize();
42
+
43
+ // Load the mock object - works exactly like the real backend
44
+ const person = await service.getPersistentObject(null, "Person", "123");
45
+ console.log(person.getAttribute("FirstName").value); // "John"
46
+ ```
47
+
48
+ ## Core Concepts
49
+
50
+ ### VirtualService
51
+
52
+ The main class that provides mock backend functionality. It extends `Service` to plug into Vidyano's service layer seamlessly.
53
+
54
+ ```typescript
55
+ const service = new VirtualService();
56
+ ```
57
+
58
+ **Key methods:**
59
+ - `registerPersistentObject(config)` - Register a mock persistent object type
60
+ - `registerQuery(config)` - Register a mock query
61
+ - `registerAction(config)` - Register a custom action handler
62
+ - `registerBusinessRule(name, validator)` - Add custom validation rules
63
+ - `registerPersistentObjectActions(type, ActionsClass)` - Register lifecycle handlers
64
+ - `initialize()` - Finalize registrations (must call before using service)
65
+
66
+ > **Important:** All registrations must happen BEFORE calling `initialize()`. Attempting to register after initialization throws an error.
67
+
68
+ ### Registration Order
69
+
70
+ Dependencies must be registered before the things that reference them:
71
+
72
+ 1. **Actions** first - Custom actions must be registered before PersistentObjects/Queries that reference them
73
+ 2. **PersistentObjects** - Define the data schema
74
+ 3. **Queries** - Must reference an already-registered PersistentObject type
75
+ 4. **PersistentObjectActions** - Lifecycle handlers (can be registered anytime before initialize)
76
+
77
+ ```typescript
78
+ // Correct order
79
+ service.registerPersistentObject({ type: "Person", attributes: [...] });
80
+ service.registerQuery({ name: "AllPeople", persistentObject: "Person", ... });
81
+ await service.initialize();
82
+
83
+ // Wrong - query references unknown type
84
+ service.registerQuery({ name: "AllPeople", persistentObject: "Person" }); // Error!
85
+ service.registerPersistentObject({ type: "Person", attributes: [...] });
86
+ ```
87
+
88
+ > **Note:** If a PersistentObject has attributes with `lookup` queries or detail `queries`, those queries must be registered first.
89
+
90
+ ## Defining Persistent Objects
91
+
92
+ Persistent objects are the core data structures in Vidyano. Each represents a type of business entity with attributes, tabs, validation rules, and behaviors.
93
+
94
+ ### Basic Structure
95
+
96
+ ```typescript
97
+ service.registerPersistentObject({
98
+ type: "Contact", // Required: unique type identifier
99
+ label: "Contact", // Display name (defaults to type)
100
+ attributes: [...], // Fields that hold data
101
+ tabs: {...}, // Optional: organize attributes into tabs
102
+ queries: [...], // Optional: detail queries (master-detail)
103
+ stateBehavior: "None" // Optional: controls edit mode behavior
104
+ });
105
+ ```
106
+
107
+ ### Attributes
108
+
109
+ Attributes define the fields of a persistent object. Each attribute has a name, type, and optional configuration:
110
+
111
+ ```typescript
112
+ service.registerPersistentObject({
113
+ type: "Contact",
114
+ attributes: [
115
+ // Minimal attribute - name only (type defaults to "String")
116
+ { name: "FirstName" },
117
+
118
+ // With explicit type and initial value
119
+ { name: "Age", type: "Int32", value: 25 },
120
+
121
+ // With custom label
122
+ { name: "Email", type: "String", label: "Email Address" },
123
+
124
+ // Required field
125
+ { name: "Company", type: "String", isRequired: true },
126
+
127
+ // Read-only field
128
+ { name: "CreatedDate", type: "DateTime", isReadOnly: true }
129
+ ]
130
+ });
131
+ ```
132
+
133
+ ### Attribute Types
134
+
135
+ Common attribute types supported by the virtual service:
136
+
137
+ | Type | Use For | Example Value |
138
+ |------|---------|---------------|
139
+ | `String` | Text, names, emails | `"John Doe"` |
140
+ | `Int32` | Whole numbers | `42` |
141
+ | `Int64` | Large integers | `9007199254740991` |
142
+ | `Decimal` | Decimal numbers | `19.99` |
143
+ | `Double` | Floating-point numbers | `3.14159` |
144
+ | `Boolean` | True/false values | `true` or `"True"` |
145
+ | `DateTime` | Dates and times | `"2026-01-23T10:00:00"` |
146
+ | `Date` | Dates only | `"2026-01-23"` |
147
+ | `Byte` | Small integers (0-255) | `128` |
148
+
149
+ > **Note:** Values are stored as strings in DTOs but converted to JavaScript types when accessed through `getValue()`. Boolean attributes accept both native booleans and string values `"True"`/`"False"`.
150
+
151
+ ### Attribute Configuration
152
+
153
+ Full attribute configuration options:
154
+
155
+ ```typescript
156
+ {
157
+ name: "Email", // Required: attribute name
158
+ type: "String", // Data type (default: "String")
159
+ label: "Email Address", // Display label (defaults to name)
160
+ value: "default@example.com", // Initial value
161
+ isRequired: true, // Makes attribute required
162
+ isReadOnly: false, // Makes attribute read-only
163
+ visibility: "Always", // When attribute is visible
164
+ triggersRefresh: false, // Triggers onRefresh when changed
165
+ rules: "NotEmpty; IsEmail", // Validation rules (semicolon-separated)
166
+ tab: "", // Tab key (empty string = default tab)
167
+ group: "Contact Info", // Group within tab
168
+ column: 0, // Column position
169
+ columnSpan: 4, // Column width (default: 4)
170
+ offset: 0, // Sort order offset
171
+ canSort: true, // Allow sorting in queries
172
+ options: ["Option1", "Option2"], // Predefined options for selection
173
+ typeHints: { "key": "value" }, // Type hints for the attribute
174
+ lookup: "AllContacts", // Query for reference lookups
175
+ displayAttribute: "Name", // Display attribute for references
176
+ canAddNewReference: false, // Allow adding new references
177
+ selectInPlace: false // Use fixed list for references
178
+ }
179
+ ```
180
+
181
+ ### Attribute Layout
182
+
183
+ Control how attributes are organized in tabs and groups:
184
+
185
+ ```typescript
186
+ service.registerPersistentObject({
187
+ type: "Person",
188
+ tabs: {
189
+ "General": { name: "General", columnCount: 0 },
190
+ "Address": { name: "Address", columnCount: 0 }
191
+ },
192
+ attributes: [
193
+ // General tab, Contact group
194
+ {
195
+ name: "FirstName",
196
+ type: "String",
197
+ tab: "General", // Tab key
198
+ group: "Contact", // Group name
199
+ column: 0, // Column position
200
+ columnSpan: 2 // Width (default: 4)
201
+ },
202
+ {
203
+ name: "Email",
204
+ type: "String",
205
+ tab: "General",
206
+ group: "Contact",
207
+ column: 2,
208
+ columnSpan: 2
209
+ },
210
+
211
+ // Address tab
212
+ {
213
+ name: "Street",
214
+ type: "String",
215
+ tab: "Address",
216
+ group: "Location"
217
+ }
218
+ ]
219
+ });
220
+ ```
221
+
222
+ > **Note:** Using an empty string `""` as the tab key creates a "default" tab. However, the tab's displayed label will be the PersistentObject's label (e.g., "Person"), not the tab's `name` property. Use explicit tab keys like `"General"` for predictable labeling.
223
+
224
+ ## Attribute Visibility
225
+
226
+ Control when attributes appear based on the object's state:
227
+
228
+ ```typescript
229
+ service.registerPersistentObject({
230
+ type: "User",
231
+ attributes: [
232
+ // Always visible (default)
233
+ { name: "Username", type: "String", visibility: "Always" },
234
+
235
+ // Only when creating new objects
236
+ { name: "Password", type: "String", visibility: "New" },
237
+
238
+ // Only when viewing existing objects
239
+ { name: "CreatedDate", type: "DateTime", visibility: "Read" },
240
+
241
+ // Only in query columns
242
+ { name: "Status", type: "String", visibility: "Query" },
243
+
244
+ // Never visible (for computed/internal fields)
245
+ { name: "InternalId", type: "String", visibility: "Never" }
246
+ ]
247
+ });
248
+
249
+ // New object - Password visible, CreatedDate hidden
250
+ const newUser = await service.getPersistentObject(null, "User", null, true);
251
+
252
+ // Existing object - Password hidden, CreatedDate visible
253
+ const existingUser = await service.getPersistentObject(null, "User", "123");
254
+ ```
255
+
256
+ **Visibility options:**
257
+ - `Always` - Visible in all contexts (default)
258
+ - `New` - Only visible when creating new objects
259
+ - `Read` - Only visible when viewing existing objects
260
+ - `Query` - Only visible in query columns
261
+ - `Never` - Never visible
262
+ - Compound: `"Read, Query"` - Visible in multiple contexts
263
+
264
+ ## Validation
265
+
266
+ ### Built-in Business Rules
267
+
268
+ The virtual service includes built-in validation rules:
269
+
270
+ ```typescript
271
+ service.registerPersistentObject({
272
+ type: "Contact",
273
+ attributes: [
274
+ // Single rule
275
+ { name: "Email", type: "String", rules: "IsEmail" },
276
+
277
+ // Multiple rules (semicolon-separated)
278
+ {
279
+ name: "Username",
280
+ type: "String",
281
+ rules: "NotEmpty; MinLength(3); MaxLength(20)"
282
+ },
283
+
284
+ // Rules with parameters
285
+ { name: "Age", type: "Int32", rules: "MinValue(18); MaxValue(120)" },
286
+
287
+ // Required fields (same as NotEmpty rule)
288
+ { name: "FirstName", type: "String", isRequired: true }
289
+ ]
290
+ });
291
+ ```
292
+
293
+ **Available rules:**
294
+
295
+ | Rule | Parameters | Description | Example |
296
+ |------|-----------|-------------|---------|
297
+ | `NotEmpty` | - | Value must not be empty/null | `"NotEmpty"` |
298
+ | `Required` | - | Alias for NotEmpty | `"Required"` |
299
+ | `IsEmail` | - | Valid email format | `"IsEmail"` |
300
+ | `IsUrl` | - | Valid URL format | `"IsUrl"` |
301
+ | `MinLength` | (length) | Minimum string length | `"MinLength(8)"` |
302
+ | `MaxLength` | (length) | Maximum string length | `"MaxLength(50)"` |
303
+ | `MinValue` | (number) | Minimum numeric value | `"MinValue(0)"` |
304
+ | `MaxValue` | (number) | Maximum numeric value | `"MaxValue(100)"` |
305
+ | `IsBase64` | - | Valid base64 string | `"IsBase64"` |
306
+ | `IsRegex` | - | Valid regex pattern | `"IsRegex"` |
307
+ | `IsWord` | - | Word characters only (\w+) | `"IsWord"` |
308
+
309
+ ### Validation Flow
310
+
311
+ Validation runs automatically when the Save action executes:
312
+
313
+ ```typescript
314
+ const contact = await service.getPersistentObject(null, "Contact", null, true);
315
+
316
+ // Set invalid email
317
+ contact.getAttribute("Email").setValue("not-an-email");
318
+
319
+ // Try to save - validation fails
320
+ await contact.save();
321
+
322
+ // Check validation error
323
+ console.log(contact.getAttribute("Email").validationError);
324
+ // "Email format is invalid"
325
+ ```
326
+
327
+ **Validation behavior:**
328
+ - Runs before save handlers execute
329
+ - First failing rule stops validation for that attribute
330
+ - Validation errors set on the attribute's `validationError` property
331
+ - If any attribute fails, the save operation is aborted
332
+ - Null and undefined values skip validation (unless using `NotEmpty`/`Required`)
333
+
334
+ ### Custom Business Rules
335
+
336
+ Register your own validation rules for domain-specific requirements:
337
+
338
+ ```typescript
339
+ // Register a custom rule (before registerPersistentObject)
340
+ service.registerBusinessRule("IsPhoneNumber", (value: any) => {
341
+ if (value == null || value === "") return;
342
+ const phoneRegex = /^\+?[\d\s-()]+$/;
343
+ if (!phoneRegex.test(String(value)))
344
+ throw new Error("Invalid phone number format");
345
+ });
346
+
347
+ // Use it in your configuration
348
+ service.registerPersistentObject({
349
+ type: "Contact",
350
+ attributes: [
351
+ {
352
+ name: "Phone",
353
+ type: "String",
354
+ rules: "NotEmpty; IsPhoneNumber"
355
+ }
356
+ ]
357
+ });
358
+ ```
359
+
360
+ **Custom rule requirements:**
361
+ - Must be registered before `registerPersistentObject()`
362
+ - Throw an `Error` with a message if validation fails
363
+ - Return nothing (or undefined) if validation passes
364
+ - Cannot override built-in rules
365
+
366
+ ## Queries
367
+
368
+ ### Basic Query Registration
369
+
370
+ Queries allow browsing and searching collections of persistent objects:
371
+
372
+ ```typescript
373
+ service.registerPersistentObject({
374
+ type: "Person",
375
+ attributes: [
376
+ { name: "Name", type: "String" },
377
+ { name: "Email", type: "String" },
378
+ { name: "Age", type: "Int32" }
379
+ ]
380
+ });
381
+
382
+ service.registerQuery({
383
+ name: "AllPeople",
384
+ persistentObject: "Person", // Required: links to PO type
385
+ label: "All People",
386
+ data: [
387
+ { Name: "Alice", Email: "alice@example.com", Age: 30 },
388
+ { Name: "Bob", Email: "bob@example.com", Age: 25 },
389
+ { Name: "Charlie", Email: "charlie@example.com", Age: 35 }
390
+ ]
391
+ });
392
+
393
+ await service.initialize();
394
+
395
+ const query = await service.getQuery("AllPeople");
396
+ await query.search();
397
+
398
+ const items = await query.items.toArrayAsync();
399
+ console.log(items.length); // 3
400
+ ```
401
+
402
+ ### Query Configuration
403
+
404
+ Full query configuration options:
405
+
406
+ ```typescript
407
+ {
408
+ name: "AllPeople", // Required: query name
409
+ persistentObject: "Person", // Required: linked PO type
410
+ label: "All People", // Display label
411
+ data: [...], // Static data for the query
412
+ autoQuery: true, // Execute automatically (default: true)
413
+ allowTextSearch: true, // Enable text search (default: true)
414
+ pageSize: 20, // Items per page (default: 20)
415
+ disableBulkEdit: false, // Disable bulk editing
416
+ actions: ["New", "Export"], // Query-level actions (by name)
417
+ itemActions: ["Edit", "Delete"] // Actions on selected items (by name)
418
+ }
419
+ ```
420
+
421
+ ### Query Columns
422
+
423
+ Columns are automatically derived from the PersistentObject's attributes:
424
+
425
+ ```typescript
426
+ service.registerPersistentObject({
427
+ type: "Person",
428
+ attributes: [
429
+ { name: "Name", type: "String", canSort: true },
430
+ { name: "Email", type: "String", canSort: true },
431
+ { name: "InternalId", type: "String", visibility: "Never" } // Hidden column
432
+ ]
433
+ });
434
+ ```
435
+
436
+ Column properties inherited from attributes:
437
+ - `canSort` - Whether column can be sorted (default: true)
438
+ - `isHidden` - Derived from visibility (Never, New = hidden)
439
+ - `offset` - Column display order
440
+
441
+ ### Text Search
442
+
443
+ Queries support case-insensitive text search across visible string columns:
444
+
445
+ ```typescript
446
+ const query = await service.getQuery("AllPeople");
447
+
448
+ // Search for "alice"
449
+ query.textSearch = "alice";
450
+ await query.search();
451
+
452
+ const results = await query.items.toArrayAsync();
453
+ console.log(results.length); // 1
454
+ console.log(results[0].values.Name); // "Alice"
455
+ ```
456
+
457
+ ### Sorting
458
+
459
+ Sort queries by one or more columns:
460
+
461
+ ```typescript
462
+ const query = await service.getQuery("AllPeople");
463
+
464
+ // Sort by age ascending
465
+ query.sortOptions = [{ name: "Age", direction: "ASC" }];
466
+ await query.search();
467
+
468
+ // Sort by multiple columns
469
+ query.sortOptions = [
470
+ { name: "Age", direction: "DESC" },
471
+ { name: "Name", direction: "ASC" }
472
+ ];
473
+ await query.search();
474
+ ```
475
+
476
+ Sorting is type-aware:
477
+ - Strings: Case-insensitive alphabetical
478
+ - Numbers: Numeric comparison
479
+ - Dates: Chronological order
480
+ - Null values sort first
481
+
482
+ ### Pagination
483
+
484
+ Control page size and navigate results:
485
+
486
+ ```typescript
487
+ service.registerQuery({
488
+ name: "AllPeople",
489
+ persistentObject: "Person",
490
+ pageSize: 10, // 10 items per page
491
+ data: [/* 100 items */]
492
+ });
493
+
494
+ const query = await service.getQuery("AllPeople");
495
+ await query.search();
496
+
497
+ // Get first page
498
+ const page1 = await query.items.sliceAsync(0, 10);
499
+
500
+ // Get second page
501
+ const page2 = await query.items.sliceAsync(10, 20);
502
+ ```
503
+
504
+ ## Actions
505
+
506
+ ### Custom Actions
507
+
508
+ Register actions with custom handlers:
509
+
510
+ ```typescript
511
+ service.registerAction({
512
+ name: "Approve",
513
+ displayName: "Approve Order",
514
+ isPinned: true,
515
+ handler: async (args: ActionArgs) => {
516
+ // Access context for reading/modifying the object
517
+ args.context.setAttributeValue("Status", "Approved");
518
+ args.context.setNotification("Order approved!", "OK", 3000);
519
+
520
+ // Return the updated object (or null for silent completion)
521
+ return args.parent;
522
+ }
523
+ });
524
+ ```
525
+
526
+ ### ActionArgs
527
+
528
+ Action handlers receive `ActionArgs` with execution context:
529
+
530
+ ```typescript
531
+ import type { ActionArgs } from "@vidyano-labs/virtual-service";
532
+
533
+ interface ActionArgs {
534
+ parent: PersistentObjectDto | null; // The PO being acted on
535
+ query?: QueryDto; // The query (for query actions)
536
+ selectedItems?: QueryResultItemDto[]; // Selected items in query
537
+ parameters?: Record<string, any>; // Action parameters
538
+ context: ActionContext; // Helper methods
539
+ }
540
+ ```
541
+
542
+ ### ActionContext
543
+
544
+ The context provides helper methods for modifying the persistent object:
545
+
546
+ ```typescript
547
+ handler: async (args: ActionArgs) => {
548
+ // Get an attribute
549
+ const emailAttr = args.context.getAttribute("Email");
550
+
551
+ // Read attribute values
552
+ const email = args.context.getAttributeValue("Email");
553
+
554
+ // Type-safe value access (pass the attribute DTO)
555
+ const age = args.context.getConvertedValue(args.context.getAttribute("Age")!);
556
+
557
+ // Modify attribute values
558
+ args.context.setAttributeValue("Status", "Active");
559
+
560
+ // Set with type conversion (pass the attribute DTO)
561
+ const countAttr = args.context.getAttribute("Count")!;
562
+ args.context.setConvertedValue(countAttr, 42);
563
+
564
+ // Set validation errors
565
+ if (!email?.includes("@"))
566
+ args.context.setValidationError("Email", "Invalid email format");
567
+
568
+ // Clear validation errors
569
+ args.context.clearValidationError("Email");
570
+
571
+ // Show notifications
572
+ args.context.setNotification("Saved successfully", "OK", 3000);
573
+ args.context.setNotification("Warning!", "Warning", 5000);
574
+ args.context.setNotification("Error occurred", "Error");
575
+
576
+ return args.parent;
577
+ }
578
+ ```
579
+
580
+ **Context methods:**
581
+
582
+ | Method | Description |
583
+ |--------|-------------|
584
+ | `getAttribute(name)` | Get the full attribute DTO |
585
+ | `getAttributeValue(name)` | Get the raw attribute value |
586
+ | `getConvertedValue(attr)` | Get type-converted value from attribute DTO |
587
+ | `setAttributeValue(name, value)` | Update raw attribute value |
588
+ | `setConvertedValue(attr, value)` | Update attribute DTO with type conversion |
589
+ | `setValidationError(name, error)` | Set a validation error message |
590
+ | `clearValidationError(name)` | Clear validation error |
591
+ | `setNotification(msg, type, duration?)` | Show notification to user |
592
+
593
+ ### Query Actions
594
+
595
+ Actions can operate on query results:
596
+
597
+ ```typescript
598
+ service.registerAction({
599
+ name: "BulkDelete",
600
+ handler: async (args: ActionArgs) => {
601
+ // Access selected items
602
+ for (const item of args.selectedItems || []) {
603
+ console.log(`Deleting item: ${item.id}`);
604
+ }
605
+
606
+ return null; // Silent completion
607
+ }
608
+ });
609
+ ```
610
+
611
+ ## Lifecycle Hooks
612
+
613
+ ### VirtualPersistentObjectActions
614
+
615
+ For complex scenarios, create a class extending `VirtualPersistentObjectActions`:
616
+
617
+ ```typescript
618
+ import { VirtualPersistentObjectActions } from "@vidyano-labs/virtual-service";
619
+ import type { VirtualPersistentObject, VirtualPersistentObjectAttribute } from "@vidyano-labs/virtual-service";
620
+ import { Dto } from "@vidyano/core";
621
+
622
+ class PersonActions extends VirtualPersistentObjectActions {
623
+ // Called when any Person DTO is created
624
+ onConstruct(obj: VirtualPersistentObject): void {
625
+ // Set defaults - note: this is synchronous
626
+ obj.setAttributeValue("CreatedDate", new Date().toISOString());
627
+ }
628
+
629
+ // Called when loading an existing Person
630
+ async onLoad(
631
+ obj: VirtualPersistentObject,
632
+ parent: VirtualPersistentObject | null
633
+ ): Promise<VirtualPersistentObject> {
634
+ // Load additional data based on ID
635
+ const id = obj.objectId;
636
+ console.log(`Loading person: ${id}`);
637
+ return obj;
638
+ }
639
+
640
+ // Called when creating a new Person via "New" action
641
+ async onNew(
642
+ obj: VirtualPersistentObject,
643
+ parent: VirtualPersistentObject | null,
644
+ query: Dto.QueryDto | null,
645
+ parameters: Record<string, string> | null
646
+ ): Promise<VirtualPersistentObject> {
647
+ // Initialize new object
648
+ obj.setAttributeValue("Status", "Draft");
649
+ return obj;
650
+ }
651
+
652
+ // Called when saving
653
+ async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
654
+ // Calls saveNew or saveExisting based on obj.isNew
655
+ return await super.onSave(obj);
656
+ }
657
+
658
+ // Called for new objects (protected)
659
+ protected async saveNew(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
660
+ obj.setAttributeValue("Id", crypto.randomUUID());
661
+ return obj;
662
+ }
663
+
664
+ // Called for existing objects (protected)
665
+ protected async saveExisting(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
666
+ obj.setAttributeValue("ModifiedDate", new Date().toISOString());
667
+ return obj;
668
+ }
669
+ }
670
+
671
+ // Register the actions class
672
+ service.registerPersistentObjectActions("Person", PersonActions);
673
+ ```
674
+
675
+ ### Lifecycle Flow
676
+
677
+ ```
678
+ PersistentObject Load: onConstruct → onLoad
679
+ PersistentObject New: onConstruct → onNew
680
+ PersistentObject Save: (validation) → onSave → (saveNew | saveExisting)
681
+ Query Construction: onConstructQuery
682
+ Query Execution: onExecuteQuery → (text search, sort, paginate)
683
+ Attribute Refresh: onRefresh
684
+ Reference Selection: onSelectReference
685
+ Deletion: onDelete
686
+ ```
687
+
688
+ ### Refresh Handling
689
+
690
+ Handle attribute changes that trigger refresh:
691
+
692
+ ```typescript
693
+ class OrderActions extends VirtualPersistentObjectActions {
694
+ async onRefresh(
695
+ obj: VirtualPersistentObject,
696
+ attribute: VirtualPersistentObjectAttribute | undefined
697
+ ): Promise<VirtualPersistentObject> {
698
+ // Calculate total when quantity or price changes
699
+ const quantity = obj.getAttributeValue("Quantity") ?? 0;
700
+ const unitPrice = obj.getAttributeValue("UnitPrice") ?? 0;
701
+ obj.setAttributeValue("Total", quantity * unitPrice);
702
+
703
+ return obj;
704
+ }
705
+ }
706
+ ```
707
+
708
+ Mark attributes that should trigger refresh:
709
+
710
+ ```typescript
711
+ service.registerPersistentObject({
712
+ type: "Order",
713
+ attributes: [
714
+ { name: "Quantity", type: "Int32", triggersRefresh: true },
715
+ { name: "UnitPrice", type: "Decimal", triggersRefresh: true },
716
+ { name: "Total", type: "Decimal", isReadOnly: true }
717
+ ]
718
+ });
719
+ ```
720
+
721
+ ### Query Execution
722
+
723
+ Provide dynamic query data:
724
+
725
+ ```typescript
726
+ class PersonActions extends VirtualPersistentObjectActions {
727
+ private database: PersonData[] = [];
728
+
729
+ // Provide data for query execution
730
+ // Framework handles text search, sort, and pagination automatically
731
+ async getEntities(
732
+ query: Dto.QueryDto,
733
+ parent: VirtualPersistentObject | null,
734
+ data: Record<string, any>[]
735
+ ): Promise<Record<string, any>[]> {
736
+ // Return all entities - framework handles search/sort/pagination
737
+ return this.database;
738
+ }
739
+
740
+ // Or fully control query execution
741
+ async onExecuteQuery(
742
+ query: Dto.QueryDto,
743
+ parent: VirtualPersistentObject | null,
744
+ data: Record<string, any>[]
745
+ ): Promise<VirtualQueryExecuteResult> {
746
+ // Custom query logic - you handle everything
747
+ const results = this.database.filter(p => p.active);
748
+ return { items: results, totalItems: results.length };
749
+ }
750
+ }
751
+ ```
752
+
753
+ ### Reference Attributes
754
+
755
+ Handle reference attribute selection:
756
+
757
+ ```typescript
758
+ class OrderActions extends VirtualPersistentObjectActions {
759
+ async onSelectReference(
760
+ parent: VirtualPersistentObject,
761
+ referenceAttribute: Dto.PersistentObjectAttributeDto,
762
+ query: Dto.QueryDto,
763
+ selectedItem: Dto.QueryResultItemDto | null
764
+ ): Promise<void> {
765
+ // Default: sets objectId and value from displayAttribute
766
+ await super.onSelectReference(parent, referenceAttribute, query, selectedItem);
767
+
768
+ // Custom: also copy related fields
769
+ if (selectedItem) {
770
+ const customerName = selectedItem.values?.find(v => v.key === "Name")?.value;
771
+ parent.setAttributeValue("CustomerName", customerName);
772
+ }
773
+ }
774
+ }
775
+ ```
776
+
777
+ Configure reference attributes:
778
+
779
+ ```typescript
780
+ // Customer PO and lookup query must be registered first
781
+ service.registerPersistentObject({
782
+ type: "Customer",
783
+ attributes: [
784
+ { name: "Name", type: "String" },
785
+ { name: "Email", type: "String" }
786
+ ]
787
+ });
788
+
789
+ service.registerQuery({
790
+ name: "AllCustomers",
791
+ persistentObject: "Customer",
792
+ data: [
793
+ { Name: "Acme Corp", Email: "contact@acme.com" },
794
+ { Name: "Globex", Email: "info@globex.com" }
795
+ ]
796
+ });
797
+
798
+ // Now Order can reference the lookup query
799
+ service.registerPersistentObject({
800
+ type: "Order",
801
+ attributes: [
802
+ { name: "OrderNumber", type: "String" },
803
+ {
804
+ name: "Customer",
805
+ type: "Reference",
806
+ lookup: "AllCustomers", // References registered query
807
+ displayAttribute: "Name" // Column to display
808
+ }
809
+ ]
810
+ });
811
+ ```
812
+
813
+ ## State Behavior
814
+
815
+ Control how persistent objects behave after actions:
816
+
817
+ ```typescript
818
+ service.registerPersistentObject({
819
+ type: "Settings",
820
+ stateBehavior: "StayInEdit", // Keep form in edit mode after save
821
+ attributes: [
822
+ { name: "Theme", type: "String" },
823
+ { name: "Language", type: "String" }
824
+ ]
825
+ });
826
+ ```
827
+
828
+ **State behavior options:**
829
+ - `None` - Default behavior
830
+ - `OpenInEdit` - Open in edit mode by default
831
+ - `StayInEdit` - Stay in edit mode after save
832
+ - `AsDialog` - Open as a dialog
833
+
834
+ ## Master-Detail Relationships
835
+
836
+ Configure detail queries for master-detail scenarios:
837
+
838
+ ```typescript
839
+ // 1. First register the detail PersistentObject
840
+ service.registerPersistentObject({
841
+ type: "OrderLine",
842
+ attributes: [
843
+ { name: "Product", type: "String" },
844
+ { name: "Quantity", type: "Int32" },
845
+ { name: "Price", type: "Decimal" }
846
+ ]
847
+ });
848
+
849
+ // 2. Then register the detail query
850
+ service.registerQuery({
851
+ name: "OrderLines",
852
+ persistentObject: "OrderLine"
853
+ });
854
+
855
+ // 3. Finally register the master PersistentObject with the query reference
856
+ service.registerPersistentObject({
857
+ type: "Order",
858
+ attributes: [
859
+ { name: "OrderNumber", type: "String" },
860
+ { name: "Total", type: "Decimal" }
861
+ ],
862
+ queries: ["OrderLines"] // Attach query as detail
863
+ });
864
+ ```
865
+
866
+ Access detail queries:
867
+
868
+ ```typescript
869
+ const order = await service.getPersistentObject(null, "Order", "123");
870
+ const linesQuery = order.queries.find(q => q.name === "OrderLines");
871
+ await linesQuery.search();
872
+ ```
873
+
874
+ ## VirtualPersistentObject Helpers
875
+
876
+ The `VirtualPersistentObject` type provides convenient helper methods:
877
+
878
+ ```typescript
879
+ // In lifecycle hooks, objects are wrapped with helpers
880
+ async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
881
+ // Get attribute by name (wrapped with helpers)
882
+ const attr = obj.getAttribute("Email");
883
+
884
+ // Get/set values
885
+ const email = obj.getAttributeValue("Email");
886
+ obj.setAttributeValue("Email", "new@example.com");
887
+
888
+ // Validation errors
889
+ obj.setValidationError("Email", "Invalid format");
890
+ obj.clearValidationError("Email");
891
+
892
+ // Notifications
893
+ obj.setNotification("Saved!", "OK", 3000);
894
+
895
+ return obj;
896
+ }
897
+ ```
898
+
899
+ **VirtualPersistentObject methods:**
900
+
901
+ | Method | Description |
902
+ |--------|-------------|
903
+ | `getAttribute(name)` | Get attribute wrapped with helpers |
904
+ | `getAttributeValue(name)` | Get converted attribute value |
905
+ | `setAttributeValue(name, value)` | Set attribute value with conversion |
906
+ | `setValidationError(name, error)` | Set validation error |
907
+ | `clearValidationError(name)` | Clear validation error |
908
+ | `setNotification(msg, type, duration?)` | Set notification |
909
+
910
+ **VirtualPersistentObjectAttribute methods:**
911
+
912
+ | Method | Description |
913
+ |--------|-------------|
914
+ | `getValue()` | Get converted value |
915
+ | `setValue(value)` | Set value with conversion |
916
+ | `setValidationError(error)` | Set validation error |
917
+ | `clearValidationError()` | Clear validation error |
918
+
919
+ ## Testing Examples
920
+
921
+ ### Unit Test Example
922
+
923
+ ```typescript
924
+ import { test, expect } from "@playwright/test";
925
+ import { VirtualService } from "@vidyano-labs/virtual-service";
926
+
927
+ test("validates email format", async () => {
928
+ const service = new VirtualService();
929
+
930
+ service.registerPersistentObject({
931
+ type: "Contact",
932
+ attributes: [
933
+ { name: "Email", type: "String", rules: "IsEmail" }
934
+ ]
935
+ });
936
+
937
+ await service.initialize();
938
+
939
+ const contact = await service.getPersistentObject(null, "Contact", null, true);
940
+ contact.getAttribute("Email").setValue("not-an-email");
941
+
942
+ await contact.save();
943
+
944
+ expect(contact.getAttribute("Email").validationError).toBe("Email format is invalid");
945
+ });
946
+ ```
947
+
948
+ ### Integration Test Example
949
+
950
+ ```typescript
951
+ import { test, expect } from "@playwright/test";
952
+ import { VirtualService } from "@vidyano-labs/virtual-service";
953
+ import type { ActionArgs } from "@vidyano-labs/virtual-service";
954
+
955
+ test("complete order workflow", async () => {
956
+ const service = new VirtualService();
957
+
958
+ service.registerAction({
959
+ name: "Submit",
960
+ handler: async (args: ActionArgs) => {
961
+ args.context.setAttributeValue("Status", "Submitted");
962
+ return args.parent;
963
+ }
964
+ });
965
+
966
+ service.registerAction({
967
+ name: "Approve",
968
+ handler: async (args: ActionArgs) => {
969
+ const status = args.context.getAttributeValue("Status");
970
+ if (status !== "Submitted") {
971
+ args.context.setNotification("Order must be submitted first", "Error");
972
+ return args.parent;
973
+ }
974
+ args.context.setAttributeValue("Status", "Approved");
975
+ return args.parent;
976
+ }
977
+ });
978
+
979
+ service.registerPersistentObject({
980
+ type: "Order",
981
+ attributes: [
982
+ { name: "Status", type: "String", value: "Draft" },
983
+ { name: "Total", type: "Decimal", value: "0" }
984
+ ],
985
+ actions: ["Submit", "Approve"] // Actions must be listed here
986
+ });
987
+
988
+ await service.initialize();
989
+
990
+ const order = await service.getPersistentObject(null, "Order", "123");
991
+ expect(order.getAttribute("Status").value).toBe("Draft");
992
+
993
+ // Submit order
994
+ await order.getAction("Submit").execute();
995
+ expect(order.getAttribute("Status").value).toBe("Submitted");
996
+
997
+ // Approve order
998
+ await order.getAction("Approve").execute();
999
+ expect(order.getAttribute("Status").value).toBe("Approved");
1000
+ });
1001
+ ```
1002
+
1003
+ ### Query Test Example
1004
+
1005
+ ```typescript
1006
+ import { test, expect } from "@playwright/test";
1007
+ import { VirtualService } from "@vidyano-labs/virtual-service";
1008
+
1009
+ test("search and sort query results", async () => {
1010
+ const service = new VirtualService();
1011
+
1012
+ service.registerPersistentObject({
1013
+ type: "Person",
1014
+ attributes: [
1015
+ { name: "Name", type: "String" },
1016
+ { name: "Age", type: "Int32" }
1017
+ ]
1018
+ });
1019
+
1020
+ service.registerQuery({
1021
+ name: "AllPeople",
1022
+ persistentObject: "Person",
1023
+ data: [
1024
+ { Name: "Alice", Age: 30 },
1025
+ { Name: "Bob", Age: 25 },
1026
+ { Name: "Charlie", Age: 35 }
1027
+ ]
1028
+ });
1029
+
1030
+ await service.initialize();
1031
+
1032
+ const query = await service.getQuery("AllPeople");
1033
+
1034
+ // Test search
1035
+ query.textSearch = "alice";
1036
+ await query.search();
1037
+ let items = await query.items.toArrayAsync();
1038
+ expect(items.length).toBe(1);
1039
+
1040
+ // Test sort
1041
+ query.textSearch = "";
1042
+ query.sortOptions = [{ name: "Age", direction: "ASC" }];
1043
+ await query.search();
1044
+ items = await query.items.toArrayAsync();
1045
+ expect(items[0].values.Name).toBe("Bob"); // Youngest first
1046
+ });
1047
+ ```
1048
+
1049
+ ## API Reference
1050
+
1051
+ ### VirtualService
1052
+
1053
+ | Method | Description |
1054
+ |--------|-------------|
1055
+ | `constructor(hooks?)` | Create service with optional custom hooks |
1056
+ | `registerPersistentObject(config)` | Register a PersistentObject type |
1057
+ | `registerQuery(config)` | Register a Query |
1058
+ | `registerAction(config)` | Register a custom action |
1059
+ | `registerBusinessRule(name, validator)` | Register a validation rule |
1060
+ | `registerPersistentObjectActions(type, Class)` | Register lifecycle handlers |
1061
+ | `initialize()` | Finalize registrations |
1062
+
1063
+ ### VirtualPersistentObjectActions
1064
+
1065
+ | Method | Description |
1066
+ |--------|-------------|
1067
+ | `onConstruct(obj)` | Called when constructing the DTO |
1068
+ | `onLoad(obj, parent)` | Called when loading an existing object |
1069
+ | `onNew(obj, parent, query, params)` | Called when creating a new object |
1070
+ | `onSave(obj)` | Called when saving |
1071
+ | `saveNew(obj)` | Called for new objects (protected) |
1072
+ | `saveExisting(obj)` | Called for existing objects (protected) |
1073
+ | `onRefresh(obj, attribute)` | Called when refreshing |
1074
+ | `onDelete(parent, query, items)` | Called when deleting items |
1075
+ | `onConstructQuery(query, parent)` | Called when constructing a query |
1076
+ | `onExecuteQuery(query, parent, data)` | Called when executing a query |
1077
+ | `getEntities(query, parent, data)` | Provide query data |
1078
+ | `onSelectReference(parent, attr, query, item)` | Called when selecting a reference |
1079
+
1080
+ ### Type Exports
1081
+
1082
+ ```typescript
1083
+ import {
1084
+ VirtualService,
1085
+ VirtualServiceHooks,
1086
+ VirtualPersistentObjectActions
1087
+ } from "@vidyano-labs/virtual-service";
1088
+
1089
+ import type {
1090
+ VirtualPersistentObject,
1091
+ VirtualPersistentObjectAttribute,
1092
+ VirtualPersistentObjectConfig,
1093
+ VirtualPersistentObjectAttributeConfig,
1094
+ VirtualQueryConfig,
1095
+ VirtualQueryExecuteResult,
1096
+ ActionConfig,
1097
+ ActionHandler,
1098
+ ActionArgs,
1099
+ ActionContext,
1100
+ RuleValidatorFn
1101
+ } from "@vidyano-labs/virtual-service";
1102
+ ```
1103
+
1104
+ ## Best Practices
1105
+
1106
+ - **Register in order** - PersistentObjects first, then Queries, then initialize
1107
+ - **Use lifecycle hooks** - Prefer `VirtualPersistentObjectActions` for complex logic over inline handlers
1108
+ - **Validate early** - Use built-in rules for common validations, custom rules for domain-specific
1109
+ - **Test comprehensively** - Cover validation, actions, queries, and edge cases
1110
+ - **Keep data realistic** - Use production-like test data to catch real issues
1111
+ - **Leverage type safety** - Use `getValue()` for type-safe value access
1112
+ - **Handle null values** - Check for null/undefined, especially in calculations
1113
+
1114
+ ## License
1115
+
1116
+ MIT