@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.
- package/README.md +5 -2
- package/dist/Adapter.d.mts +2 -2
- package/dist/Adapter.mjs +2 -2
- package/dist/internal/configuration.d.mts +4 -3
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/crypto.mjs +12 -0
- package/dist/internal/crypto.mjs.map +1 -0
- package/dist/internal/passkey/admission.mjs +19 -15
- package/dist/internal/passkey/admission.mjs.map +1 -1
- package/dist/internal/passkey/credentials.mjs +4 -4
- package/dist/internal/passkey/credentials.mjs.map +1 -1
- package/dist/internal/passkey/enrollment.mjs +6 -6
- package/dist/internal/passkey/enrollment.mjs.map +1 -1
- package/dist/internal/passkey/flow.mjs +7 -7
- package/dist/internal/passkey/flow.mjs.map +1 -1
- package/dist/internal/passkey/management.mjs +2 -2
- package/dist/internal/passkey/management.mjs.map +1 -1
- package/dist/internal/passkey/registration-ceremony.mjs +2 -2
- package/dist/internal/passkey/registration-ceremony.mjs.map +1 -1
- package/dist/internal/passkey/registration-custody.mjs +1 -1
- package/dist/internal/passkey/registration-custody.mjs.map +1 -1
- package/dist/internal/passkey/registration-write.mjs +12 -12
- package/dist/internal/passkey/registration-write.mjs.map +1 -1
- package/dist/internal/passkey/state.d.mts +1 -1
- package/dist/internal/passkey/state.mjs +8 -9
- package/dist/internal/passkey/state.mjs.map +1 -1
- package/dist/internal/passkey/target.d.mts +1 -1
- package/dist/internal/passkey/target.mjs.map +1 -1
- package/dist/internal/passkey/write-state.mjs +13 -13
- package/dist/internal/passkey/write-state.mjs.map +1 -1
- package/dist/internal/passkey/write-target.mjs.map +1 -1
- package/dist/internal/passkey-kernel.d.mts +512 -56
- package/dist/internal/passkeys.mjs +32 -14
- package/dist/internal/passkeys.mjs.map +1 -1
- package/dist/internal/password-kernel.d.mts +1 -1
- package/dist/internal/persistence.d.mts +2 -2
- package/dist/internal/persistence.mjs +16 -7
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/phone-kernel.d.mts +5 -5
- package/dist/internal/phone-kernel.mjs +14 -14
- package/dist/internal/phone-kernel.mjs.map +1 -1
- package/dist/internal/registration.mjs +8 -7
- package/dist/internal/registration.mjs.map +1 -1
- package/dist/internal/storage-mapping.d.mts +5 -4
- package/dist/internal/storage-mapping.mjs +16 -13
- package/dist/internal/storage-mapping.mjs.map +1 -1
- package/dist/internal/storage-validation.d.mts +13 -3
- package/dist/internal/storage-validation.mjs +128 -68
- package/dist/internal/storage-validation.mjs.map +1 -1
- package/dist/internal/transaction-execution-kernel.d.mts +4 -4
- package/dist/internal/transaction-execution-kernel.mjs +7 -5
- package/dist/internal/transaction-execution-kernel.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Adapter.ts +7 -1
- package/src/internal/configuration.ts +4 -2
- package/src/internal/crypto.ts +15 -0
- package/src/internal/passkey/admission.ts +22 -15
- package/src/internal/passkey/credentials.ts +4 -4
- package/src/internal/passkey/enrollment.ts +7 -7
- package/src/internal/passkey/flow.ts +8 -8
- package/src/internal/passkey/management.ts +7 -2
- package/src/internal/passkey/registration-ceremony.ts +2 -2
- package/src/internal/passkey/registration-custody.ts +1 -1
- package/src/internal/passkey/registration-write.ts +12 -12
- package/src/internal/passkey/state.ts +10 -13
- package/src/internal/passkey/target.ts +16 -8
- package/src/internal/passkey/write-state.ts +10 -14
- package/src/internal/passkey/write-target.ts +11 -3
- package/src/internal/passkeys.ts +121 -70
- package/src/internal/persistence.ts +28 -11
- package/src/internal/phone-kernel.ts +26 -17
- package/src/internal/registration.ts +10 -8
- package/src/internal/storage-mapping.ts +21 -13
- package/src/internal/storage-validation.ts +219 -139
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
interface MetadataCache {
|
|
68
|
+
active: boolean;
|
|
69
|
+
readonly clients: Map<SqlClient, Map<string, typeof Metadata.Type>>;
|
|
70
|
+
}
|
|
44
71
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
:
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
125
|
+
const tables = new Map(requirements.map(({ table }) => [qualifiedTableName(table), table]));
|
|
110
126
|
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
const missing = [...tables.entries()].filter(
|
|
128
|
+
([relation]) => !byRelation.has(`${dialect}:${relation}`),
|
|
129
|
+
);
|
|
113
130
|
|
|
114
|
-
|
|
115
|
-
keys.set(index.name, columns);
|
|
116
|
-
}
|
|
131
|
+
const relations = missing.map(([relation]) => relation);
|
|
117
132
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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(
|
|
149
|
-
and i.
|
|
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
|
-
|
|
232
|
+
) storage_key), '[]'::jsonb) as keys
|
|
233
|
+
from (values ${requested}) requested(relation)
|
|
234
|
+
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgMetadata)));
|
|
235
|
+
});
|
|
153
236
|
|
|
154
|
-
|
|
155
|
-
|
|
237
|
+
for (const row of metadata) byRelation.set(`${dialect}:${row.relation}`, row);
|
|
238
|
+
}
|
|
156
239
|
|
|
157
|
-
for (const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
!
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 }]);
|