@photon-grove/evt 0.1.0 → 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 (96) hide show
  1. package/README.md +85 -9
  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/client.d.ts +83 -0
  6. package/dist/dynamodb/client.d.ts.map +1 -0
  7. package/dist/dynamodb/client.js +6 -0
  8. package/dist/dynamodb/client.js.map +1 -0
  9. package/dist/dynamodb/index.d.ts +6 -1
  10. package/dist/dynamodb/index.d.ts.map +1 -1
  11. package/dist/dynamodb/index.js +8 -3
  12. package/dist/dynamodb/index.js.map +1 -1
  13. package/dist/dynamodb/item.d.ts +2 -0
  14. package/dist/dynamodb/item.d.ts.map +1 -1
  15. package/dist/dynamodb/item.js +5 -4
  16. package/dist/dynamodb/item.js.map +1 -1
  17. package/dist/dynamodb/marker.d.ts +11 -0
  18. package/dist/dynamodb/marker.d.ts.map +1 -0
  19. package/dist/dynamodb/marker.js +56 -0
  20. package/dist/dynamodb/marker.js.map +1 -0
  21. package/dist/dynamodb/repository.d.ts +61 -0
  22. package/dist/dynamodb/repository.d.ts.map +1 -0
  23. package/dist/dynamodb/repository.js +748 -0
  24. package/dist/dynamodb/repository.js.map +1 -0
  25. package/dist/dynamodb/requests.d.ts +68 -0
  26. package/dist/dynamodb/requests.d.ts.map +1 -0
  27. package/dist/dynamodb/requests.js +194 -0
  28. package/dist/dynamodb/requests.js.map +1 -0
  29. package/dist/dynamodb/views.d.ts +18 -0
  30. package/dist/dynamodb/views.d.ts.map +1 -0
  31. package/dist/dynamodb/views.js +56 -0
  32. package/dist/dynamodb/views.js.map +1 -0
  33. package/dist/errors.d.ts +109 -2
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +118 -2
  36. package/dist/errors.js.map +1 -1
  37. package/dist/features.d.ts +55 -0
  38. package/dist/features.d.ts.map +1 -0
  39. package/dist/features.js +126 -0
  40. package/dist/features.js.map +1 -0
  41. package/dist/index.d.ts +4 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/memory/index.d.ts +1 -1
  46. package/dist/memory/index.d.ts.map +1 -1
  47. package/dist/memory/index.js +1 -1
  48. package/dist/memory/index.js.map +1 -1
  49. package/dist/memory/repository.d.ts +38 -2
  50. package/dist/memory/repository.d.ts.map +1 -1
  51. package/dist/memory/repository.js +95 -6
  52. package/dist/memory/repository.js.map +1 -1
  53. package/dist/postgres/client.d.ts +32 -0
  54. package/dist/postgres/client.d.ts.map +1 -0
  55. package/dist/postgres/client.js +4 -0
  56. package/dist/postgres/client.js.map +1 -0
  57. package/dist/postgres/index.d.ts +4 -0
  58. package/dist/postgres/index.d.ts.map +1 -0
  59. package/dist/postgres/index.js +6 -0
  60. package/dist/postgres/index.js.map +1 -0
  61. package/dist/postgres/repository.d.ts +55 -0
  62. package/dist/postgres/repository.d.ts.map +1 -0
  63. package/dist/postgres/repository.js +527 -0
  64. package/dist/postgres/repository.js.map +1 -0
  65. package/dist/postgres/schema.d.ts +81 -0
  66. package/dist/postgres/schema.d.ts.map +1 -0
  67. package/dist/postgres/schema.js +233 -0
  68. package/dist/postgres/schema.js.map +1 -0
  69. package/dist/repository.d.ts +56 -7
  70. package/dist/repository.d.ts.map +1 -1
  71. package/dist/repository.js +24 -9
  72. package/dist/repository.js.map +1 -1
  73. package/dist/store.d.ts +7 -5
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js +34 -9
  76. package/dist/store.js.map +1 -1
  77. package/package.json +16 -3
  78. package/src/aggregate.ts +28 -0
  79. package/src/dynamodb/client.ts +102 -0
  80. package/src/dynamodb/index.ts +27 -2
  81. package/src/dynamodb/item.ts +6 -4
  82. package/src/dynamodb/marker.ts +64 -0
  83. package/src/dynamodb/repository.ts +1053 -0
  84. package/src/dynamodb/requests.ts +276 -0
  85. package/src/dynamodb/views.ts +67 -0
  86. package/src/errors.ts +176 -2
  87. package/src/features.ts +175 -0
  88. package/src/index.ts +28 -1
  89. package/src/memory/index.ts +9 -1
  90. package/src/memory/repository.ts +156 -6
  91. package/src/postgres/client.ts +37 -0
  92. package/src/postgres/index.ts +23 -0
  93. package/src/postgres/repository.ts +773 -0
  94. package/src/postgres/schema.ts +286 -0
  95. package/src/repository.ts +86 -13
  96. package/src/store.ts +61 -11
@@ -0,0 +1,175 @@
1
+ // The feature manifest a store keeps on its SYSTEM#FEATURES marker row (storage format, "Feature
2
+ // marker"): the extensions it declares, each at a stored-format version, and the entity types
3
+ // declared history-dependent or retained. A writer refuses a store that declares a pair it doesn't
4
+ // model, and every writer requires stream-heads.
5
+
6
+ import {
7
+ EvtError,
8
+ type FeaturePair,
9
+ StreamHeadsNotMigratedError,
10
+ UnsupportedFeatureError,
11
+ } from './errors.js'
12
+ import {DOCUMENT, int64, invalid} from './fields.js'
13
+ import {type JsonValue, parseJson, stringifyJson} from './json.js'
14
+ import {compareUtf8} from './utf8.js'
15
+
16
+ /** The feature every writer models and requires: per-stream head items (ADR 0007). */
17
+ export const STREAM_HEADS_FEATURE = 'stream-heads'
18
+
19
+ /** The stored-format version of {@link STREAM_HEADS_FEATURE} this package reads and writes. */
20
+ export const STREAM_HEADS_VERSION = 1
21
+
22
+ /**
23
+ * The `(feature, stored-format version)` pairs this package models. The other features the
24
+ * storage format defines (`journal`, `journal-compaction`, `postgres-views`) are Rust-first, so a
25
+ * store that declares any of them is refused.
26
+ */
27
+ export const SUPPORTED_FEATURES: readonly FeaturePair[] = Object.freeze([
28
+ Object.freeze({name: STREAM_HEADS_FEATURE, version: STREAM_HEADS_VERSION}),
29
+ ])
30
+
31
+ // What validation reads. Private, so no caller can add a pair to it.
32
+ const supported = new Map(SUPPORTED_FEATURES.map((pair) => [pair.name, pair.version]))
33
+
34
+ /** The parsed marker row. A store with no marker row has no manifest. */
35
+ export interface FeatureManifest {
36
+ /** Bumped by exactly one on every declaration. */
37
+ readonly version: bigint
38
+ /** Each declared feature's stored-format version. */
39
+ readonly features: ReadonlyMap<string, number>
40
+ /** The entity types whose commit-wide projectors are history-dependent: never compacted. */
41
+ readonly historyDependent: readonly string[]
42
+ /** The entity types configured with retention: never fenced. */
43
+ readonly retention: readonly string[]
44
+ }
45
+
46
+ /**
47
+ * Refuses a manifest that declares a pair this package doesn't model, listing every such pair,
48
+ * with an {@link UnsupportedFeatureError}.
49
+ */
50
+ export function checkSupported(manifest: FeatureManifest | null): void {
51
+ const unsupported: FeaturePair[] = []
52
+ for (const [name, version] of sortedFeatures(manifest?.features ?? new Map())) {
53
+ if (supported.get(name) !== version) {
54
+ unsupported.push({name, version})
55
+ }
56
+ }
57
+
58
+ if (unsupported.length > 0) {
59
+ throw new UnsupportedFeatureError(unsupported)
60
+ }
61
+ }
62
+
63
+ /** Whether the manifest declares stream-heads at the version this package models. */
64
+ export function declaresStreamHeads(manifest: FeatureManifest | null): boolean {
65
+ return manifest?.features.get(STREAM_HEADS_FEATURE) === STREAM_HEADS_VERSION
66
+ }
67
+
68
+ /**
69
+ * Refuses a write to a store whose manifest declares an unsupported pair, or doesn't declare
70
+ * stream-heads, without declaring anything.
71
+ */
72
+ export function checkWritable(manifest: FeatureManifest | null): void {
73
+ checkSupported(manifest)
74
+ if (!declaresStreamHeads(manifest)) {
75
+ throw new StreamHeadsNotMigratedError()
76
+ }
77
+ }
78
+
79
+ /** The manifest with stream-heads declared and its version bumped: a first writer's declaration. */
80
+ export function withStreamHeads(manifest: FeatureManifest | null): FeatureManifest {
81
+ const features = new Map(manifest?.features)
82
+ features.set(STREAM_HEADS_FEATURE, STREAM_HEADS_VERSION)
83
+
84
+ return {
85
+ version: (manifest?.version ?? 0n) + 1n,
86
+ features,
87
+ historyDependent: manifest?.historyDependent ?? [],
88
+ retention: manifest?.retention ?? [],
89
+ }
90
+ }
91
+
92
+ /** A manifest's stored JSON texts, as the marker row holds them. */
93
+ export interface ManifestTexts {
94
+ /** A JSON object, feature name to stored-format version, keys sorted. `{}` when empty. */
95
+ features: string
96
+ /** A JSON array of entity types, sorted by UTF-8 bytes and deduplicated. `[]` when empty. */
97
+ historyDependent: string
98
+ /** Like `historyDependent`. */
99
+ retention: string
100
+ }
101
+
102
+ /** Encodes a manifest's declarable parts as the marker row stores them. */
103
+ export function encodeManifest(manifest: FeatureManifest): ManifestTexts {
104
+ return {
105
+ features: stringifyJson(new Map(manifest.features)),
106
+ historyDependent: stringifyJson(entityTypeSet(manifest.historyDependent)),
107
+ retention: stringifyJson(entityTypeSet(manifest.retention)),
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Decodes a marker row's stored texts. An empty text is an empty map or set, as an absent
113
+ * attribute is.
114
+ */
115
+ export function decodeManifest(version: bigint, texts: ManifestTexts): FeatureManifest {
116
+ return {
117
+ version,
118
+ features: texts.features === '' ? new Map() : parseFeatures(texts.features),
119
+ historyDependent: parseEntityTypes(texts.historyDependent, 'historyDependent'),
120
+ retention: parseEntityTypes(texts.retention, 'retention'),
121
+ }
122
+ }
123
+
124
+ /** An entity-type set in its stored order: sorted by UTF-8 bytes and deduplicated. */
125
+ export function entityTypeSet(types: readonly string[]): string[] {
126
+ // A fresh array, sorted in place: ES2022 has no toSorted.
127
+ const set = Array.from(new Set(types))
128
+ set.sort(compareUtf8)
129
+
130
+ return set
131
+ }
132
+
133
+ function parseFeatures(text: string): Map<string, number> {
134
+ const parsed = parseStored(text, 'features')
135
+ if (!(parsed instanceof Map)) {
136
+ throw invalid('the feature manifest is not a JSON object')
137
+ }
138
+
139
+ // Go and Rust read a version as an int64. One past the safe integers is no version this
140
+ // package models, so rounding it changes only the refusal's text.
141
+ const features = new Map<string, number>()
142
+ for (const [name, version] of parsed) {
143
+ features.set(name, Number(int64(version, `the feature manifest's ${name} version`)))
144
+ }
145
+
146
+ return features
147
+ }
148
+
149
+ function parseEntityTypes(text: string, what: string): string[] {
150
+ if (text.trim() === '') {
151
+ return []
152
+ }
153
+
154
+ const parsed = parseStored(text, what)
155
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) {
156
+ throw invalid(`the ${what} set is not a JSON array of strings`)
157
+ }
158
+
159
+ return entityTypeSet(parsed as string[])
160
+ }
161
+
162
+ function parseStored(text: string, what: string): JsonValue {
163
+ try {
164
+ return parseJson(text, DOCUMENT)
165
+ } catch (error) {
166
+ throw new EvtError('invalidRecord', `the stored ${what} is not JSON`, {cause: error})
167
+ }
168
+ }
169
+
170
+ function sortedFeatures(features: ReadonlyMap<string, number>): [string, number][] {
171
+ const sorted = Array.from(features)
172
+ sorted.sort(([a], [b]) => compareUtf8(a, b))
173
+
174
+ return sorted
175
+ }
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,
@@ -30,11 +32,20 @@ export {
30
32
  ConflictError,
31
33
  EvtError,
32
34
  type EvtErrorCode,
35
+ FeatureManifestChangedError,
36
+ HistoryDependentProjectionError,
37
+ ProjectorOnlyCommitError,
38
+ RebuildInProgressError,
33
39
  ReplayError,
34
40
  ReservedEntityIdError,
35
41
  SequenceGapError,
36
42
  SnapshotError,
43
+ StoreNotInitializedError,
37
44
  StreamHeadDriftError,
45
+ StreamHeadsNotMigratedError,
46
+ TransientError,
47
+ UnsupportedFeatureError,
48
+ type FeaturePair,
38
49
  isConflict,
39
50
  isRetryable,
40
51
  } from './errors.js'
@@ -47,6 +58,15 @@ export {
47
58
  encodeSnapshot,
48
59
  eventId,
49
60
  } from './event.js'
61
+ export {
62
+ type FeatureManifest,
63
+ type ManifestTexts,
64
+ STREAM_HEADS_FEATURE,
65
+ STREAM_HEADS_VERSION,
66
+ SUPPORTED_FEATURES,
67
+ decodeManifest,
68
+ encodeManifest,
69
+ } from './features.js'
50
70
  export {JsonNumber, type JsonValue, parseJson, stringifyJson} from './json.js'
51
71
  export {
52
72
  type Metadata,
@@ -56,7 +76,14 @@ export {
56
76
  emptyMetadata,
57
77
  encodeMetadata,
58
78
  } from './metadata.js'
59
- export type {CallOptions, Commit, NewSnapshot, Repository} from './repository.js'
79
+ export type {
80
+ CallOptions,
81
+ Commit,
82
+ NewSnapshot,
83
+ ProjectorGroup,
84
+ Repository,
85
+ StoreInitializer,
86
+ } from './repository.js'
60
87
  export {
61
88
  DEFAULT_RETRY_POLICY,
62
89
  type RetryPolicy,
@@ -1,3 +1,11 @@
1
1
  // @photon-grove/evt/memory: the in-memory repository, for tests and local development.
2
2
 
3
- export {type HeadFixtures, MemRepository, headFixtures} from './repository.js'
3
+ export {
4
+ type ControlFixtures,
5
+ type HeadFixtures,
6
+ type ManifestDraft,
7
+ MemRepository,
8
+ type RebuildState,
9
+ controlFixtures,
10
+ headFixtures,
11
+ } from './repository.js'
@@ -1,21 +1,34 @@
1
1
  // The in-memory repository. It enforces the storage contract the way the live backends do: a head
2
- // per stream that every commit raises, optimistic concurrency against it, and the same failure
3
- // classification and transaction budget. It's for tests and local development.
2
+ // per stream that every commit raises, optimistic concurrency against it, the feature marker and
3
+ // the rebuild-lock check, and the same failure classification and transaction budget. It's for
4
+ // tests and local development.
4
5
 
5
6
  import {
6
7
  ConflictError,
7
8
  CompactionUncoveredError,
8
9
  EvtError,
10
+ HistoryDependentProjectionError,
11
+ RebuildInProgressError,
9
12
  SequenceGapError,
10
13
  StreamHeadDriftError,
14
+ StreamHeadsNotMigratedError,
11
15
  } from '../errors.js'
12
16
  import type {SerializedEvent, SerializedSnapshot} from '../event.js'
17
+ import {
18
+ type FeatureManifest,
19
+ checkSupported,
20
+ checkWritable,
21
+ declaresStreamHeads,
22
+ entityTypeSet,
23
+ withStreamHeads,
24
+ } from '../features.js'
13
25
  import {int64Argument} from '../fields.js'
14
26
  import type {Metadata} from '../metadata.js'
15
27
  import {
16
28
  type CallOptions,
17
29
  type Commit,
18
30
  type Repository,
31
+ type StoreInitializer,
19
32
  checkBudget,
20
33
  checkNotReserved,
21
34
  commitSizes,
@@ -30,8 +43,26 @@ interface Stream {
30
43
  head: bigint
31
44
  }
32
45
 
33
- // Reads a repository's streams, for headFixtures. The class's static block sets it.
46
+ /** The state of the rebuild fence's two rows that a commit's lock check reads. */
47
+ export interface RebuildState {
48
+ /** The generation counter: odd while a rebuild holds the store or after a force-release. */
49
+ generation: bigint
50
+ /**
51
+ * The lock row: a rebuild's lease, the item a force-release leaves in its place, or none. A
52
+ * commit is refused while either exists, and while the generation is odd.
53
+ */
54
+ lock: 'held' | 'forceReleased' | null
55
+ }
56
+
57
+ // The store-wide control rows: the feature marker and the rebuild fence.
58
+ interface Controls {
59
+ manifest: FeatureManifest | null
60
+ rebuild: RebuildState
61
+ }
62
+
63
+ // Read a repository's private state, for the fixtures. The class's static block sets them.
34
64
  let streamsOf: (repo: MemRepository) => Map<string, Stream>
65
+ let controlsOf: (repo: MemRepository) => Controls
35
66
 
36
67
  /**
37
68
  * An in-memory {@link Repository}. Every call copies what it stores and what it returns, so a
@@ -39,9 +70,26 @@ let streamsOf: (repo: MemRepository) => Map<string, Stream>
39
70
  *
40
71
  * Each call runs to completion before another starts, so concurrent commits to one stream behave
41
72
  * like racing writers on a live backend: one wins and the rest fail with a conflict.
73
+ *
74
+ * A fresh store is initialized by its first commit or snapshot write, which declares stream-heads
75
+ * on its feature manifest, so {@link initialize} is needed only where code treats every backend
76
+ * alike.
42
77
  */
43
- export class MemRepository implements Repository {
78
+ export class MemRepository implements Repository, StoreInitializer {
44
79
  readonly #streams = new Map<string, Stream>()
80
+ readonly #controls: Controls = {manifest: null, rebuild: {generation: 0n, lock: null}}
81
+
82
+ async initialize(options?: CallOptions): Promise<boolean> {
83
+ options?.signal?.throwIfAborted()
84
+ checkSupported(this.#controls.manifest)
85
+ if (declaresStreamHeads(this.#controls.manifest)) {
86
+ return false
87
+ }
88
+
89
+ this.#declareStreamHeads()
90
+
91
+ return true
92
+ }
45
93
 
46
94
  async commit(commit: Commit, options?: CallOptions): Promise<void> {
47
95
  options?.signal?.throwIfAborted()
@@ -54,6 +102,14 @@ export class MemRepository implements Repository {
54
102
  // Every backend budgets a commit the same way, so an oversized one fails before any write.
55
103
  checkBudget(commitSizes(commit, () => undefined))
56
104
 
105
+ // The control rows come first, as the two checks that open every live commit: the marker,
106
+ // then the rebuild lock, which an unsettled generation fails, a force-released one included.
107
+ this.#checkManifest()
108
+ const {rebuild} = this.#controls
109
+ if (rebuild.lock !== null || rebuild.generation % 2n !== 0n) {
110
+ throw new RebuildInProgressError()
111
+ }
112
+
57
113
  const held = this.#streams.get(commit.entityId) ?? emptyStream()
58
114
  checkWrite(commit, held)
59
115
 
@@ -120,6 +176,7 @@ export class MemRepository implements Repository {
120
176
  checkNotReserved(snapshot.entityId)
121
177
  int64Argument(snapshot.sequence, 'snapshot.sequence')
122
178
  int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
179
+ this.#checkManifest()
123
180
 
124
181
  // The stream's head must cover the snapshot. One of an empty stream (event sequence 0) may
125
182
  // have no head at all.
@@ -153,12 +210,22 @@ export class MemRepository implements Repository {
153
210
  return 0
154
211
  }
155
212
 
213
+ // Compaction validates the manifest, but never declares stream-heads itself.
214
+ const {manifest} = this.#controls
215
+ checkWritable(manifest)
216
+
156
217
  const stream = this.#streams.get(entityId)
157
- const covered = stream?.snapshot?.eventSequence ?? null
158
- if (stream === undefined || covered === null || covered < through) {
218
+ const snapshot = stream?.snapshot
219
+ const covered = snapshot?.eventSequence ?? null
220
+ if (stream === undefined || !snapshot || covered === null || covered < through) {
159
221
  throw new CompactionUncoveredError(entityId, through, covered)
160
222
  }
161
223
 
224
+ // A history-dependent type's rebuild replays the events compaction would delete.
225
+ if (manifest?.historyDependent.includes(snapshot.entityType)) {
226
+ throw new HistoryDependentProjectionError(snapshot.entityType, entityId)
227
+ }
228
+
162
229
  let deleted = 0
163
230
  for (const sequence of stream.events.keys()) {
164
231
  if (sequence <= through) {
@@ -172,6 +239,27 @@ export class MemRepository implements Repository {
172
239
 
173
240
  static {
174
241
  streamsOf = (repo) => repo.#streams
242
+ controlsOf = (repo) => repo.#controls
243
+ }
244
+
245
+ // Refuses a write to a store whose manifest declares an unsupported pair, and declares
246
+ // stream-heads on a store without it, as its first writer, when the store holds no data.
247
+ #checkManifest(): void {
248
+ checkSupported(this.#controls.manifest)
249
+ if (!declaresStreamHeads(this.#controls.manifest)) {
250
+ this.#declareStreamHeads()
251
+ }
252
+ }
253
+
254
+ #declareStreamHeads(): void {
255
+ const holdsData = Array.from(this.#streams.values()).some(
256
+ (stream) => stream.events.size > 0 || stream.snapshot !== null,
257
+ )
258
+ if (holdsData) {
259
+ throw new StreamHeadsNotMigratedError()
260
+ }
261
+
262
+ this.#controls.manifest = withStreamHeads(this.#controls.manifest)
175
263
  }
176
264
 
177
265
  // The stream's events by sequence, ascending or descending. Sequences are unique.
@@ -282,6 +370,68 @@ export function headFixtures(repo: MemRepository): HeadFixtures {
282
370
  }
283
371
  }
284
372
 
373
+ /** A feature manifest's declarable parts, which a declaration changes in place. */
374
+ export interface ManifestDraft {
375
+ features: Map<string, number>
376
+ historyDependent: string[]
377
+ retention: string[]
378
+ }
379
+
380
+ /** What a conformance suite needs to declare features and hold the rebuild lock. */
381
+ export interface ControlFixtures {
382
+ /** The stored manifest, or null when the store has no marker row. */
383
+ manifest(): FeatureManifest | null
384
+ /** Changes the manifest in one declaration, bumping its version by one, and returns it. */
385
+ declare(change: (draft: ManifestDraft) => void): FeatureManifest
386
+ /** Sets the rebuild fence's rows, as a rebuild's lifecycle would. */
387
+ setRebuild(state: RebuildState): void
388
+ }
389
+
390
+ /**
391
+ * Hooks that change a repository's control rows, for conformance suites only. A declaration may
392
+ * declare features this package doesn't model, which then refuse every write.
393
+ */
394
+ export function controlFixtures(repo: MemRepository): ControlFixtures {
395
+ const controls = controlsOf(repo)
396
+
397
+ return {
398
+ manifest() {
399
+ return controls.manifest && cloneManifest(controls.manifest)
400
+ },
401
+ declare(change) {
402
+ const draft: ManifestDraft = {
403
+ features: new Map(controls.manifest?.features),
404
+ historyDependent: [...(controls.manifest?.historyDependent ?? [])],
405
+ retention: [...(controls.manifest?.retention ?? [])],
406
+ }
407
+ change(draft)
408
+
409
+ // Stored as the marker row stores them: each set sorted and deduplicated.
410
+ controls.manifest = {
411
+ version: (controls.manifest?.version ?? 0n) + 1n,
412
+ features: new Map(draft.features),
413
+ historyDependent: entityTypeSet(draft.historyDependent),
414
+ retention: entityTypeSet(draft.retention),
415
+ }
416
+
417
+ return cloneManifest(controls.manifest)
418
+ },
419
+ setRebuild(state) {
420
+ int64Argument(state.generation, 'generation')
421
+ controls.rebuild = {...state}
422
+ },
423
+ }
424
+ }
425
+
426
+ function cloneManifest(manifest: FeatureManifest): FeatureManifest {
427
+ return {
428
+ version: manifest.version,
429
+ features: new Map(manifest.features),
430
+ historyDependent: [...manifest.historyDependent],
431
+ retention: [...manifest.retention],
432
+ }
433
+ }
434
+
285
435
  function emptyStream(): Stream {
286
436
  return {events: new Map(), snapshot: null, head: 0n}
287
437
  }
@@ -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'