@substrat-run/adapter-cloudflare 0.138.0 → 0.140.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 +95 -26
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +290 -35
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +55 -14
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +347 -69
- 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 +29 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +811 -257
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +7 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +29 -3
- package/dist/sql.js.map +1 -1
- package/package.json +12 -10
package/dist/control-plane-do.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, 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';
|
|
5
6
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
6
7
|
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
7
8
|
/**
|
|
@@ -159,6 +160,7 @@ const DIRECTORY_DDL = `
|
|
|
159
160
|
serving_ref TEXT,
|
|
160
161
|
created_at TEXT NOT NULL
|
|
161
162
|
);
|
|
163
|
+
${PEER_BINDINGS_DDL}
|
|
162
164
|
CREATE TABLE IF NOT EXISTS hostnames (
|
|
163
165
|
hostname TEXT PRIMARY KEY,
|
|
164
166
|
tenant_id TEXT NOT NULL,
|
|
@@ -416,6 +418,9 @@ const DIRECTORY_DDL = `
|
|
|
416
418
|
updated_at TEXT NOT NULL,
|
|
417
419
|
PRIMARY KEY (connection_id, state_key)
|
|
418
420
|
);
|
|
421
|
+
-- A vertical's mailed connect links (connections.md §3.5.4) — kernel-owned DDL, so
|
|
422
|
+
-- both adapters build the same table.
|
|
423
|
+
${CONNECT_LINKS_DDL}
|
|
419
424
|
CREATE TABLE IF NOT EXISTS _substrat_identities (
|
|
420
425
|
provider TEXT NOT NULL,
|
|
421
426
|
external_id TEXT NOT NULL,
|
|
@@ -478,16 +483,12 @@ const DIRECTORY_DDL = `
|
|
|
478
483
|
on_behalf_of TEXT,
|
|
479
484
|
at TEXT NOT NULL
|
|
480
485
|
);
|
|
481
|
-
--
|
|
482
|
-
--
|
|
486
|
+
-- Every admin-log index, from the kernel's one list (#2064), which the legacy
|
|
487
|
+
-- rebuild runs again after its rename.
|
|
483
488
|
-- NOTE: keep every comment in this DDL free of semicolons. The constructor
|
|
484
489
|
-- splits this string on the statement separator, and a semicolon inside a
|
|
485
490
|
-- 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);
|
|
491
|
+
${ADMIN_LOG_INDEXES_SQL}
|
|
491
492
|
-- Operational failures (#559) - what the platform could NOT do, and why. NOT the
|
|
492
493
|
-- admin log (that is the never-swept compliance witness of successful mutations)
|
|
493
494
|
-- but retention-bounded telemetry, pruned on write after OPS_FAILURE_RETENTION_DAYS.
|
|
@@ -541,6 +542,8 @@ const DIRECTORY_DDL = `
|
|
|
541
542
|
resolved_at TEXT
|
|
542
543
|
);
|
|
543
544
|
CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
|
|
545
|
+
-- #1748: tenant findings and their suppress rules - kernel-owned DDL (findings.ts).
|
|
546
|
+
${FINDINGS_DDL}
|
|
544
547
|
-- The durable sweep record (#1232): one row per unit outcome per pass - a
|
|
545
548
|
-- connection swept/skipped/failed, a schedule fired/skipped/failed. What makes
|
|
546
549
|
-- "when was this last swept" answerable after the log line rolls off. Pruned on
|
|
@@ -672,8 +675,8 @@ const SCOPE_COLUMNS_ADDED = [
|
|
|
672
675
|
* fragment and the DDL had parted company (its test holds it empty).
|
|
673
676
|
*/
|
|
674
677
|
export function planDirectoryDdl(ddl) {
|
|
675
|
-
const all =
|
|
676
|
-
const afterColumns =
|
|
678
|
+
const all = executableSqlStatements(ddl);
|
|
679
|
+
const afterColumns = executableSqlStatements(VERSION_MIGRATIONS_DDL);
|
|
677
680
|
const held = new Set(afterColumns);
|
|
678
681
|
return {
|
|
679
682
|
loop: all.filter((stmt) => !held.has(stmt)),
|
|
@@ -725,6 +728,8 @@ export const REPLIED_METHODS = [
|
|
|
725
728
|
'createOrg',
|
|
726
729
|
'registerIdentityPool',
|
|
727
730
|
'linkIdentity',
|
|
731
|
+
// #1748: refuses an expiry outside its bounds with validation_failed.
|
|
732
|
+
'createFindingRule',
|
|
728
733
|
];
|
|
729
734
|
export class ControlPlaneDO extends DurableObject {
|
|
730
735
|
sql;
|
|
@@ -821,7 +826,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
821
826
|
this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
|
|
822
827
|
for (const kind of newRecords) {
|
|
823
828
|
this.ctx.storage.transactionSync(() => {
|
|
824
|
-
for (const stmt of
|
|
829
|
+
for (const stmt of executableSqlStatements(switchesDdlOf(kind)))
|
|
825
830
|
this.sql.exec(stmt);
|
|
826
831
|
this.sql.exec(switchesBackfillSqlOf(kind));
|
|
827
832
|
});
|
|
@@ -829,7 +834,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
829
834
|
// #1184: the removal fence and its backfill from the admin log, together, the same way.
|
|
830
835
|
if (fencesAreNew) {
|
|
831
836
|
this.ctx.storage.transactionSync(() => {
|
|
832
|
-
for (const stmt of
|
|
837
|
+
for (const stmt of executableSqlStatements(MEMBERSHIP_FENCES_DDL))
|
|
833
838
|
this.sql.exec(stmt);
|
|
834
839
|
// Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
|
|
835
840
|
this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
|
|
@@ -903,7 +908,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
903
908
|
rebuildAtomically(statements) {
|
|
904
909
|
this.ctx.storage.transactionSync(() => {
|
|
905
910
|
for (const stmt of statements)
|
|
906
|
-
this.sql.exec(stmt);
|
|
911
|
+
this.sql.exec(blankSqlComments(stmt));
|
|
907
912
|
});
|
|
908
913
|
}
|
|
909
914
|
/**
|
|
@@ -968,6 +973,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
968
973
|
FROM _substrat_admin_log`,
|
|
969
974
|
'DROP TABLE _substrat_admin_log',
|
|
970
975
|
'ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log',
|
|
976
|
+
// The DROP took every index on the table: rebuild them from the kernel's list (#2064).
|
|
977
|
+
...ADMIN_LOG_INDEX_DDL,
|
|
971
978
|
]);
|
|
972
979
|
}
|
|
973
980
|
/**
|
|
@@ -1288,7 +1295,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1288
1295
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
1289
1296
|
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
1290
1297
|
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
1298
|
+
'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
|
|
1291
1299
|
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
1300
|
+
'_substrat_findings', // #1748: the tenant's findings
|
|
1301
|
+
'_substrat_finding_rules', // #1748: the tenant's suppress rules
|
|
1292
1302
|
]) {
|
|
1293
1303
|
this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
|
|
1294
1304
|
}
|
|
@@ -1648,6 +1658,9 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1648
1658
|
else {
|
|
1649
1659
|
this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
|
|
1650
1660
|
}
|
|
1661
|
+
if (to === 'archived' || to === 'reaped') {
|
|
1662
|
+
this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE tenant_id = ? AND target_scope_id = ?', tenantId, scopeId);
|
|
1663
|
+
}
|
|
1651
1664
|
if (to === 'reaped') {
|
|
1652
1665
|
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
1653
1666
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
@@ -1755,21 +1768,74 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1755
1768
|
forkedFrom: r.forked_from ?? null,
|
|
1756
1769
|
})), tenantId, vertical);
|
|
1757
1770
|
}
|
|
1771
|
+
/** Binding is read at call execution, so a queued invoke follows the current explicit choice. */
|
|
1772
|
+
resolvePeerInstance(tenantId, callerScopeId, vertical) {
|
|
1773
|
+
const rows = this.sql.exec(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
|
|
1774
|
+
WHERE tenant_id = ? AND vertical = ?`, tenantId, vertical).toArray();
|
|
1775
|
+
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];
|
|
1776
|
+
return resolvePeerInstanceFrom(rows.map((r) => ({
|
|
1777
|
+
id: r.scope_id,
|
|
1778
|
+
tenantId: r.tenant_id,
|
|
1779
|
+
vertical: r.vertical,
|
|
1780
|
+
status: r.status,
|
|
1781
|
+
kind: r.kind ?? '',
|
|
1782
|
+
forkedFrom: r.forked_from,
|
|
1783
|
+
})), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
|
|
1784
|
+
}
|
|
1785
|
+
peerBinding(tenantId, callerScopeId, vertical) {
|
|
1786
|
+
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];
|
|
1787
|
+
}
|
|
1788
|
+
peerBindings(tenantId, callerScopeId) {
|
|
1789
|
+
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();
|
|
1790
|
+
}
|
|
1791
|
+
/** Directory write and endpoint checks share this DO's serialized unit. */
|
|
1792
|
+
setPeerBinding(tenantId, callerScopeId, vertical, targetScopeId) {
|
|
1793
|
+
const caller = this.sql.exec('SELECT tenant_id, status, kind, forked_from FROM scopes WHERE scope_id = ?', callerScopeId).toArray()[0];
|
|
1794
|
+
if (!caller || caller.tenant_id !== tenantId || caller.status !== 'active' || !isPrimaryScopeRow(caller)) {
|
|
1795
|
+
return { ok: false, code: 'not_found', message: 'calling app is not a live primary scope in this tenant' };
|
|
1796
|
+
}
|
|
1797
|
+
if (targetScopeId !== null) {
|
|
1798
|
+
const target = this.sql.exec('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?', targetScopeId).toArray()[0];
|
|
1799
|
+
if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
|
|
1800
|
+
return { ok: false, code: 'not_found', message: 'target is not a live primary instance of this vertical in this tenant' };
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const before = this.peerBinding(tenantId, callerScopeId, vertical);
|
|
1804
|
+
const previous = before?.target_scope_id ?? null;
|
|
1805
|
+
if (previous === targetScopeId && before?.invalidated !== 1)
|
|
1806
|
+
return { ok: true, changed: false, previous };
|
|
1807
|
+
if (targetScopeId === null) {
|
|
1808
|
+
this.sql.exec('DELETE FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical);
|
|
1809
|
+
}
|
|
1810
|
+
else {
|
|
1811
|
+
this.sql.exec(`INSERT INTO peer_bindings (tenant_id, caller_scope_id, vertical, target_scope_id)
|
|
1812
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, caller_scope_id, vertical)
|
|
1813
|
+
DO UPDATE SET target_scope_id = excluded.target_scope_id, invalidated = 0`, tenantId, callerScopeId, vertical, targetScopeId);
|
|
1814
|
+
}
|
|
1815
|
+
return { ok: true, changed: true, previous };
|
|
1816
|
+
}
|
|
1758
1817
|
/**
|
|
1759
1818
|
* Everything the router needs to place ONE peer call (#1706), in one round trip: the
|
|
1760
1819
|
* CALLER's own scope record, and the target instance of `vertical` in that tenant with the
|
|
1761
1820
|
* script and dispatch parameters that instance runs under.
|
|
1762
1821
|
*
|
|
1763
|
-
* One
|
|
1822
|
+
* One directory RPC and two SQL reads, because this sits on a request path. The binding
|
|
1823
|
+
* joins the caller read, so even an unbound singleton adds no round trip. The caller's record is
|
|
1764
1824
|
* what decides whether it may call at all — live, primary, and the vertical it claims — and
|
|
1765
1825
|
* it is read HERE rather than inferred from routing, so this path does not inherit #1713.
|
|
1766
|
-
* The target side applies the kernel's one rule (`
|
|
1826
|
+
* The target side applies the kernel's one rule (`resolvePeerInstanceFrom`), the same
|
|
1767
1827
|
* rule the pure host and the local broker apply.
|
|
1768
1828
|
*/
|
|
1769
1829
|
peerCallTarget(tenantId, callerScopeId, callerVertical, vertical) {
|
|
1770
1830
|
const caller = this.sql
|
|
1771
|
-
.exec(`SELECT s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
|
|
1772
|
-
|
|
1831
|
+
.exec(`SELECT s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
|
|
1832
|
+
t.status AS tenant_status, pb.target_scope_id AS bound_target_scope_id,
|
|
1833
|
+
pb.invalidated AS binding_invalidated
|
|
1834
|
+
FROM scopes s
|
|
1835
|
+
LEFT JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
1836
|
+
LEFT JOIN peer_bindings pb ON pb.tenant_id = s.tenant_id
|
|
1837
|
+
AND pb.caller_scope_id = s.scope_id AND pb.vertical = ?
|
|
1838
|
+
WHERE s.scope_id = ?`, vertical, callerScopeId)
|
|
1773
1839
|
.toArray()[0];
|
|
1774
1840
|
const candidates = this.sql
|
|
1775
1841
|
.exec(`SELECT s.scope_id, s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
|
|
@@ -1786,14 +1852,14 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1786
1852
|
LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
|
|
1787
1853
|
WHERE s.tenant_id = ? AND s.vertical = ?`, tenantId, vertical)
|
|
1788
1854
|
.toArray();
|
|
1789
|
-
const resolution =
|
|
1855
|
+
const resolution = resolvePeerInstanceFrom(candidates.map((r) => ({
|
|
1790
1856
|
id: r.scope_id,
|
|
1791
1857
|
tenantId: r.tenant_id,
|
|
1792
1858
|
vertical: r.vertical,
|
|
1793
1859
|
status: r.status,
|
|
1794
1860
|
kind: r.kind ?? '',
|
|
1795
1861
|
forkedFrom: r.forked_from ?? null,
|
|
1796
|
-
})), tenantId, vertical);
|
|
1862
|
+
})), tenantId, vertical, caller?.bound_target_scope_id ?? null, caller?.binding_invalidated === 1);
|
|
1797
1863
|
const target = resolution.outcome === 'resolved'
|
|
1798
1864
|
? (candidates.find((r) => r.scope_id === resolution.instance.scopeId) ?? null)
|
|
1799
1865
|
: null;
|
|
@@ -2071,6 +2137,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2071
2137
|
*/
|
|
2072
2138
|
deleteScopeDirectory(scopeId) {
|
|
2073
2139
|
this.ctx.storage.transactionSync(() => {
|
|
2140
|
+
this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?', scopeId);
|
|
2074
2141
|
this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
|
|
2075
2142
|
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
2076
2143
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
@@ -2161,10 +2228,13 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2161
2228
|
* fence, then the bound — `boundedBy` covering every permission `roleKey` carries, against the
|
|
2162
2229
|
* tuples and the role as they stand now (`tenantCoverage`) — then the write: the TENANT role
|
|
2163
2230
|
* and its audit row for an add, `revokeAndFence`'s three for a removal. The coordinator mints
|
|
2164
|
-
* the
|
|
2231
|
+
* the rows (id, actor, attribution, `causedBy`): the role's, and the org's when the change
|
|
2232
|
+
* names one (#2047) — then the org is bounded here too (`boundedBy` a live member of it) and
|
|
2233
|
+
* joined or left in the same unit, a join expiring no later than `boundedBy`'s own membership.
|
|
2234
|
+
* The org row's `before`/`after` are written here, since only the unit knows the expiry.
|
|
2165
2235
|
*/
|
|
2166
|
-
applyMembership(change, row) {
|
|
2167
|
-
const { tenantId, principal, roleKey, boundedBy } = change;
|
|
2236
|
+
applyMembership(change, row, orgRow) {
|
|
2237
|
+
const { tenantId, principal, roleKey, boundedBy, orgId } = change;
|
|
2168
2238
|
return this.ctx.storage.transactionSync(() => {
|
|
2169
2239
|
if (change.op === 'add') {
|
|
2170
2240
|
const fence = this.sql.exec(MEMBERSHIP_FENCE_SINCE_SQL, tenantId, principal, change.unlessRemovedSince).toArray()[0];
|
|
@@ -2177,11 +2247,28 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2177
2247
|
const bound = tenantCoverage(this.directoryReader, tenantId, boundedBy, role.permissions);
|
|
2178
2248
|
if (!bound.covered)
|
|
2179
2249
|
return { applied: false, missing: bound.missing };
|
|
2250
|
+
// #2047: the org, bounded by `boundedBy`'s own live membership of it.
|
|
2251
|
+
const org = orgId ? { orgId, row: orgRow, ...orgChangeBound(this.directoryReader, tenantId, { ...change, orgId }) } : undefined;
|
|
2252
|
+
if (org) {
|
|
2253
|
+
if (!org.row)
|
|
2254
|
+
throw new Error('applyMembership: an org change needs its audit row');
|
|
2255
|
+
if (!this.readOrg(tenantId, org.orgId))
|
|
2256
|
+
return { applied: false, unknownOrg: org.orgId };
|
|
2257
|
+
if (!org.bounded)
|
|
2258
|
+
return { applied: false, notMember: org.orgId };
|
|
2259
|
+
}
|
|
2180
2260
|
if (change.op === 'remove') {
|
|
2181
|
-
|
|
2261
|
+
const roleTaken = this.revokeAndFenceIn(tenantId, principal, `role:${roleKey}`, `tenant:${tenantId}`, row);
|
|
2262
|
+
const orgLeft = !!org && this.revokeAndFenceIn(tenantId, principal, 'member', `org:${org.orgId}`, { ...org.row, before: { principal, orgId: org.orgId } });
|
|
2263
|
+
return { applied: true, changed: roleTaken || orgLeft };
|
|
2182
2264
|
}
|
|
2265
|
+
const at = new Date().toISOString();
|
|
2183
2266
|
this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
|
|
2184
|
-
this.recordAdmin({ ...row, at
|
|
2267
|
+
this.recordAdmin({ ...row, at });
|
|
2268
|
+
if (org?.bounded) {
|
|
2269
|
+
this.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${org.orgId}`, org.expiresAt);
|
|
2270
|
+
this.recordAdmin({ ...org.row, after: memberAddedAudit(principal, org.orgId, org.expiresAt), at });
|
|
2271
|
+
}
|
|
2185
2272
|
return { applied: true };
|
|
2186
2273
|
});
|
|
2187
2274
|
}
|
|
@@ -2231,7 +2318,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2231
2318
|
/** Members of an org. Live only unless `includeRevoked` — revoked rows are evidence. */
|
|
2232
2319
|
listMembers(tenantId, object, includeRevoked) {
|
|
2233
2320
|
return this.sql
|
|
2234
|
-
.exec(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
|
|
2321
|
+
.exec(`SELECT subject, revoked_at, expires_at FROM _substrat_tenant_tuples
|
|
2235
2322
|
WHERE tenant_id = ? AND relation = 'member' AND object = ?
|
|
2236
2323
|
${includeRevoked ? '' : 'AND revoked_at IS NULL'}
|
|
2237
2324
|
ORDER BY subject`, tenantId, object)
|
|
@@ -2559,6 +2646,42 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2559
2646
|
WHERE connection_id = ? AND revoked_at IS NULL`, at, id);
|
|
2560
2647
|
return true;
|
|
2561
2648
|
}
|
|
2649
|
+
// -- a vertical's mailed connect links (connections.md §3.5.4) ------------------
|
|
2650
|
+
// The kernel's statements (`connect-links.ts`), shared with the pure adapter. Each
|
|
2651
|
+
// runs inside this single-threaded object with no await between its read and its
|
|
2652
|
+
// write, which is what makes consume's single-use hold across racing callbacks here;
|
|
2653
|
+
// the UPDATE's own WHERE holds it again regardless. `now` is the coordinator's. A move
|
|
2654
|
+
// and the audit row it hands back commit in one unit: `audit` is the row the host
|
|
2655
|
+
// minted (actor, attribution), completed here from what the move changed.
|
|
2656
|
+
insertConnectLink(row, audit) {
|
|
2657
|
+
return this.auditedConnectLink(audit, (sql, write) => insertConnectLinkRow(sql, row, write));
|
|
2658
|
+
}
|
|
2659
|
+
readConnectLink(key) {
|
|
2660
|
+
return readConnectLinkRow(doRedactionSql(this.sql), key);
|
|
2661
|
+
}
|
|
2662
|
+
listConnectLinks(filter, now) {
|
|
2663
|
+
return listConnectLinkRows(doRedactionSql(this.sql), filter, now);
|
|
2664
|
+
}
|
|
2665
|
+
revokeConnectLink(key, audit) {
|
|
2666
|
+
return this.auditedConnectLink(audit, (sql, write) => revokeConnectLinkRow(sql, key, write));
|
|
2667
|
+
}
|
|
2668
|
+
consumeConnectLink(input, now, audit) {
|
|
2669
|
+
return this.auditedConnectLink(audit, (sql, write) => consumeConnectLinkRow(sql, input, now, write));
|
|
2670
|
+
}
|
|
2671
|
+
restoreConnectLink(key, now, audit) {
|
|
2672
|
+
return this.auditedConnectLink(audit, (sql, write) => restoreConnectLinkRow(sql, key, now, write));
|
|
2673
|
+
}
|
|
2674
|
+
auditedConnectLink(audit, run) {
|
|
2675
|
+
return this.ctx.storage.transactionSync(() => run(doRedactionSql(this.sql), (a) => this.recordAdmin({
|
|
2676
|
+
...audit,
|
|
2677
|
+
action: a.action,
|
|
2678
|
+
tenantId: a.target.tenantId,
|
|
2679
|
+
scopeId: a.target.scopeId,
|
|
2680
|
+
vertical: a.target.vertical,
|
|
2681
|
+
before: a.before,
|
|
2682
|
+
after: a.after,
|
|
2683
|
+
})));
|
|
2684
|
+
}
|
|
2562
2685
|
/**
|
|
2563
2686
|
* Record a connection grant directory-side (#592) — written by `grantToConnection`
|
|
2564
2687
|
* alongside the enforcement tuple, upserting in place on re-grant (the unique
|
|
@@ -2929,7 +3052,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2929
3052
|
* #1713: the scopes whose lifecycle a deployment must hold, with their lifecycle as this
|
|
2930
3053
|
* directory holds it. A scope with a vertical (only a deployment serves one), past provisioning
|
|
2931
3054
|
* and not reaped (a reaped store must not be recreated by a delivery), under a tenant that is
|
|
2932
|
-
* not reaped. `scopeId` narrows it to one scope and `tenantId` to one tenant's scopes, the
|
|
3055
|
+
* 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
|
|
2933
3056
|
* fan-out of a tenant transition. With `drift`, only the scopes the heal sweep must deliver to:
|
|
2934
3057
|
* a receipt that differs from the directory, and every scope held now. Re-delivering a held
|
|
2935
3058
|
* scope every pass is what puts a hold back on a store a carry or a restore landed without it,
|
|
@@ -2940,6 +3063,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2940
3063
|
's.vertical IS NOT NULL',
|
|
2941
3064
|
"s.status NOT IN ('provisioning', 'reaped')",
|
|
2942
3065
|
"t.status <> 'reaped'",
|
|
3066
|
+
// A vertical that declares its lifecycle held at the router (`lifecycleHold`) has no route
|
|
3067
|
+
// to deliver to and nothing a delivery would hold: none of its scopes is a target, for a
|
|
3068
|
+
// transition, the heal's drift, or the #2016 tenant-record ask alike.
|
|
3069
|
+
`COALESCE(CASE WHEN json_valid(v.install_spec) THEN json_extract(v.install_spec, '$.lifecycle') END, '') <> 'router'`,
|
|
2943
3070
|
];
|
|
2944
3071
|
const params = [];
|
|
2945
3072
|
if (filter.tenantId) {
|
|
@@ -2978,6 +3105,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2978
3105
|
${epoch} AS epoch, COALESCE(rs.revision, 0) AS scope_rev, COALESCE(rt.revision, 0) AS tenant_rev
|
|
2979
3106
|
FROM scopes s
|
|
2980
3107
|
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
3108
|
+
LEFT JOIN verticals v ON v.slug = s.vertical
|
|
2981
3109
|
LEFT JOIN scope_lifecycle_receipts r ON r.scope_id = s.scope_id
|
|
2982
3110
|
LEFT JOIN scope_tenant_asks a ON a.scope_id = s.scope_id
|
|
2983
3111
|
LEFT JOIN lifecycle_revisions rs ON rs.subject = 'scope:' || s.scope_id
|
|
@@ -3027,6 +3155,59 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3027
3155
|
SELECT ?, ?, ?, ? WHERE ${STILL_DELIVERED}`, scopeId, delivered, at, tenantRecorded ? 1 : 0, scopeId);
|
|
3028
3156
|
}
|
|
3029
3157
|
recordOpsFailure(row) {
|
|
3158
|
+
// #1748: the evidence, its issue and its finding in ONE unit — a detector that throws takes
|
|
3159
|
+
// the evidence with it, so the caller's retry observes it rather than losing the observation.
|
|
3160
|
+
this.ctx.storage.transactionSync(() => this.insertOpsFailure(row));
|
|
3161
|
+
}
|
|
3162
|
+
/**
|
|
3163
|
+
* #2064: settle an audited-change intent with no outcome — read it, and only if no outcome row
|
|
3164
|
+
* exists for its operation, write the `unknown` row and its ops-failure row. One unit, so two
|
|
3165
|
+
* racing passes settle once and the digest row cannot be lost. See the kernel's `audit-outcome.ts`.
|
|
3166
|
+
*/
|
|
3167
|
+
settleUnrecordedOutcome(input) {
|
|
3168
|
+
return this.ctx.storage.transactionSync(() => {
|
|
3169
|
+
const row = this.sql.exec(SETTLE_INTENT_SQL, input.intentId).toArray()[0];
|
|
3170
|
+
const outcome = unknownOutcomeOf(row, input.intentId, input.error);
|
|
3171
|
+
if (this.sql.exec(SETTLE_OUTCOME_SQL, ...settleOutcomeParamsOf(outcome.operation)).toArray().length > 0) {
|
|
3172
|
+
return false;
|
|
3173
|
+
}
|
|
3174
|
+
const at = new Date().toISOString();
|
|
3175
|
+
this.recordAdmin({
|
|
3176
|
+
id: ulid(),
|
|
3177
|
+
actor: input.actor,
|
|
3178
|
+
action: outcome.action,
|
|
3179
|
+
...outcome.target,
|
|
3180
|
+
before: null,
|
|
3181
|
+
after: outcome.after,
|
|
3182
|
+
at,
|
|
3183
|
+
});
|
|
3184
|
+
const failure = { ...outcome.failure, actor: input.actor };
|
|
3185
|
+
this.insertOpsFailure({
|
|
3186
|
+
id: ulid(),
|
|
3187
|
+
actor: input.actor,
|
|
3188
|
+
operation: failure.operation,
|
|
3189
|
+
stage: failure.stage ?? null,
|
|
3190
|
+
tenant_id: failure.tenantId ?? null,
|
|
3191
|
+
scope_id: failure.scopeId ?? null,
|
|
3192
|
+
vertical: failure.vertical ?? null,
|
|
3193
|
+
version: null,
|
|
3194
|
+
status: null,
|
|
3195
|
+
message: failure.message.slice(0, 2000),
|
|
3196
|
+
reference: failure.reference ?? null,
|
|
3197
|
+
origin: null,
|
|
3198
|
+
code: null,
|
|
3199
|
+
fingerprint: opsFailureFingerprint(failure),
|
|
3200
|
+
at,
|
|
3201
|
+
});
|
|
3202
|
+
return true;
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
/** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
|
|
3206
|
+
auditedOperations(refs) {
|
|
3207
|
+
return readAuditedOperations((sql, params) => this.sql.exec(sql, ...params).toArray(), refs);
|
|
3208
|
+
}
|
|
3209
|
+
/** The body of `recordOpsFailure`, for a caller already inside a transaction. */
|
|
3210
|
+
insertOpsFailure(row) {
|
|
3030
3211
|
this.sql.exec(`INSERT INTO _substrat_ops_failures
|
|
3031
3212
|
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
3032
3213
|
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);
|
|
@@ -3055,6 +3236,18 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3055
3236
|
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);
|
|
3056
3237
|
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
3057
3238
|
this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
|
|
3239
|
+
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
3240
|
+
const finding = findingOfOpsFailure({
|
|
3241
|
+
tenantId: row.tenant_id,
|
|
3242
|
+
scopeId: row.scope_id,
|
|
3243
|
+
operation: row.operation,
|
|
3244
|
+
stage: row.stage,
|
|
3245
|
+
code: row.code,
|
|
3246
|
+
vertical: row.vertical,
|
|
3247
|
+
version: row.version,
|
|
3248
|
+
});
|
|
3249
|
+
if (finding)
|
|
3250
|
+
observeFinding(doRedactionSql(this.sql), finding, row.at);
|
|
3058
3251
|
}
|
|
3059
3252
|
}
|
|
3060
3253
|
/**
|
|
@@ -3141,14 +3334,35 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3141
3334
|
}));
|
|
3142
3335
|
}
|
|
3143
3336
|
recordSweepRun(row) {
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3337
|
+
// #1748: the row and its finding in ONE unit, so a replayed drain the unique index ignores
|
|
3338
|
+
// never meets a row whose observation did not commit.
|
|
3339
|
+
this.ctx.storage.transactionSync(() => {
|
|
3340
|
+
const { rowsWritten } = this.sql.exec(`INSERT OR IGNORE INTO _substrat_sweep_runs
|
|
3341
|
+
(id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
|
|
3342
|
+
connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
|
|
3343
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.kind, row.unit, row.outcome, row.tenant_id, row.scope_id, row.vertical, row.version, row.operation, row.connection_id, row.error, row.elapsed_ms, row.request_id, row.event_type, row.observed_at, row.platform_requests, row.at);
|
|
3344
|
+
// Prune-on-write, like ops failures and for the same reason: bounded even on a
|
|
3345
|
+
// deployment whose scheduled pass is broken.
|
|
3346
|
+
const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
|
|
3347
|
+
this.sql.exec('DELETE FROM _substrat_sweep_runs WHERE at < ?', horizon);
|
|
3348
|
+
// #1748: a failed schedule (Invariant) or a stale freshness verdict (Drift). Only when the
|
|
3349
|
+
// row was written: a replayed drain the unique index ignored is not a second occurrence.
|
|
3350
|
+
const finding = rowsWritten > 0
|
|
3351
|
+
? findingOfSweepRun({
|
|
3352
|
+
kind: row.kind,
|
|
3353
|
+
unit: row.unit,
|
|
3354
|
+
outcome: row.outcome,
|
|
3355
|
+
tenantId: row.tenant_id,
|
|
3356
|
+
scopeId: row.scope_id,
|
|
3357
|
+
vertical: row.vertical,
|
|
3358
|
+
version: row.version,
|
|
3359
|
+
operation: row.operation,
|
|
3360
|
+
eventType: row.event_type,
|
|
3361
|
+
})
|
|
3362
|
+
: null;
|
|
3363
|
+
if (finding)
|
|
3364
|
+
observeFinding(doRedactionSql(this.sql), finding, row.at);
|
|
3365
|
+
});
|
|
3152
3366
|
}
|
|
3153
3367
|
listSweepRuns(query) {
|
|
3154
3368
|
const where = [];
|
|
@@ -3218,6 +3432,47 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3218
3432
|
}
|
|
3219
3433
|
return pruned;
|
|
3220
3434
|
}
|
|
3435
|
+
/** #1748: the findings retention pass, in one unit with its audit rows. */
|
|
3436
|
+
pruneFindings(limit, audit) {
|
|
3437
|
+
assertRowLimit('limit', limit);
|
|
3438
|
+
return this.audited(audit, (sql, write) => pruneFindings(sql, Date.now(), limit, write));
|
|
3439
|
+
}
|
|
3440
|
+
/**
|
|
3441
|
+
* #1748: a findings mutation and the audit rows it hands back, in ONE unit, so an audit write
|
|
3442
|
+
* that fails rolls the mutation back. `audit` is the row the host minted, with its actor and
|
|
3443
|
+
* attribution; each row the mutation hands back is written as a copy under its own id.
|
|
3444
|
+
*/
|
|
3445
|
+
audited(audit, run) {
|
|
3446
|
+
return this.ctx.storage.transactionSync(() => run(doRedactionSql(this.sql), (a) => this.recordAdmin({
|
|
3447
|
+
...audit,
|
|
3448
|
+
id: ulid(),
|
|
3449
|
+
action: a.action,
|
|
3450
|
+
tenantId: a.target.tenantId,
|
|
3451
|
+
vertical: a.target.vertical,
|
|
3452
|
+
before: a.before,
|
|
3453
|
+
after: a.after,
|
|
3454
|
+
})));
|
|
3455
|
+
}
|
|
3456
|
+
/** #1748: findings, most recently seen first — `listFindings`. */
|
|
3457
|
+
listFindings(filter) {
|
|
3458
|
+
return listFindings(doRedactionSql(this.sql), filter);
|
|
3459
|
+
}
|
|
3460
|
+
/** #1748: a verdict on one tenant finding, audited in the same unit. Undefined for an unknown one. */
|
|
3461
|
+
setFindingStatus(tenantId, id, status, at, audit) {
|
|
3462
|
+
return this.audited(audit, (sql, write) => setFindingStatus(sql, tenantId, id, status, at, write));
|
|
3463
|
+
}
|
|
3464
|
+
/** #1748: a suppress rule, applied to the findings it covers now, audited in the same unit. */
|
|
3465
|
+
createFindingRule(tenantId, input, createdBy, at, audit) {
|
|
3466
|
+
return this.audited(audit, (sql, write) => createFindingRule(sql, tenantId, input, createdBy, at, write));
|
|
3467
|
+
}
|
|
3468
|
+
/** #1748: end a rule now, audited in the same unit. */
|
|
3469
|
+
revokeFindingRule(tenantId, ruleId, at, audit) {
|
|
3470
|
+
return this.audited(audit, (sql, write) => revokeFindingRule(sql, tenantId, ruleId, at, write));
|
|
3471
|
+
}
|
|
3472
|
+
/** #1748: a tenant's suppress rules. */
|
|
3473
|
+
listFindingRules(tenantId, activeAt, limit) {
|
|
3474
|
+
return listFindingRules(doRedactionSql(this.sql), tenantId, activeAt, limit);
|
|
3475
|
+
}
|
|
3221
3476
|
listIssues(query) {
|
|
3222
3477
|
const where = [];
|
|
3223
3478
|
const params = [];
|