@yielded/auth-persistence 0.1.0-beta.31 → 0.1.0-beta.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/Adapter.mjs +8 -8
- package/dist/OAuthPersistence.mjs +10 -5
- package/dist/OAuthPersistence.mjs.map +1 -1
- package/dist/internal/configuration.d.mts +31 -5
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/email-native.mjs +10 -2
- package/dist/internal/email-native.mjs.map +1 -1
- package/dist/internal/email-policy.mjs +3 -2
- package/dist/internal/email-policy.mjs.map +1 -1
- package/dist/internal/models/oauth-model.d.mts +4 -2
- package/dist/internal/models/oauth-model.mjs.map +1 -1
- package/dist/internal/oauth/native-accounts.d.mts +1 -1
- package/dist/internal/oauth/native-accounts.mjs +6 -6
- package/dist/internal/oauth/native-accounts.mjs.map +1 -1
- package/dist/internal/oauth/native-connected.d.mts +1 -1
- package/dist/internal/oauth/native-connected.mjs +1 -1
- package/dist/internal/oauth/native-registration.mjs +19 -8
- package/dist/internal/oauth/native-registration.mjs.map +1 -1
- package/dist/internal/oauth/native-sign-in.d.mts +1 -1
- package/dist/internal/oauth/native-sign-in.mjs +1 -1
- package/dist/internal/oauth/native-state.mjs +21 -11
- package/dist/internal/oauth/native-state.mjs.map +1 -1
- package/dist/internal/oauth/state.d.mts +1 -1
- package/dist/internal/oauth/state.mjs +1 -1
- package/dist/internal/oauth-managed.mjs +182 -0
- package/dist/internal/oauth-managed.mjs.map +1 -0
- package/dist/internal/passkey-managed.mjs +1 -1
- package/dist/internal/passkey-native-registration.mjs +1 -1
- package/dist/internal/passkey-native-state.mjs +33 -23
- package/dist/internal/passkey-native-state.mjs.map +1 -1
- package/dist/internal/passkey-native-write.mjs +3 -3
- package/dist/internal/passkey-native-write.mjs.map +1 -1
- package/dist/internal/passkey-native.d.mts +15 -7
- package/dist/internal/passkey-native.mjs +2 -1
- package/dist/internal/passkey-native.mjs.map +1 -1
- package/dist/internal/passkey-policy.mjs +9 -7
- package/dist/internal/passkey-policy.mjs.map +1 -1
- package/dist/internal/password-native.mjs +9 -6
- package/dist/internal/password-native.mjs.map +1 -1
- package/dist/internal/password-policy.mjs +7 -6
- package/dist/internal/password-policy.mjs.map +1 -1
- package/dist/internal/persistence.mjs +96 -37
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/phone-native.mjs +1 -1
- package/dist/internal/phone-native.mjs.map +1 -1
- package/dist/internal/session-native-authority.mjs +6 -5
- package/dist/internal/session-native-authority.mjs.map +1 -1
- package/dist/internal/session-native-login.d.mts +2 -2
- package/dist/internal/session-native-login.mjs +2 -1
- package/dist/internal/session-native-login.mjs.map +1 -1
- package/dist/internal/session-native-pending-read.mjs +26 -20
- package/dist/internal/session-native-pending-read.mjs.map +1 -1
- package/dist/internal/session-native-record.mjs +6 -4
- package/dist/internal/session-native-record.mjs.map +1 -1
- package/dist/internal/session-native-state.mjs +3 -0
- package/dist/internal/session-native-state.mjs.map +1 -1
- package/dist/internal/session-native-stateful.d.mts +2 -2
- package/dist/internal/session-native-stateful.mjs +65 -37
- package/dist/internal/session-native-stateful.mjs.map +1 -1
- package/dist/internal/session-native-step-up.d.mts +2 -2
- package/dist/internal/session-native-step-up.mjs +14 -18
- package/dist/internal/session-native-step-up.mjs.map +1 -1
- package/dist/internal/session-native-validity.mjs +3 -1
- package/dist/internal/session-native-validity.mjs.map +1 -1
- package/dist/internal/sql-authority.mjs +5 -1
- package/dist/internal/sql-authority.mjs.map +1 -1
- package/dist/internal/sql-storage-validation.mjs +2 -1
- package/dist/internal/sql-storage-validation.mjs.map +1 -1
- package/dist/internal/sql-table.d.mts +1 -0
- package/dist/internal/sql-table.mjs.map +1 -1
- package/dist/internal/step-up-state.mjs +19 -6
- package/dist/internal/step-up-state.mjs.map +1 -1
- package/dist/internal/storage-mapping.mjs +1 -1
- package/dist/internal/storage-tables.d.mts +237 -0
- package/dist/internal/storage-tables.mjs +68 -0
- package/dist/internal/storage-tables.mjs.map +1 -1
- package/dist/internal/storage-validation.d.mts +3 -1
- package/dist/internal/storage-validation.mjs +70 -17
- package/dist/internal/storage-validation.mjs.map +1 -1
- package/dist/internal/totp-native.mjs +1 -1
- package/package.json +1 -1
- package/src/OAuthPersistence.ts +7 -2
- package/src/internal/configuration.ts +46 -7
- package/src/internal/email-native.ts +16 -6
- package/src/internal/email-policy.ts +12 -3
- package/src/internal/models/oauth-model.ts +7 -1
- package/src/internal/oauth/native-accounts.ts +3 -3
- package/src/internal/oauth/native-registration.ts +17 -6
- package/src/internal/oauth/native-state.ts +38 -18
- package/src/internal/oauth-managed.ts +277 -0
- package/src/internal/passkey-native-state.ts +59 -52
- package/src/internal/passkey-native-write.ts +2 -2
- package/src/internal/passkey-native.ts +4 -2
- package/src/internal/passkey-policy.ts +13 -9
- package/src/internal/password-native.ts +17 -9
- package/src/internal/password-policy.ts +16 -8
- package/src/internal/persistence.ts +216 -70
- package/src/internal/phone-native.ts +1 -1
- package/src/internal/session-native-authority.ts +23 -7
- package/src/internal/session-native-login.ts +6 -3
- package/src/internal/session-native-pending-read.ts +46 -33
- package/src/internal/session-native-record.ts +6 -4
- package/src/internal/session-native-state.ts +3 -0
- package/src/internal/session-native-stateful.ts +144 -51
- package/src/internal/session-native-step-up.ts +27 -23
- package/src/internal/session-native-validity.ts +3 -1
- package/src/internal/sql-authority.ts +5 -1
- package/src/internal/sql-storage-validation.ts +53 -46
- package/src/internal/sql-table.ts +1 -0
- package/src/internal/step-up-state.ts +43 -6
- package/src/internal/storage-tables.ts +76 -0
- package/src/internal/storage-validation.ts +127 -27
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
|
|
1
2
|
import { Effect } from "effect";
|
|
2
3
|
import { SqlClient } from "effect/sql";
|
|
3
4
|
|
|
@@ -7,57 +8,63 @@ import { storageKeyPlans } from "./storage-plans";
|
|
|
7
8
|
import { validateStorageBatch, type StorageValidation } from "./storage-validation";
|
|
8
9
|
|
|
9
10
|
/** Check declared mappings against physical keys before acquiring OAuth services. */
|
|
10
|
-
export const validateSqlStorage = Effect.fnUntraced(
|
|
11
|
-
|
|
12
|
-
try
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
export const validateSqlStorage = Effect.fnUntraced(
|
|
12
|
+
function* (mapping: unknown) {
|
|
13
|
+
const plans = yield* Effect.try({
|
|
14
|
+
try: () =>
|
|
15
|
+
storageKeyPlans(mapping, (table) => {
|
|
16
|
+
if (!(table instanceof Table)) throw new Error("Expected a SQL table");
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
return table.columns;
|
|
19
|
+
}),
|
|
20
|
+
catch: () =>
|
|
21
|
+
PersistenceMappingError.make({
|
|
22
|
+
operation: "mapping",
|
|
23
|
+
cause: "Invalid storage key mapping",
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
pg: () => "pg" as const,
|
|
26
|
-
sqlite: () => "sqlite" as const,
|
|
27
|
-
orElse: () => undefined,
|
|
28
|
-
});
|
|
27
|
+
const client = yield* SqlClient.SqlClient;
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
const dialect = client.onDialectOrElse({
|
|
30
|
+
pg: () => "pg" as const,
|
|
31
|
+
sqlite: () => "sqlite" as const,
|
|
32
|
+
orElse: () => undefined,
|
|
34
33
|
});
|
|
35
|
-
const validations: StorageValidation[] = [];
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
if (!(plan.table instanceof Table))
|
|
35
|
+
if (dialect === undefined)
|
|
39
36
|
return yield* PersistenceMappingError.make({
|
|
40
37
|
operation: "mapping",
|
|
41
|
-
cause: "
|
|
38
|
+
cause: "OAuth persistence requires PostgreSQL or SQLite",
|
|
42
39
|
});
|
|
43
|
-
validations
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
const validations: StorageValidation[] = [];
|
|
41
|
+
|
|
42
|
+
for (const plan of plans) {
|
|
43
|
+
if (!(plan.table instanceof Table))
|
|
44
|
+
return yield* PersistenceMappingError.make({
|
|
45
|
+
operation: "mapping",
|
|
46
|
+
cause: "Expected a SQL table",
|
|
47
|
+
});
|
|
48
|
+
validations.push({
|
|
49
|
+
table: {
|
|
50
|
+
name: plan.table.name,
|
|
51
|
+
...(plan.table.schema === undefined ? {} : { schema: plan.table.schema }),
|
|
52
|
+
columns: Object.fromEntries(
|
|
53
|
+
Object.entries(plan.table.columns).map(([key, column]) => [key, column.options]),
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
required: [plan.keys],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
yield* validateStorageBatch(dialect, validations).pipe(
|
|
60
|
+
Effect.provideService(SqlClient.SqlClient, client),
|
|
61
|
+
Effect.mapError(() =>
|
|
62
|
+
PersistenceMappingError.make({
|
|
63
|
+
operation: "mapping",
|
|
64
|
+
cause: "Required SQL storage key is unavailable",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
Effect.tapError(() => reportAuthDiagnostic("persistence-validation", "mapping")),
|
|
70
|
+
);
|
|
@@ -22,6 +22,7 @@ export const identifier = (name: string) => '"' + name.replaceAll('"', '""') + '
|
|
|
22
22
|
|
|
23
23
|
export interface ColumnOptions {
|
|
24
24
|
readonly name: string;
|
|
25
|
+
/** Driver value representation, not the database column's physical SQL type. */
|
|
25
26
|
readonly type: "text" | "integer" | "boolean";
|
|
26
27
|
readonly nullable?: boolean;
|
|
27
28
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AuthenticationAssurance, AuthenticationClock } from "@yielded/auth/Operations";
|
|
1
2
|
import {
|
|
2
3
|
SessionStepUpInvalid,
|
|
3
4
|
SessionUnavailable,
|
|
4
5
|
AuthenticationEvidence,
|
|
6
|
+
AuthenticationProof,
|
|
5
7
|
SessionMetadata,
|
|
6
8
|
SessionStepUpIntent,
|
|
7
9
|
SessionStepUpRequirement,
|
|
@@ -10,7 +12,7 @@ import {
|
|
|
10
12
|
} from "@yielded/auth/Sessions";
|
|
11
13
|
import { DateTime, Effect, Schema } from "effect";
|
|
12
14
|
|
|
13
|
-
import { sameSessionRevision } from "./session-native-state";
|
|
15
|
+
import { assessSessionAt, sameSessionRevision } from "./session-native-state";
|
|
14
16
|
|
|
15
17
|
export const stepUpIntentCodec = Schema.fromJsonString(SessionStepUpIntent);
|
|
16
18
|
const BoundedSnapshot = Schema.String.check(Schema.isMaxLength(131072));
|
|
@@ -27,11 +29,13 @@ export const decodeStepUpIntent = (snapshot: string) =>
|
|
|
27
29
|
Effect.mapError(() => SessionUnavailable.make({})),
|
|
28
30
|
);
|
|
29
31
|
|
|
30
|
-
export const stepUpIntentLive = (
|
|
32
|
+
export const stepUpIntentLive = Effect.fnUntraced(function* (
|
|
31
33
|
intent: Omit<SessionStepUpIntent, "version">,
|
|
32
34
|
kind: SessionStepUpIntent["sourceKind"],
|
|
33
35
|
now: DateTime.Utc,
|
|
34
|
-
)
|
|
36
|
+
): Effect.fn.Return<boolean> {
|
|
37
|
+
const { futureToleranceMillis } = yield* AuthenticationClock;
|
|
38
|
+
|
|
35
39
|
const n = DateTime.toEpochMillis(now),
|
|
36
40
|
authenticated = DateTime.toEpochMillis(intent.sourceAuthenticatedAt),
|
|
37
41
|
idle = DateTime.toEpochMillis(intent.sourceExpiresAt),
|
|
@@ -40,16 +44,22 @@ export const stepUpIntentLive = (
|
|
|
40
44
|
|
|
41
45
|
return (
|
|
42
46
|
intent.sourceKind === kind &&
|
|
43
|
-
authenticated
|
|
47
|
+
authenticated - n <= futureToleranceMillis &&
|
|
48
|
+
authenticated < idle &&
|
|
44
49
|
n < expires &&
|
|
45
50
|
expires <= idle &&
|
|
46
51
|
idle <= absolute
|
|
47
52
|
);
|
|
48
|
-
};
|
|
53
|
+
});
|
|
49
54
|
|
|
50
55
|
const evidenceEncoding = Schema.encodeSync(Schema.fromJsonString(AuthenticationEvidence));
|
|
51
56
|
const requirementEncoding = Schema.encodeSync(Schema.fromJsonString(SessionStepUpRequirement));
|
|
52
57
|
const metadataEncoding = Schema.encodeSync(Schema.fromJsonString(SessionMetadata));
|
|
58
|
+
const proofEncoding = Schema.encodeSync(Schema.fromJsonString(Schema.Array(AuthenticationProof)));
|
|
59
|
+
|
|
60
|
+
const assuranceEncoding = Schema.encodeSync(
|
|
61
|
+
Schema.fromJsonString(SessionMetadata.fields.assurance),
|
|
62
|
+
);
|
|
53
63
|
|
|
54
64
|
/** Validate core-owned plans before driver allocation/SQL expansion. No bearer or Claims decoding. */
|
|
55
65
|
export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(function* <Claims>(
|
|
@@ -57,7 +67,14 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
|
|
|
57
67
|
) {
|
|
58
68
|
const { intent, source, replacement, evidence } = plan;
|
|
59
69
|
const original = source.inspection;
|
|
70
|
+
const issuedAt = DateTime.toEpochMillis(replacement.inspection.session.issuedAt);
|
|
60
71
|
|
|
72
|
+
if (
|
|
73
|
+
issuedAt < DateTime.toEpochMillis(original.session.issuedAt) ||
|
|
74
|
+
evidence.proofs.some((proof) => DateTime.toEpochMillis(proof.verifiedAt) > issuedAt) ||
|
|
75
|
+
issuedAt >= DateTime.toEpochMillis(replacement.inspection.session.expiresAt)
|
|
76
|
+
)
|
|
77
|
+
return yield* SessionStepUpInvalid.make({});
|
|
61
78
|
if (
|
|
62
79
|
requirementEncoding(plan.profileRequirement) !== requirementEncoding(intent.requirement) ||
|
|
63
80
|
replacement.inspection.session.subjectId !== evidence.revision.subjectId ||
|
|
@@ -89,7 +106,9 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
|
|
|
89
106
|
DateTime.toEpochMillis(original.session.absoluteExpiresAt) ||
|
|
90
107
|
DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt) !==
|
|
91
108
|
DateTime.toEpochMillis(replacement.inspection.session.absoluteExpiresAt) ||
|
|
92
|
-
evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence)
|
|
109
|
+
evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence) ||
|
|
110
|
+
proofEncoding(evidence.proofs.slice(0, original.provenance.evidence.proofs.length)) !==
|
|
111
|
+
proofEncoding(original.provenance.evidence.proofs)
|
|
93
112
|
)
|
|
94
113
|
return yield* SessionStepUpInvalid.make({});
|
|
95
114
|
if (
|
|
@@ -111,6 +130,24 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
|
|
|
111
130
|
replacement.inspection.session.sessionId === intent.sourceSessionId
|
|
112
131
|
)
|
|
113
132
|
return yield* SessionStepUpInvalid.make({});
|
|
133
|
+
|
|
134
|
+
const at = DateTime.toEpochMillis(plan.now);
|
|
135
|
+
const base = yield* assessSessionAt(evidence, plan.baseRequirement, at);
|
|
136
|
+
|
|
137
|
+
const profile = yield* assessSessionAt(evidence, plan.profileRequirement, at);
|
|
138
|
+
|
|
139
|
+
// Freshness can change metadata at commit; validate the exact preparation snapshot here.
|
|
140
|
+
if (
|
|
141
|
+
!base.satisfied ||
|
|
142
|
+
!profile.satisfied ||
|
|
143
|
+
assuranceEncoding(
|
|
144
|
+
AuthenticationAssurance.make({
|
|
145
|
+
...base.assurance,
|
|
146
|
+
authenticatedAt: profile.assurance.authenticatedAt,
|
|
147
|
+
}),
|
|
148
|
+
) !== assuranceEncoding(replacement.inspection.session.assurance)
|
|
149
|
+
)
|
|
150
|
+
return yield* SessionStepUpInvalid.make({});
|
|
114
151
|
});
|
|
115
152
|
|
|
116
153
|
/** Compare a consumer-decoded encoded update/readback with the intended rotation.
|
|
@@ -27,6 +27,27 @@ const spec = <C extends Readonly<Record<string, Omit<StorageTable["columns"][str
|
|
|
27
27
|
unique: ReadonlyArray<ReadonlyArray<Extract<keyof C, string>>>,
|
|
28
28
|
) => ({ columns, unique });
|
|
29
29
|
|
|
30
|
+
const oauthFlow = spec(
|
|
31
|
+
{
|
|
32
|
+
moduleId: text,
|
|
33
|
+
flowId: text,
|
|
34
|
+
purpose: text,
|
|
35
|
+
generation: integer,
|
|
36
|
+
provider: text,
|
|
37
|
+
callbackId: text,
|
|
38
|
+
issuer: text,
|
|
39
|
+
responseIssuerMode: text,
|
|
40
|
+
subjectId: optionalText,
|
|
41
|
+
stateDigest: text,
|
|
42
|
+
binderVerifier: text,
|
|
43
|
+
binderExpiresAt: integer,
|
|
44
|
+
snapshot: text,
|
|
45
|
+
issuedAt: integer,
|
|
46
|
+
expiresAt: integer,
|
|
47
|
+
},
|
|
48
|
+
[["moduleId", "flowId"], ["stateDigest"]],
|
|
49
|
+
);
|
|
50
|
+
|
|
30
51
|
/** Logical roles are independent of table names and migration ownership. */
|
|
31
52
|
export const storageTables = {
|
|
32
53
|
subjects: spec({ id: text, active: boolean, securityRevision: text }, [["id"]]),
|
|
@@ -113,6 +134,61 @@ export const storageTables = {
|
|
|
113
134
|
},
|
|
114
135
|
[["moduleId", "flowId"]],
|
|
115
136
|
),
|
|
137
|
+
oauthIdentities: spec(
|
|
138
|
+
{ identityKey: text, provider: text, issuer: text, externalSubject: text, subjectId: text },
|
|
139
|
+
[["identityKey"]],
|
|
140
|
+
),
|
|
141
|
+
oauthCredentials: spec(
|
|
142
|
+
{
|
|
143
|
+
moduleId: text,
|
|
144
|
+
credentialId: text,
|
|
145
|
+
subjectId: text,
|
|
146
|
+
identityKey: text,
|
|
147
|
+
credentialRevision: text,
|
|
148
|
+
active: boolean,
|
|
149
|
+
},
|
|
150
|
+
[["credentialId"], ["identityKey"]],
|
|
151
|
+
),
|
|
152
|
+
oauthSignInFlows: oauthFlow,
|
|
153
|
+
oauthConnectedFlows: oauthFlow,
|
|
154
|
+
oauthConnectedGrants: spec(
|
|
155
|
+
{
|
|
156
|
+
moduleId: text,
|
|
157
|
+
grantId: text,
|
|
158
|
+
subjectId: text,
|
|
159
|
+
identityKey: text,
|
|
160
|
+
profileKey: text,
|
|
161
|
+
grantVersion: text,
|
|
162
|
+
tokenVersion: text,
|
|
163
|
+
state: text,
|
|
164
|
+
snapshot: text,
|
|
165
|
+
summary: text,
|
|
166
|
+
refreshClaimId: optionalText,
|
|
167
|
+
refreshNextTokenVersion: optionalText,
|
|
168
|
+
refreshClaimedAt: optionalInteger,
|
|
169
|
+
refreshClaimExpiresAt: optionalInteger,
|
|
170
|
+
expiresAt: integer,
|
|
171
|
+
},
|
|
172
|
+
[
|
|
173
|
+
["moduleId", "grantId"],
|
|
174
|
+
["moduleId", "subjectId", "profileKey", "identityKey"],
|
|
175
|
+
],
|
|
176
|
+
),
|
|
177
|
+
oauthConnectedRevocations: spec(
|
|
178
|
+
{
|
|
179
|
+
jobId: text,
|
|
180
|
+
moduleId: text,
|
|
181
|
+
subjectId: text,
|
|
182
|
+
identityKey: text,
|
|
183
|
+
snapshot: text,
|
|
184
|
+
state: text,
|
|
185
|
+
claimId: optionalText,
|
|
186
|
+
claimedAt: optionalInteger,
|
|
187
|
+
claimExpiresAt: optionalInteger,
|
|
188
|
+
retentionUntil: integer,
|
|
189
|
+
},
|
|
190
|
+
[["jobId"]],
|
|
191
|
+
),
|
|
116
192
|
proofs: spec(
|
|
117
193
|
{
|
|
118
194
|
moduleId: text,
|
|
@@ -2,11 +2,23 @@ import { Context, Effect, Schema } from "effect";
|
|
|
2
2
|
import { SqlClient } from "effect/sql/SqlClient";
|
|
3
3
|
|
|
4
4
|
import { PersistenceConfigurationError } from "./configuration";
|
|
5
|
+
import type { PhysicalTextColumn } from "./native-sql-table";
|
|
5
6
|
import type { StorageTable } from "./storage-tables";
|
|
6
7
|
|
|
7
8
|
const Columns = Schema.Array(Schema.String);
|
|
8
9
|
const Keys = Schema.Array(Columns);
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
const TextColumns = Schema.Array(
|
|
12
|
+
Schema.Struct({ name: Schema.String, type: Schema.String, collation: Schema.String }),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const Metadata = Schema.Struct({
|
|
16
|
+
relation: Schema.String,
|
|
17
|
+
columns: Columns,
|
|
18
|
+
keys: Keys,
|
|
19
|
+
textColumns: TextColumns,
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
const PgMetadata = Schema.Array(Metadata);
|
|
11
23
|
|
|
12
24
|
const SqliteMetadata = Schema.Array(
|
|
@@ -14,10 +26,18 @@ const SqliteMetadata = Schema.Array(
|
|
|
14
26
|
relation: Schema.String,
|
|
15
27
|
columns: Schema.fromJsonString(Columns),
|
|
16
28
|
keys: Schema.fromJsonString(Keys),
|
|
29
|
+
textColumns: Schema.fromJsonString(TextColumns),
|
|
17
30
|
}),
|
|
18
31
|
);
|
|
19
32
|
|
|
20
|
-
const MysqlColumns = Schema.Array(
|
|
33
|
+
const MysqlColumns = Schema.Array(
|
|
34
|
+
Schema.Struct({
|
|
35
|
+
relation: Schema.String,
|
|
36
|
+
name: Schema.String,
|
|
37
|
+
type: Schema.String,
|
|
38
|
+
collation: Schema.NullOr(Schema.String),
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
21
41
|
|
|
22
42
|
const MysqlIndexes = Schema.Array(
|
|
23
43
|
Schema.Struct({
|
|
@@ -101,28 +121,32 @@ export interface StorageValidation {
|
|
|
101
121
|
readonly required: ReadonlyArray<ReadonlyArray<string>>;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
type Dialect = "pg" | "mysql" | "sqlite";
|
|
125
|
+
|
|
126
|
+
/** Share physical type, collation and key observations only during acquisition. */
|
|
127
|
+
const readStorageMetadata = Effect.fnUntraced(
|
|
128
|
+
function* (
|
|
129
|
+
dialect: Dialect,
|
|
130
|
+
physical: ReadonlyArray<Pick<PhysicalStorageTable, "name" | "schema">>,
|
|
131
|
+
) {
|
|
109
132
|
if (
|
|
110
133
|
dialect === "sqlite" &&
|
|
111
|
-
|
|
134
|
+
physical.some((table) => table.schema !== undefined && table.schema !== "main")
|
|
112
135
|
)
|
|
113
136
|
return yield* PersistenceConfigurationError.make({
|
|
114
137
|
reason: "SQLite storage must belong to the main database",
|
|
115
138
|
});
|
|
116
|
-
const
|
|
139
|
+
const captured = yield* SqlClient;
|
|
140
|
+
const client = captured.withoutTransforms();
|
|
117
141
|
const cache = yield* CurrentMetadata;
|
|
118
142
|
|
|
119
143
|
const byRelation = cache?.active
|
|
120
|
-
? (cache.clients.get(
|
|
144
|
+
? (cache.clients.get(captured) ?? new Map<string, typeof Metadata.Type>())
|
|
121
145
|
: new Map<string, typeof Metadata.Type>();
|
|
122
146
|
|
|
123
|
-
if (cache?.active) cache.clients.set(
|
|
147
|
+
if (cache?.active) cache.clients.set(captured, byRelation);
|
|
124
148
|
|
|
125
|
-
const tables = new Map(
|
|
149
|
+
const tables = new Map(physical.map((table) => [qualifiedTableName(table), table]));
|
|
126
150
|
|
|
127
151
|
const missing = [...tables.entries()].filter(
|
|
128
152
|
([relation]) => !byRelation.has(`${dialect}:${relation}`),
|
|
@@ -151,7 +175,11 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
151
175
|
from pragma_index_list(requested.name) i
|
|
152
176
|
where i."unique" = 1 and i.partial = 0
|
|
153
177
|
and not exists(select 1 from pragma_index_info(i.name) where name is null)
|
|
154
|
-
) storage_key where json_array_length(storage_key.columns) > 0) as keys
|
|
178
|
+
) storage_key where json_array_length(storage_key.columns) > 0) as keys,
|
|
179
|
+
(select json_group_array(json_object('name', c.name, 'type', 'TEXT', 'collation', 'BINARY'))
|
|
180
|
+
from pragma_table_info(requested.name) c
|
|
181
|
+
join sqlite_schema s on s.name = requested.name and s.type = 'table'
|
|
182
|
+
where upper(c.type) = 'TEXT' and instr(lower(s.sql), 'collate') = 0) as "textColumns"
|
|
155
183
|
from requested
|
|
156
184
|
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(SqliteMetadata)));
|
|
157
185
|
})
|
|
@@ -168,7 +196,8 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
168
196
|
);
|
|
169
197
|
|
|
170
198
|
const columns = yield* client`
|
|
171
|
-
select requested.relation, c.column_name as name
|
|
199
|
+
select requested.relation, c.column_name as name,
|
|
200
|
+
c.data_type as type, c.collation_name as collation
|
|
172
201
|
from (${requested}) requested join information_schema.columns c
|
|
173
202
|
on c.table_schema = coalesce(requested.schemaName, database()) and c.table_name = requested.tableName
|
|
174
203
|
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(MysqlColumns)));
|
|
@@ -196,6 +225,13 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
196
225
|
columns: columns
|
|
197
226
|
.filter((column) => column.relation === relation)
|
|
198
227
|
.map((column) => column.name),
|
|
228
|
+
textColumns: columns.flatMap((column) =>
|
|
229
|
+
column.relation === relation &&
|
|
230
|
+
column.collation !== null &&
|
|
231
|
+
["varchar", "text", "tinytext", "mediumtext", "longtext"].includes(column.type)
|
|
232
|
+
? [{ name: column.name, type: column.type, collation: column.collation }]
|
|
233
|
+
: [],
|
|
234
|
+
),
|
|
199
235
|
keys: [...grouped.values()]
|
|
200
236
|
.filter((entries) =>
|
|
201
237
|
entries.every((entry) => entry.columnName !== null && entry.prefix === null),
|
|
@@ -229,7 +265,12 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
229
265
|
and i.indpred is null and i.indexprs is null
|
|
230
266
|
and k.ordinality <= i.indnkeyatts
|
|
231
267
|
group by i.indexrelid
|
|
232
|
-
) storage_key), '[]'::jsonb) as keys
|
|
268
|
+
) storage_key), '[]'::jsonb) as keys,
|
|
269
|
+
coalesce((select jsonb_agg(jsonb_build_object(
|
|
270
|
+
'name', a.attname::text, 'type', a.atttypid::text, 'collation', a.attcollation::text))
|
|
271
|
+
from pg_attribute a where a.attrelid = to_regclass(requested.relation)
|
|
272
|
+
and a.attnum > 0 and not a.attisdropped
|
|
273
|
+
and a.atttypid in ('text'::regtype, 'varchar'::regtype)), '[]'::jsonb) as "textColumns"
|
|
233
274
|
from (values ${requested}) requested(relation)
|
|
234
275
|
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgMetadata)));
|
|
235
276
|
});
|
|
@@ -237,18 +278,7 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
237
278
|
for (const row of metadata) byRelation.set(`${dialect}:${row.relation}`, row);
|
|
238
279
|
}
|
|
239
280
|
|
|
240
|
-
|
|
241
|
-
const relation = qualifiedTableName(table);
|
|
242
|
-
const actual = byRelation.get(`${dialect}:${relation}`);
|
|
243
|
-
|
|
244
|
-
for (const column of Object.values(table.columns)) {
|
|
245
|
-
if (actual === undefined || !actual.columns.includes(column.name))
|
|
246
|
-
return yield* PersistenceConfigurationError.make({
|
|
247
|
-
reason: `Missing SQL column ${relation}.${column.name}`,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
yield* validateKeys(table, required, actual?.keys ?? []);
|
|
251
|
-
}
|
|
281
|
+
return byRelation;
|
|
252
282
|
},
|
|
253
283
|
Effect.mapError((error) =>
|
|
254
284
|
Schema.is(PersistenceConfigurationError)(error)
|
|
@@ -257,6 +287,76 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
257
287
|
),
|
|
258
288
|
);
|
|
259
289
|
|
|
290
|
+
/** Batch physical metadata reads, reusing only the current acquisition snapshot.
|
|
291
|
+
* Every requirement is still checked; later acquisitions read fresh metadata. */
|
|
292
|
+
export const validateStorageBatch = Effect.fnUntraced(function* (
|
|
293
|
+
dialect: Dialect,
|
|
294
|
+
requirements: ReadonlyArray<StorageValidation>,
|
|
295
|
+
) {
|
|
296
|
+
if (requirements.length === 0) return;
|
|
297
|
+
|
|
298
|
+
const byRelation = yield* readStorageMetadata(
|
|
299
|
+
dialect,
|
|
300
|
+
requirements.map(({ table }) => table),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
for (const { table, required } of requirements) {
|
|
304
|
+
const relation = qualifiedTableName(table);
|
|
305
|
+
const actual = byRelation.get(`${dialect}:${relation}`);
|
|
306
|
+
|
|
307
|
+
for (const column of Object.values(table.columns)) {
|
|
308
|
+
if (actual === undefined || !actual.columns.includes(column.name))
|
|
309
|
+
return yield* PersistenceConfigurationError.make({
|
|
310
|
+
reason: `Missing SQL column ${relation}.${column.name}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
yield* validateKeys(table, required, actual?.keys ?? []);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/** Candidate declarations do not establish SQL equality compatibility. SQLite
|
|
318
|
+
* omits column collations, so only TEXT tables without COLLATE qualify. */
|
|
319
|
+
export const canJoinTextColumns = Effect.fnUntraced(function* (
|
|
320
|
+
columns: ReadonlyArray<PhysicalTextColumn | undefined>,
|
|
321
|
+
) {
|
|
322
|
+
const candidates = columns.filter((column) => column !== undefined);
|
|
323
|
+
|
|
324
|
+
if (candidates.length !== columns.length || candidates.length === 0) return false;
|
|
325
|
+
const client = yield* SqlClient;
|
|
326
|
+
|
|
327
|
+
const dialect = client.onDialectOrElse({
|
|
328
|
+
pg: () => "pg" as const,
|
|
329
|
+
mysql: () => "mysql" as const,
|
|
330
|
+
sqlite: () => "sqlite" as const,
|
|
331
|
+
orElse: () => undefined,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
dialect === undefined ||
|
|
336
|
+
(dialect === "sqlite" &&
|
|
337
|
+
candidates.some(({ table }) => table.schema !== undefined && table.schema !== "main"))
|
|
338
|
+
)
|
|
339
|
+
return false;
|
|
340
|
+
|
|
341
|
+
const metadata = yield* readStorageMetadata(
|
|
342
|
+
dialect,
|
|
343
|
+
candidates.map(({ table }) => table),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const physical = candidates.map(({ table, name }) =>
|
|
347
|
+
metadata
|
|
348
|
+
.get(`${dialect}:${qualifiedTableName(table)}`)
|
|
349
|
+
?.textColumns.find((column) => column.name === name),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const first = physical[0];
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
first !== undefined &&
|
|
356
|
+
physical.every((column) => column?.type === first.type && column.collation === first.collation)
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
260
360
|
/** Validate physical columns and unconditional uniqueness before operations run. */
|
|
261
361
|
export const validateStorage = (
|
|
262
362
|
dialect: "pg" | "mysql" | "sqlite",
|