@declaro/data 2.0.0-beta.120 → 2.0.0-beta.125

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 (47) hide show
  1. package/dist/browser/index.js +14 -14
  2. package/dist/browser/index.js.map +11 -11
  3. package/dist/node/index.cjs +163 -45
  4. package/dist/node/index.cjs.map +11 -11
  5. package/dist/node/index.js +163 -45
  6. package/dist/node/index.js.map +11 -11
  7. package/dist/ts/application/model-controller.d.ts +22 -1
  8. package/dist/ts/application/model-controller.d.ts.map +1 -1
  9. package/dist/ts/domain/events/domain-event.d.ts +1 -1
  10. package/dist/ts/domain/events/domain-event.d.ts.map +1 -1
  11. package/dist/ts/domain/events/event-types.d.ts +10 -1
  12. package/dist/ts/domain/events/event-types.d.ts.map +1 -1
  13. package/dist/ts/domain/events/mutation-event.d.ts +5 -2
  14. package/dist/ts/domain/events/mutation-event.d.ts.map +1 -1
  15. package/dist/ts/domain/events/query-event.d.ts +4 -2
  16. package/dist/ts/domain/events/query-event.d.ts.map +1 -1
  17. package/dist/ts/domain/events/request-event.d.ts +17 -2
  18. package/dist/ts/domain/events/request-event.d.ts.map +1 -1
  19. package/dist/ts/domain/interfaces/repository.d.ts +26 -0
  20. package/dist/ts/domain/interfaces/repository.d.ts.map +1 -1
  21. package/dist/ts/domain/services/model-service.d.ts +19 -1
  22. package/dist/ts/domain/services/model-service.d.ts.map +1 -1
  23. package/dist/ts/domain/services/read-only-model-service.d.ts +19 -0
  24. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -1
  25. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +21 -3
  26. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -1
  27. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts +2 -0
  28. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts.map +1 -0
  29. package/package.json +5 -5
  30. package/src/application/model-controller.test.ts +191 -0
  31. package/src/application/model-controller.ts +44 -1
  32. package/src/domain/events/domain-event.ts +1 -1
  33. package/src/domain/events/event-types.ts +9 -0
  34. package/src/domain/events/mutation-event.test.ts +369 -17
  35. package/src/domain/events/mutation-event.ts +10 -2
  36. package/src/domain/events/query-event.test.ts +218 -18
  37. package/src/domain/events/query-event.ts +8 -2
  38. package/src/domain/events/request-event.test.ts +1 -1
  39. package/src/domain/events/request-event.ts +22 -7
  40. package/src/domain/interfaces/repository.ts +29 -0
  41. package/src/domain/services/model-service.normalization.test.ts +6 -6
  42. package/src/domain/services/model-service.test.ts +311 -7
  43. package/src/domain/services/model-service.ts +88 -1
  44. package/src/domain/services/read-only-model-service.test.ts +396 -0
  45. package/src/domain/services/read-only-model-service.ts +23 -3
  46. package/src/test/mock/repositories/mock-memory-repository.trash.test.ts +736 -0
  47. package/src/test/mock/repositories/mock-memory-repository.ts +146 -46
@@ -1,11 +1,8 @@
1
- import { describe, it, expect, beforeEach } from 'bun:test'
2
- import { ModelService } from './model-service'
3
- import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
4
- import { MockBookSchema, type MockBookInput } from '../../test/mock/models/mock-book-models'
5
1
  import { EventManager } from '@declaro/core'
6
- import { mock } from 'bun:test'
7
- import type { InferDetail } from '../../shared/utils/schema-inference'
8
- import { ModelMutationAction } from '../events/event-types'
2
+ import { beforeEach, describe, expect, it, mock } from 'bun:test'
3
+ import { MockBookSchema } from '../../test/mock/models/mock-book-models'
4
+ import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
5
+ import { ModelService } from './model-service'
9
6
 
10
7
  describe('ModelService', () => {
11
8
  const namespace = 'books'
@@ -630,4 +627,311 @@ describe('ModelService', () => {
630
627
  expect(results).toEqual(inputs)
631
628
  })
632
629
  })
630
+
631
+ describe('Trash Functionality', () => {
632
+ const beforeEmptyTrashSpy = mock((event) => {})
633
+ const afterEmptyTrashSpy = mock((event) => {})
634
+ const beforePermanentlyDeleteFromTrashSpy = mock((event) => {})
635
+ const afterPermanentlyDeleteFromTrashSpy = mock((event) => {})
636
+ const beforePermanentlyDeleteSpy = mock((event) => {})
637
+ const afterPermanentlyDeleteSpy = mock((event) => {})
638
+
639
+ beforeEach(() => {
640
+ emitter.on('books::book.beforeEmptyTrash', beforeEmptyTrashSpy)
641
+ emitter.on('books::book.afterEmptyTrash', afterEmptyTrashSpy)
642
+ emitter.on('books::book.beforePermanentlyDeleteFromTrash', beforePermanentlyDeleteFromTrashSpy)
643
+ emitter.on('books::book.afterPermanentlyDeleteFromTrash', afterPermanentlyDeleteFromTrashSpy)
644
+ emitter.on('books::book.beforePermanentlyDelete', beforePermanentlyDeleteSpy)
645
+ emitter.on('books::book.afterPermanentlyDelete', afterPermanentlyDeleteSpy)
646
+
647
+ beforeEmptyTrashSpy.mockClear()
648
+ afterEmptyTrashSpy.mockClear()
649
+ beforePermanentlyDeleteFromTrashSpy.mockClear()
650
+ afterPermanentlyDeleteFromTrashSpy.mockClear()
651
+ beforePermanentlyDeleteSpy.mockClear()
652
+ afterPermanentlyDeleteSpy.mockClear()
653
+ })
654
+
655
+ describe('emptyTrash', () => {
656
+ it('should permanently delete all items from trash', async () => {
657
+ // Create and remove multiple items
658
+ const book1 = await repository.create({
659
+ title: 'Book 1',
660
+ author: 'Author 1',
661
+ publishedDate: new Date(),
662
+ })
663
+ const book2 = await repository.create({
664
+ title: 'Book 2',
665
+ author: 'Author 2',
666
+ publishedDate: new Date(),
667
+ })
668
+ const book3 = await repository.create({
669
+ title: 'Book 3',
670
+ author: 'Author 3',
671
+ publishedDate: new Date(),
672
+ })
673
+
674
+ await repository.remove({ id: book1.id })
675
+ await repository.remove({ id: book2.id })
676
+ await repository.remove({ id: book3.id })
677
+
678
+ // Empty trash
679
+ const count = await service.emptyTrash()
680
+
681
+ expect(count).toBe(3)
682
+
683
+ // Verify items are no longer in trash
684
+ const inTrash1 = await repository.load({ id: book1.id }, { removedOnly: true })
685
+ const inTrash2 = await repository.load({ id: book2.id }, { removedOnly: true })
686
+ const inTrash3 = await repository.load({ id: book3.id }, { removedOnly: true })
687
+
688
+ expect(inTrash1).toBeNull()
689
+ expect(inTrash2).toBeNull()
690
+ expect(inTrash3).toBeNull()
691
+ })
692
+
693
+ it('should permanently delete filtered items from trash', async () => {
694
+ const repositoryWithFilter = new MockMemoryRepository({
695
+ schema: mockSchema,
696
+ filter: (data, filters) => {
697
+ if (filters.text) {
698
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
699
+ }
700
+ return true
701
+ },
702
+ })
703
+
704
+ const serviceWithFilter = new ModelService({
705
+ repository: repositoryWithFilter,
706
+ emitter,
707
+ schema: mockSchema,
708
+ namespace,
709
+ })
710
+
711
+ const book1 = await repositoryWithFilter.create({
712
+ title: 'Test Book 1',
713
+ author: 'Author 1',
714
+ publishedDate: new Date(),
715
+ })
716
+ const book2 = await repositoryWithFilter.create({
717
+ title: 'Test Book 2',
718
+ author: 'Author 2',
719
+ publishedDate: new Date(),
720
+ })
721
+ const book3 = await repositoryWithFilter.create({
722
+ title: 'Other Book',
723
+ author: 'Author 3',
724
+ publishedDate: new Date(),
725
+ })
726
+
727
+ await repositoryWithFilter.remove({ id: book1.id })
728
+ await repositoryWithFilter.remove({ id: book2.id })
729
+ await repositoryWithFilter.remove({ id: book3.id })
730
+
731
+ // Empty trash with filter
732
+ const count = await serviceWithFilter.emptyTrash({ text: 'Test' })
733
+
734
+ expect(count).toBe(2)
735
+
736
+ // Verify only filtered items were deleted
737
+ const inTrash1 = await repositoryWithFilter.load({ id: book1.id }, { removedOnly: true })
738
+ const inTrash2 = await repositoryWithFilter.load({ id: book2.id }, { removedOnly: true })
739
+ const inTrash3 = await repositoryWithFilter.load({ id: book3.id }, { removedOnly: true })
740
+
741
+ expect(inTrash1).toBeNull()
742
+ expect(inTrash2).toBeNull()
743
+ expect(inTrash3).not.toBeNull()
744
+ expect(inTrash3?.title).toBe('Other Book')
745
+ })
746
+
747
+ it('should return 0 when trash is empty', async () => {
748
+ const count = await service.emptyTrash()
749
+ expect(count).toBe(0)
750
+ })
751
+
752
+ it('should trigger before and after events for emptyTrash', async () => {
753
+ const book = await repository.create({
754
+ title: 'Book to Remove',
755
+ author: 'Author',
756
+ publishedDate: new Date(),
757
+ })
758
+ await repository.remove({ id: book.id })
759
+
760
+ await service.emptyTrash()
761
+
762
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledTimes(1)
763
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledWith(
764
+ expect.objectContaining({ type: 'books::book.beforeEmptyTrash' }),
765
+ )
766
+ expect(afterEmptyTrashSpy).toHaveBeenCalledTimes(1)
767
+ expect(afterEmptyTrashSpy).toHaveBeenCalledWith(
768
+ expect.objectContaining({ type: 'books::book.afterEmptyTrash' }),
769
+ )
770
+ })
771
+
772
+ it('should not affect non-removed items', async () => {
773
+ await repository.create({ title: 'Active Book 1', author: 'Author 1', publishedDate: new Date() })
774
+ await repository.create({ title: 'Active Book 2', author: 'Author 2', publishedDate: new Date() })
775
+
776
+ const book3 = await repository.create({
777
+ title: 'Book to Remove',
778
+ author: 'Author 3',
779
+ publishedDate: new Date(),
780
+ })
781
+ await repository.remove({ id: book3.id })
782
+
783
+ // Empty trash
784
+ await service.emptyTrash()
785
+
786
+ // Verify active items are still there
787
+ const results = await repository.search({})
788
+ expect(results.results).toHaveLength(2)
789
+ })
790
+ })
791
+
792
+ describe('permanentlyDeleteFromTrash', () => {
793
+ it('should permanently delete a removed item from trash', async () => {
794
+ const book = await repository.create({
795
+ title: 'Book to Delete',
796
+ author: 'Author',
797
+ publishedDate: new Date(),
798
+ })
799
+
800
+ // Remove the book
801
+ await repository.remove({ id: book.id })
802
+
803
+ // Verify it's in trash
804
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
805
+ expect(inTrash).not.toBeNull()
806
+
807
+ // Permanently delete from trash
808
+ const deleted = await service.permanentlyDeleteFromTrash({ id: book.id })
809
+
810
+ expect(deleted.id).toBe(book.id)
811
+ expect(deleted.title).toBe('Book to Delete')
812
+
813
+ // Verify it's no longer in trash
814
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
815
+ expect(afterDelete).toBeNull()
816
+
817
+ // Verify it's not in main data either
818
+ const inMain = await repository.load({ id: book.id })
819
+ expect(inMain).toBeNull()
820
+ })
821
+
822
+ it('should throw error when trying to delete non-existent item from trash', async () => {
823
+ await expect(service.permanentlyDeleteFromTrash({ id: 999 })).rejects.toThrow()
824
+ })
825
+
826
+ it('should throw error when trying to delete active item from trash', async () => {
827
+ const book = await repository.create({
828
+ title: 'Active Book',
829
+ author: 'Author',
830
+ publishedDate: new Date(),
831
+ })
832
+
833
+ // Should fail because item is not in trash
834
+ await expect(service.permanentlyDeleteFromTrash({ id: book.id })).rejects.toThrow()
835
+ })
836
+
837
+ it('should trigger before and after events for permanentlyDeleteFromTrash', async () => {
838
+ const book = await repository.create({
839
+ title: 'Book to Delete',
840
+ author: 'Author',
841
+ publishedDate: new Date(),
842
+ })
843
+ await repository.remove({ id: book.id })
844
+
845
+ await service.permanentlyDeleteFromTrash({ id: book.id })
846
+
847
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
848
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
849
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDeleteFromTrash' }),
850
+ )
851
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
852
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
853
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDeleteFromTrash' }),
854
+ )
855
+ })
856
+ })
857
+
858
+ describe('permanentlyDelete', () => {
859
+ it('should permanently delete a removed item', async () => {
860
+ const book = await repository.create({
861
+ title: 'Book to Delete',
862
+ author: 'Author',
863
+ publishedDate: new Date(),
864
+ })
865
+
866
+ // Remove the book first
867
+ await repository.remove({ id: book.id })
868
+
869
+ // Verify it's in trash
870
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
871
+ expect(inTrash).not.toBeNull()
872
+
873
+ // Permanently delete
874
+ const deleted = await service.permanentlyDelete({ id: book.id })
875
+
876
+ expect(deleted.id).toBe(book.id)
877
+ expect(deleted.title).toBe('Book to Delete')
878
+
879
+ // Verify it's no longer anywhere
880
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
881
+ expect(afterDelete).toBeNull()
882
+
883
+ const inMain = await repository.load({ id: book.id })
884
+ expect(inMain).toBeNull()
885
+ })
886
+
887
+ it('should permanently delete an active item', async () => {
888
+ const book = await repository.create({
889
+ title: 'Active Book to Delete',
890
+ author: 'Author',
891
+ publishedDate: new Date(),
892
+ })
893
+
894
+ // Verify it exists
895
+ const exists = await repository.load({ id: book.id })
896
+ expect(exists).not.toBeNull()
897
+
898
+ // Permanently delete without removing first
899
+ const deleted = await service.permanentlyDelete({ id: book.id })
900
+
901
+ expect(deleted.id).toBe(book.id)
902
+ expect(deleted.title).toBe('Active Book to Delete')
903
+
904
+ // Verify it's no longer in main data
905
+ const afterDelete = await repository.load({ id: book.id })
906
+ expect(afterDelete).toBeNull()
907
+
908
+ // Verify it's not in trash either
909
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
910
+ expect(inTrash).toBeNull()
911
+ })
912
+
913
+ it('should throw error when trying to delete non-existent item', async () => {
914
+ await expect(service.permanentlyDelete({ id: 999 })).rejects.toThrow()
915
+ })
916
+
917
+ it('should trigger before and after events for permanentlyDelete', async () => {
918
+ const book = await repository.create({
919
+ title: 'Book to Delete',
920
+ author: 'Author',
921
+ publishedDate: new Date(),
922
+ })
923
+
924
+ await service.permanentlyDelete({ id: book.id })
925
+
926
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
927
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledWith(
928
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDelete' }),
929
+ )
930
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
931
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledWith(
932
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDelete' }),
933
+ )
934
+ })
935
+ })
936
+ })
633
937
  })
@@ -1,5 +1,11 @@
1
1
  import type { ActionDescriptor, AnyModelSchema, IActionDescriptor } from '@declaro/core'
2
- import type { InferDetail, InferInput, InferLookup, InferSummary } from '../../shared/utils/schema-inference'
2
+ import type {
3
+ InferDetail,
4
+ InferFilters,
5
+ InferInput,
6
+ InferLookup,
7
+ InferSummary,
8
+ } from '../../shared/utils/schema-inference'
3
9
  import { ModelMutationAction, ModelQueryEvent } from '../events/event-types'
4
10
  import { MutationEvent } from '../events/mutation-event'
5
11
  import type { IModelServiceArgs } from './model-service-args'
@@ -342,4 +348,85 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
342
348
  // Return normalized results
343
349
  return await Promise.all(results.map((result) => this.normalizeDetail(result)))
344
350
  }
351
+
352
+ /**
353
+ * Permanently deletes all items from trash, optionally filtered by the provided criteria.
354
+ * @param filters Optional filters to apply when selecting items to delete from trash.
355
+ * @returns The count of permanently deleted items.
356
+ */
357
+ async emptyTrash(filters?: InferFilters<TSchema>): Promise<number> {
358
+ // Emit the before empty trash event
359
+ const beforeEmptyTrashEvent = new MutationEvent<number, InferFilters<TSchema> | undefined>(
360
+ this.getDescriptor(ModelMutationAction.BeforeEmptyTrash),
361
+ filters,
362
+ )
363
+ await this.emitter.emitAsync(beforeEmptyTrashEvent)
364
+
365
+ // Perform the empty trash operation
366
+ const count = await this.repository.emptyTrash(filters)
367
+
368
+ // Emit the after empty trash event
369
+ const afterEmptyTrashEvent = new MutationEvent<number, InferFilters<TSchema> | undefined>(
370
+ this.getDescriptor(ModelMutationAction.AfterEmptyTrash),
371
+ filters,
372
+ ).setResult(count)
373
+ await this.emitter.emitAsync(afterEmptyTrashEvent)
374
+
375
+ // Return the count of deleted items
376
+ return count
377
+ }
378
+
379
+ /**
380
+ * Permanently deletes a specific item from trash based on the provided lookup.
381
+ * @param lookup The lookup criteria for the item to permanently delete from trash.
382
+ * @returns The permanently deleted item summary.
383
+ */
384
+ async permanentlyDeleteFromTrash(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
385
+ // Emit the before permanently delete from trash event
386
+ const beforePermanentlyDeleteFromTrashEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
387
+ this.getDescriptor(ModelMutationAction.BeforePermanentlyDeleteFromTrash),
388
+ lookup,
389
+ )
390
+ await this.emitter.emitAsync(beforePermanentlyDeleteFromTrashEvent)
391
+
392
+ // Perform the permanent deletion from trash
393
+ const result = await this.repository.permanentlyDeleteFromTrash(lookup)
394
+
395
+ // Emit the after permanently delete from trash event
396
+ const afterPermanentlyDeleteFromTrashEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
397
+ this.getDescriptor(ModelMutationAction.AfterPermanentlyDeleteFromTrash),
398
+ lookup,
399
+ ).setResult(result)
400
+ await this.emitter.emitAsync(afterPermanentlyDeleteFromTrashEvent)
401
+
402
+ // Return the results of the permanent deletion
403
+ return await this.normalizeSummary(result)
404
+ }
405
+
406
+ /**
407
+ * Permanently deletes an item based on the provided lookup, regardless of whether it is active or in trash.
408
+ * @param lookup The lookup criteria for the item to permanently delete.
409
+ * @returns The permanently deleted item summary.
410
+ */
411
+ async permanentlyDelete(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
412
+ // Emit the before permanently delete event
413
+ const beforePermanentlyDeleteEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
414
+ this.getDescriptor(ModelMutationAction.BeforePermanentlyDelete),
415
+ lookup,
416
+ )
417
+ await this.emitter.emitAsync(beforePermanentlyDeleteEvent)
418
+
419
+ // Perform the permanent deletion
420
+ const result = await this.repository.permanentlyDelete(lookup)
421
+
422
+ // Emit the after permanently delete event
423
+ const afterPermanentlyDeleteEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
424
+ this.getDescriptor(ModelMutationAction.AfterPermanentlyDelete),
425
+ lookup,
426
+ ).setResult(result)
427
+ await this.emitter.emitAsync(afterPermanentlyDeleteEvent)
428
+
429
+ // Return the results of the permanent deletion
430
+ return await this.normalizeSummary(result)
431
+ }
345
432
  }