@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.
Files changed (40) hide show
  1. package/README.md +14 -3
  2. package/dist/api/history.alpha.d.ts +2 -2
  3. package/dist/api/history.root.raw.alpha.d.ts +332 -19
  4. package/dist/api/history.root.raw.beta.d.ts +34 -2
  5. package/dist/api/history.root.raw.public.d.ts +34 -2
  6. package/dist/api/history.root.raw.untrimmed.d.ts +332 -19
  7. package/dist/api/history.shared.alpha.d.ts +328 -17
  8. package/dist/api/history.shared.beta.d.ts +30 -0
  9. package/dist/api/history.shared.public.d.ts +30 -0
  10. package/dist/api/history.shared.untrimmed.d.ts +328 -17
  11. package/dist/api/history.untrimmed.d.ts +2 -2
  12. package/dist/src/durable/contracts.d.ts +290 -17
  13. package/dist/src/durable/contracts.d.ts.map +1 -1
  14. package/dist/src/durable/errors.d.ts +26 -1
  15. package/dist/src/durable/errors.d.ts.map +1 -1
  16. package/dist/src/durable/errors.js +32 -1
  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 +275 -95
  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 +13 -4
  30. package/dist/src/durable/schema.d.ts.map +1 -1
  31. package/dist/src/durable/schema.js +74 -5
  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 +3 -1
  35. package/dist/src/durable/selected-index.js.map +1 -1
  36. package/dist/src/index.d.ts +6 -4
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +5 -3
  39. package/dist/src/index.js.map +1 -1
  40. 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
- sequences: connection.prepare(sql `/* sequence */ SELECT last_attempt, last_publication, last_acceptance FROM history_sequences WHERE singleton = 1`),
98
+ extendHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET expires_at = ? WHERE singleton = 1`),
99
+ clearHolder: connection.prepare(sql `/* writer */ UPDATE history_writer SET holder = NULL, expires_at = ? WHERE singleton = 1`),
100
+ sequences: connection.prepare(sql `/* sequence */ SELECT last_attempt, last_publication, last_acceptance, last_promotion FROM history_sequences WHERE singleton = 1`),
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 environment = ? AND subject = ? AND version = ? ORDER BY publication DESC`),
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) VALUES (?, ?, ?, ?, ?, ?)`),
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 WHERE result_id = ? ORDER BY acceptance_id`),
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 same scope, in contiguous recorded order.
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
- throw new HistoryIntegrityError(`Stored dependency on result ${String(dependencyId)} is outside the dependent's scope`);
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) => resolveResult(reference, scope).resultId);
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's dependencies. */
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 { lastAttempt: integer(row, 'last_attempt'), lastPublication: integer(row, 'last_publication'), lastAcceptance: integer(row, 'last_acceptance') };
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.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: 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 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() {
@@ -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.environment, subject.subject, subject.version).map(envelopeOf));
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, scope), scope);
707
+ return acceptanceOf(row, referenceOf(resultId, published), scope);
577
708
  });
578
709
  },
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)));
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();