@photon-grove/evt 0.1.1 → 0.2.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 (86) hide show
  1. package/README.md +51 -4
  2. package/dist/aggregate.d.ts +26 -0
  3. package/dist/aggregate.d.ts.map +1 -1
  4. package/dist/aggregate.js.map +1 -1
  5. package/dist/dynamodb/head.d.ts +6 -0
  6. package/dist/dynamodb/head.d.ts.map +1 -1
  7. package/dist/dynamodb/head.js +11 -0
  8. package/dist/dynamodb/head.js.map +1 -1
  9. package/dist/dynamodb/index.d.ts +2 -1
  10. package/dist/dynamodb/index.d.ts.map +1 -1
  11. package/dist/dynamodb/index.js +2 -1
  12. package/dist/dynamodb/index.js.map +1 -1
  13. package/dist/dynamodb/repository.d.ts +9 -2
  14. package/dist/dynamodb/repository.d.ts.map +1 -1
  15. package/dist/dynamodb/repository.js +123 -27
  16. package/dist/dynamodb/repository.js.map +1 -1
  17. package/dist/dynamodb/requests.d.ts +37 -3
  18. package/dist/dynamodb/requests.d.ts.map +1 -1
  19. package/dist/dynamodb/requests.js +67 -9
  20. package/dist/dynamodb/requests.js.map +1 -1
  21. package/dist/dynamodb/views.d.ts +18 -0
  22. package/dist/dynamodb/views.d.ts.map +1 -0
  23. package/dist/dynamodb/views.js +56 -0
  24. package/dist/dynamodb/views.js.map +1 -0
  25. package/dist/errors.d.ts +51 -4
  26. package/dist/errors.d.ts.map +1 -1
  27. package/dist/errors.js +60 -4
  28. package/dist/errors.js.map +1 -1
  29. package/dist/index.d.ts +4 -3
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/memory/repository.d.ts +3 -2
  34. package/dist/memory/repository.d.ts.map +1 -1
  35. package/dist/memory/repository.js +78 -29
  36. package/dist/memory/repository.js.map +1 -1
  37. package/dist/postgres/client.d.ts +32 -0
  38. package/dist/postgres/client.d.ts.map +1 -0
  39. package/dist/postgres/client.js +4 -0
  40. package/dist/postgres/client.js.map +1 -0
  41. package/dist/postgres/index.d.ts +4 -0
  42. package/dist/postgres/index.d.ts.map +1 -0
  43. package/dist/postgres/index.js +6 -0
  44. package/dist/postgres/index.js.map +1 -0
  45. package/dist/postgres/repository.d.ts +62 -0
  46. package/dist/postgres/repository.d.ts.map +1 -0
  47. package/dist/postgres/repository.js +598 -0
  48. package/dist/postgres/repository.js.map +1 -0
  49. package/dist/postgres/schema.d.ts +81 -0
  50. package/dist/postgres/schema.d.ts.map +1 -0
  51. package/dist/postgres/schema.js +249 -0
  52. package/dist/postgres/schema.js.map +1 -0
  53. package/dist/repository.d.ts +101 -6
  54. package/dist/repository.d.ts.map +1 -1
  55. package/dist/repository.js +108 -6
  56. package/dist/repository.js.map +1 -1
  57. package/dist/staging.d.ts +28 -0
  58. package/dist/staging.d.ts.map +1 -0
  59. package/dist/staging.js +89 -0
  60. package/dist/staging.js.map +1 -0
  61. package/dist/store.d.ts +15 -3
  62. package/dist/store.d.ts.map +1 -1
  63. package/dist/store.js +38 -54
  64. package/dist/store.js.map +1 -1
  65. package/dist/transaction.d.ts +113 -0
  66. package/dist/transaction.d.ts.map +1 -0
  67. package/dist/transaction.js +207 -0
  68. package/dist/transaction.js.map +1 -0
  69. package/package.json +5 -1
  70. package/src/aggregate.ts +28 -0
  71. package/src/dynamodb/head.ts +15 -0
  72. package/src/dynamodb/index.ts +2 -0
  73. package/src/dynamodb/repository.ts +177 -33
  74. package/src/dynamodb/requests.ts +114 -10
  75. package/src/dynamodb/views.ts +67 -0
  76. package/src/errors.ts +86 -4
  77. package/src/index.ts +19 -6
  78. package/src/memory/repository.ts +93 -32
  79. package/src/postgres/client.ts +37 -0
  80. package/src/postgres/index.ts +23 -0
  81. package/src/postgres/repository.ts +858 -0
  82. package/src/postgres/schema.ts +302 -0
  83. package/src/repository.ts +220 -8
  84. package/src/staging.ts +138 -0
  85. package/src/store.ts +66 -65
  86. package/src/transaction.ts +322 -0
@@ -14,6 +14,7 @@ import {
14
14
  ConflictError,
15
15
  EvtError,
16
16
  FeatureManifestChangedError,
17
+ FenceConflictError,
17
18
  HistoryDependentProjectionError,
18
19
  RebuildInProgressError,
19
20
  SequenceGapError,
@@ -34,12 +35,17 @@ import {int64Argument} from '../fields.js'
34
35
  import {
35
36
  type CallOptions,
36
37
  type Commit,
37
- type Repository,
38
+ type NormalizedTransaction,
38
39
  type StoreInitializer,
40
+ type TransactionCommit,
41
+ type TransactionalRepository,
39
42
  checkBudget,
43
+ checkFenceRetention,
40
44
  checkNotReserved,
41
- commitSizes,
42
45
  isReserved,
46
+ normalizeTransaction,
47
+ rankFailures,
48
+ transactionSizes,
43
49
  validateCommit,
44
50
  } from '../repository.js'
45
51
  import type {DynamoClient, Page, RequestOptions, TransactItem} from './client.js'
@@ -54,11 +60,15 @@ import {
54
60
  ttlFromItem,
55
61
  } from './item.js'
56
62
  import {FEATURES_PK, manifestFromItem, markerItem} from './marker.js'
63
+ import {DYNAMODB_BACKEND, transactItemSize} from './views.js'
57
64
  import {
58
65
  type CommitTtl,
59
- commitTransaction,
66
+ type StreamLayout,
67
+ type TransactionLayout,
60
68
  controlKey,
61
69
  headKey,
70
+ projectorItems,
71
+ buildTransaction,
62
72
  snapshotTransaction,
63
73
  } from './requests.js'
64
74
 
@@ -119,7 +129,7 @@ const MAX_SNAPSHOT_GUARD_RETRIES = 4
119
129
  * declares a feature, the next write fails, validates the manifest again, and either refuses an
120
130
  * unsupported feature or fails once with a retryable {@link FeatureManifestChangedError}.
121
131
  */
122
- export class DynamoRepository implements Repository, StoreInitializer {
132
+ export class DynamoRepository implements TransactionalRepository, StoreInitializer {
123
133
  readonly #client: DynamoClient
124
134
  readonly #table: string
125
135
  readonly #lazy: boolean
@@ -185,24 +195,64 @@ export class DynamoRepository implements Repository, StoreInitializer {
185
195
  options?.signal?.throwIfAborted()
186
196
  validateCommit(commit)
187
197
  checkNotReserved(commit.entityId)
188
- if (commit.events.length === 0) {
189
- return
198
+ if (commit.events.length > 0) {
199
+ await this.#write(normalizeTransaction({streams: [commit]}), options)
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Commits every stream and checks every fence in one `TransactWriteItems` call: the control
205
+ * checks, the projector operations, each written stream's actions, then a check on each fenced
206
+ * stream's head. A fence on a type this repository retains is refused before any request, and
207
+ * one on a type the store's manifest retains after the marker's validation.
208
+ */
209
+ async commitTransaction(transaction: TransactionCommit, options?: CallOptions): Promise<void> {
210
+ options?.signal?.throwIfAborted()
211
+ const normalized = normalizeTransaction(transaction)
212
+ if (normalized.streams.length > 0 || normalized.fences.length > 0) {
213
+ await this.#write(normalized, options)
190
214
  }
215
+ }
216
+
217
+ async #write(transaction: NormalizedTransaction, options?: CallOptions): Promise<void> {
218
+ // One `ttl` per entity type for the whole transaction, so a stream's head's is never below one
219
+ // of its own items.
220
+ const ttls = new Map<string, number | undefined>()
221
+ const ttlFor = (entityType: string): number | undefined => {
222
+ if (!ttls.has(entityType)) {
223
+ ttls.set(entityType, this.#ttlFor(entityType))
224
+ }
191
225
 
192
- // One `ttl` for the whole commit, so its head's is never below one of its own items.
193
- const entityType = commit.events[0]!.entityType
194
- const ttl = this.#ttlFor(entityType)
226
+ return ttls.get(entityType)
227
+ }
195
228
 
196
229
  // Every backend budgets a commit the same way, so an oversized one fails before any request.
197
- checkBudget(commitSizes(commit, (type) => (type === entityType ? ttl : undefined)))
230
+ // The projector operations are sized from the actions this repository sends, which refuses a
231
+ // group for another backend here too.
232
+ const sizes = projectorItems(transaction.projections).map(transactItemSize)
233
+ const projections = [{backend: DYNAMODB_BACKEND, operations: sizes, sizes}]
234
+ checkBudget(transactionSizes({...transaction, projections}, ttlFor))
235
+
236
+ // A fence on a type this repository retains needs no request to refuse.
237
+ checkFenceRetention(transaction, (entityType) => this.#retention.has(entityType))
198
238
 
199
239
  const request = requestOptions(options)
200
240
  const version = await this.#markerVersion(request)
201
- const items = commitTransaction(this.#table, commit, version, this.#commitTtl(commit, ttl))
241
+ if (transaction.fences.length > 0) {
242
+ // The store's manifest may retain a type this repository doesn't. The marker check holds it
243
+ // at the version read here, or fails the transaction.
244
+ const manifest = await this.#readManifest(request)
245
+ const retention = manifest?.retention ?? []
246
+ checkFenceRetention(transaction, (entityType) => retention.includes(entityType))
247
+ }
248
+
249
+ const {items, layout} = buildTransaction(this.#table, transaction, version, (commit) =>
250
+ this.#commitTtl(commit, ttlFor(commit.events[0]!.entityType)),
251
+ )
202
252
  try {
203
253
  await this.#client.transactWriteItems({TransactItems: items}, request)
204
254
  } catch (error) {
205
- throw await this.#classifyCommit(commit, error, request)
255
+ throw await this.#classifyCommit(layout, error, request)
206
256
  }
207
257
  }
208
258
 
@@ -290,6 +340,12 @@ export class DynamoRepository implements Repository, StoreInitializer {
290
340
  if (codes?.some((code) => THROTTLED.has(code))) {
291
341
  throw new TransientError('the snapshot write was throttled', {cause: error})
292
342
  }
343
+ if (codes?.includes('TransactionConflict')) {
344
+ throw new TransientError(
345
+ `transaction conflict on ${snapshot.entityId}: another writer was committing at the same time`,
346
+ {cause: error},
347
+ )
348
+ }
293
349
 
294
350
  throw error
295
351
  }
@@ -513,7 +569,14 @@ export class DynamoRepository implements Repository, StoreInitializer {
513
569
  return new FeatureManifestChangedError({cause})
514
570
  }
515
571
 
516
- async #classifyCommit(commit: Commit, error: unknown, request: RequestOptions): Promise<unknown> {
572
+ // Maps a cancelled transaction to the error it reports. The control checks outrank everything.
573
+ // Then every failed stream and fence is classified, and the ranking reports the first drift, else
574
+ // the first gap, else the first other failure, in transaction order.
575
+ async #classifyCommit(
576
+ layout: TransactionLayout,
577
+ error: unknown,
578
+ request: RequestOptions,
579
+ ): Promise<unknown> {
517
580
  const codes = cancellationCodes(error)
518
581
  if (codes === null) {
519
582
  return error
@@ -521,10 +584,14 @@ export class DynamoRepository implements Repository, StoreInitializer {
521
584
 
522
585
  const failed = codes.indexOf(CONDITION_FAILED)
523
586
  if (failed === -1) {
524
- if (codes.includes('TransactionConflict')) {
525
- return new ConflictError(
526
- commit.entityId,
527
- 'transaction conflict: another writer was committing at the same time',
587
+ // Another transaction was writing one of the items, perhaps one this commit never changes
588
+ // (a control check): nothing was written and nothing is known to be stale, so it is
589
+ // transient, like throttling, and never a conflict, as in Go and Rust.
590
+ const colliding = codes.indexOf('TransactionConflict')
591
+ if (colliding !== -1) {
592
+ return new TransientError(
593
+ `transaction conflict on ${entityAt(layout, colliding)}: another writer was committing at the same time`,
594
+ {cause: error},
528
595
  )
529
596
  }
530
597
  if (codes.some((code) => THROTTLED.has(code))) {
@@ -542,15 +609,57 @@ export class DynamoRepository implements Repository, StoreInitializer {
542
609
  return new RebuildInProgressError()
543
610
  }
544
611
 
545
- // The stream's head update, then its event puts and its snapshot put.
546
- const head = 2
547
- const eventsEnd = head + 1 + commit.events.length
548
- const eventFailed = codes.slice(head + 1, eventsEnd).includes(CONDITION_FAILED)
612
+ const failures: EvtError[] = []
613
+ for (const stream of layout.streams) {
614
+ const failure = await this.#streamFailure(stream, codes, request)
615
+ if (failure !== null) {
616
+ failures.push(failure)
617
+ }
618
+ }
619
+ for (const {index, fence} of layout.fences) {
620
+ if (codes[index] !== CONDITION_FAILED) {
621
+ continue
622
+ }
623
+
624
+ const verdict = await this.#probe(fence.entityId, false, request)
625
+ failures.push(
626
+ verdict.drift ?? new FenceConflictError(fence.entityId, fence.head, fence.provenance),
627
+ )
628
+ }
629
+
630
+ const ranked = rankFailures(failures)
631
+ if (ranked !== undefined) {
632
+ return ranked
633
+ }
634
+
635
+ // Only a projector operation's condition failed: its own error, as Go reports it.
636
+ if (failed < layout.projectorEnd) {
637
+ return error
638
+ }
639
+
640
+ return new ConflictError(
641
+ entityAt(layout, failed),
642
+ 'conditional check failed: the sequence or snapshot changed since it was read',
643
+ )
644
+ }
645
+
646
+ // What a written stream contributes to a failed transaction, or null when none of its actions
647
+ // failed. A failed head update is classified by the probe. A head update that passed while an
648
+ // event put failed is an event above the head, unless retention explains it. A failed snapshot
649
+ // put alone is an ordinary conflict.
650
+ async #streamFailure(
651
+ stream: StreamLayout,
652
+ codes: string[],
653
+ request: RequestOptions,
654
+ ): Promise<EvtError | null> {
655
+ const {commit} = stream
549
656
  const {entityId} = commit
550
657
  const first = commit.events[0]!.sequence
551
658
  const retained = this.#retention.has(commit.events[0]!.entityType)
659
+ const eventFailed = codes.slice(stream.head + 1, stream.eventsEnd).includes(CONDITION_FAILED)
660
+ const snapshotFailed = stream.snapshot !== null && codes[stream.snapshot] === CONDITION_FAILED
552
661
 
553
- if (codes[head] === CONDITION_FAILED) {
662
+ if (codes[stream.head] === CONDITION_FAILED) {
554
663
  const verdict = await this.#probe(entityId, retained, request)
555
664
  if (verdict.drift) {
556
665
  return verdict.drift
@@ -558,20 +667,26 @@ export class DynamoRepository implements Repository, StoreInitializer {
558
667
  if (verdict.healthy && first > 1n && verdict.stored < first - 1n) {
559
668
  return new SequenceGapError(entityId, verdict.stored, first)
560
669
  }
561
- } else if (codes[head] === 'None' && eventFailed) {
562
- // The head update passed and an event put failed: an event sits above the head. With
563
- // retention, TTL can delete the head before an expired event, so a create passes its head
564
- // condition and fails on the leftover item: a conflict until TTL deletes the rest.
670
+
671
+ return conflict(entityId)
672
+ }
673
+
674
+ if (eventFailed && codes[stream.head] === 'None') {
675
+ // With retention, TTL can delete the head before an expired event, so a create passes its
676
+ // head condition and fails on the leftover item: a conflict until TTL deletes the rest. The
677
+ // probe already read the remaining items when it found the head item absent below the head
678
+ // its log derives; read them only if it didn't.
565
679
  const verdict = await this.#probe(entityId, retained, request)
566
- if (!retained || !(await this.#remainingItemsExpired(entityId, request))) {
680
+ const expired =
681
+ retained && (verdict.expired ?? (await this.#remainingItemsExpired(entityId, request)))
682
+ if (!expired) {
567
683
  return verdict.drift ?? drift(entityId, verdict.head, verdict.derived)
568
684
  }
685
+
686
+ return conflict(entityId)
569
687
  }
570
688
 
571
- return new ConflictError(
572
- entityId,
573
- 'conditional check failed: the sequence or snapshot changed since it was read',
574
- )
689
+ return eventFailed || snapshotFailed ? conflict(entityId) : null
575
690
  }
576
691
 
577
692
  // Classifies a stream whose head update failed, as the failure-path probe does: a stored head
@@ -584,8 +699,11 @@ export class DynamoRepository implements Repository, StoreInitializer {
584
699
  const head = (await this.#readHead(entityId, request))?.head ?? null
585
700
  const stored = head ?? 0n
586
701
  if (stored < derived) {
587
- if (retained && head === null && (await this.#remainingItemsExpired(entityId, request))) {
588
- return {healthy: false, drift: null, stored, head, derived}
702
+ if (retained && head === null) {
703
+ const expired = await this.#remainingItemsExpired(entityId, request)
704
+ const drifted = expired ? null : drift(entityId, head, derived)
705
+
706
+ return {healthy: false, drift: drifted, stored, head, derived, expired}
589
707
  }
590
708
 
591
709
  return {healthy: false, drift: drift(entityId, head, derived), stored, head, derived}
@@ -958,6 +1076,32 @@ interface Verdict {
958
1076
  stored: bigint
959
1077
  head: bigint | null
960
1078
  derived: bigint
1079
+ /** What `#remainingItemsExpired` found, when the probe read the remaining items. */
1080
+ expired?: boolean
1081
+ }
1082
+
1083
+ function conflict(entityId: string): ConflictError {
1084
+ return new ConflictError(
1085
+ entityId,
1086
+ 'conditional check failed: the sequence or snapshot changed since it was read',
1087
+ )
1088
+ }
1089
+
1090
+ // The stream an action of a transaction belongs to, for a failure no stream check explains: the
1091
+ // written or fenced stream at that index, or else the transaction's first.
1092
+ function entityAt(layout: TransactionLayout, index: number): string {
1093
+ for (const stream of layout.streams) {
1094
+ if (index >= stream.head && (index < stream.eventsEnd || index === stream.snapshot)) {
1095
+ return stream.commit.entityId
1096
+ }
1097
+ }
1098
+
1099
+ const fenced = layout.fences.find((each) => each.index === index)
1100
+ if (fenced !== undefined) {
1101
+ return fenced.fence.entityId
1102
+ }
1103
+
1104
+ return layout.streams[0]?.commit.entityId ?? layout.fences[0]?.fence.entityId ?? ''
961
1105
  }
962
1106
 
963
1107
  function drift(entityId: string, head: bigint | null, derived: bigint): StreamHeadDriftError {
@@ -1,15 +1,18 @@
1
1
  // The `TransactWriteItems` actions of a commit and of a snapshot write, built from the storage
2
2
  // format alone (storage format, "Writes"). Every commit opens with the two control checks, the
3
- // feature marker's and the rebuild lock's, then writes the stream: its head update, its event
4
- // puts, and its snapshot put when it has one.
3
+ // feature marker's and the rebuild lock's, then its projector operations, then writes each stream:
4
+ // its head update, its event puts, and its snapshot put when it has one. A transaction then checks
5
+ // the head of every stream it fenced.
5
6
 
6
7
  import type {SerializedEvent, SerializedSnapshot} from '../event.js'
7
8
  import {int64Argument, safeIntegerArgument} from '../fields.js'
8
- import type {Commit} from '../repository.js'
9
+ import {EvtError} from '../errors.js'
10
+ import type {Commit, NormalizedTransaction, ProjectorGroup, ReadFence} from '../repository.js'
9
11
  import type {TransactItem} from './client.js'
10
- import {type HeadAction, headUpdate} from './head.js'
12
+ import {type HeadAction, headFence, headUpdate} from './head.js'
11
13
  import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
12
14
  import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
15
+ import {DYNAMODB_BACKEND} from './views.js'
13
16
 
14
17
  /** The marker check of a writer that validated the manifest at version `:v`. */
15
18
  export const MARKER_CHECK_CONDITION = 'attribute_not_exists(pk) OR version = :v'
@@ -99,8 +102,8 @@ export interface SnapshotTtl {
99
102
 
100
103
  /**
101
104
  * The actions of a commit with at least one event, in order, by a writer that validated the
102
- * feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. Exported
103
- * for the compatibility fixtures: its signature changes when commits span streams.
105
+ * feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. A projector
106
+ * group for any backend but DynamoDB is refused. Exported for the compatibility fixtures.
104
107
  */
105
108
  export function commitTransaction(
106
109
  table: string,
@@ -108,15 +111,83 @@ export function commitTransaction(
108
111
  markerVersion: bigint,
109
112
  ttl?: CommitTtl,
110
113
  ): TransactItem[] {
111
- const first = commit.events[0]
112
- const last = commit.events.at(-1)
113
- if (first === undefined || last === undefined) {
114
+ if (commit.events.length === 0) {
114
115
  throw new RangeError('a commit transaction needs at least one event')
115
116
  }
116
117
 
118
+ const transaction = {streams: [commit], fences: [], projections: commit.projections ?? []}
119
+
120
+ return transactionItems(table, transaction, markerVersion, () => ttl)
121
+ }
122
+
123
+ /** Where each stream's and fence's actions sit in a transaction, to classify a cancellation. */
124
+ export interface TransactionLayout {
125
+ /** The index after the control checks and the projector operations. */
126
+ readonly projectorEnd: number
127
+ readonly streams: readonly StreamLayout[]
128
+ readonly fences: readonly {readonly index: number; readonly fence: ReadFence}[]
129
+ }
130
+
131
+ /** A written stream's actions in a transaction. */
132
+ export interface StreamLayout {
133
+ readonly commit: Commit
134
+ /** The head update's index. Its event puts follow it. */
135
+ readonly head: number
136
+ /** The index after its last event put. */
137
+ readonly eventsEnd: number
138
+ /** The snapshot put's index, when it writes one. */
139
+ readonly snapshot: number | null
140
+ }
141
+
142
+ /**
143
+ * The actions of a normalized transaction that writes or fences at least one stream, in order:
144
+ * the control checks, the projector operations in declaration order, every written stream's head
145
+ * update, event puts and snapshot put, then a check on every fenced stream's head. `ttl` gives a
146
+ * written stream's `ttl`s when it has retention. Exported for the compatibility fixtures.
147
+ */
148
+ export function transactionItems(
149
+ table: string,
150
+ transaction: NormalizedTransaction,
151
+ markerVersion: bigint,
152
+ ttl: (commit: Commit) => CommitTtl | undefined,
153
+ ): TransactItem[] {
154
+ return buildTransaction(table, transaction, markerVersion, ttl).items
155
+ }
156
+
157
+ /** {@link transactionItems}, with where each stream's and fence's actions sit. */
158
+ export function buildTransaction(
159
+ table: string,
160
+ transaction: NormalizedTransaction,
161
+ markerVersion: bigint,
162
+ ttl: (commit: Commit) => CommitTtl | undefined,
163
+ ): {items: TransactItem[]; layout: TransactionLayout} {
117
164
  const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
165
+ items.push(...projectorItems(transaction.projections))
166
+ const projectorEnd = items.length
167
+
168
+ const streams: StreamLayout[] = []
169
+ for (const commit of transaction.streams) {
170
+ const head = items.length
171
+ items.push(...streamItems(table, commit, ttl(commit)))
172
+ const eventsEnd = head + 1 + commit.events.length
173
+ streams.push({commit, head, eventsEnd, snapshot: items.length > eventsEnd ? eventsEnd : null})
174
+ }
175
+
176
+ const fences = transaction.fences.map((fence) => {
177
+ items.push(fenceCheck(table, fence))
178
+
179
+ return {index: items.length - 1, fence}
180
+ })
181
+
182
+ return {items, layout: {projectorEnd, streams, fences}}
183
+ }
184
+
185
+ // A written stream's actions: its head update, its event puts and its snapshot put.
186
+ function streamItems(table: string, commit: Commit, ttl: CommitTtl | undefined): TransactItem[] {
187
+ const first = commit.events[0]!
188
+ const last = commit.events.at(-1)!
118
189
  const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
119
- items.push(headUpdateItem(table, head))
190
+ const items = [headUpdateItem(table, head)]
120
191
  for (const event of commit.events) {
121
192
  items.push(eventPut(table, event, ttl?.items))
122
193
  }
@@ -136,6 +207,23 @@ export function commitTransaction(
136
207
  return items
137
208
  }
138
209
 
210
+ // The check on a fenced stream's head item: it still has the head the transaction read.
211
+ function fenceCheck(table: string, fence: ReadFence): TransactItem {
212
+ const action = headFence(fence.entityId, fence.head)
213
+ const check: NonNullable<TransactItem['ConditionCheck']> = {
214
+ TableName: table,
215
+ Key: headKey(fence.entityId),
216
+ ConditionExpression: action.conditionExpression,
217
+ }
218
+ if (action.values.length > 0) {
219
+ check.ExpressionAttributeValues = Object.fromEntries(
220
+ action.values.map(([placeholder, value]) => [placeholder, number(value)]),
221
+ )
222
+ }
223
+
224
+ return {ConditionCheck: check}
225
+ }
226
+
139
227
  /**
140
228
  * The actions of a snapshot written outside a commit: the marker check, the head guard, and the
141
229
  * put, which never lowers the stored snapshot. It carries no rebuild-lock check. `ttl` is given
@@ -200,6 +288,22 @@ function snapshotGuard(
200
288
  }
201
289
  }
202
290
 
291
+ /** The DynamoDB actions of projector groups, in order. */
292
+ export function projectorItems(groups: readonly ProjectorGroup[]): TransactItem[] {
293
+ const items: TransactItem[] = []
294
+ for (const group of groups) {
295
+ if (group.backend !== DYNAMODB_BACKEND) {
296
+ throw new EvtError(
297
+ 'invalidCommit',
298
+ `invalid commit: a DynamoDB repository can't write a ${group.backend} projector group`,
299
+ )
300
+ }
301
+ items.push(...(group.operations as TransactItem[]))
302
+ }
303
+
304
+ return items
305
+ }
306
+
203
307
  function headUpdateItem(table: string, action: HeadAction): TransactItem {
204
308
  const update: NonNullable<TransactItem['Update']> = {
205
309
  TableName: table,
@@ -0,0 +1,67 @@
1
+ // The per-entity view projector: one row per entity in a views table, its state as the aggregate's
2
+ // codec encodes it, put in the same transaction as the commit's events (storage format, "Views").
3
+ // Commit-wide projectors, which own their keys, supersede it in Go and Rust, but a TypeScript
4
+ // writer of an entity type that a Go service projects this way must write the row too.
5
+
6
+ import type {Projection, Projector} from '../aggregate.js'
7
+ import {utf8Length} from '../utf8.js'
8
+ import type {TransactItem} from './client.js'
9
+ import {itemSize, viewItem} from './item.js'
10
+
11
+ /** The backend name of the projector groups the DynamoDB repository writes. */
12
+ export const DYNAMODB_BACKEND = 'dynamodb'
13
+
14
+ /**
15
+ * A projector that puts the entity's view row in `viewsTable` with every commit:
16
+ * `pk = {entityType}#{entityId}`, `sk = "VIEW"`, and the encoded state as its payload, as Go's
17
+ * `dynamo.NewViewProjector` writes it. The put is unconditional, so the row is the state after the
18
+ * entity's latest commit.
19
+ */
20
+ export function viewProjector<S>(viewsTable: string): Projector<S> {
21
+ if (viewsTable === '') {
22
+ throw new RangeError('a view projector needs a views table')
23
+ }
24
+
25
+ return {
26
+ project({entityType, entityId, encodedState}: Projection<S>) {
27
+ if (entityId === '') {
28
+ return null
29
+ }
30
+
31
+ const put: TransactItem = {
32
+ Put: {TableName: viewsTable, Item: viewItem(entityType, entityId, encodedState)},
33
+ }
34
+
35
+ return {backend: DYNAMODB_BACKEND, operations: [put], sizes: [transactItemSize(put)]}
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * An action's size in the commit budget: a put counts its item, and an update, delete or condition
42
+ * check counts its key plus its expression strings, name placeholders (the placeholder and the
43
+ * name) and attribute values.
44
+ */
45
+ export function transactItemSize(action: TransactItem): number {
46
+ if (action.Put) {
47
+ return itemSize(action.Put.Item)
48
+ }
49
+
50
+ const body = action.Update ?? action.Delete ?? action.ConditionCheck
51
+ if (!body) {
52
+ return 0
53
+ }
54
+
55
+ let size = itemSize(body.Key) + utf8Length(body.ConditionExpression ?? '')
56
+ if (action.Update) {
57
+ size += utf8Length(action.Update.UpdateExpression)
58
+ }
59
+ for (const [placeholder, name] of Object.entries(body.ExpressionAttributeNames ?? {})) {
60
+ size += utf8Length(placeholder) + utf8Length(name)
61
+ }
62
+ for (const [placeholder, value] of Object.entries(body.ExpressionAttributeValues ?? {})) {
63
+ size += utf8Length(placeholder) + itemSize({'': value})
64
+ }
65
+
66
+ return size
67
+ }