@microdelta/history 0.0.0 → 0.2.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/README.md +30 -4
- package/dist/api/history.alpha.d.ts +3 -0
- package/dist/api/history.beta.d.ts +3 -0
- package/dist/api/history.conformance.store.alpha.d.ts +3 -0
- package/dist/api/history.conformance.store.beta.d.ts +3 -0
- package/dist/api/history.conformance.store.public.d.ts +3 -0
- package/dist/api/history.conformance.store.raw.alpha.d.ts +215 -0
- package/dist/api/history.conformance.store.raw.beta.d.ts +215 -0
- package/dist/api/history.conformance.store.raw.public.d.ts +215 -0
- package/dist/api/history.conformance.store.raw.untrimmed.d.ts +215 -0
- package/dist/api/history.conformance.store.untrimmed.d.ts +3 -0
- package/dist/api/history.public.d.ts +3 -0
- package/dist/api/history.root.raw.alpha.d.ts +1371 -0
- package/dist/api/history.root.raw.beta.d.ts +425 -0
- package/dist/api/history.root.raw.public.d.ts +425 -0
- package/dist/api/history.root.raw.untrimmed.d.ts +1372 -0
- package/dist/api/history.shared.alpha.d.ts +1402 -0
- package/dist/api/history.shared.beta.d.ts +456 -0
- package/dist/api/history.shared.public.d.ts +456 -0
- package/dist/api/history.shared.untrimmed.d.ts +1403 -0
- package/dist/api/history.untrimmed.d.ts +3 -0
- package/dist/api/tsdoc-metadata.json +11 -0
- package/dist/src/completed-results.d.ts +99 -0
- package/dist/src/completed-results.d.ts.map +1 -0
- package/dist/src/completed-results.js +2 -0
- package/dist/src/completed-results.js.map +1 -0
- package/dist/src/durable/contracts.d.ts +612 -0
- package/dist/src/durable/contracts.d.ts.map +1 -0
- package/dist/src/durable/contracts.js +2 -0
- package/dist/src/durable/contracts.js.map +1 -0
- package/dist/src/durable/errors.d.ts +94 -0
- package/dist/src/durable/errors.d.ts.map +1 -0
- package/dist/src/durable/errors.js +118 -0
- package/dist/src/durable/errors.js.map +1 -0
- package/dist/src/durable/index.d.ts +9 -0
- package/dist/src/durable/index.d.ts.map +1 -0
- package/dist/src/durable/index.js +770 -0
- package/dist/src/durable/index.js.map +1 -0
- package/dist/src/durable/journal.d.ts +53 -0
- package/dist/src/durable/journal.d.ts.map +1 -0
- package/dist/src/durable/journal.js +174 -0
- package/dist/src/durable/journal.js.map +1 -0
- package/dist/src/durable/locator.d.ts +31 -0
- package/dist/src/durable/locator.d.ts.map +1 -0
- package/dist/src/durable/locator.js +50 -0
- package/dist/src/durable/locator.js.map +1 -0
- package/dist/src/durable/records.d.ts +40 -0
- package/dist/src/durable/records.d.ts.map +1 -0
- package/dist/src/durable/records.js +74 -0
- package/dist/src/durable/records.js.map +1 -0
- package/dist/src/durable/schema.d.ts +37 -0
- package/dist/src/durable/schema.d.ts.map +1 -0
- package/dist/src/durable/schema.js +335 -0
- package/dist/src/durable/schema.js.map +1 -0
- package/dist/src/durable/selected-index.d.ts +94 -0
- package/dist/src/durable/selected-index.d.ts.map +1 -0
- package/dist/src/durable/selected-index.js +594 -0
- package/dist/src/durable/selected-index.js.map +1 -0
- package/dist/src/index.d.ts +28 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +15 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/store/index.d.ts +128 -0
- package/dist/src/store/index.d.ts.map +1 -0
- package/dist/src/store/index.js +33 -0
- package/dist/src/store/index.js.map +1 -0
- package/dist/src/store/memory/index.d.ts +24 -0
- package/dist/src/store/memory/index.d.ts.map +1 -0
- package/dist/src/store/memory/index.js +162 -0
- package/dist/src/store/memory/index.js.map +1 -0
- package/dist/src/types.d.ts +100 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/test/conformance/store/index.d.ts +34 -0
- package/dist/test/conformance/store/index.d.ts.map +1 -0
- package/dist/test/conformance/store/index.js +368 -0
- package/dist/test/conformance/store/index.js.map +1 -0
- package/package.json +48 -8
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History's durable SQLite authority: the single consistency owner for
|
|
3
|
+
* attempts, the single logical writer, atomic publication, current pointers,
|
|
4
|
+
* immutable completed results, separate acceptance records, recorded
|
|
5
|
+
* promotions and Run Supervision's operation journal (PUB-001–004,
|
|
6
|
+
* RES-002–007, ARC-007, RUN-011/012/017). It owns every SQL statement and
|
|
7
|
+
* lifecycle decision over injected Machine capabilities; Node access stays in
|
|
8
|
+
* the host adapter.
|
|
9
|
+
*
|
|
10
|
+
* Every record lives in one environment namespace of one analysis. A result
|
|
11
|
+
* is admissible in the environment that published it and, only through a
|
|
12
|
+
* recorded promotion, in another environment of the same analysis: there it
|
|
13
|
+
* may be a candidate, a dependency or an acceptance target, while keeping its
|
|
14
|
+
* own exact reference, and nothing else crosses environments.
|
|
15
|
+
*
|
|
16
|
+
* Transition and recovery outcomes (each row is one IMMEDIATE transaction):
|
|
17
|
+
*
|
|
18
|
+
* | Transition | Durable change | Death before commit | Death after commit |
|
|
19
|
+
* | --- | --- | --- | --- |
|
|
20
|
+
* | acquire | new holder, fence + 1, expiry | no change | lease held until expiry |
|
|
21
|
+
* | renew | later expiry; fence unchanged | lease unchanged | lease extended |
|
|
22
|
+
* | release | no holder; fence unchanged | lease held until expiry | next grant needs no expiry |
|
|
23
|
+
* | allocate | attempt counter + 1, `allocated` attempt with key and intent | identity never issued | identity consumed, no result |
|
|
24
|
+
* | stage | `staged` content and dependencies | attempt stays `allocated` | candidate evidence only, never a result |
|
|
25
|
+
* | publish | result, index, provenance, `completed`, current pointer | attempt stays `staged` | complete result; recover by key |
|
|
26
|
+
* | abandon | `failed`/`interrupted` with evidence | attempt unchanged | attempt ended without result |
|
|
27
|
+
* | accept | acceptance record in the accepting environment naming an existing result | no change | record retained; result untouched |
|
|
28
|
+
* | promote | promotion record naming exact results for a target environment | no change, identity never issued | results admitted there; nothing else changes |
|
|
29
|
+
* | journal commit | one immutable revision per write (see `journal.ts`) | no revision, no sequence issued | every revision durable |
|
|
30
|
+
*
|
|
31
|
+
* It decides no reuse eligibility or freshness and interprets no provenance,
|
|
32
|
+
* promotion evidence or journal record.
|
|
33
|
+
* This is single-file process-termination scope, not a concurrency, power-loss
|
|
34
|
+
* or distributed-time guarantee.
|
|
35
|
+
* @packageDocumentation
|
|
36
|
+
*/
|
|
37
|
+
import { SqliteBusyError } from '@microdelta/machine';
|
|
38
|
+
import { decodeSnapshot, encodeSnapshot } from '@microdelta/value';
|
|
39
|
+
import { AttemptConflictError, AttemptStateError, HistoryClockError, HistoryIntegrityError, StaleWriterError, } from './errors.js';
|
|
40
|
+
import { parseReference, referenceFor } from './locator.js';
|
|
41
|
+
import { createJournalStore } from './journal.js';
|
|
42
|
+
import { historyScope, integer, loadRecord, requireName, requireNonnegative, requirePositive, safeSum, storeRecord, text } from './records.js';
|
|
43
|
+
import { initializeSchema } from './schema.js';
|
|
44
|
+
import { buildIndex, createSelectedIndex, indexVersion } from './selected-index.js';
|
|
45
|
+
/** Identity tag for embedded SQL; the text is passed through unchanged. */
|
|
46
|
+
const sql = String.raw;
|
|
47
|
+
/** The only payload encoding History stores for completed results. */
|
|
48
|
+
const payloadEncoding = 'MDS1';
|
|
49
|
+
/** Copy and validate a scoped subject argument. */
|
|
50
|
+
function scopedSubject(value) {
|
|
51
|
+
return { analysis: requireName(value.analysis, 'analysis'), environment: requireName(value.environment, 'environment'), subject: requireName(value.subject, 'subject') };
|
|
52
|
+
}
|
|
53
|
+
/** Copy and validate a versioned subject argument. */
|
|
54
|
+
function versionedSubject(value) {
|
|
55
|
+
return { ...scopedSubject(value), version: requirePositive(value.version, 'version') };
|
|
56
|
+
}
|
|
57
|
+
/** Copy and validate an attempt request. */
|
|
58
|
+
function attemptRequest(value) {
|
|
59
|
+
return { ...versionedSubject(value), attemptKey: requireName(value.attemptKey, 'attemptKey'), intentDigest: requireName(value.intentDigest, 'intentDigest') };
|
|
60
|
+
}
|
|
61
|
+
/** Validate a presented lease's shape; authority is checked against storage separately. */
|
|
62
|
+
function leaseArgument(lease) {
|
|
63
|
+
return { holder: requireName(lease.holder, 'lease holder'), fence: requirePositive(lease.fence, 'lease fence'), expiresAt: requireNonnegative(lease.expiresAt, 'lease expiry') };
|
|
64
|
+
}
|
|
65
|
+
/** Narrow a stored attempt state. */
|
|
66
|
+
function attemptState(row) {
|
|
67
|
+
const state = text(row, 'state');
|
|
68
|
+
if (state !== 'allocated' && state !== 'staged' && state !== 'completed' && state !== 'failed' && state !== 'interrupted') {
|
|
69
|
+
throw new HistoryIntegrityError(`Unsupported stored attempt state ${state}`);
|
|
70
|
+
}
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Open (creating when empty) the durable History store for one logical store.
|
|
75
|
+
* The file is rejected before any work unless it is empty or holds exactly
|
|
76
|
+
* this schema version for this logical store.
|
|
77
|
+
* @alpha
|
|
78
|
+
*/
|
|
79
|
+
export function openDurableHistory(options) {
|
|
80
|
+
const logicalStore = requireName(options.logicalStore, 'logicalStore');
|
|
81
|
+
const clock = options.clock;
|
|
82
|
+
const connection = options.sqlite.openSqlite(options.location);
|
|
83
|
+
try {
|
|
84
|
+
initializeSchema(connection, logicalStore);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
connection.close();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const statements = {
|
|
91
|
+
writer: connection.prepare(sql `/* writer */ SELECT last_fence, holder, expires_at, time_high_water FROM history_writer WHERE singleton = 1`),
|
|
92
|
+
observeTime: connection.prepare(sql `/* writer */ UPDATE history_writer SET time_high_water = ? WHERE singleton = 1`),
|
|
93
|
+
// Only a grant writes the fence. Renewal and release leave last_fence as
|
|
94
|
+
// they found it rather than writing back the presented lease's fence: the
|
|
95
|
+
// holder guard has already proved the two equal, and never assigning the
|
|
96
|
+
// column there means no future weakening of that guard could regress it.
|
|
97
|
+
setHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET last_fence = ?, holder = ?, expires_at = ? WHERE singleton = 1`),
|
|
98
|
+
extendHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET expires_at = ? WHERE singleton = 1`),
|
|
99
|
+
clearHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET holder = NULL, expires_at = ? WHERE singleton = 1`),
|
|
100
|
+
sequences: connection.prepare(sql `/* sequence */ SELECT last_attempt, last_publication, last_acceptance, last_promotion FROM history_sequences WHERE singleton = 1`),
|
|
101
|
+
setAttemptSequence: connection.prepare(sql `/* allocate */ UPDATE history_sequences SET last_attempt = ? WHERE singleton = 1`),
|
|
102
|
+
setPublicationSequence: connection.prepare(sql `/* publish */ UPDATE history_sequences SET last_publication = ? WHERE singleton = 1`),
|
|
103
|
+
setAcceptanceSequence: connection.prepare(sql `/* accept */ UPDATE history_sequences SET last_acceptance = ? WHERE singleton = 1`),
|
|
104
|
+
setPromotionSequence: connection.prepare(sql `/* promote */ UPDATE history_sequences SET last_promotion = ? WHERE singleton = 1`),
|
|
105
|
+
attemptByKey: connection.prepare(sql `/* attempt */ SELECT * FROM history_attempts WHERE analysis = ? AND environment = ? AND subject = ? AND attempt_key = ?`),
|
|
106
|
+
attemptById: connection.prepare(sql `/* attempt */ SELECT * FROM history_attempts WHERE attempt_id = ?`),
|
|
107
|
+
insertAttempt: connection.prepare(sql `/* allocate */ INSERT INTO history_attempts
|
|
108
|
+
(attempt_id, analysis, environment, subject, version, attempt_key, intent_digest, state, allocated_fence)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'allocated', ?)`),
|
|
110
|
+
stageAttempt: connection.prepare(sql `/* stage */ UPDATE history_attempts
|
|
111
|
+
SET state = 'staged', staged_payload = ?, staged_provenance_format = ?, staged_provenance_version = ?, staged_provenance = ?
|
|
112
|
+
WHERE attempt_id = ? AND state = 'allocated'`),
|
|
113
|
+
insertDependency: connection.prepare(sql `/* stage */ INSERT INTO history_dependencies (attempt_id, position, dependency_id) VALUES (?, ?, ?)`),
|
|
114
|
+
insertResult: connection.prepare(sql `/* publish */ INSERT INTO history_results
|
|
115
|
+
(result_id, analysis, environment, subject, version, publication, published_fence, encoding, index_version, payload, provenance_format, provenance_version, provenance)
|
|
116
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
117
|
+
completeAttempt: connection.prepare(sql `/* publish */ UPDATE history_attempts
|
|
118
|
+
SET state = 'completed', ended_fence = ?, staged_payload = NULL, staged_provenance_format = NULL, staged_provenance_version = NULL, staged_provenance = NULL
|
|
119
|
+
WHERE attempt_id = ? AND state = 'staged'`),
|
|
120
|
+
setCurrent: connection.prepare(sql `/* publish */ INSERT INTO history_current (analysis, environment, subject, result_id) VALUES (?, ?, ?, ?)
|
|
121
|
+
ON CONFLICT (analysis, environment, subject) DO UPDATE SET result_id = excluded.result_id`),
|
|
122
|
+
abandonAttempt: connection.prepare(sql `/* abandon */ UPDATE history_attempts
|
|
123
|
+
SET state = ?, ended_fence = ?, outcome_format = ?, outcome_version = ?, outcome = ?
|
|
124
|
+
WHERE attempt_id = ? AND state IN ('allocated', 'staged')`),
|
|
125
|
+
reference: connection.prepare(sql `/* reference */ SELECT r.result_id, r.analysis, r.environment, r.encoding, r.index_version, a.state
|
|
126
|
+
FROM history_results r LEFT JOIN history_attempts a ON a.attempt_id = r.result_id WHERE r.result_id = ?`),
|
|
127
|
+
envelope: connection.prepare(sql `/* envelope */ SELECT result_id, analysis, environment, subject, version, publication, encoding, index_version,
|
|
128
|
+
provenance_format, provenance_version, provenance FROM history_results WHERE result_id = ?`),
|
|
129
|
+
// Results admissible in the subject's environment: published there, or named by a promotion into it.
|
|
130
|
+
candidates: connection.prepare(sql `/* candidates */ SELECT result_id, analysis, environment, subject, version, publication, encoding, index_version,
|
|
131
|
+
provenance_format, provenance_version, provenance FROM history_results r
|
|
132
|
+
WHERE r.analysis = ? AND r.subject = ? AND r.version = ?
|
|
133
|
+
AND (r.environment = ? OR EXISTS (SELECT 1 FROM history_promotion_results pr JOIN history_promotions p ON p.promotion_id = pr.promotion_id
|
|
134
|
+
WHERE pr.result_id = r.result_id AND p.analysis = r.analysis AND p.environment = ?))
|
|
135
|
+
ORDER BY publication DESC`),
|
|
136
|
+
dependencies: connection.prepare(sql `/* envelope */ SELECT d.position, d.dependency_id, r.result_id AS present, r.analysis, r.environment
|
|
137
|
+
FROM history_dependencies d LEFT JOIN history_results r ON r.result_id = d.dependency_id WHERE d.attempt_id = ? ORDER BY d.position`),
|
|
138
|
+
current: connection.prepare(sql `/* current */ SELECT c.result_id, r.result_id AS present
|
|
139
|
+
FROM history_current c LEFT JOIN history_results r ON r.result_id = c.result_id AND r.analysis = c.analysis AND r.environment = c.environment AND r.subject = c.subject
|
|
140
|
+
WHERE c.analysis = ? AND c.environment = ? AND c.subject = ?`),
|
|
141
|
+
insertAcceptance: connection.prepare(sql `/* accept */ INSERT INTO history_acceptances (acceptance_id, result_id, environment, fence, evidence_format, evidence_version, evidence)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
|
143
|
+
insertAcceptanceDependency: connection.prepare(sql `/* accept */ INSERT INTO history_acceptance_dependencies (acceptance_id, position, dependency_id) VALUES (?, ?, ?)`),
|
|
144
|
+
acceptances: connection.prepare(sql `/* acceptance */ SELECT acceptance_id, environment, fence, evidence_format, evidence_version, evidence FROM history_acceptances
|
|
145
|
+
WHERE result_id = ? AND environment = ? ORDER BY acceptance_id`),
|
|
146
|
+
acceptanceDependencies: connection.prepare(sql `/* acceptance */ SELECT d.position, d.dependency_id, r.result_id AS present, r.analysis, r.environment
|
|
147
|
+
FROM history_acceptance_dependencies d LEFT JOIN history_results r ON r.result_id = d.dependency_id WHERE d.acceptance_id = ? ORDER BY d.position`),
|
|
148
|
+
promoted: connection.prepare(sql `/* promotion */ SELECT 1 AS present FROM history_promotion_results pr JOIN history_promotions p ON p.promotion_id = pr.promotion_id
|
|
149
|
+
WHERE pr.result_id = ? AND p.analysis = ? AND p.environment = ? LIMIT 1`),
|
|
150
|
+
insertPromotion: connection.prepare(sql `/* promote */ INSERT INTO history_promotions (promotion_id, analysis, environment, fence, evidence_format, evidence_version, evidence)
|
|
151
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
|
152
|
+
insertPromotionResult: connection.prepare(sql `/* promote */ INSERT INTO history_promotion_results (promotion_id, position, result_id) VALUES (?, ?, ?)`),
|
|
153
|
+
promotion: connection.prepare(sql `/* promotion */ SELECT * FROM history_promotions WHERE promotion_id = ?`),
|
|
154
|
+
promotions: connection.prepare(sql `/* promotion */ SELECT * FROM history_promotions WHERE analysis = ? AND environment = ? ORDER BY promotion_id`),
|
|
155
|
+
promotionsNaming: connection.prepare(sql `/* promotion */ SELECT p.* FROM history_promotions p
|
|
156
|
+
WHERE p.analysis = ? AND p.environment = ? AND EXISTS (SELECT 1 FROM history_promotion_results pr WHERE pr.promotion_id = p.promotion_id AND pr.result_id = ?)
|
|
157
|
+
ORDER BY p.promotion_id`),
|
|
158
|
+
promotionResults: connection.prepare(sql `/* promotion */ SELECT pr.position, pr.result_id, r.result_id AS present, r.analysis, r.environment
|
|
159
|
+
FROM history_promotion_results pr LEFT JOIN history_results r ON r.result_id = pr.result_id WHERE pr.promotion_id = ? ORDER BY pr.position`),
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Resolve an exact reference to a completed result of this store: the
|
|
163
|
+
* locator grammar, logical store, analysis, environment, existence,
|
|
164
|
+
* completion and stored encoding must all match. Any mismatch is an
|
|
165
|
+
* integrity failure; the current pointer is never consulted.
|
|
166
|
+
*/
|
|
167
|
+
function resolveResult(reference, expectedScope) {
|
|
168
|
+
const parts = parseReference(reference);
|
|
169
|
+
if (parts.logicalStore !== logicalStore) {
|
|
170
|
+
throw new HistoryIntegrityError('Completed-result reference belongs to a different logical store');
|
|
171
|
+
}
|
|
172
|
+
if (expectedScope !== undefined && (parts.analysis !== expectedScope.analysis || parts.environment !== expectedScope.environment)) {
|
|
173
|
+
throw new HistoryIntegrityError('Completed-result reference belongs to a different analysis or environment');
|
|
174
|
+
}
|
|
175
|
+
const row = statements.reference.get(parts.resultId);
|
|
176
|
+
if (row === undefined) {
|
|
177
|
+
throw new HistoryIntegrityError(`Missing exact completed result ${reference.locator}`);
|
|
178
|
+
}
|
|
179
|
+
if (row.analysis !== parts.analysis || row.environment !== parts.environment) {
|
|
180
|
+
throw new HistoryIntegrityError('Completed-result reference scope does not match the stored result');
|
|
181
|
+
}
|
|
182
|
+
if (row.encoding !== payloadEncoding || row.index_version !== indexVersion) {
|
|
183
|
+
throw new HistoryIntegrityError(`Stored result uses unsupported encoding ${String(row.encoding)} or index version ${String(row.index_version)}`);
|
|
184
|
+
}
|
|
185
|
+
if (row.state !== 'completed') {
|
|
186
|
+
throw new HistoryIntegrityError('Stored result is not backed by a completed attempt');
|
|
187
|
+
}
|
|
188
|
+
return { resultId: parts.resultId, scope: { analysis: parts.analysis, environment: parts.environment } };
|
|
189
|
+
}
|
|
190
|
+
const index = createSelectedIndex(connection, options.sha256, (reference) => resolveResult(reference).resultId);
|
|
191
|
+
/** Issue the exact reference for a stored result row. */
|
|
192
|
+
function referenceOf(resultId, scope) {
|
|
193
|
+
return referenceFor({ logicalStore, analysis: scope.analysis, environment: scope.environment, resultId });
|
|
194
|
+
}
|
|
195
|
+
/** Whether a recorded promotion names a result for the environment `scope` (RUN-017). */
|
|
196
|
+
function isPromotedInto(resultId, scope) {
|
|
197
|
+
return statements.promoted.get(resultId, scope.analysis, scope.environment) !== undefined;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resolve an exact reference that must be admissible in `scope`: published
|
|
201
|
+
* in that environment, or of the same analysis and named by a promotion into
|
|
202
|
+
* it. Anything else is an integrity failure, never a miss; the returned
|
|
203
|
+
* scope is the result's own publishing scope.
|
|
204
|
+
*/
|
|
205
|
+
function resolveAdmissible(reference, scope) {
|
|
206
|
+
const resolved = resolveResult(reference);
|
|
207
|
+
const published = resolved.scope;
|
|
208
|
+
if (published.analysis !== scope.analysis || (published.environment !== scope.environment && !isPromotedInto(resolved.resultId, scope))) {
|
|
209
|
+
throw new HistoryIntegrityError(`Completed result ${reference.locator} is not admissible in environment ${scope.environment} of analysis ${scope.analysis}: it was not published there and no promotion names it there`);
|
|
210
|
+
}
|
|
211
|
+
return resolved;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The namespace an acceptance is recorded or read in, always named by the
|
|
215
|
+
* caller: the result's publishing environment, or another environment of
|
|
216
|
+
* the same analysis that a promotion admits the result into.
|
|
217
|
+
*/
|
|
218
|
+
function acceptanceScope(reference, resultId, published, environment) {
|
|
219
|
+
if (environment === published.environment) {
|
|
220
|
+
return published;
|
|
221
|
+
}
|
|
222
|
+
const scope = { analysis: published.analysis, environment };
|
|
223
|
+
if (!isPromotedInto(resultId, scope)) {
|
|
224
|
+
throw new HistoryIntegrityError(`Completed result ${reference.locator} is not admissible in environment ${environment} of analysis ${published.analysis}: it was not published there and no promotion names it there`);
|
|
225
|
+
}
|
|
226
|
+
return scope;
|
|
227
|
+
}
|
|
228
|
+
/** Read the singleton writer row. */
|
|
229
|
+
function readWriter() {
|
|
230
|
+
const row = statements.writer.get();
|
|
231
|
+
if (row === undefined) {
|
|
232
|
+
throw new HistoryIntegrityError('History writer row is missing');
|
|
233
|
+
}
|
|
234
|
+
const holder = row.holder;
|
|
235
|
+
if (holder !== null && typeof holder !== 'string') {
|
|
236
|
+
throw new HistoryIntegrityError('Stored writer holder is malformed');
|
|
237
|
+
}
|
|
238
|
+
return { lastFence: integer(row, 'last_fence'), holder: holder ?? null, expiresAt: integer(row, 'expires_at'), timeHighWater: integer(row, 'time_high_water') };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Evaluate "now" for a holder operation inside its transaction: the larger
|
|
242
|
+
* of a valid host reading and the persisted high-water, which is then
|
|
243
|
+
* persisted. A host failure or invalid reading aborts the transaction.
|
|
244
|
+
*/
|
|
245
|
+
function observeTime(writer) {
|
|
246
|
+
const reading = clock.currentEpochMilliseconds();
|
|
247
|
+
if (!Number.isSafeInteger(reading) || reading < 0) {
|
|
248
|
+
throw new HistoryClockError(`Clock reading ${String(reading)} is not a nonnegative safe integer of epoch milliseconds`);
|
|
249
|
+
}
|
|
250
|
+
const now = Math.max(reading, writer.timeHighWater);
|
|
251
|
+
if (now !== writer.timeHighWater) {
|
|
252
|
+
statements.observeTime.run(now);
|
|
253
|
+
}
|
|
254
|
+
return now;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The contention outcome of a lease operation whose transaction could not
|
|
258
|
+
* take SQLite's write lock (PUB-005). The recorded writer is read without
|
|
259
|
+
* the write lock; a read that is itself busy leaves it undefined rather than
|
|
260
|
+
* guessed, while any other read failure, such as a missing writer row, is
|
|
261
|
+
* thrown as the failure it is.
|
|
262
|
+
*/
|
|
263
|
+
function contention(busy) {
|
|
264
|
+
let writer;
|
|
265
|
+
try {
|
|
266
|
+
const recorded = readWriter();
|
|
267
|
+
writer = recorded.holder === null ? undefined : Object.freeze({ holder: recorded.holder, fence: recorded.lastFence, expiresAt: recorded.expiresAt });
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (!(error instanceof SqliteBusyError)) {
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return Object.freeze({ kind: 'contended', writer, detail: busy.message });
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Run a lease operation that a waiter repeats, reporting SQLite contention
|
|
278
|
+
* as History's typed outcome. Only the host's busy failure qualifies, and it
|
|
279
|
+
* guarantees the transaction changed nothing; every other failure is thrown.
|
|
280
|
+
*/
|
|
281
|
+
function orContended(operation) {
|
|
282
|
+
try {
|
|
283
|
+
return operation();
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (!(error instanceof SqliteBusyError)) {
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
return contention(error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Run one ownership-sensitive mutation. Inside one transaction the time is
|
|
294
|
+
* observed and holder, fence and unexpired lease are checked before
|
|
295
|
+
* `operation` runs. A stale lease commits only the time observation and then
|
|
296
|
+
* fails with {@link StaleWriterError}; any other failure rolls everything back.
|
|
297
|
+
*/
|
|
298
|
+
function asHolder(presented, operation) {
|
|
299
|
+
const lease = leaseArgument(presented);
|
|
300
|
+
let outcome;
|
|
301
|
+
let stale;
|
|
302
|
+
connection.transaction(() => {
|
|
303
|
+
const writer = readWriter();
|
|
304
|
+
const now = observeTime(writer);
|
|
305
|
+
if (writer.holder !== lease.holder || writer.lastFence !== lease.fence) {
|
|
306
|
+
stale = `Writer lease ${lease.holder}/${String(lease.fence)} is not the current holder`;
|
|
307
|
+
}
|
|
308
|
+
else if (writer.expiresAt <= now) {
|
|
309
|
+
stale = `Writer lease ${lease.holder}/${String(lease.fence)} expired at ${String(writer.expiresAt)}`;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
outcome = { value: operation(now) };
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
});
|
|
316
|
+
if (outcome === undefined) {
|
|
317
|
+
throw new StaleWriterError(stale ?? 'Writer lease is not current');
|
|
318
|
+
}
|
|
319
|
+
return outcome.value;
|
|
320
|
+
}
|
|
321
|
+
/** Run Supervision's journal ports share this connection and writer check. */
|
|
322
|
+
const journals = createJournalStore(connection, asHolder);
|
|
323
|
+
/** Read one attempt row by identity inside the caller's transaction. */
|
|
324
|
+
function requireAttemptRow(attemptId) {
|
|
325
|
+
const row = statements.attemptById.get(attemptId);
|
|
326
|
+
if (row === undefined) {
|
|
327
|
+
throw new AttemptStateError(`Unknown attempt ${String(attemptId)}`);
|
|
328
|
+
}
|
|
329
|
+
return row;
|
|
330
|
+
}
|
|
331
|
+
/** Convert a stored attempt row to its frozen record. */
|
|
332
|
+
function attemptOf(row) {
|
|
333
|
+
const scope = { analysis: text(row, 'analysis'), environment: text(row, 'environment') };
|
|
334
|
+
const attemptId = integer(row, 'attempt_id');
|
|
335
|
+
const state = attemptState(row);
|
|
336
|
+
const endedFence = row.ended_fence === null ? null : integer(row, 'ended_fence');
|
|
337
|
+
const outcome = row.outcome === null ? null : loadRecord(row.outcome_format, row.outcome_version, row.outcome, 'attempt outcome');
|
|
338
|
+
return Object.freeze({
|
|
339
|
+
...scope,
|
|
340
|
+
subject: text(row, 'subject'),
|
|
341
|
+
version: integer(row, 'version'),
|
|
342
|
+
attemptKey: text(row, 'attempt_key'),
|
|
343
|
+
intentDigest: text(row, 'intent_digest'),
|
|
344
|
+
attemptId,
|
|
345
|
+
state,
|
|
346
|
+
allocatedFence: integer(row, 'allocated_fence'),
|
|
347
|
+
endedFence,
|
|
348
|
+
result: state === 'completed' ? referenceOf(attemptId, scope) : null,
|
|
349
|
+
outcome,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A stored dependency published outside the dependent's environment is
|
|
354
|
+
* valid only when it is of the same analysis and a promotion admits it into
|
|
355
|
+
* the dependent's environment. Its reference keeps its own publishing scope
|
|
356
|
+
* and must resolve exactly like any other.
|
|
357
|
+
*/
|
|
358
|
+
function promotedDependency(dependencyId, row, scope) {
|
|
359
|
+
const published = { analysis: text(row, 'analysis'), environment: text(row, 'environment') };
|
|
360
|
+
if (published.analysis !== scope.analysis || !isPromotedInto(dependencyId, scope)) {
|
|
361
|
+
throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is outside the dependent's scope`);
|
|
362
|
+
}
|
|
363
|
+
const exact = referenceOf(dependencyId, published);
|
|
364
|
+
resolveResult(exact, published);
|
|
365
|
+
return exact;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Read an attempt's or acceptance's dependency rows and verify each still
|
|
369
|
+
* names a stored result admissible in the dependent's scope (published
|
|
370
|
+
* there or promoted into it), in contiguous recorded order.
|
|
371
|
+
*/
|
|
372
|
+
function dependenciesOf(rows, scope) {
|
|
373
|
+
return Object.freeze(rows.map((row, position) => {
|
|
374
|
+
if (integer(row, 'position') !== position) {
|
|
375
|
+
throw new HistoryIntegrityError('Stored dependencies are not in contiguous order');
|
|
376
|
+
}
|
|
377
|
+
const dependencyId = integer(row, 'dependency_id');
|
|
378
|
+
if (row.present === null) {
|
|
379
|
+
throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is dangling`);
|
|
380
|
+
}
|
|
381
|
+
if (row.analysis !== scope.analysis || row.environment !== scope.environment) {
|
|
382
|
+
return promotedDependency(dependencyId, row, scope);
|
|
383
|
+
}
|
|
384
|
+
// A returned dependency must satisfy the same exact contract as any reference: completed, in scope, supported.
|
|
385
|
+
const reference = referenceOf(dependencyId, scope);
|
|
386
|
+
resolveResult(reference, scope);
|
|
387
|
+
return reference;
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
/** Convert a stored result row to its frozen envelope, verifying its metadata. */
|
|
391
|
+
function envelopeOf(row) {
|
|
392
|
+
const resultId = integer(row, 'result_id');
|
|
393
|
+
const scope = { analysis: text(row, 'analysis'), environment: text(row, 'environment') };
|
|
394
|
+
// Candidate and exact metadata are authoritative only for a result that
|
|
395
|
+
// resolves exactly: a completed producing attempt, in scope, supported encoding.
|
|
396
|
+
resolveResult(referenceOf(resultId, scope), scope);
|
|
397
|
+
return Object.freeze({
|
|
398
|
+
...scope,
|
|
399
|
+
subject: text(row, 'subject'),
|
|
400
|
+
version: integer(row, 'version'),
|
|
401
|
+
reference: referenceOf(resultId, scope),
|
|
402
|
+
attemptId: resultId,
|
|
403
|
+
publication: integer(row, 'publication'),
|
|
404
|
+
encoding: payloadEncoding,
|
|
405
|
+
provenance: loadRecord(row.provenance_format, row.provenance_version, row.provenance, 'provenance'),
|
|
406
|
+
dependencies: dependenciesOf(statements.dependencies.all(resultId), scope),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/** Resolve each dependency reference to a stored result admissible in `scope`, inside the caller's transaction. */
|
|
410
|
+
function dependencyIds(references, scope) {
|
|
411
|
+
return references.map((reference) => resolveAdmissible(reference, scope).resultId);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Every path that reports an attempt's durable outcome to a caller
|
|
415
|
+
* (re-allocation of an existing key, re-publication, recovery), and every
|
|
416
|
+
* lifecycle transition (stage, publish, abandon) before it changes anything,
|
|
417
|
+
* first checks the attempt against stored results. A completed attempt's exact result must
|
|
418
|
+
* resolve; an attempt recorded as not completed must have no result row.
|
|
419
|
+
* Either contradiction is an integrity failure, never a success and never
|
|
420
|
+
* presented as incomplete work that a caller might resume.
|
|
421
|
+
*/
|
|
422
|
+
function verifiedAttempt(attempt) {
|
|
423
|
+
if (attempt.result !== null) {
|
|
424
|
+
resolveResult(attempt.result, attempt);
|
|
425
|
+
}
|
|
426
|
+
else if (statements.reference.get(attempt.attemptId) !== undefined) {
|
|
427
|
+
throw new HistoryIntegrityError(`Attempt ${String(attempt.attemptId)} is recorded as ${attempt.state} but has a published result`);
|
|
428
|
+
}
|
|
429
|
+
return attempt;
|
|
430
|
+
}
|
|
431
|
+
/** Recovery view of one attempt record. */
|
|
432
|
+
function recoveryOf(attempt) {
|
|
433
|
+
switch (attempt.state) {
|
|
434
|
+
case 'allocated':
|
|
435
|
+
case 'staged':
|
|
436
|
+
return Object.freeze({ kind: 'incomplete', attempt });
|
|
437
|
+
case 'failed':
|
|
438
|
+
case 'interrupted':
|
|
439
|
+
return Object.freeze({ kind: 'unsuccessful', attempt });
|
|
440
|
+
case 'completed':
|
|
441
|
+
if (attempt.result === null) {
|
|
442
|
+
throw new HistoryIntegrityError('Completed attempt has no result reference');
|
|
443
|
+
}
|
|
444
|
+
return Object.freeze({ kind: 'completed', attempt, reference: attempt.result });
|
|
445
|
+
default: {
|
|
446
|
+
const exhaustive = attempt.state;
|
|
447
|
+
return exhaustive;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** Reject a stored attempt whose identity differs from the request that names its key. */
|
|
452
|
+
function assertSameExecution(attempt, request) {
|
|
453
|
+
if (attempt.intentDigest !== request.intentDigest || attempt.version !== request.version) {
|
|
454
|
+
throw new AttemptConflictError(`Attempt key ${request.attemptKey} already identifies a different execution intent or version`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** Read and convert one acceptance record, recorded in `scope`, with its dependencies. */
|
|
458
|
+
function acceptanceOf(row, reference, scope) {
|
|
459
|
+
const acceptanceId = integer(row, 'acceptance_id');
|
|
460
|
+
if (text(row, 'environment') !== scope.environment) {
|
|
461
|
+
throw new HistoryIntegrityError(`Stored acceptance ${String(acceptanceId)} belongs to another environment`);
|
|
462
|
+
}
|
|
463
|
+
return Object.freeze({
|
|
464
|
+
acceptanceId,
|
|
465
|
+
reference,
|
|
466
|
+
environment: scope.environment,
|
|
467
|
+
fence: integer(row, 'fence'),
|
|
468
|
+
evidence: loadRecord(row.evidence_format, row.evidence_version, row.evidence, 'acceptance evidence'),
|
|
469
|
+
dependencies: dependenciesOf(statements.acceptanceDependencies.all(acceptanceId), scope),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/** Read the store-wide sequence row. */
|
|
473
|
+
function readSequences() {
|
|
474
|
+
const row = statements.sequences.get();
|
|
475
|
+
if (row === undefined) {
|
|
476
|
+
throw new HistoryIntegrityError('History sequence row is missing');
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
lastAttempt: integer(row, 'last_attempt'),
|
|
480
|
+
lastPublication: integer(row, 'last_publication'),
|
|
481
|
+
lastAcceptance: integer(row, 'last_acceptance'),
|
|
482
|
+
lastPromotion: integer(row, 'last_promotion'),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Convert one stored promotion row to its frozen record. Every named result
|
|
487
|
+
* must still resolve exactly, belong to the promotion's analysis and have
|
|
488
|
+
* been published outside its target environment.
|
|
489
|
+
*/
|
|
490
|
+
function promotionOf(row) {
|
|
491
|
+
const promotionId = integer(row, 'promotion_id');
|
|
492
|
+
const target = Object.freeze({ analysis: text(row, 'analysis'), environment: text(row, 'environment') });
|
|
493
|
+
const references = statements.promotionResults.all(promotionId).map((named, position) => {
|
|
494
|
+
const resultId = integer(named, 'result_id');
|
|
495
|
+
if (integer(named, 'position') !== position || named.present === null) {
|
|
496
|
+
throw new HistoryIntegrityError(`Stored promotion ${String(promotionId)} names a missing result or is out of order`);
|
|
497
|
+
}
|
|
498
|
+
const published = { analysis: text(named, 'analysis'), environment: text(named, 'environment') };
|
|
499
|
+
if (published.analysis !== target.analysis || published.environment === target.environment) {
|
|
500
|
+
throw new HistoryIntegrityError(`Stored promotion ${String(promotionId)} names result ${String(resultId)} outside its analysis or from its own target`);
|
|
501
|
+
}
|
|
502
|
+
const exact = referenceOf(resultId, published);
|
|
503
|
+
resolveResult(exact, published);
|
|
504
|
+
return exact;
|
|
505
|
+
});
|
|
506
|
+
if (references.length === 0) {
|
|
507
|
+
throw new HistoryIntegrityError(`Stored promotion ${String(promotionId)} names no result`);
|
|
508
|
+
}
|
|
509
|
+
return Object.freeze({
|
|
510
|
+
promotionId,
|
|
511
|
+
target,
|
|
512
|
+
fence: integer(row, 'fence'),
|
|
513
|
+
evidence: loadRecord(row.evidence_format, row.evidence_version, row.evidence, 'promotion evidence'),
|
|
514
|
+
references: Object.freeze(references),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
const history = {
|
|
518
|
+
logicalStore,
|
|
519
|
+
reader: index.reader,
|
|
520
|
+
acquireWriter(request) {
|
|
521
|
+
const holder = requireName(request.holder, 'holder');
|
|
522
|
+
const leaseMilliseconds = requirePositive(request.leaseMilliseconds, 'leaseMilliseconds');
|
|
523
|
+
let acquisition;
|
|
524
|
+
const busy = orContended(() => connection.transaction(() => {
|
|
525
|
+
const writer = readWriter();
|
|
526
|
+
const now = observeTime(writer);
|
|
527
|
+
if (writer.holder !== null && writer.expiresAt > now) {
|
|
528
|
+
acquisition = Object.freeze({ kind: 'held', holder: writer.holder, expiresAt: writer.expiresAt });
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
const fence = safeSum(writer.lastFence, 1, 'writer fence');
|
|
532
|
+
const expiresAt = safeSum(now, leaseMilliseconds, 'lease expiry');
|
|
533
|
+
statements.setHolder.run(fence, holder, expiresAt);
|
|
534
|
+
acquisition = Object.freeze({ kind: 'acquired', lease: Object.freeze({ holder, fence, expiresAt }) });
|
|
535
|
+
return undefined;
|
|
536
|
+
}));
|
|
537
|
+
if (busy !== undefined) {
|
|
538
|
+
return busy;
|
|
539
|
+
}
|
|
540
|
+
if (acquisition === undefined) {
|
|
541
|
+
throw new HistoryIntegrityError('Writer acquisition produced no outcome');
|
|
542
|
+
}
|
|
543
|
+
return acquisition;
|
|
544
|
+
},
|
|
545
|
+
renewWriter(lease, leaseMilliseconds) {
|
|
546
|
+
const duration = requirePositive(leaseMilliseconds, 'leaseMilliseconds');
|
|
547
|
+
return orContended(() => asHolder(lease, (now) => {
|
|
548
|
+
const expiresAt = safeSum(now, duration, 'lease expiry');
|
|
549
|
+
statements.extendHolder.run(expiresAt);
|
|
550
|
+
return Object.freeze({ kind: 'renewed', lease: Object.freeze({ holder: lease.holder, fence: lease.fence, expiresAt }) });
|
|
551
|
+
}));
|
|
552
|
+
},
|
|
553
|
+
releaseWriter(lease) {
|
|
554
|
+
asHolder(lease, (now) => {
|
|
555
|
+
// Release clears the holder but never touches the fence counter.
|
|
556
|
+
statements.clearHolder.run(now);
|
|
557
|
+
});
|
|
558
|
+
},
|
|
559
|
+
currentWriter() {
|
|
560
|
+
const writer = readWriter();
|
|
561
|
+
return writer.holder === null ? undefined : Object.freeze({ holder: writer.holder, fence: writer.lastFence, expiresAt: writer.expiresAt });
|
|
562
|
+
},
|
|
563
|
+
allocateAttempt(lease, presented) {
|
|
564
|
+
const request = attemptRequest(presented);
|
|
565
|
+
return asHolder(lease, () => {
|
|
566
|
+
const existing = statements.attemptByKey.get(request.analysis, request.environment, request.subject, request.attemptKey);
|
|
567
|
+
if (existing !== undefined) {
|
|
568
|
+
const attempt = attemptOf(existing);
|
|
569
|
+
assertSameExecution(attempt, request);
|
|
570
|
+
return verifiedAttempt(attempt);
|
|
571
|
+
}
|
|
572
|
+
const attemptId = safeSum(readSequences().lastAttempt, 1, 'attempt identity');
|
|
573
|
+
statements.setAttemptSequence.run(attemptId);
|
|
574
|
+
statements.insertAttempt.run(attemptId, request.analysis, request.environment, request.subject, request.version, request.attemptKey, request.intentDigest, lease.fence);
|
|
575
|
+
return attemptOf(requireAttemptRow(attemptId));
|
|
576
|
+
});
|
|
577
|
+
},
|
|
578
|
+
stageAttempt(lease, request) {
|
|
579
|
+
const attemptId = requirePositive(request.attemptId, 'attemptId');
|
|
580
|
+
const payload = encodeSnapshot(request.payload);
|
|
581
|
+
const root = decodeSnapshot(payload);
|
|
582
|
+
if (root === null || typeof root !== 'object') {
|
|
583
|
+
throw new TypeError('A completed result payload needs a record or array root');
|
|
584
|
+
}
|
|
585
|
+
const provenance = storeRecord(request.provenance, 'provenance');
|
|
586
|
+
const references = [...request.dependencies];
|
|
587
|
+
return asHolder(lease, () => {
|
|
588
|
+
const attempt = verifiedAttempt(attemptOf(requireAttemptRow(attemptId)));
|
|
589
|
+
if (attempt.state !== 'allocated') {
|
|
590
|
+
throw new AttemptStateError(`Attempt ${String(attemptId)} cannot be staged from state ${attempt.state}`);
|
|
591
|
+
}
|
|
592
|
+
const dependencies = dependencyIds(references, attempt);
|
|
593
|
+
statements.stageAttempt.run(payload, provenance.format, provenance.formatVersion, provenance.content, attemptId);
|
|
594
|
+
dependencies.forEach((dependencyId, position) => {
|
|
595
|
+
statements.insertDependency.run(attemptId, position, dependencyId);
|
|
596
|
+
});
|
|
597
|
+
return attemptOf(requireAttemptRow(attemptId));
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
publishAttempt(lease, presentedId) {
|
|
601
|
+
const attemptId = requirePositive(presentedId, 'attemptId');
|
|
602
|
+
return asHolder(lease, () => {
|
|
603
|
+
const row = requireAttemptRow(attemptId);
|
|
604
|
+
const attempt = verifiedAttempt(attemptOf(row));
|
|
605
|
+
if (attempt.state === 'completed') {
|
|
606
|
+
// Already verified above: its exact result resolves, so re-publication acknowledges it.
|
|
607
|
+
if (attempt.result === null) {
|
|
608
|
+
throw new HistoryIntegrityError(`Completed attempt ${String(attemptId)} has no result reference`);
|
|
609
|
+
}
|
|
610
|
+
return attempt.result;
|
|
611
|
+
}
|
|
612
|
+
if (attempt.state !== 'staged') {
|
|
613
|
+
throw new AttemptStateError(`Attempt ${String(attemptId)} cannot be published from state ${attempt.state}`);
|
|
614
|
+
}
|
|
615
|
+
const payload = text(row, 'staged_payload');
|
|
616
|
+
const root = decodeSnapshot(payload);
|
|
617
|
+
if (root === null || typeof root !== 'object') {
|
|
618
|
+
throw new HistoryIntegrityError('Staged payload root is not a container');
|
|
619
|
+
}
|
|
620
|
+
const rows = buildIndex(root, options.sha256);
|
|
621
|
+
const publication = safeSum(readSequences().lastPublication, 1, 'publication sequence');
|
|
622
|
+
statements.setPublicationSequence.run(publication);
|
|
623
|
+
statements.insertResult.run(attemptId, attempt.analysis, attempt.environment, attempt.subject, attempt.version, publication, lease.fence, payloadEncoding, indexVersion, payload, text(row, 'staged_provenance_format'), integer(row, 'staged_provenance_version'), text(row, 'staged_provenance'));
|
|
624
|
+
index.insert(attemptId, rows);
|
|
625
|
+
if (statements.completeAttempt.run(lease.fence, attemptId).changes !== 1) {
|
|
626
|
+
throw new HistoryIntegrityError(`Attempt ${String(attemptId)} changed state during publication`);
|
|
627
|
+
}
|
|
628
|
+
statements.setCurrent.run(attempt.analysis, attempt.environment, attempt.subject, attemptId);
|
|
629
|
+
return referenceOf(attemptId, attempt);
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
abandonAttempt(lease, request) {
|
|
633
|
+
const attemptId = requirePositive(request.attemptId, 'attemptId');
|
|
634
|
+
const outcome = request.outcome;
|
|
635
|
+
if (outcome !== 'failed' && outcome !== 'interrupted') {
|
|
636
|
+
throw new TypeError('An abandoned attempt outcome must be "failed" or "interrupted"');
|
|
637
|
+
}
|
|
638
|
+
const evidence = storeRecord(request.evidence, 'outcome evidence');
|
|
639
|
+
return asHolder(lease, () => {
|
|
640
|
+
const attempt = verifiedAttempt(attemptOf(requireAttemptRow(attemptId)));
|
|
641
|
+
if (attempt.state !== 'allocated' && attempt.state !== 'staged') {
|
|
642
|
+
throw new AttemptStateError(`Attempt ${String(attemptId)} cannot be abandoned from state ${attempt.state}`);
|
|
643
|
+
}
|
|
644
|
+
statements.abandonAttempt.run(outcome, lease.fence, evidence.format, evidence.formatVersion, evidence.content, attemptId);
|
|
645
|
+
return attemptOf(requireAttemptRow(attemptId));
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
recoverAttempt(presented) {
|
|
649
|
+
const request = attemptRequest(presented);
|
|
650
|
+
const row = statements.attemptByKey.get(request.analysis, request.environment, request.subject, request.attemptKey);
|
|
651
|
+
if (row === undefined) {
|
|
652
|
+
return Object.freeze({ kind: 'absent' });
|
|
653
|
+
}
|
|
654
|
+
const attempt = attemptOf(row);
|
|
655
|
+
assertSameExecution(attempt, request);
|
|
656
|
+
return recoveryOf(verifiedAttempt(attempt));
|
|
657
|
+
},
|
|
658
|
+
findCandidates(presented) {
|
|
659
|
+
const subject = versionedSubject(presented);
|
|
660
|
+
return Object.freeze(statements.candidates.all(subject.analysis, subject.subject, subject.version, subject.environment, subject.environment).map(envelopeOf));
|
|
661
|
+
},
|
|
662
|
+
readCurrent(presented) {
|
|
663
|
+
const subject = scopedSubject(presented);
|
|
664
|
+
const row = statements.current.get(subject.analysis, subject.environment, subject.subject);
|
|
665
|
+
if (row === undefined) {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
if (row.present === null) {
|
|
669
|
+
throw new HistoryIntegrityError('Current pointer names a missing or wrong-scope result');
|
|
670
|
+
}
|
|
671
|
+
const reference = referenceOf(integer(row, 'result_id'), subject);
|
|
672
|
+
// The latest publication must itself resolve exactly; a corrupted target is never offered as current.
|
|
673
|
+
resolveResult(reference, subject);
|
|
674
|
+
return reference;
|
|
675
|
+
},
|
|
676
|
+
readEnvelope(reference) {
|
|
677
|
+
const { resultId } = resolveResult(reference);
|
|
678
|
+
const row = statements.envelope.get(resultId);
|
|
679
|
+
if (row === undefined) {
|
|
680
|
+
throw new HistoryIntegrityError(`Missing exact completed result ${reference.locator}`);
|
|
681
|
+
}
|
|
682
|
+
return envelopeOf(row);
|
|
683
|
+
},
|
|
684
|
+
verifyResult(reference) {
|
|
685
|
+
const { resultId } = resolveResult(reference);
|
|
686
|
+
return index.verify(resultId);
|
|
687
|
+
},
|
|
688
|
+
recordAcceptance(lease, request) {
|
|
689
|
+
const evidence = storeRecord(request.evidence, 'acceptance evidence');
|
|
690
|
+
const references = [...request.dependencies];
|
|
691
|
+
const environment = requireName(request.environment, 'acceptance environment');
|
|
692
|
+
return asHolder(lease, () => {
|
|
693
|
+
const { resultId, scope: published } = resolveResult(request.reference);
|
|
694
|
+
// The acceptance and its dependencies belong to the named accepting environment's namespace.
|
|
695
|
+
const scope = acceptanceScope(request.reference, resultId, published, environment);
|
|
696
|
+
const dependencies = dependencyIds(references, scope);
|
|
697
|
+
const acceptanceId = safeSum(readSequences().lastAcceptance, 1, 'acceptance identity');
|
|
698
|
+
statements.setAcceptanceSequence.run(acceptanceId);
|
|
699
|
+
statements.insertAcceptance.run(acceptanceId, resultId, scope.environment, lease.fence, evidence.format, evidence.formatVersion, evidence.content);
|
|
700
|
+
dependencies.forEach((dependencyId, position) => {
|
|
701
|
+
statements.insertAcceptanceDependency.run(acceptanceId, position, dependencyId);
|
|
702
|
+
});
|
|
703
|
+
const row = statements.acceptances.all(resultId, scope.environment).find((candidate) => candidate.acceptance_id === acceptanceId);
|
|
704
|
+
if (row === undefined) {
|
|
705
|
+
throw new HistoryIntegrityError('Recorded acceptance is not readable');
|
|
706
|
+
}
|
|
707
|
+
return acceptanceOf(row, referenceOf(resultId, published), scope);
|
|
708
|
+
});
|
|
709
|
+
},
|
|
710
|
+
readAcceptances(reference, presentedEnvironment) {
|
|
711
|
+
const environment = requireName(presentedEnvironment, 'acceptance environment');
|
|
712
|
+
const { resultId, scope: published } = resolveResult(reference);
|
|
713
|
+
const scope = acceptanceScope(reference, resultId, published, environment);
|
|
714
|
+
const exact = referenceOf(resultId, published);
|
|
715
|
+
return Object.freeze(statements.acceptances.all(resultId, scope.environment).map((row) => acceptanceOf(row, exact, scope)));
|
|
716
|
+
},
|
|
717
|
+
promoteResults(lease, request) {
|
|
718
|
+
const target = historyScope(request.target);
|
|
719
|
+
const evidence = storeRecord(request.evidence, 'promotion evidence');
|
|
720
|
+
const presented = request.references;
|
|
721
|
+
if (!Array.isArray(presented) || presented.length === 0) {
|
|
722
|
+
throw new TypeError('A promotion needs at least one completed-result reference');
|
|
723
|
+
}
|
|
724
|
+
const references = [...request.references];
|
|
725
|
+
return asHolder(lease, () => {
|
|
726
|
+
const resultIds = references.map((reference) => {
|
|
727
|
+
const { resultId, scope: published } = resolveResult(reference);
|
|
728
|
+
// A reference that cannot be promoted into the target is one integrity class, like any wrong-scope reference.
|
|
729
|
+
if (published.analysis !== target.analysis) {
|
|
730
|
+
throw new HistoryIntegrityError(`Completed result ${reference.locator} belongs to another analysis; promotion never crosses analyses`);
|
|
731
|
+
}
|
|
732
|
+
if (published.environment === target.environment) {
|
|
733
|
+
throw new HistoryIntegrityError(`Completed result ${reference.locator} was published in ${target.environment}; only results of other environments can be promoted into it`);
|
|
734
|
+
}
|
|
735
|
+
return resultId;
|
|
736
|
+
});
|
|
737
|
+
if (new Set(resultIds).size !== resultIds.length) {
|
|
738
|
+
throw new TypeError('A promotion names each completed result at most once');
|
|
739
|
+
}
|
|
740
|
+
const promotionId = safeSum(readSequences().lastPromotion, 1, 'promotion identity');
|
|
741
|
+
statements.setPromotionSequence.run(promotionId);
|
|
742
|
+
statements.insertPromotion.run(promotionId, target.analysis, target.environment, lease.fence, evidence.format, evidence.formatVersion, evidence.content);
|
|
743
|
+
resultIds.forEach((resultId, position) => {
|
|
744
|
+
statements.insertPromotionResult.run(promotionId, position, resultId);
|
|
745
|
+
});
|
|
746
|
+
const row = statements.promotion.get(promotionId);
|
|
747
|
+
if (row === undefined) {
|
|
748
|
+
throw new HistoryIntegrityError('Recorded promotion is not readable');
|
|
749
|
+
}
|
|
750
|
+
return promotionOf(row);
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
readPromotions(query) {
|
|
754
|
+
const target = historyScope(query.target);
|
|
755
|
+
if (query.reference === undefined) {
|
|
756
|
+
return Object.freeze(statements.promotions.all(target.analysis, target.environment).map(promotionOf));
|
|
757
|
+
}
|
|
758
|
+
const { resultId } = resolveResult(query.reference);
|
|
759
|
+
return Object.freeze(statements.promotionsNaming.all(target.analysis, target.environment, resultId).map(promotionOf));
|
|
760
|
+
},
|
|
761
|
+
openJournal(declaration) {
|
|
762
|
+
return journals.open(declaration);
|
|
763
|
+
},
|
|
764
|
+
close() {
|
|
765
|
+
connection.close();
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
return Object.freeze(history);
|
|
769
|
+
}
|
|
770
|
+
//# sourceMappingURL=index.js.map
|