@mannyc1/ts-release 0.2.2 → 0.3.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 (107) hide show
  1. package/ARCHITECTURE.md +60 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +129 -6
  4. package/SPEC.md +51 -0
  5. package/apps/release-ts/README.md +7 -0
  6. package/apps/ts-release-agents/package.json +1 -1
  7. package/apps/ts-release-agents/src/check.ts +7 -2
  8. package/apps/ts-release-agents/test/contract.test.ts +2 -2
  9. package/dist/bin/ts-release.js +154 -47
  10. package/dist/drivers/archive.d.ts +2 -1
  11. package/dist/drivers/archive.d.ts.map +1 -1
  12. package/dist/drivers/archive.js +12 -8
  13. package/dist/drivers/archive.js.map +1 -1
  14. package/dist/drivers/process.d.ts.map +1 -1
  15. package/dist/drivers/process.js +4 -4
  16. package/dist/drivers/process.js.map +1 -1
  17. package/dist/drivers/python-wheel.d.ts +21 -0
  18. package/dist/drivers/python-wheel.d.ts.map +1 -0
  19. package/dist/drivers/python-wheel.js +109 -0
  20. package/dist/drivers/python-wheel.js.map +1 -0
  21. package/dist/drivers/workspace.d.ts +3 -0
  22. package/dist/drivers/workspace.d.ts.map +1 -1
  23. package/dist/drivers/workspace.js +5 -1
  24. package/dist/drivers/workspace.js.map +1 -1
  25. package/dist/operation-journal/authority.d.ts +3 -0
  26. package/dist/operation-journal/authority.d.ts.map +1 -0
  27. package/dist/operation-journal/authority.js +147 -0
  28. package/dist/operation-journal/authority.js.map +1 -0
  29. package/dist/operation-journal/aws/deadline.d.ts +12 -0
  30. package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
  31. package/dist/operation-journal/aws/deadline.js +38 -0
  32. package/dist/operation-journal/aws/deadline.js.map +1 -0
  33. package/dist/operation-journal/aws/oidc.d.ts +27 -0
  34. package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
  35. package/dist/operation-journal/aws/oidc.js +361 -0
  36. package/dist/operation-journal/aws/oidc.js.map +1 -0
  37. package/dist/operation-journal/aws/policy.d.ts +42 -0
  38. package/dist/operation-journal/aws/policy.d.ts.map +1 -0
  39. package/dist/operation-journal/aws/policy.js +404 -0
  40. package/dist/operation-journal/aws/policy.js.map +1 -0
  41. package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
  42. package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
  43. package/dist/operation-journal/aws/s3-boundary.js +417 -0
  44. package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
  45. package/dist/operation-journal/aws.d.ts +11 -0
  46. package/dist/operation-journal/aws.d.ts.map +1 -0
  47. package/dist/operation-journal/aws.js +50 -0
  48. package/dist/operation-journal/aws.js.map +1 -0
  49. package/dist/operation-journal/canonical.d.ts +52 -0
  50. package/dist/operation-journal/canonical.d.ts.map +1 -0
  51. package/dist/operation-journal/canonical.js +282 -0
  52. package/dist/operation-journal/canonical.js.map +1 -0
  53. package/dist/operation-journal/model.d.ts +251 -0
  54. package/dist/operation-journal/model.d.ts.map +1 -0
  55. package/dist/operation-journal/model.js +56 -0
  56. package/dist/operation-journal/model.js.map +1 -0
  57. package/dist/operation-journal/reducer.d.ts +28 -0
  58. package/dist/operation-journal/reducer.d.ts.map +1 -0
  59. package/dist/operation-journal/reducer.js +73 -0
  60. package/dist/operation-journal/reducer.js.map +1 -0
  61. package/dist/operation-journal/s3.d.ts +7 -0
  62. package/dist/operation-journal/s3.d.ts.map +1 -0
  63. package/dist/operation-journal/s3.js +667 -0
  64. package/dist/operation-journal/s3.js.map +1 -0
  65. package/dist/operation-journal.d.ts +14 -0
  66. package/dist/operation-journal.d.ts.map +1 -0
  67. package/dist/operation-journal.js +12 -0
  68. package/dist/operation-journal.js.map +1 -0
  69. package/dist/platform/credentials.d.ts.map +1 -1
  70. package/dist/platform/credentials.js +65 -3
  71. package/dist/platform/credentials.js.map +1 -1
  72. package/dist/publication/github.d.ts.map +1 -1
  73. package/dist/publication/github.js +66 -15
  74. package/dist/publication/github.js.map +1 -1
  75. package/dist/release/capabilities.d.ts.map +1 -1
  76. package/dist/release/capabilities.js +2 -2
  77. package/dist/release/capabilities.js.map +1 -1
  78. package/dist/release/prepare.d.ts.map +1 -1
  79. package/dist/release/prepare.js +4 -5
  80. package/dist/release/prepare.js.map +1 -1
  81. package/dist/release/prepared-store.d.ts.map +1 -1
  82. package/dist/release/prepared-store.js +11 -9
  83. package/dist/release/prepared-store.js.map +1 -1
  84. package/package.json +19 -4
  85. package/src/drivers/archive.ts +17 -8
  86. package/src/drivers/process.ts +3 -4
  87. package/src/drivers/python-wheel.ts +145 -0
  88. package/src/drivers/workspace.ts +7 -1
  89. package/src/operation-journal/authority.ts +162 -0
  90. package/src/operation-journal/aws/deadline.ts +41 -0
  91. package/src/operation-journal/aws/oidc.ts +439 -0
  92. package/src/operation-journal/aws/policy.ts +491 -0
  93. package/src/operation-journal/aws/s3-boundary.ts +562 -0
  94. package/src/operation-journal/aws.ts +60 -0
  95. package/src/operation-journal/canonical.ts +373 -0
  96. package/src/operation-journal/model.ts +301 -0
  97. package/src/operation-journal/reducer.ts +112 -0
  98. package/src/operation-journal/s3.ts +884 -0
  99. package/src/operation-journal.ts +51 -0
  100. package/src/platform/credentials.ts +71 -3
  101. package/src/publication/github.ts +83 -15
  102. package/src/release/capabilities.ts +3 -2
  103. package/src/release/prepare.ts +3 -5
  104. package/src/release/prepared-store.ts +8 -7
  105. package/templates/README.md +2 -2
  106. package/templates/github-actions/release.yml +24 -7
  107. package/templates/github-actions/reviewed-release.yml +49 -14
@@ -0,0 +1,884 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import * as Effect from "effect/Effect"
3
+ import { encodeCanonicalJson } from "../model/canonical.js"
4
+ import {
5
+ decodeJournalEvent,
6
+ decodeJournalHead,
7
+ encodeJournalEvent,
8
+ encodeJournalHead,
9
+ eventDigest,
10
+ isCanonicalS3VersionId,
11
+ journalEventKey,
12
+ journalHeadKey,
13
+ journalNamespace,
14
+ journalRecordFromEvent,
15
+ makeCanonicalJournalRecord,
16
+ objectChecksum,
17
+ validateJournalOperation,
18
+ validateJournalWorkflow,
19
+ validateTransactionId,
20
+ type CanonicalJournalEvent,
21
+ type CanonicalJournalHead,
22
+ type CanonicalJournalRecord
23
+ } from "./canonical.js"
24
+ import { validateS3JournalAuthority } from "./authority.js"
25
+ import {
26
+ CanonicalOperationJournalShape,
27
+ JournalAuthorityMismatch,
28
+ JournalConflict,
29
+ JournalInputError,
30
+ JournalIntegrityError,
31
+ JournalStorageOutcomeUnknown,
32
+ JournalStorageUnavailable,
33
+ JournalTransitionError,
34
+ S3JournalBoundaryError,
35
+ type JournalAcknowledgement,
36
+ type JournalAppendError,
37
+ type JournalAppendRequest,
38
+ type JournalOperation,
39
+ type JournalReadError,
40
+ type JournalSnapshot,
41
+ type S3JournalAuthority,
42
+ type S3JournalBoundaryShape,
43
+ type S3JournalNamespaceListing,
44
+ type S3JournalObjectVersion,
45
+ type S3JournalPutRequest,
46
+ type S3JournalPutResult
47
+ } from "./model.js"
48
+ import {
49
+ admitJournalTransition,
50
+ makeJournalEvent,
51
+ makeJournalHead,
52
+ reduceJournalEvents
53
+ } from "./reducer.js"
54
+
55
+ interface LoadedEntry {
56
+ readonly headObject: S3JournalObjectVersion
57
+ readonly head: CanonicalJournalHead
58
+ readonly eventObject: S3JournalObjectVersion
59
+ readonly event: CanonicalJournalEvent
60
+ }
61
+
62
+ interface LoadedOrphan {
63
+ readonly eventObject: S3JournalObjectVersion
64
+ readonly event: CanonicalJournalEvent
65
+ }
66
+
67
+ interface LoadedJournal {
68
+ readonly authority: S3JournalAuthority
69
+ readonly operation: JournalOperation
70
+ readonly entries: ReadonlyArray<LoadedEntry>
71
+ readonly orphans: ReadonlyArray<LoadedOrphan>
72
+ readonly state: JournalSnapshot["state"]
73
+ }
74
+
75
+ const pureInput = <A>(evaluate: () => A): Effect.Effect<A, JournalInputError> => Effect.try({
76
+ try: evaluate,
77
+ catch: (cause) => cause instanceof JournalInputError
78
+ ? cause
79
+ : JournalInputError.make({ reason: "Journal input validation failed." })
80
+ })
81
+
82
+ const pureIntegrity = <A>(evaluate: () => A): Effect.Effect<A, JournalIntegrityError> => Effect.try({
83
+ try: evaluate,
84
+ catch: (cause) => cause instanceof JournalIntegrityError
85
+ ? cause
86
+ : JournalIntegrityError.make({
87
+ reason: cause instanceof JournalInputError || cause instanceof JournalTransitionError
88
+ ? cause.reason
89
+ : "Journal canonical validation failed."
90
+ })
91
+ })
92
+
93
+ const pureTransition = <A>(evaluate: () => A): Effect.Effect<A, JournalTransitionError> => Effect.try({
94
+ try: evaluate,
95
+ catch: (cause) => cause instanceof JournalTransitionError
96
+ ? cause
97
+ : JournalTransitionError.make({ reason: "Journal transition validation failed." })
98
+ })
99
+
100
+ const safeReason = (error: S3JournalBoundaryError): string =>
101
+ `S3 journal ${error.operation} boundary failed with ${error.commitment} commitment.`
102
+
103
+ const readBoundary = <A>(
104
+ effect: Effect.Effect<A, S3JournalBoundaryError>
105
+ ): Effect.Effect<A, JournalStorageUnavailable> => effect.pipe(Effect.mapError((error) =>
106
+ JournalStorageUnavailable.make({ reason: safeReason(error) })
107
+ ))
108
+
109
+ const postMutationRead = <A>(
110
+ effect: Effect.Effect<A, S3JournalBoundaryError>
111
+ ): Effect.Effect<A, JournalStorageOutcomeUnknown> => effect.pipe(Effect.mapError((error) =>
112
+ JournalStorageOutcomeUnknown.make({ reason: safeReason(error) })
113
+ ))
114
+
115
+ const sameBytes = (left: Uint8Array, right: Uint8Array): boolean =>
116
+ left.length === right.length && left.every((byte, index) => byte === right[index])
117
+
118
+ const objectIdentity = (key: string, versionId: string): string => `${key}\u0000${versionId}`
119
+
120
+ const canonicalAuthority = (authority: S3JournalAuthority): string => encodeCanonicalJson(authority)
121
+
122
+ const observeAuthority = (
123
+ expected: S3JournalAuthority,
124
+ client: S3JournalBoundaryShape
125
+ ): Effect.Effect<S3JournalAuthority, JournalAuthorityMismatch | JournalStorageUnavailable> => Effect.gen(function*() {
126
+ yield* Effect.try({
127
+ try: () => validateS3JournalAuthority(expected),
128
+ catch: (cause) => cause instanceof JournalAuthorityMismatch
129
+ ? cause
130
+ : JournalAuthorityMismatch.make({ reason: "Expected journal authority is malformed." })
131
+ })
132
+ const observed = yield* readBoundary(client.observeAuthority())
133
+ yield* Effect.try({
134
+ try: () => validateS3JournalAuthority(observed),
135
+ catch: (cause) => cause instanceof JournalAuthorityMismatch
136
+ ? cause
137
+ : JournalAuthorityMismatch.make({ reason: "Observed journal authority is malformed." })
138
+ })
139
+ if (canonicalAuthority(observed) !== canonicalAuthority(expected)) {
140
+ return yield* JournalAuthorityMismatch.make({ reason: "Observed journal authority drifted from the exact expected identity and policy." })
141
+ }
142
+ return observed
143
+ })
144
+
145
+ const minimumTenYearRetentionMilliseconds = 10 * 365 * 24 * 60 * 60 * 1_000
146
+
147
+ const validateRetainedObject = (
148
+ object: S3JournalObjectVersion,
149
+ expectedKey: string,
150
+ expectedVersionId?: string,
151
+ expectedEtag?: string
152
+ ): void => {
153
+ if (object.key !== expectedKey || (expectedVersionId !== undefined && object.versionId !== expectedVersionId) ||
154
+ (expectedEtag !== undefined && object.etag !== expectedEtag)) {
155
+ throw JournalIntegrityError.make({ reason: "Journal object identity disagrees with its exact version listing." })
156
+ }
157
+ if (!isCanonicalS3VersionId(object.versionId) || !/^"[!#-~]{1,1024}"$/u.test(object.etag)) {
158
+ throw JournalIntegrityError.make({ reason: "Journal object version or ETag is not canonical." })
159
+ }
160
+ if (!/^sha256:[a-f0-9]{64}$/u.test(object.checksumSha256) ||
161
+ object.checksumSha256 !== objectChecksum(object.bytes)) {
162
+ throw JournalIntegrityError.make({ reason: "Journal object checksum does not match its exact bytes." })
163
+ }
164
+ const modified = new Date(object.lastModified)
165
+ const retained = new Date(object.retainUntil)
166
+ if (Number.isNaN(modified.getTime()) || modified.toISOString() !== object.lastModified ||
167
+ Number.isNaN(retained.getTime()) || retained.toISOString() !== object.retainUntil ||
168
+ object.retentionMode !== "COMPLIANCE" ||
169
+ retained.getTime() - modified.getTime() < minimumTenYearRetentionMilliseconds) {
170
+ throw JournalIntegrityError.make({ reason: "Journal object is not retained in COMPLIANCE mode for the required ten years." })
171
+ }
172
+ }
173
+
174
+ function listOperation(
175
+ authority: S3JournalAuthority,
176
+ operation: JournalOperation,
177
+ maximum: number,
178
+ client: S3JournalBoundaryShape,
179
+ afterMutation: true
180
+ ): Effect.Effect<S3JournalNamespaceListing, JournalStorageOutcomeUnknown>
181
+ function listOperation(
182
+ authority: S3JournalAuthority,
183
+ operation: JournalOperation,
184
+ maximum: number,
185
+ client: S3JournalBoundaryShape,
186
+ afterMutation: false
187
+ ): Effect.Effect<S3JournalNamespaceListing, JournalStorageUnavailable>
188
+ function listOperation(
189
+ authority: S3JournalAuthority,
190
+ operation: JournalOperation,
191
+ maximum: number,
192
+ client: S3JournalBoundaryShape,
193
+ afterMutation: boolean
194
+ ): Effect.Effect<S3JournalNamespaceListing, JournalStorageUnavailable | JournalStorageOutcomeUnknown> {
195
+ const request = {
196
+ bucketName: authority.bucketName,
197
+ expectedBucketOwner: authority.expectedBucketOwner,
198
+ region: authority.region,
199
+ prefix: journalNamespace(operation),
200
+ maximum
201
+ }
202
+ return afterMutation
203
+ ? postMutationRead(client.listNamespace(request))
204
+ : readBoundary(client.listNamespace(request))
205
+ }
206
+
207
+ function getObject(
208
+ authority: S3JournalAuthority,
209
+ key: string,
210
+ versionId: string,
211
+ client: S3JournalBoundaryShape,
212
+ afterMutation: true
213
+ ): Effect.Effect<S3JournalObjectVersion, JournalStorageOutcomeUnknown>
214
+ function getObject(
215
+ authority: S3JournalAuthority,
216
+ key: string,
217
+ versionId: string,
218
+ client: S3JournalBoundaryShape,
219
+ afterMutation: false
220
+ ): Effect.Effect<S3JournalObjectVersion, JournalStorageUnavailable>
221
+ function getObject(
222
+ authority: S3JournalAuthority,
223
+ key: string,
224
+ versionId: string,
225
+ client: S3JournalBoundaryShape,
226
+ afterMutation: boolean
227
+ ): Effect.Effect<S3JournalObjectVersion, JournalStorageUnavailable | JournalStorageOutcomeUnknown> {
228
+ const request = {
229
+ bucketName: authority.bucketName,
230
+ expectedBucketOwner: authority.expectedBucketOwner,
231
+ region: authority.region,
232
+ key,
233
+ versionId
234
+ }
235
+ return afterMutation
236
+ ? postMutationRead(client.getObjectVersion(request))
237
+ : readBoundary(client.getObjectVersion(request))
238
+ }
239
+
240
+ const loadJournal = (input: {
241
+ readonly expectedAuthority: S3JournalAuthority
242
+ readonly client: S3JournalBoundaryShape
243
+ readonly operation: JournalOperation
244
+ readonly maximum: number
245
+ readonly allowStandaloneOrphans: boolean
246
+ readonly afterMutation?: boolean
247
+ }): Effect.Effect<LoadedJournal, JournalReadError | JournalStorageOutcomeUnknown> => Effect.gen(function*() {
248
+ const operation = yield* pureInput(() => validateJournalOperation(input.operation))
249
+ const authority = input.afterMutation === true
250
+ ? yield* observeAuthority(input.expectedAuthority, input.client).pipe(Effect.mapError((error) =>
251
+ error instanceof JournalStorageUnavailable
252
+ ? JournalStorageOutcomeUnknown.make({ reason: error.reason })
253
+ : error
254
+ ))
255
+ : yield* observeAuthority(input.expectedAuthority, input.client)
256
+ if (operation.releasePoint !== authority.oidc.sha) {
257
+ return yield* JournalAuthorityMismatch.make({
258
+ reason: "Journal release point does not equal the exact caller SHA."
259
+ })
260
+ }
261
+ const listing = input.afterMutation === true
262
+ ? yield* listOperation(authority, operation, input.maximum, input.client, true)
263
+ : yield* listOperation(authority, operation, input.maximum, input.client, false)
264
+ if (listing.truncated || listing.versions.length > input.maximum) {
265
+ if (input.afterMutation === true) {
266
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Journal namespace exceeded the bounded authoritative re-read." })
267
+ }
268
+ return yield* JournalIntegrityError.make({ reason: "Journal namespace exceeded the bounded authoritative read." })
269
+ }
270
+ if (listing.deleteMarkers.length > 0) {
271
+ return yield* JournalIntegrityError.make({ reason: "Journal namespace contains a forbidden delete marker." })
272
+ }
273
+ const namespace = journalNamespace(operation)
274
+ const headKey = journalHeadKey(operation)
275
+ const eventPattern = new RegExp(`^${namespace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}events/[0-9]{8}/[a-f0-9-]{36}\\.bin$`, "u")
276
+ const metadataIds = new Set<string>()
277
+ const latestByKey = new Map<string, number>()
278
+ for (const metadata of listing.versions) {
279
+ if (metadata.key !== headKey && !eventPattern.test(metadata.key)) {
280
+ return yield* JournalIntegrityError.make({ reason: "Journal namespace contains an unadmitted object key." })
281
+ }
282
+ const identity = objectIdentity(metadata.key, metadata.versionId)
283
+ if (metadataIds.has(identity)) {
284
+ return yield* JournalIntegrityError.make({ reason: "Journal version listing repeats an object version." })
285
+ }
286
+ metadataIds.add(identity)
287
+ if (metadata.isLatest) latestByKey.set(metadata.key, (latestByKey.get(metadata.key) ?? 0) + 1)
288
+ }
289
+ for (const key of new Set(listing.versions.map((version) => version.key))) {
290
+ if (latestByKey.get(key) !== 1) {
291
+ return yield* JournalIntegrityError.make({ reason: "Journal object key does not have exactly one latest version." })
292
+ }
293
+ }
294
+ const objects = new Map<string, S3JournalObjectVersion>()
295
+ for (const metadata of listing.versions) {
296
+ const object = input.afterMutation === true
297
+ ? yield* getObject(authority, metadata.key, metadata.versionId, input.client, true)
298
+ : yield* getObject(authority, metadata.key, metadata.versionId, input.client, false)
299
+ yield* pureIntegrity(() => validateRetainedObject(object, metadata.key, metadata.versionId, metadata.etag))
300
+ objects.set(objectIdentity(metadata.key, metadata.versionId), object)
301
+ }
302
+
303
+ const headMetadata = listing.versions.filter((version) => version.key === headKey)
304
+ const eventMetadata = listing.versions.filter((version) => version.key !== headKey)
305
+ const eventKeys = new Set<string>()
306
+ for (const metadata of eventMetadata) {
307
+ if (eventKeys.has(metadata.key)) {
308
+ return yield* JournalIntegrityError.make({ reason: "Immutable journal event key has more than one version." })
309
+ }
310
+ eventKeys.add(metadata.key)
311
+ }
312
+ const headByVersion = new Map(headMetadata.map((metadata) => [metadata.versionId, metadata] as const))
313
+ const latestHead = headMetadata.find((metadata) => metadata.isLatest)
314
+ const visitedHeads = new Set<string>()
315
+ const reverseEntries: Array<LoadedEntry> = []
316
+ let cursor = latestHead
317
+ while (cursor !== undefined) {
318
+ if (reverseEntries.length >= input.maximum || visitedHeads.has(cursor.versionId)) {
319
+ return yield* JournalIntegrityError.make({ reason: "Journal head chain is cyclic or exceeds its bound." })
320
+ }
321
+ visitedHeads.add(cursor.versionId)
322
+ const headObject = objects.get(objectIdentity(cursor.key, cursor.versionId))!
323
+ const head = yield* pureIntegrity(() => decodeJournalHead(headObject.bytes))
324
+ if (head.releasePoint !== operation.releasePoint || head.operationKey !== operation.operationKey) {
325
+ return yield* JournalIntegrityError.make({ reason: "Journal head belongs to a different operation." })
326
+ }
327
+ const eventObject = objects.get(objectIdentity(head.eventKey, head.eventVersionId))
328
+ if (eventObject === undefined) {
329
+ return yield* JournalIntegrityError.make({ reason: "Journal head references a missing exact event version." })
330
+ }
331
+ const event = yield* pureIntegrity(() => decodeJournalEvent(eventObject.bytes))
332
+ if (event.releasePoint !== operation.releasePoint || event.operationKey !== operation.operationKey ||
333
+ event.sequence !== head.sequence || event.transactionId !== head.transactionId ||
334
+ journalEventKey(operation, event.sequence, event.transactionId) !== head.eventKey ||
335
+ eventObject.checksumSha256 !== head.eventChecksumSha256 || eventDigest(eventObject.bytes) !== head.eventDigest) {
336
+ return yield* JournalIntegrityError.make({ reason: "Journal head and event do not form one exact canonical record." })
337
+ }
338
+ if (event.workflow.repositoryId !== authority.oidc.repositoryId) {
339
+ return yield* JournalIntegrityError.make({
340
+ reason: "Journal event workflow repository does not equal the re-observed authority."
341
+ })
342
+ }
343
+ reverseEntries.push({ headObject, head, eventObject, event })
344
+ if (head.previousHeadVersionId === null || head.previousHeadEtag === null) {
345
+ if (head.previousHeadVersionId !== null || head.previousHeadEtag !== null || event.previous !== null) {
346
+ return yield* JournalIntegrityError.make({ reason: "Journal root predecessor is only partially empty." })
347
+ }
348
+ cursor = undefined
349
+ } else {
350
+ const previous = headByVersion.get(head.previousHeadVersionId)
351
+ if (previous === undefined || previous.etag !== head.previousHeadEtag || event.previous === null ||
352
+ event.previous.headVersionId !== head.previousHeadVersionId || event.previous.headEtag !== head.previousHeadEtag) {
353
+ return yield* JournalIntegrityError.make({ reason: "Journal predecessor does not identify one retained head version." })
354
+ }
355
+ cursor = previous
356
+ }
357
+ }
358
+ if (visitedHeads.size !== headMetadata.length) {
359
+ return yield* JournalIntegrityError.make({ reason: "Journal contains an unreachable head version." })
360
+ }
361
+ const entries = reverseEntries.reverse()
362
+ for (let index = 0; index < entries.length; index += 1) {
363
+ const entry = entries[index]!
364
+ const previous = entries[index - 1]
365
+ if (previous === undefined) {
366
+ if (entry.event.sequence !== 1 || entry.event.previous !== null) {
367
+ return yield* JournalIntegrityError.make({ reason: "Journal first event is not a canonical root." })
368
+ }
369
+ } else if (entry.event.previous === null ||
370
+ entry.event.previous.eventDigest !== previous.head.eventDigest ||
371
+ entry.event.previous.headVersionId !== previous.headObject.versionId ||
372
+ entry.event.previous.headEtag !== previous.headObject.etag) {
373
+ return yield* JournalIntegrityError.make({ reason: "Journal event chain does not bind the preceding exact head." })
374
+ }
375
+ }
376
+ const state = yield* pureIntegrity(() => reduceJournalEvents(entries.map((entry) => entry.event)))
377
+ const reachableEvents = new Set(entries.map((entry) => objectIdentity(entry.eventObject.key, entry.eventObject.versionId)))
378
+ const reachableTransactions = new Set(entries.map((entry) => entry.event.transactionId))
379
+ const entryByHeadVersion = new Map(entries.map((entry) => [entry.headObject.versionId, entry] as const))
380
+ const orphans: Array<LoadedOrphan> = []
381
+ for (const metadata of eventMetadata) {
382
+ if (reachableEvents.has(objectIdentity(metadata.key, metadata.versionId))) continue
383
+ const eventObject = objects.get(objectIdentity(metadata.key, metadata.versionId))!
384
+ const event = yield* pureIntegrity(() => decodeJournalEvent(eventObject.bytes))
385
+ if (event.releasePoint !== operation.releasePoint || event.operationKey !== operation.operationKey ||
386
+ journalEventKey(operation, event.sequence, event.transactionId) !== eventObject.key) {
387
+ return yield* JournalIntegrityError.make({ reason: "Unreachable journal event is not a canonical attempt for this operation." })
388
+ }
389
+ if (event.workflow.repositoryId !== authority.oidc.repositoryId) {
390
+ return yield* JournalIntegrityError.make({
391
+ reason: "Journal orphan workflow repository does not equal the re-observed authority."
392
+ })
393
+ }
394
+ let predecessorState: JournalSnapshot["state"] = "Empty"
395
+ if (event.previous === null) {
396
+ if (event.sequence !== 1) return yield* JournalIntegrityError.make({ reason: "Orphan root event has a non-root sequence." })
397
+ } else {
398
+ const predecessor = entryByHeadVersion.get(event.previous.headVersionId)
399
+ if (predecessor === undefined || predecessor.headObject.etag !== event.previous.headEtag ||
400
+ predecessor.head.eventDigest !== event.previous.eventDigest || event.sequence !== predecessor.event.sequence + 1) {
401
+ return yield* JournalIntegrityError.make({ reason: "Orphan event does not bind one reachable predecessor." })
402
+ }
403
+ predecessorState = predecessor.event.record.tag
404
+ }
405
+ yield* pureIntegrity(() => admitJournalTransition(predecessorState, event.record.tag))
406
+ orphans.push({ eventObject, event })
407
+ }
408
+ const logicalAttemptByTransaction = new Map<string, CanonicalJournalEvent>()
409
+ for (const event of [
410
+ ...entries.map((entry) => entry.event),
411
+ ...orphans.map((orphan) => orphan.event)
412
+ ]) {
413
+ const existing = logicalAttemptByTransaction.get(event.transactionId)
414
+ if (existing !== undefined && !logicalRecordEquals(existing, event)) {
415
+ return yield* JournalIntegrityError.make({
416
+ reason: "One journal transaction ID carries divergent retained logical records."
417
+ })
418
+ }
419
+ logicalAttemptByTransaction.set(event.transactionId, event)
420
+ }
421
+ if (!input.allowStandaloneOrphans && orphans.some((orphan) => !reachableTransactions.has(orphan.event.transactionId))) {
422
+ return yield* JournalIntegrityError.make({ reason: "Journal contains an unacknowledged event transaction; explicit reconciliation is required." })
423
+ }
424
+ return { authority, operation, entries, orphans, state }
425
+ })
426
+
427
+ const acknowledgement = (loaded: LoadedJournal, entry: LoadedEntry): JournalAcknowledgement => ({
428
+ bucketArn: loaded.authority.bucketArn,
429
+ prefix: journalNamespace(loaded.operation),
430
+ releasePoint: loaded.operation.releasePoint,
431
+ operationKey: loaded.operation.operationKey,
432
+ eventKey: entry.eventObject.key,
433
+ eventVersionId: entry.eventObject.versionId,
434
+ eventChecksumSha256: entry.eventObject.checksumSha256,
435
+ headKey: entry.headObject.key,
436
+ headVersionId: entry.headObject.versionId,
437
+ headEtag: entry.headObject.etag,
438
+ headChecksumSha256: entry.headObject.checksumSha256,
439
+ recordDigest: entry.head.eventDigest,
440
+ sequence: entry.event.sequence,
441
+ transactionId: entry.event.transactionId,
442
+ previousHeadVersionId: entry.head.previousHeadVersionId,
443
+ previousHeadEtag: entry.head.previousHeadEtag
444
+ })
445
+
446
+ const snapshot = (loaded: LoadedJournal): JournalSnapshot => {
447
+ const current = loaded.entries.at(-1)
448
+ return {
449
+ ...loaded.operation,
450
+ state: loaded.state,
451
+ records: loaded.entries.map((entry) => journalRecordFromEvent(entry.event)),
452
+ acknowledgement: current === undefined ? null : acknowledgement(loaded, current)
453
+ }
454
+ }
455
+
456
+ const recoverExactEvent = (input: {
457
+ readonly loaded: LoadedJournal
458
+ readonly client: S3JournalBoundaryShape
459
+ readonly key: string
460
+ readonly bytes: Uint8Array
461
+ readonly maximum: number
462
+ }): Effect.Effect<S3JournalObjectVersion | null, JournalIntegrityError | JournalStorageOutcomeUnknown> => Effect.gen(function*() {
463
+ const listing = yield* listOperation(input.loaded.authority, input.loaded.operation, input.maximum, input.client, true)
464
+ if (listing.truncated || listing.deleteMarkers.length > 0) {
465
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Event response loss could not be reconciled within the bounded namespace." })
466
+ }
467
+ const versions = listing.versions.filter((version) => version.key === input.key)
468
+ if (versions.length === 0) return null
469
+ if (versions.length !== 1) return yield* JournalIntegrityError.make({ reason: "Immutable event key has more than one version." })
470
+ const metadata = versions[0]!
471
+ const object = yield* getObject(input.loaded.authority, metadata.key, metadata.versionId, input.client, true)
472
+ yield* pureIntegrity(() => validateRetainedObject(object, metadata.key, metadata.versionId, metadata.etag))
473
+ if (!sameBytes(object.bytes, input.bytes)) {
474
+ return yield* JournalIntegrityError.make({ reason: "Existing event key contains different canonical bytes." })
475
+ }
476
+ return object
477
+ })
478
+
479
+ const capturePut = (
480
+ effect: Effect.Effect<S3JournalPutResult, S3JournalBoundaryError>
481
+ ): Effect.Effect<CapturedPut> => effect.pipe(
482
+ Effect.map((result) => ({ _tag: "Result", result } as const)),
483
+ Effect.catch((error) => Effect.succeed({ _tag: "Error", error } as const))
484
+ )
485
+
486
+ type CapturedPut =
487
+ | { readonly _tag: "Result", readonly result: S3JournalPutResult }
488
+ | { readonly _tag: "Error", readonly error: S3JournalBoundaryError }
489
+
490
+ const writeEvent = (input: {
491
+ readonly loaded: LoadedJournal
492
+ readonly client: S3JournalBoundaryShape
493
+ readonly event: CanonicalJournalEvent
494
+ readonly maximum: number
495
+ }): Effect.Effect<S3JournalObjectVersion, JournalIntegrityError | JournalStorageOutcomeUnknown | JournalConflict> => Effect.gen(function*() {
496
+ const key = journalEventKey(input.loaded.operation, input.event.sequence, input.event.transactionId)
497
+ const bytes = encodeJournalEvent(input.event)
498
+ const request: S3JournalPutRequest = {
499
+ bucketName: input.loaded.authority.bucketName,
500
+ expectedBucketOwner: input.loaded.authority.expectedBucketOwner,
501
+ region: input.loaded.authority.region,
502
+ key,
503
+ bytes,
504
+ checksumSha256: objectChecksum(bytes),
505
+ condition: { _tag: "IfNoneMatch" }
506
+ }
507
+ for (let attempt = 0; attempt < 2; attempt += 1) {
508
+ const outcome = yield* capturePut(input.client.putEvent(request))
509
+ if (outcome._tag === "Result" && outcome.result._tag === "Committed") {
510
+ const committed = outcome.result
511
+ const object = yield* getObject(
512
+ input.loaded.authority,
513
+ key,
514
+ committed.versionId,
515
+ input.client,
516
+ true
517
+ )
518
+ yield* pureIntegrity(() => validateRetainedObject(object, key, committed.versionId, committed.etag))
519
+ if (object.checksumSha256 !== committed.checksumSha256 || !sameBytes(object.bytes, bytes)) {
520
+ return yield* JournalIntegrityError.make({ reason: "Committed event failed its immediate exact-version re-read." })
521
+ }
522
+ return object
523
+ }
524
+ const recovered = yield* recoverExactEvent({ ...input, key, bytes })
525
+ if (recovered !== null) return recovered
526
+ if (outcome._tag === "Result" && outcome.result._tag === "PreconditionFailed") {
527
+ return yield* JournalConflict.make({ reason: "Event conditional create conflicted without an exact recoverable version." })
528
+ }
529
+ if (outcome._tag === "Error" && outcome.error.commitment === "unknown" && attempt === 0) continue
530
+ if (outcome._tag === "Error" && outcome.error.commitment === "not-committed" && attempt === 0) continue
531
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Event write has no acknowledged or authoritatively absent terminal outcome." })
532
+ }
533
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Event write exceeded its bounded reconciliation." })
534
+ })
535
+
536
+ const putHeadOnce = (input: {
537
+ readonly loaded: LoadedJournal
538
+ readonly client: S3JournalBoundaryShape
539
+ readonly event: CanonicalJournalEvent
540
+ readonly eventObject: S3JournalObjectVersion
541
+ }): Effect.Effect<{
542
+ readonly outcome: CapturedPut
543
+ readonly head: CanonicalJournalHead
544
+ }, never> => {
545
+ const previous = input.loaded.entries.at(-1)
546
+ const head = makeJournalHead({
547
+ event: input.event,
548
+ eventKey: input.eventObject.key,
549
+ eventVersionId: input.eventObject.versionId,
550
+ eventChecksumSha256: input.eventObject.checksumSha256,
551
+ eventDigest: eventDigest(input.eventObject.bytes),
552
+ previousHeadVersionId: previous?.headObject.versionId ?? null,
553
+ previousHeadEtag: previous?.headObject.etag ?? null
554
+ })
555
+ const bytes = encodeJournalHead(head)
556
+ const request: S3JournalPutRequest = {
557
+ bucketName: input.loaded.authority.bucketName,
558
+ expectedBucketOwner: input.loaded.authority.expectedBucketOwner,
559
+ region: input.loaded.authority.region,
560
+ key: journalHeadKey(input.loaded.operation),
561
+ bytes,
562
+ checksumSha256: objectChecksum(bytes),
563
+ condition: previous === undefined
564
+ ? { _tag: "IfNoneMatch" }
565
+ : { _tag: "IfMatch", etag: previous.headObject.etag }
566
+ }
567
+ return capturePut(input.client.putHead(request)).pipe(Effect.map((outcome) => ({ outcome, head })))
568
+ }
569
+
570
+ const logicalRecordEquals = (left: CanonicalJournalEvent, right: CanonicalJournalEvent): boolean =>
571
+ encodeCanonicalJson(left.record) === encodeCanonicalJson(right.record) &&
572
+ encodeCanonicalJson(left.workflow) === encodeCanonicalJson(right.workflow)
573
+
574
+ const commitTransaction = (input: {
575
+ readonly loaded: LoadedJournal
576
+ readonly client: S3JournalBoundaryShape
577
+ readonly record: CanonicalJournalRecord
578
+ readonly workflow: CanonicalJournalEvent["workflow"]
579
+ readonly transactionId: string
580
+ readonly maximum: number
581
+ readonly allowRebase: boolean
582
+ }): Effect.Effect<JournalAcknowledgement, JournalAppendError> => Effect.gen(function*() {
583
+ const current = input.loaded.entries.at(-1)
584
+ yield* pureTransition(() => admitJournalTransition(input.loaded.state, input.record.tag))
585
+ const event = makeJournalEvent({
586
+ releasePoint: input.loaded.operation.releasePoint,
587
+ operationKey: input.loaded.operation.operationKey,
588
+ sequence: (current?.event.sequence ?? 0) + 1,
589
+ transactionId: input.transactionId,
590
+ previous: current?.event ?? null,
591
+ previousHead: current === undefined ? null : {
592
+ versionId: current.headObject.versionId,
593
+ etag: current.headObject.etag,
594
+ eventDigest: current.head.eventDigest
595
+ },
596
+ workflow: input.workflow,
597
+ record: input.record
598
+ })
599
+ const eventObject = yield* writeEvent({ ...input, event })
600
+ const first = yield* putHeadOnce({ ...input, event, eventObject })
601
+ if (first.outcome._tag === "Result" && first.outcome.result._tag === "Committed") {
602
+ const verified = yield* loadJournal({
603
+ expectedAuthority: input.loaded.authority,
604
+ client: input.client,
605
+ operation: input.loaded.operation,
606
+ maximum: input.maximum,
607
+ allowStandaloneOrphans: false,
608
+ afterMutation: true
609
+ })
610
+ const entry = verified.entries.find((candidate) => candidate.event.transactionId === input.transactionId)
611
+ if (entry === undefined) {
612
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Head response succeeded but exact transaction is not reachable after re-read." })
613
+ }
614
+ return acknowledgement(verified, entry)
615
+ }
616
+
617
+ let recovered = yield* loadJournal({
618
+ expectedAuthority: input.loaded.authority,
619
+ client: input.client,
620
+ operation: input.loaded.operation,
621
+ maximum: input.maximum,
622
+ allowStandaloneOrphans: true,
623
+ afterMutation: true
624
+ })
625
+ const reached = recovered.entries.find((candidate) => candidate.event.transactionId === input.transactionId)
626
+ if (reached !== undefined) return acknowledgement(recovered, reached)
627
+ const recoveredCurrent = recovered.entries.at(-1)
628
+ const oldCurrent = input.loaded.entries.at(-1)
629
+ const predecessorUnchanged = recoveredCurrent?.headObject.versionId === oldCurrent?.headObject.versionId &&
630
+ recoveredCurrent?.headObject.etag === oldCurrent?.headObject.etag &&
631
+ (recoveredCurrent !== undefined) === (oldCurrent !== undefined)
632
+
633
+ if (predecessorUnchanged) {
634
+ const retry = yield* putHeadOnce({ loaded: recovered, client: input.client, event, eventObject })
635
+ recovered = yield* loadJournal({
636
+ expectedAuthority: input.loaded.authority,
637
+ client: input.client,
638
+ operation: input.loaded.operation,
639
+ maximum: input.maximum,
640
+ allowStandaloneOrphans: true,
641
+ afterMutation: true
642
+ })
643
+ const retried = recovered.entries.find((candidate) => candidate.event.transactionId === input.transactionId)
644
+ if (retried !== undefined) return acknowledgement(recovered, retried)
645
+ if (retry.outcome._tag === "Error" || retry.outcome.result._tag === "PreconditionFailed") {
646
+ if (!input.allowRebase) {
647
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Head CAS did not acknowledge the transaction within its bounded retry." })
648
+ }
649
+ }
650
+ const afterRetry = recovered.entries.at(-1)
651
+ const stillUnchanged = afterRetry?.headObject.versionId === oldCurrent?.headObject.versionId &&
652
+ afterRetry?.headObject.etag === oldCurrent?.headObject.etag &&
653
+ (afterRetry !== undefined) === (oldCurrent !== undefined)
654
+ if (stillUnchanged) {
655
+ return yield* JournalStorageOutcomeUnknown.make({
656
+ reason: "Head CAS remained authoritatively absent after its one bounded retry."
657
+ })
658
+ }
659
+ }
660
+
661
+ if (!input.allowRebase) {
662
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Head transaction is absent after its bounded reconciliation." })
663
+ }
664
+ yield* pureTransition(() => admitJournalTransition(recovered.state, input.record.tag)).pipe(Effect.mapError((error) =>
665
+ error instanceof JournalTransitionError
666
+ ? JournalConflict.make({ reason: "A concurrent journal transition made this append inadmissible." })
667
+ : error
668
+ ))
669
+ const rebasedCurrent = recovered.entries.at(-1)
670
+ const rebasedEvent = makeJournalEvent({
671
+ releasePoint: recovered.operation.releasePoint,
672
+ operationKey: recovered.operation.operationKey,
673
+ sequence: (rebasedCurrent?.event.sequence ?? 0) + 1,
674
+ transactionId: input.transactionId,
675
+ previous: rebasedCurrent?.event ?? null,
676
+ previousHead: rebasedCurrent === undefined ? null : {
677
+ versionId: rebasedCurrent.headObject.versionId,
678
+ etag: rebasedCurrent.headObject.etag,
679
+ eventDigest: rebasedCurrent.head.eventDigest
680
+ },
681
+ workflow: input.workflow,
682
+ record: input.record
683
+ })
684
+ const rebasedObject = yield* writeEvent({ loaded: recovered, client: input.client, event: rebasedEvent, maximum: input.maximum })
685
+ yield* putHeadOnce({ loaded: recovered, client: input.client, event: rebasedEvent, eventObject: rebasedObject })
686
+ const terminal = yield* loadJournal({
687
+ expectedAuthority: input.loaded.authority,
688
+ client: input.client,
689
+ operation: input.loaded.operation,
690
+ maximum: input.maximum,
691
+ allowStandaloneOrphans: true,
692
+ afterMutation: true
693
+ })
694
+ const committed = terminal.entries.find((candidate) => candidate.event.transactionId === input.transactionId)
695
+ if (committed === undefined) {
696
+ return yield* JournalStorageOutcomeUnknown.make({ reason: "Rebased head CAS did not acknowledge the transaction." })
697
+ }
698
+ return acknowledgement(terminal, committed)
699
+ })
700
+
701
+ const orphanBindsCurrent = (loaded: LoadedJournal, orphan: LoadedOrphan): boolean => {
702
+ const current = loaded.entries.at(-1)
703
+ if (current === undefined) {
704
+ return orphan.event.sequence === 1 && orphan.event.previous === null
705
+ }
706
+ return orphan.event.sequence === current.event.sequence + 1 && orphan.event.previous !== null &&
707
+ orphan.event.previous.headVersionId === current.headObject.versionId &&
708
+ orphan.event.previous.headEtag === current.headObject.etag &&
709
+ orphan.event.previous.eventDigest === current.head.eventDigest
710
+ }
711
+
712
+ const acknowledgeExistingOrphan = (input: {
713
+ readonly loaded: LoadedJournal
714
+ readonly orphan: LoadedOrphan
715
+ readonly client: S3JournalBoundaryShape
716
+ readonly maximum: number
717
+ }): Effect.Effect<LoadedJournal, JournalAppendError> => Effect.gen(function*() {
718
+ yield* putHeadOnce({
719
+ loaded: input.loaded,
720
+ client: input.client,
721
+ event: input.orphan.event,
722
+ eventObject: input.orphan.eventObject
723
+ })
724
+ let recovered = yield* loadJournal({
725
+ expectedAuthority: input.loaded.authority,
726
+ client: input.client,
727
+ operation: input.loaded.operation,
728
+ maximum: input.maximum,
729
+ allowStandaloneOrphans: true,
730
+ afterMutation: true
731
+ })
732
+ if (recovered.entries.some((entry) => entry.event.transactionId === input.orphan.event.transactionId)) {
733
+ return recovered
734
+ }
735
+ if (!orphanBindsCurrent(recovered, input.orphan)) {
736
+ yield* commitTransaction({
737
+ loaded: recovered,
738
+ client: input.client,
739
+ record: input.orphan.event.record,
740
+ workflow: input.orphan.event.workflow,
741
+ transactionId: input.orphan.event.transactionId,
742
+ maximum: input.maximum,
743
+ allowRebase: true
744
+ })
745
+ return yield* loadJournal({
746
+ expectedAuthority: input.loaded.authority,
747
+ client: input.client,
748
+ operation: input.loaded.operation,
749
+ maximum: input.maximum,
750
+ allowStandaloneOrphans: false,
751
+ afterMutation: true
752
+ })
753
+ }
754
+ yield* putHeadOnce({
755
+ loaded: recovered,
756
+ client: input.client,
757
+ event: input.orphan.event,
758
+ eventObject: input.orphan.eventObject
759
+ })
760
+ recovered = yield* loadJournal({
761
+ expectedAuthority: input.loaded.authority,
762
+ client: input.client,
763
+ operation: input.loaded.operation,
764
+ maximum: input.maximum,
765
+ allowStandaloneOrphans: true,
766
+ afterMutation: true
767
+ })
768
+ if (!recovered.entries.some((entry) => entry.event.transactionId === input.orphan.event.transactionId)) {
769
+ return yield* JournalStorageOutcomeUnknown.make({
770
+ reason: "Existing orphan event did not acquire an acknowledged head within its bounded CAS retry."
771
+ })
772
+ }
773
+ return recovered
774
+ })
775
+
776
+ const reconcileLoaded = (input: {
777
+ readonly loaded: LoadedJournal
778
+ readonly client: S3JournalBoundaryShape
779
+ readonly maximum: number
780
+ }): Effect.Effect<LoadedJournal, JournalAppendError> => Effect.gen(function*() {
781
+ const reachableTransactions = new Set(input.loaded.entries.map((entry) => entry.event.transactionId))
782
+ const standalone = input.loaded.orphans.filter((orphan) => !reachableTransactions.has(orphan.event.transactionId))
783
+ if (standalone.length === 0) return input.loaded
784
+ const transactions = new Map<string, Array<LoadedOrphan>>()
785
+ for (const orphan of standalone) {
786
+ const attempts = transactions.get(orphan.event.transactionId) ?? []
787
+ attempts.push(orphan)
788
+ transactions.set(orphan.event.transactionId, attempts)
789
+ }
790
+ if (transactions.size !== 1) {
791
+ return yield* JournalConflict.make({ reason: "More than one unacknowledged journal transaction requires adjudication." })
792
+ }
793
+ const [transactionId, attempts] = [...transactions.entries()][0]!
794
+ const basis = attempts[0]!
795
+ if (attempts.some((attempt) => !logicalRecordEquals(basis.event, attempt.event))) {
796
+ return yield* JournalIntegrityError.make({ reason: "One orphan transaction carries multiple logical records." })
797
+ }
798
+ const directlyAdoptable = attempts.filter((attempt) => orphanBindsCurrent(input.loaded, attempt))
799
+ if (directlyAdoptable.length > 1) {
800
+ return yield* JournalIntegrityError.make({ reason: "One orphan transaction has multiple attempts for the current predecessor." })
801
+ }
802
+ if (directlyAdoptable.length === 1) {
803
+ yield* acknowledgeExistingOrphan({
804
+ loaded: input.loaded,
805
+ orphan: directlyAdoptable[0]!,
806
+ client: input.client,
807
+ maximum: input.maximum
808
+ })
809
+ } else {
810
+ yield* commitTransaction({
811
+ loaded: input.loaded,
812
+ client: input.client,
813
+ record: basis.event.record,
814
+ workflow: basis.event.workflow,
815
+ transactionId,
816
+ maximum: input.maximum,
817
+ allowRebase: true
818
+ })
819
+ }
820
+ return yield* loadJournal({
821
+ expectedAuthority: input.loaded.authority,
822
+ client: input.client,
823
+ operation: input.loaded.operation,
824
+ maximum: input.maximum,
825
+ allowStandaloneOrphans: false,
826
+ afterMutation: true
827
+ })
828
+ })
829
+
830
+ export interface S3CanonicalOperationJournalOptions {
831
+ readonly authority: S3JournalAuthority
832
+ readonly client: S3JournalBoundaryShape
833
+ }
834
+
835
+ export const makeS3CanonicalOperationJournal = (
836
+ options: S3CanonicalOperationJournalOptions
837
+ ): CanonicalOperationJournalShape => {
838
+ const maximum = 512
839
+
840
+ const load = (
841
+ operation: JournalOperation,
842
+ allowStandaloneOrphans: boolean
843
+ ) => loadJournal({
844
+ expectedAuthority: options.authority,
845
+ client: options.client,
846
+ operation,
847
+ maximum,
848
+ allowStandaloneOrphans
849
+ })
850
+
851
+ return {
852
+ read: (operation) => load(operation, false).pipe(
853
+ Effect.mapError((error) => error instanceof JournalStorageOutcomeUnknown
854
+ ? JournalStorageUnavailable.make({ reason: error.reason })
855
+ : error),
856
+ Effect.map(snapshot)
857
+ ),
858
+ reconcile: (operation) => Effect.gen(function*() {
859
+ const loaded = yield* load(operation, true)
860
+ return snapshot(yield* reconcileLoaded({ loaded, client: options.client, maximum }))
861
+ }),
862
+ append: (request: JournalAppendRequest) => Effect.gen(function*() {
863
+ const operation = yield* pureInput(() => validateJournalOperation(request))
864
+ const record = yield* pureInput(() => makeCanonicalJournalRecord(request))
865
+ const transactionId = yield* pureInput(() => validateTransactionId(randomUUID()))
866
+ const initial = yield* load(operation, true)
867
+ const loaded = yield* reconcileLoaded({ loaded: initial, client: options.client, maximum })
868
+ const workflow = yield* pureInput(() => validateJournalWorkflow({
869
+ repositoryId: loaded.authority.oidc.repositoryId,
870
+ runId: loaded.authority.oidc.runId,
871
+ runAttempt: loaded.authority.oidc.runAttempt
872
+ }))
873
+ return yield* commitTransaction({
874
+ loaded,
875
+ client: options.client,
876
+ record,
877
+ workflow,
878
+ transactionId,
879
+ maximum,
880
+ allowRebase: true
881
+ })
882
+ })
883
+ }
884
+ }