@yielded/auth-persistence 0.1.0-beta.31 → 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 (113) hide show
  1. package/README.md +9 -2
  2. package/dist/Adapter.mjs +8 -8
  3. package/dist/OAuthPersistence.mjs +10 -5
  4. package/dist/OAuthPersistence.mjs.map +1 -1
  5. package/dist/internal/configuration.d.mts +31 -5
  6. package/dist/internal/configuration.mjs.map +1 -1
  7. package/dist/internal/email-native.mjs +10 -2
  8. package/dist/internal/email-native.mjs.map +1 -1
  9. package/dist/internal/email-policy.mjs +3 -2
  10. package/dist/internal/email-policy.mjs.map +1 -1
  11. package/dist/internal/models/oauth-model.d.mts +4 -2
  12. package/dist/internal/models/oauth-model.mjs.map +1 -1
  13. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  14. package/dist/internal/oauth/native-accounts.mjs +6 -6
  15. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  16. package/dist/internal/oauth/native-connected.d.mts +1 -1
  17. package/dist/internal/oauth/native-connected.mjs +1 -1
  18. package/dist/internal/oauth/native-registration.mjs +19 -8
  19. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  20. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  21. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  22. package/dist/internal/oauth/native-state.mjs +21 -11
  23. package/dist/internal/oauth/native-state.mjs.map +1 -1
  24. package/dist/internal/oauth/state.d.mts +1 -1
  25. package/dist/internal/oauth/state.mjs +1 -1
  26. package/dist/internal/oauth-managed.mjs +182 -0
  27. package/dist/internal/oauth-managed.mjs.map +1 -0
  28. package/dist/internal/passkey-managed.mjs +1 -1
  29. package/dist/internal/passkey-native-registration.mjs +1 -1
  30. package/dist/internal/passkey-native-state.mjs +33 -23
  31. package/dist/internal/passkey-native-state.mjs.map +1 -1
  32. package/dist/internal/passkey-native-write.mjs +3 -3
  33. package/dist/internal/passkey-native-write.mjs.map +1 -1
  34. package/dist/internal/passkey-native.d.mts +15 -7
  35. package/dist/internal/passkey-native.mjs +2 -1
  36. package/dist/internal/passkey-native.mjs.map +1 -1
  37. package/dist/internal/passkey-policy.mjs +9 -7
  38. package/dist/internal/passkey-policy.mjs.map +1 -1
  39. package/dist/internal/password-native.mjs +9 -6
  40. package/dist/internal/password-native.mjs.map +1 -1
  41. package/dist/internal/password-policy.mjs +7 -6
  42. package/dist/internal/password-policy.mjs.map +1 -1
  43. package/dist/internal/persistence.mjs +96 -37
  44. package/dist/internal/persistence.mjs.map +1 -1
  45. package/dist/internal/phone-native.mjs +1 -1
  46. package/dist/internal/phone-native.mjs.map +1 -1
  47. package/dist/internal/session-native-authority.mjs +6 -5
  48. package/dist/internal/session-native-authority.mjs.map +1 -1
  49. package/dist/internal/session-native-login.d.mts +2 -2
  50. package/dist/internal/session-native-login.mjs +2 -1
  51. package/dist/internal/session-native-login.mjs.map +1 -1
  52. package/dist/internal/session-native-pending-read.mjs +26 -20
  53. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  54. package/dist/internal/session-native-record.mjs +6 -4
  55. package/dist/internal/session-native-record.mjs.map +1 -1
  56. package/dist/internal/session-native-state.mjs +3 -0
  57. package/dist/internal/session-native-state.mjs.map +1 -1
  58. package/dist/internal/session-native-stateful.d.mts +2 -2
  59. package/dist/internal/session-native-stateful.mjs +65 -37
  60. package/dist/internal/session-native-stateful.mjs.map +1 -1
  61. package/dist/internal/session-native-step-up.d.mts +2 -2
  62. package/dist/internal/session-native-step-up.mjs +14 -18
  63. package/dist/internal/session-native-step-up.mjs.map +1 -1
  64. package/dist/internal/session-native-validity.mjs +3 -1
  65. package/dist/internal/session-native-validity.mjs.map +1 -1
  66. package/dist/internal/sql-authority.mjs +5 -1
  67. package/dist/internal/sql-authority.mjs.map +1 -1
  68. package/dist/internal/sql-storage-validation.mjs +2 -1
  69. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  70. package/dist/internal/sql-table.d.mts +1 -0
  71. package/dist/internal/sql-table.mjs.map +1 -1
  72. package/dist/internal/step-up-state.mjs +19 -6
  73. package/dist/internal/step-up-state.mjs.map +1 -1
  74. package/dist/internal/storage-mapping.mjs +1 -1
  75. package/dist/internal/storage-tables.d.mts +237 -0
  76. package/dist/internal/storage-tables.mjs +68 -0
  77. package/dist/internal/storage-tables.mjs.map +1 -1
  78. package/dist/internal/storage-validation.d.mts +3 -1
  79. package/dist/internal/storage-validation.mjs +70 -17
  80. package/dist/internal/storage-validation.mjs.map +1 -1
  81. package/dist/internal/totp-native.mjs +1 -1
  82. package/package.json +1 -1
  83. package/src/OAuthPersistence.ts +7 -2
  84. package/src/internal/configuration.ts +46 -7
  85. package/src/internal/email-native.ts +16 -6
  86. package/src/internal/email-policy.ts +12 -3
  87. package/src/internal/models/oauth-model.ts +7 -1
  88. package/src/internal/oauth/native-accounts.ts +3 -3
  89. package/src/internal/oauth/native-registration.ts +17 -6
  90. package/src/internal/oauth/native-state.ts +38 -18
  91. package/src/internal/oauth-managed.ts +277 -0
  92. package/src/internal/passkey-native-state.ts +59 -52
  93. package/src/internal/passkey-native-write.ts +2 -2
  94. package/src/internal/passkey-native.ts +4 -2
  95. package/src/internal/passkey-policy.ts +13 -9
  96. package/src/internal/password-native.ts +17 -9
  97. package/src/internal/password-policy.ts +16 -8
  98. package/src/internal/persistence.ts +216 -70
  99. package/src/internal/phone-native.ts +1 -1
  100. package/src/internal/session-native-authority.ts +23 -7
  101. package/src/internal/session-native-login.ts +6 -3
  102. package/src/internal/session-native-pending-read.ts +46 -33
  103. package/src/internal/session-native-record.ts +6 -4
  104. package/src/internal/session-native-state.ts +3 -0
  105. package/src/internal/session-native-stateful.ts +144 -51
  106. package/src/internal/session-native-step-up.ts +27 -23
  107. package/src/internal/session-native-validity.ts +3 -1
  108. package/src/internal/sql-authority.ts +5 -1
  109. package/src/internal/sql-storage-validation.ts +53 -46
  110. package/src/internal/sql-table.ts +1 -0
  111. package/src/internal/step-up-state.ts +43 -6
  112. package/src/internal/storage-tables.ts +76 -0
  113. package/src/internal/storage-validation.ts +127 -27
@@ -1,3 +1,4 @@
1
+ import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
1
2
  import { Effect } from "effect";
2
3
  import { SqlClient } from "effect/sql";
3
4
 
@@ -7,57 +8,63 @@ import { storageKeyPlans } from "./storage-plans";
7
8
  import { validateStorageBatch, type StorageValidation } from "./storage-validation";
8
9
 
9
10
  /** Check declared mappings against physical keys before acquiring OAuth services. */
10
- export const validateSqlStorage = Effect.fnUntraced(function* (mapping: unknown) {
11
- const plans = yield* Effect.try({
12
- try: () =>
13
- storageKeyPlans(mapping, (table) => {
14
- if (!(table instanceof Table)) throw new Error("Expected a SQL table");
11
+ export const validateSqlStorage = Effect.fnUntraced(
12
+ function* (mapping: unknown) {
13
+ const plans = yield* Effect.try({
14
+ try: () =>
15
+ storageKeyPlans(mapping, (table) => {
16
+ if (!(table instanceof Table)) throw new Error("Expected a SQL table");
15
17
 
16
- return table.columns;
17
- }),
18
- catch: () =>
19
- PersistenceMappingError.make({ operation: "mapping", cause: "Invalid storage key mapping" }),
20
- });
21
-
22
- const client = yield* SqlClient.SqlClient;
18
+ return table.columns;
19
+ }),
20
+ catch: () =>
21
+ PersistenceMappingError.make({
22
+ operation: "mapping",
23
+ cause: "Invalid storage key mapping",
24
+ }),
25
+ });
23
26
 
24
- const dialect = client.onDialectOrElse({
25
- pg: () => "pg" as const,
26
- sqlite: () => "sqlite" as const,
27
- orElse: () => undefined,
28
- });
27
+ const client = yield* SqlClient.SqlClient;
29
28
 
30
- if (dialect === undefined)
31
- return yield* PersistenceMappingError.make({
32
- operation: "mapping",
33
- cause: "OAuth persistence requires PostgreSQL or SQLite",
29
+ const dialect = client.onDialectOrElse({
30
+ pg: () => "pg" as const,
31
+ sqlite: () => "sqlite" as const,
32
+ orElse: () => undefined,
34
33
  });
35
- const validations: StorageValidation[] = [];
36
34
 
37
- for (const plan of plans) {
38
- if (!(plan.table instanceof Table))
35
+ if (dialect === undefined)
39
36
  return yield* PersistenceMappingError.make({
40
37
  operation: "mapping",
41
- cause: "Expected a SQL table",
38
+ cause: "OAuth persistence requires PostgreSQL or SQLite",
42
39
  });
43
- validations.push({
44
- table: {
45
- name: plan.table.name,
46
- ...(plan.table.schema === undefined ? {} : { schema: plan.table.schema }),
47
- columns: Object.fromEntries(
48
- Object.entries(plan.table.columns).map(([key, column]) => [key, column.options]),
49
- ),
50
- },
51
- required: [plan.keys],
52
- });
53
- }
54
- yield* validateStorageBatch(dialect, validations).pipe(
55
- Effect.provideService(SqlClient.SqlClient, client),
56
- Effect.mapError(() =>
57
- PersistenceMappingError.make({
58
- operation: "mapping",
59
- cause: "Required SQL storage key is unavailable",
60
- }),
61
- ),
62
- );
63
- });
40
+ const validations: StorageValidation[] = [];
41
+
42
+ for (const plan of plans) {
43
+ if (!(plan.table instanceof Table))
44
+ return yield* PersistenceMappingError.make({
45
+ operation: "mapping",
46
+ cause: "Expected a SQL table",
47
+ });
48
+ validations.push({
49
+ table: {
50
+ name: plan.table.name,
51
+ ...(plan.table.schema === undefined ? {} : { schema: plan.table.schema }),
52
+ columns: Object.fromEntries(
53
+ Object.entries(plan.table.columns).map(([key, column]) => [key, column.options]),
54
+ ),
55
+ },
56
+ required: [plan.keys],
57
+ });
58
+ }
59
+ yield* validateStorageBatch(dialect, validations).pipe(
60
+ Effect.provideService(SqlClient.SqlClient, client),
61
+ Effect.mapError(() =>
62
+ PersistenceMappingError.make({
63
+ operation: "mapping",
64
+ cause: "Required SQL storage key is unavailable",
65
+ }),
66
+ ),
67
+ );
68
+ },
69
+ Effect.tapError(() => reportAuthDiagnostic("persistence-validation", "mapping")),
70
+ );
@@ -22,6 +22,7 @@ export const identifier = (name: string) => '"' + name.replaceAll('"', '""') + '
22
22
 
23
23
  export interface ColumnOptions {
24
24
  readonly name: string;
25
+ /** Driver value representation, not the database column's physical SQL type. */
25
26
  readonly type: "text" | "integer" | "boolean";
26
27
  readonly nullable?: boolean;
27
28
  }
@@ -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.
@@ -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",