@aldus-runtime/release 0.2.0-next.4 → 0.2.0-next.40
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/dist/adapter.d.ts +75 -6
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +53 -1
- package/dist/adapter.js.map +1 -1
- package/dist/bundle.d.ts +12 -1
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +44 -2
- package/dist/bundle.js.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -1
- package/dist/executor.d.ts +15 -2
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +164 -5
- package/dist/executor.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/operation.d.ts +46 -5
- package/dist/operation.d.ts.map +1 -1
- package/dist/operation.js +22 -0
- package/dist/operation.js.map +1 -1
- package/dist/ports.d.ts +1 -1
- package/package.json +5 -5
- package/src/adapter.ts +135 -9
- package/src/bundle.ts +50 -2
- package/src/errors.ts +7 -0
- package/src/executor.ts +197 -7
- package/src/index.ts +5 -0
- package/src/operation.ts +64 -0
- package/src/ports.ts +1 -1
package/src/adapter.ts
CHANGED
|
@@ -40,6 +40,21 @@ export type AdapterOutcome =
|
|
|
40
40
|
remoteId?: string;
|
|
41
41
|
/** Address of the released item, where one is meaningful. */
|
|
42
42
|
remoteUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Something an operator needs to know about a success (§20; #169 item 4).
|
|
45
|
+
*
|
|
46
|
+
* `failed` has `message` and `pending` has `message?`; `succeeded` had nowhere to put a
|
|
47
|
+
* sentence at all. An adapter that removed a marker from one video of several, or that found
|
|
48
|
+
* no marker and therefore removed nothing, succeeded — and had to discard the only part an
|
|
49
|
+
* operator would have wanted.
|
|
50
|
+
*
|
|
51
|
+
* **Not a channel for a qualified absence.** An adapter that cannot establish remote state
|
|
52
|
+
* returns {@link cannotEstablish} from `lookup`; using this to footnote a fabricated
|
|
53
|
+
* `exists: false` would be the same false record with better documentation (#169 item 3).
|
|
54
|
+
*
|
|
55
|
+
* Already redacted by the adapter (§19.2), like every other adapter-supplied string.
|
|
56
|
+
*/
|
|
57
|
+
note?: string;
|
|
43
58
|
}
|
|
44
59
|
| {
|
|
45
60
|
status: "failed";
|
|
@@ -61,14 +76,83 @@ export type AdapterOutcome =
|
|
|
61
76
|
message?: string;
|
|
62
77
|
};
|
|
63
78
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
/** @see CannotEstablish */
|
|
80
|
+
declare const CANNOT_ESTABLISH: unique symbol;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Authorities this process minted. A cast cannot manufacture membership.
|
|
84
|
+
*
|
|
85
|
+
* The same runtime proof `SynthesisPermit` uses, and for the same reason: the brand above is a
|
|
86
|
+
* phantom, absent at runtime, so a `RemoteState` narrowing that trusted it would trust a type
|
|
87
|
+
* assertion. #170 has the longer version of this argument — a check that assumes the type it is
|
|
88
|
+
* checking is not a check.
|
|
89
|
+
*/
|
|
90
|
+
const ISSUED_CANNOT_ESTABLISH = new WeakSet<object>();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The destination could not be queried, so whether it holds the operation is unknown (#169).
|
|
94
|
+
*
|
|
95
|
+
* Distinct from `exists: false` in the way that matters: **`exists: false` is the answer only a
|
|
96
|
+
* completed search can give.** Before this, an adapter that could not establish the answer had two
|
|
97
|
+
* options — return `false` and assert a search nobody performed, or throw and abort the whole
|
|
98
|
+
* reconciliation pass before a single operation executed. Both were forced, and adopters took the
|
|
99
|
+
* first.
|
|
100
|
+
*
|
|
101
|
+
* Minted by {@link cannotEstablish} and not assemblable, because a state that suppresses a
|
|
102
|
+
* publish-safety check must not be reachable by writing an object literal.
|
|
103
|
+
*/
|
|
104
|
+
export interface CannotEstablish {
|
|
105
|
+
readonly [CANNOT_ESTABLISH]: "cannot_establish";
|
|
106
|
+
/**
|
|
107
|
+
* Why the answer could not be established, for the operator (§20).
|
|
108
|
+
*
|
|
109
|
+
* **Required.** An adapter that cannot establish something and cannot say why has produced a
|
|
110
|
+
* finding nobody can act on — a rate limit, a permission, and a destination that does not retain
|
|
111
|
+
* what would identify the operation are three different problems with three different responses.
|
|
112
|
+
*/
|
|
113
|
+
readonly reason: string;
|
|
70
114
|
}
|
|
71
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Declare that the destination could not be queried (§17, §19.1; #169).
|
|
118
|
+
*
|
|
119
|
+
* @throws {AldusError} when the reason is empty.
|
|
120
|
+
*/
|
|
121
|
+
export function cannotEstablish(reason: string): CannotEstablish {
|
|
122
|
+
if (typeof reason !== "string" || reason.trim().length === 0) {
|
|
123
|
+
throw releaseError(
|
|
124
|
+
ReleaseErrorCodes.OPERATION_INVALID,
|
|
125
|
+
"An unestablished remote state must say why. A rate limit, a permission problem and a " +
|
|
126
|
+
"destination that does not retain what identifies the operation call for three different " +
|
|
127
|
+
"responses, and an operator cannot tell them apart from the absence of an answer (§17).",
|
|
128
|
+
{ category: "validation", retryable: false, details: {} },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const state = { reason } as CannotEstablish;
|
|
132
|
+
ISSUED_CANNOT_ESTABLISH.add(state);
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether a remote state is an issued {@link CannotEstablish}. Membership, not shape. */
|
|
137
|
+
export function isCannotEstablish(state: RemoteState): state is CannotEstablish {
|
|
138
|
+
return typeof state === "object" && state !== null && ISSUED_CANNOT_ESTABLISH.has(state);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What reconciliation found at the destination. */
|
|
142
|
+
export type RemoteState =
|
|
143
|
+
| {
|
|
144
|
+
/**
|
|
145
|
+
* Whether the destination holds the result of this operation.
|
|
146
|
+
*
|
|
147
|
+
* `false` asserts a **completed search**. An adapter that did not or could not search
|
|
148
|
+
* returns {@link cannotEstablish} instead.
|
|
149
|
+
*/
|
|
150
|
+
exists: boolean;
|
|
151
|
+
remoteId?: string;
|
|
152
|
+
remoteUrl?: string;
|
|
153
|
+
}
|
|
154
|
+
| CannotEstablish;
|
|
155
|
+
|
|
72
156
|
/**
|
|
73
157
|
* An adopter's implementation for one destination.
|
|
74
158
|
*
|
|
@@ -129,13 +213,52 @@ export class AdapterRegistry {
|
|
|
129
213
|
}
|
|
130
214
|
|
|
131
215
|
/** Scripted behaviour for {@link RecordingReleaseAdapter}. */
|
|
216
|
+
/**
|
|
217
|
+
* Build the remote-state map from either accepted shape, and refuse anything else.
|
|
218
|
+
*
|
|
219
|
+
* Refusing rather than tolerating, because the failure this replaces was silence: a mis-seeded
|
|
220
|
+
* harness answered every lookup as a completed search finding nothing, and a test written against
|
|
221
|
+
* it would assert the wrong behaviour and pass. A double that lies quietly is worse than one that
|
|
222
|
+
* will not start.
|
|
223
|
+
*/
|
|
224
|
+
function seedRemote(remote: RecordingAdapterOptions["remote"]): Map<string, RemoteState> {
|
|
225
|
+
if (remote === undefined) return new Map();
|
|
226
|
+
if (remote instanceof Map) return new Map(remote);
|
|
227
|
+
if (typeof remote === "object" && remote !== null && !Array.isArray(remote)) {
|
|
228
|
+
return new Map(Object.entries(remote));
|
|
229
|
+
}
|
|
230
|
+
throw releaseError(
|
|
231
|
+
ReleaseErrorCodes.OPERATION_INVALID,
|
|
232
|
+
"A recording adapter's `remote` seed must be a plain object or a Map keyed by idempotency " +
|
|
233
|
+
"key. Anything else seeds nothing, and every lookup then answers as a completed search " +
|
|
234
|
+
"that found nothing — which is the defect this double is used to test for.",
|
|
235
|
+
{ category: "validation", retryable: false, details: {} },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
132
239
|
export interface RecordingAdapterOptions {
|
|
133
240
|
/** Outcome per `operationId`. Anything unlisted succeeds. */
|
|
134
241
|
outcomes?: Readonly<Record<string, AdapterOutcome>>;
|
|
135
|
-
/**
|
|
136
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Remote state per idempotency key, as reconciliation would find it.
|
|
244
|
+
*
|
|
245
|
+
* A plain object or a `Map`, because the field it becomes is a `Map` and seeding it with one is
|
|
246
|
+
* the natural mistake. `Object.entries(aMap)` is `[]`, so a `Map` used to seed **nothing**, with
|
|
247
|
+
* no error — and every lookup then fell through to `{ exists: false }`, which reads as a
|
|
248
|
+
* completed search finding nothing. The same trap as an adapter returning `{ present: false }`:
|
|
249
|
+
* an input accepted quietly and answered as though nothing was there.
|
|
250
|
+
*/
|
|
251
|
+
remote?: Readonly<Record<string, RemoteState>> | ReadonlyMap<string, RemoteState>;
|
|
137
252
|
/** Omit `lookup` entirely, modelling a destination that cannot be queried. */
|
|
138
253
|
withoutLookup?: boolean;
|
|
254
|
+
/**
|
|
255
|
+
* Make `lookup` throw for these `operationId`s, modelling an unanticipated query failure.
|
|
256
|
+
*
|
|
257
|
+
* Distinct from returning {@link cannotEstablish}: an adapter that knows it cannot answer says
|
|
258
|
+
* so, and this is the adapter that does not know — a quota error, a dropped connection. The
|
|
259
|
+
* executor has to survive both (#169).
|
|
260
|
+
*/
|
|
261
|
+
lookupThrowsFor?: readonly string[];
|
|
139
262
|
}
|
|
140
263
|
|
|
141
264
|
/**
|
|
@@ -169,10 +292,13 @@ export class RecordingReleaseAdapter implements ReleaseAdapter {
|
|
|
169
292
|
constructor(destination: string, options: RecordingAdapterOptions = {}) {
|
|
170
293
|
this.destination = destination;
|
|
171
294
|
this.#options = options;
|
|
172
|
-
this.remote =
|
|
295
|
+
this.remote = seedRemote(options.remote);
|
|
173
296
|
if (options.withoutLookup !== true) {
|
|
174
297
|
this.lookup = (request: ReleaseRequest): Promise<RemoteState> => {
|
|
175
298
|
this.lookedUp.push(request);
|
|
299
|
+
if (options.lookupThrowsFor?.includes(request.operation.operationId) === true) {
|
|
300
|
+
return Promise.reject(new Error("destination query failed"));
|
|
301
|
+
}
|
|
176
302
|
return Promise.resolve(this.remote.get(request.idempotencyKey) ?? { exists: false });
|
|
177
303
|
};
|
|
178
304
|
}
|
package/src/bundle.ts
CHANGED
|
@@ -74,6 +74,44 @@ export function assertBundleValid(bundle: ReleaseBundle): void {
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
seen.add(operation.operationId);
|
|
77
|
+
|
|
78
|
+
// Checked at runtime, because the type is not the enforcement. `repeatable()` refuses an empty
|
|
79
|
+
// reason and nothing stops a caller writing `{ reason: "" } as RepeatableDeclaration` — and
|
|
80
|
+
// the code that will assemble operations from configuration is exactly the code that does
|
|
81
|
+
// that. Without this the operation was treated as repeatable and the warning ended in a bare
|
|
82
|
+
// colon where the justification should be.
|
|
83
|
+
//
|
|
84
|
+
// Higher stakes than the usual version of this argument: the declaration licenses performing
|
|
85
|
+
// an external effect more than once, and §13.4 binds a release approval to the bundle, so a
|
|
86
|
+
// blank reason is an approval of nothing.
|
|
87
|
+
//
|
|
88
|
+
// The **shape** as well as the emptiness. The first version read `repeatable.reason.trim()`,
|
|
89
|
+
// which assumes `reason` is a string — the assumption this check exists because it cannot
|
|
90
|
+
// make. `repeatable: true` is the single most likely thing someone writes in a config file
|
|
91
|
+
// when they mean this, and it threw a bare `TypeError` with no code, no bundle or operation
|
|
92
|
+
// id, and no sentence saying what was wrong. Fail-closed, so nothing unsafe happened; what was
|
|
93
|
+
// lost was the refusal, arriving from exactly the source the paragraph above names.
|
|
94
|
+
const declaration: unknown = (operation as { repeatable?: unknown }).repeatable;
|
|
95
|
+
if (declaration !== undefined) {
|
|
96
|
+
const reason =
|
|
97
|
+
typeof declaration === "object" && declaration !== null
|
|
98
|
+
? (declaration as { reason?: unknown }).reason
|
|
99
|
+
: undefined;
|
|
100
|
+
if (typeof reason !== "string" || reason.trim().length === 0) {
|
|
101
|
+
throw releaseError(
|
|
102
|
+
ReleaseErrorCodes.OPERATION_INVALID,
|
|
103
|
+
`Release bundle "${bundle.bundleId}" declares "${operation.operationId}" safe to ` +
|
|
104
|
+
"repeat without a usable reason. Repeating an external effect is something an " +
|
|
105
|
+
"approver accepts, and neither a blank justification nor a value that is not a " +
|
|
106
|
+
"declaration is something anyone can approve. Build it with `repeatable(reason)` " +
|
|
107
|
+
"(contract §13.4, §17).",
|
|
108
|
+
{
|
|
109
|
+
category: "validation",
|
|
110
|
+
details: { bundleId: bundle.bundleId, operationId: operation.operationId },
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
77
115
|
}
|
|
78
116
|
}
|
|
79
117
|
|
|
@@ -88,10 +126,20 @@ export function assertBundleValid(bundle: ReleaseBundle): void {
|
|
|
88
126
|
*
|
|
89
127
|
* Input hashes are sorted before digesting: the set of things released is what matters, not the
|
|
90
128
|
* order a caller happened to list them in.
|
|
129
|
+
*
|
|
130
|
+
* **The bundle's identity is deliberately not part of the key** (ADR-0033). It was, and that
|
|
131
|
+
* defeated the sentence above: nothing stores a `ReleaseBundle`, so a caller resuming after a
|
|
132
|
+
* crash reassembles one — and a reassembled bundle with a fresh `bundleId` produced a fresh key
|
|
133
|
+
* for every operation, matched no receipt, and re-executed the lot. Measured before the change:
|
|
134
|
+
* an equivalent bundle under a new id re-ran all three operations, including the media upload and
|
|
135
|
+
* the visibility transition (#40).
|
|
136
|
+
*
|
|
137
|
+
* What identifies an operation is what it does: this kind of operation, against this destination,
|
|
138
|
+
* over these exact bytes. Two bundles agreeing on all three are asking for the same external
|
|
139
|
+
* effect, and §19.1 requires that effect to happen once.
|
|
91
140
|
*/
|
|
92
|
-
export function deriveIdempotencyKey(
|
|
141
|
+
export function deriveIdempotencyKey(operation: ReleaseOperation): string {
|
|
93
142
|
const material = JSON.stringify({
|
|
94
|
-
bundleId,
|
|
95
143
|
operationId: operation.operationId,
|
|
96
144
|
kind: operation.kind,
|
|
97
145
|
destination: operation.destination,
|
package/src/errors.ts
CHANGED
|
@@ -26,6 +26,13 @@ export const ReleaseErrorCodes = {
|
|
|
26
26
|
DUPLICATE_OPERATION: "ALDUS_RELEASE_DUPLICATE_OPERATION",
|
|
27
27
|
/** A bundle was constructed with no operations at all. */
|
|
28
28
|
EMPTY_BUNDLE: "ALDUS_RELEASE_EMPTY_BUNDLE",
|
|
29
|
+
/**
|
|
30
|
+
* An operation declaration is not usable as written (#169).
|
|
31
|
+
*
|
|
32
|
+
* Today: a repeatable declaration with no reason. It licenses performing an external effect more
|
|
33
|
+
* than once, and an approver cannot accept that from a bare flag (§13.4, §17).
|
|
34
|
+
*/
|
|
35
|
+
OPERATION_INVALID: "ALDUS_RELEASE_OPERATION_INVALID",
|
|
29
36
|
/**
|
|
30
37
|
* A required operation failed, so the release did not complete.
|
|
31
38
|
*
|
package/src/executor.ts
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
import type { ActorRef, AldusEvent, ReleaseReceipt } from "@aldus-runtime/core";
|
|
27
27
|
import { SCHEMA_VERSION, newEventId, newReleaseId } from "@aldus-runtime/core";
|
|
28
28
|
|
|
29
|
-
import
|
|
29
|
+
import { cannotEstablish, isCannotEstablish } from "./adapter.js";
|
|
30
|
+
import type { AdapterRegistry, AdapterOutcome, ReleaseRequest, RemoteState } from "./adapter.js";
|
|
30
31
|
import { assertBundleValid, deriveIdempotencyKey, type ReleaseBundle } from "./bundle.js";
|
|
31
32
|
import { ReleaseErrorCodes, releaseError } from "./errors.js";
|
|
32
33
|
import type { OperationCriticality, ReleaseOperation } from "./operation.js";
|
|
@@ -78,6 +79,37 @@ export interface BundleStatus {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/** What reconciliation did about one operation. */
|
|
82
|
+
/**
|
|
83
|
+
* Which reconciliation findings an operator is warned about (§20; #169).
|
|
84
|
+
*
|
|
85
|
+
* An **allow-list**, keyed on the action. It was a deny-list — any finding carrying an explanation
|
|
86
|
+
* except `confirmed_absent` — so every action added later was opted in by default, with nobody
|
|
87
|
+
* deciding. `not_reconciled_repeatable` was opted in exactly that way, and appeared in the
|
|
88
|
+
* warnings of every ordinary release of a bundle holding a repeatable operation, reporting that
|
|
89
|
+
* the expected thing had happened.
|
|
90
|
+
*
|
|
91
|
+
* Membership answers the question a warning asks: **did something go differently than intended?**
|
|
92
|
+
*
|
|
93
|
+
* - `repaired` — yes. The local record was wrong and had to be corrected.
|
|
94
|
+
* - `unavailable` — yes. Reconciliation could not run, so an operator is being told that a
|
|
95
|
+
* protection they may assume is absent.
|
|
96
|
+
* - `confirmed_absent` — no. The ordinary path; most operations in a fresh bundle are absent, and
|
|
97
|
+
* one line each would bury the two above.
|
|
98
|
+
* - `not_reconciled_repeatable` — no. The declared, approved state of the bundle. The reason is in
|
|
99
|
+
* the reconciliation report for anyone reading it.
|
|
100
|
+
*
|
|
101
|
+
* Adding an action now requires either adding it here or leaving it out, and both are decisions.
|
|
102
|
+
* Neither happens by default.
|
|
103
|
+
*/
|
|
104
|
+
const OPERATOR_FACING_FINDINGS: ReadonlySet<ReconciliationFinding["action"]> = new Set([
|
|
105
|
+
"repaired",
|
|
106
|
+
"unavailable",
|
|
107
|
+
// Yes. The operation may or may not have happened and nobody can tell — the state an operator
|
|
108
|
+
// most needs to see, and the one the deny-list would have surfaced by accident rather than by
|
|
109
|
+
// decision (#169).
|
|
110
|
+
"cannot_establish",
|
|
111
|
+
]);
|
|
112
|
+
|
|
81
113
|
export interface ReconciliationFinding {
|
|
82
114
|
operationId: string;
|
|
83
115
|
idempotencyKey: string;
|
|
@@ -89,7 +121,21 @@ export interface ReconciliationFinding {
|
|
|
89
121
|
/** The destination does not hold it, so the operation genuinely still needs to run. */
|
|
90
122
|
| "confirmed_absent"
|
|
91
123
|
/** The adapter cannot query the destination (contract §17 "where the platform allows it"). */
|
|
92
|
-
| "unavailable"
|
|
124
|
+
| "unavailable"
|
|
125
|
+
/**
|
|
126
|
+
* The operation declares its effect safe to repeat, so it was not queried (#169).
|
|
127
|
+
*
|
|
128
|
+
* Deliberately its own action rather than folded into `confirmed_absent`. Nothing was
|
|
129
|
+
* searched for, and saying it was absent would be the false statement this exists to stop.
|
|
130
|
+
*/
|
|
131
|
+
| "not_reconciled_repeatable"
|
|
132
|
+
/**
|
|
133
|
+
* The adapter was asked and could not establish whether the operation happened (#169).
|
|
134
|
+
*
|
|
135
|
+
* Distinct from `unavailable`, which is a destination that cannot be queried at all, and from
|
|
136
|
+
* `confirmed_absent`, which is a completed search. This one asked and got no answer.
|
|
137
|
+
*/
|
|
138
|
+
| "cannot_establish";
|
|
93
139
|
explanation?: string;
|
|
94
140
|
}
|
|
95
141
|
|
|
@@ -218,7 +264,7 @@ export class ReleaseExecutor {
|
|
|
218
264
|
const findings: ReconciliationFinding[] = [];
|
|
219
265
|
const repaired: ReleaseReceipt[] = [];
|
|
220
266
|
|
|
221
|
-
for (const { operation, idempotencyKey } of this.#plan(bundle)) {
|
|
267
|
+
for (const { operation, criticality, idempotencyKey } of this.#plan(bundle)) {
|
|
222
268
|
const receipt = latest.get(idempotencyKey);
|
|
223
269
|
if (receipt !== undefined && receipt.status !== "pending") {
|
|
224
270
|
findings.push({
|
|
@@ -229,6 +275,23 @@ export class ReleaseExecutor {
|
|
|
229
275
|
continue;
|
|
230
276
|
}
|
|
231
277
|
|
|
278
|
+
// Asked of nothing, because the answer would change nothing. A repeatable operation may be
|
|
279
|
+
// performed again whatever the destination holds, so querying it buys no decision — and
|
|
280
|
+
// where reconciliation runs before execution, as it does inside `execute`, a "not there"
|
|
281
|
+
// reading can mean "the operation before it has not run yet" rather than "it did not
|
|
282
|
+
// happen". An adapter forced to answer that question has to invent one (#169).
|
|
283
|
+
if (operation.repeatable !== undefined) {
|
|
284
|
+
findings.push({
|
|
285
|
+
operationId: operation.operationId,
|
|
286
|
+
idempotencyKey,
|
|
287
|
+
action: "not_reconciled_repeatable",
|
|
288
|
+
explanation:
|
|
289
|
+
`"${operation.operationId}" declares its effect safe to repeat, so the destination ` +
|
|
290
|
+
`was not queried: ${operation.repeatable.reason}`,
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
232
295
|
const adapter = this.#adapters.require(operation.destination);
|
|
233
296
|
if (adapter.lookup === undefined) {
|
|
234
297
|
findings.push({
|
|
@@ -247,8 +310,106 @@ export class ReleaseExecutor {
|
|
|
247
310
|
idempotencyKey,
|
|
248
311
|
runId: bundle.runId,
|
|
249
312
|
};
|
|
250
|
-
|
|
313
|
+
// Guarded, and the guard is criticality-aware. `reconcile` used to `await` this bare, so a
|
|
314
|
+
// throw from any operation's `lookup` aborted the whole pass before a single `execute` —
|
|
315
|
+
// a quota error raised while asking about a tidy-up blocked the upload beside it (#169).
|
|
316
|
+
//
|
|
317
|
+
// A required operation's throw still aborts. Fail-closed belongs where a mistake publishes
|
|
318
|
+
// twice; it does not belong where a best-effort tidy-up could not be queried.
|
|
319
|
+
let remote: RemoteState;
|
|
320
|
+
try {
|
|
321
|
+
remote = await adapter.lookup(request);
|
|
322
|
+
} catch (thrown) {
|
|
323
|
+
if (criticality === "required") throw thrown;
|
|
324
|
+
remote = cannotEstablish(
|
|
325
|
+
`querying destination "${operation.destination}" failed: ${
|
|
326
|
+
thrown instanceof Error ? thrown.message : String(thrown)
|
|
327
|
+
}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (isCannotEstablish(remote)) {
|
|
332
|
+
// Asked, and no answer. What happens next depends on what the operation is for.
|
|
333
|
+
if (criticality === "required") {
|
|
334
|
+
// Unknown must never license a publish. A required operation whose prior outcome cannot
|
|
335
|
+
// be established is refused before anything executes, because performing it might
|
|
336
|
+
// repeat it and skipping it might drop it, and neither is a choice to make blind.
|
|
337
|
+
throw releaseError(
|
|
338
|
+
ReleaseErrorCodes.RECONCILIATION_UNAVAILABLE,
|
|
339
|
+
`Whether required operation "${operation.operationId}" already happened could not be ` +
|
|
340
|
+
`established: ${remote.reason}. Executing would risk performing it twice and ` +
|
|
341
|
+
"skipping it would risk dropping it, so the bundle is refused until the outcome is " +
|
|
342
|
+
"known (contract §17).",
|
|
343
|
+
{
|
|
344
|
+
category: "conflict",
|
|
345
|
+
retryable: false,
|
|
346
|
+
details: {
|
|
347
|
+
runId: bundle.runId,
|
|
348
|
+
operationId: operation.operationId,
|
|
349
|
+
destination: operation.destination,
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Best-effort: **no receipt**, and not performed this pass.
|
|
356
|
+
//
|
|
357
|
+
// Writing a `skipped` receipt was the obvious thing and it was wrong in the way this whole
|
|
358
|
+
// issue is about. `skipped` is terminal in both directions — `execute` skips it and
|
|
359
|
+
// `reconcile` treats it as already recorded — so one momentary query failure permanently
|
|
360
|
+
// retired the operation for that bundle. At the destination that produced #169 quota
|
|
361
|
+
// exhaustion is routine, so a rate limit while *asking about* a tidy-up would silently
|
|
362
|
+
// drop it forever, and the record would say `skipped`: a decision, when what happened was
|
|
363
|
+
// an unanswered question.
|
|
364
|
+
//
|
|
365
|
+
// A durable record of a transient failure is the same defect as a durable record of a
|
|
366
|
+
// search nobody performed. So the not-performing lives in this pass only — the finding and
|
|
367
|
+
// the warning say what happened now, and the next pass asks again.
|
|
368
|
+
//
|
|
369
|
+
// Not performed either, because executing on an unknown prior state is the other half of
|
|
370
|
+
// the trap: it might repeat an operation that already happened.
|
|
371
|
+
findings.push({
|
|
372
|
+
operationId: operation.operationId,
|
|
373
|
+
idempotencyKey,
|
|
374
|
+
action: "cannot_establish",
|
|
375
|
+
explanation:
|
|
376
|
+
`Whether "${operation.operationId}" already happened could not be established, so it ` +
|
|
377
|
+
`was not attempted: ${remote.reason}`,
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Not an issued cannot-establish, so it must be the other arm — and that has to be checked
|
|
383
|
+
// rather than assumed. An assembled `{ reason: "..." }` is not an issued state, so
|
|
384
|
+
// `isCannotEstablish` correctly rejects it, and it would then reach `!remote.exists` with
|
|
385
|
+
// `exists` undefined and be recorded as `confirmed_absent`: a completed search, asserted
|
|
386
|
+
// from a value that is not a `RemoteState` at all.
|
|
387
|
+
//
|
|
388
|
+
// Fifth instance of the same lesson in this contract (#170 has the other four). A narrowing
|
|
389
|
+
// that trusts the declared type is not a narrowing.
|
|
390
|
+
if (typeof remote.exists !== "boolean") {
|
|
391
|
+
throw releaseError(
|
|
392
|
+
ReleaseErrorCodes.OPERATION_INVALID,
|
|
393
|
+
`The adapter for destination "${operation.destination}" answered the lookup for ` +
|
|
394
|
+
`"${operation.operationId}" with a value that is neither a remote state nor an ` +
|
|
395
|
+
"issued `cannotEstablish`. Reading it as absent would record a completed search that " +
|
|
396
|
+
"did not happen (contract §17).",
|
|
397
|
+
{
|
|
398
|
+
category: "validation",
|
|
399
|
+
retryable: false,
|
|
400
|
+
details: {
|
|
401
|
+
runId: bundle.runId,
|
|
402
|
+
operationId: operation.operationId,
|
|
403
|
+
destination: operation.destination,
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
251
409
|
if (!remote.exists) {
|
|
410
|
+
// Reserved to a `lookup` that actually returned `exists: false`. Every other reason an
|
|
411
|
+
// operation might not have been found now has its own action, so this one means what it
|
|
412
|
+
// says: a completed search established that the destination does not hold it (#169).
|
|
252
413
|
findings.push({
|
|
253
414
|
operationId: operation.operationId,
|
|
254
415
|
idempotencyKey,
|
|
@@ -292,11 +453,17 @@ export class ReleaseExecutor {
|
|
|
292
453
|
const warnings: string[] = [];
|
|
293
454
|
const written: ReleaseReceipt[] = [];
|
|
294
455
|
|
|
456
|
+
// Operations this pass must not attempt, held in memory and never written down. A best-effort
|
|
457
|
+
// operation whose prior state could not be established is not performed now — executing on an
|
|
458
|
+
// unknown prior state might repeat something that already happened — and leaves no durable
|
|
459
|
+
// trace, so the next pass asks again rather than finding a terminal receipt (#169).
|
|
460
|
+
const unestablished = new Set<string>();
|
|
295
461
|
if (options.reconcile !== false) {
|
|
296
462
|
const report = await this.reconcile(bundle, { actor: options.actor });
|
|
297
463
|
written.push(...report.repaired);
|
|
298
464
|
for (const finding of report.findings) {
|
|
299
|
-
if (finding.
|
|
465
|
+
if (finding.action === "cannot_establish") unestablished.add(finding.operationId);
|
|
466
|
+
if (finding.explanation !== undefined && OPERATOR_FACING_FINDINGS.has(finding.action)) {
|
|
300
467
|
warnings.push(finding.explanation);
|
|
301
468
|
}
|
|
302
469
|
}
|
|
@@ -318,13 +485,28 @@ export class ReleaseExecutor {
|
|
|
318
485
|
continue;
|
|
319
486
|
}
|
|
320
487
|
|
|
488
|
+
// Skipped for this pass only. No receipt was written, so a later pass reaches the same
|
|
489
|
+
// operation with the same undecided state and asks the destination again.
|
|
490
|
+
if (unestablished.has(operation.operationId)) continue;
|
|
491
|
+
|
|
321
492
|
const existing = latest.get(idempotencyKey);
|
|
322
493
|
if (existing?.status === "succeeded" || existing?.status === "skipped") continue;
|
|
323
494
|
|
|
324
495
|
// An operation whose outcome was never confirmed must not be retried (contract §17). If
|
|
325
496
|
// reconciliation could have resolved it, it already ran above and this receipt would be
|
|
326
497
|
// terminal; reaching here means the destination cannot be queried.
|
|
327
|
-
|
|
498
|
+
//
|
|
499
|
+
// Unless the operation says repeating it is safe, which is exactly the fact this refusal
|
|
500
|
+
// needs and previously had no way to hear. Without it a best-effort tidy-up whose outcome
|
|
501
|
+
// was once unconfirmed refused **every later release of that bundle** — the same operation
|
|
502
|
+
// whose failure `execute` treats as a warning, blocking the release forever because one
|
|
503
|
+
// past attempt went unanswered (#169).
|
|
504
|
+
if (existing?.status === "pending" && operation.repeatable !== undefined) {
|
|
505
|
+
warnings.push(
|
|
506
|
+
`"${operation.operationId}" has an unconfirmed earlier outcome and declares its effect ` +
|
|
507
|
+
`safe to repeat, so it is being performed again: ${operation.repeatable.reason}`,
|
|
508
|
+
);
|
|
509
|
+
} else if (existing?.status === "pending") {
|
|
328
510
|
throw releaseError(
|
|
329
511
|
ReleaseErrorCodes.RECONCILIATION_UNAVAILABLE,
|
|
330
512
|
`"${operation.operationId}" was attempted and its outcome was never confirmed, and ` +
|
|
@@ -425,7 +607,7 @@ export class ReleaseExecutor {
|
|
|
425
607
|
];
|
|
426
608
|
return entries.map((entry) => ({
|
|
427
609
|
...entry,
|
|
428
|
-
idempotencyKey: deriveIdempotencyKey(
|
|
610
|
+
idempotencyKey: deriveIdempotencyKey(entry.operation),
|
|
429
611
|
}));
|
|
430
612
|
}
|
|
431
613
|
|
|
@@ -442,6 +624,9 @@ export class ReleaseExecutor {
|
|
|
442
624
|
schemaVersion: SCHEMA_VERSION,
|
|
443
625
|
releaseId: this.#nextReleaseId(),
|
|
444
626
|
runId: bundle.runId,
|
|
627
|
+
// Recorded, never keyed on (ADR-0033). The trace can now say which release produced this
|
|
628
|
+
// receipt; matching still happens on what the operation does, so a resumed bundle finds it.
|
|
629
|
+
bundleId: bundle.bundleId,
|
|
445
630
|
destination: operation.destination,
|
|
446
631
|
operation: operation.kind,
|
|
447
632
|
idempotencyKey,
|
|
@@ -453,6 +638,11 @@ export class ReleaseExecutor {
|
|
|
453
638
|
...(outcome.status === "succeeded" && outcome.remoteUrl !== undefined
|
|
454
639
|
? { remoteUrl: outcome.remoteUrl }
|
|
455
640
|
: {}),
|
|
641
|
+
// Carried through to the durable record. A field an adapter can set and nothing reads is
|
|
642
|
+
// the defect #107 reported, one contract over.
|
|
643
|
+
...(outcome.status === "succeeded" && outcome.note !== undefined
|
|
644
|
+
? { note: outcome.note }
|
|
645
|
+
: {}),
|
|
456
646
|
// `pending` is not terminal, so it carries no completion time (contract §17).
|
|
457
647
|
...(outcome.status === "pending" ? {} : { completedAt: at }),
|
|
458
648
|
...(outcome.status === "failed"
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
export {
|
|
19
19
|
AdapterRegistry,
|
|
20
20
|
RecordingReleaseAdapter,
|
|
21
|
+
cannotEstablish,
|
|
22
|
+
isCannotEstablish,
|
|
21
23
|
type AdapterOutcome,
|
|
24
|
+
type CannotEstablish,
|
|
22
25
|
type RecordingAdapterOptions,
|
|
23
26
|
type ReleaseAdapter,
|
|
24
27
|
type ReleaseRequest,
|
|
@@ -56,7 +59,9 @@ export {
|
|
|
56
59
|
|
|
57
60
|
export {
|
|
58
61
|
bestEffortOperation,
|
|
62
|
+
repeatable,
|
|
59
63
|
requiredOperation,
|
|
64
|
+
type RepeatableDeclaration,
|
|
60
65
|
type BestEffortOperation,
|
|
61
66
|
type OperationCriticality,
|
|
62
67
|
type ReleaseOperation,
|
package/src/operation.ts
CHANGED
|
@@ -20,8 +20,55 @@
|
|
|
20
20
|
* constructor below. A caller cannot hand-write an object literal that satisfies
|
|
21
21
|
* {@link RequiredOperation}.
|
|
22
22
|
*/
|
|
23
|
+
import { ReleaseErrorCodes, releaseError } from "./errors.js";
|
|
24
|
+
|
|
23
25
|
declare const CRITICALITY: unique symbol;
|
|
24
26
|
|
|
27
|
+
/** @see RepeatableDeclaration */
|
|
28
|
+
declare const REPEATABILITY: unique symbol;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A statement that an operation's effect may be performed more than once (§17, §19.1; #169).
|
|
32
|
+
*
|
|
33
|
+
* Minted by {@link repeatable} and not writable as an object literal, for the same reason
|
|
34
|
+
* `RequiredOperation` is not: this licenses re-performing a real external effect, and a shape a
|
|
35
|
+
* caller can assemble is a shape that gets assembled from configuration by someone who has not
|
|
36
|
+
* thought about it.
|
|
37
|
+
*
|
|
38
|
+
* The reason is **required**. An operation that may be repeated is one an approver is being asked
|
|
39
|
+
* to accept the repetition of, and "safe to repeat" with no account of why is not something anyone
|
|
40
|
+
* can approve or audit.
|
|
41
|
+
*/
|
|
42
|
+
export interface RepeatableDeclaration {
|
|
43
|
+
readonly [REPEATABILITY]: "repeatable";
|
|
44
|
+
/**
|
|
45
|
+
* Why repeating this effect is safe, for the approver and for the operator.
|
|
46
|
+
*
|
|
47
|
+
* Shown in the reconciliation finding that records the operation was not queried, so a reader
|
|
48
|
+
* sees the justification rather than only the outcome.
|
|
49
|
+
*/
|
|
50
|
+
readonly reason: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declare that repeating this operation's effect is safe (§17, §19.1; #169).
|
|
55
|
+
*
|
|
56
|
+
* @throws {AldusError} when the reason is empty. A declaration that licenses repetition and says
|
|
57
|
+
* nothing about why is the thing this exists to prevent.
|
|
58
|
+
*/
|
|
59
|
+
export function repeatable(reason: string): RepeatableDeclaration {
|
|
60
|
+
if (reason.trim().length === 0) {
|
|
61
|
+
throw releaseError(
|
|
62
|
+
ReleaseErrorCodes.OPERATION_INVALID,
|
|
63
|
+
"A repeatable declaration must say why repeating the effect is safe. It licenses performing " +
|
|
64
|
+
"an external effect more than once, and an approver cannot accept that from a bare flag " +
|
|
65
|
+
"(contract §13.4, §17).",
|
|
66
|
+
{ category: "validation", retryable: false, details: {} },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { reason } as RepeatableDeclaration;
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
/** Fields shared by both categories. */
|
|
26
73
|
export interface ReleaseOperationBase {
|
|
27
74
|
/**
|
|
@@ -65,6 +112,23 @@ export interface ReleaseOperationBase {
|
|
|
65
112
|
* The gate engine decides whether the authority is held; this package never re-decides it.
|
|
66
113
|
*/
|
|
67
114
|
requiresAuthority?: string;
|
|
115
|
+
/**
|
|
116
|
+
* Declares that repeating this operation's effect is safe (§17, §19.1; #169).
|
|
117
|
+
*
|
|
118
|
+
* **Absent means one-shot**, which is the conservative reading and the behaviour every existing
|
|
119
|
+
* bundle already has. Repetition is licensed only by saying so.
|
|
120
|
+
*
|
|
121
|
+
* In the **bundle**, not on the adapter and not in `RemoteState`. §13.4 binds a release approval
|
|
122
|
+
* to the bundle, so a fact that licenses re-performing an effect has to be visible in the
|
|
123
|
+
* artifact an approver approved — an adapter-side flag would let an adapter license repeating an
|
|
124
|
+
* operation the approver believed happened once.
|
|
125
|
+
*
|
|
126
|
+
* Not inferable from {@link ReleaseOperationBase} either. `deriveIdempotencyKey` documents its
|
|
127
|
+
* result as the key that makes re-running safe, and that holds only where the destination
|
|
128
|
+
* honours the key; plenty do not. A key's presence is a request, not a guarantee, so
|
|
129
|
+
* repeatability has to be stated.
|
|
130
|
+
*/
|
|
131
|
+
repeatable?: RepeatableDeclaration;
|
|
68
132
|
/** Opaque parameters passed through to the adapter. Never inspected here. */
|
|
69
133
|
parameters?: Readonly<Record<string, unknown>>;
|
|
70
134
|
}
|
package/src/ports.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { EventStore, RunStore } from "@aldus-runtime/file-store";
|
|
|
16
16
|
* There is no update in place and no delete. §17's receipts are an audit record of what was
|
|
17
17
|
* attempted against a destination: an operation retried after a failure produces a second
|
|
18
18
|
* receipt, not an edit to the first, because the fact that the first attempt failed is what
|
|
19
|
-
* explains the retry. {@link
|
|
19
|
+
* explains the retry. {@link latestByKey} resolves the current outcome by reading them in order.
|
|
20
20
|
*/
|
|
21
21
|
export interface ReleaseReceiptStore {
|
|
22
22
|
/** Every receipt recorded for a Run, in the order they were appended. */
|