@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/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 new Error(`unknown operation: ${operation}`);
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 new Error(entitlementDenial(operation, requiredEntitlement, all.map((r) => ({
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
- await this.runGuards(operation, ctx, input);
812
- result = await handler(ctx, input);
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
- await this.dispatch(tenantId, scopeId);
832
- return { result, platformRequests: signals.platformRequests };
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