@microdelta/history 0.1.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 +14 -3
- package/dist/api/history.alpha.d.ts +2 -2
- package/dist/api/history.root.raw.alpha.d.ts +332 -19
- package/dist/api/history.root.raw.beta.d.ts +34 -2
- package/dist/api/history.root.raw.public.d.ts +34 -2
- package/dist/api/history.root.raw.untrimmed.d.ts +332 -19
- package/dist/api/history.shared.alpha.d.ts +328 -17
- package/dist/api/history.shared.beta.d.ts +30 -0
- package/dist/api/history.shared.public.d.ts +30 -0
- package/dist/api/history.shared.untrimmed.d.ts +328 -17
- package/dist/api/history.untrimmed.d.ts +2 -2
- package/dist/src/durable/contracts.d.ts +290 -17
- package/dist/src/durable/contracts.d.ts.map +1 -1
- package/dist/src/durable/errors.d.ts +26 -1
- package/dist/src/durable/errors.d.ts.map +1 -1
- package/dist/src/durable/errors.js +32 -1
- 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 +275 -95
- 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 +13 -4
- package/dist/src/durable/schema.d.ts.map +1 -1
- package/dist/src/durable/schema.js +74 -5
- 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 +3 -1
- package/dist/src/durable/selected-index.js.map +1 -1
- package/dist/src/index.d.ts +6 -4
- 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,51 @@
|
|
|
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';
|
|
1
38
|
import { decodeSnapshot, encodeSnapshot } from '@microdelta/value';
|
|
2
39
|
import { AttemptConflictError, AttemptStateError, HistoryClockError, HistoryIntegrityError, StaleWriterError, } from './errors.js';
|
|
3
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';
|
|
4
43
|
import { initializeSchema } from './schema.js';
|
|
5
44
|
import { buildIndex, createSelectedIndex, indexVersion } from './selected-index.js';
|
|
6
45
|
/** Identity tag for embedded SQL; the text is passed through unchanged. */
|
|
7
46
|
const sql = String.raw;
|
|
8
47
|
/** The only payload encoding History stores for completed results. */
|
|
9
48
|
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
49
|
/** Copy and validate a scoped subject argument. */
|
|
40
50
|
function scopedSubject(value) {
|
|
41
51
|
return { analysis: requireName(value.analysis, 'analysis'), environment: requireName(value.environment, 'environment'), subject: requireName(value.subject, 'subject') };
|
|
@@ -52,44 +62,6 @@ function attemptRequest(value) {
|
|
|
52
62
|
function leaseArgument(lease) {
|
|
53
63
|
return { holder: requireName(lease.holder, 'lease holder'), fence: requirePositive(lease.fence, 'lease fence'), expiresAt: requireNonnegative(lease.expiresAt, 'lease expiry') };
|
|
54
64
|
}
|
|
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
65
|
/** Narrow a stored attempt state. */
|
|
94
66
|
function attemptState(row) {
|
|
95
67
|
const state = text(row, 'state');
|
|
@@ -118,11 +90,18 @@ export function openDurableHistory(options) {
|
|
|
118
90
|
const statements = {
|
|
119
91
|
writer: connection.prepare(sql `/* writer */ SELECT last_fence, holder, expires_at, time_high_water FROM history_writer WHERE singleton = 1`),
|
|
120
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.
|
|
121
97
|
setHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET last_fence = ?, holder = ?, expires_at = ? WHERE singleton = 1`),
|
|
122
|
-
|
|
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`),
|
|
123
101
|
setAttemptSequence: connection.prepare(sql `/* allocate */ UPDATE history_sequences SET last_attempt = ? WHERE singleton = 1`),
|
|
124
102
|
setPublicationSequence: connection.prepare(sql `/* publish */ UPDATE history_sequences SET last_publication = ? WHERE singleton = 1`),
|
|
125
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`),
|
|
126
105
|
attemptByKey: connection.prepare(sql `/* attempt */ SELECT * FROM history_attempts WHERE analysis = ? AND environment = ? AND subject = ? AND attempt_key = ?`),
|
|
127
106
|
attemptById: connection.prepare(sql `/* attempt */ SELECT * FROM history_attempts WHERE attempt_id = ?`),
|
|
128
107
|
insertAttempt: connection.prepare(sql `/* allocate */ INSERT INTO history_attempts
|
|
@@ -147,19 +126,37 @@ export function openDurableHistory(options) {
|
|
|
147
126
|
FROM history_results r LEFT JOIN history_attempts a ON a.attempt_id = r.result_id WHERE r.result_id = ?`),
|
|
148
127
|
envelope: connection.prepare(sql `/* envelope */ SELECT result_id, analysis, environment, subject, version, publication, encoding, index_version,
|
|
149
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.
|
|
150
130
|
candidates: connection.prepare(sql `/* candidates */ SELECT result_id, analysis, environment, subject, version, publication, encoding, index_version,
|
|
151
|
-
provenance_format, provenance_version, provenance FROM history_results
|
|
152
|
-
WHERE analysis = ? AND
|
|
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`),
|
|
153
136
|
dependencies: connection.prepare(sql `/* envelope */ SELECT d.position, d.dependency_id, r.result_id AS present, r.analysis, r.environment
|
|
154
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`),
|
|
155
138
|
current: connection.prepare(sql `/* current */ SELECT c.result_id, r.result_id AS present
|
|
156
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
|
|
157
140
|
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)
|
|
141
|
+
insertAcceptance: connection.prepare(sql `/* accept */ INSERT INTO history_acceptances (acceptance_id, result_id, environment, fence, evidence_format, evidence_version, evidence)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
|
159
143
|
insertAcceptanceDependency: connection.prepare(sql `/* accept */ INSERT INTO history_acceptance_dependencies (acceptance_id, position, dependency_id) VALUES (?, ?, ?)`),
|
|
160
|
-
acceptances: connection.prepare(sql `/* acceptance */ SELECT acceptance_id, fence, evidence_format, evidence_version, evidence FROM history_acceptances
|
|
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`),
|
|
161
146
|
acceptanceDependencies: connection.prepare(sql `/* acceptance */ SELECT d.position, d.dependency_id, r.result_id AS present, r.analysis, r.environment
|
|
162
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`),
|
|
163
160
|
};
|
|
164
161
|
/**
|
|
165
162
|
* Resolve an exact reference to a completed result of this store: the
|
|
@@ -195,6 +192,39 @@ export function openDurableHistory(options) {
|
|
|
195
192
|
function referenceOf(resultId, scope) {
|
|
196
193
|
return referenceFor({ logicalStore, analysis: scope.analysis, environment: scope.environment, resultId });
|
|
197
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
|
+
}
|
|
198
228
|
/** Read the singleton writer row. */
|
|
199
229
|
function readWriter() {
|
|
200
230
|
const row = statements.writer.get();
|
|
@@ -223,6 +253,42 @@ export function openDurableHistory(options) {
|
|
|
223
253
|
}
|
|
224
254
|
return now;
|
|
225
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
|
+
}
|
|
226
292
|
/**
|
|
227
293
|
* Run one ownership-sensitive mutation. Inside one transaction the time is
|
|
228
294
|
* observed and holder, fence and unexpired lease are checked before
|
|
@@ -252,6 +318,8 @@ export function openDurableHistory(options) {
|
|
|
252
318
|
}
|
|
253
319
|
return outcome.value;
|
|
254
320
|
}
|
|
321
|
+
/** Run Supervision's journal ports share this connection and writer check. */
|
|
322
|
+
const journals = createJournalStore(connection, asHolder);
|
|
255
323
|
/** Read one attempt row by identity inside the caller's transaction. */
|
|
256
324
|
function requireAttemptRow(attemptId) {
|
|
257
325
|
const row = statements.attemptById.get(attemptId);
|
|
@@ -281,9 +349,25 @@ export function openDurableHistory(options) {
|
|
|
281
349
|
outcome,
|
|
282
350
|
});
|
|
283
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
|
+
}
|
|
284
367
|
/**
|
|
285
368
|
* Read an attempt's or acceptance's dependency rows and verify each still
|
|
286
|
-
* names a stored result in the
|
|
369
|
+
* names a stored result admissible in the dependent's scope (published
|
|
370
|
+
* there or promoted into it), in contiguous recorded order.
|
|
287
371
|
*/
|
|
288
372
|
function dependenciesOf(rows, scope) {
|
|
289
373
|
return Object.freeze(rows.map((row, position) => {
|
|
@@ -295,7 +379,7 @@ export function openDurableHistory(options) {
|
|
|
295
379
|
throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is dangling`);
|
|
296
380
|
}
|
|
297
381
|
if (row.analysis !== scope.analysis || row.environment !== scope.environment) {
|
|
298
|
-
|
|
382
|
+
return promotedDependency(dependencyId, row, scope);
|
|
299
383
|
}
|
|
300
384
|
// A returned dependency must satisfy the same exact contract as any reference: completed, in scope, supported.
|
|
301
385
|
const reference = referenceOf(dependencyId, scope);
|
|
@@ -322,9 +406,9 @@ export function openDurableHistory(options) {
|
|
|
322
406
|
dependencies: dependenciesOf(statements.dependencies.all(resultId), scope),
|
|
323
407
|
});
|
|
324
408
|
}
|
|
325
|
-
/** Resolve each dependency reference to a stored result in `scope`, inside the caller's transaction. */
|
|
409
|
+
/** Resolve each dependency reference to a stored result admissible in `scope`, inside the caller's transaction. */
|
|
326
410
|
function dependencyIds(references, scope) {
|
|
327
|
-
return references.map((reference) =>
|
|
411
|
+
return references.map((reference) => resolveAdmissible(reference, scope).resultId);
|
|
328
412
|
}
|
|
329
413
|
/**
|
|
330
414
|
* Every path that reports an attempt's durable outcome to a caller
|
|
@@ -370,12 +454,16 @@ export function openDurableHistory(options) {
|
|
|
370
454
|
throw new AttemptConflictError(`Attempt key ${request.attemptKey} already identifies a different execution intent or version`);
|
|
371
455
|
}
|
|
372
456
|
}
|
|
373
|
-
/** Read and convert one acceptance record
|
|
457
|
+
/** Read and convert one acceptance record, recorded in `scope`, with its dependencies. */
|
|
374
458
|
function acceptanceOf(row, reference, scope) {
|
|
375
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
|
+
}
|
|
376
463
|
return Object.freeze({
|
|
377
464
|
acceptanceId,
|
|
378
465
|
reference,
|
|
466
|
+
environment: scope.environment,
|
|
379
467
|
fence: integer(row, 'fence'),
|
|
380
468
|
evidence: loadRecord(row.evidence_format, row.evidence_version, row.evidence, 'acceptance evidence'),
|
|
381
469
|
dependencies: dependenciesOf(statements.acceptanceDependencies.all(acceptanceId), scope),
|
|
@@ -387,7 +475,44 @@ export function openDurableHistory(options) {
|
|
|
387
475
|
if (row === undefined) {
|
|
388
476
|
throw new HistoryIntegrityError('History sequence row is missing');
|
|
389
477
|
}
|
|
390
|
-
return {
|
|
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
|
+
});
|
|
391
516
|
}
|
|
392
517
|
const history = {
|
|
393
518
|
logicalStore,
|
|
@@ -396,7 +521,7 @@ export function openDurableHistory(options) {
|
|
|
396
521
|
const holder = requireName(request.holder, 'holder');
|
|
397
522
|
const leaseMilliseconds = requirePositive(request.leaseMilliseconds, 'leaseMilliseconds');
|
|
398
523
|
let acquisition;
|
|
399
|
-
connection.transaction(() => {
|
|
524
|
+
const busy = orContended(() => connection.transaction(() => {
|
|
400
525
|
const writer = readWriter();
|
|
401
526
|
const now = observeTime(writer);
|
|
402
527
|
if (writer.holder !== null && writer.expiresAt > now) {
|
|
@@ -408,7 +533,10 @@ export function openDurableHistory(options) {
|
|
|
408
533
|
statements.setHolder.run(fence, holder, expiresAt);
|
|
409
534
|
acquisition = Object.freeze({ kind: 'acquired', lease: Object.freeze({ holder, fence, expiresAt }) });
|
|
410
535
|
return undefined;
|
|
411
|
-
});
|
|
536
|
+
}));
|
|
537
|
+
if (busy !== undefined) {
|
|
538
|
+
return busy;
|
|
539
|
+
}
|
|
412
540
|
if (acquisition === undefined) {
|
|
413
541
|
throw new HistoryIntegrityError('Writer acquisition produced no outcome');
|
|
414
542
|
}
|
|
@@ -416,16 +544,16 @@ export function openDurableHistory(options) {
|
|
|
416
544
|
},
|
|
417
545
|
renewWriter(lease, leaseMilliseconds) {
|
|
418
546
|
const duration = requirePositive(leaseMilliseconds, 'leaseMilliseconds');
|
|
419
|
-
return asHolder(lease, (now) => {
|
|
547
|
+
return orContended(() => asHolder(lease, (now) => {
|
|
420
548
|
const expiresAt = safeSum(now, duration, 'lease expiry');
|
|
421
|
-
statements.
|
|
422
|
-
return Object.freeze({ holder: lease.holder, fence: lease.fence, expiresAt });
|
|
423
|
-
});
|
|
549
|
+
statements.extendHolder.run(expiresAt);
|
|
550
|
+
return Object.freeze({ kind: 'renewed', lease: Object.freeze({ holder: lease.holder, fence: lease.fence, expiresAt }) });
|
|
551
|
+
}));
|
|
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() {
|
|
@@ -529,7 +657,7 @@ export function openDurableHistory(options) {
|
|
|
529
657
|
},
|
|
530
658
|
findCandidates(presented) {
|
|
531
659
|
const subject = versionedSubject(presented);
|
|
532
|
-
return Object.freeze(statements.candidates.all(subject.analysis, subject.
|
|
660
|
+
return Object.freeze(statements.candidates.all(subject.analysis, subject.subject, subject.version, subject.environment, subject.environment).map(envelopeOf));
|
|
533
661
|
},
|
|
534
662
|
readCurrent(presented) {
|
|
535
663
|
const subject = scopedSubject(presented);
|
|
@@ -560,26 +688,78 @@ 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.acceptances.all(resultId).find((candidate) => candidate.acceptance_id === acceptanceId);
|
|
703
|
+
const row = statements.acceptances.all(resultId, scope.environment).find((candidate) => candidate.acceptance_id === acceptanceId);
|
|
573
704
|
if (row === undefined) {
|
|
574
705
|
throw new HistoryIntegrityError('Recorded acceptance is not readable');
|
|
575
706
|
}
|
|
576
|
-
return acceptanceOf(row, referenceOf(resultId,
|
|
707
|
+
return acceptanceOf(row, referenceOf(resultId, published), scope);
|
|
577
708
|
});
|
|
578
709
|
},
|
|
579
|
-
readAcceptances(reference) {
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
|
|
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);
|
|
583
763
|
},
|
|
584
764
|
close() {
|
|
585
765
|
connection.close();
|