@yielded/auth-persistence 0.1.0-beta.30 → 0.1.0-beta.32

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 (130) hide show
  1. package/README.md +11 -7
  2. package/dist/Adapter.d.mts +1 -1
  3. package/dist/Adapter.mjs +8 -8
  4. package/dist/OAuthPersistence.mjs +10 -5
  5. package/dist/OAuthPersistence.mjs.map +1 -1
  6. package/dist/internal/configuration.d.mts +49 -13
  7. package/dist/internal/configuration.mjs.map +1 -1
  8. package/dist/internal/email-native.mjs +10 -2
  9. package/dist/internal/email-native.mjs.map +1 -1
  10. package/dist/internal/email-policy.mjs +3 -2
  11. package/dist/internal/email-policy.mjs.map +1 -1
  12. package/dist/internal/models/oauth-model.d.mts +4 -2
  13. package/dist/internal/models/oauth-model.mjs.map +1 -1
  14. package/dist/internal/models/password-model.d.mts +2 -1
  15. package/dist/internal/models/password-model.mjs.map +1 -1
  16. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  17. package/dist/internal/oauth/native-accounts.mjs +6 -6
  18. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  19. package/dist/internal/oauth/native-connected.d.mts +1 -1
  20. package/dist/internal/oauth/native-connected.mjs +1 -1
  21. package/dist/internal/oauth/native-registration.mjs +19 -8
  22. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  23. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  24. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  25. package/dist/internal/oauth/native-state.mjs +21 -11
  26. package/dist/internal/oauth/native-state.mjs.map +1 -1
  27. package/dist/internal/oauth/state.d.mts +1 -1
  28. package/dist/internal/oauth/state.mjs +1 -1
  29. package/dist/internal/oauth-managed.mjs +182 -0
  30. package/dist/internal/oauth-managed.mjs.map +1 -0
  31. package/dist/internal/passkey-managed.mjs +5 -3
  32. package/dist/internal/passkey-managed.mjs.map +1 -1
  33. package/dist/internal/passkey-native-registration.mjs +1 -1
  34. package/dist/internal/passkey-native-state.mjs +33 -23
  35. package/dist/internal/passkey-native-state.mjs.map +1 -1
  36. package/dist/internal/passkey-native-write.mjs +3 -3
  37. package/dist/internal/passkey-native-write.mjs.map +1 -1
  38. package/dist/internal/passkey-native.d.mts +15 -7
  39. package/dist/internal/passkey-native.mjs +2 -1
  40. package/dist/internal/passkey-native.mjs.map +1 -1
  41. package/dist/internal/passkey-policy.mjs +9 -7
  42. package/dist/internal/passkey-policy.mjs.map +1 -1
  43. package/dist/internal/password-native.mjs +22 -5
  44. package/dist/internal/password-native.mjs.map +1 -1
  45. package/dist/internal/password-policy.mjs +7 -6
  46. package/dist/internal/password-policy.mjs.map +1 -1
  47. package/dist/internal/password-registration-native.d.mts +1 -1
  48. package/dist/internal/persistence.d.mts +3 -0
  49. package/dist/internal/persistence.mjs +99 -42
  50. package/dist/internal/persistence.mjs.map +1 -1
  51. package/dist/internal/phone-native.mjs +1 -1
  52. package/dist/internal/phone-native.mjs.map +1 -1
  53. package/dist/internal/registration.mjs +17 -8
  54. package/dist/internal/registration.mjs.map +1 -1
  55. package/dist/internal/session-native-authority.mjs +8 -6
  56. package/dist/internal/session-native-authority.mjs.map +1 -1
  57. package/dist/internal/session-native-login.d.mts +2 -2
  58. package/dist/internal/session-native-login.mjs +4 -2
  59. package/dist/internal/session-native-login.mjs.map +1 -1
  60. package/dist/internal/session-native-pending-read.mjs +26 -20
  61. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  62. package/dist/internal/session-native-record.mjs +6 -4
  63. package/dist/internal/session-native-record.mjs.map +1 -1
  64. package/dist/internal/session-native-state.mjs +24 -8
  65. package/dist/internal/session-native-state.mjs.map +1 -1
  66. package/dist/internal/session-native-stateful.d.mts +2 -2
  67. package/dist/internal/session-native-stateful.mjs +67 -38
  68. package/dist/internal/session-native-stateful.mjs.map +1 -1
  69. package/dist/internal/session-native-step-up.d.mts +2 -2
  70. package/dist/internal/session-native-step-up.mjs +17 -19
  71. package/dist/internal/session-native-step-up.mjs.map +1 -1
  72. package/dist/internal/session-native-validity.mjs +3 -1
  73. package/dist/internal/session-native-validity.mjs.map +1 -1
  74. package/dist/internal/sql-authority.mjs +5 -1
  75. package/dist/internal/sql-authority.mjs.map +1 -1
  76. package/dist/internal/sql-storage-validation.mjs +2 -1
  77. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  78. package/dist/internal/sql-table.d.mts +1 -0
  79. package/dist/internal/sql-table.mjs.map +1 -1
  80. package/dist/internal/sqlite-policy-guard.mjs +23 -0
  81. package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
  82. package/dist/internal/step-up-state.mjs +19 -6
  83. package/dist/internal/step-up-state.mjs.map +1 -1
  84. package/dist/internal/storage-mapping.d.mts +9 -5
  85. package/dist/internal/storage-mapping.mjs +17 -5
  86. package/dist/internal/storage-mapping.mjs.map +1 -1
  87. package/dist/internal/storage-tables.d.mts +237 -0
  88. package/dist/internal/storage-tables.mjs +68 -0
  89. package/dist/internal/storage-tables.mjs.map +1 -1
  90. package/dist/internal/storage-validation.d.mts +3 -1
  91. package/dist/internal/storage-validation.mjs +70 -17
  92. package/dist/internal/storage-validation.mjs.map +1 -1
  93. package/dist/internal/totp-native.mjs +1 -1
  94. package/package.json +1 -1
  95. package/src/OAuthPersistence.ts +7 -2
  96. package/src/internal/configuration.ts +72 -15
  97. package/src/internal/email-native.ts +16 -6
  98. package/src/internal/email-policy.ts +12 -3
  99. package/src/internal/models/oauth-model.ts +7 -1
  100. package/src/internal/models/password-model.ts +2 -1
  101. package/src/internal/oauth/native-accounts.ts +3 -3
  102. package/src/internal/oauth/native-registration.ts +17 -6
  103. package/src/internal/oauth/native-state.ts +38 -18
  104. package/src/internal/oauth-managed.ts +277 -0
  105. package/src/internal/passkey-managed.ts +8 -2
  106. package/src/internal/passkey-native-state.ts +59 -52
  107. package/src/internal/passkey-native-write.ts +2 -2
  108. package/src/internal/passkey-native.ts +4 -2
  109. package/src/internal/passkey-policy.ts +13 -9
  110. package/src/internal/password-native.ts +45 -11
  111. package/src/internal/password-policy.ts +16 -8
  112. package/src/internal/persistence.ts +360 -204
  113. package/src/internal/phone-native.ts +1 -1
  114. package/src/internal/registration.ts +53 -40
  115. package/src/internal/session-native-authority.ts +26 -8
  116. package/src/internal/session-native-login.ts +9 -4
  117. package/src/internal/session-native-pending-read.ts +46 -33
  118. package/src/internal/session-native-record.ts +6 -4
  119. package/src/internal/session-native-state.ts +32 -10
  120. package/src/internal/session-native-stateful.ts +147 -52
  121. package/src/internal/session-native-step-up.ts +31 -24
  122. package/src/internal/session-native-validity.ts +3 -1
  123. package/src/internal/sql-authority.ts +5 -1
  124. package/src/internal/sql-storage-validation.ts +53 -46
  125. package/src/internal/sql-table.ts +1 -0
  126. package/src/internal/sqlite-policy-guard.ts +34 -0
  127. package/src/internal/step-up-state.ts +43 -6
  128. package/src/internal/storage-mapping.ts +40 -4
  129. package/src/internal/storage-tables.ts +76 -0
  130. package/src/internal/storage-validation.ts +127 -27
@@ -0,0 +1,34 @@
1
+ import { Array, Effect } from "effect";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
+ import type { Fragment } from "effect/sql/Statement";
4
+
5
+ import { appendSqlBatchStatement, registerSqlBatchPostcondition } from "./sql-commit";
6
+
7
+ export interface SqlitePolicyValue {
8
+ readonly column: Fragment;
9
+ readonly value: Fragment;
10
+ }
11
+
12
+ /** Check original values before writes and at the end of the same atomic batch.
13
+ * Small groups bound parameters and expression depth without expanding retained
14
+ * values into hex/JSON. BINARY preserves text bytes; IS also compares NULLs. */
15
+ export const guardSqlitePolicy = Effect.fnUntraced(function* (input: {
16
+ readonly name: string;
17
+ readonly table: Fragment;
18
+ readonly owner: Fragment;
19
+ readonly values: ReadonlyArray<SqlitePolicyValue>;
20
+ }) {
21
+ const sql = (yield* SqlClient).withoutTransforms();
22
+
23
+ for (const values of Array.chunksOf(input.values, 32)) {
24
+ const matches = sql.and(
25
+ values.map(({ column, value }) => sql`${column} collate binary is ${value}`),
26
+ );
27
+
28
+ const condition = sql`exists(select 1 from ${input.table} where ${input.owner} and ${matches})`;
29
+ const statement = sql`select case when ${condition} then 1 else json_extract('[]', '$[auth-batch-precondition]') end as valid`;
30
+
31
+ yield* appendSqlBatchStatement(statement);
32
+ yield* registerSqlBatchPostcondition({ name: input.name, statement });
33
+ }
34
+ });
@@ -1,7 +1,9 @@
1
+ import { AuthenticationAssurance, AuthenticationClock } from "@yielded/auth/Operations";
1
2
  import {
2
3
  SessionStepUpInvalid,
3
4
  SessionUnavailable,
4
5
  AuthenticationEvidence,
6
+ AuthenticationProof,
5
7
  SessionMetadata,
6
8
  SessionStepUpIntent,
7
9
  SessionStepUpRequirement,
@@ -10,7 +12,7 @@ import {
10
12
  } from "@yielded/auth/Sessions";
11
13
  import { DateTime, Effect, Schema } from "effect";
12
14
 
13
- import { sameSessionRevision } from "./session-native-state";
15
+ import { assessSessionAt, sameSessionRevision } from "./session-native-state";
14
16
 
15
17
  export const stepUpIntentCodec = Schema.fromJsonString(SessionStepUpIntent);
16
18
  const BoundedSnapshot = Schema.String.check(Schema.isMaxLength(131072));
@@ -27,11 +29,13 @@ export const decodeStepUpIntent = (snapshot: string) =>
27
29
  Effect.mapError(() => SessionUnavailable.make({})),
28
30
  );
29
31
 
30
- export const stepUpIntentLive = (
32
+ export const stepUpIntentLive = Effect.fnUntraced(function* (
31
33
  intent: Omit<SessionStepUpIntent, "version">,
32
34
  kind: SessionStepUpIntent["sourceKind"],
33
35
  now: DateTime.Utc,
34
- ) => {
36
+ ): Effect.fn.Return<boolean> {
37
+ const { futureToleranceMillis } = yield* AuthenticationClock;
38
+
35
39
  const n = DateTime.toEpochMillis(now),
36
40
  authenticated = DateTime.toEpochMillis(intent.sourceAuthenticatedAt),
37
41
  idle = DateTime.toEpochMillis(intent.sourceExpiresAt),
@@ -40,16 +44,22 @@ export const stepUpIntentLive = (
40
44
 
41
45
  return (
42
46
  intent.sourceKind === kind &&
43
- authenticated <= n &&
47
+ authenticated - n <= futureToleranceMillis &&
48
+ authenticated < idle &&
44
49
  n < expires &&
45
50
  expires <= idle &&
46
51
  idle <= absolute
47
52
  );
48
- };
53
+ });
49
54
 
50
55
  const evidenceEncoding = Schema.encodeSync(Schema.fromJsonString(AuthenticationEvidence));
51
56
  const requirementEncoding = Schema.encodeSync(Schema.fromJsonString(SessionStepUpRequirement));
52
57
  const metadataEncoding = Schema.encodeSync(Schema.fromJsonString(SessionMetadata));
58
+ const proofEncoding = Schema.encodeSync(Schema.fromJsonString(Schema.Array(AuthenticationProof)));
59
+
60
+ const assuranceEncoding = Schema.encodeSync(
61
+ Schema.fromJsonString(SessionMetadata.fields.assurance),
62
+ );
53
63
 
54
64
  /** Validate core-owned plans before driver allocation/SQL expansion. No bearer or Claims decoding. */
55
65
  export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(function* <Claims>(
@@ -57,7 +67,14 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
57
67
  ) {
58
68
  const { intent, source, replacement, evidence } = plan;
59
69
  const original = source.inspection;
70
+ const issuedAt = DateTime.toEpochMillis(replacement.inspection.session.issuedAt);
60
71
 
72
+ if (
73
+ issuedAt < DateTime.toEpochMillis(original.session.issuedAt) ||
74
+ evidence.proofs.some((proof) => DateTime.toEpochMillis(proof.verifiedAt) > issuedAt) ||
75
+ issuedAt >= DateTime.toEpochMillis(replacement.inspection.session.expiresAt)
76
+ )
77
+ return yield* SessionStepUpInvalid.make({});
61
78
  if (
62
79
  requirementEncoding(plan.profileRequirement) !== requirementEncoding(intent.requirement) ||
63
80
  replacement.inspection.session.subjectId !== evidence.revision.subjectId ||
@@ -89,7 +106,9 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
89
106
  DateTime.toEpochMillis(original.session.absoluteExpiresAt) ||
90
107
  DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt) !==
91
108
  DateTime.toEpochMillis(replacement.inspection.session.absoluteExpiresAt) ||
92
- evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence)
109
+ evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence) ||
110
+ proofEncoding(evidence.proofs.slice(0, original.provenance.evidence.proofs.length)) !==
111
+ proofEncoding(original.provenance.evidence.proofs)
93
112
  )
94
113
  return yield* SessionStepUpInvalid.make({});
95
114
  if (
@@ -111,6 +130,24 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
111
130
  replacement.inspection.session.sessionId === intent.sourceSessionId
112
131
  )
113
132
  return yield* SessionStepUpInvalid.make({});
133
+
134
+ const at = DateTime.toEpochMillis(plan.now);
135
+ const base = yield* assessSessionAt(evidence, plan.baseRequirement, at);
136
+
137
+ const profile = yield* assessSessionAt(evidence, plan.profileRequirement, at);
138
+
139
+ // Freshness can change metadata at commit; validate the exact preparation snapshot here.
140
+ if (
141
+ !base.satisfied ||
142
+ !profile.satisfied ||
143
+ assuranceEncoding(
144
+ AuthenticationAssurance.make({
145
+ ...base.assurance,
146
+ authenticatedAt: profile.assurance.authenticatedAt,
147
+ }),
148
+ ) !== assuranceEncoding(replacement.inspection.session.assurance)
149
+ )
150
+ return yield* SessionStepUpInvalid.make({});
114
151
  });
115
152
 
116
153
  /** Compare a consumer-decoded encoded update/readback with the intended rotation.
@@ -16,6 +16,7 @@ import {
16
16
  type StatefulSessionRecord,
17
17
  } from "@yielded/auth/Sessions";
18
18
  import { Crypto, DateTime, Effect, Redacted, Schema, Struct } from "effect";
19
+ import { SqlClient } from "effect/sql";
19
20
 
20
21
  import type { MappingInput } from "./configuration";
21
22
  import { randomId } from "./crypto";
@@ -27,6 +28,8 @@ import {
27
28
  } from "./models/password-model";
28
29
  import { requiredProofConstraints, type AnyProofPersistenceMapping } from "./models/proof-model";
29
30
  import type { StatefulSessionMapping, PendingAuthenticationMapping } from "./models/session-model";
31
+ import type { NativeSqlTables } from "./native-sql-table";
32
+ import { exactSqlText } from "./sql-change";
30
33
  import { makeStorageClock } from "./storage-clock";
31
34
  import { storageTables, type StorageRole } from "./storage-tables";
32
35
  import type { TableModel, SqlExpression } from "./table-model";
@@ -40,12 +43,16 @@ const string = (value: unknown) => Schema.decodeUnknownSync(Schema.String)(value
40
43
 
41
44
  /** Derive shared row mappings from a managed or custom storage layout.
42
45
  * Acquire Effect Crypto once; the returned allocators retain that implementation.
43
- * Each factory requires its role tables to exist. Adapter authors supply typed
44
- * table handles and dialect-specific clocks/commit predicates, and may refine
45
- * authority policy for explicit services. This does not alter composed defaults.
46
+ * Each factory requires its role tables to exist. Supplying native tables derives
47
+ * batch commit predicates and subject policy columns. Adapter authors may refine
48
+ * clocks and authority policy for explicit services.
46
49
  */
47
- export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
50
+ export const makeMappings = Effect.fnUntraced(function* (
51
+ input: MappingInput,
52
+ tables?: NativeSqlTables,
53
+ ) {
48
54
  const crypto = yield* Crypto.Crypto;
55
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms();
49
56
 
50
57
  const allocate = randomId.pipe(
51
58
  Effect.provideService(Crypto.Crypto, crypto),
@@ -67,12 +74,27 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
67
74
 
68
75
  const { subjects: s, encodeInstant: instant, decodeInstant: readInstant } = input;
69
76
  const subjectId = { toNative: s.toNative, toSubject: s.toSubject, equals: Object.is };
77
+ const identifierTable = tables?.(table("identifiers"));
78
+
79
+ const activeIdentifier =
80
+ identifierTable === undefined
81
+ ? undefined
82
+ : sql`${identifierTable.column("active")} = ${identifierTable.value("active", true)}`;
70
83
 
71
84
  const subject = {
72
85
  table: s.table,
73
86
  id: s.id,
74
87
  status: s.status,
75
88
  securityRevision: s.securityRevision,
89
+ // Policy callbacks may read any application column. Identity, status and
90
+ // security revision have their own final checks and can be intentionally revised.
91
+ ...(tables === undefined
92
+ ? {}
93
+ : {
94
+ requirementColumns: tables(s.table).keys.filter(
95
+ (key) => key !== s.id && key !== s.status && key !== s.securityRevision,
96
+ ),
97
+ }),
76
98
  isActiveStatus: (value: unknown) => Object.is(value, s.activeValue),
77
99
  d1ActiveStatusValue: s.activeValue,
78
100
  activeStatusValue: s.activeValue,
@@ -88,6 +110,7 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
88
110
  revision: "revision",
89
111
  status: "active",
90
112
  activeStatusValue: true,
113
+ d1ActiveStatusValue: true,
91
114
  isActiveStatus: (value: unknown) => value === true,
92
115
  encodeInsert: (row: object) => ({ ...row, active: true }),
93
116
  encodeRevision: (revision: SecurityRevision) => ({ revision }),
@@ -142,6 +165,11 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
142
165
  verifiedAt: "verifiedAt",
143
166
  bindingRevision: "revision",
144
167
  isCurrent: (row) => row.active === true,
168
+ ...(activeIdentifier === undefined
169
+ ? {}
170
+ : {
171
+ d1CurrentCondition: () => activeIdentifier,
172
+ }),
145
173
  encodeInitialInsert: (identifier, subjectId, revision) => ({
146
174
  ...identifier,
147
175
  subjectId,
@@ -230,6 +258,14 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
230
258
  bindingRevision: "revision",
231
259
  isCurrent: (row) => row.active === true && row.verifiedAt !== null,
232
260
  isMutableTarget: (row) => row.active === true && row.verifiedAt === null,
261
+ ...(identifierTable === undefined || activeIdentifier === undefined
262
+ ? {}
263
+ : {
264
+ d1CurrentCondition: ({ bindingRevision }) =>
265
+ sql`${activeIdentifier} and ${identifierTable.column("verifiedAt")} is not null and ${exactSqlText(sql, identifierTable.column("revision"), identifierTable.value("revision", bindingRevision))}`,
266
+ d1MutableTargetCondition: () =>
267
+ sql`${activeIdentifier} and ${identifierTable.column("verifiedAt")} is null`,
268
+ }),
233
269
  encodeVerifiedInsert: ({ identifier, subjectId, verifiedAtMillis, bindingRevision }) => ({
234
270
  ...identifier,
235
271
  subjectId,
@@ -27,6 +27,27 @@ const spec = <C extends Readonly<Record<string, Omit<StorageTable["columns"][str
27
27
  unique: ReadonlyArray<ReadonlyArray<Extract<keyof C, string>>>,
28
28
  ) => ({ columns, unique });
29
29
 
30
+ const oauthFlow = spec(
31
+ {
32
+ moduleId: text,
33
+ flowId: text,
34
+ purpose: text,
35
+ generation: integer,
36
+ provider: text,
37
+ callbackId: text,
38
+ issuer: text,
39
+ responseIssuerMode: text,
40
+ subjectId: optionalText,
41
+ stateDigest: text,
42
+ binderVerifier: text,
43
+ binderExpiresAt: integer,
44
+ snapshot: text,
45
+ issuedAt: integer,
46
+ expiresAt: integer,
47
+ },
48
+ [["moduleId", "flowId"], ["stateDigest"]],
49
+ );
50
+
30
51
  /** Logical roles are independent of table names and migration ownership. */
31
52
  export const storageTables = {
32
53
  subjects: spec({ id: text, active: boolean, securityRevision: text }, [["id"]]),
@@ -113,6 +134,61 @@ export const storageTables = {
113
134
  },
114
135
  [["moduleId", "flowId"]],
115
136
  ),
137
+ oauthIdentities: spec(
138
+ { identityKey: text, provider: text, issuer: text, externalSubject: text, subjectId: text },
139
+ [["identityKey"]],
140
+ ),
141
+ oauthCredentials: spec(
142
+ {
143
+ moduleId: text,
144
+ credentialId: text,
145
+ subjectId: text,
146
+ identityKey: text,
147
+ credentialRevision: text,
148
+ active: boolean,
149
+ },
150
+ [["credentialId"], ["identityKey"]],
151
+ ),
152
+ oauthSignInFlows: oauthFlow,
153
+ oauthConnectedFlows: oauthFlow,
154
+ oauthConnectedGrants: spec(
155
+ {
156
+ moduleId: text,
157
+ grantId: text,
158
+ subjectId: text,
159
+ identityKey: text,
160
+ profileKey: text,
161
+ grantVersion: text,
162
+ tokenVersion: text,
163
+ state: text,
164
+ snapshot: text,
165
+ summary: text,
166
+ refreshClaimId: optionalText,
167
+ refreshNextTokenVersion: optionalText,
168
+ refreshClaimedAt: optionalInteger,
169
+ refreshClaimExpiresAt: optionalInteger,
170
+ expiresAt: integer,
171
+ },
172
+ [
173
+ ["moduleId", "grantId"],
174
+ ["moduleId", "subjectId", "profileKey", "identityKey"],
175
+ ],
176
+ ),
177
+ oauthConnectedRevocations: spec(
178
+ {
179
+ jobId: text,
180
+ moduleId: text,
181
+ subjectId: text,
182
+ identityKey: text,
183
+ snapshot: text,
184
+ state: text,
185
+ claimId: optionalText,
186
+ claimedAt: optionalInteger,
187
+ claimExpiresAt: optionalInteger,
188
+ retentionUntil: integer,
189
+ },
190
+ [["jobId"]],
191
+ ),
116
192
  proofs: spec(
117
193
  {
118
194
  moduleId: text,
@@ -2,11 +2,23 @@ import { Context, Effect, Schema } from "effect";
2
2
  import { SqlClient } from "effect/sql/SqlClient";
3
3
 
4
4
  import { PersistenceConfigurationError } from "./configuration";
5
+ import type { PhysicalTextColumn } from "./native-sql-table";
5
6
  import type { StorageTable } from "./storage-tables";
6
7
 
7
8
  const Columns = Schema.Array(Schema.String);
8
9
  const Keys = Schema.Array(Columns);
9
- const Metadata = Schema.Struct({ relation: Schema.String, columns: Columns, keys: Keys });
10
+
11
+ const TextColumns = Schema.Array(
12
+ Schema.Struct({ name: Schema.String, type: Schema.String, collation: Schema.String }),
13
+ );
14
+
15
+ const Metadata = Schema.Struct({
16
+ relation: Schema.String,
17
+ columns: Columns,
18
+ keys: Keys,
19
+ textColumns: TextColumns,
20
+ });
21
+
10
22
  const PgMetadata = Schema.Array(Metadata);
11
23
 
12
24
  const SqliteMetadata = Schema.Array(
@@ -14,10 +26,18 @@ const SqliteMetadata = Schema.Array(
14
26
  relation: Schema.String,
15
27
  columns: Schema.fromJsonString(Columns),
16
28
  keys: Schema.fromJsonString(Keys),
29
+ textColumns: Schema.fromJsonString(TextColumns),
17
30
  }),
18
31
  );
19
32
 
20
- const MysqlColumns = Schema.Array(Schema.Struct({ relation: Schema.String, name: Schema.String }));
33
+ const MysqlColumns = Schema.Array(
34
+ Schema.Struct({
35
+ relation: Schema.String,
36
+ name: Schema.String,
37
+ type: Schema.String,
38
+ collation: Schema.NullOr(Schema.String),
39
+ }),
40
+ );
21
41
 
22
42
  const MysqlIndexes = Schema.Array(
23
43
  Schema.Struct({
@@ -101,28 +121,32 @@ export interface StorageValidation {
101
121
  readonly required: ReadonlyArray<ReadonlyArray<string>>;
102
122
  }
103
123
 
104
- /** Batch physical metadata reads, reusing only the current acquisition snapshot.
105
- * Every requirement is still checked; later acquisitions read fresh metadata. */
106
- export const validateStorageBatch = Effect.fnUntraced(
107
- function* (dialect: "pg" | "mysql" | "sqlite", requirements: ReadonlyArray<StorageValidation>) {
108
- if (requirements.length === 0) return;
124
+ type Dialect = "pg" | "mysql" | "sqlite";
125
+
126
+ /** Share physical type, collation and key observations only during acquisition. */
127
+ const readStorageMetadata = Effect.fnUntraced(
128
+ function* (
129
+ dialect: Dialect,
130
+ physical: ReadonlyArray<Pick<PhysicalStorageTable, "name" | "schema">>,
131
+ ) {
109
132
  if (
110
133
  dialect === "sqlite" &&
111
- requirements.some(({ table }) => table.schema !== undefined && table.schema !== "main")
134
+ physical.some((table) => table.schema !== undefined && table.schema !== "main")
112
135
  )
113
136
  return yield* PersistenceConfigurationError.make({
114
137
  reason: "SQLite storage must belong to the main database",
115
138
  });
116
- const client = yield* SqlClient;
139
+ const captured = yield* SqlClient;
140
+ const client = captured.withoutTransforms();
117
141
  const cache = yield* CurrentMetadata;
118
142
 
119
143
  const byRelation = cache?.active
120
- ? (cache.clients.get(client) ?? new Map<string, typeof Metadata.Type>())
144
+ ? (cache.clients.get(captured) ?? new Map<string, typeof Metadata.Type>())
121
145
  : new Map<string, typeof Metadata.Type>();
122
146
 
123
- if (cache?.active) cache.clients.set(client, byRelation);
147
+ if (cache?.active) cache.clients.set(captured, byRelation);
124
148
 
125
- const tables = new Map(requirements.map(({ table }) => [qualifiedTableName(table), table]));
149
+ const tables = new Map(physical.map((table) => [qualifiedTableName(table), table]));
126
150
 
127
151
  const missing = [...tables.entries()].filter(
128
152
  ([relation]) => !byRelation.has(`${dialect}:${relation}`),
@@ -151,7 +175,11 @@ export const validateStorageBatch = Effect.fnUntraced(
151
175
  from pragma_index_list(requested.name) i
152
176
  where i."unique" = 1 and i.partial = 0
153
177
  and not exists(select 1 from pragma_index_info(i.name) where name is null)
154
- ) storage_key where json_array_length(storage_key.columns) > 0) as keys
178
+ ) storage_key where json_array_length(storage_key.columns) > 0) as keys,
179
+ (select json_group_array(json_object('name', c.name, 'type', 'TEXT', 'collation', 'BINARY'))
180
+ from pragma_table_info(requested.name) c
181
+ join sqlite_schema s on s.name = requested.name and s.type = 'table'
182
+ where upper(c.type) = 'TEXT' and instr(lower(s.sql), 'collate') = 0) as "textColumns"
155
183
  from requested
156
184
  `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(SqliteMetadata)));
157
185
  })
@@ -168,7 +196,8 @@ export const validateStorageBatch = Effect.fnUntraced(
168
196
  );
169
197
 
170
198
  const columns = yield* client`
171
- select requested.relation, c.column_name as name
199
+ select requested.relation, c.column_name as name,
200
+ c.data_type as type, c.collation_name as collation
172
201
  from (${requested}) requested join information_schema.columns c
173
202
  on c.table_schema = coalesce(requested.schemaName, database()) and c.table_name = requested.tableName
174
203
  `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(MysqlColumns)));
@@ -196,6 +225,13 @@ export const validateStorageBatch = Effect.fnUntraced(
196
225
  columns: columns
197
226
  .filter((column) => column.relation === relation)
198
227
  .map((column) => column.name),
228
+ textColumns: columns.flatMap((column) =>
229
+ column.relation === relation &&
230
+ column.collation !== null &&
231
+ ["varchar", "text", "tinytext", "mediumtext", "longtext"].includes(column.type)
232
+ ? [{ name: column.name, type: column.type, collation: column.collation }]
233
+ : [],
234
+ ),
199
235
  keys: [...grouped.values()]
200
236
  .filter((entries) =>
201
237
  entries.every((entry) => entry.columnName !== null && entry.prefix === null),
@@ -229,7 +265,12 @@ export const validateStorageBatch = Effect.fnUntraced(
229
265
  and i.indpred is null and i.indexprs is null
230
266
  and k.ordinality <= i.indnkeyatts
231
267
  group by i.indexrelid
232
- ) storage_key), '[]'::jsonb) as keys
268
+ ) storage_key), '[]'::jsonb) as keys,
269
+ coalesce((select jsonb_agg(jsonb_build_object(
270
+ 'name', a.attname::text, 'type', a.atttypid::text, 'collation', a.attcollation::text))
271
+ from pg_attribute a where a.attrelid = to_regclass(requested.relation)
272
+ and a.attnum > 0 and not a.attisdropped
273
+ and a.atttypid in ('text'::regtype, 'varchar'::regtype)), '[]'::jsonb) as "textColumns"
233
274
  from (values ${requested}) requested(relation)
234
275
  `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgMetadata)));
235
276
  });
@@ -237,18 +278,7 @@ export const validateStorageBatch = Effect.fnUntraced(
237
278
  for (const row of metadata) byRelation.set(`${dialect}:${row.relation}`, row);
238
279
  }
239
280
 
240
- for (const { table, required } of requirements) {
241
- const relation = qualifiedTableName(table);
242
- const actual = byRelation.get(`${dialect}:${relation}`);
243
-
244
- for (const column of Object.values(table.columns)) {
245
- if (actual === undefined || !actual.columns.includes(column.name))
246
- return yield* PersistenceConfigurationError.make({
247
- reason: `Missing SQL column ${relation}.${column.name}`,
248
- });
249
- }
250
- yield* validateKeys(table, required, actual?.keys ?? []);
251
- }
281
+ return byRelation;
252
282
  },
253
283
  Effect.mapError((error) =>
254
284
  Schema.is(PersistenceConfigurationError)(error)
@@ -257,6 +287,76 @@ export const validateStorageBatch = Effect.fnUntraced(
257
287
  ),
258
288
  );
259
289
 
290
+ /** Batch physical metadata reads, reusing only the current acquisition snapshot.
291
+ * Every requirement is still checked; later acquisitions read fresh metadata. */
292
+ export const validateStorageBatch = Effect.fnUntraced(function* (
293
+ dialect: Dialect,
294
+ requirements: ReadonlyArray<StorageValidation>,
295
+ ) {
296
+ if (requirements.length === 0) return;
297
+
298
+ const byRelation = yield* readStorageMetadata(
299
+ dialect,
300
+ requirements.map(({ table }) => table),
301
+ );
302
+
303
+ for (const { table, required } of requirements) {
304
+ const relation = qualifiedTableName(table);
305
+ const actual = byRelation.get(`${dialect}:${relation}`);
306
+
307
+ for (const column of Object.values(table.columns)) {
308
+ if (actual === undefined || !actual.columns.includes(column.name))
309
+ return yield* PersistenceConfigurationError.make({
310
+ reason: `Missing SQL column ${relation}.${column.name}`,
311
+ });
312
+ }
313
+ yield* validateKeys(table, required, actual?.keys ?? []);
314
+ }
315
+ });
316
+
317
+ /** Candidate declarations do not establish SQL equality compatibility. SQLite
318
+ * omits column collations, so only TEXT tables without COLLATE qualify. */
319
+ export const canJoinTextColumns = Effect.fnUntraced(function* (
320
+ columns: ReadonlyArray<PhysicalTextColumn | undefined>,
321
+ ) {
322
+ const candidates = columns.filter((column) => column !== undefined);
323
+
324
+ if (candidates.length !== columns.length || candidates.length === 0) return false;
325
+ const client = yield* SqlClient;
326
+
327
+ const dialect = client.onDialectOrElse({
328
+ pg: () => "pg" as const,
329
+ mysql: () => "mysql" as const,
330
+ sqlite: () => "sqlite" as const,
331
+ orElse: () => undefined,
332
+ });
333
+
334
+ if (
335
+ dialect === undefined ||
336
+ (dialect === "sqlite" &&
337
+ candidates.some(({ table }) => table.schema !== undefined && table.schema !== "main"))
338
+ )
339
+ return false;
340
+
341
+ const metadata = yield* readStorageMetadata(
342
+ dialect,
343
+ candidates.map(({ table }) => table),
344
+ );
345
+
346
+ const physical = candidates.map(({ table, name }) =>
347
+ metadata
348
+ .get(`${dialect}:${qualifiedTableName(table)}`)
349
+ ?.textColumns.find((column) => column.name === name),
350
+ );
351
+
352
+ const first = physical[0];
353
+
354
+ return (
355
+ first !== undefined &&
356
+ physical.every((column) => column?.type === first.type && column.collation === first.collation)
357
+ );
358
+ });
359
+
260
360
  /** Validate physical columns and unconditional uniqueness before operations run. */
261
361
  export const validateStorage = (
262
362
  dialect: "pg" | "mysql" | "sqlite",