@yielded/auth-persistence 0.1.0-beta.18 → 0.1.0-beta.20

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 (75) 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 +4 -3
  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/passkey/admission.mjs +19 -15
  9. package/dist/internal/passkey/admission.mjs.map +1 -1
  10. package/dist/internal/passkey/credentials.mjs +4 -4
  11. package/dist/internal/passkey/credentials.mjs.map +1 -1
  12. package/dist/internal/passkey/enrollment.mjs +6 -6
  13. package/dist/internal/passkey/enrollment.mjs.map +1 -1
  14. package/dist/internal/passkey/flow.mjs +7 -7
  15. package/dist/internal/passkey/flow.mjs.map +1 -1
  16. package/dist/internal/passkey/management.mjs +2 -2
  17. package/dist/internal/passkey/management.mjs.map +1 -1
  18. package/dist/internal/passkey/registration-ceremony.mjs +2 -2
  19. package/dist/internal/passkey/registration-ceremony.mjs.map +1 -1
  20. package/dist/internal/passkey/registration-custody.mjs +1 -1
  21. package/dist/internal/passkey/registration-custody.mjs.map +1 -1
  22. package/dist/internal/passkey/registration-write.mjs +12 -12
  23. package/dist/internal/passkey/registration-write.mjs.map +1 -1
  24. package/dist/internal/passkey/state.d.mts +1 -1
  25. package/dist/internal/passkey/state.mjs +8 -9
  26. package/dist/internal/passkey/state.mjs.map +1 -1
  27. package/dist/internal/passkey/target.d.mts +1 -1
  28. package/dist/internal/passkey/target.mjs.map +1 -1
  29. package/dist/internal/passkey/write-state.mjs +13 -13
  30. package/dist/internal/passkey/write-state.mjs.map +1 -1
  31. package/dist/internal/passkey/write-target.mjs.map +1 -1
  32. package/dist/internal/passkey-kernel.d.mts +512 -56
  33. package/dist/internal/passkeys.mjs +32 -14
  34. package/dist/internal/passkeys.mjs.map +1 -1
  35. package/dist/internal/password-kernel.d.mts +1 -1
  36. package/dist/internal/persistence.d.mts +2 -2
  37. package/dist/internal/persistence.mjs +16 -7
  38. package/dist/internal/persistence.mjs.map +1 -1
  39. package/dist/internal/phone-kernel.d.mts +5 -5
  40. package/dist/internal/phone-kernel.mjs +14 -14
  41. package/dist/internal/phone-kernel.mjs.map +1 -1
  42. package/dist/internal/registration.mjs +8 -7
  43. package/dist/internal/registration.mjs.map +1 -1
  44. package/dist/internal/storage-mapping.d.mts +5 -4
  45. package/dist/internal/storage-mapping.mjs +16 -13
  46. package/dist/internal/storage-mapping.mjs.map +1 -1
  47. package/dist/internal/storage-validation.d.mts +13 -3
  48. package/dist/internal/storage-validation.mjs +128 -68
  49. package/dist/internal/storage-validation.mjs.map +1 -1
  50. package/dist/internal/transaction-execution-kernel.d.mts +4 -4
  51. package/dist/internal/transaction-execution-kernel.mjs +7 -5
  52. package/dist/internal/transaction-execution-kernel.mjs.map +1 -1
  53. package/package.json +1 -1
  54. package/src/Adapter.ts +7 -1
  55. package/src/internal/configuration.ts +4 -2
  56. package/src/internal/crypto.ts +15 -0
  57. package/src/internal/passkey/admission.ts +22 -15
  58. package/src/internal/passkey/credentials.ts +4 -4
  59. package/src/internal/passkey/enrollment.ts +7 -7
  60. package/src/internal/passkey/flow.ts +8 -8
  61. package/src/internal/passkey/management.ts +7 -2
  62. package/src/internal/passkey/registration-ceremony.ts +2 -2
  63. package/src/internal/passkey/registration-custody.ts +1 -1
  64. package/src/internal/passkey/registration-write.ts +12 -12
  65. package/src/internal/passkey/state.ts +10 -13
  66. package/src/internal/passkey/target.ts +16 -8
  67. package/src/internal/passkey/write-state.ts +10 -14
  68. package/src/internal/passkey/write-target.ts +11 -3
  69. package/src/internal/passkeys.ts +121 -70
  70. package/src/internal/persistence.ts +28 -11
  71. package/src/internal/phone-kernel.ts +26 -17
  72. package/src/internal/registration.ts +10 -8
  73. package/src/internal/storage-mapping.ts +21 -13
  74. package/src/internal/storage-validation.ts +219 -139
  75. package/src/internal/transaction-execution-kernel.ts +30 -11
@@ -1,13 +1,13 @@
1
- import { randomId } from "@yielded/auth-crypto";
2
1
  import { coordinateCommit, hasCommitScope, LifecycleHooks } from "@yielded/auth/Hooks";
3
2
  import type { LoginIdentifier } from "@yielded/auth/Identity";
4
3
  import { PasswordUnavailable } from "@yielded/auth/Password";
5
4
  import { reportPersistenceFailure } from "@yielded/auth/Persistence";
6
5
  import type { SubjectId } from "@yielded/auth/Schema";
7
6
  import { SecurityRevision } from "@yielded/auth/Sessions";
8
- import { type Context, Effect, Schema } from "effect";
7
+ import { type Context, Crypto, Effect, Schema } from "effect";
9
8
 
10
9
  import { PersistenceConfigurationError } from "./configuration";
10
+ import { randomId } from "./crypto";
11
11
  import { CurrentPasswordSql } from "./password-kernel";
12
12
  import type { QueryOperations } from "./query-operations";
13
13
  import type { PasswordRegistrationAuthority } from "./registration-contract";
@@ -25,7 +25,7 @@ type CreateSubject = (input: {
25
25
  * always suppress; a public request ID never recovers a private password intent.
26
26
  */
27
27
  export const makeRegistrationAuthority = Effect.fn("makeRegistrationAuthority")(function* <R>(
28
- mappings: ReturnType<typeof makeMappings>,
28
+ mappings: Effect.Success<ReturnType<typeof makeMappings>>,
29
29
  operations: QueryOperations,
30
30
  standalone: Effect.Effect<void, PasswordUnavailable>,
31
31
  provisioning: Context.Key<R, object>,
@@ -33,10 +33,11 @@ export const makeRegistrationAuthority = Effect.fn("makeRegistrationAuthority")(
33
33
  ): Effect.fn.Return<
34
34
  PasswordRegistrationAuthority<unknown>,
35
35
  PersistenceConfigurationError,
36
- LifecycleHooks | CurrentPasswordSql | R
36
+ LifecycleHooks | CurrentPasswordSql | Crypto.Crypto | R
37
37
  > {
38
38
  const database = yield* CurrentPasswordSql;
39
39
  const hooks = yield* LifecycleHooks;
40
+ const crypto = yield* Crypto.Crypto;
40
41
  // Core has already decoded registration with the selected strategy's Schema;
41
42
  // the dynamic strategy table erases only that heterogeneous callback signature.
42
43
  const creators = (yield* provisioning) as Readonly<Record<string, CreateSubject>>;
@@ -129,7 +130,7 @@ export const makeRegistrationAuthority = Effect.fn("makeRegistrationAuthority")(
129
130
  mapping.identifier.encodeInitialInsert(
130
131
  input.identifier,
131
132
  nativeId,
132
- SecurityRevision.make(randomId()),
133
+ SecurityRevision.make(yield* randomId),
133
134
  ),
134
135
  )
135
136
  .onConflictDoNothing()
@@ -139,8 +140,8 @@ export const makeRegistrationAuthority = Effect.fn("makeRegistrationAuthority")(
139
140
  // subject too, then persist a non-authorizing receipt in a new transaction.
140
141
  if (bound.length !== 1) return yield* new IdentifierTaken({});
141
142
 
142
- const credentialId = randomId();
143
- const credentialRevision = SecurityRevision.make(randomId());
143
+ const credentialId = yield* randomId;
144
+ const credentialRevision = SecurityRevision.make(yield* randomId);
144
145
 
145
146
  yield* tx.insert(mapping.credential.table).values(
146
147
  mapping.credential.encodeInsert({
@@ -148,7 +149,7 @@ export const makeRegistrationAuthority = Effect.fn("makeRegistrationAuthority")(
148
149
  subjectId: nativeId,
149
150
  credentialId,
150
151
  credentialRevision,
151
- verifierVersion: SecurityRevision.make(randomId()),
152
+ verifierVersion: SecurityRevision.make(yield* randomId),
152
153
  replacement: input.replacement,
153
154
  }),
154
155
  );
@@ -169,6 +170,7 @@ export const makeRegistrationAuthority = Effect.fn("makeRegistrationAuthority")(
169
170
  return committed.value;
170
171
  }).pipe(
171
172
  Effect.provideService(LifecycleHooks, hooks),
173
+ Effect.provideService(Crypto.Crypto, crypto),
172
174
  (work) => reportPersistenceFailure(work, Schema.is(PasswordUnavailable)),
173
175
  Effect.mapError(() => PasswordUnavailable.make({})),
174
176
  ),
@@ -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 }]);