@microdelta/history 0.1.0 → 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/README.md +15 -3
- package/dist/api/history.alpha.d.ts +2 -2
- package/dist/api/history.root.raw.alpha.d.ts +380 -21
- package/dist/api/history.root.raw.beta.d.ts +40 -2
- package/dist/api/history.root.raw.public.d.ts +40 -2
- package/dist/api/history.root.raw.untrimmed.d.ts +380 -21
- package/dist/api/history.shared.alpha.d.ts +376 -19
- package/dist/api/history.shared.beta.d.ts +36 -0
- package/dist/api/history.shared.public.d.ts +36 -0
- package/dist/api/history.shared.untrimmed.d.ts +376 -19
- package/dist/api/history.untrimmed.d.ts +2 -2
- package/dist/src/durable/contracts.d.ts +292 -17
- package/dist/src/durable/contracts.d.ts.map +1 -1
- package/dist/src/durable/errors.d.ts +44 -3
- package/dist/src/durable/errors.d.ts.map +1 -1
- package/dist/src/durable/errors.js +43 -7
- package/dist/src/durable/errors.js.map +1 -1
- package/dist/src/durable/index.d.ts.map +1 -1
- package/dist/src/durable/index.js +299 -111
- package/dist/src/durable/index.js.map +1 -1
- 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/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 +14 -3
- package/dist/src/durable/schema.d.ts.map +1 -1
- package/dist/src/durable/schema.js +99 -21
- package/dist/src/durable/schema.js.map +1 -1
- package/dist/src/durable/selected-index.d.ts.map +1 -1
- package/dist/src/durable/selected-index.js +19 -5
- package/dist/src/durable/selected-index.js.map +1 -1
- package/dist/src/durable/sqlite-boundary.d.ts +11 -0
- package/dist/src/durable/sqlite-boundary.d.ts.map +1 -0
- package/dist/src/durable/sqlite-boundary.js +79 -0
- package/dist/src/durable/sqlite-boundary.js.map +1 -0
- package/dist/src/index.d.ts +7 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -3
- package/dist/src/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,41 +1,15 @@
|
|
|
1
1
|
import { decodeSnapshot, encodeSnapshot } from '@microdelta/value';
|
|
2
|
-
import { AttemptConflictError, AttemptStateError, HistoryClockError, HistoryIntegrityError, StaleWriterError, } from './errors.js';
|
|
2
|
+
import { AttemptConflictError, AttemptStateError, HistoryClockError, HistoryBusyError, HistoryIntegrityError, StaleWriterError, } from './errors.js';
|
|
3
3
|
import { parseReference, referenceFor } from './locator.js';
|
|
4
|
+
import { createJournalStore } from './journal.js';
|
|
5
|
+
import { historyScope, integer, loadRecord, requireName, requireNonnegative, requirePositive, safeSum, storeRecord, text } from './records.js';
|
|
4
6
|
import { initializeSchema } from './schema.js';
|
|
7
|
+
import { isHistorySqliteBoundaryBusy, openHistorySqlite } from './sqlite-boundary.js';
|
|
5
8
|
import { buildIndex, createSelectedIndex, indexVersion } from './selected-index.js';
|
|
6
9
|
/** Identity tag for embedded SQL; the text is passed through unchanged. */
|
|
7
10
|
const sql = String.raw;
|
|
8
11
|
/** The only payload encoding History stores for completed results. */
|
|
9
12
|
const payloadEncoding = 'MDS1';
|
|
10
|
-
/** Require a non-empty string argument. */
|
|
11
|
-
function requireName(value, name) {
|
|
12
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
13
|
-
throw new TypeError(`${name} must be a non-empty string`);
|
|
14
|
-
}
|
|
15
|
-
return value;
|
|
16
|
-
}
|
|
17
|
-
/** Require a positive safe-integer argument. */
|
|
18
|
-
function requirePositive(value, name) {
|
|
19
|
-
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
20
|
-
throw new TypeError(`${name} must be a positive safe integer`);
|
|
21
|
-
}
|
|
22
|
-
return value;
|
|
23
|
-
}
|
|
24
|
-
/** Require a nonnegative safe-integer argument. */
|
|
25
|
-
function requireNonnegative(value, name) {
|
|
26
|
-
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
27
|
-
throw new TypeError(`${name} must be a nonnegative safe integer`);
|
|
28
|
-
}
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
/** Add two safe integers or fail rather than round. */
|
|
32
|
-
function safeSum(left, right, name) {
|
|
33
|
-
const sum = left + right;
|
|
34
|
-
if (!Number.isSafeInteger(sum)) {
|
|
35
|
-
throw new RangeError(`${name} exceeds the safe integer range`);
|
|
36
|
-
}
|
|
37
|
-
return sum;
|
|
38
|
-
}
|
|
39
13
|
/** Copy and validate a scoped subject argument. */
|
|
40
14
|
function scopedSubject(value) {
|
|
41
15
|
return { analysis: requireName(value.analysis, 'analysis'), environment: requireName(value.environment, 'environment'), subject: requireName(value.subject, 'subject') };
|
|
@@ -52,44 +26,6 @@ function attemptRequest(value) {
|
|
|
52
26
|
function leaseArgument(lease) {
|
|
53
27
|
return { holder: requireName(lease.holder, 'lease holder'), fence: requirePositive(lease.fence, 'lease fence'), expiresAt: requireNonnegative(lease.expiresAt, 'lease expiry') };
|
|
54
28
|
}
|
|
55
|
-
/** Validate and canonicalize an owner-defined versioned record. */
|
|
56
|
-
function storeRecord(record, name) {
|
|
57
|
-
return {
|
|
58
|
-
format: requireName(record.format, `${name} format`),
|
|
59
|
-
formatVersion: requirePositive(record.formatVersion, `${name} format version`),
|
|
60
|
-
content: encodeSnapshot(record.content),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
/** Decode a stored versioned record into frozen data, or fail as corruption. */
|
|
64
|
-
function loadRecord(format, formatVersion, content, name) {
|
|
65
|
-
if (typeof format !== 'string' || format.length === 0 || typeof formatVersion !== 'number' || !Number.isSafeInteger(formatVersion) || formatVersion <= 0 || typeof content !== 'string') {
|
|
66
|
-
throw new HistoryIntegrityError(`Stored ${name} has a malformed version tag`);
|
|
67
|
-
}
|
|
68
|
-
let decoded;
|
|
69
|
-
try {
|
|
70
|
-
decoded = decodeSnapshot(content);
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
throw new HistoryIntegrityError(`Stored ${name} is not canonical MDS1: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
-
}
|
|
75
|
-
return Object.freeze({ format, formatVersion, content: decoded });
|
|
76
|
-
}
|
|
77
|
-
/** Narrow a stored cell to text. */
|
|
78
|
-
function text(row, column) {
|
|
79
|
-
const value = row[column];
|
|
80
|
-
if (typeof value !== 'string') {
|
|
81
|
-
throw new HistoryIntegrityError(`Stored History column ${column} is not text`);
|
|
82
|
-
}
|
|
83
|
-
return value;
|
|
84
|
-
}
|
|
85
|
-
/** Narrow a stored cell to a safe integer. */
|
|
86
|
-
function integer(row, column) {
|
|
87
|
-
const value = row[column];
|
|
88
|
-
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
89
|
-
throw new HistoryIntegrityError(`Stored History column ${column} is not an integer`);
|
|
90
|
-
}
|
|
91
|
-
return value;
|
|
92
|
-
}
|
|
93
29
|
/** Narrow a stored attempt state. */
|
|
94
30
|
function attemptState(row) {
|
|
95
31
|
const state = text(row, 'state');
|
|
@@ -107,22 +43,36 @@ function attemptState(row) {
|
|
|
107
43
|
export function openDurableHistory(options) {
|
|
108
44
|
const logicalStore = requireName(options.logicalStore, 'logicalStore');
|
|
109
45
|
const clock = options.clock;
|
|
110
|
-
const connection = options.sqlite
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
46
|
+
const connection = openHistorySqlite(options.sqlite, options.location);
|
|
47
|
+
/** Close a partly opened History handle without hiding the original opening failure. */
|
|
48
|
+
function duringOpening(operation) {
|
|
49
|
+
try {
|
|
50
|
+
return operation();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
try {
|
|
54
|
+
connection.close();
|
|
55
|
+
}
|
|
56
|
+
catch { /* The opening failure is the diagnostic owed to the caller. */ }
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
117
59
|
}
|
|
118
|
-
|
|
60
|
+
duringOpening(() => initializeSchema(connection, logicalStore));
|
|
61
|
+
const statements = duringOpening(() => ({
|
|
119
62
|
writer: connection.prepare(sql `/* writer */ SELECT last_fence, holder, expires_at, time_high_water FROM history_writer WHERE singleton = 1`),
|
|
120
63
|
observeTime: connection.prepare(sql `/* writer */ UPDATE history_writer SET time_high_water = ? WHERE singleton = 1`),
|
|
64
|
+
// Only a grant writes the fence. Renewal and release leave last_fence as
|
|
65
|
+
// they found it rather than writing back the presented lease's fence: the
|
|
66
|
+
// holder guard has already proved the two equal, and never assigning the
|
|
67
|
+
// column there means no future weakening of that guard could regress it.
|
|
121
68
|
setHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET last_fence = ?, holder = ?, expires_at = ? WHERE singleton = 1`),
|
|
122
|
-
|
|
69
|
+
extendHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET expires_at = ? WHERE singleton = 1`),
|
|
70
|
+
clearHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET holder = NULL, expires_at = ? WHERE singleton = 1`),
|
|
71
|
+
sequences: connection.prepare(sql `/* sequence */ SELECT last_attempt, last_publication, last_acceptance, last_promotion FROM history_sequences WHERE singleton = 1`),
|
|
123
72
|
setAttemptSequence: connection.prepare(sql `/* allocate */ UPDATE history_sequences SET last_attempt = ? WHERE singleton = 1`),
|
|
124
73
|
setPublicationSequence: connection.prepare(sql `/* publish */ UPDATE history_sequences SET last_publication = ? WHERE singleton = 1`),
|
|
125
74
|
setAcceptanceSequence: connection.prepare(sql `/* accept */ UPDATE history_sequences SET last_acceptance = ? WHERE singleton = 1`),
|
|
75
|
+
setPromotionSequence: connection.prepare(sql `/* promote */ UPDATE history_sequences SET last_promotion = ? WHERE singleton = 1`),
|
|
126
76
|
attemptByKey: connection.prepare(sql `/* attempt */ SELECT * FROM history_attempts WHERE analysis = ? AND environment = ? AND subject = ? AND attempt_key = ?`),
|
|
127
77
|
attemptById: connection.prepare(sql `/* attempt */ SELECT * FROM history_attempts WHERE attempt_id = ?`),
|
|
128
78
|
insertAttempt: connection.prepare(sql `/* allocate */ INSERT INTO history_attempts
|
|
@@ -147,20 +97,52 @@ export function openDurableHistory(options) {
|
|
|
147
97
|
FROM history_results r LEFT JOIN history_attempts a ON a.attempt_id = r.result_id WHERE r.result_id = ?`),
|
|
148
98
|
envelope: connection.prepare(sql `/* envelope */ SELECT result_id, analysis, environment, subject, version, publication, encoding, index_version,
|
|
149
99
|
provenance_format, provenance_version, provenance FROM history_results WHERE result_id = ?`),
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
100
|
+
// The local arm seeks the complete subject index. The promotion arm must
|
|
101
|
+
// visit target promotions before their results: its CROSS JOIN order avoids
|
|
102
|
+
// an analysis-wide result scan when no promotion exists. UNION removes a
|
|
103
|
+
// result admitted twice while retaining every distinct eligible result.
|
|
104
|
+
candidates: connection.prepare(sql `/* candidates */ WITH requested(analysis, environment, subject, version) AS (VALUES (?, ?, ?, ?))
|
|
105
|
+
SELECT * FROM (
|
|
106
|
+
SELECT r.result_id, r.analysis, r.environment, r.subject, r.version, r.publication, r.encoding, r.index_version,
|
|
107
|
+
r.provenance_format, r.provenance_version, r.provenance FROM requested s JOIN history_results r
|
|
108
|
+
ON r.analysis = s.analysis AND r.environment = s.environment AND r.subject = s.subject AND r.version = s.version
|
|
109
|
+
UNION
|
|
110
|
+
SELECT r.result_id, r.analysis, r.environment, r.subject, r.version, r.publication, r.encoding, r.index_version,
|
|
111
|
+
r.provenance_format, r.provenance_version, r.provenance FROM requested s CROSS JOIN history_promotions p
|
|
112
|
+
CROSS JOIN history_promotion_results pr CROSS JOIN history_results r
|
|
113
|
+
WHERE p.analysis = s.analysis AND p.environment = s.environment
|
|
114
|
+
AND pr.promotion_id = p.promotion_id AND r.result_id = pr.result_id
|
|
115
|
+
AND r.analysis = s.analysis AND r.subject = s.subject AND r.version = s.version
|
|
116
|
+
) ORDER BY publication DESC`),
|
|
153
117
|
dependencies: connection.prepare(sql `/* envelope */ SELECT d.position, d.dependency_id, r.result_id AS present, r.analysis, r.environment
|
|
154
118
|
FROM history_dependencies d LEFT JOIN history_results r ON r.result_id = d.dependency_id WHERE d.attempt_id = ? ORDER BY d.position`),
|
|
155
119
|
current: connection.prepare(sql `/* current */ SELECT c.result_id, r.result_id AS present
|
|
156
120
|
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
|
|
157
121
|
WHERE c.analysis = ? AND c.environment = ? AND c.subject = ?`),
|
|
158
|
-
insertAcceptance: connection.prepare(sql `/* accept */ INSERT INTO history_acceptances (acceptance_id, result_id, fence, evidence_format, evidence_version, evidence)
|
|
122
|
+
insertAcceptance: connection.prepare(sql `/* accept */ INSERT INTO history_acceptances (acceptance_id, result_id, environment, fence, evidence_format, evidence_version, evidence)
|
|
123
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
|
159
124
|
insertAcceptanceDependency: connection.prepare(sql `/* accept */ INSERT INTO history_acceptance_dependencies (acceptance_id, position, dependency_id) VALUES (?, ?, ?)`),
|
|
160
|
-
|
|
125
|
+
// An insertion is acknowledged by its exact identity within the intended result and environment, never by a history scan or newest-row guess.
|
|
126
|
+
acceptanceById: connection.prepare(sql `/* acceptance */ SELECT acceptance_id, result_id, environment, fence, evidence_format, evidence_version, evidence FROM history_acceptances
|
|
127
|
+
WHERE acceptance_id = ? AND result_id = ? AND environment = ?`),
|
|
128
|
+
// Explicit historical inspection remains complete and ordered, independently of the acknowledgement hot path.
|
|
129
|
+
acceptances: connection.prepare(sql `/* acceptance */ SELECT acceptance_id, environment, fence, evidence_format, evidence_version, evidence FROM history_acceptances
|
|
130
|
+
WHERE result_id = ? AND environment = ? ORDER BY acceptance_id`),
|
|
161
131
|
acceptanceDependencies: connection.prepare(sql `/* acceptance */ SELECT d.position, d.dependency_id, r.result_id AS present, r.analysis, r.environment
|
|
162
132
|
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`),
|
|
163
|
-
|
|
133
|
+
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
|
|
134
|
+
WHERE pr.result_id = ? AND p.analysis = ? AND p.environment = ? LIMIT 1`),
|
|
135
|
+
insertPromotion: connection.prepare(sql `/* promote */ INSERT INTO history_promotions (promotion_id, analysis, environment, fence, evidence_format, evidence_version, evidence)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
|
137
|
+
insertPromotionResult: connection.prepare(sql `/* promote */ INSERT INTO history_promotion_results (promotion_id, position, result_id) VALUES (?, ?, ?)`),
|
|
138
|
+
promotion: connection.prepare(sql `/* promotion */ SELECT * FROM history_promotions WHERE promotion_id = ?`),
|
|
139
|
+
promotions: connection.prepare(sql `/* promotion */ SELECT * FROM history_promotions WHERE analysis = ? AND environment = ? ORDER BY promotion_id`),
|
|
140
|
+
promotionsNaming: connection.prepare(sql `/* promotion */ SELECT p.* FROM history_promotions p
|
|
141
|
+
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 = ?)
|
|
142
|
+
ORDER BY p.promotion_id`),
|
|
143
|
+
promotionResults: connection.prepare(sql `/* promotion */ SELECT pr.position, pr.result_id, r.result_id AS present, r.analysis, r.environment
|
|
144
|
+
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`),
|
|
145
|
+
}));
|
|
164
146
|
/**
|
|
165
147
|
* Resolve an exact reference to a completed result of this store: the
|
|
166
148
|
* locator grammar, logical store, analysis, environment, existence,
|
|
@@ -190,11 +172,44 @@ export function openDurableHistory(options) {
|
|
|
190
172
|
}
|
|
191
173
|
return { resultId: parts.resultId, scope: { analysis: parts.analysis, environment: parts.environment } };
|
|
192
174
|
}
|
|
193
|
-
const index = createSelectedIndex(connection, options.sha256, (reference) => resolveResult(reference).resultId);
|
|
175
|
+
const index = duringOpening(() => createSelectedIndex(connection, options.sha256, (reference) => resolveResult(reference).resultId));
|
|
194
176
|
/** Issue the exact reference for a stored result row. */
|
|
195
177
|
function referenceOf(resultId, scope) {
|
|
196
178
|
return referenceFor({ logicalStore, analysis: scope.analysis, environment: scope.environment, resultId });
|
|
197
179
|
}
|
|
180
|
+
/** Whether a recorded promotion names a result for the environment `scope` (RUN-017). */
|
|
181
|
+
function isPromotedInto(resultId, scope) {
|
|
182
|
+
return statements.promoted.get(resultId, scope.analysis, scope.environment) !== undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Resolve an exact reference that must be admissible in `scope`: published
|
|
186
|
+
* in that environment, or of the same analysis and named by a promotion into
|
|
187
|
+
* it. Anything else is an integrity failure, never a miss; the returned
|
|
188
|
+
* scope is the result's own publishing scope.
|
|
189
|
+
*/
|
|
190
|
+
function resolveAdmissible(reference, scope) {
|
|
191
|
+
const resolved = resolveResult(reference);
|
|
192
|
+
const published = resolved.scope;
|
|
193
|
+
if (published.analysis !== scope.analysis || (published.environment !== scope.environment && !isPromotedInto(resolved.resultId, scope))) {
|
|
194
|
+
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`);
|
|
195
|
+
}
|
|
196
|
+
return resolved;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The namespace an acceptance is recorded or read in, always named by the
|
|
200
|
+
* caller: the result's publishing environment, or another environment of
|
|
201
|
+
* the same analysis that a promotion admits the result into.
|
|
202
|
+
*/
|
|
203
|
+
function acceptanceScope(reference, resultId, published, environment) {
|
|
204
|
+
if (environment === published.environment) {
|
|
205
|
+
return published;
|
|
206
|
+
}
|
|
207
|
+
const scope = { analysis: published.analysis, environment };
|
|
208
|
+
if (!isPromotedInto(resultId, scope)) {
|
|
209
|
+
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`);
|
|
210
|
+
}
|
|
211
|
+
return scope;
|
|
212
|
+
}
|
|
198
213
|
/** Read the singleton writer row. */
|
|
199
214
|
function readWriter() {
|
|
200
215
|
const row = statements.writer.get();
|
|
@@ -212,8 +227,8 @@ export function openDurableHistory(options) {
|
|
|
212
227
|
* of a valid host reading and the persisted high-water, which is then
|
|
213
228
|
* persisted. A host failure or invalid reading aborts the transaction.
|
|
214
229
|
*/
|
|
215
|
-
function observeTime(writer) {
|
|
216
|
-
const reading =
|
|
230
|
+
function observeTime(writer, readClock = () => clock.currentEpochMilliseconds()) {
|
|
231
|
+
const reading = readClock();
|
|
217
232
|
if (!Number.isSafeInteger(reading) || reading < 0) {
|
|
218
233
|
throw new HistoryClockError(`Clock reading ${String(reading)} is not a nonnegative safe integer of epoch milliseconds`);
|
|
219
234
|
}
|
|
@@ -223,19 +238,68 @@ export function openDurableHistory(options) {
|
|
|
223
238
|
}
|
|
224
239
|
return now;
|
|
225
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The contention outcome of a lease operation whose transaction could not
|
|
243
|
+
* take SQLite's write lock (PUB-005). The recorded writer is read without
|
|
244
|
+
* the write lock; a read that is itself busy leaves it undefined rather than
|
|
245
|
+
* guessed, while any other read failure, such as a missing writer row, is
|
|
246
|
+
* thrown as the failure it is.
|
|
247
|
+
*/
|
|
248
|
+
function contention(busy) {
|
|
249
|
+
let writer;
|
|
250
|
+
try {
|
|
251
|
+
const recorded = readWriter();
|
|
252
|
+
writer = recorded.holder === null ? undefined : Object.freeze({ holder: recorded.holder, fence: recorded.lastFence, expiresAt: recorded.expiresAt });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (!isHistorySqliteBoundaryBusy(error)) {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return Object.freeze({ kind: 'contended', writer, detail: busy.message });
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Run a lease operation that a waiter repeats, reporting SQLite contention
|
|
263
|
+
* as History's typed outcome. A clock callback's exact exception remains a
|
|
264
|
+
* callback failure even if it contains a busy error from this or another
|
|
265
|
+
* History handle; only boundary errors outside that callback qualify.
|
|
266
|
+
*/
|
|
267
|
+
function orContended(operation) {
|
|
268
|
+
let callbackFailed = false;
|
|
269
|
+
let callbackError;
|
|
270
|
+
const readClock = () => {
|
|
271
|
+
try {
|
|
272
|
+
return clock.currentEpochMilliseconds();
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
callbackFailed = true;
|
|
276
|
+
callbackError = error;
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
try {
|
|
281
|
+
return operation(readClock);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if ((callbackFailed && error === callbackError) || !isHistorySqliteBoundaryBusy(error)) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return contention(error);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
226
290
|
/**
|
|
227
291
|
* Run one ownership-sensitive mutation. Inside one transaction the time is
|
|
228
292
|
* observed and holder, fence and unexpired lease are checked before
|
|
229
293
|
* `operation` runs. A stale lease commits only the time observation and then
|
|
230
294
|
* fails with {@link StaleWriterError}; any other failure rolls everything back.
|
|
231
295
|
*/
|
|
232
|
-
function asHolder(presented, operation) {
|
|
296
|
+
function asHolder(presented, operation, readClock = () => clock.currentEpochMilliseconds()) {
|
|
233
297
|
const lease = leaseArgument(presented);
|
|
234
298
|
let outcome;
|
|
235
299
|
let stale;
|
|
236
300
|
connection.transaction(() => {
|
|
237
301
|
const writer = readWriter();
|
|
238
|
-
const now = observeTime(writer);
|
|
302
|
+
const now = observeTime(writer, readClock);
|
|
239
303
|
if (writer.holder !== lease.holder || writer.lastFence !== lease.fence) {
|
|
240
304
|
stale = `Writer lease ${lease.holder}/${String(lease.fence)} is not the current holder`;
|
|
241
305
|
}
|
|
@@ -252,6 +316,8 @@ export function openDurableHistory(options) {
|
|
|
252
316
|
}
|
|
253
317
|
return outcome.value;
|
|
254
318
|
}
|
|
319
|
+
/** Run Supervision's journal ports share this connection and writer check. */
|
|
320
|
+
const journals = duringOpening(() => createJournalStore(connection, asHolder));
|
|
255
321
|
/** Read one attempt row by identity inside the caller's transaction. */
|
|
256
322
|
function requireAttemptRow(attemptId) {
|
|
257
323
|
const row = statements.attemptById.get(attemptId);
|
|
@@ -281,9 +347,25 @@ export function openDurableHistory(options) {
|
|
|
281
347
|
outcome,
|
|
282
348
|
});
|
|
283
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* A stored dependency published outside the dependent's environment is
|
|
352
|
+
* valid only when it is of the same analysis and a promotion admits it into
|
|
353
|
+
* the dependent's environment. Its reference keeps its own publishing scope
|
|
354
|
+
* and must resolve exactly like any other.
|
|
355
|
+
*/
|
|
356
|
+
function promotedDependency(dependencyId, row, scope) {
|
|
357
|
+
const published = { analysis: text(row, 'analysis'), environment: text(row, 'environment') };
|
|
358
|
+
if (published.analysis !== scope.analysis || !isPromotedInto(dependencyId, scope)) {
|
|
359
|
+
throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is outside the dependent's scope`);
|
|
360
|
+
}
|
|
361
|
+
const exact = referenceOf(dependencyId, published);
|
|
362
|
+
resolveResult(exact, published);
|
|
363
|
+
return exact;
|
|
364
|
+
}
|
|
284
365
|
/**
|
|
285
366
|
* Read an attempt's or acceptance's dependency rows and verify each still
|
|
286
|
-
* names a stored result in the
|
|
367
|
+
* names a stored result admissible in the dependent's scope (published
|
|
368
|
+
* there or promoted into it), in contiguous recorded order.
|
|
287
369
|
*/
|
|
288
370
|
function dependenciesOf(rows, scope) {
|
|
289
371
|
return Object.freeze(rows.map((row, position) => {
|
|
@@ -295,7 +377,7 @@ export function openDurableHistory(options) {
|
|
|
295
377
|
throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is dangling`);
|
|
296
378
|
}
|
|
297
379
|
if (row.analysis !== scope.analysis || row.environment !== scope.environment) {
|
|
298
|
-
|
|
380
|
+
return promotedDependency(dependencyId, row, scope);
|
|
299
381
|
}
|
|
300
382
|
// A returned dependency must satisfy the same exact contract as any reference: completed, in scope, supported.
|
|
301
383
|
const reference = referenceOf(dependencyId, scope);
|
|
@@ -322,9 +404,9 @@ export function openDurableHistory(options) {
|
|
|
322
404
|
dependencies: dependenciesOf(statements.dependencies.all(resultId), scope),
|
|
323
405
|
});
|
|
324
406
|
}
|
|
325
|
-
/** Resolve each dependency reference to a stored result in `scope`, inside the caller's transaction. */
|
|
407
|
+
/** Resolve each dependency reference to a stored result admissible in `scope`, inside the caller's transaction. */
|
|
326
408
|
function dependencyIds(references, scope) {
|
|
327
|
-
return references.map((reference) =>
|
|
409
|
+
return references.map((reference) => resolveAdmissible(reference, scope).resultId);
|
|
328
410
|
}
|
|
329
411
|
/**
|
|
330
412
|
* Every path that reports an attempt's durable outcome to a caller
|
|
@@ -370,12 +452,16 @@ export function openDurableHistory(options) {
|
|
|
370
452
|
throw new AttemptConflictError(`Attempt key ${request.attemptKey} already identifies a different execution intent or version`);
|
|
371
453
|
}
|
|
372
454
|
}
|
|
373
|
-
/** Read and convert one acceptance record
|
|
455
|
+
/** Read and convert one acceptance record, recorded in `scope`, with its dependencies. */
|
|
374
456
|
function acceptanceOf(row, reference, scope) {
|
|
375
457
|
const acceptanceId = integer(row, 'acceptance_id');
|
|
458
|
+
if (text(row, 'environment') !== scope.environment) {
|
|
459
|
+
throw new HistoryIntegrityError(`Stored acceptance ${String(acceptanceId)} belongs to another environment`);
|
|
460
|
+
}
|
|
376
461
|
return Object.freeze({
|
|
377
462
|
acceptanceId,
|
|
378
463
|
reference,
|
|
464
|
+
environment: scope.environment,
|
|
379
465
|
fence: integer(row, 'fence'),
|
|
380
466
|
evidence: loadRecord(row.evidence_format, row.evidence_version, row.evidence, 'acceptance evidence'),
|
|
381
467
|
dependencies: dependenciesOf(statements.acceptanceDependencies.all(acceptanceId), scope),
|
|
@@ -387,7 +473,44 @@ export function openDurableHistory(options) {
|
|
|
387
473
|
if (row === undefined) {
|
|
388
474
|
throw new HistoryIntegrityError('History sequence row is missing');
|
|
389
475
|
}
|
|
390
|
-
return {
|
|
476
|
+
return {
|
|
477
|
+
lastAttempt: integer(row, 'last_attempt'),
|
|
478
|
+
lastPublication: integer(row, 'last_publication'),
|
|
479
|
+
lastAcceptance: integer(row, 'last_acceptance'),
|
|
480
|
+
lastPromotion: integer(row, 'last_promotion'),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Convert one stored promotion row to its frozen record. Every named result
|
|
485
|
+
* must still resolve exactly, belong to the promotion's analysis and have
|
|
486
|
+
* been published outside its target environment.
|
|
487
|
+
*/
|
|
488
|
+
function promotionOf(row) {
|
|
489
|
+
const promotionId = integer(row, 'promotion_id');
|
|
490
|
+
const target = Object.freeze({ analysis: text(row, 'analysis'), environment: text(row, 'environment') });
|
|
491
|
+
const references = statements.promotionResults.all(promotionId).map((named, position) => {
|
|
492
|
+
const resultId = integer(named, 'result_id');
|
|
493
|
+
if (integer(named, 'position') !== position || named.present === null) {
|
|
494
|
+
throw new HistoryIntegrityError(`Stored promotion ${String(promotionId)} names a missing result or is out of order`);
|
|
495
|
+
}
|
|
496
|
+
const published = { analysis: text(named, 'analysis'), environment: text(named, 'environment') };
|
|
497
|
+
if (published.analysis !== target.analysis || published.environment === target.environment) {
|
|
498
|
+
throw new HistoryIntegrityError(`Stored promotion ${String(promotionId)} names result ${String(resultId)} outside its analysis or from its own target`);
|
|
499
|
+
}
|
|
500
|
+
const exact = referenceOf(resultId, published);
|
|
501
|
+
resolveResult(exact, published);
|
|
502
|
+
return exact;
|
|
503
|
+
});
|
|
504
|
+
if (references.length === 0) {
|
|
505
|
+
throw new HistoryIntegrityError(`Stored promotion ${String(promotionId)} names no result`);
|
|
506
|
+
}
|
|
507
|
+
return Object.freeze({
|
|
508
|
+
promotionId,
|
|
509
|
+
target,
|
|
510
|
+
fence: integer(row, 'fence'),
|
|
511
|
+
evidence: loadRecord(row.evidence_format, row.evidence_version, row.evidence, 'promotion evidence'),
|
|
512
|
+
references: Object.freeze(references),
|
|
513
|
+
});
|
|
391
514
|
}
|
|
392
515
|
const history = {
|
|
393
516
|
logicalStore,
|
|
@@ -396,9 +519,9 @@ export function openDurableHistory(options) {
|
|
|
396
519
|
const holder = requireName(request.holder, 'holder');
|
|
397
520
|
const leaseMilliseconds = requirePositive(request.leaseMilliseconds, 'leaseMilliseconds');
|
|
398
521
|
let acquisition;
|
|
399
|
-
connection.transaction(() => {
|
|
522
|
+
const busy = orContended((readClock) => connection.transaction(() => {
|
|
400
523
|
const writer = readWriter();
|
|
401
|
-
const now = observeTime(writer);
|
|
524
|
+
const now = observeTime(writer, readClock);
|
|
402
525
|
if (writer.holder !== null && writer.expiresAt > now) {
|
|
403
526
|
acquisition = Object.freeze({ kind: 'held', holder: writer.holder, expiresAt: writer.expiresAt });
|
|
404
527
|
return undefined;
|
|
@@ -408,7 +531,10 @@ export function openDurableHistory(options) {
|
|
|
408
531
|
statements.setHolder.run(fence, holder, expiresAt);
|
|
409
532
|
acquisition = Object.freeze({ kind: 'acquired', lease: Object.freeze({ holder, fence, expiresAt }) });
|
|
410
533
|
return undefined;
|
|
411
|
-
});
|
|
534
|
+
}));
|
|
535
|
+
if (busy !== undefined) {
|
|
536
|
+
return busy;
|
|
537
|
+
}
|
|
412
538
|
if (acquisition === undefined) {
|
|
413
539
|
throw new HistoryIntegrityError('Writer acquisition produced no outcome');
|
|
414
540
|
}
|
|
@@ -416,16 +542,18 @@ export function openDurableHistory(options) {
|
|
|
416
542
|
},
|
|
417
543
|
renewWriter(lease, leaseMilliseconds) {
|
|
418
544
|
const duration = requirePositive(leaseMilliseconds, 'leaseMilliseconds');
|
|
419
|
-
|
|
545
|
+
// Read caller-owned lease properties before classifying storage contention.
|
|
546
|
+
const presented = leaseArgument(lease);
|
|
547
|
+
return orContended((readClock) => asHolder(presented, (now) => {
|
|
420
548
|
const expiresAt = safeSum(now, duration, 'lease expiry');
|
|
421
|
-
statements.
|
|
422
|
-
return Object.freeze({
|
|
423
|
-
});
|
|
549
|
+
statements.extendHolder.run(expiresAt);
|
|
550
|
+
return Object.freeze({ kind: 'renewed', lease: Object.freeze({ holder: presented.holder, fence: presented.fence, expiresAt }) });
|
|
551
|
+
}, readClock));
|
|
424
552
|
},
|
|
425
553
|
releaseWriter(lease) {
|
|
426
554
|
asHolder(lease, (now) => {
|
|
427
|
-
// Release clears the holder but never
|
|
428
|
-
statements.
|
|
555
|
+
// Release clears the holder but never touches the fence counter.
|
|
556
|
+
statements.clearHolder.run(now);
|
|
429
557
|
});
|
|
430
558
|
},
|
|
431
559
|
currentWriter() {
|
|
@@ -560,26 +688,86 @@ export function openDurableHistory(options) {
|
|
|
560
688
|
recordAcceptance(lease, request) {
|
|
561
689
|
const evidence = storeRecord(request.evidence, 'acceptance evidence');
|
|
562
690
|
const references = [...request.dependencies];
|
|
691
|
+
const environment = requireName(request.environment, 'acceptance environment');
|
|
563
692
|
return asHolder(lease, () => {
|
|
564
|
-
const { resultId, scope } = resolveResult(request.reference);
|
|
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);
|
|
565
696
|
const dependencies = dependencyIds(references, scope);
|
|
566
697
|
const acceptanceId = safeSum(readSequences().lastAcceptance, 1, 'acceptance identity');
|
|
567
698
|
statements.setAcceptanceSequence.run(acceptanceId);
|
|
568
|
-
statements.insertAcceptance.run(acceptanceId, resultId, lease.fence, evidence.format, evidence.formatVersion, evidence.content);
|
|
699
|
+
statements.insertAcceptance.run(acceptanceId, resultId, scope.environment, lease.fence, evidence.format, evidence.formatVersion, evidence.content);
|
|
569
700
|
dependencies.forEach((dependencyId, position) => {
|
|
570
701
|
statements.insertAcceptanceDependency.run(acceptanceId, position, dependencyId);
|
|
571
702
|
});
|
|
572
|
-
const row = statements.
|
|
703
|
+
const row = statements.acceptanceById.get(acceptanceId, resultId, scope.environment);
|
|
573
704
|
if (row === undefined) {
|
|
574
705
|
throw new HistoryIntegrityError('Recorded acceptance is not readable');
|
|
575
706
|
}
|
|
576
|
-
|
|
707
|
+
// The returned row must attest to this transaction's inserted identity, scope, fence, and evidence;
|
|
708
|
+
// a stale or mis-scoped row is not a successful acceptance even if the backend returned one row.
|
|
709
|
+
if (integer(row, 'acceptance_id') !== acceptanceId || integer(row, 'result_id') !== resultId
|
|
710
|
+
|| text(row, 'environment') !== scope.environment || integer(row, 'fence') !== lease.fence
|
|
711
|
+
|| text(row, 'evidence_format') !== evidence.format || integer(row, 'evidence_version') !== evidence.formatVersion
|
|
712
|
+
|| text(row, 'evidence') !== evidence.content) {
|
|
713
|
+
throw new HistoryIntegrityError('Recorded acceptance readback does not match the inserted record');
|
|
714
|
+
}
|
|
715
|
+
return acceptanceOf(row, referenceOf(resultId, published), scope);
|
|
716
|
+
});
|
|
717
|
+
},
|
|
718
|
+
readAcceptances(reference, presentedEnvironment) {
|
|
719
|
+
const environment = requireName(presentedEnvironment, 'acceptance environment');
|
|
720
|
+
const { resultId, scope: published } = resolveResult(reference);
|
|
721
|
+
const scope = acceptanceScope(reference, resultId, published, environment);
|
|
722
|
+
const exact = referenceOf(resultId, published);
|
|
723
|
+
return Object.freeze(statements.acceptances.all(resultId, scope.environment).map((row) => acceptanceOf(row, exact, scope)));
|
|
724
|
+
},
|
|
725
|
+
promoteResults(lease, request) {
|
|
726
|
+
const target = historyScope(request.target);
|
|
727
|
+
const evidence = storeRecord(request.evidence, 'promotion evidence');
|
|
728
|
+
const presented = request.references;
|
|
729
|
+
if (!Array.isArray(presented) || presented.length === 0) {
|
|
730
|
+
throw new TypeError('A promotion needs at least one completed-result reference');
|
|
731
|
+
}
|
|
732
|
+
const references = [...request.references];
|
|
733
|
+
return asHolder(lease, () => {
|
|
734
|
+
const resultIds = references.map((reference) => {
|
|
735
|
+
const { resultId, scope: published } = resolveResult(reference);
|
|
736
|
+
// A reference that cannot be promoted into the target is one integrity class, like any wrong-scope reference.
|
|
737
|
+
if (published.analysis !== target.analysis) {
|
|
738
|
+
throw new HistoryIntegrityError(`Completed result ${reference.locator} belongs to another analysis; promotion never crosses analyses`);
|
|
739
|
+
}
|
|
740
|
+
if (published.environment === target.environment) {
|
|
741
|
+
throw new HistoryIntegrityError(`Completed result ${reference.locator} was published in ${target.environment}; only results of other environments can be promoted into it`);
|
|
742
|
+
}
|
|
743
|
+
return resultId;
|
|
744
|
+
});
|
|
745
|
+
if (new Set(resultIds).size !== resultIds.length) {
|
|
746
|
+
throw new TypeError('A promotion names each completed result at most once');
|
|
747
|
+
}
|
|
748
|
+
const promotionId = safeSum(readSequences().lastPromotion, 1, 'promotion identity');
|
|
749
|
+
statements.setPromotionSequence.run(promotionId);
|
|
750
|
+
statements.insertPromotion.run(promotionId, target.analysis, target.environment, lease.fence, evidence.format, evidence.formatVersion, evidence.content);
|
|
751
|
+
resultIds.forEach((resultId, position) => {
|
|
752
|
+
statements.insertPromotionResult.run(promotionId, position, resultId);
|
|
753
|
+
});
|
|
754
|
+
const row = statements.promotion.get(promotionId);
|
|
755
|
+
if (row === undefined) {
|
|
756
|
+
throw new HistoryIntegrityError('Recorded promotion is not readable');
|
|
757
|
+
}
|
|
758
|
+
return promotionOf(row);
|
|
577
759
|
});
|
|
578
760
|
},
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
761
|
+
readPromotions(query) {
|
|
762
|
+
const target = historyScope(query.target);
|
|
763
|
+
if (query.reference === undefined) {
|
|
764
|
+
return Object.freeze(statements.promotions.all(target.analysis, target.environment).map(promotionOf));
|
|
765
|
+
}
|
|
766
|
+
const { resultId } = resolveResult(query.reference);
|
|
767
|
+
return Object.freeze(statements.promotionsNaming.all(target.analysis, target.environment, resultId).map(promotionOf));
|
|
768
|
+
},
|
|
769
|
+
openJournal(declaration) {
|
|
770
|
+
return journals.open(declaration);
|
|
583
771
|
},
|
|
584
772
|
close() {
|
|
585
773
|
connection.close();
|