@photon-grove/evt 0.0.0 → 0.1.1

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 (149) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +164 -1
  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/client.d.ts +83 -0
  20. package/dist/dynamodb/client.d.ts.map +1 -0
  21. package/dist/dynamodb/client.js +6 -0
  22. package/dist/dynamodb/client.js.map +1 -0
  23. package/dist/dynamodb/head.d.ts +34 -0
  24. package/dist/dynamodb/head.d.ts.map +1 -0
  25. package/dist/dynamodb/head.js +62 -0
  26. package/dist/dynamodb/head.js.map +1 -0
  27. package/dist/dynamodb/index.d.ts +6 -0
  28. package/dist/dynamodb/index.d.ts.map +1 -0
  29. package/dist/dynamodb/index.js +8 -0
  30. package/dist/dynamodb/index.js.map +1 -0
  31. package/dist/dynamodb/item.d.ts +70 -0
  32. package/dist/dynamodb/item.d.ts.map +1 -0
  33. package/dist/dynamodb/item.js +264 -0
  34. package/dist/dynamodb/item.js.map +1 -0
  35. package/dist/dynamodb/marker.d.ts +11 -0
  36. package/dist/dynamodb/marker.d.ts.map +1 -0
  37. package/dist/dynamodb/marker.js +56 -0
  38. package/dist/dynamodb/marker.js.map +1 -0
  39. package/dist/dynamodb/repository.d.ts +61 -0
  40. package/dist/dynamodb/repository.d.ts.map +1 -0
  41. package/dist/dynamodb/repository.js +731 -0
  42. package/dist/dynamodb/repository.js.map +1 -0
  43. package/dist/dynamodb/requests.d.ts +65 -0
  44. package/dist/dynamodb/requests.d.ts.map +1 -0
  45. package/dist/dynamodb/requests.js +179 -0
  46. package/dist/dynamodb/requests.js.map +1 -0
  47. package/dist/envelope.d.ts +15 -0
  48. package/dist/envelope.d.ts.map +1 -0
  49. package/dist/envelope.js +59 -0
  50. package/dist/envelope.js.map +1 -0
  51. package/dist/errors.d.ts +205 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +240 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/event.d.ts +47 -0
  56. package/dist/event.d.ts.map +1 -0
  57. package/dist/event.js +64 -0
  58. package/dist/event.js.map +1 -0
  59. package/dist/features.d.ts +55 -0
  60. package/dist/features.d.ts.map +1 -0
  61. package/dist/features.js +126 -0
  62. package/dist/features.js.map +1 -0
  63. package/dist/fields.d.ts +31 -0
  64. package/dist/fields.d.ts.map +1 -0
  65. package/dist/fields.js +105 -0
  66. package/dist/fields.js.map +1 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +17 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/json.d.ts +47 -0
  72. package/dist/json.d.ts.map +1 -0
  73. package/dist/json.js +458 -0
  74. package/dist/json.js.map +1 -0
  75. package/dist/memory/index.d.ts +2 -0
  76. package/dist/memory/index.d.ts.map +1 -0
  77. package/dist/memory/index.js +3 -0
  78. package/dist/memory/index.js.map +1 -0
  79. package/dist/memory/repository.d.ts +70 -0
  80. package/dist/memory/repository.d.ts.map +1 -0
  81. package/dist/memory/repository.js +308 -0
  82. package/dist/memory/repository.js.map +1 -0
  83. package/dist/metadata.d.ts +47 -0
  84. package/dist/metadata.d.ts.map +1 -0
  85. package/dist/metadata.js +75 -0
  86. package/dist/metadata.js.map +1 -0
  87. package/dist/repository.d.ts +131 -0
  88. package/dist/repository.d.ts.map +1 -0
  89. package/dist/repository.js +130 -0
  90. package/dist/repository.js.map +1 -0
  91. package/dist/retry.d.ts +25 -0
  92. package/dist/retry.d.ts.map +1 -0
  93. package/dist/retry.js +50 -0
  94. package/dist/retry.js.map +1 -0
  95. package/dist/snapshots.d.ts +21 -0
  96. package/dist/snapshots.d.ts.map +1 -0
  97. package/dist/snapshots.js +35 -0
  98. package/dist/snapshots.js.map +1 -0
  99. package/dist/store.d.ts +103 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +246 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/testing/equal.d.ts +22 -0
  104. package/dist/testing/equal.d.ts.map +1 -0
  105. package/dist/testing/equal.js +121 -0
  106. package/dist/testing/equal.js.map +1 -0
  107. package/dist/testing/index.d.ts +3 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +5 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/scenario.d.ts +136 -0
  112. package/dist/testing/scenario.d.ts.map +1 -0
  113. package/dist/testing/scenario.js +307 -0
  114. package/dist/testing/scenario.js.map +1 -0
  115. package/dist/utf8.d.ts +17 -0
  116. package/dist/utf8.d.ts.map +1 -0
  117. package/dist/utf8.js +82 -0
  118. package/dist/utf8.js.map +1 -0
  119. package/package.json +58 -2
  120. package/src/aggregate.ts +196 -0
  121. package/src/base64.ts +80 -0
  122. package/src/codec.ts +139 -0
  123. package/src/commit-id.ts +50 -0
  124. package/src/dynamodb/client.ts +102 -0
  125. package/src/dynamodb/head.ts +93 -0
  126. package/src/dynamodb/index.ts +45 -0
  127. package/src/dynamodb/item.ts +334 -0
  128. package/src/dynamodb/marker.ts +64 -0
  129. package/src/dynamodb/repository.ts +1030 -0
  130. package/src/dynamodb/requests.ts +256 -0
  131. package/src/envelope.ts +67 -0
  132. package/src/errors.ts +365 -0
  133. package/src/event.ts +117 -0
  134. package/src/features.ts +175 -0
  135. package/src/fields.ts +134 -0
  136. package/src/globals.d.ts +39 -0
  137. package/src/index.ts +98 -0
  138. package/src/json.ts +546 -0
  139. package/src/memory/index.ts +11 -0
  140. package/src/memory/repository.ts +463 -0
  141. package/src/metadata.ts +120 -0
  142. package/src/repository.ts +275 -0
  143. package/src/retry.ts +74 -0
  144. package/src/snapshots.ts +42 -0
  145. package/src/store.ts +377 -0
  146. package/src/testing/equal.ts +146 -0
  147. package/src/testing/index.ts +14 -0
  148. package/src/testing/scenario.ts +406 -0
  149. package/src/utf8.ts +92 -0
@@ -0,0 +1,45 @@
1
+ // @photon-grove/evt/dynamodb: the DynamoDB backend. It imports nothing from the AWS SDK: the
2
+ // repository takes the SDK's `DynamoDB` client, or anything shaped like it, as a DynamoClient.
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
+ ttlFromItem,
21
+ viewItem,
22
+ } from './item.js'
23
+ export {
24
+ type ConditionCheckAction,
25
+ type DeleteAction,
26
+ type DynamoClient,
27
+ type Expressions,
28
+ type GetItemInput,
29
+ type Page,
30
+ type PutAction,
31
+ type PutItemInput,
32
+ type QueryInput,
33
+ type RequestOptions,
34
+ type ScanInput,
35
+ type TransactItem,
36
+ type UpdateAction,
37
+ } from './client.js'
38
+ export {FEATURES_PK, REBUILD_LOCK_PK, manifestFromItem, markerItem} from './marker.js'
39
+ export {DynamoRepository, type DynamoRepositoryOptions} from './repository.js'
40
+ export {
41
+ type CommitTtl,
42
+ type SnapshotTtl,
43
+ commitTransaction,
44
+ snapshotTransaction,
45
+ } from './requests.js'
@@ -0,0 +1,334 @@
1
+ // The DynamoDB item layout of the event log. It's pure: it shapes items the way the AWS SDK's
2
+ // low-level client takes them, and imports nothing from the SDK.
3
+ //
4
+ // | Attribute | Type | Event item | Snapshot item (sk 0) | Head item (sk -1) |
5
+ // | ------------ | ---- | -------------- | -------------------- | ----------------- |
6
+ // | `pk` | S | entity ID | entity ID | entity ID |
7
+ // | `sk` | N | event sequence | `0` | `-1` |
8
+ // | `type` | S | event type | | |
9
+ // | `version` | N | event version | | |
10
+ // | `entityType` | S | entity type | entity type | |
11
+ // | `payload` | S | payload JSON | state JSON | |
12
+ // | `metadata` | S | metadata JSON | | |
13
+ // | `seq` | N | | snapshot sequence | |
14
+ // | `eventSeq` | N | | last event folded in | |
15
+ // | `head` | N | | | highest sequence |
16
+ // | `ttl` | N | optional | optional | optional |
17
+
18
+ import {type SerializedEvent, type SerializedSnapshot, eventId} from '../event.js'
19
+ import {EvtError} from '../errors.js'
20
+ import {
21
+ DOCUMENT,
22
+ checkInt64,
23
+ int64Argument,
24
+ invalid,
25
+ safeIntegerArgument,
26
+ } from '../fields.js'
27
+ import {parseJson} from '../json.js'
28
+ import {encodeMetadata, metadataFromJson} from '../metadata.js'
29
+ import {utf8Decode, utf8Encode, utf8Length} from '../utf8.js'
30
+
31
+ /** A DynamoDB attribute value, shaped like the SDK's `AttributeValue`. */
32
+ export type AttributeValue =
33
+ | {S: string}
34
+ | {N: string}
35
+ | {B: Uint8Array}
36
+ | {BOOL: boolean}
37
+ | {NULL: boolean}
38
+ | {M: Item}
39
+ | {L: AttributeValue[]}
40
+ | {SS: string[]}
41
+ | {NS: string[]}
42
+ | {BS: Uint8Array[]}
43
+
44
+ /** A DynamoDB item. */
45
+ export type Item = Record<string, AttributeValue>
46
+
47
+ /** The sort key of a stream's inline snapshot. */
48
+ export const SNAPSHOT_SORT_KEY = 0n
49
+
50
+ /** The sort key of a stream's head item. */
51
+ export const HEAD_SORT_KEY = -1n
52
+
53
+ /** The sort key of a single-item view row. */
54
+ export const DEFAULT_VIEW_SORT_KEY = 'VIEW'
55
+
56
+ /** Encodes an event as an event-log item. `ttl` (Unix seconds) is written only when positive. */
57
+ export function eventItem(event: SerializedEvent, ttl?: number): Item {
58
+ return withTtl(
59
+ {
60
+ pk: {S: event.entityId},
61
+ sk: {N: int64Argument(event.sequence, 'event.sequence').toString()},
62
+ type: {S: event.type},
63
+ version: {N: safeIntegerArgument(event.version, 'event.version').toString()},
64
+ entityType: {S: event.entityType},
65
+ payload: {S: payloadText(event.payload, 'event payload')},
66
+ metadata: {S: encodeMetadata(event.metadata)},
67
+ },
68
+ ttl,
69
+ )
70
+ }
71
+
72
+ /** Encodes a stream's inline snapshot item. `ttl` is written only when positive. */
73
+ export function snapshotItem(snapshot: SerializedSnapshot, ttl?: number): Item {
74
+ return withTtl(
75
+ {
76
+ pk: {S: snapshot.entityId},
77
+ sk: {N: SNAPSHOT_SORT_KEY.toString()},
78
+ seq: {N: int64Argument(snapshot.sequence, 'snapshot.sequence').toString()},
79
+ eventSeq: {N: int64Argument(snapshot.eventSequence, 'snapshot.eventSequence').toString()},
80
+ entityType: {S: snapshot.entityType},
81
+ payload: {S: payloadText(snapshot.payload, 'snapshot payload')},
82
+ },
83
+ ttl,
84
+ )
85
+ }
86
+
87
+ /** Encodes a stream's head item. `ttl` is written only when positive. */
88
+ export function headItem(entityId: string, head: bigint, ttl?: number): Item {
89
+ const item: Item = {
90
+ pk: {S: entityId},
91
+ sk: {N: HEAD_SORT_KEY.toString()},
92
+ head: {N: int64Argument(head, 'head').toString()},
93
+ }
94
+
95
+ return withTtl(item, ttl)
96
+ }
97
+
98
+ /** Encodes a single-item view row: `pk = {entityType}#{entityId}`, `sk = "VIEW"`. */
99
+ export function viewItem(entityType: string, entityId: string, payload: Uint8Array): Item {
100
+ return {
101
+ pk: {S: `${entityType}#${entityId}`},
102
+ sk: {S: DEFAULT_VIEW_SORT_KEY},
103
+ entityID: {S: entityId},
104
+ entityType: {S: entityType},
105
+ payload: {S: payloadText(payload, 'view payload')},
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Decodes an event-log item. Missing attributes other than the key are empty, and `metadata` must
111
+ * be present and not blank. A JSON `null` metadata is the zero value, as in Go.
112
+ */
113
+ export function eventFromItem(item: Item): SerializedEvent {
114
+ const entityId = requiredString(item, 'pk')
115
+ const sequence = requiredNumber(item, 'sk')
116
+ const id = eventId(entityId, sequence)
117
+ const metadata = optionalString(item, 'metadata') ?? ''
118
+ if (metadata.trim() === '') {
119
+ throw invalid(`event ${id} has empty metadata`)
120
+ }
121
+
122
+ let decoded
123
+ try {
124
+ decoded = metadataFromJson(parseJson(metadata, DOCUMENT))
125
+ } catch (error) {
126
+ throw new EvtError('invalidRecord', `event ${id} metadata: ${message(error)}`, {cause: error})
127
+ }
128
+
129
+ return {
130
+ id,
131
+ sequence,
132
+ type: optionalString(item, 'type') ?? '',
133
+ version: safeInteger(optionalNumber(item, 'version') ?? 0n, 'version'),
134
+ entityId,
135
+ entityType: optionalString(item, 'entityType') ?? '',
136
+ payload: utf8Encode(optionalString(item, 'payload') ?? ''),
137
+ metadata: decoded,
138
+ }
139
+ }
140
+
141
+ /** Decodes a stream's inline snapshot item. */
142
+ export function snapshotFromItem(item: Item): SerializedSnapshot {
143
+ return {
144
+ entityType: optionalString(item, 'entityType') ?? '',
145
+ entityId: requiredString(item, 'pk'),
146
+ sequence: optionalNumber(item, 'seq') ?? 0n,
147
+ eventSequence: optionalNumber(item, 'eventSeq') ?? 0n,
148
+ payload: utf8Encode(optionalString(item, 'payload') ?? ''),
149
+ }
150
+ }
151
+
152
+ /** Decodes a head item into its head and its `ttl`, if it has one. */
153
+ export function headFromItem(item: Item): {head: bigint; ttl: number | undefined} {
154
+ return {head: requiredNumber(item, 'head'), ttl: ttlFromItem(item)}
155
+ }
156
+
157
+ /** An item's `ttl` in Unix seconds, or undefined when it has none. */
158
+ export function ttlFromItem(item: Item): number | undefined {
159
+ const ttl = optionalNumber(item, 'ttl')
160
+
161
+ return ttl === undefined ? undefined : safeInteger(ttl, 'ttl')
162
+ }
163
+
164
+ /** Whether an item is a stream's inline snapshot (`sk = 0`). */
165
+ export function isSnapshotItem(item: Item): boolean {
166
+ return sortKey(item) === SNAPSHOT_SORT_KEY
167
+ }
168
+
169
+ /** An item's numeric sort key, if it has one. */
170
+ export function sortKey(item: Item): bigint | undefined {
171
+ const sk = item['sk']
172
+ if (sk === undefined || !('N' in sk)) {
173
+ return undefined
174
+ }
175
+
176
+ return parseNumber(sk.N)
177
+ }
178
+
179
+ /**
180
+ * The `ttl` of an item written at `nowMs` (Unix milliseconds) under a retention of `retentionMs`:
181
+ * their sum in whole Unix seconds, rounded down after adding, as Go's `Time.Add(...).Unix()` is.
182
+ * Undefined, so no `ttl` is written, for a zero retention. A retention with a fraction of a
183
+ * millisecond is refused, because Go would count it.
184
+ */
185
+ export function expiry(nowMs: number, retentionMs: number): number | undefined {
186
+ if (!Number.isSafeInteger(nowMs) || !Number.isSafeInteger(retentionMs) || retentionMs < 0) {
187
+ throw new EvtError(
188
+ 'invalidArgument',
189
+ 'the clock and the retention must be whole, non-negative milliseconds: ' +
190
+ `${nowMs}, ${retentionMs}`,
191
+ )
192
+ }
193
+ if (retentionMs === 0) {
194
+ return undefined
195
+ }
196
+
197
+ return Math.floor((nowMs + retentionMs) / 1000)
198
+ }
199
+
200
+ /**
201
+ * The size DynamoDB counts for an item: each attribute's name plus its value, in UTF-8 bytes. The
202
+ * commit budget measures every action with it.
203
+ */
204
+ export function itemSize(item: Item): number {
205
+ let size = 0
206
+ for (const [name, value] of Object.entries(item)) {
207
+ size += utf8Length(name) + valueSize(value)
208
+ }
209
+
210
+ return size
211
+ }
212
+
213
+ function valueSize(value: AttributeValue): number {
214
+ if ('S' in value) {
215
+ return utf8Length(value.S)
216
+ }
217
+ if ('N' in value) {
218
+ return utf8Length(value.N)
219
+ }
220
+ if ('B' in value) {
221
+ return value.B.length
222
+ }
223
+ if ('BOOL' in value || 'NULL' in value) {
224
+ return 1
225
+ }
226
+ if ('M' in value) {
227
+ return 3 + itemSize(value.M) + Object.keys(value.M).length
228
+ }
229
+ if ('L' in value) {
230
+ return value.L.reduce((size, entry) => size + 1 + valueSize(entry), 3)
231
+ }
232
+ if ('SS' in value) {
233
+ return value.SS.reduce((size, entry) => size + utf8Length(entry), 0)
234
+ }
235
+ if ('NS' in value) {
236
+ return value.NS.reduce((size, entry) => size + utf8Length(entry), 0)
237
+ }
238
+
239
+ return value.BS.reduce((size, entry) => size + entry.length, 0)
240
+ }
241
+
242
+ function withTtl(item: Item, ttl: number | undefined): Item {
243
+ if (ttl !== undefined && safeIntegerArgument(ttl, 'ttl') > 0) {
244
+ item['ttl'] = {N: ttl.toString()}
245
+ }
246
+
247
+ return item
248
+ }
249
+
250
+ // A payload is stored as a DynamoDB string, so it must be UTF-8. A null payload is stored empty.
251
+ function payloadText(payload: Uint8Array | null, what: string): string {
252
+ if (payload === null) {
253
+ return ''
254
+ }
255
+
256
+ try {
257
+ return utf8Decode(payload, what)
258
+ } catch (error) {
259
+ const reason = `${what} is not valid UTF-8 and cannot be stored as a string`
260
+ throw new EvtError('invalidArgument', reason, {cause: error})
261
+ }
262
+ }
263
+
264
+ function requiredString(item: Item, name: string): string {
265
+ const value = optionalString(item, name)
266
+ if (value === undefined) {
267
+ throw invalid(`item has no ${name} attribute`)
268
+ }
269
+
270
+ return value
271
+ }
272
+
273
+ function requiredNumber(item: Item, name: string): bigint {
274
+ const value = optionalNumber(item, name)
275
+ if (value === undefined) {
276
+ throw invalid(`item has no ${name} attribute`)
277
+ }
278
+
279
+ return value
280
+ }
281
+
282
+ function optionalString(item: Item, name: string): string | undefined {
283
+ const value = item[name]
284
+ if (value === undefined || 'NULL' in value) {
285
+ return undefined
286
+ }
287
+ if (!('S' in value)) {
288
+ throw invalid(`attribute ${name} is not a string`)
289
+ }
290
+
291
+ return value.S
292
+ }
293
+
294
+ function optionalNumber(item: Item, name: string): bigint | undefined {
295
+ const value = item[name]
296
+ if (value === undefined || 'NULL' in value) {
297
+ return undefined
298
+ }
299
+ if (!('N' in value)) {
300
+ throw invalid(`attribute ${name} is not a number`)
301
+ }
302
+
303
+ const number = parseNumber(value.N)
304
+ if (number === undefined) {
305
+ throw invalid(`attribute ${name} is not an integer: ${value.N}`)
306
+ }
307
+
308
+ return number
309
+ }
310
+
311
+ // An int64 in decimal, as strconv.ParseInt reads it: an optional sign, then digits.
312
+ function parseNumber(text: string): bigint | undefined {
313
+ if (!/^[+-]?\d+$/.test(text)) {
314
+ return undefined
315
+ }
316
+
317
+ try {
318
+ return checkInt64(BigInt(text), 'number')
319
+ } catch {
320
+ return undefined
321
+ }
322
+ }
323
+
324
+ function safeInteger(value: bigint, name: string): number {
325
+ if (value < BigInt(Number.MIN_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
326
+ throw invalid(`attribute ${name} is ${value}, past the integers it holds`)
327
+ }
328
+
329
+ return Number(value)
330
+ }
331
+
332
+ function message(error: unknown): string {
333
+ return error instanceof Error ? error.message : String(error)
334
+ }
@@ -0,0 +1,64 @@
1
+ // The DynamoDB item of the feature-marker row. The control rows live in the reserved SYSTEM#
2
+ // namespace at sort key 0.
3
+ //
4
+ // | Attribute | Type | Feature-marker item (`pk = SYSTEM#FEATURES`, `sk = 0`) |
5
+ // | ------------------ | ---- | ------------------------------------------------------------ |
6
+ // | `version` | N | bumped on every change to the manifest |
7
+ // | `features` | S | JSON object, feature name → stored-format version, sorted |
8
+ // | `historyDependent` | S | JSON array of entity types, sorted. Absent when empty |
9
+ // | `retention` | S | JSON array of entity types, sorted. Absent when empty |
10
+
11
+ import {checkInt64, invalid} from '../fields.js'
12
+ import {type FeatureManifest, decodeManifest, encodeManifest} from '../features.js'
13
+ import {type Item, SNAPSHOT_SORT_KEY} from './item.js'
14
+
15
+ /** The partition key of the feature-marker row. */
16
+ export const FEATURES_PK = 'SYSTEM#FEATURES'
17
+
18
+ /** The partition key of the rebuild-lock row, which every commit checks is absent. */
19
+ export const REBUILD_LOCK_PK = 'SYSTEM#REBUILD'
20
+
21
+ /** Encodes a feature manifest as its marker item. Empty entity-type sets are left out. */
22
+ export function markerItem(manifest: FeatureManifest): Item {
23
+ const texts = encodeManifest(manifest)
24
+ const item: Item = {
25
+ pk: {S: FEATURES_PK},
26
+ sk: {N: SNAPSHOT_SORT_KEY.toString()},
27
+ version: {N: manifest.version.toString()},
28
+ features: {S: texts.features},
29
+ }
30
+ if (manifest.historyDependent.length > 0) {
31
+ item['historyDependent'] = {S: texts.historyDependent}
32
+ }
33
+ if (manifest.retention.length > 0) {
34
+ item['retention'] = {S: texts.retention}
35
+ }
36
+
37
+ return item
38
+ }
39
+
40
+ /** Decodes a marker item. An absent text attribute is an empty map or set. */
41
+ export function manifestFromItem(item: Item): FeatureManifest {
42
+ const version = item['version']
43
+ if (version === undefined || !('N' in version) || !/^\d+$/.test(version.N)) {
44
+ throw invalid('the feature marker has no version')
45
+ }
46
+
47
+ return decodeManifest(checkInt64(BigInt(version.N), 'the feature marker version'), {
48
+ features: text(item, 'features'),
49
+ historyDependent: text(item, 'historyDependent'),
50
+ retention: text(item, 'retention'),
51
+ })
52
+ }
53
+
54
+ function text(item: Item, name: string): string {
55
+ const value = item[name]
56
+ if (value === undefined) {
57
+ return ''
58
+ }
59
+ if (!('S' in value)) {
60
+ throw invalid(`the feature marker's ${name} attribute is not a string`)
61
+ }
62
+
63
+ return value.S
64
+ }