@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.
Files changed (44) hide show
  1. package/README.md +15 -3
  2. package/dist/api/history.alpha.d.ts +2 -2
  3. package/dist/api/history.root.raw.alpha.d.ts +380 -21
  4. package/dist/api/history.root.raw.beta.d.ts +40 -2
  5. package/dist/api/history.root.raw.public.d.ts +40 -2
  6. package/dist/api/history.root.raw.untrimmed.d.ts +380 -21
  7. package/dist/api/history.shared.alpha.d.ts +376 -19
  8. package/dist/api/history.shared.beta.d.ts +36 -0
  9. package/dist/api/history.shared.public.d.ts +36 -0
  10. package/dist/api/history.shared.untrimmed.d.ts +376 -19
  11. package/dist/api/history.untrimmed.d.ts +2 -2
  12. package/dist/src/durable/contracts.d.ts +292 -17
  13. package/dist/src/durable/contracts.d.ts.map +1 -1
  14. package/dist/src/durable/errors.d.ts +44 -3
  15. package/dist/src/durable/errors.d.ts.map +1 -1
  16. package/dist/src/durable/errors.js +43 -7
  17. package/dist/src/durable/errors.js.map +1 -1
  18. package/dist/src/durable/index.d.ts.map +1 -1
  19. package/dist/src/durable/index.js +299 -111
  20. package/dist/src/durable/index.js.map +1 -1
  21. package/dist/src/durable/journal.d.ts +53 -0
  22. package/dist/src/durable/journal.d.ts.map +1 -0
  23. package/dist/src/durable/journal.js +174 -0
  24. package/dist/src/durable/journal.js.map +1 -0
  25. package/dist/src/durable/records.d.ts +40 -0
  26. package/dist/src/durable/records.d.ts.map +1 -0
  27. package/dist/src/durable/records.js +74 -0
  28. package/dist/src/durable/records.js.map +1 -0
  29. package/dist/src/durable/schema.d.ts +14 -3
  30. package/dist/src/durable/schema.d.ts.map +1 -1
  31. package/dist/src/durable/schema.js +99 -21
  32. package/dist/src/durable/schema.js.map +1 -1
  33. package/dist/src/durable/selected-index.d.ts.map +1 -1
  34. package/dist/src/durable/selected-index.js +19 -5
  35. package/dist/src/durable/selected-index.js.map +1 -1
  36. package/dist/src/durable/sqlite-boundary.d.ts +11 -0
  37. package/dist/src/durable/sqlite-boundary.d.ts.map +1 -0
  38. package/dist/src/durable/sqlite-boundary.js +79 -0
  39. package/dist/src/durable/sqlite-boundary.js.map +1 -0
  40. package/dist/src/index.d.ts +7 -5
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +5 -3
  43. package/dist/src/index.js.map +1 -1
  44. 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.openSqlite(options.location);
111
- try {
112
- initializeSchema(connection, logicalStore);
113
- }
114
- catch (error) {
115
- connection.close();
116
- throw error;
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
- const statements = {
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
- sequences: connection.prepare(sql `/* sequence */ SELECT last_attempt, last_publication, last_acceptance FROM history_sequences WHERE singleton = 1`),
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
- 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 environment = ? AND subject = ? AND version = ? ORDER BY publication DESC`),
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) VALUES (?, ?, ?, ?, ?, ?)`),
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
- acceptances: connection.prepare(sql `/* acceptance */ SELECT acceptance_id, fence, evidence_format, evidence_version, evidence FROM history_acceptances WHERE result_id = ? ORDER BY acceptance_id`),
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 = clock.currentEpochMilliseconds();
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 same scope, in contiguous recorded order.
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
- throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is outside the dependent's scope`);
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) => resolveResult(reference, scope).resultId);
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's dependencies. */
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 { lastAttempt: integer(row, 'last_attempt'), lastPublication: integer(row, 'last_publication'), lastAcceptance: integer(row, 'last_acceptance') };
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
- return asHolder(lease, (now) => {
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.setHolder.run(lease.fence, lease.holder, expiresAt);
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: 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 resets the fence counter.
428
- statements.setHolder.run(lease.fence, null, now);
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.acceptances.all(resultId).find((candidate) => candidate.acceptance_id === acceptanceId);
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
- return acceptanceOf(row, referenceOf(resultId, scope), scope);
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
- readAcceptances(reference) {
580
- const { resultId, scope } = resolveResult(reference);
581
- const exact = referenceOf(resultId, scope);
582
- return Object.freeze(statements.acceptances.all(resultId).map((row) => acceptanceOf(row, exact, scope)));
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();