@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.
- package/README.md +39 -4
- package/dist/aggregate.d.ts +26 -0
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js.map +1 -1
- package/dist/dynamodb/index.d.ts +1 -0
- package/dist/dynamodb/index.d.ts.map +1 -1
- package/dist/dynamodb/index.js +1 -0
- package/dist/dynamodb/index.js.map +1 -1
- package/dist/dynamodb/repository.d.ts.map +1 -1
- package/dist/dynamodb/repository.js +24 -7
- package/dist/dynamodb/repository.js.map +1 -1
- package/dist/dynamodb/requests.d.ts +5 -2
- package/dist/dynamodb/requests.d.ts.map +1 -1
- package/dist/dynamodb/requests.js +19 -4
- package/dist/dynamodb/requests.js.map +1 -1
- package/dist/dynamodb/views.d.ts +18 -0
- package/dist/dynamodb/views.d.ts.map +1 -0
- package/dist/dynamodb/views.js +56 -0
- package/dist/dynamodb/views.js.map +1 -0
- package/dist/errors.d.ts +14 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/postgres/client.d.ts +32 -0
- package/dist/postgres/client.d.ts.map +1 -0
- package/dist/postgres/client.js +4 -0
- package/dist/postgres/client.js.map +1 -0
- package/dist/postgres/index.d.ts +4 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +6 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/postgres/repository.d.ts +55 -0
- package/dist/postgres/repository.d.ts.map +1 -0
- package/dist/postgres/repository.js +527 -0
- package/dist/postgres/repository.js.map +1 -0
- package/dist/postgres/schema.d.ts +81 -0
- package/dist/postgres/schema.d.ts.map +1 -0
- package/dist/postgres/schema.js +233 -0
- package/dist/postgres/schema.js.map +1 -0
- package/dist/repository.d.ts +26 -6
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +21 -5
- package/dist/repository.js.map +1 -1
- package/dist/store.d.ts +4 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +31 -6
- package/dist/store.js.map +1 -1
- package/package.json +5 -1
- package/src/aggregate.ts +28 -0
- package/src/dynamodb/index.ts +1 -0
- package/src/dynamodb/repository.ts +29 -6
- package/src/dynamodb/requests.ts +24 -4
- package/src/dynamodb/views.ts +67 -0
- package/src/errors.ts +23 -0
- package/src/index.ts +4 -0
- package/src/postgres/client.ts +37 -0
- package/src/postgres/index.ts +23 -0
- package/src/postgres/repository.ts +773 -0
- package/src/postgres/schema.ts +286 -0
- package/src/repository.ts +52 -7
- 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
|
-
|
|
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,
|
|
546
|
-
|
|
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
|
-
|
|
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
|
|
588
|
-
|
|
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 {
|
package/src/dynamodb/requests.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
103
|
-
* for
|
|
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'
|