@substrat-run/adapter-cloudflare 0.135.0 → 0.136.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/connector-calls.d.ts +11 -0
- package/dist/connector-calls.d.ts.map +1 -0
- package/dist/connector-calls.js +10 -0
- package/dist/connector-calls.js.map +1 -0
- package/dist/control-plane-do.d.ts +76 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +172 -49
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +193 -12
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +497 -46
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/live-reads.d.ts +5 -4
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +5 -4
- package/dist/live-reads.js.map +1 -1
- package/dist/r2.d.ts +2 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js.map +1 -1
- package/dist/route-resolver.d.ts +7 -0
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +4 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +632 -100
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +32 -6
- package/dist/scope-sweeper-do.d.ts.map +1 -1
- package/dist/scope-sweeper-do.js +35 -5
- package/dist/scope-sweeper-do.js.map +1 -1
- package/package.json +10 -9
package/dist/scope-do.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
2
2
|
import { DurableObject } from 'cloudflare:workers';
|
|
3
|
-
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
3
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
4
4
|
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
|
|
5
5
|
import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
6
6
|
import { OperationQueue } from './serialization.js';
|
|
7
7
|
import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
|
|
8
|
-
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
8
|
+
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
9
9
|
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
10
|
-
import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
|
|
10
|
+
import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
|
|
11
11
|
/**
|
|
12
12
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
13
13
|
* Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
|
|
@@ -327,6 +327,9 @@ const KERNEL_DDL = `
|
|
|
327
327
|
-- an exchange trades that secret for. Shared with the pure adapter from
|
|
328
328
|
-- @substrat-run/kernel so the two cannot part company; the column comments are there.
|
|
329
329
|
${CAPABILITY_DDL}
|
|
330
|
+
-- #1686: where a copied scope's data came from, and the event id its own events start above.
|
|
331
|
+
-- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
|
|
332
|
+
${COPY_ORIGIN_DDL}
|
|
330
333
|
`;
|
|
331
334
|
/**
|
|
332
335
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -362,6 +365,137 @@ function kernelEmit(ctx, event) {
|
|
|
362
365
|
throw new Error('kernelEmit: not an operation context this host built');
|
|
363
366
|
write(event);
|
|
364
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* The scope DO's write revision (#1722): the one SQL handle this object writes through, and the
|
|
370
|
+
* one way it opens a transaction, so a writer or a transaction added later cannot slip past it.
|
|
371
|
+
* A carry's conditional restore compares the revision, so every change to the store has to move
|
|
372
|
+
* it: an UPDATE in place (a drain receipt, a redrain) as surely as an emitted event (Codex #2008
|
|
373
|
+
* r2). `suspended` is the wake's idempotent DDL, a load's drop-and-replay and a migration's own
|
|
374
|
+
* statements, which advance it themselves.
|
|
375
|
+
*
|
|
376
|
+
* **Atomic with the write (Codex #2008 r4).** The bump runs synchronously, right BEFORE the first
|
|
377
|
+
* write that needs it, on the same connection: inside a `transactionSync` or `transaction` it
|
|
378
|
+
* commits or rolls back with that transaction's writes, and outside one it joins the same
|
|
379
|
+
* coalesced batch. A bump that fails throws before the write runs, so no write lands under the old
|
|
380
|
+
* revision. Nothing is deferred to a later commit.
|
|
381
|
+
*
|
|
382
|
+
* **Once per run, not per statement** (a bump per statement cost 28.7–32.4% on a write-heavy
|
|
383
|
+
* operation, `write-revision-cost.test.ts`). After a bump, later writes skip it while they are
|
|
384
|
+
* covered by it: until the run ends (a microtask clears the flag at its first await or its end)
|
|
385
|
+
* and until a transaction boundary. Entering a transaction clears it, so each committing
|
|
386
|
+
* transaction holds a bump of its own; leaving one clears it too, so after a rollback (which took
|
|
387
|
+
* the bump with it) or a commit the next write bumps again. Clearing more often only over-counts,
|
|
388
|
+
* and over-counting only refuses a restore that could have landed.
|
|
389
|
+
*/
|
|
390
|
+
class WriteRevision {
|
|
391
|
+
raw;
|
|
392
|
+
storage;
|
|
393
|
+
suspended;
|
|
394
|
+
refusal;
|
|
395
|
+
/** Whether the current run, in the current transaction scope, already holds a bump. */
|
|
396
|
+
covered = false;
|
|
397
|
+
/** Inside `bookkeeping`: the one path whose writes advance no revision. */
|
|
398
|
+
keeping = false;
|
|
399
|
+
/** A statement's text is almost always a constant, so its answer is remembered (bounded). */
|
|
400
|
+
writes = new Map();
|
|
401
|
+
sql;
|
|
402
|
+
constructor(raw, storage, suspended,
|
|
403
|
+
/** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
|
|
404
|
+
refusal) {
|
|
405
|
+
this.raw = raw;
|
|
406
|
+
this.storage = storage;
|
|
407
|
+
this.suspended = suspended;
|
|
408
|
+
this.refusal = refusal;
|
|
409
|
+
const exec = (query, ...bindings) => {
|
|
410
|
+
if (this.keeping && this.isWrite(query)) {
|
|
411
|
+
// Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
|
|
412
|
+
// else: any other write here would be one the revision never saw, which is the hole this
|
|
413
|
+
// class exists to close.
|
|
414
|
+
if (!isCopyMarkInsert(query) && !isLifecycleWrite(query)) {
|
|
415
|
+
throw substratError('internal', `the bookkeeping path takes only the copy-marker insert and the lifecycle delivery (#1722, #1713), not: ${query.slice(0, 80)}`);
|
|
416
|
+
}
|
|
417
|
+
return raw.exec(query, ...bindings);
|
|
418
|
+
}
|
|
419
|
+
if (!this.suspended() && this.isWrite(query)) {
|
|
420
|
+
const refused = this.refusal();
|
|
421
|
+
if (refused)
|
|
422
|
+
throw substratError('conflict', refused);
|
|
423
|
+
if (!this.covered)
|
|
424
|
+
this.bump();
|
|
425
|
+
}
|
|
426
|
+
return raw.exec(query, ...bindings);
|
|
427
|
+
};
|
|
428
|
+
this.sql = new Proxy(raw, {
|
|
429
|
+
get(target, prop) {
|
|
430
|
+
if (prop === 'exec')
|
|
431
|
+
return exec;
|
|
432
|
+
const value = Reflect.get(target, prop, target);
|
|
433
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Store bookkeeping that is not the scope's data: marking the store a copy (#2005, Codex #2008
|
|
439
|
+
* r10). That only ever restricts what the store may run and changes no data, so it advances no
|
|
440
|
+
* write revision: a backfill that marks a carry's source between its export and its wipe must
|
|
441
|
+
* not read as a write the carry did not copy (that would keep the copy for nothing). Enforced,
|
|
442
|
+
* not trusted: any write in here but the marker insert (`isCopyMarkInsert`) or the lifecycle
|
|
443
|
+
* delivery (`isLifecycleWrite`, #1713) throws. Clearing a
|
|
444
|
+
* marker is NOT bookkeeping (r11): it loosens the store, so it is a write a carry fences on.
|
|
445
|
+
*/
|
|
446
|
+
bookkeeping(run) {
|
|
447
|
+
const was = this.keeping;
|
|
448
|
+
this.keeping = true;
|
|
449
|
+
try {
|
|
450
|
+
return run();
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
this.keeping = was;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** `storage.transactionSync`, at a transaction boundary. */
|
|
457
|
+
transactionSync(run) {
|
|
458
|
+
this.covered = false;
|
|
459
|
+
try {
|
|
460
|
+
return this.storage.transactionSync(run);
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
this.covered = false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** `storage.transaction`, at a transaction boundary. */
|
|
467
|
+
async transaction(run) {
|
|
468
|
+
this.covered = false;
|
|
469
|
+
try {
|
|
470
|
+
return await this.storage.transaction(run);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
this.covered = false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
bump() {
|
|
477
|
+
// No catch: a bump that cannot be written fails the write it was for.
|
|
478
|
+
this.raw.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, '1')
|
|
479
|
+
ON CONFLICT (key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`, WRITE_REVISION_KEY);
|
|
480
|
+
this.covered = true;
|
|
481
|
+
queueMicrotask(() => {
|
|
482
|
+
this.covered = false;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
isWrite(query) {
|
|
486
|
+
let known = this.writes.get(query);
|
|
487
|
+
if (known === undefined) {
|
|
488
|
+
known = isWriteStatement(query);
|
|
489
|
+
if (this.writes.size >= 512)
|
|
490
|
+
this.writes.clear();
|
|
491
|
+
this.writes.set(query, known);
|
|
492
|
+
}
|
|
493
|
+
return known;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** What a write into a wiped copy answers (#1722): the scope's data lives in another script now. */
|
|
497
|
+
const CARRIED_AWAY_WRITE_REFUSAL = 'this copy of the scope was carried to another script and wiped (#1722); it takes no writes — ' +
|
|
498
|
+
'a request that reaches it was routed before the move';
|
|
365
499
|
function toRpcError(err) {
|
|
366
500
|
if (err instanceof Error) {
|
|
367
501
|
return err.constructor === Error ? err : new Error(err.message);
|
|
@@ -584,6 +718,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
584
718
|
* `runInDurableObject` — rather than assuming the runtime's documented behaviour.
|
|
585
719
|
*/
|
|
586
720
|
webSocketMessagesHandled = 0;
|
|
721
|
+
/**
|
|
722
|
+
* #1722: while true, a write does not advance the write revision. True through the wake's
|
|
723
|
+
* own idempotent DDL (which every wake re-runs, and which changes nothing) and through a
|
|
724
|
+
* load's drop-and-replay, which sets the revision itself once the store is rebuilt.
|
|
725
|
+
*/
|
|
726
|
+
revisionSuspended = true;
|
|
727
|
+
/**
|
|
728
|
+
* #1722 (Codex #2008 r7): whether this store is a copy a carry wiped (it holds the
|
|
729
|
+
* `carried_away` tombstone). Such a store takes no write: a stale request still routed here
|
|
730
|
+
* after the wipe would land a write that belongs to no live copy. A load, a rollback's
|
|
731
|
+
* restore, ends it. Read on every wake and after every load.
|
|
732
|
+
*/
|
|
733
|
+
carriedAwayCopy = false;
|
|
734
|
+
/** #1722: the write revision, and the one way this object opens a transaction. */
|
|
735
|
+
revision;
|
|
587
736
|
constructor(ctx, env) {
|
|
588
737
|
super(ctx, env);
|
|
589
738
|
// #1860: the client's 45s keep-alive (demos/ticket0/app/src/feed.ts) answered at
|
|
@@ -591,11 +740,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
591
740
|
// subscriber's ping still reaches `webSocketMessage` and pins the DO in memory —
|
|
592
741
|
// exactly the hibernation cost `acceptWebSocket` (below) exists to avoid.
|
|
593
742
|
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
|
|
594
|
-
this
|
|
743
|
+
// #1722: the ONE handle this object writes through and the one way it opens a transaction,
|
|
744
|
+
// so every write advances the write revision a carry's restore is fenced on, inside the
|
|
745
|
+
// transaction that commits it. Nothing else here may take the raw handle or open a
|
|
746
|
+
// transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
|
|
747
|
+
this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
|
|
748
|
+
this.sql = this.revision.sql;
|
|
595
749
|
for (const stmt of splitSqlStatements(KERNEL_DDL)) {
|
|
596
750
|
this.sql.exec(stmt);
|
|
597
751
|
}
|
|
598
752
|
this.applySpineColumnAdditions();
|
|
753
|
+
this.revisionSuspended = false;
|
|
754
|
+
this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
|
|
599
755
|
for (const registration of modules)
|
|
600
756
|
this.registerModule(registration);
|
|
601
757
|
for (const [name, handler] of Object.entries(bareOps))
|
|
@@ -860,7 +1016,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
860
1016
|
*/
|
|
861
1017
|
undrainedEventsRead(limit) {
|
|
862
1018
|
return readUndrainedOutbox((offset, count) => this.sql
|
|
863
|
-
.exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, count, offset)
|
|
1019
|
+
.exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`, count, offset)
|
|
864
1020
|
.toArray(), limit);
|
|
865
1021
|
}
|
|
866
1022
|
/**
|
|
@@ -1024,10 +1180,27 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1024
1180
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
1025
1181
|
VALUES (?, ?, ?, ?, NULL)`, id, moduleId, when, error);
|
|
1026
1182
|
};
|
|
1183
|
+
// #1901: one line per (event, importing module), as for the scope's own consumers.
|
|
1184
|
+
// The handler runs as the producer's peer, so that is who the line says it ran as.
|
|
1185
|
+
const lines = asyncLinePass();
|
|
1186
|
+
const unitOf = (moduleId, eventId, eventType) => ({
|
|
1187
|
+
kind: 'consumer',
|
|
1188
|
+
tenantId,
|
|
1189
|
+
scopeId,
|
|
1190
|
+
invocationId: asyncInvocationId(null),
|
|
1191
|
+
operation: moduleId,
|
|
1192
|
+
eventType,
|
|
1193
|
+
eventId,
|
|
1194
|
+
attempt: 1,
|
|
1195
|
+
startedAt: Date.now(),
|
|
1196
|
+
principalKind: peerSubject.kind,
|
|
1197
|
+
versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
1198
|
+
});
|
|
1027
1199
|
for (const w of batch.withheld) {
|
|
1028
1200
|
this.sql.exec(IMPORT_RECORD_SQL, w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
|
|
1029
1201
|
for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
|
|
1030
1202
|
deadLetter(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
|
|
1203
|
+
lines.write({ ...unitOf(moduleId, w.id, w.type), outcome: 'dead-lettered' });
|
|
1031
1204
|
}
|
|
1032
1205
|
result.withheld += 1;
|
|
1033
1206
|
}
|
|
@@ -1040,14 +1213,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1040
1213
|
ran = true;
|
|
1041
1214
|
if (imp.schemaVersion !== e.schemaVersion) {
|
|
1042
1215
|
deadLetter(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
|
|
1216
|
+
lines.write({ ...unitOf(imp.moduleId, e.id, e.type), outcome: 'dead-lettered' });
|
|
1043
1217
|
result.deadLettered += 1;
|
|
1044
1218
|
continue;
|
|
1045
1219
|
}
|
|
1046
1220
|
const { hops: _hops, ...fact } = e;
|
|
1047
1221
|
const event = structuredClone({ ...fact, source });
|
|
1222
|
+
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
1048
1223
|
this.causedBy = e.id;
|
|
1224
|
+
this.unitInvocationId = unit.invocationId;
|
|
1049
1225
|
try {
|
|
1050
|
-
await this.
|
|
1226
|
+
await this.revision.transaction(async () => {
|
|
1051
1227
|
// As the producer's principal: real checks against the grants this vertical's
|
|
1052
1228
|
// `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
|
|
1053
1229
|
await imp.handler(this.importContext(tenantId, scopeId, peerSubject), event);
|
|
@@ -1055,14 +1231,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1055
1231
|
VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
|
|
1056
1232
|
});
|
|
1057
1233
|
result.delivered += 1;
|
|
1234
|
+
lines.write({ ...unit, outcome: 'delivered' });
|
|
1058
1235
|
}
|
|
1059
1236
|
catch (err) {
|
|
1060
1237
|
// Dead-letter (v0), outside the rolled-back transaction.
|
|
1061
1238
|
deadLetter(e.id, imp.moduleId, new Date().toISOString(), String(err));
|
|
1062
1239
|
result.deadLettered += 1;
|
|
1240
|
+
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
1063
1241
|
}
|
|
1064
1242
|
finally {
|
|
1065
1243
|
this.causedBy = null;
|
|
1244
|
+
this.unitInvocationId = null;
|
|
1066
1245
|
}
|
|
1067
1246
|
}
|
|
1068
1247
|
if (!ran)
|
|
@@ -1070,6 +1249,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1070
1249
|
}
|
|
1071
1250
|
this.sql.exec(IMPORT_CURSOR_ADVANCE_SQL, source.scopeId, source.vertical, batch.next, at);
|
|
1072
1251
|
result.cursor = batch.next;
|
|
1252
|
+
lines.end();
|
|
1073
1253
|
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
1074
1254
|
return result;
|
|
1075
1255
|
});
|
|
@@ -1320,7 +1500,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1320
1500
|
* and the re-assert in which a sweep could run the grants just seated.
|
|
1321
1501
|
*/
|
|
1322
1502
|
async seatTuples(tuples, switchOff) {
|
|
1323
|
-
return this.queue.enqueue(() => this.
|
|
1503
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => {
|
|
1324
1504
|
for (const t of tuples) {
|
|
1325
1505
|
const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
|
|
1326
1506
|
this.sql.exec(seat.sql, ...seat.params);
|
|
@@ -1629,7 +1809,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1629
1809
|
// commit together, or a throw (from either) rolls domain writes AND
|
|
1630
1810
|
// emitted events back as one — verified across `await` in workerd.
|
|
1631
1811
|
try {
|
|
1632
|
-
await this.
|
|
1812
|
+
await this.revision.transaction(async () => {
|
|
1633
1813
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
|
|
1634
1814
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1635
1815
|
// — not the guards, not the handler, not the permission check inside
|
|
@@ -2111,7 +2291,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2111
2291
|
// the reason it is read there: that is where this call holds the DO to itself.
|
|
2112
2292
|
const liveSince = this.liveHighWaterMark();
|
|
2113
2293
|
try {
|
|
2114
|
-
await this.
|
|
2294
|
+
await this.revision.transaction(async () => {
|
|
2115
2295
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.upload');
|
|
2116
2296
|
assertAllowed(await ctx.check(gate.write, parsed.entity));
|
|
2117
2297
|
this.sql.exec(`INSERT INTO _substrat_attachments
|
|
@@ -2235,7 +2415,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2235
2415
|
// #938: same mark-then-settle pair as the upload path above.
|
|
2236
2416
|
const liveSince = this.liveHighWaterMark();
|
|
2237
2417
|
try {
|
|
2238
|
-
await this.
|
|
2418
|
+
await this.revision.transaction(async () => {
|
|
2239
2419
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.remove');
|
|
2240
2420
|
assertAllowed(await ctx.check(gate.write, record.entity));
|
|
2241
2421
|
this.sql.exec('DELETE FROM _substrat_attachments WHERE id = ?', attachmentId);
|
|
@@ -2301,7 +2481,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2301
2481
|
*/
|
|
2302
2482
|
async attachmentTextRecord(attachmentId, outcome) {
|
|
2303
2483
|
await this.ensureMigrations();
|
|
2304
|
-
return this.queue.enqueue(async () => this.
|
|
2484
|
+
return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
|
|
2305
2485
|
}
|
|
2306
2486
|
// -- attachments through a capability (#1686) -------------------------------
|
|
2307
2487
|
// The coordinator hashed the session token; only the hash arrives. Each verb resolves it
|
|
@@ -2435,7 +2615,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2435
2615
|
* as one `transactionSync`, so its reads and writes are one unit.
|
|
2436
2616
|
*/
|
|
2437
2617
|
async switchSystemSchedules(moduleId, scopeId, to, at) {
|
|
2438
|
-
const outcome = await this.queue.enqueue(() => this.
|
|
2618
|
+
const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
|
|
2439
2619
|
// #1819: the instance that applied it, which the rewind hold's release rule reads.
|
|
2440
2620
|
return { ...outcome, instance: this.instanceId };
|
|
2441
2621
|
}
|
|
@@ -2450,14 +2630,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2450
2630
|
// The consumer's own running imports decide whether the edge exists: read here, in the
|
|
2451
2631
|
// deployment whose handlers would run, never from the platform's request.
|
|
2452
2632
|
const imports = this.crossVertical.consumes();
|
|
2453
|
-
return this.queue.enqueue(() => this.
|
|
2633
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
|
|
2454
2634
|
}
|
|
2455
2635
|
/**
|
|
2456
2636
|
* Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
|
|
2457
2637
|
* the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
|
|
2458
2638
|
*/
|
|
2459
2639
|
async switchPeer(vertical, scopeId, to, at) {
|
|
2460
|
-
return this.queue.enqueue(() => this.
|
|
2640
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
|
|
2461
2641
|
}
|
|
2462
2642
|
/**
|
|
2463
2643
|
* Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
|
|
@@ -2581,7 +2761,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2581
2761
|
*/
|
|
2582
2762
|
switchHoldOn(scopeId, moduleId, claimIds) {
|
|
2583
2763
|
this.switchHoldsTable();
|
|
2584
|
-
this.
|
|
2764
|
+
this.revision.transactionSync(() => {
|
|
2585
2765
|
const seq = this.sql
|
|
2586
2766
|
.exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
|
|
2587
2767
|
ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
|
|
@@ -2606,7 +2786,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2606
2786
|
switchHoldClaim(scopeId, moduleIds, claimId, token) {
|
|
2607
2787
|
this.switchHoldsTable();
|
|
2608
2788
|
const at = new Date().toISOString();
|
|
2609
|
-
this.
|
|
2789
|
+
this.revision.transactionSync(() => {
|
|
2610
2790
|
for (const moduleId of moduleIds) {
|
|
2611
2791
|
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2612
2792
|
SELECT ?, ?, ?, 'pending', NULL, ?
|
|
@@ -2626,7 +2806,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2626
2806
|
switchHoldJoin(scopeId, moduleId, claimIds) {
|
|
2627
2807
|
this.switchHoldsTable();
|
|
2628
2808
|
const at = new Date().toISOString();
|
|
2629
|
-
this.
|
|
2809
|
+
this.revision.transactionSync(() => {
|
|
2630
2810
|
for (const claimId of claimIds) {
|
|
2631
2811
|
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2632
2812
|
SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
|
|
@@ -2684,7 +2864,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2684
2864
|
/** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
|
|
2685
2865
|
switchHoldRelease(scopeId, moduleId, claimIds) {
|
|
2686
2866
|
this.switchHoldsTable();
|
|
2687
|
-
this.
|
|
2867
|
+
this.revision.transactionSync(() => {
|
|
2688
2868
|
if (moduleId === null || claimIds === null) {
|
|
2689
2869
|
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
|
|
2690
2870
|
this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
|
|
@@ -2721,7 +2901,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2721
2901
|
// and the event's `occurredAt` are one fact, and two clock reads could disagree.
|
|
2722
2902
|
const now = instant.parse(new Date().toISOString());
|
|
2723
2903
|
let outcome = null;
|
|
2724
|
-
await this.
|
|
2904
|
+
await this.revision.transaction(async () => {
|
|
2725
2905
|
outcome = await exchangeCapability({
|
|
2726
2906
|
sql: doSpineSql(this.sql),
|
|
2727
2907
|
now,
|
|
@@ -2751,7 +2931,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2751
2931
|
*/
|
|
2752
2932
|
async revokeCapabilityAsPlatform(id, actor) {
|
|
2753
2933
|
await this.ensureMigrations();
|
|
2754
|
-
return await this.queue.enqueue(() => this.
|
|
2934
|
+
return await this.queue.enqueue(() => this.revision.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
|
|
2755
2935
|
}
|
|
2756
2936
|
/**
|
|
2757
2937
|
* The operator's read of this scope's capabilities (#1686). The kernel's one read
|
|
@@ -2873,7 +3053,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2873
3053
|
// `jobRunInsert`: an `await` between them is an output-gate boundary, and the
|
|
2874
3054
|
// point of doing this in one RPC is that there is no boundary to be evicted
|
|
2875
3055
|
// at. Same reason `SCHEDULE_STATE_REBUILD` insists on it.
|
|
2876
|
-
this.
|
|
3056
|
+
this.revision.transactionSync(() => {
|
|
2877
3057
|
this.sql.exec(`INSERT INTO _substrat_job_runs
|
|
2878
3058
|
(id, module_id, job, instance, payload, status, cursor, counters, attempts,
|
|
2879
3059
|
last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
|
|
@@ -2973,7 +3153,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2973
3153
|
// `transactionSync` with both statements inline — NOT `await
|
|
2974
3154
|
// this.jobRunPatch(...)` then the delete. The await is an output-gate
|
|
2975
3155
|
// boundary, which is precisely the gap this method exists to close.
|
|
2976
|
-
this.
|
|
3156
|
+
this.revision.transactionSync(() => {
|
|
2977
3157
|
this.sql.exec(JOB_RUN_PATCH_SQL, patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError, patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id);
|
|
2978
3158
|
this.sql.exec('DELETE FROM _substrat_job_steps WHERE run_id = ?', id);
|
|
2979
3159
|
});
|
|
@@ -3042,7 +3222,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3042
3222
|
if (this.applied.has(key))
|
|
3043
3223
|
continue;
|
|
3044
3224
|
try {
|
|
3045
|
-
await this.
|
|
3225
|
+
await this.revision.transaction(async () => {
|
|
3046
3226
|
const already = this.sql
|
|
3047
3227
|
.exec('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
|
|
3048
3228
|
.toArray()[0];
|
|
@@ -3052,8 +3232,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3052
3232
|
// #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
3053
3233
|
// spine guard's REFERENCES rule is applied here.
|
|
3054
3234
|
assertNoSpineReference(migration.sql, `migration ${key}`);
|
|
3055
|
-
|
|
3056
|
-
|
|
3235
|
+
// #1722: not counted per statement, so `total_changes()` measures the migration
|
|
3236
|
+
// alone. The journal row below is a write, and advances the revision once.
|
|
3237
|
+
this.revisionSuspended = true;
|
|
3238
|
+
try {
|
|
3239
|
+
for (const stmt of splitSqlStatements(migration.sql)) {
|
|
3240
|
+
this.sql.exec(stmt);
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
finally {
|
|
3244
|
+
this.revisionSuspended = false;
|
|
3057
3245
|
}
|
|
3058
3246
|
const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3059
3247
|
this.sql.exec('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed) VALUES (?, ?, ?, ?, ?)', moduleId, migration.version, new Date().toISOString(), Math.max(0, Math.round(performance.now() - started)), after - before);
|
|
@@ -3101,7 +3289,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3101
3289
|
.exec(`SELECT o.* FROM _substrat_outbox o
|
|
3102
3290
|
LEFT JOIN _substrat_deliveries d
|
|
3103
3291
|
ON d.event_id = o.id AND d.consumer_module = ?
|
|
3104
|
-
WHERE o.type = ?
|
|
3292
|
+
WHERE o.type = ? AND ${emittedHere('o.')}
|
|
3105
3293
|
AND (d.event_id IS NULL
|
|
3106
3294
|
OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
|
|
3107
3295
|
ORDER BY o.id`, deliveryId, eventType, new Date().toISOString())
|
|
@@ -3118,6 +3306,69 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3118
3306
|
}
|
|
3119
3307
|
return { events, undecodable };
|
|
3120
3308
|
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Whether this scope is a copy (#2005) — the classification on its `_substrat_copy_origin` row
|
|
3311
|
+
* (#2009), which only the directory's word sets, not a load's events mark. A CP-less
|
|
3312
|
+
* coordinator has no directory to read a scope's kind from, so this is how it holds a copy's
|
|
3313
|
+
* executor deliveries inert; a coordinator with a directory asks that instead.
|
|
3314
|
+
*/
|
|
3315
|
+
isCopy() {
|
|
3316
|
+
return this.sql.exec(IS_COPY_SQL).toArray().length > 0;
|
|
3317
|
+
}
|
|
3318
|
+
/** The lifecycle the platform last delivered to this scope (#1713), or null for none. */
|
|
3319
|
+
lifecycle() {
|
|
3320
|
+
return readLifecycle(this.switchSql());
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* Store a lifecycle the platform delivered (#1713, `writeLifecycle`): kept only when it is not
|
|
3324
|
+
* older than the one held. Bookkeeping, like the copy marker: it changes what runs here, not
|
|
3325
|
+
* the scope's data, so it does not advance the write revision a carry fences on.
|
|
3326
|
+
*/
|
|
3327
|
+
setLifecycle(next) {
|
|
3328
|
+
let out;
|
|
3329
|
+
this.revision.transactionSync(() => {
|
|
3330
|
+
out = this.revision.bookkeeping(() => writeLifecycle(this.switchSql(), next));
|
|
3331
|
+
});
|
|
3332
|
+
return out;
|
|
3333
|
+
}
|
|
3334
|
+
/** Mark this scope a copy (#2005, `markCopyOrigin`): the repair of a copy that predates the
|
|
3335
|
+
* marker. Answers whether this call stamped it; a store that already reads as a copy is left
|
|
3336
|
+
* as it is, and a load's events mark is never moved (#2009). */
|
|
3337
|
+
markCopy() {
|
|
3338
|
+
let marked = false;
|
|
3339
|
+
this.revision.transactionSync(() => {
|
|
3340
|
+
marked = this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
|
|
3341
|
+
});
|
|
3342
|
+
return marked;
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Clear a mistaken copy classification (#2005, `clearCopyMarker`); a load's events mark stays,
|
|
3346
|
+
* so copied work still never runs here (#2009). A
|
|
3347
|
+
* clear is a write like any other (Codex #2008 r11), never bookkeeping: it lets the store run
|
|
3348
|
+
* work a copy holds inert, so it advances the write revision, and a carry that exported
|
|
3349
|
+
* before it cannot wipe the repaired store. That carry's wipe keeps it instead.
|
|
3350
|
+
*/
|
|
3351
|
+
clearCopyMark(
|
|
3352
|
+
/** The store the caller means (Codex #2008 r12–r13): the platform's reconcile of the store a
|
|
3353
|
+
* carry landed names that carry's load stamp and the revision it read, so a store another
|
|
3354
|
+
* load has replaced since (a governed restore, whose classification is its own) is refused,
|
|
3355
|
+
* compared here, in the clear's own transaction. Absent for staff's correction. */
|
|
3356
|
+
expect) {
|
|
3357
|
+
return this.revision.transactionSync(() => {
|
|
3358
|
+
const from = this.metaValue(WRITE_REVISION_KEY);
|
|
3359
|
+
if (expect && (this.metaValue(LOAD_STAMP_KEY) !== expect.loadStamp || from !== expect.revision)) {
|
|
3360
|
+
return 'changed';
|
|
3361
|
+
}
|
|
3362
|
+
const outcome = clearCopyMarker(this.switchSql());
|
|
3363
|
+
// The clear's own revisions, so a carry's refused wipe can tell that this clear, and
|
|
3364
|
+
// nothing else, is what changed here since its export (`COPY_MARK_CLEARED_KEY`).
|
|
3365
|
+
if (outcome === 'cleared') {
|
|
3366
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)
|
|
3367
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value`, COPY_MARK_CLEARED_KEY, JSON.stringify({ from, to: this.metaValue(WRITE_REVISION_KEY) }));
|
|
3368
|
+
}
|
|
3369
|
+
return outcome;
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3121
3372
|
/**
|
|
3122
3373
|
* The same read as a bare list of events, for a coordinator deployed before
|
|
3123
3374
|
* `pendingExecutorDeliveries` (#1636). An undecodable row is left out rather than
|
|
@@ -3362,7 +3613,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3362
3613
|
let result;
|
|
3363
3614
|
const rollback = new Error('read-only console rollback');
|
|
3364
3615
|
try {
|
|
3365
|
-
await this.
|
|
3616
|
+
await this.revision.transaction(async () => {
|
|
3366
3617
|
const cursor = this.sql.exec(stmt);
|
|
3367
3618
|
const columns = cursor.columnNames;
|
|
3368
3619
|
const rows = [];
|
|
@@ -3442,12 +3693,32 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3442
3693
|
return defs.map(({ name, sql }) => {
|
|
3443
3694
|
// Raw positional rows, cells as-is (blobs kept as bytes, not nulled like a UI
|
|
3444
3695
|
// read) so the dump reloads faithfully. The name is from the live schema.
|
|
3445
|
-
|
|
3696
|
+
// #1722: the load stamp and the write revision describe THIS store, so they never leave
|
|
3697
|
+
// in a dump (`exportDumpStamped` hands the stamp over beside one); every load writes its own.
|
|
3698
|
+
const cursor = name === '_substrat_meta'
|
|
3699
|
+
? this.sql.exec(`SELECT * FROM "${name}" WHERE key NOT IN (${STORE_LOCAL_META_KEYS.map(() => '?').join(', ')})`, ...STORE_LOCAL_META_KEYS)
|
|
3700
|
+
: this.sql.exec(`SELECT * FROM "${name}"`);
|
|
3446
3701
|
const columns = cursor.columnNames;
|
|
3447
3702
|
const rows = Array.from(cursor.raw(), (row) => row);
|
|
3448
3703
|
return { name, ddl: sql, columns, rows };
|
|
3449
3704
|
});
|
|
3450
3705
|
}
|
|
3706
|
+
/**
|
|
3707
|
+
* `exportDump`, with the store's load stamp read in the same call (#1722): the stamp a
|
|
3708
|
+
* carry's fenced wipe of this copy later expects. A store no load has stamped gets one now,
|
|
3709
|
+
* so the stamp handed out is present exactly while nothing has been loaded here since. One
|
|
3710
|
+
* synchronous method, so no load can land between the dump and the stamp.
|
|
3711
|
+
*/
|
|
3712
|
+
exportDumpStamped() {
|
|
3713
|
+
let stamp = this.loadStamp();
|
|
3714
|
+
// A wiped copy takes no write, a stamp included; the carry refuses its tombstone anyway.
|
|
3715
|
+
if (!stamp && !this.carriedAwayCopy) {
|
|
3716
|
+
stamp = ulid();
|
|
3717
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, stamp);
|
|
3718
|
+
}
|
|
3719
|
+
// Read after the stamp's own write, so it is the revision the store holds as it is dumped.
|
|
3720
|
+
return { tables: this.exportDump(), loadStamp: stamp, revision: this.metaValue(WRITE_REVISION_KEY) };
|
|
3721
|
+
}
|
|
3451
3722
|
/**
|
|
3452
3723
|
* Load a dump into this (freshly-provisioned) scope — the write half of the fork
|
|
3453
3724
|
* (host.ts importScope). Drop-then-replay: the provisioning schema is wiped and rebuilt,
|
|
@@ -3529,6 +3800,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3529
3800
|
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
3530
3801
|
// as the other #1525 columns above argued.
|
|
3531
3802
|
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
3803
|
+
// #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
|
|
3804
|
+
// see `COPY_ORIGIN_DDL`).
|
|
3805
|
+
'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
|
|
3532
3806
|
]) {
|
|
3533
3807
|
try {
|
|
3534
3808
|
this.sql.exec(alter);
|
|
@@ -3574,12 +3848,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3574
3848
|
// runtime forbids a manual BEGIN through `sql.exec`, and this body is wholly
|
|
3575
3849
|
// synchronous, which is the one case the sync API is for (it commits at the
|
|
3576
3850
|
// first await, and there is none). It also has to be sync because the caller is.
|
|
3577
|
-
this.
|
|
3851
|
+
this.revision.transactionSync(() => {
|
|
3578
3852
|
for (const stmt of splitSqlStatements(SCHEDULE_STATE_REBUILD))
|
|
3579
3853
|
this.sql.exec(stmt);
|
|
3580
3854
|
});
|
|
3581
3855
|
}
|
|
3582
|
-
async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, } = {}) {
|
|
3856
|
+
async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, } = {}) {
|
|
3583
3857
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
3584
3858
|
//
|
|
3585
3859
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -3601,7 +3875,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3601
3875
|
// A dump written before indexes were excluded may still carry them; skipped
|
|
3602
3876
|
// rather than failing a restore over data about to be recomputed.
|
|
3603
3877
|
// Matched without case, as SQLite resolves a table name (#1883 review).
|
|
3604
|
-
|
|
3878
|
+
// #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
|
|
3879
|
+
const replayable = capabilitiesForLoad(tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase())), destScopeId, sourceScopeId);
|
|
3605
3880
|
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
3606
3881
|
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
3607
3882
|
// TABLE executed that too — with entirely plain identifiers, which is why no
|
|
@@ -3613,74 +3888,123 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3613
3888
|
if (exact && !sourceScopeId) {
|
|
3614
3889
|
throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
|
|
3615
3890
|
}
|
|
3616
|
-
const switched = await this.
|
|
3617
|
-
|
|
3618
|
-
//
|
|
3619
|
-
const
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
if (
|
|
3627
|
-
|
|
3628
|
-
|
|
3891
|
+
const switched = await this.revision.transaction(async () => {
|
|
3892
|
+
const before = this.loadMarker();
|
|
3893
|
+
// #1713: the lifecycle the platform delivered here, read before the drops take it.
|
|
3894
|
+
const lifecycleBefore = readLifecycle(this.switchSql());
|
|
3895
|
+
// #1722 (Codex #2008 r7): a kept copy holds writes nothing else has. No load replaces it
|
|
3896
|
+
// except its own resolution, and that only at the revision the operator acted on.
|
|
3897
|
+
const kept = this.metaValue(KEPT_DIVERGENT_KEY);
|
|
3898
|
+
if (resolveKept) {
|
|
3899
|
+
if (!kept)
|
|
3900
|
+
throw substratError('precondition_failed', 'no kept copy here to resolve; nothing was loaded');
|
|
3901
|
+
if (before.revision !== resolveKept.revision ||
|
|
3902
|
+
(resolveKept.loadStamp !== undefined && before.loadStamp !== resolveKept.loadStamp)) {
|
|
3903
|
+
throw substratError('precondition_failed', 'the kept copy changed since it was read; nothing was loaded');
|
|
3904
|
+
}
|
|
3629
3905
|
}
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
this.sql.exec(t.ddl);
|
|
3638
|
-
// KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
|
|
3639
|
-
// WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
|
|
3640
|
-
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
3641
|
-
// `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
|
|
3642
|
-
// them, and without them the very next permission check would raise a bare `no such
|
|
3643
|
-
// table: _substrat_roles`. Roles land empty here and are re-projected by the
|
|
3644
|
-
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
3645
|
-
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
3646
|
-
// leaves to it.
|
|
3647
|
-
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
3648
|
-
this.sql.exec(stmt);
|
|
3649
|
-
this.applySpineColumnAdditions();
|
|
3650
|
-
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
3651
|
-
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
3652
|
-
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
3653
|
-
// untyped column the checker never reads.
|
|
3654
|
-
for (const t of replayable) {
|
|
3655
|
-
if (isSpineTable(t.name))
|
|
3656
|
-
for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
|
|
3657
|
-
this.sql.exec(alter);
|
|
3906
|
+
else if (kept) {
|
|
3907
|
+
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
3908
|
+
}
|
|
3909
|
+
if (expect) {
|
|
3910
|
+
if (before.loadStamp !== expect.loadStamp || (expect.revision !== undefined && before.revision !== expect.revision)) {
|
|
3911
|
+
throw substratError('precondition_failed', 'scope store changed since it was read; nothing was loaded');
|
|
3912
|
+
}
|
|
3658
3913
|
}
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3914
|
+
// #1722: the drops take `_substrat_meta` with them; the revision is written back below.
|
|
3915
|
+
this.revisionSuspended = true;
|
|
3916
|
+
try {
|
|
3917
|
+
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
3918
|
+
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
3919
|
+
const existing = this.sql
|
|
3920
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
|
|
3921
|
+
.toArray();
|
|
3922
|
+
// Search index tables are left alone here and rebuilt below (#827): dropping a
|
|
3923
|
+
// shadow table directly is an error, and `sqlite_master` order would reach one
|
|
3924
|
+
// before its virtual table.
|
|
3925
|
+
for (const { name } of existing) {
|
|
3926
|
+
if (isSearchIndexTable(name.toLowerCase()))
|
|
3927
|
+
continue;
|
|
3928
|
+
this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
3929
|
+
}
|
|
3930
|
+
// A vertical's tables take the dump's own DDL. The spine never does (#1883): a dump
|
|
3931
|
+
// that declared a column the checker compares on as COLLATE NOCASE would otherwise
|
|
3932
|
+
// decide how this DO's permission checks match. Every `_substrat_*` table is built
|
|
3933
|
+
// from KERNEL_DDL instead, and the dump contributes only rows, by column name
|
|
3934
|
+
// (`spineRowsInsert`), a missing column taking the kernel's default.
|
|
3935
|
+
for (const t of replayable)
|
|
3936
|
+
if (!isSpineTable(t.name))
|
|
3937
|
+
this.sql.exec(t.ddl);
|
|
3938
|
+
// KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
|
|
3939
|
+
// WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
|
|
3940
|
+
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
3941
|
+
// `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
|
|
3942
|
+
// them, and without them the very next permission check would raise a bare `no such
|
|
3943
|
+
// table: _substrat_roles`. Roles land empty here and are re-projected by the
|
|
3944
|
+
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
3945
|
+
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
3946
|
+
// leaves to it.
|
|
3947
|
+
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
3948
|
+
this.sql.exec(stmt);
|
|
3949
|
+
this.applySpineColumnAdditions();
|
|
3950
|
+
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
3951
|
+
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
3952
|
+
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
3953
|
+
// untyped column the checker never reads.
|
|
3954
|
+
for (const t of replayable) {
|
|
3955
|
+
if (isSpineTable(t.name))
|
|
3956
|
+
for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
|
|
3957
|
+
this.sql.exec(alter);
|
|
3958
|
+
}
|
|
3959
|
+
for (const t of replayable) {
|
|
3960
|
+
const insert = dumpRowsInsert(t, columnsOf);
|
|
3961
|
+
for (const row of t.rows)
|
|
3962
|
+
this.sql.exec(insert, ...row);
|
|
3963
|
+
}
|
|
3964
|
+
// #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
|
|
3965
|
+
// not this one. Dropped, so a restore never carries a receipt over; the repair
|
|
3966
|
+
// projection that follows writes this scope's own.
|
|
3967
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
|
|
3968
|
+
// #1722: whatever stamp an export read here no longer describes this store, so every load
|
|
3969
|
+
// replaces it: with the carry's own, or with none. And the write revision moves on from
|
|
3970
|
+
// where it stood before the drops, so it never goes back.
|
|
3971
|
+
if (loadStamp) {
|
|
3972
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, loadStamp);
|
|
3973
|
+
}
|
|
3974
|
+
else {
|
|
3975
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, LOAD_STAMP_KEY);
|
|
3976
|
+
}
|
|
3977
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, WRITE_REVISION_KEY, String(Number(before.revision ?? 0) + 1));
|
|
3978
|
+
// #1575: attachment text is not in a dump, so the load left it as it was. Drop the
|
|
3979
|
+
// text of attachments the dump did not bring back, and queue extraction for those
|
|
3980
|
+
// it brought back without text — the bytes decide what that run finds.
|
|
3981
|
+
const now = new Date().toISOString();
|
|
3982
|
+
// #1686: nothing the source queued runs in a copy; a return leaves it all queued. BEFORE
|
|
3983
|
+
// the extraction queue below, which is this scope's own work, not the source's.
|
|
3984
|
+
settleCopiedWork(this.switchSql(), destScopeId, sourceScopeId, now);
|
|
3985
|
+
if (markCopy)
|
|
3986
|
+
markCopyOrigin(this.switchSql(), now);
|
|
3987
|
+
// #1713: a return keeps the newer of this store's lifecycle and the dump's, so a backup
|
|
3988
|
+
// from before a suspension does not lift it; a copy keeps only its own.
|
|
3989
|
+
settleLifecycleAfterLoad(this.switchSql(), lifecycleBefore, markCopy === true || isCopyLoad(destScopeId, sourceScopeId));
|
|
3990
|
+
reconcileAttachmentText(doSpineSql(this.sql), ulid, now);
|
|
3991
|
+
// Re-point the restored grants at THIS scope (after the spine exists, so a dump
|
|
3992
|
+
// that carried no tuples table still finds one here).
|
|
3993
|
+
if (destScopeId) {
|
|
3994
|
+
this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
|
|
3995
|
+
}
|
|
3996
|
+
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
3997
|
+
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
3998
|
+
// the dump's grants never commit live without the switch that should cover them.
|
|
3999
|
+
return destScopeId && switchOff
|
|
4000
|
+
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
4001
|
+
: [];
|
|
3663
4002
|
}
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
// projection that follows writes this scope's own.
|
|
3667
|
-
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
|
|
3668
|
-
// #1575: attachment text is not in a dump, so the load left it as it was. Drop the
|
|
3669
|
-
// text of attachments the dump did not bring back, and queue extraction for those
|
|
3670
|
-
// it brought back without text — the bytes decide what that run finds.
|
|
3671
|
-
reconcileAttachmentText(doSpineSql(this.sql), ulid, new Date().toISOString());
|
|
3672
|
-
// Re-point the restored grants at THIS scope (after the spine exists, so a dump
|
|
3673
|
-
// that carried no tuples table still finds one here).
|
|
3674
|
-
if (destScopeId) {
|
|
3675
|
-
this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
|
|
4003
|
+
finally {
|
|
4004
|
+
this.revisionSuspended = false;
|
|
3676
4005
|
}
|
|
3677
|
-
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
3678
|
-
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
3679
|
-
// the dump's grants never commit live without the switch that should cover them.
|
|
3680
|
-
return destScopeId && switchOff
|
|
3681
|
-
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
3682
|
-
: [];
|
|
3683
4006
|
});
|
|
4007
|
+
this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
|
|
3684
4008
|
// Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
|
|
3685
4009
|
// create, so it also repairs an index a dump left stale, and the triggers it
|
|
3686
4010
|
// recreates are what keep the restored scope in step from here. Skipped for a plan
|
|
@@ -3695,6 +4019,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3695
4019
|
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
3696
4020
|
this.sql.exec(stmt);
|
|
3697
4021
|
}
|
|
4022
|
+
// #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
|
|
4023
|
+
// as on a wake. A copy's own events then sort above every copied one, which is what
|
|
4024
|
+
// `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
|
|
4025
|
+
const top = this.sql.exec('SELECT MAX(id) AS id FROM _substrat_outbox').toArray()[0]?.id;
|
|
4026
|
+
if (top) {
|
|
4027
|
+
try {
|
|
4028
|
+
this.mintEventId.seedFrom(top);
|
|
4029
|
+
}
|
|
4030
|
+
catch {
|
|
4031
|
+
// Not a ULID: nothing this mint could have written, so there is no floor to keep.
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
3698
4034
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
3699
4035
|
// later migrate() builds on the imported state, not the provisioning state.
|
|
3700
4036
|
this.applied.clear();
|
|
@@ -3728,6 +4064,164 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3728
4064
|
this.lastFailure = null;
|
|
3729
4065
|
return switched;
|
|
3730
4066
|
}
|
|
4067
|
+
/**
|
|
4068
|
+
* `importDump` with its two refusals answered as values (#1722): across this RPC a throw
|
|
4069
|
+
* carries only its message, and the host has to tell "the store moved since it was read"
|
|
4070
|
+
* (`changed`, 412) and "this store is a kept copy" (`kept`, 409) from a failure.
|
|
4071
|
+
*/
|
|
4072
|
+
async importDumpChecked(tables, destScopeId, opts) {
|
|
4073
|
+
try {
|
|
4074
|
+
return { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
|
|
4075
|
+
}
|
|
4076
|
+
catch (e) {
|
|
4077
|
+
const code = errorCodeOf(e);
|
|
4078
|
+
if (code === 'precondition_failed')
|
|
4079
|
+
return { refused: 'changed' };
|
|
4080
|
+
if (code === 'conflict')
|
|
4081
|
+
return { refused: 'kept' };
|
|
4082
|
+
throw toRpcError(e);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
/** The kept-copy marker (#1722), or null when this store is not one. */
|
|
4086
|
+
keptCopy() {
|
|
4087
|
+
const raw = this.metaValue(KEPT_DIVERGENT_KEY);
|
|
4088
|
+
return raw ? JSON.parse(raw) : null;
|
|
4089
|
+
}
|
|
4090
|
+
/**
|
|
4091
|
+
* Release a kept copy that is the scope's live store after all (#1722, Codex #2008 r8): a carry
|
|
4092
|
+
* that protected its source when the route read elsewhere, while a rollback was about to bind
|
|
4093
|
+
* it. Clears the marker only, fenced on the write revision read; the data stays as it is.
|
|
4094
|
+
*/
|
|
4095
|
+
releaseKeptCopy(revision,
|
|
4096
|
+
/** The directory says this scope is not primary (#2005): the released store is marked a copy. */
|
|
4097
|
+
markCopy,
|
|
4098
|
+
/** The load stamp read with `revision` (Codex #2008 r13): a store a load replaced is refused.
|
|
4099
|
+
* A kept copy refuses every load but its own resolution, so this holds by construction too. */
|
|
4100
|
+
loadStamp) {
|
|
4101
|
+
return this.revision.transactionSync(() => {
|
|
4102
|
+
if (!this.metaValue(KEPT_DIVERGENT_KEY))
|
|
4103
|
+
return { refused: 'not-kept' };
|
|
4104
|
+
if (this.metaValue(WRITE_REVISION_KEY) !== revision)
|
|
4105
|
+
return { refused: 'changed' };
|
|
4106
|
+
if (loadStamp !== undefined && this.metaValue(LOAD_STAMP_KEY) !== loadStamp)
|
|
4107
|
+
return { refused: 'changed' };
|
|
4108
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, KEPT_DIVERGENT_KEY);
|
|
4109
|
+
if (markCopy)
|
|
4110
|
+
this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
|
|
4111
|
+
return { released: true };
|
|
4112
|
+
});
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* Discard a kept copy (#1722): the staff resolution that wipes it to the tombstone, only if it
|
|
4116
|
+
* is still one at the write revision the operator acted on. The marker goes with the wipe.
|
|
4117
|
+
*/
|
|
4118
|
+
async discardKeptCopy(scopeId, revision, carriedAway,
|
|
4119
|
+
/** The directory says this scope is not primary (#2005): as `wipeCarried`. */
|
|
4120
|
+
markCopy,
|
|
4121
|
+
/** The load stamp read with `revision` (Codex #2008 r13), as `releaseKeptCopy`. */
|
|
4122
|
+
loadStamp) {
|
|
4123
|
+
if (!this.metaValue(KEPT_DIVERGENT_KEY))
|
|
4124
|
+
return { refused: 'not-kept' };
|
|
4125
|
+
try {
|
|
4126
|
+
await this.importDump(carriedAwayDump(carriedAway), scopeId, {
|
|
4127
|
+
sourceScopeId: scopeId,
|
|
4128
|
+
resolveKept: { revision, ...(loadStamp !== undefined ? { loadStamp } : {}) },
|
|
4129
|
+
markCopy: markCopy === true || this.isCopy(), // #2005: as `wipeCarried`
|
|
4130
|
+
});
|
|
4131
|
+
return { discarded: true };
|
|
4132
|
+
}
|
|
4133
|
+
catch (e) {
|
|
4134
|
+
if (errorCodeOf(e) === 'precondition_failed')
|
|
4135
|
+
return { refused: 'changed' };
|
|
4136
|
+
throw toRpcError(e);
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
/** What a carry's restore into this store expects to find unchanged (#1722): see `LoadMarker`. */
|
|
4140
|
+
loadMarker() {
|
|
4141
|
+
return { loadStamp: this.metaValue(LOAD_STAMP_KEY), revision: this.metaValue(WRITE_REVISION_KEY) };
|
|
4142
|
+
}
|
|
4143
|
+
/** This store's load stamp (#1722), or null when no load has written one. */
|
|
4144
|
+
loadStamp() {
|
|
4145
|
+
return this.metaValue(LOAD_STAMP_KEY);
|
|
4146
|
+
}
|
|
4147
|
+
metaValue(key) {
|
|
4148
|
+
const row = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = ?`, key).toArray()[0];
|
|
4149
|
+
return row?.value ?? null;
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Wipe the copy a carry left in this script (#1722), only if nothing was loaded here since
|
|
4153
|
+
* the carry exported it. A rollback that restored into this DO in the meantime replaced or
|
|
4154
|
+
* cleared the stamp, and its restore stays. Non-terminal, unlike `destroyStorage`: the store is a load of
|
|
4155
|
+
* the `carriedAway` tombstone, so a later restore into it works as on any scope. False when
|
|
4156
|
+
* refused.
|
|
4157
|
+
*/
|
|
4158
|
+
async wipeCarried(scopeId, expectLoadStamp, carriedAway, { expectRevision, protectIfChanged, markCopy, } = {}) {
|
|
4159
|
+
// A store marked now stays one: the copy-origin row goes on the tombstone (the wipe), or
|
|
4160
|
+
// on the copy kept (a refusal), and never counts as a data write (`bookkeeping`).
|
|
4161
|
+
const copy = markCopy === true || this.isCopy();
|
|
4162
|
+
try {
|
|
4163
|
+
await this.importDump(carriedAwayDump(carriedAway), scopeId, {
|
|
4164
|
+
sourceScopeId: scopeId,
|
|
4165
|
+
expect: { loadStamp: expectLoadStamp, ...(expectRevision !== undefined ? { revision: expectRevision } : {}) },
|
|
4166
|
+
// #2005: a copy wiped is still a copy. The drop-and-replay takes the copy-origin row
|
|
4167
|
+
// with everything else, so it is written again, or the tombstone would read as primary.
|
|
4168
|
+
markCopy: copy,
|
|
4169
|
+
});
|
|
4170
|
+
return true;
|
|
4171
|
+
}
|
|
4172
|
+
catch (e) {
|
|
4173
|
+
const code = errorCodeOf(e);
|
|
4174
|
+
// Only on the directory's word: the store's own marker, read before the refused load, may
|
|
4175
|
+
// have been cleared by staff since (Codex #2008 r12), and marking it again would undo that.
|
|
4176
|
+
if (markCopy === true) {
|
|
4177
|
+
this.revision.transactionSync(() => this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), carriedAway.at)));
|
|
4178
|
+
}
|
|
4179
|
+
if (code === 'conflict') {
|
|
4180
|
+
// Already a kept copy, and the scope has been carried away from it again (Codex #2008
|
|
4181
|
+
// r9). That is recorded on the marker, which is a write: it advances the revision, so a
|
|
4182
|
+
// release (staff or a carry's auto-release) that read the revision before this, when the
|
|
4183
|
+
// scope still routed here, fails its compare-and-set rather than clearing the marker of
|
|
4184
|
+
// a copy the scope has just left.
|
|
4185
|
+
this.revision.transactionSync(() => {
|
|
4186
|
+
const raw = this.metaValue(KEPT_DIVERGENT_KEY);
|
|
4187
|
+
if (!raw)
|
|
4188
|
+
return;
|
|
4189
|
+
const kept = { ...JSON.parse(raw), leftAgain: { to: carriedAway.to, at: carriedAway.at } };
|
|
4190
|
+
this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(kept), KEPT_DIVERGENT_KEY);
|
|
4191
|
+
});
|
|
4192
|
+
return false;
|
|
4193
|
+
}
|
|
4194
|
+
if (code !== 'precondition_failed')
|
|
4195
|
+
throw toRpcError(e);
|
|
4196
|
+
// Refused. When nothing was LOADED here since the export (the stamp is the one read) but
|
|
4197
|
+
// the store was WRITTEN (the revision moved), the copy holds a write the carry never
|
|
4198
|
+
// copied: protect it in the store itself, decided again inside its own transaction.
|
|
4199
|
+
this.revision.transactionSync(() => {
|
|
4200
|
+
const now = this.loadMarker();
|
|
4201
|
+
const writtenSince = expectRevision !== undefined && now.loadStamp === expectLoadStamp && now.revision !== expectRevision;
|
|
4202
|
+
const changedSince = now.loadStamp !== expectLoadStamp || (expectRevision !== undefined && now.revision !== expectRevision);
|
|
4203
|
+
// A wiped copy holds nothing to protect (and takes no write, the marker included).
|
|
4204
|
+
if (this.carriedAwayCopy || this.metaValue(KEPT_DIVERGENT_KEY))
|
|
4205
|
+
return;
|
|
4206
|
+
if (!writtenSince && !(protectIfChanged && changedSince))
|
|
4207
|
+
return;
|
|
4208
|
+
const kept = { carriedTo: carriedAway.to, keptAt: carriedAway.at, revision: now.revision };
|
|
4209
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, KEPT_DIVERGENT_KEY, JSON.stringify(kept));
|
|
4210
|
+
// Codex #2008 r12: changed ONLY by a staff clear of the copy marker — the clear's own
|
|
4211
|
+
// revisions span exactly the export's and the store's now — so the data is what the carry
|
|
4212
|
+
// copied, and only the fresher classification is not. Recorded on the marker with the
|
|
4213
|
+
// revision this keep committed at (the same run: no second bump), which the platform's
|
|
4214
|
+
// discard is fenced on once it has brought the clear to where the scope runs.
|
|
4215
|
+
const cleared = this.metaValue(COPY_MARK_CLEARED_KEY);
|
|
4216
|
+
const span = cleared ? JSON.parse(cleared) : null;
|
|
4217
|
+
if (writtenSince && span && span.from === expectRevision && span.to === now.revision) {
|
|
4218
|
+
const settled = { ...kept, clearedOnly: { revision: this.metaValue(WRITE_REVISION_KEY) } };
|
|
4219
|
+
this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(settled), KEPT_DIVERGENT_KEY);
|
|
4220
|
+
}
|
|
4221
|
+
});
|
|
4222
|
+
return false;
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
3731
4225
|
/**
|
|
3732
4226
|
* Re-point scope-level tuples at the scope they now live in.
|
|
3733
4227
|
*
|
|
@@ -3874,6 +4368,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3874
4368
|
* later alarm-driven drain with a call it had nothing to do with.
|
|
3875
4369
|
*/
|
|
3876
4370
|
invocationId = null;
|
|
4371
|
+
/**
|
|
4372
|
+
* #1901: the id a unit of async work with no call around it logs under — a consumer a
|
|
4373
|
+
* seed or an import delivered — set for its handler's duration and cleared after. Read
|
|
4374
|
+
* by `ctx.log` only, never the spine: an event a consumer emits outside a call still
|
|
4375
|
+
* records no invocation (#1525), while its log lines join the consumer's own line.
|
|
4376
|
+
*/
|
|
4377
|
+
unitInvocationId = null;
|
|
3877
4378
|
async dispatch(tenantId, scopeId,
|
|
3878
4379
|
/**
|
|
3879
4380
|
* #1525: the call this drain is running in, or null. Every round of the loop
|
|
@@ -3881,13 +4382,23 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3881
4382
|
* whole cascade is one call's work.
|
|
3882
4383
|
*/
|
|
3883
4384
|
invocationId) {
|
|
4385
|
+
// #1901: one line per delivery, capped per drain.
|
|
4386
|
+
const lines = asyncLinePass();
|
|
4387
|
+
try {
|
|
4388
|
+
await this.dispatchRounds(tenantId, scopeId, invocationId, lines);
|
|
4389
|
+
}
|
|
4390
|
+
finally {
|
|
4391
|
+
lines.end();
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
async dispatchRounds(tenantId, scopeId, invocationId, lines) {
|
|
3884
4395
|
for (let round = 0; round < 50; round++) {
|
|
3885
4396
|
let deliveredAny = false;
|
|
3886
4397
|
for (const mod of this.modules.values()) {
|
|
3887
4398
|
for (const consumer of mod.consumers) {
|
|
3888
4399
|
const rows = this.sql
|
|
3889
4400
|
.exec(`SELECT * FROM _substrat_outbox o
|
|
3890
|
-
WHERE o.type = ?
|
|
4401
|
+
WHERE o.type = ? AND ${emittedHere('o.')}
|
|
3891
4402
|
AND NOT EXISTS (
|
|
3892
4403
|
SELECT 1 FROM _substrat_deliveries d
|
|
3893
4404
|
WHERE d.event_id = o.id AND d.consumer_module = ?
|
|
@@ -3895,6 +4406,20 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3895
4406
|
ORDER BY o.id`, consumer.eventType, mod.id)
|
|
3896
4407
|
.toArray();
|
|
3897
4408
|
for (const row of rows) {
|
|
4409
|
+
// #1901: this delivery's line — the call's id when it runs in one, else its own.
|
|
4410
|
+
const unit = {
|
|
4411
|
+
kind: 'consumer',
|
|
4412
|
+
tenantId,
|
|
4413
|
+
scopeId,
|
|
4414
|
+
invocationId: asyncInvocationId(invocationId),
|
|
4415
|
+
operation: mod.id,
|
|
4416
|
+
eventType: consumer.eventType,
|
|
4417
|
+
eventId: row.id,
|
|
4418
|
+
// A module consumer is tried once: a throw dead-letters it (v0).
|
|
4419
|
+
attempt: 1,
|
|
4420
|
+
startedAt: Date.now(),
|
|
4421
|
+
versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
4422
|
+
};
|
|
3898
4423
|
let event;
|
|
3899
4424
|
try {
|
|
3900
4425
|
event = domainEventOf(row);
|
|
@@ -3908,14 +4433,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3908
4433
|
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
3909
4434
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3910
4435
|
VALUES (?, ?, ?, ?, ?)`, row.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4436
|
+
// Never reached the handler, so nothing threw in it: a warning, not an error.
|
|
4437
|
+
lines.write({ ...unit, outcome: 'dead-lettered' });
|
|
3911
4438
|
continue;
|
|
3912
4439
|
}
|
|
3913
4440
|
// #1237: anything this consumer emits was emitted BECAUSE of this event
|
|
3914
4441
|
// — the step a backwards walk used to stop dead at, since a consumer
|
|
3915
4442
|
// emit records no operation either.
|
|
3916
4443
|
this.causedBy = event.id;
|
|
4444
|
+
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4445
|
+
this.unitInvocationId = unit.invocationId;
|
|
3917
4446
|
try {
|
|
3918
|
-
await this.
|
|
4447
|
+
await this.revision.transaction(async () => {
|
|
3919
4448
|
const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, {
|
|
3920
4449
|
system: mod.id,
|
|
3921
4450
|
});
|
|
@@ -3925,6 +4454,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3925
4454
|
VALUES (?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), invocationId);
|
|
3926
4455
|
});
|
|
3927
4456
|
deliveredAny = true;
|
|
4457
|
+
lines.write({ ...unit, outcome: 'delivered' });
|
|
3928
4458
|
}
|
|
3929
4459
|
catch (err) {
|
|
3930
4460
|
// Dead-letter (v0): journal the failure so one poison event
|
|
@@ -3932,8 +4462,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3932
4462
|
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
3933
4463
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3934
4464
|
VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4465
|
+
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
3935
4466
|
}
|
|
3936
4467
|
finally {
|
|
4468
|
+
this.unitInvocationId = null;
|
|
3937
4469
|
// Cleared on BOTH paths. Left set, the id leaks onto every later emit
|
|
3938
4470
|
// this DO makes — an operation's own event stamped as caused by
|
|
3939
4471
|
// whatever was delivered last, which is worse than recording nothing
|
|
@@ -4101,7 +4633,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4101
4633
|
* owns that stack itself. It is exactly this asymmetry that makes `RunSub`
|
|
4102
4634
|
* closure-shaped rather than an enter/rollback/release triple.
|
|
4103
4635
|
*/
|
|
4104
|
-
const runSub = (_depth, fn) => this.
|
|
4636
|
+
const runSub = (_depth, fn) => this.revision.transaction(fn);
|
|
4105
4637
|
// Lifted so `grant` reuses the SAME check the operation itself passes —
|
|
4106
4638
|
// a delegation check that could differ from the operation's would be a
|
|
4107
4639
|
// second opinion about what the caller holds.
|
|
@@ -4198,7 +4730,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4198
4730
|
tenantId,
|
|
4199
4731
|
scopeId,
|
|
4200
4732
|
operation: operation ?? null,
|
|
4201
|
-
invocationId: () => this.invocationId,
|
|
4733
|
+
invocationId: () => this.invocationId ?? this.unitInvocationId,
|
|
4202
4734
|
principalKind: systemActor ? 'system' : subject.kind,
|
|
4203
4735
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
4204
4736
|
// log's text is not JSON.
|
|
@@ -4279,7 +4811,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4279
4811
|
});
|
|
4280
4812
|
const rows = sql.exec(q.sql, ...q.params).toArray();
|
|
4281
4813
|
const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
|
|
4282
|
-
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn);
|
|
4814
|
+
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
|
|
4283
4815
|
const page = { entries: rows, nextCursor };
|
|
4284
4816
|
if (!params.total)
|
|
4285
4817
|
return page;
|
|
@@ -4512,7 +5044,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4512
5044
|
// The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
|
|
4513
5045
|
// serializes but does not roll back, and a role insert that throws part-way must not leave
|
|
4514
5046
|
// the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
|
|
4515
|
-
return this.queue.enqueue(() => this.
|
|
5047
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => {
|
|
4516
5048
|
// #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
|
|
4517
5049
|
// a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
|
|
4518
5050
|
// moves, so a misdirected projection can never re-point a scope or leave its roles behind.
|