@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.
- package/ARCHITECTURE.md +60 -0
- package/CHANGELOG.md +82 -0
- package/README.md +129 -6
- package/SPEC.md +51 -0
- package/apps/release-ts/README.md +7 -0
- package/apps/ts-release-agents/package.json +1 -1
- package/apps/ts-release-agents/src/check.ts +7 -2
- package/apps/ts-release-agents/test/contract.test.ts +2 -2
- package/dist/bin/ts-release.js +154 -47
- package/dist/drivers/archive.d.ts +2 -1
- package/dist/drivers/archive.d.ts.map +1 -1
- package/dist/drivers/archive.js +12 -8
- package/dist/drivers/archive.js.map +1 -1
- package/dist/drivers/process.d.ts.map +1 -1
- package/dist/drivers/process.js +4 -4
- package/dist/drivers/process.js.map +1 -1
- package/dist/drivers/python-wheel.d.ts +21 -0
- package/dist/drivers/python-wheel.d.ts.map +1 -0
- package/dist/drivers/python-wheel.js +109 -0
- package/dist/drivers/python-wheel.js.map +1 -0
- package/dist/drivers/workspace.d.ts +3 -0
- package/dist/drivers/workspace.d.ts.map +1 -1
- package/dist/drivers/workspace.js +5 -1
- package/dist/drivers/workspace.js.map +1 -1
- package/dist/operation-journal/authority.d.ts +3 -0
- package/dist/operation-journal/authority.d.ts.map +1 -0
- package/dist/operation-journal/authority.js +147 -0
- package/dist/operation-journal/authority.js.map +1 -0
- package/dist/operation-journal/aws/deadline.d.ts +12 -0
- package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
- package/dist/operation-journal/aws/deadline.js +38 -0
- package/dist/operation-journal/aws/deadline.js.map +1 -0
- package/dist/operation-journal/aws/oidc.d.ts +27 -0
- package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
- package/dist/operation-journal/aws/oidc.js +361 -0
- package/dist/operation-journal/aws/oidc.js.map +1 -0
- package/dist/operation-journal/aws/policy.d.ts +42 -0
- package/dist/operation-journal/aws/policy.d.ts.map +1 -0
- package/dist/operation-journal/aws/policy.js +404 -0
- package/dist/operation-journal/aws/policy.js.map +1 -0
- package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
- package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
- package/dist/operation-journal/aws/s3-boundary.js +417 -0
- package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
- package/dist/operation-journal/aws.d.ts +11 -0
- package/dist/operation-journal/aws.d.ts.map +1 -0
- package/dist/operation-journal/aws.js +50 -0
- package/dist/operation-journal/aws.js.map +1 -0
- package/dist/operation-journal/canonical.d.ts +52 -0
- package/dist/operation-journal/canonical.d.ts.map +1 -0
- package/dist/operation-journal/canonical.js +282 -0
- package/dist/operation-journal/canonical.js.map +1 -0
- package/dist/operation-journal/model.d.ts +251 -0
- package/dist/operation-journal/model.d.ts.map +1 -0
- package/dist/operation-journal/model.js +56 -0
- package/dist/operation-journal/model.js.map +1 -0
- package/dist/operation-journal/reducer.d.ts +28 -0
- package/dist/operation-journal/reducer.d.ts.map +1 -0
- package/dist/operation-journal/reducer.js +73 -0
- package/dist/operation-journal/reducer.js.map +1 -0
- package/dist/operation-journal/s3.d.ts +7 -0
- package/dist/operation-journal/s3.d.ts.map +1 -0
- package/dist/operation-journal/s3.js +667 -0
- package/dist/operation-journal/s3.js.map +1 -0
- package/dist/operation-journal.d.ts +14 -0
- package/dist/operation-journal.d.ts.map +1 -0
- package/dist/operation-journal.js +12 -0
- package/dist/operation-journal.js.map +1 -0
- package/dist/platform/credentials.d.ts.map +1 -1
- package/dist/platform/credentials.js +65 -3
- package/dist/platform/credentials.js.map +1 -1
- package/dist/publication/github.d.ts.map +1 -1
- package/dist/publication/github.js +66 -15
- package/dist/publication/github.js.map +1 -1
- package/dist/release/capabilities.d.ts.map +1 -1
- package/dist/release/capabilities.js +2 -2
- package/dist/release/capabilities.js.map +1 -1
- package/dist/release/prepare.d.ts.map +1 -1
- package/dist/release/prepare.js +4 -5
- package/dist/release/prepare.js.map +1 -1
- package/dist/release/prepared-store.d.ts.map +1 -1
- package/dist/release/prepared-store.js +11 -9
- package/dist/release/prepared-store.js.map +1 -1
- package/package.json +19 -4
- package/src/drivers/archive.ts +17 -8
- package/src/drivers/process.ts +3 -4
- package/src/drivers/python-wheel.ts +145 -0
- package/src/drivers/workspace.ts +7 -1
- package/src/operation-journal/authority.ts +162 -0
- package/src/operation-journal/aws/deadline.ts +41 -0
- package/src/operation-journal/aws/oidc.ts +439 -0
- package/src/operation-journal/aws/policy.ts +491 -0
- package/src/operation-journal/aws/s3-boundary.ts +562 -0
- package/src/operation-journal/aws.ts +60 -0
- package/src/operation-journal/canonical.ts +373 -0
- package/src/operation-journal/model.ts +301 -0
- package/src/operation-journal/reducer.ts +112 -0
- package/src/operation-journal/s3.ts +884 -0
- package/src/operation-journal.ts +51 -0
- package/src/platform/credentials.ts +71 -3
- package/src/publication/github.ts +83 -15
- package/src/release/capabilities.ts +3 -2
- package/src/release/prepare.ts +3 -5
- package/src/release/prepared-store.ts +8 -7
- package/templates/README.md +2 -2
- package/templates/github-actions/release.yml +24 -7
- 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
|
+
}
|