@auto-engineer/server-generator-apollo-emmett 0.11.13 → 0.11.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/src/codegen/extract/messages.d.ts +1 -0
- package/dist/src/codegen/extract/messages.d.ts.map +1 -1
- package/dist/src/codegen/extract/messages.js +4 -1
- package/dist/src/codegen/extract/messages.js.map +1 -1
- package/dist/src/codegen/extract/projection.d.ts +1 -0
- package/dist/src/codegen/extract/projection.d.ts.map +1 -1
- package/dist/src/codegen/extract/projection.js +12 -0
- package/dist/src/codegen/extract/projection.js.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.js +3 -2
- package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
- package/dist/src/codegen/templates/query/projection.specs.specs.ts +366 -0
- package/dist/src/codegen/templates/query/projection.specs.ts +409 -1
- package/dist/src/codegen/templates/query/projection.specs.ts.ejs +6 -2
- package/dist/src/codegen/templates/query/projection.ts.ejs +69 -12
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/codegen/extract/messages.ts +6 -1
- package/src/codegen/extract/projection.ts +14 -0
- package/src/codegen/scaffoldFromSchema.ts +3 -0
- package/src/codegen/templates/query/projection.specs.specs.ts +366 -0
- package/src/codegen/templates/query/projection.specs.ts +409 -1
- package/src/codegen/templates/query/projection.specs.ts.ejs +6 -2
- package/src/codegen/templates/query/projection.ts.ejs +69 -12
|
@@ -239,7 +239,7 @@ describe('projection.ts.ejs', () => {
|
|
|
239
239
|
* }
|
|
240
240
|
*
|
|
241
241
|
* 2. Cast document parameter to extended type:
|
|
242
|
-
* const current: InternalAvailableListings =
|
|
242
|
+
* const current: InternalAvailableListings = document ?? { ...defaults };
|
|
243
243
|
*
|
|
244
244
|
* 3. Cast return values to extended type:
|
|
245
245
|
* return { ...allFields, internalField } as InternalAvailableListings;
|
|
@@ -378,4 +378,412 @@ describe('projection.ts.ejs', () => {
|
|
|
378
378
|
"
|
|
379
379
|
`);
|
|
380
380
|
});
|
|
381
|
+
|
|
382
|
+
it('should generate a valid singleton projection file', async () => {
|
|
383
|
+
const flows: Model = {
|
|
384
|
+
variant: 'specs',
|
|
385
|
+
narratives: [
|
|
386
|
+
{
|
|
387
|
+
name: 'todo-flow',
|
|
388
|
+
slices: [
|
|
389
|
+
{
|
|
390
|
+
type: 'command',
|
|
391
|
+
name: 'manage-todo',
|
|
392
|
+
stream: 'todo-${todoId}',
|
|
393
|
+
client: {
|
|
394
|
+
description: 'manage todo UI',
|
|
395
|
+
},
|
|
396
|
+
server: {
|
|
397
|
+
description: 'handles todo operations',
|
|
398
|
+
specs: {
|
|
399
|
+
name: 'Manage todo command',
|
|
400
|
+
rules: [
|
|
401
|
+
{
|
|
402
|
+
description: 'Should handle todo operations',
|
|
403
|
+
examples: [
|
|
404
|
+
{
|
|
405
|
+
description: 'User adds todo',
|
|
406
|
+
when: {
|
|
407
|
+
commandRef: 'AddTodo',
|
|
408
|
+
exampleData: {
|
|
409
|
+
todoId: 'todo_123',
|
|
410
|
+
title: 'Buy milk',
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
then: [
|
|
414
|
+
{
|
|
415
|
+
eventRef: 'TodoAdded',
|
|
416
|
+
exampleData: {
|
|
417
|
+
todoId: 'todo_123',
|
|
418
|
+
title: 'Buy milk',
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
type: 'query',
|
|
431
|
+
name: 'view-summary',
|
|
432
|
+
stream: 'todos',
|
|
433
|
+
client: {
|
|
434
|
+
description: 'view todo summary UI',
|
|
435
|
+
},
|
|
436
|
+
server: {
|
|
437
|
+
description: 'singleton projection for todo summary',
|
|
438
|
+
data: [
|
|
439
|
+
{
|
|
440
|
+
target: {
|
|
441
|
+
type: 'State',
|
|
442
|
+
name: 'TodoSummary',
|
|
443
|
+
},
|
|
444
|
+
origin: {
|
|
445
|
+
type: 'projection',
|
|
446
|
+
name: 'TodoSummaryProjection',
|
|
447
|
+
singleton: true,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
specs: {
|
|
452
|
+
name: 'View summary query',
|
|
453
|
+
rules: [
|
|
454
|
+
{
|
|
455
|
+
description: 'Should aggregate todo counts',
|
|
456
|
+
examples: [
|
|
457
|
+
{
|
|
458
|
+
description: 'Todo added updates count',
|
|
459
|
+
when: [
|
|
460
|
+
{
|
|
461
|
+
eventRef: 'TodoAdded',
|
|
462
|
+
exampleData: {
|
|
463
|
+
todoId: 'todo_123',
|
|
464
|
+
title: 'Buy milk',
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
then: [
|
|
469
|
+
{
|
|
470
|
+
stateRef: 'TodoSummary',
|
|
471
|
+
exampleData: {
|
|
472
|
+
totalCount: 1,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
messages: [
|
|
487
|
+
{
|
|
488
|
+
type: 'command',
|
|
489
|
+
name: 'AddTodo',
|
|
490
|
+
fields: [
|
|
491
|
+
{ name: 'todoId', type: 'string', required: true },
|
|
492
|
+
{ name: 'title', type: 'string', required: true },
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
type: 'event',
|
|
497
|
+
name: 'TodoAdded',
|
|
498
|
+
source: 'internal',
|
|
499
|
+
fields: [
|
|
500
|
+
{ name: 'todoId', type: 'string', required: true },
|
|
501
|
+
{ name: 'title', type: 'string', required: true },
|
|
502
|
+
],
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
type: 'state',
|
|
506
|
+
name: 'TodoSummary',
|
|
507
|
+
fields: [{ name: 'totalCount', type: 'number', required: true }],
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const plans = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
|
|
513
|
+
const projectionFile = plans.find((p) => p.outputPath.endsWith('view-summary/projection.ts'));
|
|
514
|
+
|
|
515
|
+
expect(projectionFile?.contents).toMatchInlineSnapshot(`
|
|
516
|
+
"import {
|
|
517
|
+
inMemorySingleStreamProjection,
|
|
518
|
+
type ReadEvent,
|
|
519
|
+
type InMemoryReadEventMetadata,
|
|
520
|
+
} from '@event-driven-io/emmett';
|
|
521
|
+
import type { TodoSummary } from './state';
|
|
522
|
+
import type { TodoAdded } from '../manage-todo/events';
|
|
523
|
+
|
|
524
|
+
// SINGLETON AGGREGATION PATTERN
|
|
525
|
+
// This projection maintains a single document that aggregates data from multiple entities.
|
|
526
|
+
// Use internal state to track individual entity information for accurate calculations.
|
|
527
|
+
interface InternalTodoSummary extends TodoSummary {
|
|
528
|
+
_entities?: Record<string, { status?: string; [key: string]: unknown }>;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
type AllEvents = TodoAdded;
|
|
532
|
+
|
|
533
|
+
export const projection = inMemorySingleStreamProjection<TodoSummary, AllEvents>({
|
|
534
|
+
collectionName: 'TodoSummaryProjection',
|
|
535
|
+
canHandle: ['TodoAdded'],
|
|
536
|
+
getDocumentId: (_event) => 'todo-summary',
|
|
537
|
+
evolve: (
|
|
538
|
+
document: TodoSummary | null,
|
|
539
|
+
event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
|
|
540
|
+
): TodoSummary | null => {
|
|
541
|
+
switch (event.type) {
|
|
542
|
+
case 'TodoAdded': {
|
|
543
|
+
/**
|
|
544
|
+
* ## IMPLEMENTATION INSTRUCTIONS ##
|
|
545
|
+
* **SINGLETON AGGREGATION PATTERN**
|
|
546
|
+
*
|
|
547
|
+
* This projection maintains ONE document aggregating data from MANY entities.
|
|
548
|
+
*
|
|
549
|
+
* CRITICAL: Use internal state to track individual entity information:
|
|
550
|
+
*
|
|
551
|
+
* 1. Access current state:
|
|
552
|
+
* const current: InternalTodoSummary = document ?? { ...initialState, _entities: {} };
|
|
553
|
+
*
|
|
554
|
+
* 2. Track entity changes:
|
|
555
|
+
* // a) Extract the unique identifier that distinguishes this entity
|
|
556
|
+
* // Examine event.data to find the ID field (often 'id' or '<entity>Id')
|
|
557
|
+
* const entityId = event.data.[ENTITY_ID_FIELD];
|
|
558
|
+
*
|
|
559
|
+
* // b) Store/update entity state with relevant properties from event.data
|
|
560
|
+
* // Include only fields needed for aggregation calculations
|
|
561
|
+
* current._entities[entityId] = { [field]: value, ... };
|
|
562
|
+
*
|
|
563
|
+
* 3. Calculate aggregates from entity states:
|
|
564
|
+
* const counts = Object.values(current._entities).reduce((acc, entity) => {
|
|
565
|
+
* acc[entity.status] = (acc[entity.status] || 0) + 1;
|
|
566
|
+
* return acc;
|
|
567
|
+
* }, {});
|
|
568
|
+
*
|
|
569
|
+
* 4. Return with internal state:
|
|
570
|
+
* return { ...publicFields, _entities: current._entities } as InternalTodoSummary;
|
|
571
|
+
*/
|
|
572
|
+
return {
|
|
573
|
+
totalCount: /* TODO: map from event.data */ 0,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
default:
|
|
577
|
+
return document;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
export default projection;
|
|
583
|
+
"
|
|
584
|
+
`);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should generate a valid composite key projection file', async () => {
|
|
588
|
+
const flows: Model = {
|
|
589
|
+
variant: 'specs',
|
|
590
|
+
narratives: [
|
|
591
|
+
{
|
|
592
|
+
name: 'user-project-flow',
|
|
593
|
+
slices: [
|
|
594
|
+
{
|
|
595
|
+
type: 'command',
|
|
596
|
+
name: 'manage-user-project',
|
|
597
|
+
stream: 'user-project-${userId}-${projectId}',
|
|
598
|
+
client: {
|
|
599
|
+
description: 'manage user project UI',
|
|
600
|
+
},
|
|
601
|
+
server: {
|
|
602
|
+
description: 'handles user project operations',
|
|
603
|
+
specs: {
|
|
604
|
+
name: 'Manage user project command',
|
|
605
|
+
rules: [
|
|
606
|
+
{
|
|
607
|
+
description: 'Should handle user project operations',
|
|
608
|
+
examples: [
|
|
609
|
+
{
|
|
610
|
+
description: 'User joins project',
|
|
611
|
+
when: {
|
|
612
|
+
commandRef: 'JoinProject',
|
|
613
|
+
exampleData: {
|
|
614
|
+
userId: 'user_123',
|
|
615
|
+
projectId: 'proj_456',
|
|
616
|
+
role: 'developer',
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
then: [
|
|
620
|
+
{
|
|
621
|
+
eventRef: 'UserJoinedProject',
|
|
622
|
+
exampleData: {
|
|
623
|
+
userId: 'user_123',
|
|
624
|
+
projectId: 'proj_456',
|
|
625
|
+
role: 'developer',
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
type: 'query',
|
|
638
|
+
name: 'view-user-projects',
|
|
639
|
+
stream: 'user-projects',
|
|
640
|
+
client: {
|
|
641
|
+
description: 'view user projects UI',
|
|
642
|
+
},
|
|
643
|
+
server: {
|
|
644
|
+
description: 'composite key projection for user projects',
|
|
645
|
+
data: [
|
|
646
|
+
{
|
|
647
|
+
target: {
|
|
648
|
+
type: 'State',
|
|
649
|
+
name: 'UserProject',
|
|
650
|
+
},
|
|
651
|
+
origin: {
|
|
652
|
+
type: 'projection',
|
|
653
|
+
name: 'UserProjectsProjection',
|
|
654
|
+
idField: ['userId', 'projectId'],
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
specs: {
|
|
659
|
+
name: 'View user projects query',
|
|
660
|
+
rules: [
|
|
661
|
+
{
|
|
662
|
+
description: 'Should track user project memberships',
|
|
663
|
+
examples: [
|
|
664
|
+
{
|
|
665
|
+
description: 'User joins project',
|
|
666
|
+
when: [
|
|
667
|
+
{
|
|
668
|
+
eventRef: 'UserJoinedProject',
|
|
669
|
+
exampleData: {
|
|
670
|
+
userId: 'user_123',
|
|
671
|
+
projectId: 'proj_456',
|
|
672
|
+
role: 'developer',
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
],
|
|
676
|
+
then: [
|
|
677
|
+
{
|
|
678
|
+
stateRef: 'UserProject',
|
|
679
|
+
exampleData: {
|
|
680
|
+
userId: 'user_123',
|
|
681
|
+
projectId: 'proj_456',
|
|
682
|
+
role: 'developer',
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
},
|
|
687
|
+
],
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
messages: [
|
|
697
|
+
{
|
|
698
|
+
type: 'command',
|
|
699
|
+
name: 'JoinProject',
|
|
700
|
+
fields: [
|
|
701
|
+
{ name: 'userId', type: 'string', required: true },
|
|
702
|
+
{ name: 'projectId', type: 'string', required: true },
|
|
703
|
+
{ name: 'role', type: 'string', required: true },
|
|
704
|
+
],
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
type: 'event',
|
|
708
|
+
name: 'UserJoinedProject',
|
|
709
|
+
source: 'internal',
|
|
710
|
+
fields: [
|
|
711
|
+
{ name: 'userId', type: 'string', required: true },
|
|
712
|
+
{ name: 'projectId', type: 'string', required: true },
|
|
713
|
+
{ name: 'role', type: 'string', required: true },
|
|
714
|
+
],
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
type: 'state',
|
|
718
|
+
name: 'UserProject',
|
|
719
|
+
fields: [
|
|
720
|
+
{ name: 'userId', type: 'string', required: true },
|
|
721
|
+
{ name: 'projectId', type: 'string', required: true },
|
|
722
|
+
{ name: 'role', type: 'string', required: true },
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const plans = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
|
|
729
|
+
const projectionFile = plans.find((p) => p.outputPath.endsWith('view-user-projects/projection.ts'));
|
|
730
|
+
|
|
731
|
+
expect(projectionFile?.contents).toMatchInlineSnapshot(`
|
|
732
|
+
"import {
|
|
733
|
+
inMemorySingleStreamProjection,
|
|
734
|
+
type ReadEvent,
|
|
735
|
+
type InMemoryReadEventMetadata,
|
|
736
|
+
} from '@event-driven-io/emmett';
|
|
737
|
+
import type { UserProject } from './state';
|
|
738
|
+
import type { UserJoinedProject } from '../manage-user-project/events';
|
|
739
|
+
|
|
740
|
+
type AllEvents = UserJoinedProject;
|
|
741
|
+
|
|
742
|
+
export const projection = inMemorySingleStreamProjection<UserProject, AllEvents>({
|
|
743
|
+
collectionName: 'UserProjectsProjection',
|
|
744
|
+
canHandle: ['UserJoinedProject'],
|
|
745
|
+
getDocumentId: (event) => \`\${event.data.userId}-\${event.data.projectId}\`,
|
|
746
|
+
evolve: (
|
|
747
|
+
document: UserProject | null,
|
|
748
|
+
event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
|
|
749
|
+
): UserProject | null => {
|
|
750
|
+
switch (event.type) {
|
|
751
|
+
case 'UserJoinedProject': {
|
|
752
|
+
/**
|
|
753
|
+
* ## IMPLEMENTATION INSTRUCTIONS ##
|
|
754
|
+
* **COMPOSITE KEY PROJECTION**
|
|
755
|
+
*
|
|
756
|
+
* This projection uses a composite key: userId + projectId
|
|
757
|
+
* Document ID format: \`\${event.data.userId}-\${event.data.projectId}\`
|
|
758
|
+
*
|
|
759
|
+
* CRITICAL: You MUST include ALL key fields in every return statement:
|
|
760
|
+
* - userId: event.data.userId
|
|
761
|
+
* - projectId: event.data.projectId
|
|
762
|
+
*
|
|
763
|
+
* Missing even one key field will cause the projection to fail.
|
|
764
|
+
* Key fields typically map directly from event data (no transformation needed).
|
|
765
|
+
*
|
|
766
|
+
* Example implementation:
|
|
767
|
+
* return {
|
|
768
|
+
* userId: event.data.userId,
|
|
769
|
+
* projectId: event.data.projectId,
|
|
770
|
+
* // ... other fields
|
|
771
|
+
* };
|
|
772
|
+
*/
|
|
773
|
+
return {
|
|
774
|
+
userId: /* TODO: map from event.data */ '',
|
|
775
|
+
projectId: /* TODO: map from event.data */ '',
|
|
776
|
+
role: /* TODO: map from event.data */ '',
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
default:
|
|
780
|
+
return document;
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
export default projection;
|
|
786
|
+
"
|
|
787
|
+
`);
|
|
788
|
+
});
|
|
381
789
|
});
|
|
@@ -141,7 +141,9 @@ _%>
|
|
|
141
141
|
.then(async (state) => {
|
|
142
142
|
const document = await state.database
|
|
143
143
|
.collection<<%= TargetType %>>('<%= projName %>')
|
|
144
|
-
.findOne((
|
|
144
|
+
.findOne(<% if (projectionSingleton) { %>);<%
|
|
145
|
+
} else {
|
|
146
|
+
%>(doc) => <%
|
|
145
147
|
const idField = projectionIdField ?? 'id';
|
|
146
148
|
if (idField.includes('-')) {
|
|
147
149
|
// Handle composite keys
|
|
@@ -157,7 +159,9 @@ if (idField.includes('-')) {
|
|
|
157
159
|
const valueStr = typeof value === 'string' ? `'${value}'` : value || "'test-id'";
|
|
158
160
|
%>doc.<%= idField %> === <%= valueStr %><%
|
|
159
161
|
}
|
|
160
|
-
%>)
|
|
162
|
+
%>);<%
|
|
163
|
+
}
|
|
164
|
+
%>
|
|
161
165
|
|
|
162
166
|
const expected: <%= TargetType %> = {
|
|
163
167
|
<% const stateKeys = Object.keys(expectedState.exampleData || {});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<%
|
|
2
|
+
const origin = slice.server?.data?.[0]?.origin;
|
|
3
|
+
const isSingleton = origin?.singleton === true;
|
|
4
|
+
const idField = origin?.idField;
|
|
5
|
+
const isCompositeKey = Array.isArray(idField);
|
|
6
|
+
const compositeKeyFields = isCompositeKey ? idField : [];
|
|
7
|
+
%>import {
|
|
2
8
|
inMemorySingleStreamProjection,
|
|
3
9
|
type ReadEvent,
|
|
4
10
|
type InMemoryReadEventMetadata,
|
|
@@ -35,7 +41,14 @@ if (stateEnums.length > 0) {
|
|
|
35
41
|
import { <%= stateEnums.join(', ') %> } from '../../../shared';
|
|
36
42
|
<%
|
|
37
43
|
} -%>
|
|
38
|
-
|
|
44
|
+
<% if (isSingleton) { %>
|
|
45
|
+
// SINGLETON AGGREGATION PATTERN
|
|
46
|
+
// This projection maintains a single document that aggregates data from multiple entities.
|
|
47
|
+
// Use internal state to track individual entity information for accurate calculations.
|
|
48
|
+
interface Internal<%= pascalCase(targetName || 'State') %> extends <%= pascalCase(targetName || 'State') %> {
|
|
49
|
+
_entities?: Record<string, { status?: string; [key: string]: unknown }>;
|
|
50
|
+
}
|
|
51
|
+
<% } %>
|
|
39
52
|
type AllEvents = <%= allEventTypes %>;
|
|
40
53
|
|
|
41
54
|
export const projection = inMemorySingleStreamProjection<
|
|
@@ -44,17 +57,17 @@ AllEvents
|
|
|
44
57
|
>({
|
|
45
58
|
collectionName: '<%= pascalCase(slice.server?.data?.[0]?.origin?.name || "unknown-collection") %>',
|
|
46
59
|
canHandle: [<%- events.map(e => `'${e.type}'`).join(', ') %>],
|
|
47
|
-
getDocumentId: (event) => <%
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
index === 0 ? `\${event.data.${part}}` : `-\${event.data.${part}}`
|
|
60
|
+
getDocumentId: (<%- isSingleton ? '_event' : 'event' %>) => <%
|
|
61
|
+
if (isSingleton) {
|
|
62
|
+
%>'<%= toKebabCase(slice.server?.data?.[0]?.target?.name || 'singleton') %>'<%
|
|
63
|
+
} else if (isCompositeKey) {
|
|
64
|
+
const template = compositeKeyFields.map((field, index) =>
|
|
65
|
+
index === 0 ? `\${event.data.${field}}` : `-\${event.data.${field}}`
|
|
54
66
|
).join('');
|
|
55
67
|
%>`<%= template %>`<%
|
|
56
68
|
} else {
|
|
57
|
-
|
|
69
|
+
const singleIdField = typeof idField === 'string' ? idField : 'id';
|
|
70
|
+
%>event.data.<%= singleIdField %><%
|
|
58
71
|
}
|
|
59
72
|
%>,
|
|
60
73
|
evolve: (
|
|
@@ -93,7 +106,51 @@ switch (event.type) {
|
|
|
93
106
|
case '<%= event.type %>': {
|
|
94
107
|
/**
|
|
95
108
|
* ## IMPLEMENTATION INSTRUCTIONS ##
|
|
96
|
-
<% if (
|
|
109
|
+
<% if (isSingleton) { -%>
|
|
110
|
+
* **SINGLETON AGGREGATION PATTERN**
|
|
111
|
+
*
|
|
112
|
+
* This projection maintains ONE document aggregating data from MANY entities.
|
|
113
|
+
*
|
|
114
|
+
* CRITICAL: Use internal state to track individual entity information:
|
|
115
|
+
*
|
|
116
|
+
* 1. Access current state:
|
|
117
|
+
* const current: Internal<%= pascalCase(targetName || 'State') %> = document ?? { ...initialState, _entities: {} };
|
|
118
|
+
*
|
|
119
|
+
* 2. Track entity changes:
|
|
120
|
+
* // a) Extract the unique identifier that distinguishes this entity
|
|
121
|
+
* // Examine event.data to find the ID field (often 'id' or '<entity>Id')
|
|
122
|
+
* const entityId = event.data.[ENTITY_ID_FIELD];
|
|
123
|
+
*
|
|
124
|
+
* // b) Store/update entity state with relevant properties from event.data
|
|
125
|
+
* // Include only fields needed for aggregation calculations
|
|
126
|
+
* current._entities[entityId] = { [field]: value, ... };
|
|
127
|
+
*
|
|
128
|
+
* 3. Calculate aggregates from entity states:
|
|
129
|
+
* const counts = Object.values(current._entities).reduce((acc, entity) => {
|
|
130
|
+
* acc[entity.status] = (acc[entity.status] || 0) + 1;
|
|
131
|
+
* return acc;
|
|
132
|
+
* }, {});
|
|
133
|
+
*
|
|
134
|
+
* 4. Return with internal state:
|
|
135
|
+
* return { ...publicFields, _entities: current._entities } as Internal<%= pascalCase(targetName || 'State') %>;
|
|
136
|
+
<% } else if (isCompositeKey) { -%>
|
|
137
|
+
* **COMPOSITE KEY PROJECTION**
|
|
138
|
+
*
|
|
139
|
+
* This projection uses a composite key: <%= compositeKeyFields.join(' + ') %>
|
|
140
|
+
* Document ID format: `${event.data.<%= compositeKeyFields[0] %>}-${event.data.<%= compositeKeyFields.slice(1).join('}}-${event.data.') %>}`
|
|
141
|
+
*
|
|
142
|
+
* CRITICAL: You MUST include ALL key fields in every return statement:
|
|
143
|
+
* <%- compositeKeyFields.map(f => `- ${f}: event.data.${f}`).join('\n * ') %>
|
|
144
|
+
*
|
|
145
|
+
* Missing even one key field will cause the projection to fail.
|
|
146
|
+
* Key fields typically map directly from event data (no transformation needed).
|
|
147
|
+
*
|
|
148
|
+
* Example implementation:
|
|
149
|
+
* return {
|
|
150
|
+
* <%- compositeKeyFields.map(f => `${f}: event.data.${f},`).join('\n * ') %>
|
|
151
|
+
* // ... other fields
|
|
152
|
+
* };
|
|
153
|
+
<% } else if (isRemovalEvent || eventNameSuggestsRemoval) { -%>
|
|
97
154
|
* This event might indicate removal of a <%= targetName || 'document' %>.
|
|
98
155
|
*
|
|
99
156
|
* - If the intent is to **remove the document**, return `null`.
|
|
@@ -112,7 +169,7 @@ case '<%= event.type %>': {
|
|
|
112
169
|
* }
|
|
113
170
|
*
|
|
114
171
|
* 2. Cast document parameter to extended type:
|
|
115
|
-
* const current: Internal<%= pascalCase(targetName || 'State') %> =
|
|
172
|
+
* const current: Internal<%= pascalCase(targetName || 'State') %> = document ?? { ...defaults };
|
|
116
173
|
*
|
|
117
174
|
* 3. Cast return values to extended type:
|
|
118
175
|
* return { ...allFields, internalField } as Internal<%= pascalCase(targetName || 'State') %>;
|