@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
@@ -0,0 +1,302 @@
1
+ // The PostgreSQL layout of an event log, independent of any driver (storage format, "PostgreSQL"):
2
+ // the table names a store is configured with, the engine relations derived from them, and the
3
+ // idempotent DDL that Go's EnsureSchema and Rust's ensure_schema run.
4
+
5
+ import {EvtError} from '../errors.js'
6
+
7
+ /** The default events table. */
8
+ export const DEFAULT_EVENTS_TABLE = 'evt_events'
9
+
10
+ /** The default snapshots table. */
11
+ export const DEFAULT_SNAPSHOTS_TABLE = 'evt_snapshots'
12
+
13
+ /**
14
+ * The infix the engine relations derive from the events table name with, such as
15
+ * `evt_events__evt_features`. A configured table name may not contain it, in any case, so one
16
+ * log's tables can never equal another log's derived relation.
17
+ */
18
+ export const RESERVED_INFIX = '__evt_'
19
+
20
+ /**
21
+ * The longest events table name, in bytes. Its longest derived relation,
22
+ * `{events}__evt_rebuild_generation_pkey`, then fills PostgreSQL's 63-byte identifier limit.
23
+ */
24
+ export const MAX_EVENTS_TABLE_NAME_BYTES = 63 - '__evt_rebuild_generation_pkey'.length
25
+
26
+ /** The longest snapshots table name, in bytes, which leaves room for its `_pkey` index. */
27
+ export const MAX_SNAPSHOTS_TABLE_NAME_BYTES = 63 - '_pkey'.length
28
+
29
+ // Only pgTables can make a PgTables, so the names the DDL interpolates are always validated.
30
+ declare const validated: unique symbol
31
+
32
+ /**
33
+ * An event log's two configured tables, validated and folded to lower case. Only
34
+ * {@link pgTables} makes one.
35
+ */
36
+ export interface PgTables {
37
+ readonly events: string
38
+ readonly snapshots: string
39
+ readonly [validated]: true
40
+ }
41
+
42
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
43
+
44
+ // PostgreSQL's reserved keywords (pg_get_keywords() categories R and T), which can't be unquoted
45
+ // table names. The same list as Go's and Rust's.
46
+ const RESERVED_KEYWORDS = new Set(
47
+ `all analyse analyze and any array as asc asymmetric authorization binary both case cast check
48
+ collate collation column concurrently constraint create cross current_catalog current_date
49
+ current_role current_schema current_time current_timestamp current_user default deferrable desc
50
+ distinct do else end except false fetch for foreign freeze from full grant group having ilike in
51
+ initially inner intersect into is isnull join lateral leading left like limit localtime
52
+ localtimestamp natural not notnull null offset on only or order outer overlaps placing primary
53
+ references returning right select session_user similar some symmetric system_user table
54
+ tablesample then to trailing true union unique user using variadic verbose when where window
55
+ with`.split(/\s+/),
56
+ )
57
+
58
+ /**
59
+ * Validates an event log's table names and folds them to lower case, as PostgreSQL folds unquoted
60
+ * identifiers. Each must be a plain identifier that isn't a reserved keyword, within its length
61
+ * limit, without the {@link RESERVED_INFIX}, and no two relations the schema creates may share a
62
+ * name. A name that breaks a rule fails with `invalidArgument`, as Go and Rust refuse it at
63
+ * construction.
64
+ */
65
+ export function pgTables(
66
+ events: string = DEFAULT_EVENTS_TABLE,
67
+ snapshots: string = DEFAULT_SNAPSHOTS_TABLE,
68
+ ): PgTables {
69
+ for (const [name, limit] of [
70
+ [events, MAX_EVENTS_TABLE_NAME_BYTES],
71
+ [snapshots, MAX_SNAPSHOTS_TABLE_NAME_BYTES],
72
+ ] as const) {
73
+ if (!validIdentifier(name, limit)) {
74
+ throw invalidConfig(`invalid table name ${JSON.stringify(name)}`)
75
+ }
76
+ if (name.toLowerCase().includes(RESERVED_INFIX)) {
77
+ throw invalidConfig(
78
+ `table name ${JSON.stringify(name)} must not contain the reserved infix ` +
79
+ JSON.stringify(RESERVED_INFIX),
80
+ )
81
+ }
82
+ }
83
+
84
+ const tables = {events: events.toLowerCase(), snapshots: snapshots.toLowerCase()} as PgTables
85
+ const shared = sharedRelation(tables)
86
+ if (shared !== null) {
87
+ throw invalidConfig(
88
+ `tables ${JSON.stringify(events)} and ${JSON.stringify(snapshots)} would both use the ` +
89
+ `name ${JSON.stringify(shared)}`,
90
+ )
91
+ }
92
+
93
+ return tables
94
+ }
95
+
96
+ /** The command-outcomes table: `evt_events` yields `evt_events__evt_command_outcomes`. */
97
+ export function outcomesTable(tables: PgTables): string {
98
+ return derived(tables.events, 'command_outcomes')
99
+ }
100
+
101
+ /** The feature-marker table: `evt_events` yields `evt_events__evt_features`. */
102
+ export function featuresTable(tables: PgTables): string {
103
+ return derived(tables.events, 'features')
104
+ }
105
+
106
+ /** The rebuild-lock table: `evt_events` yields `evt_events__evt_rebuild_lock`. */
107
+ export function rebuildLockTable(tables: PgTables): string {
108
+ return derived(tables.events, 'rebuild_lock')
109
+ }
110
+
111
+ /** The rebuild-generation table: `evt_events` yields `evt_events__evt_rebuild_generation`. */
112
+ export function rebuildGenerationTable(tables: PgTables): string {
113
+ return derived(tables.events, 'rebuild_generation')
114
+ }
115
+
116
+ /** The stream-heads table: `evt_events` yields `evt_events__evt_stream_heads`. */
117
+ export function streamHeadsTable(tables: PgTables): string {
118
+ return derived(tables.events, 'stream_heads')
119
+ }
120
+
121
+ /** The entity-type index of the events table: `evt_events__evt_entity_type_idx`. */
122
+ export function entityTypeIndex(tables: PgTables): string {
123
+ return derived(tables.events, 'entity_type_idx')
124
+ }
125
+
126
+ /**
127
+ * The idempotent DDL of an event log, byte for byte the DDL Go's `EnsureSchema` and Rust's
128
+ * `ensure_schema` run: the events and snapshots tables, the entity-type index (dropping its
129
+ * pre-v0.9 name), and the command-outcome, feature-marker, rebuild-fence and stream-heads tables.
130
+ * Run it inside one transaction, after {@link SCHEMA_LOCK} with the events table name, as Go and
131
+ * Rust do.
132
+ */
133
+ export function schemaDdl(tables: PgTables): string {
134
+ const {events, snapshots} = tables
135
+
136
+ return `
137
+ CREATE TABLE IF NOT EXISTS ${events} (
138
+ entity_id text NOT NULL,
139
+ sequence bigint NOT NULL,
140
+ event_id text NOT NULL,
141
+ event_type text NOT NULL,
142
+ version bigint NOT NULL,
143
+ entity_type text NOT NULL,
144
+ payload bytea NOT NULL,
145
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
146
+ created_at timestamptz NOT NULL DEFAULT now(),
147
+ PRIMARY KEY (entity_id, sequence)
148
+ );
149
+ CREATE INDEX IF NOT EXISTS ${entityTypeIndex(tables)} ON ${events} (entity_type);
150
+ DO $$
151
+ BEGIN
152
+ IF EXISTS (
153
+ SELECT 1 FROM pg_index
154
+ WHERE indexrelid = to_regclass('${events}_entity_type_idx')
155
+ AND indrelid = '${events}'::regclass
156
+ ) THEN
157
+ DROP INDEX ${events}_entity_type_idx;
158
+ END IF;
159
+ END $$;
160
+
161
+ CREATE TABLE IF NOT EXISTS ${snapshots} (
162
+ entity_id text PRIMARY KEY,
163
+ entity_type text NOT NULL,
164
+ sequence bigint NOT NULL,
165
+ event_sequence bigint NOT NULL,
166
+ payload bytea NOT NULL,
167
+ updated_at timestamptz NOT NULL DEFAULT now()
168
+ );
169
+
170
+ CREATE TABLE IF NOT EXISTS ${outcomesTable(tables)} (
171
+ command_id text PRIMARY KEY,
172
+ status text NOT NULL CHECK (status IN ('accepted', 'rejected')),
173
+ digest text NOT NULL,
174
+ response bytea,
175
+ events jsonb NOT NULL DEFAULT '[]'::jsonb,
176
+ rejection jsonb,
177
+ code text,
178
+ created_at timestamptz NOT NULL DEFAULT now(),
179
+ command_type text,
180
+ targets jsonb,
181
+ principal text,
182
+ CHECK ((status = 'rejected' AND rejection IS NOT NULL AND code IS NOT NULL)
183
+ OR (status = 'accepted' AND rejection IS NULL AND code IS NULL))
184
+ );
185
+
186
+ DO $$
187
+ BEGIN
188
+ IF (SELECT count(*) FROM pg_attribute
189
+ WHERE attrelid = '${outcomesTable(tables)}'::regclass
190
+ AND attname IN ('command_type', 'targets', 'principal')
191
+ AND NOT attisdropped) < 3 THEN
192
+ ALTER TABLE ${outcomesTable(tables)}
193
+ ADD COLUMN IF NOT EXISTS command_type text,
194
+ ADD COLUMN IF NOT EXISTS targets jsonb,
195
+ ADD COLUMN IF NOT EXISTS principal text;
196
+ END IF;
197
+ END $$;
198
+
199
+ CREATE TABLE IF NOT EXISTS ${featuresTable(tables)} (
200
+ id integer PRIMARY KEY,
201
+ version bigint NOT NULL,
202
+ features text NOT NULL,
203
+ history_dependent text NOT NULL DEFAULT '[]',
204
+ retention text NOT NULL DEFAULT '[]'
205
+ );
206
+
207
+ CREATE TABLE IF NOT EXISTS ${rebuildLockTable(tables)} (
208
+ id integer PRIMARY KEY,
209
+ owner text NOT NULL,
210
+ generation bigint NOT NULL,
211
+ lease_expires_at timestamptz NOT NULL
212
+ );
213
+
214
+ CREATE TABLE IF NOT EXISTS ${rebuildGenerationTable(tables)} (
215
+ id integer PRIMARY KEY,
216
+ value bigint NOT NULL,
217
+ force_released boolean NOT NULL DEFAULT false
218
+ );
219
+
220
+ CREATE TABLE IF NOT EXISTS ${streamHeadsTable(tables)} (
221
+ entity_id text PRIMARY KEY,
222
+ entity_type text NOT NULL,
223
+ head bigint NOT NULL CHECK (head >= 1)
224
+ );
225
+ `
226
+ }
227
+
228
+ /**
229
+ * Serializes schema changes across processes, in every language: `IF NOT EXISTS` alone can still
230
+ * race in PostgreSQL's catalogs. `$1` is the events table name.
231
+ */
232
+ export const SCHEMA_LOCK = 'SELECT pg_advisory_xact_lock(1702261762, hashtext($1))'
233
+
234
+ /**
235
+ * The per-entity transaction lock every commit and snapshot write takes, `$1` being the entity
236
+ * ID. Go and Rust run this exact statement, so writers in every language exclude each other.
237
+ */
238
+ export const ENTITY_LOCK = 'SELECT pg_advisory_xact_lock(1702261760, hashtext($1))'
239
+
240
+ /**
241
+ * The shared engine-row lock a commit takes and holds to `COMMIT`, `$1` being a key from
242
+ * {@link featuresLockKey} or {@link rebuildLockKey}. Commits don't serialize against each other.
243
+ */
244
+ export const ENGINE_LOCK_SHARED = 'SELECT pg_advisory_xact_lock_shared(1702261761, hashtext($1))'
245
+
246
+ /**
247
+ * The exclusive engine-row lock a declaration or a rebuild lifecycle step takes for its short
248
+ * transaction, so it never slips between a commit's check and its `COMMIT`.
249
+ */
250
+ export const ENGINE_LOCK_EXCLUSIVE = 'SELECT pg_advisory_xact_lock(1702261761, hashtext($1))'
251
+
252
+ /** The feature marker's engine-row lock key: `{events}:features`. */
253
+ export function featuresLockKey(tables: PgTables): string {
254
+ return `${tables.events}:features`
255
+ }
256
+
257
+ /** The rebuild fence's engine-row lock key: `{events}:rebuild`. */
258
+ export function rebuildLockKey(tables: PgTables): string {
259
+ return `${tables.events}:rebuild`
260
+ }
261
+
262
+ function derived(events: string, suffix: string): string {
263
+ return `${events}${RESERVED_INFIX}${suffix}`
264
+ }
265
+
266
+ // A valid identifier is ASCII, so its length is its byte count and folding it changes only A-Z.
267
+ // The type check refuses a non-string passed from JavaScript, which the pattern would coerce.
268
+ function validIdentifier(name: unknown, limit: number): name is string {
269
+ return (
270
+ typeof name === 'string' &&
271
+ name.length <= limit &&
272
+ IDENTIFIER.test(name) &&
273
+ !RESERVED_KEYWORDS.has(name.toLowerCase())
274
+ )
275
+ }
276
+
277
+ // A name two relations of the schema would share, or null, with the names already folded to lower
278
+ // case. CREATE TABLE IF NOT EXISTS would otherwise skip a table whose name an index already took.
279
+ function sharedRelation(tables: PgTables): string | null {
280
+ const {events, snapshots} = tables
281
+ const derivedTables = [
282
+ outcomesTable(tables),
283
+ featuresTable(tables),
284
+ rebuildLockTable(tables),
285
+ rebuildGenerationTable(tables),
286
+ streamHeadsTable(tables),
287
+ ]
288
+ const relations = [
289
+ events,
290
+ `${events}_pkey`,
291
+ entityTypeIndex(tables),
292
+ ...derivedTables.flatMap((table) => [table, `${table}_pkey`]),
293
+ snapshots,
294
+ `${snapshots}_pkey`,
295
+ ]
296
+
297
+ return relations.find((relation, index) => relations.indexOf(relation) !== index) ?? null
298
+ }
299
+
300
+ function invalidConfig(message: string): EvtError {
301
+ return new EvtError('invalidArgument', `postgres: ${message}`)
302
+ }
package/src/repository.ts CHANGED
@@ -1,12 +1,22 @@
1
1
  // The storage contract every backend implements, and the commit rules every backend applies the
2
2
  // same way: validation, the reserved namespaces, and the transaction budget.
3
3
 
4
- import {headActionSize, headUpdate} from './dynamodb/head.js'
4
+ import {headActionSize, headFence, headUpdate} from './dynamodb/head.js'
5
5
  import {eventItem, itemSize, snapshotItem} from './dynamodb/item.js'
6
6
  import {FEATURES_PK, REBUILD_LOCK_PK} from './dynamodb/marker.js'
7
- import {CommitTooLargeError, EvtError, ReservedEntityIdError} from './errors.js'
7
+ import {
8
+ CommitTooLargeError,
9
+ EvtError,
10
+ type FenceProvenance,
11
+ ProjectorOnlyCommitError,
12
+ ReservedEntityIdError,
13
+ RetentionConflictError,
14
+ SequenceGapError,
15
+ StreamHeadDriftError,
16
+ } from './errors.js'
8
17
  import {type SerializedEvent, type SerializedSnapshot, eventId} from './event.js'
9
18
  import {MAX_COMMIT_ITEM_BYTES} from './snapshots.js'
19
+ import {compareUtf8} from './utf8.js'
10
20
 
11
21
  /** A snapshot to write with a commit. Its event sequence is the commit's last event. */
12
22
  export interface NewSnapshot {
@@ -17,9 +27,24 @@ export interface NewSnapshot {
17
27
  payload: Uint8Array
18
28
  }
19
29
 
30
+ /**
31
+ * Operations a projector adds to a commit, for the backend it names to write in the commit's own
32
+ * transaction (Go's transaction groups). The DynamoDB repository writes `dynamodb` groups, whose
33
+ * operations are `TransactWriteItems` actions. The in-memory repository budgets every group and
34
+ * writes none, as Go's does, so code with projectors runs on it unchanged.
35
+ */
36
+ export interface ProjectorGroup {
37
+ /** The backend whose transaction carries the operations, such as `dynamodb`. */
38
+ readonly backend: string
39
+ /** The operations, in the order they're written. */
40
+ readonly operations: readonly unknown[]
41
+ /** Each operation's size in the commit budget, as DynamoDB counts it. */
42
+ readonly sizes: readonly number[]
43
+ }
44
+
20
45
  /**
21
46
  * Everything a repository writes atomically for one command. A commit with no events is a no-op,
22
- * and its snapshot is ignored.
47
+ * and its snapshot is ignored, unless it carries projector operations: then it's refused.
23
48
  */
24
49
  export interface Commit {
25
50
  /** The stream the events are appended to. */
@@ -28,6 +53,8 @@ export interface Commit {
28
53
  events: SerializedEvent[]
29
54
  /** The snapshot to write, when the commit reaches a snapshot boundary. */
30
55
  snapshot?: NewSnapshot | null | undefined
56
+ /** The operations the aggregate's projectors add, written first, in order. */
57
+ projections?: readonly ProjectorGroup[] | null | undefined
31
58
  }
32
59
 
33
60
  /** Options every repository call takes. */
@@ -53,7 +80,9 @@ export interface CallOptions {
53
80
  * {@link StreamHeadDriftError}, ranked above a gap and any other conflict;
54
81
  * - refuse a commit's snapshot whose sequence isn't the stored snapshot's plus one (1 with none
55
82
  * stored) with a {@link ConflictError};
56
- * - write a commit's events, head and snapshot atomically, or nothing;
83
+ * - write a commit's events, head and snapshot atomically, or nothing, with the projector
84
+ * operations it writes (a backend writes only its own groups), and refuse projector operations
85
+ * without events with a {@link ProjectorOnlyCommitError};
57
86
  * - refuse an entity ID in a reserved namespace on every write path, before any request;
58
87
  * - refuse every write (a commit, a snapshot write, compaction) to a store whose feature manifest
59
88
  * declares a pair this package doesn't model, with an {@link UnsupportedFeatureError}, and to
@@ -119,6 +148,131 @@ export interface StoreInitializer {
119
148
  initialize(options?: CallOptions): Promise<boolean>
120
149
  }
121
150
 
151
+ /**
152
+ * A stream a transaction read but doesn't write, whose head must not move before it commits (ADR
153
+ * 0007): the fence passes while the stream's stored head still equals `head`.
154
+ */
155
+ export interface ReadFence {
156
+ entityId: string
157
+ /** The stream's entity type, which a fence on a retained type is refused for. */
158
+ entityType: string
159
+ /** The head the transaction observed: 0 for a stream with no events. */
160
+ head: bigint
161
+ /** Whether the engine read the head itself, or the caller stamped it. */
162
+ provenance: FenceProvenance
163
+ }
164
+
165
+ /**
166
+ * Everything one transaction writes atomically: the streams it appends to, and fences on the
167
+ * streams it read. A fence on a stream the transaction also writes is dropped, since that stream's
168
+ * head update already conditions on the head it read. A transaction that writes nothing and fences
169
+ * nothing is a no-op.
170
+ */
171
+ export interface TransactionCommit {
172
+ /** The streams written, each a {@link Commit}, at most one per stream. */
173
+ streams: readonly Commit[]
174
+ /** The streams read but not written, at most one fence per stream. */
175
+ fences?: readonly ReadFence[] | null | undefined
176
+ }
177
+
178
+ /**
179
+ * A repository that commits several streams atomically, with read fences (ADR 0004). A
180
+ * transaction is refused as a commit is, and also:
181
+ *
182
+ * - with `invalidCommit` when it writes a stream twice, fences a stream twice, fences at a head
183
+ * below 0 or at the largest int64, or carries a snapshot for a stream it writes no events to;
184
+ * - with a {@link RetentionConflictError} when it fences an entity type with retention: by the
185
+ * repository's own configuration before any request, and by the store's manifest after the
186
+ * manifest's other refusals, but always before the rebuild lock's;
187
+ * - with a {@link FenceConflictError} when a fenced stream's head moved, unless the stream
188
+ * drifted, ranked after every drift and gap of the transaction, in transaction order.
189
+ */
190
+ export interface TransactionalRepository extends Repository {
191
+ /** Appends to every stream and checks every fence atomically. */
192
+ commitTransaction(transaction: TransactionCommit, options?: CallOptions): Promise<void>
193
+ }
194
+
195
+ /** A transaction as the backends write it. */
196
+ export interface NormalizedTransaction {
197
+ /** The streams it writes events to, sorted by entity ID bytes, without their projections. */
198
+ readonly streams: readonly Commit[]
199
+ /** Its fences on the streams it doesn't write, sorted the same way. */
200
+ readonly fences: readonly ReadFence[]
201
+ /** The written streams' projector groups, in the order the streams were declared. */
202
+ readonly projections: readonly ProjectorGroup[]
203
+ }
204
+
205
+ /**
206
+ * Validates a transaction and puts it in the order every backend writes it: the written streams,
207
+ * then the fences, each sorted by entity ID bytes, the order that pins a DynamoDB transaction and
208
+ * PostgreSQL's locks. The projector groups keep the order the streams were declared in. Streams
209
+ * without events are dropped, and so are the fences on streams that write events, whose head
210
+ * updates condition on the head they read. A fence on a stream listed without events stays.
211
+ */
212
+ export function normalizeTransaction(transaction: TransactionCommit): NormalizedTransaction {
213
+ const listed = new Set<string>()
214
+ for (const stream of transaction.streams) {
215
+ validateCommit(stream)
216
+ checkNotReserved(stream.entityId)
217
+ if (listed.has(stream.entityId)) {
218
+ throw invalidCommit(`duplicate stream ${stream.entityId}`)
219
+ }
220
+ listed.add(stream.entityId)
221
+ }
222
+
223
+ const fenced = new Set<string>()
224
+ for (const fence of transaction.fences ?? []) {
225
+ checkNotReserved(fence.entityId)
226
+ if (fence.head < 0n || fence.head >= MAX_INT64 || fenced.has(fence.entityId)) {
227
+ throw invalidCommit(`invalid or duplicate fence ${fence.entityId}`)
228
+ }
229
+ fenced.add(fence.entityId)
230
+ }
231
+
232
+ for (const stream of transaction.streams) {
233
+ if (stream.snapshot && stream.events.length === 0) {
234
+ throw invalidCommit(`the snapshot of ${stream.entityId} must cover this commit's last event`)
235
+ }
236
+ }
237
+
238
+ const written = transaction.streams.filter((stream) => stream.events.length > 0)
239
+ const projections = written.flatMap((stream) => stream.projections ?? [])
240
+ const streams = written.map((stream) => Object.assign({}, stream, {projections: undefined}))
241
+ streams.sort(byId)
242
+ const writes = new Set(streams.map((stream) => stream.entityId))
243
+ const fences = (transaction.fences ?? []).filter((fence) => !writes.has(fence.entityId))
244
+ fences.sort(byId)
245
+
246
+ return {streams, fences, projections}
247
+ }
248
+
249
+ /**
250
+ * Refuses a transaction that fences a retained entity type, by the store's manifest or the
251
+ * repository's own configuration.
252
+ */
253
+ export function checkFenceRetention(
254
+ transaction: NormalizedTransaction,
255
+ retained: (entityType: string) => boolean,
256
+ ): void {
257
+ for (const fence of transaction.fences) {
258
+ if (retained(fence.entityType)) {
259
+ throw new RetentionConflictError(fence.entityType)
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * The error a failed transaction reports from the failures of its streams and fences, given in
266
+ * transaction order: the first drift, else the first gap, else the first of the rest.
267
+ */
268
+ export function rankFailures(failures: readonly EvtError[]): EvtError | undefined {
269
+ return (
270
+ failures.find((failure) => failure instanceof StreamHeadDriftError) ??
271
+ failures.find((failure) => failure instanceof SequenceGapError) ??
272
+ failures[0]
273
+ )
274
+ }
275
+
122
276
  /** The most actions one commit may carry, DynamoDB's transaction limit. */
123
277
  export const MAX_COMMIT_ACTIONS = 100
124
278
 
@@ -147,11 +301,27 @@ export function checkNotReserved(entityId: string): void {
147
301
  * Checks that a commit's events can be stored as written: each belongs to the commit's entity and
148
302
  * has the ID `{entityId}:{sequence}`, they share one entity type, the first sequence is at least
149
303
  * 1, and the sequences are contiguous. A snapshot must have the events' entity type and a
150
- * sequence of at least 1. A commit with no events is valid, and its snapshot isn't checked.
304
+ * sequence of at least 1, and each projector group a size per operation. A commit with no events
305
+ * is valid, and its snapshot isn't checked, but its projector operations are refused.
151
306
  */
152
307
  export function validateCommit(commit: Commit): void {
308
+ let operations = 0
309
+ for (const group of commit.projections ?? []) {
310
+ if (group.sizes.length !== group.operations.length) {
311
+ throw invalidCommit(
312
+ `a ${group.backend} projector group has ${group.operations.length} operations but ` +
313
+ `${group.sizes.length} sizes`,
314
+ )
315
+ }
316
+ operations += group.operations.length
317
+ }
318
+
153
319
  const [first] = commit.events
154
320
  if (first === undefined) {
321
+ if (operations > 0) {
322
+ throw new ProjectorOnlyCommitError(operations)
323
+ }
324
+
155
325
  return
156
326
  }
157
327
 
@@ -207,16 +377,50 @@ export function validateCommit(commit: Commit): void {
207
377
 
208
378
  /**
209
379
  * The size of each action a commit's transaction carries, in order: the feature-marker and
210
- * rebuild-lock checks, then the stream's head update, its event puts, and its snapshot put when
211
- * it writes one. `ttl` gives the expiry an entity type's items carry, if it's retained. Every
212
- * backend budgets a commit by these sizes, so an oversized commit fails the same way on each.
380
+ * rebuild-lock checks, the projector operations, then the stream's head update, its event puts,
381
+ * and its snapshot put when it writes one. `ttl` gives the expiry an entity type's items carry, if
382
+ * it's retained. Every backend budgets a commit by these sizes, so an oversized commit fails the
383
+ * same way on each.
213
384
  */
214
385
  export function commitSizes(
215
386
  commit: Commit,
216
387
  ttl: (entityType: string) => number | undefined,
388
+ ): number[] {
389
+ const transaction = {streams: [commit], fences: [], projections: commit.projections ?? []}
390
+
391
+ return transactionSizes(transaction, ttl)
392
+ }
393
+
394
+ /**
395
+ * The size of each action a transaction carries: the two control checks, every stream's projector
396
+ * operations, every written stream's head update, event puts and snapshot put, then a check on
397
+ * every fenced stream's head. Every backend budgets a transaction by these sizes.
398
+ */
399
+ export function transactionSizes(
400
+ transaction: NormalizedTransaction,
401
+ ttl: (entityType: string) => number | undefined,
217
402
  ): number[] {
218
403
  // The control rows every commit checks: the feature marker and the rebuild lock.
219
404
  const sizes = [controlCheckSize(FEATURES_PK), controlCheckSize(REBUILD_LOCK_PK)]
405
+ for (const group of transaction.projections) {
406
+ sizes.push(...group.sizes)
407
+ }
408
+ for (const stream of transaction.streams) {
409
+ sizes.push(...streamSizes(stream, ttl))
410
+ }
411
+ for (const fence of transaction.fences) {
412
+ sizes.push(headActionSize(headFence(fence.entityId, fence.head)))
413
+ }
414
+
415
+ return sizes
416
+ }
417
+
418
+ // A written stream's actions: its head update, its event puts and its snapshot put.
419
+ function streamSizes(
420
+ commit: Commit,
421
+ ttl: (entityType: string) => number | undefined,
422
+ ): number[] {
423
+ const sizes: number[] = []
220
424
  const first = commit.events[0]
221
425
  const last = commit.events.at(-1)
222
426
  if (first === undefined || last === undefined) {
@@ -265,6 +469,14 @@ export function checkBudget(sizes: readonly number[]): void {
265
469
  }
266
470
  }
267
471
 
472
+ // Orders streams and fences by their entity IDs' UTF-8 bytes.
473
+ function byId(a: {entityId: string}, b: {entityId: string}): number {
474
+ return compareUtf8(a.entityId, b.entityId)
475
+ }
476
+
477
+ // The largest int64, whose successor no head can reach.
478
+ const MAX_INT64 = 2n ** 63n - 1n
479
+
268
480
  // A control check is sized by its key: `pk` and `sk = 0`.
269
481
  function controlCheckSize(pk: string): number {
270
482
  return itemSize({pk: {S: pk}, sk: {N: '0'}})