@substrat-run/adapter-cloudflare 0.139.0 → 0.141.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 +2 -2
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +4 -0
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +93 -9
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +368 -71
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +36 -8
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +346 -58
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +30 -7
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +43 -9
- package/dist/live-reads.js.map +1 -1
- package/dist/route-resolver.d.ts +1 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +7 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +30 -2
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +731 -219
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +2 -0
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +23 -1
- package/dist/sql.js.map +1 -1
- package/package.json +12 -12
package/dist/control-plane-do.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, resolveVerticalInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, } from '@substrat-run/kernel';
|
|
2
|
+
import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink as insertConnectLinkRow, listConnectLinks as listConnectLinkRows, readConnectLink as readConnectLinkRow, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, unknownOutcomeOf, ADMIN_LOG_INDEX_DDL, ADMIN_LOG_INDEXES_SQL, readAuditedOperations, } from '@substrat-run/kernel';
|
|
3
3
|
import { replyOf } from './do-reply.js';
|
|
4
|
-
import {
|
|
4
|
+
import { switchSqlOver } from './scope-do.js';
|
|
5
|
+
import { blankSqlComments, executableSqlStatements } from '@substrat-run/kernel';
|
|
6
|
+
import { COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
|
|
5
7
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
6
8
|
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
7
9
|
/**
|
|
@@ -138,6 +140,10 @@ const DIRECTORY_DDL = `
|
|
|
138
140
|
status TEXT NOT NULL DEFAULT 'active',
|
|
139
141
|
schema_version TEXT NOT NULL DEFAULT '0',
|
|
140
142
|
vertical_version_id TEXT,
|
|
143
|
+
erasure_epoch INTEGER,
|
|
144
|
+
-- #1722: set once a reap has started draining this scope's script copies, and never
|
|
145
|
+
-- cleared: only deleting the row ends it. While set, no copy move is recorded or bound.
|
|
146
|
+
reap_claimed_at TEXT,
|
|
141
147
|
-- Last FAILED migration attempt (§5.3). All null / 0 = healthy. Written on the
|
|
142
148
|
-- failure path so a scope that fails closed stops rendering as active, and
|
|
143
149
|
-- cleared on the next success. See ScopeDO.applyPendingMigrations.
|
|
@@ -159,6 +165,26 @@ const DIRECTORY_DDL = `
|
|
|
159
165
|
serving_ref TEXT,
|
|
160
166
|
created_at TEXT NOT NULL
|
|
161
167
|
);
|
|
168
|
+
-- #1722: durable inventory of per-script copies. A fresh directory has no rows;
|
|
169
|
+
-- the historical audit-log backfill runs separately, after this additive migration.
|
|
170
|
+
CREATE TABLE IF NOT EXISTS scope_script_copies (
|
|
171
|
+
tenant_id TEXT NOT NULL,
|
|
172
|
+
scope_id TEXT NOT NULL,
|
|
173
|
+
script_ref TEXT NOT NULL,
|
|
174
|
+
move_id TEXT NOT NULL,
|
|
175
|
+
state TEXT NOT NULL,
|
|
176
|
+
load_stamp TEXT,
|
|
177
|
+
revision TEXT,
|
|
178
|
+
last_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
179
|
+
-- 'source' | 'destination'. A pending entry belongs to its move until lease_until; a
|
|
180
|
+
-- sweep that claims it after that sets lease_owner (kernel scope-copy-ledger.ts).
|
|
181
|
+
role TEXT,
|
|
182
|
+
lease_until TEXT,
|
|
183
|
+
lease_owner TEXT,
|
|
184
|
+
PRIMARY KEY (tenant_id, scope_id, script_ref, move_id)
|
|
185
|
+
);
|
|
186
|
+
CREATE INDEX IF NOT EXISTS scope_script_copies_state ON scope_script_copies (state, scope_id);
|
|
187
|
+
${PEER_BINDINGS_DDL}
|
|
162
188
|
CREATE TABLE IF NOT EXISTS hostnames (
|
|
163
189
|
hostname TEXT PRIMARY KEY,
|
|
164
190
|
tenant_id TEXT NOT NULL,
|
|
@@ -416,6 +442,9 @@ const DIRECTORY_DDL = `
|
|
|
416
442
|
updated_at TEXT NOT NULL,
|
|
417
443
|
PRIMARY KEY (connection_id, state_key)
|
|
418
444
|
);
|
|
445
|
+
-- A vertical's mailed connect links (connections.md §3.5.4) — kernel-owned DDL, so
|
|
446
|
+
-- both adapters build the same table.
|
|
447
|
+
${CONNECT_LINKS_DDL}
|
|
419
448
|
CREATE TABLE IF NOT EXISTS _substrat_identities (
|
|
420
449
|
provider TEXT NOT NULL,
|
|
421
450
|
external_id TEXT NOT NULL,
|
|
@@ -478,16 +507,12 @@ const DIRECTORY_DDL = `
|
|
|
478
507
|
on_behalf_of TEXT,
|
|
479
508
|
at TEXT NOT NULL
|
|
480
509
|
);
|
|
481
|
-
--
|
|
482
|
-
--
|
|
510
|
+
-- Every admin-log index, from the kernel's one list (#2064), which the legacy
|
|
511
|
+
-- rebuild runs again after its rename.
|
|
483
512
|
-- NOTE: keep every comment in this DDL free of semicolons. The constructor
|
|
484
513
|
-- splits this string on the statement separator, and a semicolon inside a
|
|
485
514
|
-- comment strands a comment-only fragment that execs as "no statement".
|
|
486
|
-
|
|
487
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_scope ON _substrat_admin_log (scope_id, id);
|
|
488
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_actor ON _substrat_admin_log (actor, id);
|
|
489
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_action ON _substrat_admin_log (action, id);
|
|
490
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_at ON _substrat_admin_log (at);
|
|
515
|
+
${ADMIN_LOG_INDEXES_SQL}
|
|
491
516
|
-- Operational failures (#559) - what the platform could NOT do, and why. NOT the
|
|
492
517
|
-- admin log (that is the never-swept compliance witness of successful mutations)
|
|
493
518
|
-- but retention-bounded telemetry, pruned on write after OPS_FAILURE_RETENTION_DAYS.
|
|
@@ -652,6 +677,8 @@ const SCOPE_COLUMNS_ADDED = [
|
|
|
652
677
|
'name TEXT',
|
|
653
678
|
'vertical TEXT',
|
|
654
679
|
'vertical_version_id TEXT',
|
|
680
|
+
'erasure_epoch INTEGER',
|
|
681
|
+
'reap_claimed_at TEXT',
|
|
655
682
|
'provisioned_version_id TEXT',
|
|
656
683
|
'migration_failed_version TEXT',
|
|
657
684
|
'migration_error TEXT',
|
|
@@ -674,8 +701,8 @@ const SCOPE_COLUMNS_ADDED = [
|
|
|
674
701
|
* fragment and the DDL had parted company (its test holds it empty).
|
|
675
702
|
*/
|
|
676
703
|
export function planDirectoryDdl(ddl) {
|
|
677
|
-
const all =
|
|
678
|
-
const afterColumns =
|
|
704
|
+
const all = executableSqlStatements(ddl);
|
|
705
|
+
const afterColumns = executableSqlStatements(VERSION_MIGRATIONS_DDL);
|
|
679
706
|
const held = new Set(afterColumns);
|
|
680
707
|
return {
|
|
681
708
|
loop: all.filter((stmt) => !held.has(stmt)),
|
|
@@ -825,7 +852,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
825
852
|
this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
|
|
826
853
|
for (const kind of newRecords) {
|
|
827
854
|
this.ctx.storage.transactionSync(() => {
|
|
828
|
-
for (const stmt of
|
|
855
|
+
for (const stmt of executableSqlStatements(switchesDdlOf(kind)))
|
|
829
856
|
this.sql.exec(stmt);
|
|
830
857
|
this.sql.exec(switchesBackfillSqlOf(kind));
|
|
831
858
|
});
|
|
@@ -833,7 +860,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
833
860
|
// #1184: the removal fence and its backfill from the admin log, together, the same way.
|
|
834
861
|
if (fencesAreNew) {
|
|
835
862
|
this.ctx.storage.transactionSync(() => {
|
|
836
|
-
for (const stmt of
|
|
863
|
+
for (const stmt of executableSqlStatements(MEMBERSHIP_FENCES_DDL))
|
|
837
864
|
this.sql.exec(stmt);
|
|
838
865
|
// Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
|
|
839
866
|
this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
|
|
@@ -907,7 +934,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
907
934
|
rebuildAtomically(statements) {
|
|
908
935
|
this.ctx.storage.transactionSync(() => {
|
|
909
936
|
for (const stmt of statements)
|
|
910
|
-
this.sql.exec(stmt);
|
|
937
|
+
this.sql.exec(blankSqlComments(stmt));
|
|
911
938
|
});
|
|
912
939
|
}
|
|
913
940
|
/**
|
|
@@ -972,6 +999,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
972
999
|
FROM _substrat_admin_log`,
|
|
973
1000
|
'DROP TABLE _substrat_admin_log',
|
|
974
1001
|
'ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log',
|
|
1002
|
+
// The DROP took every index on the table: rebuild them from the kernel's list (#2064).
|
|
1003
|
+
...ADMIN_LOG_INDEX_DDL,
|
|
975
1004
|
]);
|
|
976
1005
|
}
|
|
977
1006
|
/**
|
|
@@ -1292,6 +1321,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1292
1321
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
1293
1322
|
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
1294
1323
|
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
1324
|
+
'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
|
|
1295
1325
|
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
1296
1326
|
'_substrat_findings', // #1748: the tenant's findings
|
|
1297
1327
|
'_substrat_finding_rules', // #1748: the tenant's suppress rules
|
|
@@ -1654,6 +1684,9 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1654
1684
|
else {
|
|
1655
1685
|
this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
|
|
1656
1686
|
}
|
|
1687
|
+
if (to === 'archived' || to === 'reaped') {
|
|
1688
|
+
this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE tenant_id = ? AND target_scope_id = ?', tenantId, scopeId);
|
|
1689
|
+
}
|
|
1657
1690
|
if (to === 'reaped') {
|
|
1658
1691
|
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
1659
1692
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
@@ -1761,21 +1794,74 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1761
1794
|
forkedFrom: r.forked_from ?? null,
|
|
1762
1795
|
})), tenantId, vertical);
|
|
1763
1796
|
}
|
|
1797
|
+
/** Binding is read at call execution, so a queued invoke follows the current explicit choice. */
|
|
1798
|
+
resolvePeerInstance(tenantId, callerScopeId, vertical) {
|
|
1799
|
+
const rows = this.sql.exec(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
|
|
1800
|
+
WHERE tenant_id = ? AND vertical = ?`, tenantId, vertical).toArray();
|
|
1801
|
+
const binding = this.sql.exec('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical).toArray()[0];
|
|
1802
|
+
return resolvePeerInstanceFrom(rows.map((r) => ({
|
|
1803
|
+
id: r.scope_id,
|
|
1804
|
+
tenantId: r.tenant_id,
|
|
1805
|
+
vertical: r.vertical,
|
|
1806
|
+
status: r.status,
|
|
1807
|
+
kind: r.kind ?? '',
|
|
1808
|
+
forkedFrom: r.forked_from,
|
|
1809
|
+
})), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
|
|
1810
|
+
}
|
|
1811
|
+
peerBinding(tenantId, callerScopeId, vertical) {
|
|
1812
|
+
return this.sql.exec('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical).toArray()[0];
|
|
1813
|
+
}
|
|
1814
|
+
peerBindings(tenantId, callerScopeId) {
|
|
1815
|
+
return this.sql.exec('SELECT vertical, target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? ORDER BY vertical', tenantId, callerScopeId).toArray();
|
|
1816
|
+
}
|
|
1817
|
+
/** Directory write and endpoint checks share this DO's serialized unit. */
|
|
1818
|
+
setPeerBinding(tenantId, callerScopeId, vertical, targetScopeId) {
|
|
1819
|
+
const caller = this.sql.exec('SELECT tenant_id, status, kind, forked_from FROM scopes WHERE scope_id = ?', callerScopeId).toArray()[0];
|
|
1820
|
+
if (!caller || caller.tenant_id !== tenantId || caller.status !== 'active' || !isPrimaryScopeRow(caller)) {
|
|
1821
|
+
return { ok: false, code: 'not_found', message: 'calling app is not a live primary scope in this tenant' };
|
|
1822
|
+
}
|
|
1823
|
+
if (targetScopeId !== null) {
|
|
1824
|
+
const target = this.sql.exec('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?', targetScopeId).toArray()[0];
|
|
1825
|
+
if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
|
|
1826
|
+
return { ok: false, code: 'not_found', message: 'target is not a live primary instance of this vertical in this tenant' };
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
const before = this.peerBinding(tenantId, callerScopeId, vertical);
|
|
1830
|
+
const previous = before?.target_scope_id ?? null;
|
|
1831
|
+
if (previous === targetScopeId && before?.invalidated !== 1)
|
|
1832
|
+
return { ok: true, changed: false, previous };
|
|
1833
|
+
if (targetScopeId === null) {
|
|
1834
|
+
this.sql.exec('DELETE FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical);
|
|
1835
|
+
}
|
|
1836
|
+
else {
|
|
1837
|
+
this.sql.exec(`INSERT INTO peer_bindings (tenant_id, caller_scope_id, vertical, target_scope_id)
|
|
1838
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, caller_scope_id, vertical)
|
|
1839
|
+
DO UPDATE SET target_scope_id = excluded.target_scope_id, invalidated = 0`, tenantId, callerScopeId, vertical, targetScopeId);
|
|
1840
|
+
}
|
|
1841
|
+
return { ok: true, changed: true, previous };
|
|
1842
|
+
}
|
|
1764
1843
|
/**
|
|
1765
1844
|
* Everything the router needs to place ONE peer call (#1706), in one round trip: the
|
|
1766
1845
|
* CALLER's own scope record, and the target instance of `vertical` in that tenant with the
|
|
1767
1846
|
* script and dispatch parameters that instance runs under.
|
|
1768
1847
|
*
|
|
1769
|
-
* One
|
|
1848
|
+
* One directory RPC and two SQL reads, because this sits on a request path. The binding
|
|
1849
|
+
* joins the caller read, so even an unbound singleton adds no round trip. The caller's record is
|
|
1770
1850
|
* what decides whether it may call at all — live, primary, and the vertical it claims — and
|
|
1771
1851
|
* it is read HERE rather than inferred from routing, so this path does not inherit #1713.
|
|
1772
|
-
* The target side applies the kernel's one rule (`
|
|
1852
|
+
* The target side applies the kernel's one rule (`resolvePeerInstanceFrom`), the same
|
|
1773
1853
|
* rule the pure host and the local broker apply.
|
|
1774
1854
|
*/
|
|
1775
1855
|
peerCallTarget(tenantId, callerScopeId, callerVertical, vertical) {
|
|
1776
1856
|
const caller = this.sql
|
|
1777
|
-
.exec(`SELECT s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
|
|
1778
|
-
|
|
1857
|
+
.exec(`SELECT s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
|
|
1858
|
+
t.status AS tenant_status, pb.target_scope_id AS bound_target_scope_id,
|
|
1859
|
+
pb.invalidated AS binding_invalidated
|
|
1860
|
+
FROM scopes s
|
|
1861
|
+
LEFT JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
1862
|
+
LEFT JOIN peer_bindings pb ON pb.tenant_id = s.tenant_id
|
|
1863
|
+
AND pb.caller_scope_id = s.scope_id AND pb.vertical = ?
|
|
1864
|
+
WHERE s.scope_id = ?`, vertical, callerScopeId)
|
|
1779
1865
|
.toArray()[0];
|
|
1780
1866
|
const candidates = this.sql
|
|
1781
1867
|
.exec(`SELECT s.scope_id, s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
|
|
@@ -1792,14 +1878,14 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1792
1878
|
LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
|
|
1793
1879
|
WHERE s.tenant_id = ? AND s.vertical = ?`, tenantId, vertical)
|
|
1794
1880
|
.toArray();
|
|
1795
|
-
const resolution =
|
|
1881
|
+
const resolution = resolvePeerInstanceFrom(candidates.map((r) => ({
|
|
1796
1882
|
id: r.scope_id,
|
|
1797
1883
|
tenantId: r.tenant_id,
|
|
1798
1884
|
vertical: r.vertical,
|
|
1799
1885
|
status: r.status,
|
|
1800
1886
|
kind: r.kind ?? '',
|
|
1801
1887
|
forkedFrom: r.forked_from ?? null,
|
|
1802
|
-
})), tenantId, vertical);
|
|
1888
|
+
})), tenantId, vertical, caller?.bound_target_scope_id ?? null, caller?.binding_invalidated === 1);
|
|
1803
1889
|
const target = resolution.outcome === 'resolved'
|
|
1804
1890
|
? (candidates.find((r) => r.scope_id === resolution.instance.scopeId) ?? null)
|
|
1805
1891
|
: null;
|
|
@@ -2021,9 +2107,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2021
2107
|
this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
|
|
2022
2108
|
serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, slug);
|
|
2023
2109
|
}
|
|
2024
|
-
/** Point a scope's routing at the serving script its data now lives in (#286).
|
|
2025
|
-
|
|
2026
|
-
|
|
2110
|
+
/** Point a scope's routing at the serving script its data now lives in (#286). False when a
|
|
2111
|
+
* conditional move lost its compare-and-set: the host throws, since a refusal thrown here
|
|
2112
|
+
* would cross the RPC boundary without its code. */
|
|
2113
|
+
setScopeServingRef(scopeId, servingRef, expectedErasureEpoch, confirmMove) {
|
|
2114
|
+
return this.moveScopeRow('serving_ref = ?', [servingRef], scopeId, { expectedErasureEpoch, confirmMove });
|
|
2027
2115
|
}
|
|
2028
2116
|
/** Move a fork's GC deadline forward, or pin it (`null`) — preview-and-snapshots.md §9. */
|
|
2029
2117
|
setScopeExpiresAt(scopeId, expiresAt) {
|
|
@@ -2047,13 +2135,131 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2047
2135
|
setAdmission(id, admission, note) {
|
|
2048
2136
|
this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id);
|
|
2049
2137
|
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2138
|
+
/** False when a conditional bind lost its compare-and-set (the host throws, as above). */
|
|
2139
|
+
bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId, expectedErasureEpoch, confirmMove) {
|
|
2140
|
+
return this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, verticalSlug], scopeId, { expectedVersionId, expectedErasureEpoch, confirmMove });
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Move a scope's binding or route (#1722), conditionally: on the binding the caller read, on
|
|
2144
|
+
* the erasure epoch its carry read (a NULL epoch, from before the column, is 0) with no reap
|
|
2145
|
+
* claimed, and on its copy move still owning a live lease. The move's confirmation commits in
|
|
2146
|
+
* the same transaction as the row. False when any condition failed and nothing was written.
|
|
2147
|
+
*/
|
|
2148
|
+
moveScopeRow(set, setArgs, scopeId, cond) {
|
|
2149
|
+
const now = new Date().toISOString();
|
|
2150
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2151
|
+
const moved = this.sql.exec(`UPDATE scopes SET ${set} WHERE scope_id = ?
|
|
2152
|
+
${cond.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
|
|
2153
|
+
${cond.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
|
|
2154
|
+
${cond.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}`, ...setArgs, scopeId, ...(cond.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)));
|
|
2155
|
+
if (moved.rowsWritten === 0)
|
|
2156
|
+
return false;
|
|
2157
|
+
if (cond.confirmMove) {
|
|
2158
|
+
const { tenant_id } = this.sql.exec('SELECT tenant_id FROM scopes WHERE scope_id = ?', scopeId).one();
|
|
2159
|
+
this.sql.exec(COPY_MOVE_CONFIRM_SQL, ...copyMoveConfirmParams(cond.confirmMove, tenant_id, scopeId, now));
|
|
2160
|
+
}
|
|
2161
|
+
return true;
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
scopeErasureEpoch(tenantId, scopeId) {
|
|
2165
|
+
// NULL on every row from before the column (#1722): the same 0 every CAS compares against.
|
|
2166
|
+
const row = this.sql.exec('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
|
|
2167
|
+
.toArray()[0];
|
|
2168
|
+
if (!row)
|
|
2169
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2170
|
+
return row.erasure_epoch;
|
|
2171
|
+
}
|
|
2172
|
+
claimSubjectErasure(tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount) {
|
|
2173
|
+
return this.sql.exec(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
|
|
2174
|
+
WHERE tenant_id = ? AND scope_id = ? AND vertical_version_id IS ?
|
|
2175
|
+
AND serving_ref IS ? AND COALESCE(erasure_epoch, 0) = ?
|
|
2176
|
+
AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS copy
|
|
2177
|
+
WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id
|
|
2178
|
+
AND copy.state = 'pending')
|
|
2179
|
+
AND (SELECT COUNT(*) FROM scope_script_copies AS copy
|
|
2180
|
+
WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id) = ?`, tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount).rowsWritten > 0;
|
|
2181
|
+
}
|
|
2182
|
+
recordScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, role = null, loadStamp = null, leaseMs = SCOPE_COPY_LEASE_MS) {
|
|
2183
|
+
if (!scriptRef || !moveId)
|
|
2184
|
+
return 'invalid';
|
|
2185
|
+
const written = this.sql.exec(`INSERT INTO scope_script_copies (tenant_id, scope_id, script_ref, move_id, state, role, load_stamp, lease_until)
|
|
2186
|
+
SELECT tenant_id, scope_id, ?, ?, 'pending', ?, ?, ? FROM scopes
|
|
2187
|
+
WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
|
|
2188
|
+
ON CONFLICT (tenant_id, scope_id, script_ref, move_id) DO NOTHING`, scriptRef, moveId, role, loadStamp, new Date(Date.now() + leaseMs).toISOString(), tenantId, scopeId);
|
|
2189
|
+
if (written.rowsWritten > 0)
|
|
2190
|
+
return 'recorded';
|
|
2191
|
+
const scope = this.sql.exec('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
|
|
2192
|
+
.toArray()[0];
|
|
2193
|
+
if (!scope)
|
|
2194
|
+
return 'missing';
|
|
2195
|
+
if (scope.reap_claimed_at !== null)
|
|
2196
|
+
return 'reaping';
|
|
2197
|
+
return 'recorded'; // idempotent retry of this move
|
|
2198
|
+
}
|
|
2199
|
+
claimExpiredScopeScriptCopies(input) {
|
|
2200
|
+
const limit = assertRowLimit('limit', input.limit);
|
|
2201
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2202
|
+
const due = this.sql.exec(COPY_EXPIRED_SQL, input.now, limit).toArray();
|
|
2203
|
+
const claimed = [];
|
|
2204
|
+
for (const r of due) {
|
|
2205
|
+
if (this.sql.exec(COPY_CLAIM_SQL, input.owner, input.leaseUntil, r.tenant_id, r.scope_id, r.script_ref, r.move_id, input.now).rowsWritten === 0)
|
|
2206
|
+
continue;
|
|
2207
|
+
claimed.push(this.sql.exec(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies
|
|
2208
|
+
WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?`, r.tenant_id, r.scope_id, r.script_ref, r.move_id).one());
|
|
2209
|
+
}
|
|
2210
|
+
return claimed;
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
beginScopeScriptReap(tenantId, scopeId) {
|
|
2214
|
+
const written = this.sql.exec(`UPDATE scopes SET reap_claimed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
2215
|
+
WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
|
|
2216
|
+
AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS move
|
|
2217
|
+
WHERE move.tenant_id = scopes.tenant_id AND move.scope_id = scopes.scope_id
|
|
2218
|
+
AND move.state = 'pending')`, tenantId, scopeId);
|
|
2219
|
+
if (written.rowsWritten > 0)
|
|
2220
|
+
return 'claimed';
|
|
2221
|
+
const scope = this.sql.exec('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
|
|
2222
|
+
.toArray()[0];
|
|
2223
|
+
if (!scope)
|
|
2224
|
+
return 'missing';
|
|
2225
|
+
return scope.reap_claimed_at !== null ? 'claimed' : 'pending'; // a reaper resumes its earlier claim
|
|
2226
|
+
}
|
|
2227
|
+
settleScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, state, loadStamp, revision, claimedBy) {
|
|
2228
|
+
return this.sql.exec(`UPDATE scope_script_copies SET state = ?, load_stamp = ?, revision = ?,
|
|
2229
|
+
last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
2230
|
+
WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?
|
|
2231
|
+
AND (state <> 'done' OR ? = 'done')
|
|
2232
|
+
${claimedBy === undefined ? '' : "AND state = 'pending' AND lease_owner = ?"}`, state, loadStamp, revision, tenantId, scopeId, scriptRef, moveId, state, ...(claimedBy === undefined ? [] : [claimedBy])).rowsWritten > 0;
|
|
2233
|
+
}
|
|
2234
|
+
touchScopeScriptCopy(tenantId, scopeId, scriptRef, moveId) {
|
|
2235
|
+
this.sql.exec(`UPDATE scope_script_copies SET last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
2236
|
+
WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ? AND state = 'eligible'`, tenantId, scopeId, scriptRef, moveId);
|
|
2237
|
+
}
|
|
2238
|
+
listScopeScriptCopies(filter) {
|
|
2239
|
+
const where = [];
|
|
2240
|
+
const args = [];
|
|
2241
|
+
if (filter.tenantId) {
|
|
2242
|
+
where.push('tenant_id = ?');
|
|
2243
|
+
args.push(filter.tenantId);
|
|
2244
|
+
}
|
|
2245
|
+
if (filter.scopeId) {
|
|
2246
|
+
where.push('scope_id = ?');
|
|
2247
|
+
args.push(filter.scopeId);
|
|
2248
|
+
}
|
|
2249
|
+
if (filter.state) {
|
|
2250
|
+
where.push('state = ?');
|
|
2251
|
+
args.push(filter.state);
|
|
2252
|
+
}
|
|
2253
|
+
if (filter.after) {
|
|
2254
|
+
if (!filter.tenantId || !filter.scopeId)
|
|
2255
|
+
throw substratError('validation_failed', 'copy cursor requires tenant and scope');
|
|
2256
|
+
where.push('(script_ref > ? OR (script_ref = ? AND move_id > ?))');
|
|
2257
|
+
args.push(filter.after.scriptRef, filter.after.scriptRef, filter.after.moveId);
|
|
2056
2258
|
}
|
|
2259
|
+
const limit = assertRowLimit('limit', filter.limit ?? 100);
|
|
2260
|
+
const order = filter.scopeId ? 'script_ref, move_id' : 'last_attempt_at, tenant_id, scope_id, script_ref';
|
|
2261
|
+
return this.sql.exec(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
2262
|
+
ORDER BY ${order} LIMIT ?`, ...args, limit).toArray();
|
|
2057
2263
|
}
|
|
2058
2264
|
/**
|
|
2059
2265
|
* Record which version this scope's provision last ran against (#1172).
|
|
@@ -2077,6 +2283,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2077
2283
|
*/
|
|
2078
2284
|
deleteScopeDirectory(scopeId) {
|
|
2079
2285
|
this.ctx.storage.transactionSync(() => {
|
|
2286
|
+
this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?', scopeId);
|
|
2080
2287
|
this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
|
|
2081
2288
|
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
2082
2289
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
@@ -2585,6 +2792,42 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2585
2792
|
WHERE connection_id = ? AND revoked_at IS NULL`, at, id);
|
|
2586
2793
|
return true;
|
|
2587
2794
|
}
|
|
2795
|
+
// -- a vertical's mailed connect links (connections.md §3.5.4) ------------------
|
|
2796
|
+
// The kernel's statements (`connect-links.ts`), shared with the pure adapter. Each
|
|
2797
|
+
// runs inside this single-threaded object with no await between its read and its
|
|
2798
|
+
// write, which is what makes consume's single-use hold across racing callbacks here;
|
|
2799
|
+
// the UPDATE's own WHERE holds it again regardless. `now` is the coordinator's. A move
|
|
2800
|
+
// and the audit row it hands back commit in one unit: `audit` is the row the host
|
|
2801
|
+
// minted (actor, attribution), completed here from what the move changed.
|
|
2802
|
+
insertConnectLink(row, audit) {
|
|
2803
|
+
return this.auditedConnectLink(audit, (sql, write) => insertConnectLinkRow(sql, row, write));
|
|
2804
|
+
}
|
|
2805
|
+
readConnectLink(key) {
|
|
2806
|
+
return readConnectLinkRow(doRedactionSql(this.sql), key);
|
|
2807
|
+
}
|
|
2808
|
+
listConnectLinks(filter, now) {
|
|
2809
|
+
return listConnectLinkRows(doRedactionSql(this.sql), filter, now);
|
|
2810
|
+
}
|
|
2811
|
+
revokeConnectLink(key, audit) {
|
|
2812
|
+
return this.auditedConnectLink(audit, (sql, write) => revokeConnectLinkRow(sql, key, write));
|
|
2813
|
+
}
|
|
2814
|
+
consumeConnectLink(input, now, audit) {
|
|
2815
|
+
return this.auditedConnectLink(audit, (sql, write) => consumeConnectLinkRow(sql, input, now, write));
|
|
2816
|
+
}
|
|
2817
|
+
restoreConnectLink(key, now, audit) {
|
|
2818
|
+
return this.auditedConnectLink(audit, (sql, write) => restoreConnectLinkRow(sql, key, now, write));
|
|
2819
|
+
}
|
|
2820
|
+
auditedConnectLink(audit, run) {
|
|
2821
|
+
return this.ctx.storage.transactionSync(() => run(doRedactionSql(this.sql), (a) => this.recordAdmin({
|
|
2822
|
+
...audit,
|
|
2823
|
+
action: a.action,
|
|
2824
|
+
tenantId: a.target.tenantId,
|
|
2825
|
+
scopeId: a.target.scopeId,
|
|
2826
|
+
vertical: a.target.vertical,
|
|
2827
|
+
before: a.before,
|
|
2828
|
+
after: a.after,
|
|
2829
|
+
})));
|
|
2830
|
+
}
|
|
2588
2831
|
/**
|
|
2589
2832
|
* Record a connection grant directory-side (#592) — written by `grantToConnection`
|
|
2590
2833
|
* alongside the enforcement tuple, upserting in place on re-grant (the unique
|
|
@@ -2955,7 +3198,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2955
3198
|
* #1713: the scopes whose lifecycle a deployment must hold, with their lifecycle as this
|
|
2956
3199
|
* directory holds it. A scope with a vertical (only a deployment serves one), past provisioning
|
|
2957
3200
|
* and not reaped (a reaped store must not be recreated by a delivery), under a tenant that is
|
|
2958
|
-
* not reaped. `scopeId` narrows it to one scope and `tenantId` to one tenant's scopes, the
|
|
3201
|
+
* not reaped, of a vertical that does not declare its lifecycle held at the router. `scopeId` narrows it to one scope and `tenantId` to one tenant's scopes, the
|
|
2959
3202
|
* fan-out of a tenant transition. With `drift`, only the scopes the heal sweep must deliver to:
|
|
2960
3203
|
* a receipt that differs from the directory, and every scope held now. Re-delivering a held
|
|
2961
3204
|
* scope every pass is what puts a hold back on a store a carry or a restore landed without it,
|
|
@@ -2966,6 +3209,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2966
3209
|
's.vertical IS NOT NULL',
|
|
2967
3210
|
"s.status NOT IN ('provisioning', 'reaped')",
|
|
2968
3211
|
"t.status <> 'reaped'",
|
|
3212
|
+
// A vertical that declares its lifecycle held at the router (`lifecycleHold`) has no route
|
|
3213
|
+
// to deliver to and nothing a delivery would hold: none of its scopes is a target, for a
|
|
3214
|
+
// transition, the heal's drift, or the #2016 tenant-record ask alike.
|
|
3215
|
+
`COALESCE(CASE WHEN json_valid(v.install_spec) THEN json_extract(v.install_spec, '$.lifecycle') END, '') <> 'router'`,
|
|
2969
3216
|
];
|
|
2970
3217
|
const params = [];
|
|
2971
3218
|
if (filter.tenantId) {
|
|
@@ -3004,6 +3251,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3004
3251
|
${epoch} AS epoch, COALESCE(rs.revision, 0) AS scope_rev, COALESCE(rt.revision, 0) AS tenant_rev
|
|
3005
3252
|
FROM scopes s
|
|
3006
3253
|
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
3254
|
+
LEFT JOIN verticals v ON v.slug = s.vertical
|
|
3007
3255
|
LEFT JOIN scope_lifecycle_receipts r ON r.scope_id = s.scope_id
|
|
3008
3256
|
LEFT JOIN scope_tenant_asks a ON a.scope_id = s.scope_id
|
|
3009
3257
|
LEFT JOIN lifecycle_revisions rs ON rs.subject = 'scope:' || s.scope_id
|
|
@@ -3055,50 +3303,99 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3055
3303
|
recordOpsFailure(row) {
|
|
3056
3304
|
// #1748: the evidence, its issue and its finding in ONE unit — a detector that throws takes
|
|
3057
3305
|
// the evidence with it, so the caller's retry observes it rather than losing the observation.
|
|
3058
|
-
this.ctx.storage.transactionSync(() =>
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
if (row.fingerprint !== null) {
|
|
3072
|
-
this.sql.exec(`INSERT INTO _substrat_issues
|
|
3073
|
-
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
|
|
3074
|
-
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
3075
|
-
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
3076
|
-
seen_count = seen_count + 1,
|
|
3077
|
-
last_seen = excluded.last_seen,
|
|
3078
|
-
last_message = excluded.last_message,
|
|
3079
|
-
last_tenant_id = excluded.last_tenant_id,
|
|
3080
|
-
last_owner_kind = excluded.last_owner_kind,
|
|
3081
|
-
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
3082
|
-
last_version = COALESCE(excluded.last_version, last_version),
|
|
3083
|
-
origin = COALESCE(excluded.origin, origin),
|
|
3084
|
-
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at, row.message, row.tenant_id, issueExemplarOwner(row.tenant_id), row.vertical, row.version);
|
|
3085
|
-
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
3086
|
-
this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
|
|
3087
|
-
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
3088
|
-
const finding = findingOfOpsFailure({
|
|
3089
|
-
tenantId: row.tenant_id,
|
|
3090
|
-
scopeId: row.scope_id,
|
|
3091
|
-
operation: row.operation,
|
|
3092
|
-
stage: row.stage,
|
|
3093
|
-
code: row.code,
|
|
3094
|
-
vertical: row.vertical,
|
|
3095
|
-
version: row.version,
|
|
3096
|
-
});
|
|
3097
|
-
if (finding)
|
|
3098
|
-
observeFinding(doRedactionSql(this.sql), finding, row.at);
|
|
3306
|
+
this.ctx.storage.transactionSync(() => this.insertOpsFailure(row));
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3309
|
+
* #2064: settle an audited-change intent with no outcome — read it, and only if no outcome row
|
|
3310
|
+
* exists for its operation, write the `unknown` row and its ops-failure row. One unit, so two
|
|
3311
|
+
* racing passes settle once and the digest row cannot be lost. See the kernel's `audit-outcome.ts`.
|
|
3312
|
+
*/
|
|
3313
|
+
settleUnrecordedOutcome(input) {
|
|
3314
|
+
return this.ctx.storage.transactionSync(() => {
|
|
3315
|
+
const row = this.sql.exec(SETTLE_INTENT_SQL, input.intentId).toArray()[0];
|
|
3316
|
+
const outcome = unknownOutcomeOf(row, input.intentId, input.error);
|
|
3317
|
+
if (this.sql.exec(SETTLE_OUTCOME_SQL, ...settleOutcomeParamsOf(outcome.operation)).toArray().length > 0) {
|
|
3318
|
+
return false;
|
|
3099
3319
|
}
|
|
3320
|
+
const at = new Date().toISOString();
|
|
3321
|
+
this.recordAdmin({
|
|
3322
|
+
id: ulid(),
|
|
3323
|
+
actor: input.actor,
|
|
3324
|
+
action: outcome.action,
|
|
3325
|
+
...outcome.target,
|
|
3326
|
+
before: null,
|
|
3327
|
+
after: outcome.after,
|
|
3328
|
+
at,
|
|
3329
|
+
});
|
|
3330
|
+
const failure = { ...outcome.failure, actor: input.actor };
|
|
3331
|
+
this.insertOpsFailure({
|
|
3332
|
+
id: ulid(),
|
|
3333
|
+
actor: input.actor,
|
|
3334
|
+
operation: failure.operation,
|
|
3335
|
+
stage: failure.stage ?? null,
|
|
3336
|
+
tenant_id: failure.tenantId ?? null,
|
|
3337
|
+
scope_id: failure.scopeId ?? null,
|
|
3338
|
+
vertical: failure.vertical ?? null,
|
|
3339
|
+
version: null,
|
|
3340
|
+
status: null,
|
|
3341
|
+
message: failure.message.slice(0, 2000),
|
|
3342
|
+
reference: failure.reference ?? null,
|
|
3343
|
+
origin: null,
|
|
3344
|
+
code: null,
|
|
3345
|
+
fingerprint: opsFailureFingerprint(failure),
|
|
3346
|
+
at,
|
|
3347
|
+
});
|
|
3348
|
+
return true;
|
|
3100
3349
|
});
|
|
3101
3350
|
}
|
|
3351
|
+
/** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
|
|
3352
|
+
auditedOperations(refs) {
|
|
3353
|
+
return readAuditedOperations((sql, params) => this.sql.exec(sql, ...params).toArray(), refs);
|
|
3354
|
+
}
|
|
3355
|
+
/** The body of `recordOpsFailure`, for a caller already inside a transaction. */
|
|
3356
|
+
insertOpsFailure(row) {
|
|
3357
|
+
this.sql.exec(`INSERT INTO _substrat_ops_failures
|
|
3358
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
3359
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.actor, row.operation, row.stage, row.tenant_id, row.scope_id, row.vertical, row.version, row.status, row.message, row.reference, row.origin, row.code, row.fingerprint, row.at);
|
|
3360
|
+
// Prune-on-write (#559): the retention lives HERE, not in a cron — every insert
|
|
3361
|
+
// pays for its own housekeeping, so the table stays bounded even on a deployment
|
|
3362
|
+
// whose scheduled pass is broken (the exact circumstance this table records).
|
|
3363
|
+
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
3364
|
+
this.sql.exec('DELETE FROM _substrat_ops_failures WHERE at < ?', horizon);
|
|
3365
|
+
// The issues materialization (#1233): the group's counters live on their own
|
|
3366
|
+
// row, bumped in the same call, because the evidence self-prunes above and a
|
|
3367
|
+
// count must survive its own exemplars. A fresh arrival regresses a resolved
|
|
3368
|
+
// issue; an ignored one stays ignored — that is what ignoring means.
|
|
3369
|
+
if (row.fingerprint !== null) {
|
|
3370
|
+
this.sql.exec(`INSERT INTO _substrat_issues
|
|
3371
|
+
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
|
|
3372
|
+
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
3373
|
+
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
3374
|
+
seen_count = seen_count + 1,
|
|
3375
|
+
last_seen = excluded.last_seen,
|
|
3376
|
+
last_message = excluded.last_message,
|
|
3377
|
+
last_tenant_id = excluded.last_tenant_id,
|
|
3378
|
+
last_owner_kind = excluded.last_owner_kind,
|
|
3379
|
+
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
3380
|
+
last_version = COALESCE(excluded.last_version, last_version),
|
|
3381
|
+
origin = COALESCE(excluded.origin, origin),
|
|
3382
|
+
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at, row.message, row.tenant_id, issueExemplarOwner(row.tenant_id), row.vertical, row.version);
|
|
3383
|
+
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
3384
|
+
this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
|
|
3385
|
+
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
3386
|
+
const finding = findingOfOpsFailure({
|
|
3387
|
+
tenantId: row.tenant_id,
|
|
3388
|
+
scopeId: row.scope_id,
|
|
3389
|
+
operation: row.operation,
|
|
3390
|
+
stage: row.stage,
|
|
3391
|
+
code: row.code,
|
|
3392
|
+
vertical: row.vertical,
|
|
3393
|
+
version: row.version,
|
|
3394
|
+
});
|
|
3395
|
+
if (finding)
|
|
3396
|
+
observeFinding(doRedactionSql(this.sql), finding, row.at);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3102
3399
|
/**
|
|
3103
3400
|
* #1632: subject erasure's directory half — the failure text this directory keeps about a
|
|
3104
3401
|
* scope's subject. The kernel's walk, so the SQLite host runs the identical statements.
|