@substrat-run/adapter-cloudflare 0.159.0 → 0.161.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/dist/control-plane-do.d.ts +131 -30
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +358 -94
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +885 -7
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +476 -200
- package/dist/host.js.map +1 -1
- package/dist/platform-sweeper-do.d.ts +3 -2
- package/dist/platform-sweeper-do.d.ts.map +1 -1
- package/dist/platform-sweeper-do.js +3 -2
- package/dist/platform-sweeper-do.js.map +1 -1
- package/dist/r2.d.ts +7 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js +23 -2
- package/dist/r2.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +207 -42
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +4 -2
- package/dist/scope-sweeper-do.d.ts.map +1 -1
- package/dist/scope-sweeper-do.js +7 -1
- package/dist/scope-sweeper-do.js.map +1 -1
- package/package.json +9 -9
package/dist/control-plane-do.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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, reservedRoleRefusal, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, 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, VERTICAL_SCOPE_STORES_DDL, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, driverDeclarationsOf, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, forgetVerticalScopeStores, indexVersionScopeStores, verticalScopeStoresOf, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, versionsAwaitDigest, deriveMigrationDigestsBatch, deriveVersionMigrationDigest, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, samePeerVertical, peerVerticalSpellings, 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';
|
|
2
|
+
import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, reservedRoleDefinitionRefusal, reservedRoleRefusal, TENANT_SEATED_ROLES_SQL, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeAttachmentStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, 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, VERTICAL_SCOPE_STORES_DDL, DEPLOYMENT_WIRE_AUTH_DDL, deploymentWireAuthOf, forgetVerticalWireAuth, writeDeploymentWireAuth, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, driverDeclarationsOf, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, forgetVerticalScopeStores, indexVersionScopeStores, verticalScopeStoresOf, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, versionsAwaitDigest, deriveMigrationDigestsBatch, deriveVersionMigrationDigest, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, samePeerVertical, peerVerticalSpellings, 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
4
|
import { switchSqlOver } from './scope-do.js';
|
|
5
5
|
import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsReconciledOf, PROVISIONED_VERSIONS_SQL, provisionedVersionsParams, } from '@substrat-run/kernel';
|
|
6
6
|
import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_RENEW_SQL, copyMoveRenewParams, PENDING_COPY_KEEP_ALIVE_SQL, pendingCopyKeepAliveParams, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
|
|
7
|
+
import { opsFailureReferenceWhere } from '@substrat-run/kernel';
|
|
7
8
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
8
|
-
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
9
|
+
import { assertReplayableDump, AUTO_ADMISSION_NOTE, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
9
10
|
/**
|
|
10
11
|
* The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
|
|
11
12
|
* (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
|
|
@@ -315,6 +316,7 @@ const DIRECTORY_DDL = `
|
|
|
315
316
|
);
|
|
316
317
|
${VERSION_MIGRATIONS_DDL}
|
|
317
318
|
${VERTICAL_SCOPE_STORES_DDL}
|
|
319
|
+
${DEPLOYMENT_WIRE_AUTH_DDL}
|
|
318
320
|
CREATE TABLE IF NOT EXISTS vertical_channels (
|
|
319
321
|
vertical_slug TEXT NOT NULL,
|
|
320
322
|
channel TEXT NOT NULL,
|
|
@@ -653,6 +655,12 @@ const DIRECTORY_DDL = `
|
|
|
653
655
|
CREATE INDEX IF NOT EXISTS _substrat_model_usage_at ON _substrat_model_usage (at);
|
|
654
656
|
-- #1524: the stored storage gauge — one row per (scope, UTC day), kernel-owned DDL.
|
|
655
657
|
${SCOPE_STORAGE_DDL}
|
|
658
|
+
-- #2501: the daily request rollup — one row per (UTC day, tenant, vertical, scope), kernel-owned DDL.
|
|
659
|
+
${REQUEST_ROLLUP_DDL}
|
|
660
|
+
-- #2501: the daily meter snapshot — one row per (UTC day, tenant), kernel-owned DDL.
|
|
661
|
+
${METER_DAYS_DDL}
|
|
662
|
+
-- #2501: who owned each vertical slug, period by period — kernel-owned DDL.
|
|
663
|
+
${VERTICAL_OWNERS_DDL}
|
|
656
664
|
CREATE INDEX IF NOT EXISTS scopes_tenant ON scopes (tenant_id, scope_id);
|
|
657
665
|
-- #1713: the lifecycle each hosted scope's deployment last acknowledged, as
|
|
658
666
|
-- "<scope status>/<tenant status>" (lifecycleReceipt). A CP-less deployment has no
|
|
@@ -799,6 +807,17 @@ export const REPLIED_METHODS = [
|
|
|
799
807
|
// #2309: a fleet rebuild's lease write refuses with conflict or validation_failed.
|
|
800
808
|
'claimStoreDriverRebuild',
|
|
801
809
|
'advanceStoreDriverRebuild',
|
|
810
|
+
// #2528: the vertical writes that read their row's `before` and their guards in the unit that
|
|
811
|
+
// writes, refusing an unknown vertical or version with not_found and a failed guard with conflict.
|
|
812
|
+
'updateVerticalListed',
|
|
813
|
+
'updateVerticalPublishRequest',
|
|
814
|
+
'updateVerticalInstallsBlocked',
|
|
815
|
+
'updateVerticalTenantProvisioner',
|
|
816
|
+
'updateVerticalEmailSender',
|
|
817
|
+
'deleteVertical',
|
|
818
|
+
'admitVersion',
|
|
819
|
+
'rejectVersion',
|
|
820
|
+
'setVerticalServing',
|
|
802
821
|
];
|
|
803
822
|
export class ControlPlaneDO extends DurableObject {
|
|
804
823
|
sql;
|
|
@@ -1177,6 +1196,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1177
1196
|
this.addColumn('_substrat_entitlements', 'granted_by TEXT');
|
|
1178
1197
|
for (const stmt of LEGACY_SCOPE_ROWS_BACKFILL)
|
|
1179
1198
|
this.sql.exec(stmt);
|
|
1199
|
+
// #2501: an ownership period for every vertical registered before the history existed.
|
|
1200
|
+
this.sql.exec(VERTICAL_OWNERS_BACKFILL);
|
|
1180
1201
|
// After the backfill: a UNIQUE index over NULL slugs would permit the
|
|
1181
1202
|
// duplicates it exists to forbid (SQLite treats NULLs as distinct).
|
|
1182
1203
|
//
|
|
@@ -1316,8 +1337,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1316
1337
|
.toArray()[0];
|
|
1317
1338
|
return row ? this.mapTenant(row) : undefined;
|
|
1318
1339
|
}
|
|
1319
|
-
/**
|
|
1320
|
-
|
|
1340
|
+
/**
|
|
1341
|
+
* Idempotent on the id; return the new tenant, or null if it already existed. `audit` is the
|
|
1342
|
+
* coordinator's row, written in the insert's unit with the tenant as its `after`.
|
|
1343
|
+
*/
|
|
1344
|
+
createTenant(id, slug, name, createdAt, provisionedByTenant = null, audit) {
|
|
1321
1345
|
if (this.readTenant(id))
|
|
1322
1346
|
return null; // idempotent — nothing created
|
|
1323
1347
|
// Checked explicitly rather than left to `INSERT OR IGNORE` + the
|
|
@@ -1327,15 +1351,17 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1327
1351
|
const slugOwner = this.sql
|
|
1328
1352
|
.exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
|
|
1329
1353
|
.toArray()[0];
|
|
1354
|
+
// #2556: the refusal never names the owner. Another tenant holds it, and the text reaches
|
|
1355
|
+
// whoever asked (a manager vertical's intent journal among them).
|
|
1330
1356
|
if (slugOwner) {
|
|
1331
|
-
throw substratError('conflict', `tenant slug '${slug}' already taken
|
|
1357
|
+
throw substratError('conflict', `tenant slug '${slug}' is already taken (slugs are unique)`);
|
|
1332
1358
|
}
|
|
1333
|
-
this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
1334
|
-
VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
|
|
1359
|
+
this.writeAndAudit(() => this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
1360
|
+
VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant).rowsWritten > 0, audit && (() => ({ ...audit, after: this.readTenant(id) })));
|
|
1335
1361
|
return this.readTenant(id) ?? null;
|
|
1336
1362
|
}
|
|
1337
|
-
/** Throw if absent; else UPDATE and return the previous status. */
|
|
1338
|
-
setTenantStatus(tenantId, status) {
|
|
1363
|
+
/** Throw if absent; else UPDATE and return the previous status, which `audit`'s `before` records. */
|
|
1364
|
+
setTenantStatus(tenantId, status, audit) {
|
|
1339
1365
|
const before = this.readTenant(tenantId);
|
|
1340
1366
|
if (!before)
|
|
1341
1367
|
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
@@ -1348,15 +1374,13 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1348
1374
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
1349
1375
|
// entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
|
|
1350
1376
|
// transitionScope uses for archived_at.
|
|
1351
|
-
this.
|
|
1352
|
-
|
|
1353
|
-
this.sql.exec('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?', status, new Date().toISOString(), tenantId)
|
|
1354
|
-
|
|
1355
|
-
else {
|
|
1356
|
-
this.sql.exec('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?', status, tenantId);
|
|
1357
|
-
}
|
|
1377
|
+
this.writeAndAudit(() => {
|
|
1378
|
+
const moved = (status === 'deleting'
|
|
1379
|
+
? this.sql.exec('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?', status, new Date().toISOString(), tenantId)
|
|
1380
|
+
: this.sql.exec('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?', status, tenantId)).rowsWritten > 0;
|
|
1358
1381
|
this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713, with the change it counts
|
|
1359
|
-
|
|
1382
|
+
return moved;
|
|
1383
|
+
}, audit && { ...audit, before: { status: before.status } });
|
|
1360
1384
|
return before.status;
|
|
1361
1385
|
}
|
|
1362
1386
|
/**
|
|
@@ -1367,7 +1391,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1367
1391
|
* previous status (for the audit before/after). Idempotent: set-to-empty DELETEs
|
|
1368
1392
|
* plus the status flip converge on a re-run after a partial failure.
|
|
1369
1393
|
*/
|
|
1370
|
-
reapTenant(tenantId) {
|
|
1394
|
+
reapTenant(tenantId, audit) {
|
|
1371
1395
|
const before = this.readTenant(tenantId);
|
|
1372
1396
|
if (!before)
|
|
1373
1397
|
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
@@ -1376,40 +1400,47 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1376
1400
|
}
|
|
1377
1401
|
// KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
|
|
1378
1402
|
// compliance witness — never swept). Scope rows were already reaped individually.
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1403
|
+
// One unit with the flip and `audit`, whose `before` is the status read here.
|
|
1404
|
+
this.writeAndAudit(() => {
|
|
1405
|
+
for (const table of [
|
|
1406
|
+
'_substrat_identities', // PII: external subjects/emails bound to principals
|
|
1407
|
+
'_substrat_identity_pools', // the tenant's IdP topology declarations
|
|
1408
|
+
'_substrat_tenant_tuples', // membership + tenant-level grants
|
|
1409
|
+
'_substrat_roles', // operator-defined roles
|
|
1410
|
+
'_substrat_entitlements', // per-tenant SKU flags
|
|
1411
|
+
'orgs', // K-22 org records
|
|
1412
|
+
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
1413
|
+
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
1414
|
+
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
1415
|
+
'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
|
|
1416
|
+
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
1417
|
+
'_substrat_findings', // #1748: the tenant's findings
|
|
1418
|
+
'_substrat_finding_rules', // #1748: the tenant's suppress rules
|
|
1419
|
+
'_substrat_scope_storage', // #1524: storage samples of scopes whose storage is gone
|
|
1420
|
+
'_substrat_scope_storage_attempts', // #1524: and the phase's last try at each
|
|
1421
|
+
'_substrat_scope_store_storage', // #2212: scope-store samples, gone with the tenant
|
|
1422
|
+
'_substrat_scope_attachment_storage', // #2501: attachment samples, gone with the tenant
|
|
1423
|
+
]) {
|
|
1424
|
+
this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
|
|
1425
|
+
}
|
|
1426
|
+
const reaped = this.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId).rowsWritten > 0;
|
|
1401
1427
|
// Its scopes were reaped one by one, each forgetting its own; any left over go here.
|
|
1402
1428
|
this.forgetLifecycleDeliveries('scope_id IN (SELECT scope_id FROM scopes WHERE tenant_id = ?)', tenantId);
|
|
1403
1429
|
this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713
|
|
1404
|
-
|
|
1430
|
+
return reaped;
|
|
1431
|
+
}, audit && { ...audit, before: { status: before.status } });
|
|
1405
1432
|
return before.status;
|
|
1406
1433
|
}
|
|
1407
|
-
/**
|
|
1408
|
-
|
|
1434
|
+
/**
|
|
1435
|
+
* Rename the DISPLAY name (never the slug); throw if absent; return the previous name. A rename
|
|
1436
|
+
* to the name it already has writes nothing, `audit` included; otherwise `audit`'s `before` is
|
|
1437
|
+
* the name read here.
|
|
1438
|
+
*/
|
|
1439
|
+
setTenantName(tenantId, name, audit) {
|
|
1409
1440
|
const before = this.readTenant(tenantId);
|
|
1410
1441
|
if (!before)
|
|
1411
1442
|
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1412
|
-
this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
|
|
1443
|
+
this.writeAndAudit(() => before.name !== name && this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId).rowsWritten > 0, audit && { ...audit, before: { name: before.name } });
|
|
1413
1444
|
return before.name;
|
|
1414
1445
|
}
|
|
1415
1446
|
getTenant(tenantId) {
|
|
@@ -1805,20 +1836,53 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1805
1836
|
if (to === 'reaped') {
|
|
1806
1837
|
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
1807
1838
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
1808
|
-
// #
|
|
1809
|
-
|
|
1839
|
+
// #2515: its storage is gone, and its measured days are kept for the invoice (retention ends them).
|
|
1840
|
+
retireScopeStorage(doRedactionSql(this.sql), scopeId);
|
|
1810
1841
|
}
|
|
1811
1842
|
this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
|
|
1812
1843
|
});
|
|
1813
1844
|
return { ok: true, status: row.status, vertical: row.vertical };
|
|
1814
1845
|
}
|
|
1815
1846
|
// -- roles (checker rule 1) -------------------------------------------------
|
|
1816
|
-
/**
|
|
1847
|
+
/**
|
|
1848
|
+
* Kept for a coordinator from before #2447, which calls it and reads the previous role (for the
|
|
1849
|
+
* audit `before`) or null. The definition is judged as `defineRoleOrRefusal` judges it, and a
|
|
1850
|
+
* refusal is THROWN, so such a coordinator cannot read it as the previous role and audit a change
|
|
1851
|
+
* that never happened.
|
|
1852
|
+
*/
|
|
1817
1853
|
defineRole(tenantId, role) {
|
|
1854
|
+
const outcome = this.defineRoleOrRefusal(tenantId, role);
|
|
1855
|
+
if ('refused' in outcome)
|
|
1856
|
+
throw new Error(outcome.refused);
|
|
1857
|
+
return outcome.before;
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* INSERT OR REPLACE; answer the previous role (for the audit `before`) or null. Judged first, in
|
|
1861
|
+
* the same unit (#2447, `reservedRoleDefinitionRefusal`): against `reservation`, what the
|
|
1862
|
+
* coordinator's modules reserve and declare, and the tenant's roles as stored now, so no
|
|
1863
|
+
* concurrent definition lands between the read and the write. Whether the key is seated decides a
|
|
1864
|
+
* system-held definition of a key with no system-held definition stored (none at all included): its tenant-level seats are read here, in the unit, and
|
|
1865
|
+
* `seatedInScopes` carries what the coordinator read off the tenant's scopes. A refusal is answered
|
|
1866
|
+
* as data, so its text survives the hop, and writes nothing. The current coordinator calls this one
|
|
1867
|
+
* (the same split as `transitionScope` and `transitionScopeOrRefusal`).
|
|
1868
|
+
*/
|
|
1869
|
+
defineRoleOrRefusal(tenantId, role, reservation = { reserved: [], declared: [] }, seatedInScopes = false) {
|
|
1870
|
+
const tenantRoles = this.listRoles({ tenantId }).map((r) => ({
|
|
1871
|
+
key: r.role_key,
|
|
1872
|
+
permissions: JSON.parse(r.permissions),
|
|
1873
|
+
holder: r.holder ?? undefined,
|
|
1874
|
+
}));
|
|
1875
|
+
const stored = tenantRoles.find((r) => r.key === role.key);
|
|
1876
|
+
const seated = stored?.holder !== 'system' && role.holder === 'system' &&
|
|
1877
|
+
(seatedInScopes ||
|
|
1878
|
+
this.sql.exec(TENANT_SEATED_ROLES_SQL, tenantId, new Date().toISOString()).toArray().some((r) => r.role_key === role.key));
|
|
1879
|
+
const refused = reservedRoleDefinitionRefusal(role, { reserved: new Set(reservation.reserved), declared: new Set(reservation.declared) }, tenantRoles, seated);
|
|
1880
|
+
if (refused)
|
|
1881
|
+
return { refused };
|
|
1818
1882
|
const before = this.getRole(tenantId, role.key) ?? null;
|
|
1819
1883
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder)
|
|
1820
1884
|
VALUES (?, ?, ?, ?, ?)`, tenantId, role.key, JSON.stringify(role.permissions), String(role.source), role.holder ?? 'person');
|
|
1821
|
-
return before;
|
|
1885
|
+
return { before };
|
|
1822
1886
|
}
|
|
1823
1887
|
/** Raw rows; the coordinator parses them through the `tenantRole` contract. */
|
|
1824
1888
|
listRoles(filter) {
|
|
@@ -2149,8 +2213,14 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2149
2213
|
.exec('SELECT * FROM verticals WHERE slug = ?', slug)
|
|
2150
2214
|
.toArray()[0];
|
|
2151
2215
|
}
|
|
2152
|
-
insertVertical(slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt) {
|
|
2153
|
-
|
|
2216
|
+
insertVertical(slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt, audit) {
|
|
2217
|
+
// #2501: the slug's ownership period opens with its row, in the same transaction.
|
|
2218
|
+
this.writeAndAudit(() => {
|
|
2219
|
+
const changed = this.sql.exec('INSERT INTO verticals (slug, name, source, owner_tenant, env_spec, install_spec, listed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt).rowsWritten > 0;
|
|
2220
|
+
if (changed)
|
|
2221
|
+
openVerticalOwnerRow(doRedactionSql(this.sql), slug, ownerTenant, createdAt);
|
|
2222
|
+
return changed;
|
|
2223
|
+
}, audit);
|
|
2154
2224
|
}
|
|
2155
2225
|
/** Refresh a vertical's declared env-spec + install-spec (both evolve with the manifest).
|
|
2156
2226
|
* `listed` rides along ONLY for builtin re-seeds (null ⇒ leave it alone): a pushed
|
|
@@ -2161,25 +2231,55 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2161
2231
|
this.sql.exec('UPDATE verticals SET listed = ? WHERE slug = ?', listed, slug);
|
|
2162
2232
|
}
|
|
2163
2233
|
}
|
|
2164
|
-
/**
|
|
2165
|
-
|
|
2166
|
-
|
|
2234
|
+
/**
|
|
2235
|
+
* Publish/unpublish a vertical to the public marketplace + resolve any pending request. Listing
|
|
2236
|
+
* refuses a prod version that is auto-admitted (only its author has read it), judged on the
|
|
2237
|
+
* channel as this unit reads it (#2528).
|
|
2238
|
+
*/
|
|
2239
|
+
updateVerticalListed(slug, listed, audit) {
|
|
2240
|
+
this.updateVertical(slug, 'listed = ?, publish_requested_at = NULL', [listed], (r) => {
|
|
2241
|
+
if (listed) {
|
|
2242
|
+
const prod = this.sql
|
|
2243
|
+
.exec(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
|
|
2244
|
+
WHERE c.vertical_slug = ? AND c.channel = 'prod'`, slug)
|
|
2245
|
+
.toArray()[0];
|
|
2246
|
+
if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
2247
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
|
|
2248
|
+
`a staff admit must vouch for it before listing`);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
return { listed: !!r.listed };
|
|
2252
|
+
}, audit);
|
|
2167
2253
|
}
|
|
2168
2254
|
/** Record a builder's pending publish request (marketplace-publish.md §5). */
|
|
2169
|
-
updateVerticalPublishRequest(slug, requestedAt) {
|
|
2170
|
-
this.
|
|
2255
|
+
updateVerticalPublishRequest(slug, requestedAt, audit) {
|
|
2256
|
+
this.updateVertical(slug, 'publish_requested_at = ?', [requestedAt], () => null, audit);
|
|
2171
2257
|
}
|
|
2172
2258
|
/** Block/unblock new installs of a vertical (staff kill-switch). */
|
|
2173
|
-
updateVerticalInstallsBlocked(slug, blocked) {
|
|
2174
|
-
this.
|
|
2259
|
+
updateVerticalInstallsBlocked(slug, blocked, audit) {
|
|
2260
|
+
this.updateVertical(slug, 'installs_blocked = ?', [blocked], (r) => ({ installsBlocked: !!r.installs_blocked }), audit);
|
|
2175
2261
|
}
|
|
2176
2262
|
/** Grant/revoke the tenant-provisioner capability (#412, staff grant). */
|
|
2177
|
-
updateVerticalTenantProvisioner(slug, granted) {
|
|
2178
|
-
this.
|
|
2263
|
+
updateVerticalTenantProvisioner(slug, granted, audit) {
|
|
2264
|
+
this.updateVertical(slug, 'tenant_provisioner = ?', [granted], (r) => ({ tenantProvisioner: !!r.tenant_provisioner }), audit);
|
|
2179
2265
|
}
|
|
2180
2266
|
/** Grant/revoke the email-sender capability (#303, staff grant). */
|
|
2181
|
-
updateVerticalEmailSender(slug, granted) {
|
|
2182
|
-
this.
|
|
2267
|
+
updateVerticalEmailSender(slug, granted, audit) {
|
|
2268
|
+
this.updateVertical(slug, 'email_sender = ?', [granted], (r) => ({ emailSender: !!r.email_sender }), audit);
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* #2528: one vertical row update whose row reads its `before` (and runs its guard, by throwing)
|
|
2272
|
+
* from the vertical as this unit reads it, so neither can be stale against the write.
|
|
2273
|
+
*/
|
|
2274
|
+
updateVertical(slug, set, args, judge, audit) {
|
|
2275
|
+
let before = null;
|
|
2276
|
+
this.writeAndAudit(() => {
|
|
2277
|
+
const r = this.readVertical(slug);
|
|
2278
|
+
if (!r)
|
|
2279
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
2280
|
+
before = judge(r);
|
|
2281
|
+
return this.sql.exec(`UPDATE verticals SET ${set} WHERE slug = ?`, ...args, slug).rowsWritten > 0;
|
|
2282
|
+
}, audit && (() => ({ ...audit, before })));
|
|
2183
2283
|
}
|
|
2184
2284
|
/**
|
|
2185
2285
|
* How many scopes a vertical still backs, split by liveness — deleteVertical's refusal
|
|
@@ -2198,17 +2298,48 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2198
2298
|
return { live: r?.live ?? 0, archived: r?.archived ?? 0 };
|
|
2199
2299
|
}
|
|
2200
2300
|
/**
|
|
2201
|
-
* Remove a vertical's registry presence: channels, versions, the row.
|
|
2202
|
-
*
|
|
2301
|
+
* Remove a vertical's registry presence: channels, versions, the row. Refused while any
|
|
2302
|
+
* restorable scope is bound (`countScopesForVertical`): a deleted registry row would strand
|
|
2303
|
+
* those scopes' version pins and routing. That refusal and `audit`'s `before` are read in the
|
|
2304
|
+
* unit that deletes (#2528).
|
|
2203
2305
|
*/
|
|
2204
|
-
deleteVertical(slug) {
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
this.
|
|
2306
|
+
deleteVertical(slug, audit, until) {
|
|
2307
|
+
// #2501: one transaction, so the row and its ownership period close together. `until` is the
|
|
2308
|
+
// host's stamp, on the clock the period's `since` (the row's created_at) was read on. It is
|
|
2309
|
+
// optional and trailing so a caller from before it, sending (slug, audit), still closes the
|
|
2310
|
+
// period, on this DO's own reading of that clock; anything but an instant is refused.
|
|
2311
|
+
const closedAt = until === undefined ? verticalOwnerNow() : instant.parse(until);
|
|
2312
|
+
let before = null;
|
|
2313
|
+
this.writeAndAudit(() => {
|
|
2314
|
+
const existing = this.readVertical(slug);
|
|
2315
|
+
if (!existing)
|
|
2316
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
2317
|
+
// An `archived` scope (a deleted app) still blocks — unarchive can bring it back — but the
|
|
2318
|
+
// refusal names reap/restore, because the app itself is already gone.
|
|
2319
|
+
const bound = this.countScopesForVertical(slug);
|
|
2320
|
+
if (bound.live > 0) {
|
|
2321
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
2322
|
+
}
|
|
2323
|
+
if (bound.archived > 0) {
|
|
2324
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
2325
|
+
}
|
|
2326
|
+
before = { slug, source: existing.source, ownerTenant: existing.owner_tenant };
|
|
2327
|
+
this.sql.exec('DELETE FROM vertical_channels WHERE vertical_slug = ?', slug);
|
|
2328
|
+
this.sql.exec('DELETE FROM vertical_channel_history WHERE vertical_slug = ?', slug);
|
|
2329
|
+
this.sql.exec(`DELETE FROM vertical_version_migrations
|
|
2330
|
+
WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`, slug);
|
|
2331
|
+
forgetVerticalScopeStores(this.kernelSql, slug);
|
|
2332
|
+
forgetVerticalWireAuth(this.kernelSql, slug);
|
|
2333
|
+
this.sql.exec('DELETE FROM vertical_versions WHERE vertical_slug = ?', slug);
|
|
2334
|
+
const changed = this.sql.exec('DELETE FROM verticals WHERE slug = ?', slug).rowsWritten > 0;
|
|
2335
|
+
if (changed)
|
|
2336
|
+
closeVerticalOwnerRow(doRedactionSql(this.sql), slug, closedAt);
|
|
2337
|
+
return changed;
|
|
2338
|
+
}, audit && (() => ({ ...audit, before })));
|
|
2339
|
+
}
|
|
2340
|
+
/** #2501: every period of a slug's ownership, oldest first — kernel `vertical-owners.ts`. */
|
|
2341
|
+
listVerticalOwners(slug) {
|
|
2342
|
+
return listVerticalOwnerRows(doRedactionSql(this.sql), slug);
|
|
2212
2343
|
}
|
|
2213
2344
|
listVerticals(page) {
|
|
2214
2345
|
const where = [];
|
|
@@ -2234,17 +2365,27 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2234
2365
|
* the SQL already split off (`splitManifestMigrations`); `migrations` is null for a version
|
|
2235
2366
|
* that carries none to show.
|
|
2236
2367
|
*/
|
|
2237
|
-
insertVersion(v) {
|
|
2238
|
-
this
|
|
2368
|
+
insertVersion(v, audit) {
|
|
2369
|
+
// #2528: whether it self-admits is judged on the vertical as this unit reads it — a PRIVATE
|
|
2370
|
+
// vertical (tenant-owned, not listed) self-admits, noted so the publish seam can tell a staff
|
|
2371
|
+
// vouch from this shortcut — whatever the caller judged from its own earlier read.
|
|
2372
|
+
let admission = v.admission;
|
|
2373
|
+
this.writeAndAudit(() => {
|
|
2374
|
+
const owning = this.readVertical(v.verticalSlug);
|
|
2375
|
+
if (!owning)
|
|
2376
|
+
throw substratError('not_found', `unknown vertical '${v.verticalSlug}'`);
|
|
2377
|
+
const selfAdmits = owning.owner_tenant !== null && !owning.listed;
|
|
2378
|
+
admission = selfAdmits ? 'admitted' : 'pending';
|
|
2239
2379
|
this.sql.exec(`INSERT INTO vertical_versions
|
|
2240
2380
|
(id, vertical_slug, version, manifest_digest, permission_digest,
|
|
2241
2381
|
migration_digest, deployment_ref, admission, admission_note, manifest_json,
|
|
2242
2382
|
origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
|
|
2243
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef,
|
|
2383
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, admission, selfAdmits ? AUTO_ADMISSION_NOTE : null, v.manifestJson, v.originJson, v.migrations?.length ?? null, v.createdAt);
|
|
2244
2384
|
writeVersionMigrations(this.kernelSql, v.id, v.migrations);
|
|
2245
2385
|
// #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
|
|
2246
2386
|
indexVersionScopeStores(this.kernelSql, v.verticalSlug, v.id, v.manifestJson);
|
|
2247
|
-
|
|
2387
|
+
return true;
|
|
2388
|
+
}, audit && (() => ({ ...audit, after: { ...audit.after, admission } })));
|
|
2248
2389
|
}
|
|
2249
2390
|
/** Every scope-store binding any version of the vertical declared (#2286); indexes versions not indexed yet. */
|
|
2250
2391
|
verticalScopeStores(slug) {
|
|
@@ -2254,11 +2395,46 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2254
2395
|
readVersionMigrations(id) {
|
|
2255
2396
|
return versionMigrationsOf(this.kernelSql, id);
|
|
2256
2397
|
}
|
|
2257
|
-
/**
|
|
2258
|
-
*
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2398
|
+
/**
|
|
2399
|
+
* Record what the serving script now runs — written only after a successful in-place upload, so
|
|
2400
|
+
* a failed serve leaves the trailing state visible. #2528: `expected` is the serving ref and
|
|
2401
|
+
* version the caller's #2178 gate judged the move from (nulls: nothing served); the write lands
|
|
2402
|
+
* only if the row still names both (`SERVES_NOW_CONDITION`), and `audit`'s `before` is the
|
|
2403
|
+
* serving state this unit read. False when either moved. Optional and trailing, so a caller from
|
|
2404
|
+
* before it still writes unconditionally.
|
|
2405
|
+
*/
|
|
2406
|
+
setVerticalServing(slug, s, audit, expected) {
|
|
2407
|
+
let before = null;
|
|
2408
|
+
let raced = false;
|
|
2409
|
+
this.writeAndAudit(() => {
|
|
2410
|
+
const r = this.readVertical(slug);
|
|
2411
|
+
if (!r)
|
|
2412
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
2413
|
+
before = r.serving_ref ? { ref: r.serving_ref, versionId: r.serving_version_id } : null;
|
|
2414
|
+
const moved = this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
|
|
2415
|
+
serving_do_classes = ?, serving_migration_tag = ?
|
|
2416
|
+
WHERE ${expected === undefined ? 'slug = ?' : SERVES_NOW_CONDITION}`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, ...(expected === undefined ? [slug] : [slug, expected.ref, expected.versionId])).rowsWritten > 0;
|
|
2417
|
+
raced = !moved;
|
|
2418
|
+
return moved;
|
|
2419
|
+
}, audit && (() => ({ ...audit, before })));
|
|
2420
|
+
return !raced;
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* One upload's capability record (RFC wire-signing §7, Q8), replacing the script's earlier one. False,
|
|
2424
|
+
* with nothing written and no admin row, for a vertical the registry does not hold: the host answers
|
|
2425
|
+
* that `not_found` on its side of the RPC, so the code survives.
|
|
2426
|
+
*/
|
|
2427
|
+
recordWireAuth(row, audit) {
|
|
2428
|
+
return this.writeAndAudit(() => writeDeploymentWireAuth(this.kernelSql, row), audit);
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* One page of the stored capability records the filter's reach confines it to (one tenant's
|
|
2432
|
+
* verticals, or the fleet), by script name. The host judged the filter before the call; it is judged
|
|
2433
|
+
* again here (`deploymentWireAuthOf`), so no caller of the object reaches the table unconfined or
|
|
2434
|
+
* unbounded.
|
|
2435
|
+
*/
|
|
2436
|
+
listWireAuth(filter) {
|
|
2437
|
+
return deploymentWireAuthOf(this.kernelSql, filter);
|
|
2262
2438
|
}
|
|
2263
2439
|
/** Point a scope's routing at the serving script its data now lives in (#286). False when a
|
|
2264
2440
|
* conditional move lost its compare-and-set: the host throws, since a refusal thrown here
|
|
@@ -2297,8 +2473,54 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2297
2473
|
await deriveVersionMigrationDigest(this.kernelSql, r.id);
|
|
2298
2474
|
return read();
|
|
2299
2475
|
}
|
|
2300
|
-
|
|
2301
|
-
|
|
2476
|
+
/** Kept for a coordinator from before #2528, which judged the admission itself. */
|
|
2477
|
+
setAdmission(id, admission, note, audit) {
|
|
2478
|
+
this.writeAndAudit(() => this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id).rowsWritten > 0, audit);
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Admit a version, judged on its admission as this unit reads it (#2528). Idempotent on an
|
|
2482
|
+
* admitted one — except an AUTO-admitted version, which this upgrades to a manual vouch by
|
|
2483
|
+
* clearing the note (what the publish seam requires). A rejected one is refused.
|
|
2484
|
+
*/
|
|
2485
|
+
admitVersion(id, audit) {
|
|
2486
|
+
let row = { before: null, after: null };
|
|
2487
|
+
this.writeAndAudit(() => {
|
|
2488
|
+
const v = this.versionAdmission(id);
|
|
2489
|
+
if (v.admission === 'admitted') {
|
|
2490
|
+
if (v.admission_note !== AUTO_ADMISSION_NOTE)
|
|
2491
|
+
return false;
|
|
2492
|
+
row = { before: { admission: v.admission, note: v.admission_note }, after: { admission: 'admitted', note: null } };
|
|
2493
|
+
}
|
|
2494
|
+
else if (v.admission === 'rejected') {
|
|
2495
|
+
throw substratError('conflict', `version ${id} was rejected — publish a new one`);
|
|
2496
|
+
}
|
|
2497
|
+
else {
|
|
2498
|
+
row = { before: { admission: v.admission }, after: { admission: 'admitted' } };
|
|
2499
|
+
}
|
|
2500
|
+
return this.sql.exec("UPDATE vertical_versions SET admission = 'admitted', admission_note = NULL WHERE id = ?", id)
|
|
2501
|
+
.rowsWritten > 0;
|
|
2502
|
+
}, audit && (() => ({ ...audit, ...row })));
|
|
2503
|
+
}
|
|
2504
|
+
/** Reject a version, judged as `admitVersion` is: an admitted one is refused, a rejected one is a no-op. */
|
|
2505
|
+
rejectVersion(id, note, audit) {
|
|
2506
|
+
let before = null;
|
|
2507
|
+
this.writeAndAudit(() => {
|
|
2508
|
+
const v = this.versionAdmission(id);
|
|
2509
|
+
if (v.admission === 'admitted')
|
|
2510
|
+
throw substratError('conflict', `version ${id} is already admitted — it may be bound`);
|
|
2511
|
+
if (v.admission === 'rejected')
|
|
2512
|
+
return false;
|
|
2513
|
+
before = { admission: v.admission };
|
|
2514
|
+
return this.sql.exec("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?", note, id)
|
|
2515
|
+
.rowsWritten > 0;
|
|
2516
|
+
}, audit && (() => ({ ...audit, before, after: { admission: 'rejected', note } })));
|
|
2517
|
+
}
|
|
2518
|
+
versionAdmission(id) {
|
|
2519
|
+
const v = this.sql.exec('SELECT admission, admission_note FROM vertical_versions WHERE id = ?', id)
|
|
2520
|
+
.toArray()[0];
|
|
2521
|
+
if (!v)
|
|
2522
|
+
throw substratError('not_found', `unknown version ${id}`);
|
|
2523
|
+
return v;
|
|
2302
2524
|
}
|
|
2303
2525
|
/**
|
|
2304
2526
|
* False when a conditional bind lost its compare-and-set (the host throws, as above). #2347:
|
|
@@ -2338,12 +2560,15 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2338
2560
|
return true;
|
|
2339
2561
|
}, audit);
|
|
2340
2562
|
}
|
|
2341
|
-
/**
|
|
2563
|
+
/**
|
|
2564
|
+
* The directory effect and its admin row either both commit or both roll back. A thunk is
|
|
2565
|
+
* read after the write, in the same unit, for a row that carries what only the write knows.
|
|
2566
|
+
*/
|
|
2342
2567
|
writeAndAudit(write, audit) {
|
|
2343
2568
|
return this.ctx.storage.transactionSync(() => {
|
|
2344
2569
|
const changed = write();
|
|
2345
2570
|
if (changed && audit)
|
|
2346
|
-
this.recordAdmin(audit);
|
|
2571
|
+
this.recordAdmin(typeof audit === 'function' ? audit() : audit);
|
|
2347
2572
|
return changed;
|
|
2348
2573
|
});
|
|
2349
2574
|
}
|
|
@@ -2521,10 +2746,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2521
2746
|
* #2333: move a channel's pointer, and record the move, only if it still names `from` (null: the
|
|
2522
2747
|
* channel has none yet). A compare-and-set in one synchronous transaction, so no other promote
|
|
2523
2748
|
* runs between the read and the write: a promote judged its gates against `from` across awaits,
|
|
2524
|
-
* and the one that lost the race is told so (`false`) and writes nothing.
|
|
2749
|
+
* and the one that lost the race is told so (`false`) and writes nothing, `audit` included.
|
|
2525
2750
|
*/
|
|
2526
|
-
moveChannel(from, history) {
|
|
2527
|
-
return this.
|
|
2751
|
+
moveChannel(from, history, audit) {
|
|
2752
|
+
return this.writeAndAudit(() => {
|
|
2528
2753
|
const now = this.readChannel(history.vertical_slug, history.channel);
|
|
2529
2754
|
if ((now?.version_id ?? null) !== from)
|
|
2530
2755
|
return false;
|
|
@@ -2533,7 +2758,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2533
2758
|
ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`, history.vertical_slug, history.channel, history.version_id, history.at, history.version_id, history.at);
|
|
2534
2759
|
this.insertChannelHistory(history);
|
|
2535
2760
|
return true;
|
|
2536
|
-
});
|
|
2761
|
+
}, audit);
|
|
2537
2762
|
}
|
|
2538
2763
|
listChannels(verticalSlug, page) {
|
|
2539
2764
|
const where = ['vertical_slug = ?'];
|
|
@@ -2819,6 +3044,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2819
3044
|
storage: listScopeStorageRows(doRedactionSql(this.sql), { tenantId: tenantId, latest: true }),
|
|
2820
3045
|
storageAttempts: listScopeStorageAttemptRows(doRedactionSql(this.sql), tenantId),
|
|
2821
3046
|
storeStorage: listScopeStoreStorageRows(doRedactionSql(this.sql), tenantId),
|
|
3047
|
+
attachmentStorage: listScopeAttachmentStorageRows(doRedactionSql(this.sql), tenantId),
|
|
2822
3048
|
};
|
|
2823
3049
|
}
|
|
2824
3050
|
// -- the stored storage gauge (#1524) — kernel `storage-gauge.ts` --------------
|
|
@@ -2834,6 +3060,33 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2834
3060
|
pruneScopeStorage(limit) {
|
|
2835
3061
|
return pruneScopeStorageRows(doRedactionSql(this.sql), Date.now(), limit);
|
|
2836
3062
|
}
|
|
3063
|
+
// -- the daily request rollup (#2501) — kernel `request-rollup.ts` -------------
|
|
3064
|
+
recordRequestDay(input) {
|
|
3065
|
+
return { recorded: this.ctx.storage.transactionSync(() => recordRequestDayRows(doRedactionSql(this.sql), input)) };
|
|
3066
|
+
}
|
|
3067
|
+
listRequestDays(filter) {
|
|
3068
|
+
return listRequestDayRows(doRedactionSql(this.sql), filter);
|
|
3069
|
+
}
|
|
3070
|
+
listRequestRollupDays(filter) {
|
|
3071
|
+
return listRequestRollupDayRows(doRedactionSql(this.sql), filter);
|
|
3072
|
+
}
|
|
3073
|
+
pruneRequestDays(limit, retentionMonths) {
|
|
3074
|
+
return this.ctx.storage.transactionSync(() => pruneRequestDayRows(doRedactionSql(this.sql), Date.now(), limit, retentionMonths));
|
|
3075
|
+
}
|
|
3076
|
+
// -- the daily meter snapshot (#2501) — kernel `meter-days.ts` -------------------
|
|
3077
|
+
// One transaction: the snapshot's reads and its rows are one instant of the directory.
|
|
3078
|
+
recordMeterDay(input) {
|
|
3079
|
+
return this.ctx.storage.transactionSync(() => recordMeterDayRows(doRedactionSql(this.sql), input));
|
|
3080
|
+
}
|
|
3081
|
+
listMeterDays(filter) {
|
|
3082
|
+
return listMeterDayRows(doRedactionSql(this.sql), filter);
|
|
3083
|
+
}
|
|
3084
|
+
listMeterDayTakes(filter) {
|
|
3085
|
+
return listMeterDayTakeRows(doRedactionSql(this.sql), filter);
|
|
3086
|
+
}
|
|
3087
|
+
pruneMeterDays(limit, retentionMonths) {
|
|
3088
|
+
return this.ctx.storage.transactionSync(() => pruneMeterDayRows(doRedactionSql(this.sql), Date.now(), limit, retentionMonths));
|
|
3089
|
+
}
|
|
2837
3090
|
// -- identity pools (K-23) --------------------------------------------------
|
|
2838
3091
|
readPool(provider) {
|
|
2839
3092
|
return this.sql
|
|
@@ -3607,9 +3860,15 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3607
3860
|
}
|
|
3608
3861
|
/** The body of `recordOpsFailure`, for a caller already inside a transaction. */
|
|
3609
3862
|
insertOpsFailure(row) {
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3863
|
+
// #2540: a tenant's row with a `detail` is a pair — the tenant's row says what was answered, and
|
|
3864
|
+
// a row with no tenant, which only staff read, carries the failure's own text. Counted once.
|
|
3865
|
+
const detail = row.tenant_id && row.detail_id && typeof row.detail === 'string' ? row.detail.slice(0, 2000) : null;
|
|
3866
|
+
const insert = (id, tenantId, message) => this.sql.exec(`INSERT INTO _substrat_ops_failures
|
|
3867
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
3868
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, row.actor, row.operation, row.stage, tenantId, row.scope_id, row.vertical, row.version, row.status, message, row.reference, row.origin, row.code, row.fingerprint, row.at);
|
|
3869
|
+
if (detail !== null)
|
|
3870
|
+
insert(row.detail_id, null, detail);
|
|
3871
|
+
insert(row.id, row.tenant_id, row.message);
|
|
3613
3872
|
// Prune-on-write (#559): the retention lives HERE, not in a cron — every insert
|
|
3614
3873
|
// pays for its own housekeeping, so the table stays bounded even on a deployment
|
|
3615
3874
|
// whose scheduled pass is broken (the exact circumstance this table records).
|
|
@@ -3632,7 +3891,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3632
3891
|
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
3633
3892
|
last_version = COALESCE(excluded.last_version, last_version),
|
|
3634
3893
|
origin = COALESCE(excluded.origin, origin),
|
|
3635
|
-
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at,
|
|
3894
|
+
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at,
|
|
3895
|
+
// The issues are staff's (#2540): a pair's exemplar is its own text, not the tenant's
|
|
3896
|
+
// sentence, and it is owned as the row it is copied from is, the platform's — so erasure
|
|
3897
|
+
// reaches it on an id match, as it reaches that row.
|
|
3898
|
+
detail ?? row.message, detail !== null ? null : row.tenant_id, issueExemplarOwner(detail !== null ? null : row.tenant_id), row.vertical, row.version);
|
|
3636
3899
|
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
3637
3900
|
this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
|
|
3638
3901
|
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
@@ -3688,8 +3951,9 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3688
3951
|
params.push(query.fingerprint);
|
|
3689
3952
|
}
|
|
3690
3953
|
if (query.reference) {
|
|
3691
|
-
|
|
3692
|
-
|
|
3954
|
+
const byReference = opsFailureReferenceWhere(query.reference, query.referenceInText === true);
|
|
3955
|
+
where.push(byReference.sql);
|
|
3956
|
+
params.push(...byReference.params);
|
|
3693
3957
|
}
|
|
3694
3958
|
if (query.since) {
|
|
3695
3959
|
where.push('at >= ?');
|