@vidyano-labs/virtual-service 0.2.0 → 0.4.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 +352 -167
  2. package/index.d.ts +379 -127
  3. package/index.js +1427 -1611
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -56,11 +56,10 @@ const service = new VirtualService();
56
56
  ```
57
57
 
58
58
  **Key methods:**
59
- - `registerPersistentObject(config)` - Register a mock persistent object type
59
+ - `registerPersistentObject(config, actionsClass?)` - Register a mock persistent object type with optional lifecycle class
60
60
  - `registerQuery(config)` - Register a mock query
61
- - `registerAction(config)` - Register a custom action handler
61
+ - `registerCustomAction(name, handler)` or `registerCustomAction(config, handler)` - Register a custom action
62
62
  - `registerBusinessRule(name, validator)` - Add custom validation rules
63
- - `registerPersistentObjectActions(type, ActionsClass)` - Register lifecycle handlers
64
63
  - `initialize()` - Finalize registrations (must call before using service)
65
64
 
66
65
  > **Important:** All registrations must happen BEFORE calling `initialize()`. Attempting to register after initialization throws an error.
@@ -70,9 +69,8 @@ const service = new VirtualService();
70
69
  Dependencies must be registered before the things that reference them:
71
70
 
72
71
  1. **Actions** first - Custom actions must be registered before PersistentObjects/Queries that reference them
73
- 2. **PersistentObjects** - Define the data schema
72
+ 2. **PersistentObjects** - Define the data schema (with optional lifecycle class)
74
73
  3. **Queries** - Must reference an already-registered PersistentObject type
75
- 4. **PersistentObjectActions** - Lifecycle handlers (can be registered anytime before initialize)
76
74
 
77
75
  ```typescript
78
76
  // Correct order
@@ -308,7 +306,7 @@ service.registerPersistentObject({
308
306
 
309
307
  ### Validation Flow
310
308
 
311
- Validation runs automatically when the Save action executes:
309
+ Validation runs automatically when saving - `onSave` calls `checkRules()` before delegating to `saveNew`/`saveExisting`:
312
310
 
313
311
  ```typescript
314
312
  const contact = await service.getPersistentObject(null, "Contact", null, true);
@@ -322,24 +320,77 @@ await contact.save();
322
320
  // Check validation error
323
321
  console.log(contact.getAttribute("Email").validationError);
324
322
  // "Email format is invalid"
323
+
324
+ // Notification is also set on the object
325
+ console.log(contact.notification);
326
+ // "Some required information is missing or incorrect."
325
327
  ```
326
328
 
327
329
  **Validation behavior:**
328
- - Runs before save handlers execute
330
+ - Runs inside `onSave` via the `checkRules()` method (before `saveNew`/`saveExisting`)
329
331
  - First failing rule stops validation for that attribute
330
332
  - Validation errors set on the attribute's `validationError` property
331
- - If any attribute fails, the save operation is aborted
333
+ - If any attribute fails, a notification is set and save handlers are not called
332
334
  - Null and undefined values skip validation (unless using `NotEmpty`/`Required`)
333
335
 
336
+ ### Overriding Validation
337
+
338
+ Override `checkRules()` in your `VirtualPersistentObjectActions` class to customize validation:
339
+
340
+ ```typescript
341
+ class PersonActions extends VirtualPersistentObjectActions {
342
+ // Custom validation - completely replace default behavior
343
+ checkRules(obj: VirtualPersistentObject): boolean {
344
+ const name = obj.getAttributeValue("Name");
345
+ if (name === "reserved") {
346
+ obj.setValidationError("Name", "This name is reserved");
347
+ obj.setNotification("Validation failed", "Error");
348
+ return false;
349
+ }
350
+ return true; // Skip default validation
351
+ }
352
+ }
353
+
354
+ // Or combine custom + default validation
355
+ class OrderActions extends VirtualPersistentObjectActions {
356
+ checkRules(obj: VirtualPersistentObject): boolean {
357
+ // Custom business logic first
358
+ const total = obj.getAttributeValue("Total");
359
+ const discount = obj.getAttributeValue("Discount");
360
+ if (discount > total) {
361
+ obj.setValidationError("Discount", "Discount cannot exceed total");
362
+ obj.setNotification("Validation failed", "Error");
363
+ return false;
364
+ }
365
+
366
+ // Then run default rule-based validation
367
+ return super.checkRules(obj);
368
+ }
369
+ }
370
+
371
+ // Skip validation entirely
372
+ class DraftActions extends VirtualPersistentObjectActions {
373
+ checkRules(_obj: VirtualPersistentObject): boolean {
374
+ return true; // Always valid - skip all validation
375
+ }
376
+ }
377
+ ```
378
+
379
+ **checkRules behavior:**
380
+ - Returns `true` if all validations pass, `false` if any fail
381
+ - When returning `false`, `saveNew`/`saveExisting` are not called
382
+ - The object passed to `checkRules` is a `VirtualPersistentObject`
383
+ - Call `super.checkRules(obj)` to include default rule-based validation
384
+
334
385
  ### Custom Business Rules
335
386
 
336
387
  Register your own validation rules for domain-specific requirements:
337
388
 
338
389
  ```typescript
339
- import type { RuleValidationContext } from "@vidyano-labs/virtual-service";
390
+ import type { VirtualPersistentObjectAttribute } from "@vidyano-labs/virtual-service";
340
391
 
341
392
  // Register a custom rule (before registerPersistentObject)
342
- service.registerBusinessRule("IsPhoneNumber", (value: any, context: RuleValidationContext) => {
393
+ service.registerBusinessRule("IsPhoneNumber", (value: any, attr: VirtualPersistentObjectAttribute) => {
343
394
  if (value == null || value === "") return;
344
395
  const phoneRegex = /^\+?[\d\s-()]+$/;
345
396
  if (!phoneRegex.test(String(value)))
@@ -359,21 +410,23 @@ service.registerPersistentObject({
359
410
  });
360
411
  ```
361
412
 
362
- **Validation context:**
363
- The `RuleValidationContext` parameter provides access to:
364
- - `context.persistentObject` - The persistent object being validated (wrapped with helper methods)
365
- - `context.attribute` - The attribute being validated (wrapped with helper methods)
413
+ **Attribute parameter:**
414
+ The `attr` parameter is the `VirtualPersistentObjectAttribute` being validated, which provides access to:
415
+ - `attr.service` - The VirtualService instance (use for `getMessage()` translations)
416
+ - `attr.persistentObject` - The persistent object being validated
417
+ - `attr.getValue()` / `attr.setValue()` - Get/set the attribute value
418
+ - All DTO properties (`name`, `type`, `rules`, etc.)
366
419
 
367
420
  This allows cross-field validation:
368
421
 
369
422
  ```typescript
370
423
  // Validate password confirmation matches password
371
- service.registerBusinessRule("MatchesPassword", (value: any, context: RuleValidationContext) => {
424
+ service.registerBusinessRule("MatchesPassword", (value: any, attr: VirtualPersistentObjectAttribute) => {
372
425
  if (!value) return;
373
426
 
374
- const passwordValue = context.persistentObject.getAttributeValue("Password");
427
+ const passwordValue = attr.persistentObject.getAttributeValue("Password");
375
428
  if (value !== passwordValue)
376
- throw new Error("Passwords do not match");
429
+ throw new Error(attr.service.getMessage("MatchesPassword"));
377
430
  });
378
431
 
379
432
  service.registerPersistentObject({
@@ -391,11 +444,112 @@ service.registerPersistentObject({
391
444
 
392
445
  **Custom rule requirements:**
393
446
  - Must be registered before `registerPersistentObject()`
394
- - Receives two parameters: `value` (the attribute value) and `context` (validation context)
447
+ - Receives two parameters: `value` (the converted attribute value) and `attr` (the attribute being validated)
395
448
  - Throw an `Error` with a message if validation fails
396
449
  - Return nothing (or undefined) if validation passes
397
450
  - Cannot override built-in rules
398
451
 
452
+ ### Translating Validation Messages
453
+
454
+ Customize validation error messages by setting the static `VirtualService.messages` property:
455
+
456
+ ```typescript
457
+ import { VirtualService } from "@vidyano-labs/virtual-service";
458
+
459
+ // Set custom messages (e.g., Dutch translations)
460
+ VirtualService.messages = {
461
+ "Required": "Dit veld is verplicht",
462
+ "NotEmpty": "Dit veld mag niet leeg zijn",
463
+ "IsEmail": "E-mailformaat is ongeldig",
464
+ "MaxLength": "Maximale lengte is {0} tekens",
465
+ "MinLength": "Minimale lengte is {0} tekens",
466
+ "MaxValue": "Maximale waarde is {0}",
467
+ "MinValue": "Minimale waarde is {0}",
468
+ "IsBase64": "Waarde moet een geldige base64 string zijn",
469
+ "IsRegex": "Waarde moet een geldige reguliere expressie zijn",
470
+ "IsWord": "Waarde mag alleen woordtekens bevatten",
471
+ "IsUrl": "Waarde moet een geldige URL zijn",
472
+ "ValidationRulesFailed": "Sommige vereiste informatie ontbreekt of is onjuist."
473
+ };
474
+
475
+ const service = new VirtualService();
476
+
477
+ service.registerPersistentObject({
478
+ type: "Person",
479
+ attributes: [
480
+ {
481
+ name: "Email",
482
+ type: "String",
483
+ rules: "Required; MaxLength(100); IsEmail"
484
+ }
485
+ ]
486
+ });
487
+
488
+ await service.initialize();
489
+
490
+ const person = await service.getPersistentObject(null, "Person", null, true);
491
+ await person.save();
492
+
493
+ // Error message uses custom translation
494
+ console.log(person.getAttribute("Email").validationError);
495
+ // "Dit veld is verplicht"
496
+ ```
497
+
498
+ **How it works:**
499
+ 1. Set `VirtualService.messages` with your custom messages before creating services
500
+ 2. Messages use `{0}`, `{1}` placeholders for positional parameters
501
+ 3. The `getMessage(key, ...params)` method formats messages with the provided parameters
502
+ 4. If a key is not found in the messages dictionary, the key itself is returned
503
+
504
+ **Custom rules can also use getMessage:**
505
+
506
+ ```typescript
507
+ import type { VirtualPersistentObjectAttribute } from "@vidyano-labs/virtual-service";
508
+
509
+ // Set custom messages including your custom rule keys
510
+ VirtualService.messages = {
511
+ ...VirtualService.messages, // Keep default messages
512
+ "MinimumAge": "L'âge minimum est {0}",
513
+ "MatchesPassword": "Les mots de passe ne correspondent pas"
514
+ };
515
+
516
+ const service = new VirtualService();
517
+
518
+ // Custom rule using getMessage via attr.service
519
+ service.registerBusinessRule("MinimumAge", (value: any, attr: VirtualPersistentObjectAttribute, minAge: number) => {
520
+ if (!value)
521
+ return;
522
+
523
+ const age = Number(value);
524
+ if (age < minAge)
525
+ throw new Error(attr.service.getMessage("MinimumAge", minAge));
526
+ });
527
+
528
+ // Custom rule can still throw direct error strings
529
+ service.registerBusinessRule("IsPhoneNumber", (value: any, _attr: VirtualPersistentObjectAttribute) => {
530
+ if (!value)
531
+ return;
532
+
533
+ const phoneRegex = /^\+?[\d\s-()]+$/;
534
+ if (!phoneRegex.test(String(value)))
535
+ throw new Error("Invalid phone number format"); // Direct string, not translated
536
+ });
537
+ ```
538
+
539
+ **Built-in message keys:**
540
+ - `Required` - Field is required (no params)
541
+ - `NotEmpty` - Field cannot be empty (no params)
542
+ - `IsEmail` - Invalid email format (no params)
543
+ - `IsUrl` - Invalid URL format (no params)
544
+ - `MaxLength` - Maximum length exceeded (param: max length)
545
+ - `MinLength` - Minimum length not met (param: min length)
546
+ - `MaxValue` - Maximum value exceeded (param: max value)
547
+ - `MinValue` - Minimum value not met (param: min value)
548
+ - `IsBase64` - Invalid base64 string (no params)
549
+ - `IsRegex` - Invalid regex pattern (no params)
550
+ - `IsWord` - Invalid word characters (no params)
551
+ - `ValidationRulesFailed` - Notification message when validation fails (no params)
552
+
399
553
  ## Queries
400
554
 
401
555
  ### Basic Query Registration
@@ -538,109 +692,108 @@ const page2 = await query.items.sliceAsync(10, 20);
538
692
 
539
693
  ### Custom Actions
540
694
 
541
- Register actions with custom handlers:
695
+ Register actions with custom handlers. You can use either a simple string name or a full config object:
542
696
 
543
697
  ```typescript
544
- service.registerAction({
545
- name: "Approve",
546
- displayName: "Approve Order",
547
- isPinned: true,
548
- handler: async (args: ActionArgs) => {
549
- // Access context for reading/modifying the object
550
- args.context.setAttributeValue("Status", "Approved");
551
- args.context.setNotification("Order approved!", "OK", 3000);
552
-
553
- // Return the updated object (or null for silent completion)
698
+ // Simple: just the action name
699
+ service.registerCustomAction("Approve", async (args) => {
700
+ args.parent.setAttributeValue("Status", "Approved");
701
+ return args.parent;
702
+ });
703
+
704
+ // Full config: with displayName, isPinned, etc.
705
+ service.registerCustomAction(
706
+ {
707
+ name: "Approve",
708
+ displayName: "Approve Order",
709
+ isPinned: true
710
+ },
711
+ async (args) => {
712
+ args.parent.setAttributeValue("Status", "Approved");
713
+ args.parent.setNotification("Order approved!", "OK", 3000);
554
714
  return args.parent;
555
715
  }
556
- });
716
+ );
557
717
  ```
558
718
 
559
719
  ### ActionArgs
560
720
 
561
- Action handlers receive `ActionArgs` with execution context:
721
+ Action handlers receive an `args` object with execution context:
562
722
 
563
723
  ```typescript
564
- import type { ActionArgs } from "@vidyano-labs/virtual-service";
565
-
566
- interface ActionArgs {
567
- parent: PersistentObjectDto | null; // The PO being acted on
568
- query?: QueryDto; // The query (for query actions)
569
- selectedItems?: QueryResultItemDto[]; // Selected items in query
570
- parameters?: Record<string, any>; // Action parameters
571
- context: ActionContext; // Helper methods
572
- }
724
+ // args parameter contains:
725
+ // - parent: VirtualPersistentObject | null // The PO being acted on
726
+ // - query?: VirtualQuery // The query (for query actions)
727
+ // - selectedItems?: VirtualQueryResultItem[] // Selected items in query
728
+ // - parameters?: Record<string, any> // Action parameters
573
729
  ```
574
730
 
575
- ### ActionContext
576
731
 
577
- The context provides helper methods for modifying the persistent object:
732
+ ### Working with Parent in Action Handlers
733
+
734
+ Since `args.parent` is a `VirtualPersistentObject`, you can use its methods directly:
578
735
 
579
736
  ```typescript
580
- handler: async (args: ActionArgs) => {
737
+ handler: async (args) => {
581
738
  // Get an attribute
582
- const emailAttr = args.context.getAttribute("Email");
739
+ const emailAttr = args.parent.getAttribute("Email");
583
740
 
584
- // Read attribute values
585
- const email = args.context.getAttributeValue("Email");
741
+ // Read attribute values (type-converted)
742
+ const email = args.parent.getAttributeValue("Email");
586
743
 
587
- // Type-safe value access (pass the attribute DTO)
588
- const age = args.context.getConvertedValue(args.context.getAttribute("Age")!);
744
+ // Read from the attribute directly
745
+ const age = emailAttr?.getValue();
589
746
 
590
747
  // Modify attribute values
591
- args.context.setAttributeValue("Status", "Active");
748
+ args.parent.setAttributeValue("Status", "Active");
592
749
 
593
- // Set with type conversion (pass the attribute DTO)
594
- const countAttr = args.context.getAttribute("Count")!;
595
- args.context.setConvertedValue(countAttr, 42);
750
+ // Set via attribute directly
751
+ emailAttr?.setValue("new@example.com");
596
752
 
597
753
  // Set validation errors
598
754
  if (!email?.includes("@"))
599
- args.context.setValidationError("Email", "Invalid email format");
755
+ args.parent.setValidationError("Email", "Invalid email format");
600
756
 
601
- // Clear validation errors
602
- args.context.clearValidationError("Email");
757
+ // Clear validation errors (pass null or empty string)
758
+ args.parent.setValidationError("Email", null);
603
759
 
604
760
  // Show notifications
605
- args.context.setNotification("Saved successfully", "OK", 3000);
606
- args.context.setNotification("Warning!", "Warning", 5000);
607
- args.context.setNotification("Error occurred", "Error");
761
+ args.parent.setNotification("Saved successfully", "OK", 3000);
762
+ args.parent.setNotification("Warning!", "Warning", 5000);
763
+ args.parent.setNotification("Error occurred", "Error");
608
764
 
609
765
  return args.parent;
610
766
  }
611
767
  ```
612
768
 
613
- **Context methods:**
614
-
615
- | Method | Description |
616
- |--------|-------------|
617
- | `getAttribute(name)` | Get the full attribute DTO |
618
- | `getAttributeValue(name)` | Get the raw attribute value |
619
- | `getConvertedValue(attr)` | Get type-converted value from attribute DTO |
620
- | `setAttributeValue(name, value)` | Update raw attribute value |
621
- | `setConvertedValue(attr, value)` | Update attribute DTO with type conversion |
622
- | `setValidationError(name, error)` | Set a validation error message |
623
- | `clearValidationError(name)` | Clear validation error |
624
- | `setNotification(msg, type, duration?)` | Show notification to user |
769
+ See [VirtualPersistentObject Methods](#virtualpersistentobject-methods) for the full list.
625
770
 
626
771
  ### Query Actions
627
772
 
628
773
  Actions can operate on query results:
629
774
 
630
775
  ```typescript
631
- service.registerAction({
632
- name: "BulkDelete",
633
- handler: async (args: ActionArgs) => {
634
- // Access selected items
635
- for (const item of args.selectedItems || []) {
636
- console.log(`Deleting item: ${item.id}`);
637
- }
638
-
639
- return null; // Silent completion
776
+ service.registerCustomAction("BulkDelete", async (args) => {
777
+ // Access selected items
778
+ for (const item of args.selectedItems || []) {
779
+ console.log(`Deleting item: ${item.id}`);
780
+ // Use getValue to read column values
781
+ const name = item.getValue("Name");
782
+ console.log(` Name: ${name}`);
640
783
  }
784
+
785
+ return null; // Silent completion
641
786
  });
642
787
  ```
643
788
 
789
+ **VirtualQueryResultItem methods:**
790
+
791
+ | Method | Description |
792
+ |--------|-------------|
793
+ | `getValue(columnName)` | Get a value from the item by column name |
794
+ | `query` | Reference to the parent VirtualQuery |
795
+ | `service` | Reference to the VirtualService instance |
796
+
644
797
  ## Lifecycle Hooks
645
798
 
646
799
  ### VirtualPersistentObjectActions
@@ -648,43 +801,45 @@ service.registerAction({
648
801
  For complex scenarios, create a class extending `VirtualPersistentObjectActions`:
649
802
 
650
803
  ```typescript
651
- import { VirtualPersistentObjectActions } from "@vidyano-labs/virtual-service";
804
+ import { VirtualPersistentObjectActions, VirtualQuery } from "@vidyano-labs/virtual-service";
652
805
  import type { VirtualPersistentObject, VirtualPersistentObjectAttribute } from "@vidyano-labs/virtual-service";
653
- import { Dto } from "@vidyano/core";
654
806
 
655
807
  class PersonActions extends VirtualPersistentObjectActions {
656
- // Called when any Person DTO is created
808
+ // Called after the object is built
657
809
  onConstruct(obj: VirtualPersistentObject): void {
658
- // Set defaults - note: this is synchronous
659
810
  obj.setAttributeValue("CreatedDate", new Date().toISOString());
660
811
  }
661
812
 
662
813
  // Called when loading an existing Person
663
814
  async onLoad(
664
- obj: VirtualPersistentObject,
815
+ objectId: string,
665
816
  parent: VirtualPersistentObject | null
666
817
  ): Promise<VirtualPersistentObject> {
818
+ const obj = await super.onLoad(objectId, parent);
819
+
667
820
  // Load additional data based on ID
668
- const id = obj.objectId;
669
- console.log(`Loading person: ${id}`);
821
+ console.log(`Loading person: ${objectId}`);
822
+
670
823
  return obj;
671
824
  }
672
825
 
673
826
  // Called when creating a new Person via "New" action
674
827
  async onNew(
675
- obj: VirtualPersistentObject,
676
828
  parent: VirtualPersistentObject | null,
677
- query: Dto.QueryDto | null,
829
+ query: VirtualQuery | null,
678
830
  parameters: Record<string, string> | null
679
831
  ): Promise<VirtualPersistentObject> {
832
+ const obj = await super.onNew(parent, query, parameters);
833
+
680
834
  // Initialize new object
681
835
  obj.setAttributeValue("Status", "Draft");
836
+
682
837
  return obj;
683
838
  }
684
839
 
685
840
  // Called when saving
686
841
  async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
687
- // Calls saveNew or saveExisting based on obj.isNew
842
+ // Calls checkRules, then saveNew or saveExisting based on obj.isNew
688
843
  return await super.onSave(obj);
689
844
  }
690
845
 
@@ -701,18 +856,26 @@ class PersonActions extends VirtualPersistentObjectActions {
701
856
  }
702
857
  }
703
858
 
704
- // Register the actions class
705
- service.registerPersistentObjectActions("Person", PersonActions);
859
+ // Register with the actions class
860
+ service.registerPersistentObject({
861
+ type: "Person",
862
+ attributes: [
863
+ { name: "FirstName", type: "String" },
864
+ { name: "CreatedDate", type: "DateTime" },
865
+ { name: "ModifiedDate", type: "DateTime" },
866
+ { name: "Status", type: "String" }
867
+ ]
868
+ }, PersonActions);
706
869
  ```
707
870
 
708
871
  ### Lifecycle Flow
709
872
 
710
873
  ```
711
- PersistentObject Load: onConstruct → onLoad
712
- PersistentObject New: onConstruct → onNew
713
- PersistentObject Save: (validation)onSave → (saveNew | saveExisting)
714
- Query Construction: onConstructQuery
715
- Query Execution: onExecuteQuery → (text search, sort, paginate)
874
+ PersistentObject Load: onLoad (calls onConstruct internally)
875
+ PersistentObject New: onNew (calls onConstruct internally)
876
+ PersistentObject Save: onSavecheckRules → (saveNew | saveExisting)
877
+ Query Get: onGetQuery (calls onConstructQuery internally)
878
+ Query Execution: onExecuteQuery → getEntities
716
879
  Attribute Refresh: onRefresh
717
880
  Reference Selection: onSelectReference
718
881
  Deletion: onDelete
@@ -751,6 +914,27 @@ service.registerPersistentObject({
751
914
  });
752
915
  ```
753
916
 
917
+ ### Query Retrieval
918
+
919
+ Customize how queries are retrieved:
920
+
921
+ ```typescript
922
+ class PersonActions extends VirtualPersistentObjectActions {
923
+ // Called when a query is requested
924
+ async onGetQuery(
925
+ queryName: string,
926
+ parent: VirtualPersistentObject | null
927
+ ): Promise<VirtualQuery> {
928
+ const query = await super.onGetQuery(queryName, parent);
929
+
930
+ // Custom post-processing
931
+ console.log(`Query ${queryName} loaded with ${query.totalItems} items`);
932
+
933
+ return query;
934
+ }
935
+ }
936
+ ```
937
+
754
938
  ### Query Execution
755
939
 
756
940
  Provide dynamic query data:
@@ -762,7 +946,7 @@ class PersonActions extends VirtualPersistentObjectActions {
762
946
  // Provide data for query execution
763
947
  // Framework handles text search, sort, and pagination automatically
764
948
  async getEntities(
765
- query: Dto.QueryDto,
949
+ query: VirtualQuery,
766
950
  parent: VirtualPersistentObject | null,
767
951
  data: Record<string, any>[]
768
952
  ): Promise<Record<string, any>[]> {
@@ -772,7 +956,7 @@ class PersonActions extends VirtualPersistentObjectActions {
772
956
 
773
957
  // Or fully control query execution
774
958
  async onExecuteQuery(
775
- query: Dto.QueryDto,
959
+ query: VirtualQuery,
776
960
  parent: VirtualPersistentObject | null,
777
961
  data: Record<string, any>[]
778
962
  ): Promise<VirtualQueryExecuteResult> {
@@ -791,16 +975,16 @@ Handle reference attribute selection:
791
975
  class OrderActions extends VirtualPersistentObjectActions {
792
976
  async onSelectReference(
793
977
  parent: VirtualPersistentObject,
794
- referenceAttribute: Dto.PersistentObjectAttributeDto,
795
- query: Dto.QueryDto,
796
- selectedItem: Dto.QueryResultItemDto | null
978
+ referenceAttribute: VirtualPersistentObjectAttribute,
979
+ query: VirtualQuery,
980
+ selectedItem: VirtualQueryResultItem | null
797
981
  ): Promise<void> {
798
982
  // Default: sets objectId and value from displayAttribute
799
983
  await super.onSelectReference(parent, referenceAttribute, query, selectedItem);
800
984
 
801
985
  // Custom: also copy related fields
802
986
  if (selectedItem) {
803
- const customerName = selectedItem.values?.find(v => v.key === "Name")?.value;
987
+ const customerName = selectedItem.getValue("Name");
804
988
  parent.setAttributeValue("CustomerName", customerName);
805
989
  }
806
990
  }
@@ -904,23 +1088,47 @@ const linesQuery = order.queries.find(q => q.name === "OrderLines");
904
1088
  await linesQuery.search();
905
1089
  ```
906
1090
 
907
- ## VirtualPersistentObject Helpers
1091
+ ### Detail Query Pre-Execution
1092
+
1093
+ By default, detail queries are pre-executed when the parent PersistentObject is loaded. This means the client immediately has access to the query results without needing to call `search()`.
1094
+
1095
+ You can control this behavior in `onConstruct` by setting `isIncludedInParentObject`:
1096
+
1097
+ ```typescript
1098
+ class OrderActions extends VirtualPersistentObjectActions {
1099
+ onConstruct(obj: VirtualPersistentObject): void {
1100
+ // Exclude OrderLines from being pre-executed (lazy load instead)
1101
+ const orderLinesQuery = obj.queries!.find(q => q.name === "OrderLines")!;
1102
+ orderLinesQuery.isIncludedInParentObject = false;
1103
+ }
1104
+ }
1105
+ ```
1106
+
1107
+ **Behavior:**
1108
+ - `isIncludedInParentObject = true` (default) - Query is executed before returning the PersistentObject; results are immediately available
1109
+ - `isIncludedInParentObject = false` - Query is not pre-executed; client must call `search()` to load results
1110
+
1111
+ This is useful for optimizing performance when detail queries contain large datasets that aren't always needed immediately.
908
1112
 
909
- The `VirtualPersistentObject` type provides convenient helper methods:
1113
+ ## VirtualPersistentObject Methods
1114
+
1115
+ The `VirtualPersistentObject` type provides these methods:
910
1116
 
911
1117
  ```typescript
912
- // In lifecycle hooks, objects are wrapped with helpers
913
1118
  async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
914
- // Get attribute by name (wrapped with helpers)
1119
+ // Get attribute by name
915
1120
  const attr = obj.getAttribute("Email");
916
1121
 
917
1122
  // Get/set values
918
1123
  const email = obj.getAttributeValue("Email");
919
1124
  obj.setAttributeValue("Email", "new@example.com");
920
1125
 
921
- // Validation errors
1126
+ // Validation errors (pass null/empty to clear)
922
1127
  obj.setValidationError("Email", "Invalid format");
923
- obj.clearValidationError("Email");
1128
+ obj.setValidationError("Email", null); // Clear error
1129
+
1130
+ // Access the service
1131
+ const message = obj.service.getMessage("CustomKey");
924
1132
 
925
1133
  // Notifications
926
1134
  obj.setNotification("Saved!", "OK", 3000);
@@ -933,12 +1141,12 @@ async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
933
1141
 
934
1142
  | Method | Description |
935
1143
  |--------|-------------|
936
- | `getAttribute(name)` | Get attribute wrapped with helpers |
1144
+ | `getAttribute(name)` | Get attribute by name |
937
1145
  | `getAttributeValue(name)` | Get converted attribute value |
938
1146
  | `setAttributeValue(name, value)` | Set attribute value with conversion |
939
- | `setValidationError(name, error)` | Set validation error |
940
- | `clearValidationError(name)` | Clear validation error |
1147
+ | `setValidationError(name, error)` | Set validation error (pass `null`/empty to clear) |
941
1148
  | `setNotification(msg, type, duration?)` | Set notification |
1149
+ | `service` | Reference to the VirtualService instance |
942
1150
 
943
1151
  **VirtualPersistentObjectAttribute methods:**
944
1152
 
@@ -946,8 +1154,10 @@ async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
946
1154
  |--------|-------------|
947
1155
  | `getValue()` | Get converted value |
948
1156
  | `setValue(value)` | Set value with conversion |
949
- | `setValidationError(error)` | Set validation error |
950
- | `clearValidationError()` | Clear validation error |
1157
+ | `setValidationError(error)` | Set validation error (pass `null`/empty to clear) |
1158
+ | `persistentObject` | Reference to the parent VirtualPersistentObject |
1159
+ | `service` | Reference to the VirtualService instance |
1160
+
951
1161
 
952
1162
  ## Testing Examples
953
1163
 
@@ -983,30 +1193,23 @@ test("validates email format", async () => {
983
1193
  ```typescript
984
1194
  import { test, expect } from "@playwright/test";
985
1195
  import { VirtualService } from "@vidyano-labs/virtual-service";
986
- import type { ActionArgs } from "@vidyano-labs/virtual-service";
987
1196
 
988
1197
  test("complete order workflow", async () => {
989
1198
  const service = new VirtualService();
990
1199
 
991
- service.registerAction({
992
- name: "Submit",
993
- handler: async (args: ActionArgs) => {
994
- args.context.setAttributeValue("Status", "Submitted");
995
- return args.parent;
996
- }
1200
+ service.registerCustomAction("Submit", async (args) => {
1201
+ args.parent.setAttributeValue("Status", "Submitted");
1202
+ return args.parent;
997
1203
  });
998
1204
 
999
- service.registerAction({
1000
- name: "Approve",
1001
- handler: async (args: ActionArgs) => {
1002
- const status = args.context.getAttributeValue("Status");
1003
- if (status !== "Submitted") {
1004
- args.context.setNotification("Order must be submitted first", "Error");
1005
- return args.parent;
1006
- }
1007
- args.context.setAttributeValue("Status", "Approved");
1205
+ service.registerCustomAction("Approve", async (args) => {
1206
+ const status = args.parent.getAttributeValue("Status");
1207
+ if (status !== "Submitted") {
1208
+ args.parent.setNotification("Order must be submitted first", "Error");
1008
1209
  return args.parent;
1009
1210
  }
1211
+ args.parent.setAttributeValue("Status", "Approved");
1212
+ return args.parent;
1010
1213
  });
1011
1214
 
1012
1215
  service.registerPersistentObject({
@@ -1086,54 +1289,36 @@ test("search and sort query results", async () => {
1086
1289
  | Method | Description |
1087
1290
  |--------|-------------|
1088
1291
  | `constructor(hooks?)` | Create service with optional custom hooks |
1089
- | `registerPersistentObject(config)` | Register a PersistentObject type |
1292
+ | `registerPersistentObject(config, actionsClass?)` | Register a PersistentObject type with optional lifecycle class |
1090
1293
  | `registerQuery(config)` | Register a Query |
1091
- | `registerAction(config)` | Register a custom action |
1294
+ | `registerCustomAction(name, handler)` | Register a custom action (simple) |
1295
+ | `registerCustomAction(config, handler)` | Register a custom action (with config) |
1092
1296
  | `registerBusinessRule(name, validator)` | Register a validation rule |
1093
- | `registerPersistentObjectActions(type, Class)` | Register lifecycle handlers |
1297
+ | `getMessage(key, ...params)` | Get a formatted message by key |
1094
1298
  | `initialize()` | Finalize registrations |
1095
1299
 
1300
+ | Static Property | Description |
1301
+ |-----------------|-------------|
1302
+ | `VirtualService.messages` | Get/set the global messages dictionary for translations |
1303
+
1096
1304
  ### VirtualPersistentObjectActions
1097
1305
 
1098
1306
  | Method | Description |
1099
1307
  |--------|-------------|
1100
- | `onConstruct(obj)` | Called when constructing the DTO |
1101
- | `onLoad(obj, parent)` | Called when loading an existing object |
1102
- | `onNew(obj, parent, query, params)` | Called when creating a new object |
1103
- | `onSave(obj)` | Called when saving |
1308
+ | `onConstruct(obj: VirtualPersistentObject)` | Called after the object is built (synchronous) |
1309
+ | `onLoad(objectId: string, parent)` | Called when loading an existing object - call `super.onLoad()` to build |
1310
+ | `onNew(parent, query: VirtualQuery, params)` | Called when creating a new object - call `super.onNew()` to build |
1311
+ | `onGetQuery(queryName: string, parent)` | Called when retrieving a query - call `super.onGetQuery()` to build |
1312
+ | `onSave(obj)` | Called when saving (calls checkRules, then saveNew/saveExisting) |
1313
+ | `checkRules(obj)` | Validates attributes against rules (overridable) |
1104
1314
  | `saveNew(obj)` | Called for new objects (protected) |
1105
1315
  | `saveExisting(obj)` | Called for existing objects (protected) |
1106
- | `onRefresh(obj, attribute)` | Called when refreshing |
1107
- | `onDelete(parent, query, items)` | Called when deleting items |
1108
- | `onConstructQuery(query, parent)` | Called when constructing a query |
1109
- | `onExecuteQuery(query, parent, data)` | Called when executing a query |
1110
- | `getEntities(query, parent, data)` | Provide query data |
1111
- | `onSelectReference(parent, attr, query, item)` | Called when selecting a reference |
1112
-
1113
- ### Type Exports
1114
-
1115
- ```typescript
1116
- import {
1117
- VirtualService,
1118
- VirtualServiceHooks,
1119
- VirtualPersistentObjectActions
1120
- } from "@vidyano-labs/virtual-service";
1121
-
1122
- import type {
1123
- VirtualPersistentObject,
1124
- VirtualPersistentObjectAttribute,
1125
- VirtualPersistentObjectConfig,
1126
- VirtualPersistentObjectAttributeConfig,
1127
- VirtualQueryConfig,
1128
- VirtualQueryExecuteResult,
1129
- ActionConfig,
1130
- ActionHandler,
1131
- ActionArgs,
1132
- ActionContext,
1133
- RuleValidatorFn,
1134
- RuleValidationContext
1135
- } from "@vidyano-labs/virtual-service";
1136
- ```
1316
+ | `onRefresh(obj, attribute: VirtualPersistentObjectAttribute)` | Called when refreshing |
1317
+ | `onDelete(parent, query: VirtualQuery, items: VirtualQueryResultItem[])` | Called when deleting items |
1318
+ | `onConstructQuery(query: VirtualQuery, parent)` | Called after query is built (synchronous) |
1319
+ | `onExecuteQuery(query: VirtualQuery, parent, data)` | Called when executing a query |
1320
+ | `getEntities(query: VirtualQuery, parent, data)` | Provide query data |
1321
+ | `onSelectReference(parent, attr: VirtualPersistentObjectAttribute, query: VirtualQuery, item: VirtualQueryResultItem)` | Called when selecting a reference |
1137
1322
 
1138
1323
  ## Best Practices
1139
1324