@yielded/auth-persistence 0.1.0-beta.17 → 0.1.0-beta.19

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 (85) hide show
  1. package/README.md +5 -2
  2. package/dist/Adapter.d.mts +2 -2
  3. package/dist/Adapter.mjs +2 -2
  4. package/dist/internal/configuration.d.mts +5 -4
  5. package/dist/internal/configuration.mjs.map +1 -1
  6. package/dist/internal/crypto.mjs +12 -0
  7. package/dist/internal/crypto.mjs.map +1 -0
  8. package/dist/internal/email-kernel.d.mts +7 -7
  9. package/dist/internal/passkey/admission.mjs +19 -15
  10. package/dist/internal/passkey/admission.mjs.map +1 -1
  11. package/dist/internal/passkey/credentials.mjs +4 -4
  12. package/dist/internal/passkey/credentials.mjs.map +1 -1
  13. package/dist/internal/passkey/enrollment.mjs +6 -6
  14. package/dist/internal/passkey/enrollment.mjs.map +1 -1
  15. package/dist/internal/passkey/flow.mjs +7 -7
  16. package/dist/internal/passkey/flow.mjs.map +1 -1
  17. package/dist/internal/passkey/management.mjs +2 -2
  18. package/dist/internal/passkey/management.mjs.map +1 -1
  19. package/dist/internal/passkey/registration-ceremony.mjs +2 -2
  20. package/dist/internal/passkey/registration-ceremony.mjs.map +1 -1
  21. package/dist/internal/passkey/registration-custody.mjs +1 -1
  22. package/dist/internal/passkey/registration-custody.mjs.map +1 -1
  23. package/dist/internal/passkey/registration-write.mjs +12 -12
  24. package/dist/internal/passkey/registration-write.mjs.map +1 -1
  25. package/dist/internal/passkey/state.d.mts +1 -1
  26. package/dist/internal/passkey/state.mjs +8 -9
  27. package/dist/internal/passkey/state.mjs.map +1 -1
  28. package/dist/internal/passkey/target.d.mts +1 -1
  29. package/dist/internal/passkey/target.mjs.map +1 -1
  30. package/dist/internal/passkey/write-state.mjs +13 -13
  31. package/dist/internal/passkey/write-state.mjs.map +1 -1
  32. package/dist/internal/passkey/write-target.mjs.map +1 -1
  33. package/dist/internal/passkey-kernel.d.mts +705 -249
  34. package/dist/internal/passkeys.mjs +32 -14
  35. package/dist/internal/passkeys.mjs.map +1 -1
  36. package/dist/internal/password-kernel.d.mts +6 -6
  37. package/dist/internal/password-kernel.mjs +1 -1
  38. package/dist/internal/password-kernel.mjs.map +1 -1
  39. package/dist/internal/persistence.d.mts +2 -2
  40. package/dist/internal/persistence.mjs +18 -9
  41. package/dist/internal/persistence.mjs.map +1 -1
  42. package/dist/internal/phone-kernel.d.mts +7 -7
  43. package/dist/internal/phone-kernel.mjs +14 -14
  44. package/dist/internal/phone-kernel.mjs.map +1 -1
  45. package/dist/internal/registration.mjs +8 -7
  46. package/dist/internal/registration.mjs.map +1 -1
  47. package/dist/internal/session-kernel.mjs +2 -1
  48. package/dist/internal/session-kernel.mjs.map +1 -1
  49. package/dist/internal/sql-query.mjs +1 -1
  50. package/dist/internal/sql-query.mjs.map +1 -1
  51. package/dist/internal/storage-mapping.d.mts +5 -4
  52. package/dist/internal/storage-mapping.mjs +16 -13
  53. package/dist/internal/storage-mapping.mjs.map +1 -1
  54. package/dist/internal/storage-validation.d.mts +13 -3
  55. package/dist/internal/storage-validation.mjs +128 -68
  56. package/dist/internal/storage-validation.mjs.map +1 -1
  57. package/dist/internal/transaction-execution-kernel.d.mts +4 -4
  58. package/dist/internal/transaction-execution-kernel.mjs +7 -5
  59. package/dist/internal/transaction-execution-kernel.mjs.map +1 -1
  60. package/package.json +1 -1
  61. package/src/Adapter.ts +7 -1
  62. package/src/internal/configuration.ts +5 -3
  63. package/src/internal/crypto.ts +15 -0
  64. package/src/internal/passkey/admission.ts +22 -15
  65. package/src/internal/passkey/credentials.ts +4 -4
  66. package/src/internal/passkey/enrollment.ts +7 -7
  67. package/src/internal/passkey/flow.ts +8 -8
  68. package/src/internal/passkey/management.ts +7 -2
  69. package/src/internal/passkey/registration-ceremony.ts +2 -2
  70. package/src/internal/passkey/registration-custody.ts +1 -1
  71. package/src/internal/passkey/registration-write.ts +12 -12
  72. package/src/internal/passkey/state.ts +10 -13
  73. package/src/internal/passkey/target.ts +16 -8
  74. package/src/internal/passkey/write-state.ts +10 -14
  75. package/src/internal/passkey/write-target.ts +11 -3
  76. package/src/internal/passkeys.ts +121 -70
  77. package/src/internal/password-kernel.ts +2 -2
  78. package/src/internal/persistence.ts +30 -13
  79. package/src/internal/phone-kernel.ts +26 -17
  80. package/src/internal/registration.ts +10 -8
  81. package/src/internal/session-kernel.ts +3 -2
  82. package/src/internal/sql-query.ts +3 -2
  83. package/src/internal/storage-mapping.ts +21 -13
  84. package/src/internal/storage-validation.ts +219 -139
  85. package/src/internal/transaction-execution-kernel.ts +30 -11
@@ -1,4 +1,3 @@
1
- import { randomId } from "@yielded/auth-crypto";
2
1
  import { EmailCredentialSnapshot } from "@yielded/auth/Email";
3
2
  import { LoginIdentifier } from "@yielded/auth/Identity";
4
3
  import {
@@ -26,9 +25,10 @@ import {
26
25
  SessionCredentialVersion,
27
26
  type StatefulSessionRecord,
28
27
  } from "@yielded/auth/Sessions";
29
- import { DateTime, Effect, Redacted, Schema } from "effect";
28
+ import { Crypto, DateTime, Effect, Redacted, Schema } from "effect";
30
29
 
31
30
  import type { MappingInput } from "./configuration";
31
+ import { randomId } from "./crypto";
32
32
  import { PersistenceMappingError } from "./mapping-error";
33
33
  import { requiredEmailAddressConstraints, type AnyEmailAddressMapping } from "./models/email-model";
34
34
  import {
@@ -77,11 +77,19 @@ const ProofContinuation = Schema.Struct({
77
77
  });
78
78
 
79
79
  /** Derive shared row mappings from a managed or custom storage layout.
80
+ * Acquire Effect Crypto once; the returned allocators retain that implementation.
80
81
  * Each factory requires its role tables to exist. Adapter authors supply typed
81
82
  * table handles and dialect-specific clocks/commit predicates, and may refine
82
83
  * authority policy for explicit services. This does not alter composed defaults.
83
84
  */
84
- export const makeMappings = (input: MappingInput) => {
85
+ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
86
+ const crypto = yield* Crypto.Crypto;
87
+
88
+ const allocate = randomId.pipe(
89
+ Effect.provideService(Crypto.Crypto, crypto),
90
+ Effect.mapError(failure),
91
+ );
92
+
85
93
  const table = (role: StorageRole) => {
86
94
  const found = input.tables[role];
87
95
 
@@ -107,7 +115,7 @@ export const makeMappings = (input: MappingInput) => {
107
115
  d1ActiveStatusValue: s.activeValue,
108
116
  decodeRequirement: s.requirements,
109
117
  decodeActionRequirement: s.actionRequirements,
110
- nextSecurityRevisionSync: () => SecurityRevision.make(randomId()),
118
+ nextSecurityRevision: () => allocate.pipe(Effect.map(SecurityRevision.make)),
111
119
  };
112
120
 
113
121
  const authorityCredential = () => ({
@@ -127,7 +135,7 @@ export const makeMappings = (input: MappingInput) => {
127
135
  constraints: requiredProofConstraints,
128
136
  encodeInstant: instant,
129
137
  decodeInstant: readInstant,
130
- allocateVersionSync: () => ProofVersion.make(randomId()),
138
+ allocateVersion: allocate.pipe(Effect.map(ProofVersion.make)),
131
139
  isRequestConflict: () => false,
132
140
  isSeriesConflict: () => false,
133
141
  isCommandConflict: () => false,
@@ -373,9 +381,9 @@ export const makeMappings = (input: MappingInput) => {
373
381
  }),
374
382
  encodeInstant: instant,
375
383
  decodeInstant: readInstant,
376
- allocateAttemptIdSync: () => PasswordAttemptId.make(randomId()),
377
- allocateCredentialIdSync: randomId,
378
- allocateRevisionSync: () => SecurityRevision.make(randomId()),
384
+ allocateAttemptId: allocate.pipe(Effect.map(PasswordAttemptId.make)),
385
+ allocateCredentialId: allocate,
386
+ allocateRevision: allocate.pipe(Effect.map(SecurityRevision.make)),
379
387
  commandRetentionMillis: 86_400_000,
380
388
  sessionInvalidation: "same-authority-immediate",
381
389
  identifier: {
@@ -521,8 +529,8 @@ export const makeMappings = (input: MappingInput) => {
521
529
  changeDisposition: "retire-source",
522
530
  encodeInstant: instant,
523
531
  decodeInstant: readInstant,
524
- allocateCredentialIdSync: randomId,
525
- allocateRevisionSync: () => SecurityRevision.make(randomId()),
532
+ allocateCredentialId: allocate,
533
+ allocateRevision: allocate.pipe(Effect.map(SecurityRevision.make)),
526
534
  commandRetentionMillis: 86_400_000,
527
535
  sessionInvalidation: "same-authority-immediate",
528
536
  isCommandConflict: () => false,
@@ -694,8 +702,8 @@ export const makeMappings = (input: MappingInput) => {
694
702
  expiresAt: "expiresAt",
695
703
  absoluteExpiresAt: "absoluteExpiresAt",
696
704
  encodeInstant: (date) => instant(DateTime.toEpochMillis(date)),
697
- allocateIdSync: randomId,
698
- allocateVersionSync: () => SecurityRevision.make(randomId()),
705
+ allocateId: allocate,
706
+ allocateVersion: allocate.pipe(Effect.map(SecurityRevision.make)),
699
707
  encodeInsert: (value, ids) => ({ ...encodeRow(value), ...ids }),
700
708
  encodeRotation: encodeRow,
701
709
  decode: (row) =>
@@ -710,4 +718,4 @@ export const makeMappings = (input: MappingInput) => {
710
718
  };
711
719
 
712
720
  return { table, authority, proofs, passwords, emails, sessions, string };
713
- };
721
+ });
@@ -1,14 +1,33 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Context, Effect, Schema } from "effect";
2
2
  import { SqlClient } from "effect/sql/SqlClient";
3
3
 
4
4
  import { PersistenceConfigurationError } from "./configuration";
5
5
  import type { StorageTable } from "./storage-tables";
6
6
 
7
- const Column = Schema.Struct({ name: Schema.String, pk: Schema.Int });
8
- const Index = Schema.Struct({ name: Schema.String, unique: Schema.Int, partial: Schema.Int });
9
- const IndexColumn = Schema.Struct({ name: Schema.NullOr(Schema.String) });
10
- const PgColumns = Schema.Array(Schema.Struct({ name: Schema.String }));
11
- const PgKeys = Schema.Array(Schema.Struct({ columns: Schema.Array(Schema.String) }));
7
+ const Columns = Schema.Array(Schema.String);
8
+ const Keys = Schema.Array(Columns);
9
+ const Metadata = Schema.Struct({ relation: Schema.String, columns: Columns, keys: Keys });
10
+ const PgMetadata = Schema.Array(Metadata);
11
+
12
+ const SqliteMetadata = Schema.Array(
13
+ Schema.Struct({
14
+ relation: Schema.String,
15
+ columns: Schema.fromJsonString(Columns),
16
+ keys: Schema.fromJsonString(Keys),
17
+ }),
18
+ );
19
+
20
+ const MysqlColumns = Schema.Array(Schema.Struct({ relation: Schema.String, name: Schema.String }));
21
+
22
+ const MysqlIndexes = Schema.Array(
23
+ Schema.Struct({
24
+ relation: Schema.String,
25
+ name: Schema.String,
26
+ columnName: Schema.NullOr(Schema.String),
27
+ prefix: Schema.NullOr(Schema.Number),
28
+ }),
29
+ );
30
+
12
31
  const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
13
32
 
14
33
  /** Physical metadata needed for catalog validation; value codecs remain adapter-owned. */
@@ -21,165 +40,226 @@ export interface PhysicalStorageTable {
21
40
  export const qualifiedTableName = (table: Pick<StorageTable, "name" | "schema">) =>
22
41
  (table.schema === undefined ? "" : quote(table.schema) + ".") + quote(table.name);
23
42
 
24
- /** Validate physical uniqueness before the first authentication operation. A
25
- * partial or expression index cannot establish these unconditional guarantees. */
26
- export const validateStorage = Effect.fnUntraced(
27
- function* (
28
- dialect: "pg" | "mysql" | "sqlite",
29
- table: PhysicalStorageTable,
30
- required: ReadonlyArray<ReadonlyArray<string>>,
31
- ) {
32
- const client = yield* SqlClient;
43
+ const validateKeys = Effect.fnUntraced(function* (
44
+ table: PhysicalStorageTable,
45
+ required: ReadonlyArray<ReadonlyArray<string>>,
46
+ physicalKeys: ReadonlyArray<ReadonlyArray<string>>,
47
+ ) {
48
+ for (const keys of required) {
49
+ const columns = keys.map((key) => table.columns[key]?.name);
33
50
 
34
- const physicalKeys: ReadonlyArray<ReadonlyArray<string>> = yield* dialect === "sqlite"
35
- ? Effect.gen(function* () {
36
- if (table.schema !== undefined && table.schema !== "main")
37
- return yield* PersistenceConfigurationError.make({
38
- reason: "SQLite storage must belong to the main database",
39
- });
51
+ if (
52
+ columns.some((column) => column === undefined) ||
53
+ !physicalKeys.some(
54
+ // A stronger key also guarantees the required tuple's uniqueness.
55
+ (actual) =>
56
+ actual.length > 0 &&
57
+ actual.length <= columns.length &&
58
+ actual.every((column) => columns.includes(column)),
59
+ )
60
+ )
61
+ return yield* PersistenceConfigurationError.make({
62
+ reason: `Missing unique key on ${table.name} (${keys.join(", ")})`,
63
+ });
64
+ }
65
+ });
40
66
 
41
- const columns = yield* client`select name, pk from pragma_table_info(${table.name})`.pipe(
42
- Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Column))),
43
- );
67
+ interface MetadataCache {
68
+ active: boolean;
69
+ readonly clients: Map<SqlClient, Map<string, typeof Metadata.Type>>;
70
+ }
44
71
 
45
- for (const column of Object.values(table.columns)) {
46
- if (!columns.some((actual) => actual.name === column.name))
47
- return yield* PersistenceConfigurationError.make({
48
- reason: `Missing SQL column ${table.name}.${column.name}`,
49
- });
50
- }
51
- const keys: string[][] = [];
52
- const primary = columns.filter((column) => column.pk > 0).sort((a, b) => a.pk - b.pk);
72
+ const CurrentMetadata = Context.Reference<MetadataCache | undefined>(
73
+ "@yielded/auth-persistence/StorageMetadata",
74
+ { defaultValue: () => undefined },
75
+ );
53
76
 
54
- if (primary.length > 0) keys.push(primary.map((column) => column.name));
77
+ /** Share metadata only while constructing one service graph. Captured contexts
78
+ * cannot reuse it after acquisition succeeds, fails, or is interrupted. */
79
+ export const withStorageValidation = Effect.fnUntraced(function* <A, E, R>(
80
+ effect: Effect.Effect<A, E, R>,
81
+ ): Effect.fn.Return<A, E, R> {
82
+ const parent = yield* CurrentMetadata;
55
83
 
56
- const indexes =
57
- yield* client`select name, "unique", partial from pragma_index_list(${table.name})`.pipe(
58
- Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Index))),
59
- );
84
+ if (parent?.active) return yield* effect;
85
+ const cache: MetadataCache = { active: true, clients: new Map() };
60
86
 
61
- for (const index of indexes) {
62
- if (index.unique !== 1 || index.partial !== 0) continue;
87
+ return yield* effect.pipe(
88
+ Effect.provideService(CurrentMetadata, cache),
89
+ Effect.ensuring(
90
+ Effect.sync(() => {
91
+ cache.active = false;
92
+ cache.clients.clear();
93
+ }),
94
+ ),
95
+ );
96
+ });
63
97
 
64
- const columns = yield* client`select name from pragma_index_info(${index.name})`.pipe(
65
- Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(IndexColumn))),
66
- );
98
+ /** Requirements checked against one physical catalog snapshot. */
99
+ export interface StorageValidation {
100
+ readonly table: PhysicalStorageTable;
101
+ readonly required: ReadonlyArray<ReadonlyArray<string>>;
102
+ }
67
103
 
68
- if (columns.every((column) => column.name !== null))
69
- keys.push(columns.flatMap((column) => (column.name === null ? [] : [column.name])));
70
- }
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;
109
+ if (
110
+ dialect === "sqlite" &&
111
+ requirements.some(({ table }) => table.schema !== undefined && table.schema !== "main")
112
+ )
113
+ return yield* PersistenceConfigurationError.make({
114
+ reason: "SQLite storage must belong to the main database",
115
+ });
116
+ const client = yield* SqlClient;
117
+ const cache = yield* CurrentMetadata;
71
118
 
72
- return keys;
73
- })
74
- : dialect === "mysql"
75
- ? Effect.gen(function* () {
76
- const columns = yield* client`
77
- select column_name as name from information_schema.columns
78
- where table_schema = coalesce(${table.schema ?? null}, database())
79
- and table_name = ${table.name}
80
- `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgColumns)));
81
-
82
- for (const column of Object.values(table.columns)) {
83
- if (!columns.some((actual) => actual.name === column.name))
84
- return yield* PersistenceConfigurationError.make({
85
- reason: `Missing SQL column ${table.name}.${column.name}`,
86
- });
87
- }
88
-
89
- const indexes = yield* client`
90
- select index_name as name, column_name as columnName, sub_part as prefix
91
- from information_schema.statistics
92
- where table_schema = coalesce(${table.schema ?? null}, database())
93
- and table_name = ${table.name} and non_unique = 0
94
- order by index_name, seq_in_index
95
- `.pipe(
96
- Effect.flatMap(
97
- Schema.decodeUnknownEffect(
98
- Schema.Array(
99
- Schema.Struct({
100
- name: Schema.String,
101
- columnName: Schema.NullOr(Schema.String),
102
- prefix: Schema.NullOr(Schema.Number),
103
- }),
104
- ),
105
- ),
106
- ),
107
- );
119
+ const byRelation = cache?.active
120
+ ? (cache.clients.get(client) ?? new Map<string, typeof Metadata.Type>())
121
+ : new Map<string, typeof Metadata.Type>();
122
+
123
+ if (cache?.active) cache.clients.set(client, byRelation);
108
124
 
109
- const keys = new Map<string, Array<(typeof indexes)[number]>>();
125
+ const tables = new Map(requirements.map(({ table }) => [qualifiedTableName(table), table]));
110
126
 
111
- for (const index of indexes) {
112
- const columns = keys.get(index.name) ?? [];
127
+ const missing = [...tables.entries()].filter(
128
+ ([relation]) => !byRelation.has(`${dialect}:${relation}`),
129
+ );
113
130
 
114
- columns.push(index);
115
- keys.set(index.name, columns);
116
- }
131
+ const relations = missing.map(([relation]) => relation);
117
132
 
118
- return [...keys.values()]
119
- .filter((columns) =>
120
- columns.every((column) => column.columnName !== null && column.prefix === null),
133
+ if (relations.length > 0) {
134
+ const metadata = yield* dialect === "sqlite"
135
+ ? Effect.gen(function* () {
136
+ const requested = client.join(
137
+ ", ",
138
+ false,
139
+ )(missing.map(([relation, table]) => client`(${relation}, ${table.name})`));
140
+
141
+ return yield* client`
142
+ with requested(relation, name) as (values ${requested})
143
+ select requested.relation,
144
+ (select json_group_array(name) from pragma_table_info(requested.name)) as columns,
145
+ (select json_group_array(json(storage_key.columns)) from (
146
+ select json_group_array(name) as columns from (
147
+ select name from pragma_table_info(requested.name) where pk > 0 order by pk
121
148
  )
122
- .map((columns) =>
123
- columns.flatMap((column) =>
124
- column.columnName === null ? [] : [column.columnName],
149
+ union all
150
+ select (select json_group_array(name) from pragma_index_info(i.name)) as columns
151
+ from pragma_index_list(requested.name) i
152
+ where i."unique" = 1 and i.partial = 0
153
+ 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
155
+ from requested
156
+ `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(SqliteMetadata)));
157
+ })
158
+ : dialect === "mysql"
159
+ ? Effect.gen(function* () {
160
+ const requested = client.join(
161
+ " union all ",
162
+ false,
163
+ )(
164
+ missing.map(
165
+ ([relation, table]) =>
166
+ client`select ${relation} as relation, ${table.schema ?? null} as schemaName, ${table.name} as tableName`,
125
167
  ),
126
168
  );
127
- })
128
- : Effect.gen(function* () {
129
- const relation = qualifiedTableName(table);
130
169
 
131
- const columns =
132
- yield* client`select attname as name from pg_attribute where attrelid = to_regclass(${relation}) and attnum > 0 and not attisdropped`.pipe(
133
- Effect.flatMap(Schema.decodeUnknownEffect(PgColumns)),
134
- );
170
+ const columns = yield* client`
171
+ select requested.relation, c.column_name as name
172
+ from (${requested}) requested join information_schema.columns c
173
+ on c.table_schema = coalesce(requested.schemaName, database()) and c.table_name = requested.tableName
174
+ `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(MysqlColumns)));
175
+
176
+ const indexes = yield* client`
177
+ select requested.relation, i.index_name as name, i.column_name as columnName, i.sub_part as prefix
178
+ from (${requested}) requested join information_schema.statistics i
179
+ on i.table_schema = coalesce(requested.schemaName, database()) and i.table_name = requested.tableName
180
+ where i.non_unique = 0 order by requested.relation, i.index_name, i.seq_in_index
181
+ `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(MysqlIndexes)));
182
+
183
+ return relations.map((relation) => {
184
+ const grouped = new Map<string, Array<(typeof indexes)[number]>>();
185
+
186
+ for (const index of indexes) {
187
+ if (index.relation !== relation) continue;
188
+ const entries = grouped.get(index.name) ?? [];
189
+
190
+ entries.push(index);
191
+ grouped.set(index.name, entries);
192
+ }
135
193
 
136
- for (const column of Object.values(table.columns)) {
137
- if (!columns.some((actual) => actual.name === column.name))
138
- return yield* PersistenceConfigurationError.make({
139
- reason: `Missing SQL column ${relation}.${column.name}`,
140
- });
141
- }
194
+ return {
195
+ relation,
196
+ columns: columns
197
+ .filter((column) => column.relation === relation)
198
+ .map((column) => column.name),
199
+ keys: [...grouped.values()]
200
+ .filter((entries) =>
201
+ entries.every((entry) => entry.columnName !== null && entry.prefix === null),
202
+ )
203
+ .map((entries) =>
204
+ entries.flatMap((entry) =>
205
+ entry.columnName === null ? [] : [entry.columnName],
206
+ ),
207
+ ),
208
+ };
209
+ });
210
+ })
211
+ : Effect.gen(function* () {
212
+ const requested = client.join(
213
+ ", ",
214
+ false,
215
+ )(relations.map((relation) => client`(${relation}::text)`));
142
216
 
143
- const indexes = yield* client`
144
- select array_agg(a.attname order by k.ordinality) as columns
217
+ return yield* client`
218
+ select requested.relation,
219
+ array(select a.attname::text from pg_attribute a
220
+ where a.attrelid = to_regclass(requested.relation)
221
+ and a.attnum > 0 and not a.attisdropped) as columns,
222
+ coalesce((select jsonb_agg(storage_key.columns) from (
223
+ select array_agg(a.attname::text order by k.ordinality) as columns
145
224
  from pg_index i
146
225
  cross join lateral unnest(i.indkey) with ordinality as k(attnum, ordinality)
147
226
  join pg_attribute a on a.attrelid = i.indrelid and a.attnum = k.attnum
148
- where i.indrelid = to_regclass(${relation}) and i.indisunique and i.indisvalid
149
- and i.indimmediate and i.indpred is null and i.indexprs is null
227
+ where i.indrelid = to_regclass(requested.relation)
228
+ and i.indisunique and i.indisvalid and i.indimmediate
229
+ and i.indpred is null and i.indexprs is null
150
230
  and k.ordinality <= i.indnkeyatts
151
231
  group by i.indexrelid
152
- `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgKeys)));
232
+ ) storage_key), '[]'::jsonb) as keys
233
+ from (values ${requested}) requested(relation)
234
+ `.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgMetadata)));
235
+ });
153
236
 
154
- return indexes.map((index) => index.columns);
155
- });
237
+ for (const row of metadata) byRelation.set(`${dialect}:${row.relation}`, row);
238
+ }
156
239
 
157
- for (const keys of required) {
158
- const columns = keys.map((key) => table.columns[key]?.name);
159
-
160
- if (
161
- columns.some((column) => column === undefined) ||
162
- !physicalKeys.some(
163
- // A stronger key also guarantees the required tuple's uniqueness.
164
- (actual) =>
165
- actual.length > 0 &&
166
- actual.length <= columns.length &&
167
- actual.every((column) => columns.includes(column)),
168
- )
169
- )
170
- return yield* PersistenceConfigurationError.make({
171
- reason: `Missing unique key on ${table.name} (${keys.join(", ")})`,
172
- });
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 ?? []);
173
251
  }
174
252
  },
175
- (effect, _dialect, table) =>
176
- effect.pipe(
177
- Effect.mapError((error) =>
178
- Schema.is(PersistenceConfigurationError)(error)
179
- ? error
180
- : PersistenceConfigurationError.make({
181
- reason: `Cannot validate SQL storage for ${table.name}`,
182
- }),
183
- ),
184
- ),
253
+ Effect.mapError((error) =>
254
+ Schema.is(PersistenceConfigurationError)(error)
255
+ ? error
256
+ : PersistenceConfigurationError.make({ reason: "Cannot validate SQL storage metadata" }),
257
+ ),
185
258
  );
259
+
260
+ /** Validate physical columns and unconditional uniqueness before operations run. */
261
+ export const validateStorage = (
262
+ dialect: "pg" | "mysql" | "sqlite",
263
+ table: PhysicalStorageTable,
264
+ required: ReadonlyArray<ReadonlyArray<string>>,
265
+ ) => validateStorageBatch(dialect, [{ table, required }]);
@@ -7,7 +7,7 @@ import {
7
7
  } from "@yielded/auth/Hooks";
8
8
  /* oxlint-disable no-explicit-any -- existing storage kernels erase foreign table shapes; domain errors remain typed. */
9
9
  /* oxlint-disable no-explicit-any -- private execution bridge retains exact public driver wrappers. */
10
- import { Context, Effect } from "effect";
10
+ import { Context, Crypto, Effect, type PlatformError } from "effect";
11
11
  import type * as SqlClient from "effect/sql/SqlClient";
12
12
  import type * as SqlError from "effect/sql/SqlError";
13
13
  import type { Statement } from "effect/sql/Statement";
@@ -56,7 +56,11 @@ export interface TransactionExecution<Failure, OwnerId, Requirements = Lifecycle
56
56
  readonly run: <A, E, R>(
57
57
  body: Effect.Effect<A, E, R>,
58
58
  mutation?: boolean,
59
- ) => Effect.Effect<A, Failure, Exclude<R, OwnerId | CurrentCommitJournal> | Requirements>;
59
+ ) => Effect.Effect<
60
+ A,
61
+ Failure,
62
+ Exclude<Exclude<R, OwnerId | CurrentCommitJournal>, Crypto.Crypto> | Requirements
63
+ >;
60
64
  }
61
65
 
62
66
  export const makeTransactionExecutionKernel = (
@@ -76,10 +80,15 @@ export const makeTransactionExecutionKernel = (
76
80
  ownerTag: Context.Key<OwnerId, TransactionOwner<Failure>>,
77
81
  configuration: TransactionTargetConfiguration<Failure>,
78
82
  unavailable: () => Failure,
79
- nonce: () => string,
83
+ nonce: Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>,
80
84
  bound?: TransactionBound<Failure>,
81
- ): Effect.fn.Return<TransactionExecution<Failure, OwnerId>, never, NativeDatabase> {
85
+ ): Effect.fn.Return<
86
+ TransactionExecution<Failure, OwnerId>,
87
+ never,
88
+ NativeDatabase | Crypto.Crypto
89
+ > {
82
90
  const database = yield* NativeDatabase;
91
+ const crypto = yield* Crypto.Crypto;
83
92
 
84
93
  const invariant: (value: unknown) => asserts value = (value) => {
85
94
  if (!value) throw unavailable();
@@ -101,11 +110,19 @@ export const makeTransactionExecutionKernel = (
101
110
  run: <A, E, R>(
102
111
  body: Effect.Effect<A, E, R>,
103
112
  mutation = true,
104
- ): Effect.Effect<A, Failure, Exclude<R, OwnerId | CurrentCommitJournal> | LifecycleHooks> => {
113
+ ): Effect.Effect<
114
+ A,
115
+ Failure,
116
+ Exclude<Exclude<R, OwnerId | CurrentCommitJournal>, Crypto.Crypto> | LifecycleHooks
117
+ > => {
105
118
  return Effect.suspend(() => {
106
119
  let entered = false;
107
120
 
108
- const work =
121
+ const work: Effect.Effect<
122
+ A,
123
+ E | Failure | HookConfigurationError | SqlError.SqlError | PlatformError.PlatformError,
124
+ Exclude<R, OwnerId | CurrentCommitJournal> | LifecycleHooks | Crypto.Crypto
125
+ > =
109
126
  bound !== undefined
110
127
  ? Effect.gen(function* () {
111
128
  if (!bound.active) return yield* Effect.fail(unavailable());
@@ -148,7 +165,7 @@ export const makeTransactionExecutionKernel = (
148
165
  : Effect.gen(function* () {
149
166
  if (yield* hasCommitScope) return yield* Effect.fail(unavailable());
150
167
  yield* configuration.standaloneGuard(database.$client.transactionService);
151
- const marker = nonce();
168
+ const marker = yield* nonce;
152
169
 
153
170
  const result = yield* coordinateCommit(
154
171
  (journal) => {
@@ -189,6 +206,7 @@ export const makeTransactionExecutionKernel = (
189
206
  });
190
207
 
191
208
  return work.pipe(
209
+ Effect.provideService(Crypto.Crypto, crypto),
192
210
  (operation) => reportTransactionFailure(operation, unavailable),
193
211
  Effect.mapError(unavailable),
194
212
  Effect.catchDefect(() => Effect.fail(unavailable())),
@@ -215,7 +233,7 @@ export const makeTransactionExecutionKernel = (
215
233
  configuration: TransactionTargetConfiguration<Failure>,
216
234
  allocate: Effect.Effect<Resources, AllocationError, AllocationRequirements>,
217
235
  unavailable: () => Failure,
218
- nonce: () => string,
236
+ nonce: Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>,
219
237
  services: (execution: TransactionExecution<Failure, OwnerId>, resources: Resources) => Services,
220
238
  owner: (
221
239
  transaction: Transaction,
@@ -225,11 +243,12 @@ export const makeTransactionExecutionKernel = (
225
243
  ): Effect.Effect<
226
244
  A,
227
245
  TransactionCoordinatorError<E, Failure>,
228
- Exclude<R, CurrentCommitJournal> | LifecycleHooks | AllocationRequirements
246
+ Exclude<R, CurrentCommitJournal> | LifecycleHooks | AllocationRequirements | Crypto.Crypto
229
247
  > => {
230
248
  return Effect.gen(function* () {
231
249
  if (yield* hasCommitScope) return yield* Effect.fail(unavailable());
232
250
  yield* configuration.standaloneGuard(database.$client.transactionService);
251
+ const crypto = yield* Crypto.Crypto;
233
252
 
234
253
  // Allocation is independent of registration data and always precedes the physical owner.
235
254
  const resources = yield* allocate.pipe(
@@ -238,7 +257,7 @@ export const makeTransactionExecutionKernel = (
238
257
  Effect.catchDefect(() => Effect.fail(unavailable())),
239
258
  );
240
259
 
241
- const marker = nonce();
260
+ const marker = yield* nonce.pipe(Effect.mapError(unavailable));
242
261
 
243
262
  const result = yield* coordinateCommit(
244
263
  (journal) => {
@@ -293,7 +312,7 @@ export const makeTransactionExecutionKernel = (
293
312
  }),
294
313
  ),
295
314
  );
296
- });
315
+ }).pipe(Effect.provideService(Crypto.Crypto, crypto));
297
316
 
298
317
  return configuration.mode === "batch"
299
318
  ? run(database)