@photon-grove/evt 0.1.1 → 0.1.2

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 (65) hide show
  1. package/README.md +39 -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/index.d.ts +1 -0
  6. package/dist/dynamodb/index.d.ts.map +1 -1
  7. package/dist/dynamodb/index.js +1 -0
  8. package/dist/dynamodb/index.js.map +1 -1
  9. package/dist/dynamodb/repository.d.ts.map +1 -1
  10. package/dist/dynamodb/repository.js +24 -7
  11. package/dist/dynamodb/repository.js.map +1 -1
  12. package/dist/dynamodb/requests.d.ts +5 -2
  13. package/dist/dynamodb/requests.d.ts.map +1 -1
  14. package/dist/dynamodb/requests.js +19 -4
  15. package/dist/dynamodb/requests.js.map +1 -1
  16. package/dist/dynamodb/views.d.ts +18 -0
  17. package/dist/dynamodb/views.d.ts.map +1 -0
  18. package/dist/dynamodb/views.js +56 -0
  19. package/dist/dynamodb/views.js.map +1 -0
  20. package/dist/errors.d.ts +14 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +15 -0
  23. package/dist/errors.js.map +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/postgres/client.d.ts +32 -0
  29. package/dist/postgres/client.d.ts.map +1 -0
  30. package/dist/postgres/client.js +4 -0
  31. package/dist/postgres/client.js.map +1 -0
  32. package/dist/postgres/index.d.ts +4 -0
  33. package/dist/postgres/index.d.ts.map +1 -0
  34. package/dist/postgres/index.js +6 -0
  35. package/dist/postgres/index.js.map +1 -0
  36. package/dist/postgres/repository.d.ts +55 -0
  37. package/dist/postgres/repository.d.ts.map +1 -0
  38. package/dist/postgres/repository.js +527 -0
  39. package/dist/postgres/repository.js.map +1 -0
  40. package/dist/postgres/schema.d.ts +81 -0
  41. package/dist/postgres/schema.d.ts.map +1 -0
  42. package/dist/postgres/schema.js +233 -0
  43. package/dist/postgres/schema.js.map +1 -0
  44. package/dist/repository.d.ts +26 -6
  45. package/dist/repository.d.ts.map +1 -1
  46. package/dist/repository.js +21 -5
  47. package/dist/repository.js.map +1 -1
  48. package/dist/store.d.ts +4 -2
  49. package/dist/store.d.ts.map +1 -1
  50. package/dist/store.js +31 -6
  51. package/dist/store.js.map +1 -1
  52. package/package.json +5 -1
  53. package/src/aggregate.ts +28 -0
  54. package/src/dynamodb/index.ts +1 -0
  55. package/src/dynamodb/repository.ts +29 -6
  56. package/src/dynamodb/requests.ts +24 -4
  57. package/src/dynamodb/views.ts +67 -0
  58. package/src/errors.ts +23 -0
  59. package/src/index.ts +4 -0
  60. package/src/postgres/client.ts +37 -0
  61. package/src/postgres/index.ts +23 -0
  62. package/src/postgres/repository.ts +773 -0
  63. package/src/postgres/schema.ts +286 -0
  64. package/src/repository.ts +52 -7
  65. package/src/store.ts +58 -8
@@ -54,11 +54,13 @@ import {
54
54
  ttlFromItem,
55
55
  } from './item.js'
56
56
  import {FEATURES_PK, manifestFromItem, markerItem} from './marker.js'
57
+ import {DYNAMODB_BACKEND, transactItemSize} from './views.js'
57
58
  import {
58
59
  type CommitTtl,
59
60
  commitTransaction,
60
61
  controlKey,
61
62
  headKey,
63
+ projectorItems,
62
64
  snapshotTransaction,
63
65
  } from './requests.js'
64
66
 
@@ -194,7 +196,12 @@ export class DynamoRepository implements Repository, StoreInitializer {
194
196
  const ttl = this.#ttlFor(entityType)
195
197
 
196
198
  // 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)))
199
+ // The projector operations are sized from the actions this repository sends, which refuses a
200
+ // group for another backend here too.
201
+ const sizes = projectorItems(commit).map(transactItemSize)
202
+ const group = {backend: DYNAMODB_BACKEND, operations: sizes, sizes}
203
+ const budgeted = {...commit, projections: [group]}
204
+ checkBudget(commitSizes(budgeted, (type) => (type === entityType ? ttl : undefined)))
198
205
 
199
206
  const request = requestOptions(options)
200
207
  const version = await this.#markerVersion(request)
@@ -542,8 +549,10 @@ export class DynamoRepository implements Repository, StoreInitializer {
542
549
  return new RebuildInProgressError()
543
550
  }
544
551
 
545
- // The stream's head update, then its event puts and its snapshot put.
546
- const head = 2
552
+ // The projector operations, then the stream's head update, its event puts and its snapshot
553
+ // put.
554
+ const projected = projectorItems(commit).length
555
+ const head = 2 + projected
547
556
  const eventsEnd = head + 1 + commit.events.length
548
557
  const eventFailed = codes.slice(head + 1, eventsEnd).includes(CONDITION_FAILED)
549
558
  const {entityId} = commit
@@ -562,12 +571,21 @@ export class DynamoRepository implements Repository, StoreInitializer {
562
571
  // The head update passed and an event put failed: an event sits above the head. With
563
572
  // retention, TTL can delete the head before an expired event, so a create passes its head
564
573
  // condition and fails on the leftover item: a conflict until TTL deletes the rest.
574
+ // The probe already read the remaining items when it found the head item absent below the
575
+ // head its log derives; read them only if it didn't.
565
576
  const verdict = await this.#probe(entityId, retained, request)
566
- if (!retained || !(await this.#remainingItemsExpired(entityId, request))) {
577
+ const expired =
578
+ retained && (verdict.expired ?? (await this.#remainingItemsExpired(entityId, request)))
579
+ if (!expired) {
567
580
  return verdict.drift ?? drift(entityId, verdict.head, verdict.derived)
568
581
  }
569
582
  }
570
583
 
584
+ // Only a projector operation's condition failed: its own error, as Go reports it.
585
+ if (failed < head && !codes.slice(head).includes(CONDITION_FAILED)) {
586
+ return error
587
+ }
588
+
571
589
  return new ConflictError(
572
590
  entityId,
573
591
  'conditional check failed: the sequence or snapshot changed since it was read',
@@ -584,8 +602,11 @@ export class DynamoRepository implements Repository, StoreInitializer {
584
602
  const head = (await this.#readHead(entityId, request))?.head ?? null
585
603
  const stored = head ?? 0n
586
604
  if (stored < derived) {
587
- if (retained && head === null && (await this.#remainingItemsExpired(entityId, request))) {
588
- return {healthy: false, drift: null, stored, head, derived}
605
+ if (retained && head === null) {
606
+ const expired = await this.#remainingItemsExpired(entityId, request)
607
+ const drifted = expired ? null : drift(entityId, head, derived)
608
+
609
+ return {healthy: false, drift: drifted, stored, head, derived, expired}
589
610
  }
590
611
 
591
612
  return {healthy: false, drift: drift(entityId, head, derived), stored, head, derived}
@@ -958,6 +979,8 @@ interface Verdict {
958
979
  stored: bigint
959
980
  head: bigint | null
960
981
  derived: bigint
982
+ /** What `#remainingItemsExpired` found, when the probe read the remaining items. */
983
+ expired?: boolean
961
984
  }
962
985
 
963
986
  function drift(entityId: string, head: bigint | null, derived: bigint): StreamHeadDriftError {
@@ -1,15 +1,17 @@
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 the stream:
4
+ // its head update, its event puts, and its snapshot put when it has one.
5
5
 
6
6
  import type {SerializedEvent, SerializedSnapshot} from '../event.js'
7
7
  import {int64Argument, safeIntegerArgument} from '../fields.js'
8
+ import {EvtError} from '../errors.js'
8
9
  import type {Commit} from '../repository.js'
9
10
  import type {TransactItem} from './client.js'
10
11
  import {type HeadAction, headUpdate} from './head.js'
11
12
  import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
12
13
  import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
14
+ import {DYNAMODB_BACKEND} from './views.js'
13
15
 
14
16
  /** The marker check of a writer that validated the manifest at version `:v`. */
15
17
  export const MARKER_CHECK_CONDITION = 'attribute_not_exists(pk) OR version = :v'
@@ -99,8 +101,9 @@ export interface SnapshotTtl {
99
101
 
100
102
  /**
101
103
  * 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.
104
+ * feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. A projector
105
+ * group for any backend but DynamoDB is refused. Exported for the compatibility fixtures: its
106
+ * signature changes when commits span streams.
104
107
  */
105
108
  export function commitTransaction(
106
109
  table: string,
@@ -115,6 +118,7 @@ export function commitTransaction(
115
118
  }
116
119
 
117
120
  const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
121
+ items.push(...projectorItems(commit))
118
122
  const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
119
123
  items.push(headUpdateItem(table, head))
120
124
  for (const event of commit.events) {
@@ -200,6 +204,22 @@ function snapshotGuard(
200
204
  }
201
205
  }
202
206
 
207
+ /** The DynamoDB actions of a commit's projector groups, in order. */
208
+ export function projectorItems(commit: Commit): TransactItem[] {
209
+ const items: TransactItem[] = []
210
+ for (const group of commit.projections ?? []) {
211
+ if (group.backend !== DYNAMODB_BACKEND) {
212
+ throw new EvtError(
213
+ 'invalidCommit',
214
+ `invalid commit: a DynamoDB repository can't write a ${group.backend} projector group`,
215
+ )
216
+ }
217
+ items.push(...(group.operations as TransactItem[]))
218
+ }
219
+
220
+ return items
221
+ }
222
+
203
223
  function headUpdateItem(table: string, action: HeadAction): TransactItem {
204
224
  const update: NonNullable<TransactItem['Update']> = {
205
225
  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
+ }
package/src/errors.ts CHANGED
@@ -30,6 +30,8 @@
30
30
  * - `transient`: the backend throttled the write. Retryable. Nothing was written.
31
31
  * - `rebuildInProgress`: a projection rebuild holds the store, so commits wait. Retryable.
32
32
  * - `historyDependentProjection`: compaction was refused for a history-dependent entity type.
33
+ * - `projectorOnlyCommit`: a commit carries projector operations but no events. Nothing was
34
+ * written.
33
35
  * - `replay`: a stored event failed to upcast or decode while an aggregate loaded.
34
36
  * - `snapshot`: a stored snapshot failed to decode while an aggregate loaded.
35
37
  */
@@ -51,6 +53,7 @@ export type EvtErrorCode =
51
53
  | 'transient'
52
54
  | 'rebuildInProgress'
53
55
  | 'historyDependentProjection'
56
+ | 'projectorOnlyCommit'
54
57
  | 'replay'
55
58
  | 'snapshot'
56
59
 
@@ -301,6 +304,26 @@ export class HistoryDependentProjectionError extends EvtError {
301
304
  }
302
305
  }
303
306
 
307
+ /**
308
+ * A commit carried projector operations but no events. Views are projections of committed events,
309
+ * so operations with no event behind them are refused on every backend. Nothing was written.
310
+ */
311
+ export class ProjectorOnlyCommitError extends EvtError {
312
+ override readonly name = 'ProjectorOnlyCommitError'
313
+ override readonly code = 'projectorOnlyCommit'
314
+ /** How many projector operations the commit carried. */
315
+ readonly operations: number
316
+
317
+ constructor(operations: number) {
318
+ super(
319
+ 'projectorOnlyCommit',
320
+ `a commit carries ${operations} projector operations but no events; views need a ` +
321
+ 'backing event in the same commit',
322
+ )
323
+ this.operations = operations
324
+ }
325
+ }
326
+
304
327
  /** A load failed closed on a stored event: an upcaster threw, or the payload didn't decode. */
305
328
  export class ReplayError extends EvtError {
306
329
  override readonly name = 'ReplayError'
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@ export {
9
9
  type EventDefinition,
10
10
  type EventDefinitions,
11
11
  type EventOf,
12
+ type Projection,
13
+ type Projector,
12
14
  type Rejection,
13
15
  type Upcaster,
14
16
  defineAggregate,
@@ -32,6 +34,7 @@ export {
32
34
  type EvtErrorCode,
33
35
  FeatureManifestChangedError,
34
36
  HistoryDependentProjectionError,
37
+ ProjectorOnlyCommitError,
35
38
  RebuildInProgressError,
36
39
  ReplayError,
37
40
  ReservedEntityIdError,
@@ -77,6 +80,7 @@ export type {
77
80
  CallOptions,
78
81
  Commit,
79
82
  NewSnapshot,
83
+ ProjectorGroup,
80
84
  Repository,
81
85
  StoreInitializer,
82
86
  } from './repository.js'
@@ -0,0 +1,37 @@
1
+ // The PostgreSQL client a PgRepository takes: a structural interface that `pg`'s Pool, Neon's
2
+ // serverless Pool and PGlite satisfy without an adapter, so this package imports no driver.
3
+
4
+ /** The result of one statement: its rows, as the driver returns them. */
5
+ export interface PgResult {
6
+ readonly rows: readonly unknown[]
7
+ }
8
+
9
+ /** Something that runs one statement: `$1`, `$2` and so on in `text` take `values` in order. */
10
+ export interface PgQueryable {
11
+ query(text: string, values?: unknown[]): Promise<PgResult>
12
+ }
13
+
14
+ /**
15
+ * A connection held for one transaction, as a pool lends it. `release` returns it to the pool, and
16
+ * gets the error when the connection may be unusable, so the pool can discard it.
17
+ */
18
+ export interface PgConnection extends PgQueryable {
19
+ release(error?: Error): void
20
+ }
21
+
22
+ /**
23
+ * The client a {@link PgRepository} runs on.
24
+ *
25
+ * - A pool, such as `pg`'s `Pool` or Neon's serverless `Pool`, has `connect`, which lends one
26
+ * connection for each transaction. Single statements outside a transaction go to `query`.
27
+ * - A single connection without `connect`, such as PGlite, runs everything itself, so every
28
+ * repository on it runs one operation at a time on it. Nothing else may use the connection
29
+ * meanwhile: a statement sent inside a repository's transaction would join it.
30
+ *
31
+ * Every value the repository sends is a string or null, with casts in the SQL, and every column it
32
+ * reads is text, so no driver's type parsing touches a stored value. A `pg` `Client` has a
33
+ * `connect` that opens the client rather than lending a connection, so pass a `Pool` instead.
34
+ */
35
+ export interface PgClient extends PgQueryable {
36
+ connect?(): Promise<PgConnection>
37
+ }
@@ -0,0 +1,23 @@
1
+ // @photon-grove/evt/postgres: the PostgreSQL backend. It imports no driver: the repository takes
2
+ // `pg`'s Pool, or anything shaped like it, as a PgClient.
3
+
4
+ export {type PgClient, type PgConnection, type PgQueryable, type PgResult} from './client.js'
5
+ export {POSTGRES_BACKEND, PgRepository, type PgRepositoryOptions} from './repository.js'
6
+
7
+ export {
8
+ DEFAULT_EVENTS_TABLE,
9
+ DEFAULT_SNAPSHOTS_TABLE,
10
+ MAX_EVENTS_TABLE_NAME_BYTES,
11
+ MAX_SNAPSHOTS_TABLE_NAME_BYTES,
12
+ type PgTables,
13
+ RESERVED_INFIX,
14
+ SCHEMA_LOCK,
15
+ entityTypeIndex,
16
+ featuresTable,
17
+ outcomesTable,
18
+ pgTables,
19
+ rebuildGenerationTable,
20
+ rebuildLockTable,
21
+ schemaDdl,
22
+ streamHeadsTable,
23
+ } from './schema.js'