@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.
- package/README.md +5 -2
- package/dist/Adapter.d.mts +2 -2
- package/dist/Adapter.mjs +2 -2
- package/dist/internal/configuration.d.mts +5 -4
- 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/email-kernel.d.mts +7 -7
- 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 +705 -249
- package/dist/internal/passkeys.mjs +32 -14
- package/dist/internal/passkeys.mjs.map +1 -1
- package/dist/internal/password-kernel.d.mts +6 -6
- package/dist/internal/password-kernel.mjs +1 -1
- package/dist/internal/password-kernel.mjs.map +1 -1
- package/dist/internal/persistence.d.mts +2 -2
- package/dist/internal/persistence.mjs +18 -9
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/phone-kernel.d.mts +7 -7
- 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/session-kernel.mjs +2 -1
- package/dist/internal/session-kernel.mjs.map +1 -1
- package/dist/internal/sql-query.mjs +1 -1
- package/dist/internal/sql-query.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 +5 -3
- 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/password-kernel.ts +2 -2
- package/src/internal/persistence.ts +30 -13
- package/src/internal/phone-kernel.ts +26 -17
- package/src/internal/registration.ts +10 -8
- package/src/internal/session-kernel.ts +3 -2
- package/src/internal/sql-query.ts +3 -2
- 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,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 }]);
|
|
@@ -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<
|
|
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:
|
|
83
|
+
nonce: Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>,
|
|
80
84
|
bound?: TransactionBound<Failure>,
|
|
81
|
-
): Effect.fn.Return<
|
|
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<
|
|
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:
|
|
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)
|