@photon-grove/evt 0.0.0-stage → 0.1.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 (124) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +122 -2
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.d.ts.map +1 -0
  5. package/dist/aggregate.js +92 -0
  6. package/dist/aggregate.js.map +1 -0
  7. package/dist/base64.d.ts +8 -0
  8. package/dist/base64.d.ts.map +1 -0
  9. package/dist/base64.js +67 -0
  10. package/dist/base64.js.map +1 -0
  11. package/dist/codec.d.ts +56 -0
  12. package/dist/codec.d.ts.map +1 -0
  13. package/dist/codec.js +90 -0
  14. package/dist/codec.js.map +1 -0
  15. package/dist/commit-id.d.ts +3 -0
  16. package/dist/commit-id.d.ts.map +1 -0
  17. package/dist/commit-id.js +43 -0
  18. package/dist/commit-id.js.map +1 -0
  19. package/dist/dynamodb/head.d.ts +34 -0
  20. package/dist/dynamodb/head.d.ts.map +1 -0
  21. package/dist/dynamodb/head.js +62 -0
  22. package/dist/dynamodb/head.js.map +1 -0
  23. package/dist/dynamodb/index.d.ts +2 -0
  24. package/dist/dynamodb/index.d.ts.map +1 -0
  25. package/dist/dynamodb/index.js +4 -0
  26. package/dist/dynamodb/index.js.map +1 -0
  27. package/dist/dynamodb/item.d.ts +68 -0
  28. package/dist/dynamodb/item.d.ts.map +1 -0
  29. package/dist/dynamodb/item.js +263 -0
  30. package/dist/dynamodb/item.js.map +1 -0
  31. package/dist/envelope.d.ts +15 -0
  32. package/dist/envelope.d.ts.map +1 -0
  33. package/dist/envelope.js +59 -0
  34. package/dist/envelope.js.map +1 -0
  35. package/dist/errors.d.ts +111 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +139 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/event.d.ts +47 -0
  40. package/dist/event.d.ts.map +1 -0
  41. package/dist/event.js +64 -0
  42. package/dist/event.js.map +1 -0
  43. package/dist/fields.d.ts +31 -0
  44. package/dist/fields.d.ts.map +1 -0
  45. package/dist/fields.js +105 -0
  46. package/dist/fields.js.map +1 -0
  47. package/dist/index.d.ts +14 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +16 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/json.d.ts +47 -0
  52. package/dist/json.d.ts.map +1 -0
  53. package/dist/json.js +458 -0
  54. package/dist/json.js.map +1 -0
  55. package/dist/memory/index.d.ts +2 -0
  56. package/dist/memory/index.d.ts.map +1 -0
  57. package/dist/memory/index.js +3 -0
  58. package/dist/memory/index.js.map +1 -0
  59. package/dist/memory/repository.d.ts +34 -0
  60. package/dist/memory/repository.d.ts.map +1 -0
  61. package/dist/memory/repository.js +219 -0
  62. package/dist/memory/repository.js.map +1 -0
  63. package/dist/metadata.d.ts +47 -0
  64. package/dist/metadata.d.ts.map +1 -0
  65. package/dist/metadata.js +75 -0
  66. package/dist/metadata.js.map +1 -0
  67. package/dist/repository.d.ts +102 -0
  68. package/dist/repository.d.ts.map +1 -0
  69. package/dist/repository.js +131 -0
  70. package/dist/repository.js.map +1 -0
  71. package/dist/retry.d.ts +25 -0
  72. package/dist/retry.d.ts.map +1 -0
  73. package/dist/retry.js +50 -0
  74. package/dist/retry.js.map +1 -0
  75. package/dist/snapshots.d.ts +21 -0
  76. package/dist/snapshots.d.ts.map +1 -0
  77. package/dist/snapshots.js +35 -0
  78. package/dist/snapshots.js.map +1 -0
  79. package/dist/store.d.ts +103 -0
  80. package/dist/store.d.ts.map +1 -0
  81. package/dist/store.js +246 -0
  82. package/dist/store.js.map +1 -0
  83. package/dist/testing/equal.d.ts +22 -0
  84. package/dist/testing/equal.d.ts.map +1 -0
  85. package/dist/testing/equal.js +121 -0
  86. package/dist/testing/equal.js.map +1 -0
  87. package/dist/testing/index.d.ts +3 -0
  88. package/dist/testing/index.d.ts.map +1 -0
  89. package/dist/testing/index.js +5 -0
  90. package/dist/testing/index.js.map +1 -0
  91. package/dist/testing/scenario.d.ts +136 -0
  92. package/dist/testing/scenario.d.ts.map +1 -0
  93. package/dist/testing/scenario.js +307 -0
  94. package/dist/testing/scenario.js.map +1 -0
  95. package/dist/utf8.d.ts +17 -0
  96. package/dist/utf8.d.ts.map +1 -0
  97. package/dist/utf8.js +82 -0
  98. package/dist/utf8.js.map +1 -0
  99. package/package.json +56 -4
  100. package/src/aggregate.ts +196 -0
  101. package/src/base64.ts +80 -0
  102. package/src/codec.ts +139 -0
  103. package/src/commit-id.ts +50 -0
  104. package/src/dynamodb/head.ts +93 -0
  105. package/src/dynamodb/index.ts +21 -0
  106. package/src/dynamodb/item.ts +332 -0
  107. package/src/envelope.ts +67 -0
  108. package/src/errors.ts +214 -0
  109. package/src/event.ts +117 -0
  110. package/src/fields.ts +134 -0
  111. package/src/globals.d.ts +39 -0
  112. package/src/index.ts +75 -0
  113. package/src/json.ts +546 -0
  114. package/src/memory/index.ts +3 -0
  115. package/src/memory/repository.ts +313 -0
  116. package/src/metadata.ts +120 -0
  117. package/src/repository.ts +247 -0
  118. package/src/retry.ts +74 -0
  119. package/src/snapshots.ts +42 -0
  120. package/src/store.ts +377 -0
  121. package/src/testing/equal.ts +146 -0
  122. package/src/testing/index.ts +14 -0
  123. package/src/testing/scenario.ts +406 -0
  124. package/src/utf8.ts +92 -0
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@photon-grove/evt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Event sourcing for TypeScript, wire-compatible with the Go and Rust evt implementations.",
5
+ "keywords": [
6
+ "event-sourcing",
7
+ "cqrs",
8
+ "dynamodb",
9
+ "postgresql"
10
+ ],
11
+ "homepage": "https://github.com/photon-grove/evt/tree/main/packages/evt#readme",
12
+ "bugs": "https://github.com/photon-grove/evt/issues",
13
+ "license": "Apache-2.0",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/photon-grove/evt.git",
17
+ "directory": "packages/evt"
18
+ },
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "engines": {
22
+ "node": ">=22.12"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./dynamodb": {
34
+ "types": "./dist/dynamodb/index.d.ts",
35
+ "default": "./dist/dynamodb/index.js"
36
+ },
37
+ "./memory": {
38
+ "types": "./dist/memory/index.d.ts",
39
+ "default": "./dist/memory/index.js"
40
+ },
41
+ "./testing": {
42
+ "types": "./dist/testing/index.d.ts",
43
+ "default": "./dist/testing/index.js"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc",
49
+ "check": "tsc --noEmit && tsc -p tsconfig.test.json && oxlint --deny-warnings && node scripts/check-imports.ts",
50
+ "test": "vitest run"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^25.9.9",
54
+ "oxlint": "1.88.0",
55
+ "typescript": "^7.0.2",
56
+ "vitest": "5.0.5"
57
+ }
58
+ }
@@ -0,0 +1,196 @@
1
+ // Aggregates: an entity's state folded from its events, the commands it decides on, and the typed
2
+ // events it records, each with its stored version, payload codec and upcasters.
3
+
4
+ import type {Codec} from './codec.js'
5
+ import {EvtError, ReplayError, messageOf} from './errors.js'
6
+ import type {SerializedEvent} from './event.js'
7
+ import {type JsonValue, parseJson, stringifyJson} from './json.js'
8
+ import {utf8Decode, utf8Encode} from './utf8.js'
9
+
10
+ /**
11
+ * Migrates a stored payload of its event type before it is decoded, taking an event at the
12
+ * upcaster's from version to its to version. An event type's upcasters run in declaration order,
13
+ * each once, and each sees the output of the ones before it, so a 1 → 2 upcaster followed by a
14
+ * 2 → 3 one takes a version 1 event to version 3. Stored events are never rewritten.
15
+ */
16
+ export interface Upcaster {
17
+ readonly from: number
18
+ readonly to: number
19
+ upcast(payload: Uint8Array): Uint8Array
20
+ }
21
+
22
+ /**
23
+ * An upcaster that rewrites a JSON payload. `migrate` gets it losslessly, as {@link parseJson}
24
+ * reads it (objects as `Map`s in their stored order, numbers as {@link JsonNumber}s), and returns
25
+ * the new payload, which is written with {@link stringifyJson}. What the migration doesn't touch
26
+ * is written back exactly, whatever its integers or keys.
27
+ */
28
+ export function jsonUpcaster(
29
+ from: number,
30
+ to: number,
31
+ migrate: (payload: JsonValue) => unknown,
32
+ ): Upcaster {
33
+ return {
34
+ from,
35
+ to,
36
+ upcast(payload) {
37
+ const migrated = migrate(parseJson(utf8Decode(payload, 'payload')))
38
+
39
+ return utf8Encode(stringifyJson(migrated))
40
+ },
41
+ }
42
+ }
43
+
44
+ /** How one event type is stored: its current version, its payload codec, and its upcasters. */
45
+ export interface EventDefinition<T> {
46
+ /** The version new events are written with, and the one stored events are upcast to. */
47
+ readonly version: number
48
+ readonly codec: Codec<T>
49
+ readonly upcasters?: readonly Upcaster[] | undefined
50
+ }
51
+
52
+ // oxlint-disable-next-line typescript/no-explicit-any -- each entry has its own data type.
53
+ export type EventDefinitions = Readonly<Record<string, EventDefinition<any>>>
54
+
55
+ /** The events a set of definitions describes: `{type, data}` for each event type. */
56
+ export type EventOf<D extends EventDefinitions> = {
57
+ [K in keyof D & string]: {
58
+ type: K
59
+ data: D[K] extends EventDefinition<infer T> ? T : never
60
+ }
61
+ }[keyof D & string]
62
+
63
+ /** Declares an aggregate's event types, checking each version and upcaster. */
64
+ export function defineEvents<const D extends EventDefinitions>(definitions: D): D {
65
+ for (const [type, definition] of Object.entries(definitions)) {
66
+ positive(definition.version, `${type} version`)
67
+ for (const upcaster of definition.upcasters ?? []) {
68
+ positive(upcaster.from, `${type} upcaster from`)
69
+ positive(upcaster.to, `${type} upcaster to`)
70
+ }
71
+ }
72
+
73
+ return definitions
74
+ }
75
+
76
+ /** A domain refusal of a command: a code to branch on, a message, and optional JSON details. */
77
+ export interface Rejection {
78
+ code: string
79
+ message: string
80
+ details?: unknown
81
+ }
82
+
83
+ /** What a command handler decides: accept with events (possibly none), or reject. */
84
+ export type Decision<E> =
85
+ | {kind: 'accept'; events: readonly E[]}
86
+ | {kind: 'reject'; rejection: Rejection}
87
+
88
+ /** An aggregate type, as {@link defineAggregate} takes it. */
89
+ export interface AggregateDefinition<S, C, D extends EventDefinitions> {
90
+ /** The stored entity type, such as `account`. */
91
+ readonly type: string
92
+ readonly events: D
93
+ /** The snapshot codec: how the state is stored. */
94
+ readonly state: Codec<S>
95
+ /** The state of an entity with no events. */
96
+ initial(): S
97
+ /** Decides what a command does. It must not change `state`. */
98
+ handle(state: S, command: C): Decision<EventOf<D>> | Promise<Decision<EventOf<D>>>
99
+ /** Folds one event into the state, returning the next state. It must not fail. */
100
+ apply(state: S, event: EventOf<D>): S
101
+ /**
102
+ * This type's own number of events between snapshots, overriding the store's. 0 disables its
103
+ * snapshots.
104
+ */
105
+ readonly snapshotInterval?: number | undefined
106
+ }
107
+
108
+ /** A checked aggregate type. */
109
+ export type Aggregate<S, C, D extends EventDefinitions> = Readonly<AggregateDefinition<S, C, D>>
110
+
111
+ /** Declares an aggregate type. */
112
+ export function defineAggregate<S, C, D extends EventDefinitions>(
113
+ definition: AggregateDefinition<S, C, D>,
114
+ ): Aggregate<S, C, D> {
115
+ if (definition.type === '') {
116
+ throw new EvtError('invalidArgument', 'an aggregate type must have a name')
117
+ }
118
+ if (definition.snapshotInterval !== undefined) {
119
+ const interval = definition.snapshotInterval
120
+ if (!Number.isSafeInteger(interval) || interval < 0) {
121
+ throw new EvtError(
122
+ 'invalidArgument',
123
+ `${definition.type} snapshot interval must be a whole number of events: ${interval}`,
124
+ )
125
+ }
126
+ }
127
+
128
+ return definition
129
+ }
130
+
131
+ /** Encodes one event's data with its type's codec, as the payload and version to store. */
132
+ export function encodeEventData<D extends EventDefinitions>(
133
+ events: D,
134
+ event: EventOf<D>,
135
+ ): {version: number; payload: Uint8Array} {
136
+ const definition = definitionOf(events, event.type)
137
+ if (definition === undefined) {
138
+ throw new EvtError('invalidArgument', `${event.type} is not one of the aggregate's event types`)
139
+ }
140
+
141
+ return {version: definition.version, payload: definition.codec.encode(event.data)}
142
+ }
143
+
144
+ /** Upcasts and decodes a stored event, failing closed with a {@link ReplayError}. */
145
+ export function replayEvent<D extends EventDefinitions>(
146
+ events: D,
147
+ stored: SerializedEvent,
148
+ ): EventOf<D> {
149
+ const definition = definitionOf(events, stored.type)
150
+ if (definition === undefined) {
151
+ throw new ReplayError(stored.type, stored.version, 'decode', 'unknown event type')
152
+ }
153
+
154
+ let payload = stored.payload ?? new Uint8Array()
155
+ let version = stored.version
156
+ for (const upcaster of definition.upcasters ?? []) {
157
+ if (upcaster.from !== version) {
158
+ continue
159
+ }
160
+
161
+ try {
162
+ payload = upcaster.upcast(payload)
163
+ } catch (error) {
164
+ throw new ReplayError(stored.type, version, 'upcast', messageOf(error), error)
165
+ }
166
+ version = upcaster.to
167
+ }
168
+
169
+ if (version !== definition.version) {
170
+ throw new ReplayError(
171
+ stored.type,
172
+ version,
173
+ 'decode',
174
+ `no upcaster takes it to version ${definition.version}`,
175
+ )
176
+ }
177
+
178
+ try {
179
+ return {type: stored.type, data: definition.codec.decode(payload)} as EventOf<D>
180
+ } catch (error) {
181
+ throw new ReplayError(stored.type, version, 'decode', messageOf(error), error)
182
+ }
183
+ }
184
+
185
+ function definitionOf<D extends EventDefinitions>(
186
+ events: D,
187
+ type: string,
188
+ ): EventDefinition<unknown> | undefined {
189
+ return Object.hasOwn(events, type) ? events[type] : undefined
190
+ }
191
+
192
+ function positive(value: number, what: string): void {
193
+ if (!Number.isSafeInteger(value) || value < 1) {
194
+ throw new EvtError('invalidArgument', `${what} must be a whole number of at least 1: ${value}`)
195
+ }
196
+ }
package/src/base64.ts ADDED
@@ -0,0 +1,80 @@
1
+ // Standard, padded base64 (Go's base64.StdEncoding), which is how a Go []byte appears in JSON.
2
+ // It's our own code because Buffer is Node-only and Uint8Array.fromBase64 isn't on every runtime.
3
+
4
+ import {EvtError} from './errors.js'
5
+
6
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
7
+
8
+ const SYMBOLS = new Map(Array.from(ALPHABET, (symbol, value) => [symbol.charCodeAt(0), value]))
9
+
10
+ const PAD = 0x3d // '='
11
+
12
+ /** Encodes bytes as standard, padded base64. */
13
+ export function base64Encode(bytes: Uint8Array): string {
14
+ let out = ''
15
+ for (let i = 0; i < bytes.length; i += 3) {
16
+ const b0 = bytes[i] ?? 0
17
+ const b1 = bytes[i + 1] ?? 0
18
+ const b2 = bytes[i + 2] ?? 0
19
+ const triple = (b0 << 16) | (b1 << 8) | b2
20
+
21
+ out += ALPHABET.charAt((triple >> 18) & 63)
22
+ out += ALPHABET.charAt((triple >> 12) & 63)
23
+ out += i + 1 < bytes.length ? ALPHABET.charAt((triple >> 6) & 63) : '='
24
+ out += i + 2 < bytes.length ? ALPHABET.charAt(triple & 63) : '='
25
+ }
26
+
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * Decodes standard, padded base64 the way Go does: `\r` and `\n` are skipped, and anything else
32
+ * outside the alphabet, missing padding, or padding before the end is refused.
33
+ */
34
+ export function base64Decode(text: string): Uint8Array<ArrayBuffer> {
35
+ const symbols: number[] = []
36
+ for (let i = 0; i < text.length; i++) {
37
+ const unit = text.charCodeAt(i)
38
+ if (unit !== 0x0d && unit !== 0x0a) {
39
+ symbols.push(unit)
40
+ }
41
+ }
42
+ if (symbols.length % 4 !== 0) {
43
+ throw invalid(`length ${symbols.length}`)
44
+ }
45
+
46
+ const quads = symbols.length / 4
47
+ const last = symbols.slice(-2)
48
+ const padding = last[1] === PAD ? (last[0] === PAD ? 2 : 1) : 0
49
+ const out = new Uint8Array(quads * 3 - padding)
50
+
51
+ for (let quad = 0; quad < quads; quad++) {
52
+ let triple = 0
53
+ for (let offset = 0; offset < 4; offset++) {
54
+ const index = quad * 4 + offset
55
+ const symbol = symbols[index] ?? PAD
56
+ const isPadding = quad === quads - 1 && offset >= 4 - padding
57
+ const value = isPadding ? 0 : SYMBOLS.get(symbol)
58
+ if (value === undefined) {
59
+ throw invalid(`at input byte ${index}`)
60
+ }
61
+
62
+ triple = (triple << 6) | value
63
+ }
64
+
65
+ const at = quad * 3
66
+ out[at] = triple >> 16
67
+ if (at + 1 < out.length) {
68
+ out[at + 1] = (triple >> 8) & 0xff
69
+ }
70
+ if (at + 2 < out.length) {
71
+ out[at + 2] = triple & 0xff
72
+ }
73
+ }
74
+
75
+ return out
76
+ }
77
+
78
+ function invalid(where: string): EvtError {
79
+ return new EvtError('invalidRecord', `illegal base64 data ${where}`)
80
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,139 @@
1
+ // Payload codecs: how an application turns event data and aggregate state into stored bytes and
2
+ // back. evt stores the bytes verbatim, so a codec owns their format.
3
+
4
+ import {EvtError, messageOf} from './errors.js'
5
+ import {JsonNumber, type JsonValue, parseJson, stringifyJson} from './json.js'
6
+ import {utf8Decode, utf8Encode} from './utf8.js'
7
+
8
+ /** Encodes values of `T` to stored bytes and decodes them back. */
9
+ export interface Codec<T> {
10
+ encode(value: T): Uint8Array
11
+ decode(bytes: Uint8Array): T
12
+ }
13
+
14
+ /**
15
+ * The part of a [Standard Schema](https://standardschema.dev) validator evt calls, so Zod,
16
+ * Valibot, ArkType and the rest work without evt depending on any of them.
17
+ */
18
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
19
+ readonly '~standard': {
20
+ readonly version: 1
21
+ readonly vendor: string
22
+ readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>
23
+ readonly types?: {readonly input: Input; readonly output: Output} | undefined
24
+ }
25
+ }
26
+
27
+ /** A Standard Schema validation result: the value, or the issues that refused it. */
28
+ export type StandardResult<Output> =
29
+ | {readonly value: Output; readonly issues?: undefined}
30
+ | {readonly issues: readonly {readonly message: string}[]}
31
+
32
+ /** Options for {@link json}. */
33
+ export interface JsonOptions<T> {
34
+ /** Validates each value before it's encoded and after it's decoded, synchronously. */
35
+ schema?: StandardSchemaV1<unknown, T> | undefined
36
+ }
37
+
38
+ /**
39
+ * A JSON codec written with evt's encoder (the one Go's `encoding/json` output is reproduced
40
+ * with). Plain objects encode in property order.
41
+ *
42
+ * Decoding gives plain objects, arrays and numbers, so `T` must be plain JSON: a `Map`, a `bigint`
43
+ * or a `Date` doesn't decode back to itself. A domain that must match a Go or Rust one byte for
44
+ * byte, with a map whose keys are written sorted, needs a codec of its own that decodes the map as
45
+ * a `Map`. An integer past `Number.MAX_SAFE_INTEGER` is refused rather than rounded.
46
+ *
47
+ * Encoding refuses a value decoding would refuse, and with a `schema`, each value is validated
48
+ * before it's encoded and after it's decoded, so an event or a snapshot that couldn't be read back
49
+ * fails its commit instead of every later load. The schema must not transform values: the value
50
+ * is encoded as given.
51
+ */
52
+ export function json<T>(options: JsonOptions<T> = {}): Codec<T> {
53
+ const schema = options.schema
54
+
55
+ return {
56
+ encode(value) {
57
+ if (schema) {
58
+ validate(schema, value, 'invalidArgument')
59
+ }
60
+
61
+ const text = stringifyJson(value)
62
+ readable(text)
63
+
64
+ return utf8Encode(text)
65
+ },
66
+ decode(bytes) {
67
+ const value = plainJson(parseJson(utf8Decode(bytes, 'payload')))
68
+
69
+ return schema ? validate(schema, value, 'invalidRecord') : (value as T)
70
+ },
71
+ }
72
+ }
73
+
74
+ /**
75
+ * A JSON value as plain JavaScript: objects as plain objects, numbers as `number`. An integer past
76
+ * the safe range is refused, because `number` would round it.
77
+ */
78
+ export function plainJson(value: JsonValue): unknown {
79
+ if (value instanceof JsonNumber) {
80
+ const number = Number(value.text)
81
+ if (value.isInteger && !Number.isSafeInteger(number)) {
82
+ throw new EvtError(
83
+ 'invalidRecord',
84
+ `the integer ${value.text} is past the integers a number holds; decode it with a codec ` +
85
+ 'of its own',
86
+ )
87
+ }
88
+
89
+ return number
90
+ }
91
+ if (Array.isArray(value)) {
92
+ return value.map(plainJson)
93
+ }
94
+ if (value instanceof Map) {
95
+ const object: Record<string, unknown> = {}
96
+ for (const [key, entry] of value) {
97
+ // defineProperty, so a "__proto__" key is a plain property and never a prototype.
98
+ Object.defineProperty(object, key, {
99
+ value: plainJson(entry),
100
+ enumerable: true,
101
+ writable: true,
102
+ configurable: true,
103
+ })
104
+ }
105
+
106
+ return object
107
+ }
108
+
109
+ return value
110
+ }
111
+
112
+ // Refuses JSON that decode would refuse, such as an integer past the safe range, so the value
113
+ // fails its commit instead of every later load.
114
+ function readable(text: string): void {
115
+ try {
116
+ plainJson(parseJson(text))
117
+ } catch (error) {
118
+ throw new EvtError('invalidArgument', messageOf(error), {cause: error})
119
+ }
120
+ }
121
+
122
+ function validate<T>(
123
+ schema: StandardSchemaV1<unknown, T>,
124
+ value: unknown,
125
+ code: 'invalidArgument' | 'invalidRecord',
126
+ ): T {
127
+ const result = schema['~standard'].validate(value)
128
+ if ('then' in result && typeof result.then === 'function') {
129
+ throw new EvtError('invalidArgument', 'a codec schema must validate synchronously')
130
+ }
131
+
132
+ const settled = result as StandardResult<T>
133
+ if (settled.issues) {
134
+ const issues = settled.issues.map((issue) => issue.message).join('; ')
135
+ throw new EvtError(code, `the payload does not match its schema: ${issues}`)
136
+ }
137
+
138
+ return settled.value
139
+ }
@@ -0,0 +1,50 @@
1
+ // Commit IDs for commits whose command carries none: ULID-format identifiers, 26 Crockford base32
2
+ // characters for a 48-bit millisecond timestamp and 80 random bits. IDs one generator makes in one
3
+ // millisecond increase, so they sort by creation time.
4
+
5
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
6
+ const ENTROPY_MAX = (1n << 80n) - 1n
7
+ const TIMESTAMP_MASK = (1n << 48n) - 1n
8
+
9
+ /** Makes a ULID-format commit ID generator that reads `clock` (Unix milliseconds). */
10
+ export function commitIds(clock: () => number): () => string {
11
+ let lastMs = -1n
12
+ let entropy = 0n
13
+
14
+ return () => {
15
+ // A clock that steps backwards keeps the last timestamp, so IDs never decrease.
16
+ let ms = BigInt(Math.max(0, Math.floor(clock()))) & TIMESTAMP_MASK
17
+ if (ms < lastMs) {
18
+ ms = lastMs
19
+ }
20
+
21
+ if (ms === lastMs && entropy < ENTROPY_MAX) {
22
+ entropy++
23
+ } else {
24
+ // A new millisecond, or 2^80 IDs in this one.
25
+ if (ms === lastMs) {
26
+ ms = (ms + 1n) & TIMESTAMP_MASK
27
+ }
28
+ lastMs = ms
29
+ entropy = randomEntropy()
30
+ }
31
+
32
+ return encode((ms << 80n) | entropy)
33
+ }
34
+ }
35
+
36
+ function randomEntropy(): bigint {
37
+ const bytes = crypto.getRandomValues(new Uint8Array(10))
38
+
39
+ return bytes.reduce((value, byte) => (value << 8n) | BigInt(byte), 0n)
40
+ }
41
+
42
+ function encode(value: bigint): string {
43
+ let text = ''
44
+ for (let i = 0; i < 26; i++) {
45
+ text = ALPHABET[Number(value & 31n)]! + text
46
+ value >>= 5n
47
+ }
48
+
49
+ return text
50
+ }
@@ -0,0 +1,93 @@
1
+ // The DynamoDB actions on a stream's head item (ADR 0007), independent of any SDK: the one source
2
+ // of the head's expression strings, which the writer builds its actions from and the commit
3
+ // budget sizes. The fence and snapshot-guard forms join them with the DynamoDB backend.
4
+
5
+ import {safeIntegerArgument} from '../fields.js'
6
+ import {utf8Length} from '../utf8.js'
7
+ import {HEAD_SORT_KEY, itemSize} from './item.js'
8
+
9
+ /** The update expression of a head update on a stream without retention. */
10
+ export const HEAD_UPDATE = 'SET head = :last'
11
+
12
+ /** The update expression of a head update on a stream with retention, which raises `ttl` too. */
13
+ export const HEAD_UPDATE_RETAINED = 'SET head = :last, #ttl = :t'
14
+
15
+ /** The condition of the head update of a stream the commit creates (first sequence 1). */
16
+ export const HEAD_CREATE_CONDITION = 'attribute_not_exists(head)'
17
+
18
+ /** The condition of the head update of an append: the head is the sequence before the first. */
19
+ export const HEAD_APPEND_CONDITION = 'head = :prev'
20
+
21
+ /** {@link HEAD_APPEND_CONDITION} with retention: `ttl` is raised, never lowered. */
22
+ export const HEAD_APPEND_RETAINED_CONDITION =
23
+ 'head = :prev AND (attribute_not_exists(#ttl) OR #ttl <= :t)'
24
+
25
+ /** An `Update` or `ConditionCheck` on a stream's head item. */
26
+ export interface HeadAction {
27
+ entityId: string
28
+ /** The update expression, or null for a `ConditionCheck`. */
29
+ updateExpression: string | null
30
+ conditionExpression: string
31
+ /** `ExpressionAttributeNames`: placeholder and attribute name. */
32
+ names: [string, string][]
33
+ /** `ExpressionAttributeValues`: placeholder and number. */
34
+ values: [string, bigint][]
35
+ }
36
+
37
+ /**
38
+ * The head update of a stream whose commit writes events `first` to `last`. A first sequence of 1
39
+ * creates the head. `ttl` is the `:t` of the never-lower rule, given only for a stream with
40
+ * retention.
41
+ */
42
+ export function headUpdate(
43
+ entityId: string,
44
+ first: bigint,
45
+ last: bigint,
46
+ ttl?: number,
47
+ ): HeadAction {
48
+ const create = first <= 1n
49
+ const values: [string, bigint][] = [[':last', last]]
50
+ const names: [string, string][] = []
51
+
52
+ if (!create) {
53
+ values.push([':prev', first - 1n])
54
+ }
55
+ if (ttl !== undefined) {
56
+ names.push(['#ttl', 'ttl'])
57
+ values.push([':t', BigInt(safeIntegerArgument(ttl, 'ttl'))])
58
+ }
59
+
60
+ let conditionExpression = HEAD_CREATE_CONDITION
61
+ if (!create) {
62
+ conditionExpression =
63
+ ttl === undefined ? HEAD_APPEND_CONDITION : HEAD_APPEND_RETAINED_CONDITION
64
+ }
65
+
66
+ return {
67
+ entityId,
68
+ updateExpression: ttl === undefined ? HEAD_UPDATE : HEAD_UPDATE_RETAINED,
69
+ conditionExpression,
70
+ names,
71
+ values,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * An action's size in the commit budget, by the rule for any update, delete or condition check:
77
+ * its key attributes plus its expression strings, name placeholders (the placeholder and the
78
+ * name), and attribute values (the placeholder and the number's text).
79
+ */
80
+ export function headActionSize(action: HeadAction): number {
81
+ const key = itemSize({pk: {S: action.entityId}, sk: {N: HEAD_SORT_KEY.toString()}})
82
+ let size =
83
+ key + utf8Length(action.updateExpression ?? '') + utf8Length(action.conditionExpression)
84
+
85
+ for (const [placeholder, name] of action.names) {
86
+ size += utf8Length(placeholder) + utf8Length(name)
87
+ }
88
+ for (const [placeholder, number] of action.values) {
89
+ size += utf8Length(placeholder) + number.toString().length
90
+ }
91
+
92
+ return size
93
+ }
@@ -0,0 +1,21 @@
1
+ // @photon-grove/evt/dynamodb: the DynamoDB backend. So far it holds the item codec, which imports
2
+ // nothing from the AWS SDK.
3
+
4
+ export {
5
+ type AttributeValue,
6
+ type Item,
7
+ DEFAULT_VIEW_SORT_KEY,
8
+ HEAD_SORT_KEY,
9
+ SNAPSHOT_SORT_KEY,
10
+ eventFromItem,
11
+ eventItem,
12
+ expiry,
13
+ headFromItem,
14
+ headItem,
15
+ isSnapshotItem,
16
+ itemSize,
17
+ snapshotFromItem,
18
+ snapshotItem,
19
+ sortKey,
20
+ viewItem,
21
+ } from './item.js'