@yielded/auth-persistence-drizzle 0.1.0-beta.21 → 0.1.0-beta.23

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.
Files changed (66) hide show
  1. package/dist/Libsql.d.mts +19 -19
  2. package/dist/Mysql2.d.mts +14 -14
  3. package/dist/Pglite.d.mts +14 -14
  4. package/dist/Postgres.d.mts +6 -6
  5. package/dist/SqliteBun.d.mts +27 -27
  6. package/dist/SqliteDo.d.mts +13 -13
  7. package/dist/SqliteNode.d.mts +23 -23
  8. package/dist/SqliteWasm.d.mts +13 -13
  9. package/dist/drizzle/d1-password-prepared.d.mts +3 -3
  10. package/dist/drizzle/d1-password-prepared.mjs.map +1 -1
  11. package/dist/drizzle/d1-passwords.d.mts +8 -9
  12. package/dist/drizzle/d1-passwords.mjs +103 -173
  13. package/dist/drizzle/d1-passwords.mjs.map +1 -1
  14. package/dist/drizzle/d1-proofs.d.mts +1 -1
  15. package/dist/drizzle/d1-sessions.d.mts +1 -1
  16. package/dist/drizzle/mysql-password-prepared.d.mts +5 -5
  17. package/dist/drizzle/mysql-password-prepared.mjs.map +1 -1
  18. package/dist/drizzle/mysql-passwords.d.mts +9 -9
  19. package/dist/drizzle/mysql-passwords.mjs.map +1 -1
  20. package/dist/drizzle/native-sql-table.mjs +110 -0
  21. package/dist/drizzle/native-sql-table.mjs.map +1 -0
  22. package/dist/drizzle/oauth-connected-target.d.mts +5 -5
  23. package/dist/drizzle/oauth-target.d.mts +1 -1
  24. package/dist/drizzle/password-model.d.mts +5 -7
  25. package/dist/drizzle/password-prepared-model.d.mts +5 -5
  26. package/dist/drizzle/password-prepared-model.mjs.map +1 -1
  27. package/dist/drizzle/password-sql.mjs +2 -1
  28. package/dist/drizzle/password-sql.mjs.map +1 -1
  29. package/dist/drizzle/password-target.mjs +13 -2
  30. package/dist/drizzle/password-target.mjs.map +1 -1
  31. package/dist/drizzle/pg-password-prepared.d.mts +5 -5
  32. package/dist/drizzle/pg-password-prepared.mjs.map +1 -1
  33. package/dist/drizzle/pg-passwords.d.mts +9 -9
  34. package/dist/drizzle/pg-passwords.mjs.map +1 -1
  35. package/dist/drizzle/phone-target.d.mts +1 -1
  36. package/dist/drizzle/session-sql.mjs +2 -1
  37. package/dist/drizzle/session-sql.mjs.map +1 -1
  38. package/dist/drizzle/sqlite-password-prepared.mjs.map +1 -1
  39. package/dist/drizzle/sqlite-passwords.mjs.map +1 -1
  40. package/dist/drizzle/storage-validation.mjs +0 -13
  41. package/dist/drizzle/storage-validation.mjs.map +1 -1
  42. package/dist/drizzle/totp-target.d.mts +1 -1
  43. package/dist/index.d.mts +2 -2
  44. package/dist/internal/drizzle-postgres.mjs +2 -0
  45. package/dist/internal/drizzle-postgres.mjs.map +1 -1
  46. package/dist/internal/drizzle-sqlite.mjs +2 -0
  47. package/dist/internal/drizzle-sqlite.mjs.map +1 -1
  48. package/package.json +1 -1
  49. package/src/drizzle/d1-password-prepared.ts +2 -20
  50. package/src/drizzle/d1-passwords.ts +232 -519
  51. package/src/drizzle/mysql-password-prepared.ts +4 -12
  52. package/src/drizzle/mysql-passwords.ts +5 -15
  53. package/src/drizzle/native-sql-table.ts +229 -0
  54. package/src/drizzle/password-model.ts +0 -20
  55. package/src/drizzle/password-prepared-model.ts +0 -12
  56. package/src/drizzle/password-sql.ts +3 -0
  57. package/src/drizzle/password-target.ts +41 -4
  58. package/src/drizzle/pg-password-prepared.ts +4 -12
  59. package/src/drizzle/pg-passwords.ts +5 -15
  60. package/src/drizzle/session-sql.ts +2 -1
  61. package/src/drizzle/sqlite-password-prepared.ts +4 -48
  62. package/src/drizzle/sqlite-passwords.ts +7 -21
  63. package/src/drizzle/storage-validation.ts +0 -8
  64. package/src/index.ts +0 -4
  65. package/src/internal/drizzle-postgres.ts +2 -0
  66. package/src/internal/drizzle-sqlite.ts +2 -0
@@ -1,6 +1,10 @@
1
1
  /* oxlint-disable no-explicit-any -- D1 plans bridge consumer-owned Drizzle rows and SQL statements. */
2
2
  import type { D1Client } from "@effect/sql-d1/D1Client";
3
- import { NativeDatabase } from "@yielded/auth-persistence/Adapter";
3
+ import {
4
+ NativeDatabase,
5
+ requireStandalone,
6
+ samePasswordCredentialSnapshot as sameSnapshot,
7
+ } from "@yielded/auth-persistence/Adapter";
4
8
  import {
5
9
  coordinateCommit,
6
10
  CurrentCommitJournal,
@@ -23,7 +27,8 @@ import {
23
27
  type PasswordAttemptDecision,
24
28
  type PasswordCredentialSnapshot,
25
29
  type PasswordMutationDecision,
26
- type PasswordAttemptPolicy,
30
+ type PasswordAttemptPreparation,
31
+ type PasswordAttemptAdmission,
27
32
  } from "@yielded/auth/Password";
28
33
  import { reportPersistenceFailure } from "@yielded/auth/Persistence";
29
34
  import {
@@ -38,20 +43,7 @@ import {
38
43
  AuthenticationRequirement,
39
44
  type SecurityRevision,
40
45
  } from "@yielded/auth/Sessions";
41
- import {
42
- and,
43
- count,
44
- eq,
45
- gte,
46
- inArray,
47
- lte,
48
- notExists,
49
- sql,
50
- type AnyRelations,
51
- type SQL,
52
- type Table,
53
- } from "drizzle-orm";
54
- import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core";
46
+ import { and, eq, inArray, lte, sql, type AnyRelations, type SQL, type Table } from "drizzle-orm";
55
47
  import type { EffectSQLiteD1Database } from "drizzle-orm/effect-d1";
56
48
  import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
57
49
  import { Cause, DateTime, Effect, Option, Predicate, Redacted, Schema, Context } from "effect";
@@ -68,9 +60,7 @@ import {
68
60
  type AnyPasswordRegistrationMapping,
69
61
  type D1PasswordPersistenceMapping,
70
62
  type PasswordAttemptRecord,
71
- type PasswordRateScopeKind,
72
63
  type PasswordRegistrationMapping,
73
- type PasswordScopeKeys,
74
64
  requiredPasswordConstraints,
75
65
  requiredPasswordRegistrationConstraints,
76
66
  } from "./password-model";
@@ -85,24 +75,14 @@ type PlanPrepare<Method extends (...args: any[]) => any, A> = (
85
75
  ) => PreparedCommit<A>;
86
76
 
87
77
  type Database = EffectSQLiteD1Database<AnyRelations> & { readonly $client: D1Client };
88
- type Mapping = D1PasswordPersistenceMapping<
89
- Table,
90
- Table,
91
- Table,
92
- Table,
93
- Table,
94
- Table,
95
- Table,
96
- Table,
97
- unknown
98
- >;
78
+ type Mapping = D1PasswordPersistenceMapping<Table, Table, Table, Table, Table, Table, unknown>;
99
79
  type RegistrationMapping<Registration> = AnyPasswordRegistrationMapping<Registration>;
100
- type AdapterFailure = PersistenceMappingError | EffectDrizzleQueryError;
101
- const mapAdapter = <A>(effect: Effect.Effect<A, AdapterFailure>) => effect;
102
80
 
103
81
  interface Planned<A> {
104
82
  readonly receipt: A;
105
83
  readonly statements: ReadonlyArray<Statement<any>>;
84
+ /** Final-state guards without `changes()`, repeated after coordinated statements. */
85
+ readonly postconditions?: ReadonlyArray<Statement<any>>;
106
86
  readonly retryable?: (cause: unknown) => boolean;
107
87
  readonly journalGuard?: PreparedCommit<void>;
108
88
  }
@@ -290,23 +270,6 @@ const attemptColumns = (mapping: Mapping) => ({
290
270
  state: column(mapping.attempt.table, mapping.attempt.state),
291
271
  });
292
272
 
293
- const scopeColumns = (mapping: Mapping) => ({
294
- moduleId: column(mapping.rateScope.table, mapping.rateScope.moduleId),
295
- action: column(mapping.rateScope.table, mapping.rateScope.action),
296
- scopeKind: column(mapping.rateScope.table, mapping.rateScope.scopeKind),
297
- scopeKey: column(mapping.rateScope.table, mapping.rateScope.scopeKey),
298
- });
299
-
300
- const chargeColumns = (mapping: Mapping) => ({
301
- moduleId: column(mapping.charge.table, mapping.charge.moduleId),
302
- action: column(mapping.charge.table, mapping.charge.action),
303
- scopeKind: column(mapping.charge.table, mapping.charge.scopeKind),
304
- scopeKey: column(mapping.charge.table, mapping.charge.scopeKey),
305
- attemptId: column(mapping.charge.table, mapping.charge.attemptId),
306
- occurredAt: column(mapping.charge.table, mapping.charge.occurredAt),
307
- retentionUntil: column(mapping.charge.table, mapping.charge.retentionUntil),
308
- });
309
-
310
273
  const commandColumns = (mapping: Mapping) => ({
311
274
  moduleId: column(mapping.command.table, mapping.command.moduleId),
312
275
  commandId: column(mapping.command.table, mapping.command.commandId),
@@ -396,137 +359,6 @@ const resolveCredential = Effect.fn("DrizzleD1Password.resolveCredential")(funct
396
359
  return { nativeSubjectId, subject, identifier: identifierRow, credential, snapshot };
397
360
  });
398
361
 
399
- interface ScopeEntry {
400
- readonly kind: PasswordRateScopeKind;
401
- readonly key: string;
402
- readonly limit: number;
403
- readonly windowMillis: number;
404
- }
405
-
406
- const scopeEntries = (keys: PasswordScopeKeys, policy: PasswordAttemptPolicy) => {
407
- const entries: ScopeEntry[] = [
408
- { kind: "action", key: keys.action, ...policy.action },
409
- { kind: "identifier", key: keys.identifier, ...policy.identifier },
410
- ];
411
-
412
- if (keys.subject !== undefined)
413
- entries.push({ kind: "subject", key: keys.subject, ...policy.subject });
414
-
415
- return entries;
416
- };
417
-
418
- const livePendingAttempts = (
419
- mapping: Mapping,
420
- moduleId: string,
421
- action: "sign-in" | "change",
422
- entry: ScopeEntry,
423
- identifier: LoginIdentifier,
424
- nativeSubjectId: unknown | undefined,
425
- activeAt: unknown,
426
- ) => {
427
- const a = attemptColumns(mapping);
428
-
429
- return and(
430
- entry.kind === "action"
431
- ? and(eq(a.moduleId, moduleId), eq(a.action, action))
432
- : entry.kind === "identifier"
433
- ? and(
434
- eq(a.moduleId, moduleId),
435
- eq(a.action, action),
436
- eq(a.identifierNamespace, identifier.namespace),
437
- eq(a.identifierValue, identifier.value),
438
- )
439
- : and(eq(a.moduleId, moduleId), eq(a.action, action), eq(a.subjectId, nativeSubjectId)),
440
- eq(a.state, "pending"),
441
- sql`${a.retentionUntil} > ${activeAt}`,
442
- );
443
- };
444
-
445
- const scopeAdmissionCondition = (
446
- mapping: Mapping,
447
- moduleId: string,
448
- action: "sign-in" | "change",
449
- entry: ScopeEntry,
450
- policy: PasswordAttemptPolicy,
451
- identifier: LoginIdentifier,
452
- nativeSubjectId: unknown | undefined,
453
- ) => {
454
- const c = chargeColumns(mapping);
455
-
456
- const pending = livePendingAttempts(
457
- mapping,
458
- moduleId,
459
- action,
460
- entry,
461
- identifier,
462
- nativeSubjectId,
463
- mapping.d1.engineNow,
464
- );
465
-
466
- return sql`(select count(*) from ${mapping.charge.table} where ${c.moduleId} = ${sql.param(moduleId, c.moduleId)} and ${c.action} = ${sql.param(action, c.action)} and ${c.scopeKind} = ${sql.param(entry.kind, c.scopeKind)} and ${c.scopeKey} = ${sql.param(entry.key, c.scopeKey)} and ${c.occurredAt} >= ${mapping.d1.engineInstantMinus(entry.windowMillis)}) < ${entry.limit} and (select count(*) from ${mapping.attempt.table} where ${pending}) < ${policy.maximumPending}`;
467
- };
468
-
469
- const readScopeAdmission = Effect.fn("DrizzleD1Password.readScopeAdmission")(function* (
470
- mapping: Mapping,
471
- moduleId: string,
472
- action: "sign-in" | "change",
473
- entries: ReadonlyArray<ScopeEntry>,
474
- policy: PasswordAttemptPolicy,
475
- identifier: LoginIdentifier,
476
- nativeSubjectId: unknown | undefined,
477
- now: number,
478
- ) {
479
- const database = yield* CurrentD1PlanningDatabase;
480
-
481
- const c = chargeColumns(mapping);
482
- const admitted: ScopeEntry[] = [];
483
-
484
- for (let index = 0; index < entries.length; index++) {
485
- const entry = entries[index]!;
486
-
487
- const chargeRows = yield* mapAdapter(
488
- database
489
- .select({ total: count() })
490
- .from(mapping.charge.table)
491
- .where(
492
- and(
493
- eq(c.moduleId, moduleId),
494
- eq(c.action, action),
495
- eq(c.scopeKind, entry.kind),
496
- eq(c.scopeKey, entry.key),
497
- gte(c.occurredAt, mapping.encodeInstant(now - entry.windowMillis)),
498
- ),
499
- ),
500
- );
501
-
502
- const pendingRows = yield* mapAdapter(
503
- database
504
- .select({ total: count() })
505
- .from(mapping.attempt.table)
506
- .where(
507
- livePendingAttempts(
508
- mapping,
509
- moduleId,
510
- action,
511
- entry,
512
- identifier,
513
- nativeSubjectId,
514
- mapping.encodeInstant(now),
515
- ),
516
- ),
517
- );
518
-
519
- const admittedHere =
520
- Number(chargeRows[0]?.total ?? 0) < entry.limit &&
521
- Number(pendingRows[0]?.total ?? 0) < policy.maximumPending;
522
-
523
- if (admittedHere) admitted.push(entry);
524
- else if (index === 0) break;
525
- }
526
-
527
- return admitted;
528
- });
529
-
530
362
  const evidenceProofsJson = Schema.encodeSync(
531
363
  Schema.fromJsonString(AuthenticationEvidence.fields.proofs),
532
364
  );
@@ -555,25 +387,6 @@ const assuranceCondition = (
555
387
  const sameIdentifier = (left: LoginIdentifier, right: LoginIdentifier) =>
556
388
  left.namespace === right.namespace && left.value === right.value;
557
389
 
558
- const sameSnapshot = (left: PasswordCredentialSnapshot, right: PasswordCredentialSnapshot) =>
559
- left.moduleId === right.moduleId &&
560
- left.revision.subjectId === right.revision.subjectId &&
561
- left.revision.securityRevision === right.revision.securityRevision &&
562
- left.credentialId === right.credentialId &&
563
- left.credentialRevision === right.credentialRevision &&
564
- left.verifierVersion === right.verifierVersion &&
565
- left.normalization === right.normalization &&
566
- left.identifierVerifiedAtMillis === right.identifierVerifiedAtMillis &&
567
- left.revision.credentials.length === right.revision.credentials.length &&
568
- left.revision.credentials.every((entry) =>
569
- right.revision.credentials.some(
570
- (other) => other.credentialId === entry.credentialId && other.revision === entry.revision,
571
- ),
572
- ) &&
573
- left.identifierBindingRevision === right.identifierBindingRevision &&
574
- sameIdentifier(left.identifier, right.identifier) &&
575
- Redacted.value(left.verifier) === Redacted.value(right.verifier);
576
-
577
390
  const evidenceSatisfiedAt = (
578
391
  evidence: PasswordMutationInput["authorization"]["evidence"],
579
392
  requirement: PasswordMutationInput["authorization"]["requirement"],
@@ -947,7 +760,7 @@ const admittedSnapshotCondition = (
947
760
  identifier: snapshot.identifier,
948
761
  nativeSubjectId: resolved.nativeSubjectId,
949
762
  }),
950
- sql`exists(select 1 from ${mapping.identifier.table} where ${i.namespace} = ${sql.param(snapshot.identifier.namespace, i.namespace)} and ${i.value} = ${sql.param(snapshot.identifier.value, i.value)} and ${i.subjectId} = ${sql.param(resolved.nativeSubjectId, i.subjectId)} and ${i.bindingRevision} = ${sql.param(snapshot.identifierBindingRevision, i.bindingRevision)})`,
763
+ sql`exists(select 1 from ${mapping.identifier.table} where ${i.namespace} = ${sql.param(snapshot.identifier.namespace, i.namespace)} and ${i.value} = ${sql.param(snapshot.identifier.value, i.value)} and ${i.subjectId} = ${sql.param(resolved.nativeSubjectId, i.subjectId)} and ${i.bindingRevision} = ${sql.param(snapshot.identifierBindingRevision, i.bindingRevision)} and ${Predicate.isNullish(resolved.identifier[mapping.identifier.verifiedAt]) ? sql`${i.verifiedAt} is null` : sql`${i.verifiedAt} = ${sql.param(resolved.identifier[mapping.identifier.verifiedAt], i.verifiedAt)}`})`,
951
764
  sql`exists(select 1 from ${mapping.credential.table} where ${c.moduleId} = ${sql.param(moduleId, c.moduleId)} and ${c.subjectId} = ${sql.param(resolved.nativeSubjectId, c.subjectId)} and ${c.credentialId} = ${sql.param(snapshot.credentialId, c.credentialId)} and ${c.credentialRevision} = ${sql.param(snapshot.credentialRevision, c.credentialRevision)} and ${c.verifierVersion} = ${sql.param(snapshot.verifierVersion, c.verifierVersion)} and ${c.verifier} = ${sql.param(Redacted.value(snapshot.verifier), c.verifier)} and ${c.normalization} = ${sql.param(snapshot.normalization, c.normalization)})`,
952
765
  ...snapshot.revision.credentials.map(
953
766
  (item) =>
@@ -1150,6 +963,7 @@ const compileAddMutation = Effect.fn("DrizzleD1Password.compileAddMutation")(fun
1150
963
  return {
1151
964
  statements,
1152
965
  appliedCondition,
966
+ marker,
1153
967
  retryable: (cause: unknown) => isGuardFailure(cause, marker),
1154
968
  };
1155
969
  });
@@ -1179,200 +993,125 @@ const makePasswordPlans = (
1179
993
  });
1180
994
 
1181
995
  return {
1182
- admitAttempt: <A>(
1183
- input: Parameters<PasswordPersistence["Service"]["admitAttempt"]>[0],
1184
- prepare: PlanPrepare<PasswordPersistence["Service"]["admitAttempt"], A>,
1185
- ) =>
996
+ prepareAttempt: (uncaptured: Parameters<PasswordPersistence["Service"]["prepareAttempt"]>[0]) =>
1186
997
  Effect.gen(function* () {
1187
- const database = yield* CurrentD1PlanningDatabase;
1188
-
1189
- const attemptId = yield* allocate(mapping.allocateAttemptId, mapping.allocateAttemptIdSync);
1190
-
1191
- return yield* run(
1192
- Effect.gen(function* () {
1193
- const journal = yield* CurrentCommitJournal;
1194
-
1195
- const now = yield* readEngineNowMillis(mapping);
1196
-
1197
- const resolved = yield* resolveCredential(
1198
- mapping,
1199
- input.moduleId,
1200
- input.identifier,
1201
- input.subjectId,
1202
- );
998
+ if (!validConstraints(mapping)) return yield* unavailable();
1203
999
 
1204
- const keys = mapping.scopeKeys({
1205
- moduleId: input.moduleId,
1206
- action: input.action,
1207
- identifier: input.identifier,
1208
- ...(resolved?.snapshot === undefined
1209
- ? {}
1210
- : { subjectId: resolved.snapshot.revision.subjectId }),
1211
- });
1212
-
1213
- const scopes = scopeEntries(keys, input.policy);
1214
-
1215
- const admitted = yield* readScopeAdmission(
1216
- mapping,
1217
- input.moduleId,
1218
- input.action,
1219
- scopes,
1220
- input.policy,
1221
- input.identifier,
1222
- resolved?.nativeSubjectId,
1223
- now,
1224
- );
1000
+ const input = Object.freeze({
1001
+ ...uncaptured,
1002
+ identifier: Object.freeze({ ...uncaptured.identifier }),
1003
+ });
1225
1004
 
1226
- const allAdmitted = admitted.length === scopes.length;
1227
- const considered = admitted.length === 0 ? scopes.slice(0, 1) : scopes;
1005
+ const resolved = yield* resolveCredential(
1006
+ mapping,
1007
+ input.moduleId,
1008
+ input.identifier,
1009
+ input.subjectId,
1010
+ );
1228
1011
 
1229
- const receipt = prepare(
1230
- allAdmitted
1231
- ? {
1232
- _tag: "Admitted" as const,
1233
- attemptId,
1234
- ...(resolved?.snapshot === undefined ? {} : { credential: resolved.snapshot }),
1235
- }
1236
- : { _tag: "Denied" as const },
1237
- journal,
1238
- );
1012
+ const snapshot = resolved?.snapshot;
1013
+ const attemptId = yield* allocate(mapping.allocateAttemptId, mapping.allocateAttemptIdSync);
1014
+ let used = false;
1239
1015
 
1240
- const marker = `effect-auth-password-guard:admit:${attemptId}`;
1241
- const statements: Statement<any>[] = [];
1016
+ return Object.freeze({
1017
+ ...(snapshot === undefined ? {} : { credential: snapshot }),
1018
+ // Claim outside execution: a definite batch rollback may replan other
1019
+ // operations, but a prepared admission never consumes its limits twice.
1020
+ claim: Effect.suspend(() => {
1021
+ if (used) return Effect.fail(unavailable());
1022
+ used = true;
1242
1023
 
1243
- for (const entry of considered) {
1244
- statements.push(
1245
- yield* statement(
1246
- database
1247
- .insert(mapping.rateScope.table)
1248
- .values(
1249
- mapping.rateScope.encodeInsert({
1250
- moduleId: input.moduleId,
1251
- action: input.action,
1252
- scopeKind: entry.kind,
1253
- scopeKey: entry.key,
1024
+ return Effect.void;
1025
+ }),
1026
+ admit: <A>(prepare: PreparePasswordCommit<PasswordAttemptAdmission, A>) =>
1027
+ run(
1028
+ Effect.gen(function* () {
1029
+ const database = yield* CurrentD1PlanningDatabase;
1030
+ const journal = yield* CurrentCommitJournal;
1031
+ const now = yield* readEngineNowMillis(mapping);
1032
+ const marker = `effect-auth-password-guard:admit:${attemptId}`;
1033
+ const a = attemptColumns(mapping);
1034
+ const key = and(eq(a.moduleId, input.moduleId), eq(a.attemptId, attemptId))!;
1035
+ const current = admittedSnapshotCondition(mapping, resolved, input.moduleId);
1036
+
1037
+ const record: PasswordAttemptRecord = {
1038
+ moduleId: input.moduleId,
1039
+ action: input.action,
1040
+ attemptId,
1041
+ identifier: input.identifier,
1042
+ ...(snapshot === undefined
1043
+ ? {}
1044
+ : {
1045
+ subjectId: snapshot.revision.subjectId,
1046
+ credentialId: snapshot.credentialId,
1047
+ securityRevision: snapshot.revision.securityRevision,
1048
+ credentialRevision: snapshot.credentialRevision,
1049
+ verifierVersion: snapshot.verifierVersion,
1050
+ identifierBindingRevision: snapshot.identifierBindingRevision,
1254
1051
  }),
1255
- )
1256
- .onConflictDoNothing(),
1257
- ),
1258
- );
1259
- }
1260
-
1261
- const conditions = considered.map((entry) => {
1262
- const condition = scopeAdmissionCondition(
1263
- mapping,
1264
- input.moduleId,
1265
- input.action,
1266
- entry,
1267
- input.policy,
1268
- input.identifier,
1269
- resolved?.nativeSubjectId,
1270
- );
1271
-
1272
- return admitted.includes(entry) ? condition : sql`not (${condition})`;
1273
- });
1052
+ admittedAtMillis: now,
1053
+ deadlineMillis: now + input.attemptLifetimeMillis,
1054
+ retentionUntilMillis: now + input.attemptLifetimeMillis,
1055
+ };
1056
+
1057
+ const receipt = prepare(
1058
+ {
1059
+ _tag: "Admitted",
1060
+ attemptId,
1061
+ ...(snapshot === undefined ? {} : { credential: snapshot }),
1062
+ },
1063
+ journal,
1064
+ );
1274
1065
 
1275
- statements.push(
1276
- yield* assertion(
1277
- and(
1278
- ...conditions,
1279
- ...(allAdmitted
1280
- ? [admittedSnapshotCondition(mapping, resolved, input.moduleId)]
1281
- : []),
1282
- )!,
1283
- marker,
1284
- ),
1285
- );
1286
- for (const entry of admitted)
1287
- statements.push(
1288
- yield* statement(
1289
- database.insert(mapping.charge.table).values(
1290
- driverValues(
1291
- mapping.charge.encodeInsert({
1292
- moduleId: input.moduleId,
1293
- action: input.action,
1294
- scopeKind: entry.kind,
1295
- scopeKey: entry.key,
1296
- attemptId,
1297
- occurredAtMillis: now,
1298
- retentionUntilMillis: now + entry.windowMillis,
1299
- }),
1300
- [
1301
- [mapping.charge.occurredAt, mapping.d1.engineNow],
1302
- [
1303
- mapping.charge.retentionUntil,
1304
- mapping.d1.engineInstantPlus(entry.windowMillis),
1305
- ],
1306
- ],
1307
- ),
1066
+ const admitted = and(
1067
+ current,
1068
+ sql`exists(select 1 from ${mapping.attempt.table} where ${key} and ${a.state} = ${"pending"} and ${a.deadline} > ${mapping.d1.engineNow})`,
1069
+ )!;
1070
+
1071
+ const statements: Statement<any>[] = [
1072
+ yield* assertion(
1073
+ and(
1074
+ current,
1075
+ sql`not exists(select 1 from ${mapping.attempt.table} where ${key})`,
1076
+ )!,
1077
+ marker,
1308
1078
  ),
1309
- ),
1310
- );
1311
- if (allAdmitted) {
1312
- const snapshot = resolved?.snapshot;
1313
-
1314
- const record: PasswordAttemptRecord = {
1315
- moduleId: input.moduleId,
1316
- action: input.action,
1317
- attemptId,
1318
- identifier: input.identifier,
1319
- ...(snapshot === undefined
1320
- ? {}
1321
- : {
1322
- subjectId: snapshot.revision.subjectId,
1323
- credentialId: snapshot.credentialId,
1324
- securityRevision: snapshot.revision.securityRevision,
1325
- credentialRevision: snapshot.credentialRevision,
1326
- verifierVersion: snapshot.verifierVersion,
1327
- identifierBindingRevision: snapshot.identifierBindingRevision,
1328
- }),
1329
- admittedAtMillis: now,
1330
- deadlineMillis: now + input.policy.attemptLifetimeMillis,
1331
- retentionUntilMillis: Math.max(
1332
- now + input.policy.attemptLifetimeMillis,
1333
- ...scopes.map((entry) => now + entry.windowMillis),
1334
- ),
1335
- };
1336
-
1337
- statements.push(
1338
- yield* statement(
1339
- database.insert(mapping.attempt.table).values(
1340
- driverValues(
1341
- mapping.attempt.encodeInsert(record, {
1342
- ...(resolved?.nativeSubjectId === undefined
1343
- ? {}
1344
- : { nativeSubjectId: resolved.nativeSubjectId }),
1345
- state: "pending",
1346
- }),
1347
- [
1348
- [mapping.attempt.admittedAt, mapping.d1.engineNow],
1349
- [
1350
- mapping.attempt.deadline,
1351
- mapping.d1.engineInstantPlus(input.policy.attemptLifetimeMillis),
1352
- ],
1079
+ yield* statement(
1080
+ database.insert(mapping.attempt.table).values(
1081
+ driverValues(
1082
+ mapping.attempt.encodeInsert(record, {
1083
+ ...(resolved?.nativeSubjectId === undefined
1084
+ ? {}
1085
+ : { nativeSubjectId: resolved.nativeSubjectId }),
1086
+ state: "pending",
1087
+ }),
1353
1088
  [
1354
- mapping.attempt.retentionUntil,
1355
- mapping.d1.engineInstantPlus(
1356
- Math.max(
1357
- input.policy.attemptLifetimeMillis,
1358
- ...scopes.map((entry) => entry.windowMillis),
1359
- ),
1360
- ),
1089
+ [mapping.attempt.admittedAt, mapping.d1.engineNow],
1090
+ [
1091
+ mapping.attempt.deadline,
1092
+ mapping.d1.engineInstantPlus(input.attemptLifetimeMillis),
1093
+ ],
1094
+ [
1095
+ mapping.attempt.retentionUntil,
1096
+ mapping.d1.engineInstantPlus(input.attemptLifetimeMillis),
1097
+ ],
1361
1098
  ],
1362
- ],
1099
+ ),
1363
1100
  ),
1364
1101
  ),
1365
- ),
1366
- );
1367
- }
1368
-
1369
- return {
1370
- receipt,
1371
- statements,
1372
- retryable: (cause: unknown) => isGuardFailure(cause, marker),
1373
- };
1374
- }),
1375
- );
1102
+ yield* assertion(and(sql`changes() = 1`, admitted)!, marker),
1103
+ ];
1104
+
1105
+ // A failed captured-snapshot guard cannot authorize refreshing the
1106
+ // candidate after the strategy has consumed that subject's limit.
1107
+ return {
1108
+ receipt,
1109
+ statements,
1110
+ postconditions: [yield* assertion(admitted, marker)],
1111
+ };
1112
+ }),
1113
+ ),
1114
+ });
1376
1115
  }),
1377
1116
  settleAttempt: <A>(
1378
1117
  uncaptured: Parameters<PasswordPersistence["Service"]["settleAttempt"]>[0],
@@ -1456,8 +1195,24 @@ const makePasswordPlans = (
1456
1195
  eq(a.state, "pending"),
1457
1196
  )!;
1458
1197
 
1198
+ const attemptSnapshot =
1199
+ verified && resolved?.snapshot !== undefined
1200
+ ? and(
1201
+ eq(a.action, row[mapping.attempt.action]),
1202
+ eq(a.subjectId, resolved.nativeSubjectId),
1203
+ eq(a.identifierNamespace, input.captured!.identifier.namespace),
1204
+ eq(a.identifierValue, input.captured!.identifier.value),
1205
+ eq(a.credentialId, input.captured!.credentialId),
1206
+ eq(a.securityRevision, input.captured!.revision.securityRevision),
1207
+ eq(a.credentialRevision, input.captured!.credentialRevision),
1208
+ eq(a.verifierVersion, input.captured!.verifierVersion),
1209
+ eq(a.identifierBindingRevision, input.captured!.identifierBindingRevision),
1210
+ eq(a.deadline, row[mapping.attempt.deadline]),
1211
+ )!
1212
+ : sql`true`;
1213
+
1459
1214
  const condition = verified
1460
- ? and(base, sql`${a.deadline} > ${mapping.d1.engineNow}`)!
1215
+ ? and(base, attemptSnapshot, sql`${a.deadline} > ${mapping.d1.engineNow}`)!
1461
1216
  : base;
1462
1217
 
1463
1218
  const statements: Statement<any>[] = [
@@ -1473,23 +1228,15 @@ const makePasswordPlans = (
1473
1228
  ),
1474
1229
  ];
1475
1230
 
1231
+ let finalAuthority = sql`true`;
1232
+
1476
1233
  if (verified && resolved?.snapshot !== undefined) {
1477
1234
  const captured = input.captured!;
1478
- const s = subjectColumns(mapping);
1479
- const i = identifierColumns(mapping);
1480
1235
  const c = credentialColumns(mapping);
1481
1236
 
1482
- const authority = and(
1483
- sql`exists(select 1 from ${mapping.subject.table} where ${s.id} = ${sql.param(resolved.nativeSubjectId, s.id)} and ${s.status} = ${sql.param(mapping.subject.d1ActiveStatusValue, s.status)} and ${s.securityRevision} = ${sql.param(input.captured!.revision.securityRevision, s.securityRevision)})`,
1484
- mapping.identifier.d1CurrentCondition({
1485
- identifier: input.captured!.identifier,
1486
- nativeSubjectId: resolved.nativeSubjectId,
1487
- }),
1488
- sql`exists(select 1 from ${mapping.credential.table} where ${c.moduleId} = ${sql.param(input.moduleId, c.moduleId)} and ${c.subjectId} = ${sql.param(resolved.nativeSubjectId, c.subjectId)} and ${c.credentialId} = ${sql.param(input.captured!.credentialId, c.credentialId)} and ${c.credentialRevision} = ${sql.param(input.captured!.credentialRevision, c.credentialRevision)} and ${c.verifierVersion} = ${sql.param(input.captured!.verifierVersion, c.verifierVersion)} and ${c.verifier} = ${sql.param(Redacted.value(input.captured!.verifier), c.verifier)})`,
1489
- sql`exists(select 1 from ${mapping.identifier.table} where ${i.namespace} = ${sql.param(input.captured!.identifier.namespace, i.namespace)} and ${i.value} = ${sql.param(input.captured!.identifier.value, i.value)} and ${i.subjectId} = ${sql.param(resolved.nativeSubjectId, i.subjectId)} and ${i.bindingRevision} = ${sql.param(input.captured!.identifierBindingRevision, i.bindingRevision)})`,
1490
- )!;
1491
-
1492
- statements.splice(1, 0, yield* assertion(authority, marker));
1237
+ finalAuthority = admittedSnapshotCondition(mapping, resolved, input.moduleId);
1238
+
1239
+ statements.splice(1, 0, yield* assertion(finalAuthority, marker));
1493
1240
  if (
1494
1241
  input.rehash !== undefined &&
1495
1242
  input.rehash.expectedVersion === captured.verifierVersion &&
@@ -1501,6 +1248,7 @@ const makePasswordPlans = (
1501
1248
  );
1502
1249
 
1503
1250
  statements.push(
1251
+ yield* assertion(sql`changes() = 1`, marker),
1504
1252
  yield* statement(
1505
1253
  database
1506
1254
  .update(mapping.credential.table)
@@ -1519,12 +1267,33 @@ const makePasswordPlans = (
1519
1267
  ),
1520
1268
  ),
1521
1269
  );
1270
+ finalAuthority = admittedSnapshotCondition(
1271
+ mapping,
1272
+ {
1273
+ ...resolved,
1274
+ snapshot: {
1275
+ ...resolved.snapshot,
1276
+ verifierVersion: nextVersion,
1277
+ verifier: input.rehash.nextVerifier,
1278
+ },
1279
+ },
1280
+ input.moduleId,
1281
+ );
1522
1282
  }
1523
1283
  }
1524
1284
 
1285
+ const settled = and(
1286
+ finalAuthority,
1287
+ sql`exists(select 1 from ${mapping.attempt.table} where ${a.moduleId} = ${sql.param(input.moduleId, a.moduleId)} and ${a.attemptId} = ${sql.param(input.attemptId, a.attemptId)} and ${a.state} = ${sql.param(decision, a.state)} and ${attemptSnapshot}${verified ? sql` and ${a.deadline} > ${mapping.d1.engineNow}` : sql``})`,
1288
+ )!;
1289
+
1290
+ // Run after attempt/rehash triggers, before the batch can publish its journal.
1291
+ statements.push(yield* assertion(and(sql`changes() = 1`, settled)!, marker));
1292
+
1525
1293
  return {
1526
1294
  receipt,
1527
1295
  statements,
1296
+ postconditions: [yield* assertion(settled, marker)],
1528
1297
  retryable: (cause: unknown) => isGuardFailure(cause, marker),
1529
1298
  };
1530
1299
  }),
@@ -1619,10 +1388,14 @@ const makePasswordPlans = (
1619
1388
  nextSecurityRevision,
1620
1389
  });
1621
1390
 
1391
+ if (compiled === undefined)
1392
+ return { receipt: prepare("rejected", journal), statements: [] };
1393
+
1622
1394
  return {
1623
- receipt: prepare(compiled === undefined ? "rejected" : "changed", journal),
1624
- statements: compiled?.statements ?? [],
1625
- ...(compiled?.retryable === undefined ? {} : { retryable: compiled.retryable }),
1395
+ receipt: prepare("changed", journal),
1396
+ statements: compiled.statements,
1397
+ postconditions: [yield* assertion(compiled.appliedCondition, compiled.marker)],
1398
+ retryable: compiled.retryable,
1626
1399
  };
1627
1400
  }),
1628
1401
  );
@@ -1826,6 +1599,7 @@ const replacementPlan = Effect.fn("Drizzle.replacementPlan")(function* <A>(
1826
1599
  return {
1827
1600
  receipt: passwordReceipt ?? prepare("rejected", journal),
1828
1601
  statements: compiled.statements,
1602
+ ...(compiled.postconditions === undefined ? {} : { postconditions: compiled.postconditions }),
1829
1603
  retryable: (cause: unknown) =>
1830
1604
  isGuardFailure(cause, marker) ||
1831
1605
  isGuardFailure(
@@ -1839,12 +1613,12 @@ const replacementPlan = Effect.fn("Drizzle.replacementPlan")(function* <A>(
1839
1613
  };
1840
1614
  }
1841
1615
 
1616
+ const applied = yield* assertion(protectedMutation.appliedCondition, marker);
1617
+
1842
1618
  return {
1843
1619
  receipt: prepare("changed", journal),
1844
- statements: [
1845
- ...protectedMutation.statements,
1846
- yield* assertion(protectedMutation.appliedCondition, marker),
1847
- ],
1620
+ statements: [...protectedMutation.statements, applied],
1621
+ postconditions: [applied],
1848
1622
  retryable: (cause: unknown) => isGuardFailure(cause, marker),
1849
1623
  };
1850
1624
  });
@@ -1860,63 +1634,15 @@ const cleanupPlan = Effect.fn("Drizzle.cleanupPlan")(function* <A>(
1860
1634
  const now = yield* readEngineNowMillis(mapping);
1861
1635
  const nativeNow = mapping.encodeInstant(now);
1862
1636
  const a = attemptColumns(mapping);
1863
- const c = chargeColumns(mapping);
1864
- const sc = scopeColumns(mapping);
1865
- let remaining = input.limit;
1866
- let hasMore = false;
1867
1637
 
1868
1638
  const attempts = yield* database
1869
1639
  .select({ attemptId: a.attemptId })
1870
1640
  .from(mapping.attempt.table)
1871
1641
  .where(and(eq(a.moduleId, input.moduleId), lte(a.retentionUntil, nativeNow)))
1872
- .limit(remaining + 1);
1873
-
1874
- if (attempts.length > remaining) hasMore = true;
1875
- const selectedAttempts = attempts.slice(0, remaining);
1876
-
1877
- remaining -= selectedAttempts.length;
1878
-
1879
- const charges = yield* database
1880
- .select({
1881
- action: c.action,
1882
- scopeKind: c.scopeKind,
1883
- scopeKey: c.scopeKey,
1884
- attemptId: c.attemptId,
1885
- })
1886
- .from(mapping.charge.table)
1887
- .where(and(eq(c.moduleId, input.moduleId), lte(c.retentionUntil, nativeNow)))
1888
- .limit(remaining + 1);
1889
-
1890
- if (charges.length > remaining) hasMore = true;
1891
- const selectedCharges = charges.slice(0, remaining);
1892
-
1893
- remaining -= selectedCharges.length;
1894
-
1895
- const scopes = yield* database
1896
- .select({ action: sc.action, scopeKind: sc.scopeKind, scopeKey: sc.scopeKey })
1897
- .from(mapping.rateScope.table)
1898
- .where(
1899
- and(
1900
- eq(sc.moduleId, input.moduleId),
1901
- notExists(
1902
- database
1903
- .select({ one: sql`1` })
1904
- .from(mapping.charge.table)
1905
- .where(
1906
- and(
1907
- eq(c.moduleId, sc.moduleId),
1908
- eq(c.action, sc.action),
1909
- eq(c.scopeKind, sc.scopeKind),
1910
- eq(c.scopeKey, sc.scopeKey),
1911
- ),
1912
- ),
1913
- ),
1914
- ),
1915
- )
1916
- .limit(remaining + 1);
1642
+ .limit(input.limit + 1);
1917
1643
 
1918
- if (scopes.length > remaining) hasMore = true;
1919
- const selectedScopes = scopes.slice(0, remaining);
1644
+ const selectedAttempts = attempts.slice(0, input.limit);
1645
+ const hasMore = attempts.length > selectedAttempts.length;
1920
1646
  const marker = `effect-auth-password-guard:cleanup:${input.moduleId}:${now}`;
1921
1647
  const statements: Statement<any>[] = [];
1922
1648
 
@@ -1935,58 +1661,11 @@ const cleanupPlan = Effect.fn("Drizzle.cleanupPlan")(function* <A>(
1935
1661
  yield* statement(database.delete(mapping.attempt.table).where(condition)),
1936
1662
  );
1937
1663
  }
1938
- for (const row of selectedCharges) {
1939
- const condition = and(
1940
- eq(c.moduleId, input.moduleId),
1941
- eq(c.action, row.action),
1942
- eq(c.scopeKind, row.scopeKind),
1943
- eq(c.scopeKey, row.scopeKey),
1944
- eq(c.attemptId, row.attemptId),
1945
- lte(c.retentionUntil, mapping.d1.engineNow),
1946
- )!;
1947
-
1948
- statements.push(
1949
- yield* assertion(
1950
- sql`exists(select 1 from ${mapping.charge.table} where ${condition})`,
1951
- marker,
1952
- ),
1953
- yield* statement(database.delete(mapping.charge.table).where(condition)),
1954
- );
1955
- }
1956
- for (const row of selectedScopes) {
1957
- const condition = and(
1958
- eq(sc.moduleId, input.moduleId),
1959
- eq(sc.action, row.action),
1960
- eq(sc.scopeKind, row.scopeKind),
1961
- eq(sc.scopeKey, row.scopeKey),
1962
- notExists(
1963
- database
1964
- .select({ one: sql`1` })
1965
- .from(mapping.charge.table)
1966
- .where(
1967
- and(
1968
- eq(c.moduleId, sc.moduleId),
1969
- eq(c.action, sc.action),
1970
- eq(c.scopeKind, sc.scopeKind),
1971
- eq(c.scopeKey, sc.scopeKey),
1972
- ),
1973
- ),
1974
- ),
1975
- )!;
1976
-
1977
- statements.push(
1978
- yield* assertion(
1979
- sql`exists(select 1 from ${mapping.rateScope.table} where ${condition})`,
1980
- marker,
1981
- ),
1982
- yield* statement(database.delete(mapping.rateScope.table).where(condition)),
1983
- );
1984
- }
1985
1664
 
1986
1665
  return {
1987
1666
  receipt: prepare(
1988
1667
  {
1989
- removed: selectedAttempts.length + selectedCharges.length + selectedScopes.length,
1668
+ removed: selectedAttempts.length,
1990
1669
  hasMore,
1991
1670
  },
1992
1671
  journal,
@@ -2002,12 +1681,10 @@ export const makeD1PasswordPersistenceServices = Effect.fnUntraced(function* <
2002
1681
  C extends AnySQLiteTable,
2003
1682
  AC extends AnySQLiteTable,
2004
1683
  A extends AnySQLiteTable,
2005
- RS extends AnySQLiteTable,
2006
- CE extends AnySQLiteTable,
2007
1684
  M extends AnySQLiteTable,
2008
1685
  NativeId,
2009
1686
  >(
2010
- mapping: D1PasswordPersistenceMapping<S, I, C, AC, A, RS, CE, M, NativeId>,
1687
+ mapping: D1PasswordPersistenceMapping<S, I, C, AC, A, M, NativeId>,
2011
1688
  proofMapping?: D1ProofPersistenceMapping<
2012
1689
  any,
2013
1690
  any,
@@ -2033,9 +1710,14 @@ export const makeD1PasswordPersistenceServices = Effect.fnUntraced(function* <
2033
1710
  const hooks = yield* LifecycleHooks;
2034
1711
  const plans = makePasswordPlans(mapping as unknown as Mapping, proofMapping);
2035
1712
 
1713
+ const standalone = Effect.gen(function* () {
1714
+ if (yield* hasCommitScope) return yield* unavailable();
1715
+ yield* requireStandalone(unavailable, database.$client.transactionService);
1716
+ });
1717
+
2036
1718
  const run = <Out, Err, Env>(plan: Effect.Effect<Planned<Out>, Err, Env>) =>
2037
1719
  Effect.gen(function* () {
2038
- if (yield* hasCommitScope) return yield* unavailable();
1720
+ yield* standalone;
2039
1721
 
2040
1722
  return yield* executeStandalone(plan, 2);
2041
1723
  }).pipe(
@@ -2044,8 +1726,19 @@ export const makeD1PasswordPersistenceServices = Effect.fnUntraced(function* <
2044
1726
  );
2045
1727
 
2046
1728
  const service: PasswordPersistence["Service"] = {
2047
- admitAttempt: (input, prepare) =>
2048
- run(plans.admitAttempt(input, prepare)).pipe(translateFailure),
1729
+ prepareAttempt: (input) =>
1730
+ standalone.pipe(
1731
+ Effect.andThen(plans.prepareAttempt(input)),
1732
+ Effect.map((planned): PasswordAttemptPreparation =>
1733
+ Object.freeze({
1734
+ ...(planned.credential === undefined ? {} : { credential: planned.credential }),
1735
+ admit: <A>(prepare: PreparePasswordCommit<PasswordAttemptAdmission, A>) =>
1736
+ planned.claim.pipe(Effect.andThen(run(planned.admit(prepare))), translateFailure),
1737
+ }),
1738
+ ),
1739
+ Effect.provideService(CurrentD1PlanningDatabase, database),
1740
+ translateFailure,
1741
+ ),
2049
1742
  settleAttempt: (input, prepare) =>
2050
1743
  run(plans.settleAttempt(input, prepare)).pipe(translateFailure),
2051
1744
  readForSubject: (input) =>
@@ -2097,8 +1790,6 @@ export function coordinateD1PasswordPersistence<
2097
1790
  C extends AnySQLiteTable,
2098
1791
  AC extends AnySQLiteTable,
2099
1792
  At extends AnySQLiteTable,
2100
- RS extends AnySQLiteTable,
2101
- CE extends AnySQLiteTable,
2102
1793
  M extends AnySQLiteTable,
2103
1794
  NativeId,
2104
1795
  A,
@@ -2109,7 +1800,7 @@ export function coordinateD1PasswordPersistence<
2109
1800
  >(
2110
1801
  acquire: Effect.Effect<Database, DatabaseError, DatabaseRequirements>,
2111
1802
  options: {
2112
- readonly mapping: D1PasswordPersistenceMapping<S, I, C, AC, At, RS, CE, M, NativeId>;
1803
+ readonly mapping: D1PasswordPersistenceMapping<S, I, C, AC, At, M, NativeId>;
2113
1804
  readonly proofMapping?:
2114
1805
  | D1ProofPersistenceMapping<any, any, any, any, any, any, any, any, any, any, any, any>
2115
1806
  | undefined;
@@ -2173,8 +1864,27 @@ export function coordinateD1PasswordPersistence<
2173
1864
  );
2174
1865
 
2175
1866
  const service: PasswordPersistence["Service"] = {
2176
- admitAttempt: (input, prepare) =>
2177
- owner.run(run(plans.admitAttempt(input, prepare)).pipe(translateFailure)),
1867
+ prepareAttempt: (input) =>
1868
+ owner.run(
1869
+ plans.prepareAttempt(input).pipe(
1870
+ Effect.map((planned): PasswordAttemptPreparation =>
1871
+ Object.freeze({
1872
+ ...(planned.credential === undefined
1873
+ ? {}
1874
+ : { credential: planned.credential }),
1875
+ admit: <A>(prepare: PreparePasswordCommit<PasswordAttemptAdmission, A>) =>
1876
+ owner.run(
1877
+ planned.claim.pipe(
1878
+ Effect.andThen(run(planned.admit(prepare))),
1879
+ translateFailure,
1880
+ ),
1881
+ ),
1882
+ }),
1883
+ ),
1884
+ Effect.provideService(CurrentD1PlanningDatabase, database),
1885
+ translateFailure,
1886
+ ),
1887
+ ),
2178
1888
  settleAttempt: (input, prepare) =>
2179
1889
  owner.run(run(plans.settleAttempt(input, prepare)).pipe(translateFailure)),
2180
1890
  readForSubject: (input) =>
@@ -2229,16 +1939,19 @@ export function coordinateD1PasswordPersistence<
2229
1939
  if (status._tag === "Success" || status.failure._tag !== "CommitPending")
2230
1940
  return yield* unavailable();
2231
1941
  }
2232
- yield* database.$client.batch(statements).pipe(
2233
- Effect.catchCause((cause) =>
2234
- Effect.failCause(
2235
- Cause.map(cause, (error) =>
2236
- everyFailureMatches(cause, mutation?.retryable) ? unavailable() : error,
1942
+ // Re-assert the password's final state after application statements.
1943
+ yield* database.$client
1944
+ .batch([...statements, ...(mutation?.postconditions ?? [])])
1945
+ .pipe(
1946
+ Effect.catchCause((cause) =>
1947
+ Effect.failCause(
1948
+ Cause.map(cause, (error) =>
1949
+ everyFailureMatches(cause, mutation?.retryable) ? unavailable() : error,
1950
+ ),
2237
1951
  ),
2238
1952
  ),
2239
- ),
2240
- translateFailure,
2241
- );
1953
+ translateFailure,
1954
+ );
2242
1955
 
2243
1956
  return value;
2244
1957
  }),