@substrat-run/adapter-cloudflare 0.87.0 → 0.89.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/host.d.ts +10 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +69 -3
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +246 -12
- package/dist/scope-do.js.map +1 -1
- package/package.json +4 -4
package/dist/scope-do.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, } from '@substrat-run/contracts';
|
|
3
|
-
import { ulid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, PermissionDenied, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, } from '@substrat-run/kernel';
|
|
2
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, } from '@substrat-run/contracts';
|
|
3
|
+
import { ulid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, PermissionDenied, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
|
|
4
4
|
import { OperationQueue } from './serialization.js';
|
|
5
5
|
import { doScopedSql } from './sql.js';
|
|
6
6
|
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
@@ -211,6 +211,12 @@ const KERNEL_DDL = `
|
|
|
211
211
|
);
|
|
212
212
|
CREATE INDEX IF NOT EXISTS _substrat_attachments_entity
|
|
213
213
|
ON _substrat_attachments (entity_type, entity_id);
|
|
214
|
+
-- #901: an entity's version is the ULID of the last event about it, so
|
|
215
|
+
-- MAX(id) per (entity_type, entity_id) is the read. The id column sits last so
|
|
216
|
+
-- SQLite walks to the end of the matched range instead of aggregating over it.
|
|
217
|
+
${OUTBOX_ENTITY_INDEX}
|
|
218
|
+
-- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
|
|
219
|
+
${IDEMPOTENCY_DDL}
|
|
214
220
|
`;
|
|
215
221
|
/**
|
|
216
222
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -241,6 +247,27 @@ function toRpcError(err) {
|
|
|
241
247
|
}
|
|
242
248
|
/** The check subject for an attachment gate (#476): the connection when the connector door
|
|
243
249
|
* set one, else the principal — mirrors the invoke path's subject selection. */
|
|
250
|
+
/**
|
|
251
|
+
* The entity a guarded operation's precondition is about (#129).
|
|
252
|
+
*
|
|
253
|
+
* `idFrom` is compile-checked to name an input field and the host has already
|
|
254
|
+
* parsed that input, so the field exists with its declared type. What remains
|
|
255
|
+
* possible is a field the schema lets the caller OMIT — and a precondition with no
|
|
256
|
+
* row to read is not a weaker check but an absent one, indistinguishable from one
|
|
257
|
+
* that passed. Refused rather than skipped.
|
|
258
|
+
*
|
|
259
|
+
* A module-level function rather than a method, so the pure adapter and this one
|
|
260
|
+
* are demonstrably answering with the same rule.
|
|
261
|
+
*/
|
|
262
|
+
function concurrencyRefOf(operation, guarded, parsed) {
|
|
263
|
+
const id = parsed?.[guarded.idFrom];
|
|
264
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
265
|
+
throw new Error(`${operation} declares concurrency over '${guarded.entity}' keyed by ` +
|
|
266
|
+
`'${guarded.idFrom}', but the parsed input carries no such id — there is no ` +
|
|
267
|
+
'row whose version could be compared');
|
|
268
|
+
}
|
|
269
|
+
return { entityType: guarded.entity, entityId: id };
|
|
270
|
+
}
|
|
244
271
|
function attachSubject(principal, connectionId) {
|
|
245
272
|
return connectionId ? { kind: 'connection', id: connectionId } : { kind: 'principal', id: principal };
|
|
246
273
|
}
|
|
@@ -372,6 +399,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
372
399
|
sql;
|
|
373
400
|
queue = new OperationQueue();
|
|
374
401
|
operations = new Map();
|
|
402
|
+
/**
|
|
403
|
+
* #893: name → the declared input schema, parsed before guards and handler.
|
|
404
|
+
* Port of the pure adapter's map; `defineOperation` bindings with no
|
|
405
|
+
* declaration behind them stay unparsed, as they stay ungated.
|
|
406
|
+
*/
|
|
407
|
+
operationInput = new Map();
|
|
408
|
+
/** #129: name → the entity whose version an `If-Match` is compared against. */
|
|
409
|
+
operationConcurrency = new Map();
|
|
410
|
+
/** #116: the operations that declared `idempotency: false` — refusals, not participants. */
|
|
411
|
+
operationIdempotencyOptOut = new Set();
|
|
375
412
|
modules = new Map();
|
|
376
413
|
guards = new Map();
|
|
377
414
|
predicates = new Map();
|
|
@@ -509,8 +546,45 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
509
546
|
this.withdrawn.set(name, manifest.id);
|
|
510
547
|
this.operations.delete(name);
|
|
511
548
|
}
|
|
549
|
+
// #893: a schema declared for an operation this module does not bind
|
|
550
|
+
// enforces nothing while reading as coverage — refused, as in the pure adapter.
|
|
551
|
+
const declaredInputs = registration.operationInputs ?? {};
|
|
552
|
+
const ownOps = new Set(Object.keys(registration.operations ?? {}));
|
|
553
|
+
const unbound = Object.keys(declaredInputs).filter((name) => !ownOps.has(name));
|
|
554
|
+
if (unbound.length > 0) {
|
|
555
|
+
throw new Error(`${manifest.id} declares operationInputs for unbound operation(s): ` +
|
|
556
|
+
`${unbound.sort().join(', ')} — a schema on nothing reads as a parse that is not there`);
|
|
557
|
+
}
|
|
558
|
+
// Same rule for a declared precondition, and it matters more: a
|
|
559
|
+
// `concurrency` on an unbound name is a guarantee nothing enforces.
|
|
560
|
+
const declaredConcurrency = registration.operationConcurrency ?? {};
|
|
561
|
+
const unguarded = Object.keys(declaredConcurrency).filter((name) => !ownOps.has(name));
|
|
562
|
+
if (unguarded.length > 0) {
|
|
563
|
+
throw new Error(`${manifest.id} declares operationConcurrency for unbound operation(s): ` +
|
|
564
|
+
`${unguarded.sort().join(', ')} — a precondition on nothing reads as a guard that is not there`);
|
|
565
|
+
}
|
|
566
|
+
// #116: same rule again for an opt-out — one on an unbound name reads as a
|
|
567
|
+
// deliberate exclusion of an operation that is not there.
|
|
568
|
+
const declaredOptOuts = registration.operationIdempotencyOptOuts ?? [];
|
|
569
|
+
const unboundOptOuts = declaredOptOuts.filter((name) => !ownOps.has(name));
|
|
570
|
+
if (unboundOptOuts.length > 0) {
|
|
571
|
+
throw new Error(`${manifest.id} declares idempotency: false for unbound operation(s): ` +
|
|
572
|
+
`${[...unboundOptOuts].sort().join(', ')} — an opt-out on nothing reads as an ` +
|
|
573
|
+
'exclusion someone decided, of an operation that does not exist');
|
|
574
|
+
}
|
|
512
575
|
for (const [name, handler] of Object.entries(registration.operations ?? {})) {
|
|
513
576
|
this.defineOperation(name, handler);
|
|
577
|
+
// The schema follows the HANDLER, not the name: a withdrawn operation
|
|
578
|
+
// never binds, and has nothing to parse for.
|
|
579
|
+
const schema = declaredInputs[name];
|
|
580
|
+
if (schema && this.operations.has(name))
|
|
581
|
+
this.operationInput.set(name, schema);
|
|
582
|
+
const guarded = declaredConcurrency[name];
|
|
583
|
+
if (guarded && this.operations.has(name))
|
|
584
|
+
this.operationConcurrency.set(name, guarded);
|
|
585
|
+
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
586
|
+
this.operationIdempotencyOptOut.add(name);
|
|
587
|
+
}
|
|
514
588
|
}
|
|
515
589
|
}
|
|
516
590
|
defineOperation(name, handler) {
|
|
@@ -745,19 +819,29 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
745
819
|
* an error either, because without this flag the DO throws. No flag day, and no
|
|
746
820
|
* window where a failure reads as a success.
|
|
747
821
|
*/
|
|
748
|
-
failureEnvelope
|
|
822
|
+
failureEnvelope,
|
|
823
|
+
/**
|
|
824
|
+
* #129: request preconditions, evaluated inside the operation's transaction.
|
|
825
|
+
*
|
|
826
|
+
* Unlike every other argument added to this RPC, silently ignoring this one
|
|
827
|
+
* would fail OPEN — the write commits with nothing compared. The reply
|
|
828
|
+
* therefore carries `concurrency.ifMatchChecked`, and a coordinator that
|
|
829
|
+
* sent an `If-Match` and does not see it refuses the success. An old DO
|
|
830
|
+
* cannot set the acknowledgement, which is exactly how the skew is caught.
|
|
831
|
+
*/
|
|
832
|
+
invokeOptions) {
|
|
749
833
|
if (!failureEnvelope) {
|
|
750
834
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
751
835
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
752
836
|
try {
|
|
753
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId);
|
|
837
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions);
|
|
754
838
|
}
|
|
755
839
|
catch (err) {
|
|
756
840
|
throw toRpcError(err);
|
|
757
841
|
}
|
|
758
842
|
}
|
|
759
843
|
try {
|
|
760
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId);
|
|
844
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions);
|
|
761
845
|
}
|
|
762
846
|
catch (err) {
|
|
763
847
|
// The ONE place the error keeps its structure: flattened here, rebuilt by the
|
|
@@ -767,11 +851,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
767
851
|
}
|
|
768
852
|
}
|
|
769
853
|
/** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
|
|
770
|
-
async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId) {
|
|
854
|
+
async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions) {
|
|
771
855
|
await this.ensureMigrations();
|
|
772
856
|
const handler = this.operations.get(operation);
|
|
857
|
+
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
858
|
+
// to reach a 404, because the platform's own refusals were as untyped as anything
|
|
859
|
+
// else. Naming the code once here is what lets those patterns go.
|
|
773
860
|
if (!handler)
|
|
774
|
-
throw
|
|
861
|
+
throw substratError('not_found', `unknown operation: ${operation}`);
|
|
775
862
|
// #304 entitlement gate, scope-local path: fail closed on the projected view exactly as
|
|
776
863
|
// the coordinator fails closed against the CP. Only active once entitlements have been
|
|
777
864
|
// projected (the `entitlements_enforced` marker) — before that the scope trusts upstream,
|
|
@@ -789,14 +876,55 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
789
876
|
.exec(`SELECT entitlement_key, expires_at FROM _substrat_entitlements
|
|
790
877
|
WHERE tenant_id = ? ORDER BY entitlement_key`, tenantId)
|
|
791
878
|
.toArray();
|
|
792
|
-
throw
|
|
879
|
+
throw substratError('not_found', entitlementDenial(operation, requiredEntitlement, all.map((r) => ({
|
|
793
880
|
key: r.entitlement_key,
|
|
794
881
|
expired: r.expires_at !== null && r.expires_at <= now,
|
|
795
882
|
}))));
|
|
796
883
|
}
|
|
797
884
|
}
|
|
885
|
+
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
886
|
+
// declaration. Outside the queue and outside the transaction: a malformed
|
|
887
|
+
// call takes no turn and opens nothing. Guards read the parsed input too,
|
|
888
|
+
// so a K-17 pre-condition sees what the handler will.
|
|
889
|
+
const declaredInput = this.operationInput.get(operation);
|
|
890
|
+
const parsed = declaredInput ? declaredInput.parse(input) : input;
|
|
891
|
+
// #129. Refused rather than ignored, for the reason the coordinator refuses an
|
|
892
|
+
// unacknowledged one: a caller sending `If-Match` believes its write is
|
|
893
|
+
// conditional, and a 200 that compared nothing leaves that belief in place.
|
|
894
|
+
const guarded = this.operationConcurrency.get(operation);
|
|
895
|
+
if (invokeOptions?.ifMatch !== undefined && !guarded) {
|
|
896
|
+
throw new Error(`${operation} was called with If-Match but declares no \`concurrency\` — ` +
|
|
897
|
+
'nothing would have been compared. Declare it, or drop the header');
|
|
898
|
+
}
|
|
899
|
+
const guardedRef = guarded ? concurrencyRefOf(operation, guarded, parsed) : undefined;
|
|
900
|
+
// #116. Refused rather than ignored, for the reason two paragraphs up: an
|
|
901
|
+
// operation that opted out never records its response, so a caller who sent
|
|
902
|
+
// a key and got a 200 believes a retry is safe when it would execute again.
|
|
903
|
+
const idempotencyKey = invokeOptions?.idempotencyKey;
|
|
904
|
+
if (idempotencyKey !== undefined) {
|
|
905
|
+
if (this.operationIdempotencyOptOut.has(operation)) {
|
|
906
|
+
throw new Error(idempotencyOptedOutMessage(operation));
|
|
907
|
+
}
|
|
908
|
+
assertIdempotencyKey(idempotencyKey);
|
|
909
|
+
}
|
|
910
|
+
// The subject a key is scoped to — the same three-way read `recordDenial`
|
|
911
|
+
// makes below, hoisted because both need it. A key belongs to whoever sent
|
|
912
|
+
// it: two principals choosing `1` must not reach each other's response.
|
|
913
|
+
const idempotencySubjectRef = systemModuleId
|
|
914
|
+
? { kind: 'system', id: systemModuleId }
|
|
915
|
+
: connectionId
|
|
916
|
+
? { kind: 'connection', id: connectionId }
|
|
917
|
+
: { kind: 'principal', id: principal };
|
|
918
|
+
// Fingerprinted from the PARSED input (defaults applied), before the queue:
|
|
919
|
+
// a pure hash of what the caller sent has no business inside a transaction.
|
|
920
|
+
const fingerprint = idempotencyKey === undefined ? undefined : await requestFingerprint(operation, parsed);
|
|
798
921
|
return this.queue.enqueue(async () => {
|
|
799
922
|
let result;
|
|
923
|
+
let committedVersion = null;
|
|
924
|
+
// #116: set when this invocation was answered from a recording rather
|
|
925
|
+
// than run. Read after the transaction, where it decides both the
|
|
926
|
+
// envelope's acknowledgement and whether there is anything to dispatch.
|
|
927
|
+
let replayed = false;
|
|
800
928
|
// #458: how many platform intents THIS invoke enqueued. Counted inside the
|
|
801
929
|
// transaction, reported only after commit — a rolled-back intent is no signal.
|
|
802
930
|
// The envelope return (below) is the DO↔coordinator wire for it; both sides
|
|
@@ -808,8 +936,51 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
808
936
|
try {
|
|
809
937
|
await this.ctx.storage.transaction(async () => {
|
|
810
938
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals);
|
|
811
|
-
|
|
812
|
-
|
|
939
|
+
// #116: a retry is answered from the recording, and nothing else runs
|
|
940
|
+
// — not the guards, not the handler, not the permission check inside
|
|
941
|
+
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
942
|
+
// responses; `idempotency.ts` states what that does not promise.
|
|
943
|
+
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
944
|
+
const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
|
|
945
|
+
const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
|
|
946
|
+
if (prior) {
|
|
947
|
+
const replay = replayFor(idempotencyKey, fingerprint, prior);
|
|
948
|
+
result = replay.result;
|
|
949
|
+
// Only a guarded operation may report a tag (#129).
|
|
950
|
+
if (guardedRef)
|
|
951
|
+
committedVersion = replay.entityVersion;
|
|
952
|
+
replayed = true;
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// #129: snapshot the version BEFORE the handler, compare AFTER it.
|
|
957
|
+
// Before, because the handler's own `emit` moves it; after, because the
|
|
958
|
+
// permission check lives inside the handler and must answer first — a
|
|
959
|
+
// precondition evaluated ahead of it turns the operation into a version
|
|
960
|
+
// oracle for a principal who may not read the entity at all. The full
|
|
961
|
+
// reasoning is on the pure adapter, which does the identical thing.
|
|
962
|
+
const seen = guardedRef && invokeOptions?.ifMatch !== undefined
|
|
963
|
+
? this.versionAt(guardedRef)
|
|
964
|
+
: undefined;
|
|
965
|
+
await this.runGuards(operation, ctx, parsed);
|
|
966
|
+
result = await handler(ctx, parsed);
|
|
967
|
+
if (guardedRef && invokeOptions?.ifMatch !== undefined) {
|
|
968
|
+
assertIfMatch(guardedRef, invokeOptions.ifMatch, seen ?? null);
|
|
969
|
+
}
|
|
970
|
+
// The tag describes the row as THIS write left it, so it is read after
|
|
971
|
+
// the handler and still inside the transaction.
|
|
972
|
+
if (guardedRef)
|
|
973
|
+
committedVersion = this.versionAt(guardedRef);
|
|
974
|
+
// #116: recorded INSIDE the transaction, which is what makes a failed
|
|
975
|
+
// request retried rather than replayed — it rolls back with the writes
|
|
976
|
+
// it describes. The prune rides along, on the only path that adds a row.
|
|
977
|
+
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
978
|
+
const at = new Date().toISOString();
|
|
979
|
+
const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
|
|
980
|
+
this.sql.exec(record.sql, ...record.params);
|
|
981
|
+
const prune = idempotencyPruneStatement(at);
|
|
982
|
+
this.sql.exec(prune.sql, ...prune.params);
|
|
983
|
+
}
|
|
813
984
|
});
|
|
814
985
|
}
|
|
815
986
|
catch (err) {
|
|
@@ -828,8 +999,32 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
828
999
|
throw err;
|
|
829
1000
|
}
|
|
830
1001
|
// Post-commit: drain the outbox to consumers, each delivery its own txn.
|
|
831
|
-
|
|
832
|
-
|
|
1002
|
+
// Skipped on a replay: nothing was written, so there is nothing this
|
|
1003
|
+
// invocation added to drain. Anything the ORIGINAL left undrained is the
|
|
1004
|
+
// outbox's own retry backstop, which is what that backstop is for.
|
|
1005
|
+
if (!replayed)
|
|
1006
|
+
await this.dispatch(tenantId, scopeId);
|
|
1007
|
+
return {
|
|
1008
|
+
result,
|
|
1009
|
+
platformRequests: signals.platformRequests,
|
|
1010
|
+
// The acknowledgement the coordinator's skew check reads (#116), on the
|
|
1011
|
+
// same reasoning as `ifMatchChecked` below and with a sharper failure: a
|
|
1012
|
+
// DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
|
|
1013
|
+
// return 200, which is the duplicate the header was sent to prevent.
|
|
1014
|
+
...(idempotencyKey !== undefined
|
|
1015
|
+
? { idempotency: { keyHonoured: true, replayed } }
|
|
1016
|
+
: {}),
|
|
1017
|
+
// The acknowledgement the coordinator's skew check reads. Present only
|
|
1018
|
+
// for a guarded operation, so an unguarded one costs nothing.
|
|
1019
|
+
...(guardedRef
|
|
1020
|
+
? {
|
|
1021
|
+
concurrency: {
|
|
1022
|
+
version: committedVersion,
|
|
1023
|
+
ifMatchChecked: invokeOptions?.ifMatch !== undefined,
|
|
1024
|
+
},
|
|
1025
|
+
}
|
|
1026
|
+
: {}),
|
|
1027
|
+
};
|
|
833
1028
|
});
|
|
834
1029
|
}
|
|
835
1030
|
// -- attachments (#473): the metadata half of the attachment surface --------
|
|
@@ -1020,6 +1215,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1020
1215
|
last_status = excluded.last_status`, operation, at, status);
|
|
1021
1216
|
}
|
|
1022
1217
|
// -- guards (K-17) --------------------------------------------------------
|
|
1218
|
+
/** This scope's spine, read under whatever transaction the caller already opened. */
|
|
1219
|
+
versionAt(ref) {
|
|
1220
|
+
const q = entityVersionQuery(ref);
|
|
1221
|
+
return entityVersionOf(this.sql.exec(q.sql, ...q.params).toArray());
|
|
1222
|
+
}
|
|
1023
1223
|
async runGuards(operation, ctx, input) {
|
|
1024
1224
|
const declared = this.guards.get(operation);
|
|
1025
1225
|
if (!declared)
|
|
@@ -1310,6 +1510,32 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1310
1510
|
}
|
|
1311
1511
|
return result;
|
|
1312
1512
|
}
|
|
1513
|
+
// -- the denial log (K-35, #867) ------------------------------------------
|
|
1514
|
+
// The refusals recorded in THIS scope's own database, read back. Authorization and
|
|
1515
|
+
// the (tenantId, scopeId) K-3 cross-check happen on the coordinator before these
|
|
1516
|
+
// RPCs are reached, exactly as for the introspection reads above.
|
|
1517
|
+
/** A bounded page of raw denial rows, newest first. */
|
|
1518
|
+
listDenials(filter) {
|
|
1519
|
+
const q = denialListQuery(filter);
|
|
1520
|
+
return this.sql.exec(q.sql, ...q.params).toArray().map(mapDenialRow);
|
|
1521
|
+
}
|
|
1522
|
+
/** The same log bucketed per (actor, permission), with the window's own facts. */
|
|
1523
|
+
summarizeDenials(filter) {
|
|
1524
|
+
const b = denialSummaryQuery(filter);
|
|
1525
|
+
const buckets = this.sql.exec(b.sql, ...b.params).toArray().map(mapDenialBucketRow);
|
|
1526
|
+
const t = denialTotalsQuery(filter);
|
|
1527
|
+
const totals = this.sql.exec(t.sql, ...t.params).toArray()[0];
|
|
1528
|
+
// Unfiltered on purpose — these describe the log, not the query (denial-query.ts).
|
|
1529
|
+
const w = this.sql.exec(DENIAL_WINDOW_QUERY).toArray()[0];
|
|
1530
|
+
return {
|
|
1531
|
+
buckets,
|
|
1532
|
+
total: Number(totals.total),
|
|
1533
|
+
actors: Number(totals.actors),
|
|
1534
|
+
windowOldestAt: w.oldest_at ?? null,
|
|
1535
|
+
windowNewestAt: w.newest_at ?? null,
|
|
1536
|
+
drained: Number(w.drained ?? 0),
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1313
1539
|
/**
|
|
1314
1540
|
* A COMPLETE dump of this scope's DB (preview-and-snapshots.md §3) — every table
|
|
1315
1541
|
* (incl. the `_substrat_*` spine), its DDL, and every row. No `.backup()` on DO
|
|
@@ -1693,6 +1919,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1693
1919
|
const q = platformRequestHistoryQuery(filter);
|
|
1694
1920
|
return sql.exec(q.sql, ...q.params).toArray().map(rowToPlatformRequest);
|
|
1695
1921
|
},
|
|
1922
|
+
// #901. Mirror of the pure adapter, and the reason the contract suite
|
|
1923
|
+
// runs on both: the query is ordinary SQL, but it is only a seek rather
|
|
1924
|
+
// than a scan because of an index in spine DDL that workerd's regulator
|
|
1925
|
+
// has to permit — which no amount of local green would surface.
|
|
1926
|
+
versionOf: (entity) => {
|
|
1927
|
+
const q = entityVersionQuery(entity);
|
|
1928
|
+
return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
|
|
1929
|
+
},
|
|
1696
1930
|
check: runCheck,
|
|
1697
1931
|
// #827. Mirror of the pure adapter: the plan comes from registration, the
|
|
1698
1932
|
// rows from this DO's own SQLite, and the index is maintained by triggers
|