@yielded/auth-persistence 0.1.0-beta.10

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 (182) hide show
  1. package/README.md +27 -0
  2. package/dist/Adapter.d.mts +30 -0
  3. package/dist/Adapter.mjs +25 -0
  4. package/dist/index.d.mts +7 -0
  5. package/dist/index.mjs +7 -0
  6. package/dist/internal/PasswordPreparedPostconditions.d.mts +22 -0
  7. package/dist/internal/PasswordPreparedPostconditions.mjs +12 -0
  8. package/dist/internal/PasswordPreparedPostconditions.mjs.map +1 -0
  9. package/dist/internal/configuration.d.mts +174 -0
  10. package/dist/internal/configuration.mjs +7 -0
  11. package/dist/internal/configuration.mjs.map +1 -0
  12. package/dist/internal/email-kernel.d.mts +275 -0
  13. package/dist/internal/email-kernel.mjs +487 -0
  14. package/dist/internal/email-kernel.mjs.map +1 -0
  15. package/dist/internal/mapping-error.d.mts +10 -0
  16. package/dist/internal/mapping-error.mjs +30 -0
  17. package/dist/internal/mapping-error.mjs.map +1 -0
  18. package/dist/internal/models/common.d.mts +12 -0
  19. package/dist/internal/models/email-model.d.mts +353 -0
  20. package/dist/internal/models/email-model.mjs +21 -0
  21. package/dist/internal/models/email-model.mjs.map +1 -0
  22. package/dist/internal/models/passkey-model.d.mts +266 -0
  23. package/dist/internal/models/passkey-model.mjs +34 -0
  24. package/dist/internal/models/passkey-model.mjs.map +1 -0
  25. package/dist/internal/models/passkey-registration-ceremony-model.d.mts +49 -0
  26. package/dist/internal/models/passkey-registration-ceremony-model.mjs +18 -0
  27. package/dist/internal/models/passkey-registration-ceremony-model.mjs.map +1 -0
  28. package/dist/internal/models/passkey-write-model.d.mts +189 -0
  29. package/dist/internal/models/passkey-write-model.mjs +13 -0
  30. package/dist/internal/models/passkey-write-model.mjs.map +1 -0
  31. package/dist/internal/models/password-model.d.mts +328 -0
  32. package/dist/internal/models/password-model.mjs +23 -0
  33. package/dist/internal/models/password-model.mjs.map +1 -0
  34. package/dist/internal/models/proof-model.d.mts +285 -0
  35. package/dist/internal/models/proof-model.mjs +17 -0
  36. package/dist/internal/models/proof-model.mjs.map +1 -0
  37. package/dist/internal/models/session-model.d.mts +199 -0
  38. package/dist/internal/models/session-model.mjs +20 -0
  39. package/dist/internal/models/session-model.mjs.map +1 -0
  40. package/dist/internal/models/step-up-model.d.mts +66 -0
  41. package/dist/internal/models/step-up-model.mjs +9 -0
  42. package/dist/internal/models/step-up-model.mjs.map +1 -0
  43. package/dist/internal/oauth-app.d.mts +11 -0
  44. package/dist/internal/oauth-app.mjs +57 -0
  45. package/dist/internal/oauth-app.mjs.map +1 -0
  46. package/dist/internal/oauth-server.d.mts +10 -0
  47. package/dist/internal/oauth-server.mjs +66 -0
  48. package/dist/internal/oauth-server.mjs.map +1 -0
  49. package/dist/internal/passkey/admission.d.mts +6 -0
  50. package/dist/internal/passkey/admission.mjs +216 -0
  51. package/dist/internal/passkey/admission.mjs.map +1 -0
  52. package/dist/internal/passkey/credentials.d.mts +14 -0
  53. package/dist/internal/passkey/credentials.mjs +249 -0
  54. package/dist/internal/passkey/credentials.mjs.map +1 -0
  55. package/dist/internal/passkey/enrollment.mjs +138 -0
  56. package/dist/internal/passkey/enrollment.mjs.map +1 -0
  57. package/dist/internal/passkey/flow.d.mts +21 -0
  58. package/dist/internal/passkey/flow.mjs +452 -0
  59. package/dist/internal/passkey/flow.mjs.map +1 -0
  60. package/dist/internal/passkey/management.mjs +231 -0
  61. package/dist/internal/passkey/management.mjs.map +1 -0
  62. package/dist/internal/passkey/registration-ceremony.d.mts +9 -0
  63. package/dist/internal/passkey/registration-ceremony.mjs +129 -0
  64. package/dist/internal/passkey/registration-ceremony.mjs.map +1 -0
  65. package/dist/internal/passkey/registration-custody.d.mts +2 -0
  66. package/dist/internal/passkey/registration-custody.mjs +52 -0
  67. package/dist/internal/passkey/registration-custody.mjs.map +1 -0
  68. package/dist/internal/passkey/registration-write.mjs +211 -0
  69. package/dist/internal/passkey/registration-write.mjs.map +1 -0
  70. package/dist/internal/passkey/state.d.mts +10 -0
  71. package/dist/internal/passkey/state.mjs +234 -0
  72. package/dist/internal/passkey/state.mjs.map +1 -0
  73. package/dist/internal/passkey/target.d.mts +19 -0
  74. package/dist/internal/passkey/target.mjs +190 -0
  75. package/dist/internal/passkey/target.mjs.map +1 -0
  76. package/dist/internal/passkey/write-state.d.mts +17 -0
  77. package/dist/internal/passkey/write-state.mjs +345 -0
  78. package/dist/internal/passkey/write-state.mjs.map +1 -0
  79. package/dist/internal/passkey/write-target.mjs +192 -0
  80. package/dist/internal/passkey/write-target.mjs.map +1 -0
  81. package/dist/internal/passkey-kernel.d.mts +5592 -0
  82. package/dist/internal/passkey-kernel.mjs +49 -0
  83. package/dist/internal/passkey-kernel.mjs.map +1 -0
  84. package/dist/internal/passkeys.d.mts +7 -0
  85. package/dist/internal/passkeys.mjs +368 -0
  86. package/dist/internal/passkeys.mjs.map +1 -0
  87. package/dist/internal/password-kernel.d.mts +290 -0
  88. package/dist/internal/password-kernel.mjs +704 -0
  89. package/dist/internal/password-kernel.mjs.map +1 -0
  90. package/dist/internal/persistence.d.mts +25 -0
  91. package/dist/internal/persistence.mjs +291 -0
  92. package/dist/internal/persistence.mjs.map +1 -0
  93. package/dist/internal/phone-kernel.d.mts +162 -0
  94. package/dist/internal/phone-kernel.mjs +434 -0
  95. package/dist/internal/phone-kernel.mjs.map +1 -0
  96. package/dist/internal/proof-kernel.d.mts +93 -0
  97. package/dist/internal/proof-kernel.mjs +619 -0
  98. package/dist/internal/proof-kernel.mjs.map +1 -0
  99. package/dist/internal/query-operations.d.mts +61 -0
  100. package/dist/internal/registration-contract.d.mts +16 -0
  101. package/dist/internal/registration.mjs +66 -0
  102. package/dist/internal/registration.mjs.map +1 -0
  103. package/dist/internal/session-kernel.d.mts +48 -0
  104. package/dist/internal/session-kernel.mjs +737 -0
  105. package/dist/internal/session-kernel.mjs.map +1 -0
  106. package/dist/internal/sql-persistence.d.mts +10 -0
  107. package/dist/internal/sql-persistence.mjs +37 -0
  108. package/dist/internal/sql-persistence.mjs.map +1 -0
  109. package/dist/internal/sql-query.mjs +207 -0
  110. package/dist/internal/sql-query.mjs.map +1 -0
  111. package/dist/internal/sql-table.d.mts +36 -0
  112. package/dist/internal/sql-table.mjs +81 -0
  113. package/dist/internal/sql-table.mjs.map +1 -0
  114. package/dist/internal/standalone.d.mts +9 -0
  115. package/dist/internal/standalone.mjs +13 -0
  116. package/dist/internal/standalone.mjs.map +1 -0
  117. package/dist/internal/step-up-state.d.mts +49 -0
  118. package/dist/internal/step-up-state.mjs +32 -0
  119. package/dist/internal/step-up-state.mjs.map +1 -0
  120. package/dist/internal/storage-mapping.mjs +686 -0
  121. package/dist/internal/storage-mapping.mjs.map +1 -0
  122. package/dist/internal/storage-tables.d.mts +913 -0
  123. package/dist/internal/storage-tables.mjs +387 -0
  124. package/dist/internal/storage-tables.mjs.map +1 -0
  125. package/dist/internal/storage-validation.mjs +58 -0
  126. package/dist/internal/storage-validation.mjs.map +1 -0
  127. package/dist/internal/transaction-execution-kernel.d.mts +38 -0
  128. package/dist/internal/transaction-execution-kernel.mjs +116 -0
  129. package/dist/internal/transaction-execution-kernel.mjs.map +1 -0
  130. package/dist/internal/transaction-kernel.d.mts +77 -0
  131. package/dist/internal/transaction-kernel.mjs +260 -0
  132. package/dist/internal/transaction-kernel.mjs.map +1 -0
  133. package/package.json +1 -0
  134. package/src/Adapter.ts +291 -0
  135. package/src/index.ts +6 -0
  136. package/src/internal/PasswordPreparedPostconditions.ts +30 -0
  137. package/src/internal/configuration.ts +282 -0
  138. package/src/internal/email-kernel.ts +1535 -0
  139. package/src/internal/mapping-error.ts +36 -0
  140. package/src/internal/models/common.ts +11 -0
  141. package/src/internal/models/email-model.ts +559 -0
  142. package/src/internal/models/passkey-model.ts +390 -0
  143. package/src/internal/models/passkey-registration-ceremony-model.ts +72 -0
  144. package/src/internal/models/passkey-write-model.ts +297 -0
  145. package/src/internal/models/password-model.ts +513 -0
  146. package/src/internal/models/proof-model.ts +424 -0
  147. package/src/internal/models/session-model.ts +375 -0
  148. package/src/internal/models/step-up-model.ts +135 -0
  149. package/src/internal/oauth-app.ts +82 -0
  150. package/src/internal/oauth-server.ts +89 -0
  151. package/src/internal/passkey/admission.ts +361 -0
  152. package/src/internal/passkey/credentials.ts +511 -0
  153. package/src/internal/passkey/enrollment.ts +326 -0
  154. package/src/internal/passkey/flow.ts +1062 -0
  155. package/src/internal/passkey/management.ts +421 -0
  156. package/src/internal/passkey/registration-ceremony.ts +283 -0
  157. package/src/internal/passkey/registration-custody.ts +91 -0
  158. package/src/internal/passkey/registration-write.ts +426 -0
  159. package/src/internal/passkey/state.ts +353 -0
  160. package/src/internal/passkey/target.ts +586 -0
  161. package/src/internal/passkey/write-state.ts +681 -0
  162. package/src/internal/passkey/write-target.ts +452 -0
  163. package/src/internal/passkey-kernel.ts +104 -0
  164. package/src/internal/passkeys.ts +606 -0
  165. package/src/internal/password-kernel.ts +2124 -0
  166. package/src/internal/persistence.ts +588 -0
  167. package/src/internal/phone-kernel.ts +846 -0
  168. package/src/internal/proof-kernel.ts +1954 -0
  169. package/src/internal/query-operations.ts +81 -0
  170. package/src/internal/registration-contract.ts +22 -0
  171. package/src/internal/registration.ts +161 -0
  172. package/src/internal/session-kernel.ts +2217 -0
  173. package/src/internal/sql-persistence.ts +45 -0
  174. package/src/internal/sql-query.ts +298 -0
  175. package/src/internal/sql-table.ts +104 -0
  176. package/src/internal/standalone.ts +30 -0
  177. package/src/internal/step-up-state.ts +139 -0
  178. package/src/internal/storage-mapping.ts +708 -0
  179. package/src/internal/storage-tables.ts +408 -0
  180. package/src/internal/storage-validation.ts +117 -0
  181. package/src/internal/transaction-execution-kernel.ts +313 -0
  182. package/src/internal/transaction-kernel.ts +593 -0
@@ -0,0 +1,45 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql";
3
+
4
+ import { PersistenceConfigurationError, type PersistenceApi } from "./configuration";
5
+ import { makeComposedPasskeys } from "./passkeys";
6
+ import { createPersistence } from "./persistence";
7
+ import { makeSqlDatabase, sqlQueryOperations } from "./sql-query";
8
+ import { Table } from "./sql-table";
9
+ import type { StorageTable } from "./storage-tables";
10
+
11
+ const table = (definition: StorageTable) =>
12
+ new Table(definition.name, definition.columns, definition.unique, definition.schema);
13
+
14
+ export const AuthPersistence: PersistenceApi<Table> & { readonly table: typeof table } = {
15
+ ...createPersistence({
16
+ makeTable: table,
17
+ describe: (table: Table): StorageTable => ({
18
+ name: table.name,
19
+ ...(table.schema === undefined ? {} : { schema: table.schema }),
20
+ columns: Object.fromEntries(
21
+ Object.entries(table.columns).map(([key, column]) => [key, column.options]),
22
+ ),
23
+ unique: table.unique,
24
+ }),
25
+ operations: sqlQueryOperations,
26
+ passkeys: makeComposedPasskeys(sqlQueryOperations),
27
+ acquire: Effect.gen(function* () {
28
+ const sql = yield* SqlClient.SqlClient;
29
+
30
+ const dialect = sql.onDialectOrElse({
31
+ pg: () => "pg" as const,
32
+ sqlite: () => "sqlite" as const,
33
+ orElse: () => undefined,
34
+ });
35
+
36
+ if (dialect === undefined)
37
+ return yield* PersistenceConfigurationError.make({
38
+ reason: "Use an explicit adapter for this SQL dialect",
39
+ });
40
+
41
+ return makeSqlDatabase(sql, dialect);
42
+ }),
43
+ }),
44
+ table,
45
+ };
@@ -0,0 +1,298 @@
1
+ import { Effect, Predicate } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql/SqlClient";
3
+ import type { SqlError } from "effect/unstable/sql/SqlError";
4
+
5
+ import { PersistenceMappingError } from "./mapping-error";
6
+ import type { QueryOperations } from "./query-operations";
7
+ import {
8
+ type Column,
9
+ Fragment,
10
+ Table,
11
+ identifier,
12
+ type Compiler,
13
+ type Dialect,
14
+ type Row,
15
+ } from "./sql-table";
16
+
17
+ const render = (value: unknown, compiler: Compiler): string => {
18
+ if (value instanceof Fragment) return value.render(compiler);
19
+ if (Predicate.hasProperty(value, "getSQL") && typeof value.getSQL === "function") {
20
+ return render(value.getSQL(), compiler);
21
+ }
22
+
23
+ compiler.parameters.push(
24
+ compiler.dialect === "sqlite" && typeof value === "boolean" ? Number(value) : value,
25
+ );
26
+
27
+ return compiler.dialect === "pg" ? `$${compiler.parameters.length}` : "?";
28
+ };
29
+
30
+ const template = (parts: TemplateStringsArray, ...values: unknown[]): Fragment =>
31
+ new Fragment((compiler) =>
32
+ parts.reduce(
33
+ (sql, part, i) => sql + (i === 0 ? "" : render(values[i - 1], compiler)) + part,
34
+ "",
35
+ ),
36
+ );
37
+
38
+ const combine = (separator: string, values: ReadonlyArray<Fragment | undefined>) => {
39
+ const fragments = values.filter((value) => value !== undefined);
40
+
41
+ return fragments.length === 0
42
+ ? undefined
43
+ : new Fragment(
44
+ (compiler) =>
45
+ "(" + fragments.map((fragment) => fragment.render(compiler)).join(separator) + ")",
46
+ );
47
+ };
48
+
49
+ const and = (...values: ReadonlyArray<Fragment | undefined>) => combine(" AND ", values);
50
+ const or = (...values: ReadonlyArray<Fragment | undefined>) => combine(" OR ", values);
51
+
52
+ const operations = {
53
+ and,
54
+ or,
55
+ asc: (value: unknown) => template`${value} ASC`,
56
+ eq: (left: unknown, right: unknown) => template`${left} = ${right}`,
57
+ gt: (left: unknown, right: unknown) => template`${left} > ${right}`,
58
+ gte: (left: unknown, right: unknown) => template`${left} >= ${right}`,
59
+ lte: (left: unknown, right: unknown) => template`${left} <= ${right}`,
60
+ isNull: (value: unknown) => template`${value} IS NULL`,
61
+ notExists: (value: unknown) => template`NOT EXISTS (${value})`,
62
+ inArray: (column: unknown, values: ReadonlyArray<unknown>) =>
63
+ values.length === 0
64
+ ? template`false`
65
+ : new Fragment(
66
+ (compiler) =>
67
+ `${render(column, compiler)} IN (${values.map((value) => render(value, compiler)).join(", ")})`,
68
+ ),
69
+ sql: Object.assign(template, {
70
+ param: (value: unknown, column: Column) => column.mapToDriverValue(value),
71
+ join: (values: ReadonlyArray<unknown>, separator: Fragment = template``) =>
72
+ new Fragment((compiler) =>
73
+ values
74
+ .map(
75
+ (value, index) =>
76
+ (index === 0 ? "" : separator.render(compiler)) + render(value, compiler),
77
+ )
78
+ .join(""),
79
+ ),
80
+ }),
81
+ getTableColumns: (table: Table) => table.columns,
82
+ column: (table: Table, key: string) => {
83
+ const column = table.columns[key];
84
+
85
+ if (column === undefined)
86
+ throw PersistenceMappingError.make({ operation: "column", cause: key });
87
+
88
+ return column;
89
+ },
90
+ updateValues: (entries: ReadonlyArray<readonly [string, unknown]>) => Object.fromEntries(entries),
91
+ balancedD1And: and,
92
+ // Raw clients use interactive transactions, so no D1 batch compaction is needed.
93
+ compactD1GeneratedStatement: <S>(_client: unknown, statement: S) => statement,
94
+ };
95
+
96
+ // The existing kernels erase query-builder shapes at this boundary. The raw compiler
97
+ // implements precisely those operations; no row decoding or Effect requirements are cast.
98
+ export const sqlQueryOperations = operations as unknown as QueryOperations;
99
+
100
+ type Selection = Readonly<Record<string, Fragment>>;
101
+
102
+ interface QueryState {
103
+ readonly operation: "select" | "insert" | "update" | "delete";
104
+ readonly table?: Table | Fragment;
105
+ readonly joins?: ReadonlyArray<{
106
+ readonly table: Table | Fragment;
107
+ readonly on: Fragment;
108
+ }>;
109
+ readonly selection?: Selection;
110
+ readonly where?: Fragment;
111
+ readonly values?: Row;
112
+ readonly returning?: Selection | true;
113
+ readonly order?: ReadonlyArray<Fragment>;
114
+ readonly limit?: number;
115
+ readonly lock?: boolean;
116
+ readonly conflict?: boolean;
117
+ }
118
+
119
+ const selectionFor = (state: QueryState): Selection =>
120
+ state.selection ?? (state.table instanceof Table ? state.table.columns : {});
121
+
122
+ const selectionFields = (selection: Selection) => {
123
+ const used = new Set(Object.keys(selection));
124
+ let index = 0;
125
+
126
+ return Object.entries(selection).flatMap<{
127
+ readonly key: string;
128
+ readonly name: string | undefined;
129
+ readonly column: Fragment;
130
+ readonly alias: string;
131
+ }>(([key, value]) =>
132
+ value instanceof Table
133
+ ? Object.entries(value.columns).map(([name, column]) => {
134
+ let alias: string;
135
+
136
+ do alias = `auth_column_${index++}`;
137
+ while (used.has(alias));
138
+ used.add(alias);
139
+
140
+ return { key, name, column, alias };
141
+ })
142
+ : [{ key, name: undefined, column: value, alias: key }],
143
+ );
144
+ };
145
+
146
+ const projection = (selection: Selection, compiler: Compiler) =>
147
+ selectionFields(selection)
148
+ .map(({ column, alias }) => `${column.render(compiler)} AS ${identifier(alias)}`)
149
+ .join(", ");
150
+
151
+ // Joined table selections retain their own column codecs and nested row names.
152
+ const decodeRow = (selection: Selection, row: Row): Row => {
153
+ const entries: Array<readonly [string, unknown]> = [];
154
+ const groups = new Map<string, Array<readonly [string, unknown]>>();
155
+
156
+ for (const field of selectionFields(selection)) {
157
+ const value = field.column.decode(row[field.alias]);
158
+
159
+ if (field.name === undefined) entries.push([field.key, value]);
160
+ else {
161
+ const group = groups.get(field.key) ?? [];
162
+
163
+ group.push([field.name, value]);
164
+ groups.set(field.key, group);
165
+ }
166
+ }
167
+
168
+ return Object.fromEntries([
169
+ ...entries,
170
+ ...Array.from(groups, ([key, values]) => [key, Object.fromEntries(values)]),
171
+ ]);
172
+ };
173
+
174
+ const compile = (state: QueryState, compiler: Compiler): string => {
175
+ const table = state.table;
176
+
177
+ if (table === undefined)
178
+ throw PersistenceMappingError.make({ operation: "query", cause: "Missing table" });
179
+ let query: string;
180
+
181
+ if (state.operation === "select") {
182
+ query = `SELECT ${projection(selectionFor(state), compiler)} FROM ${table.render(compiler)}`;
183
+ for (const join of state.joins ?? [])
184
+ query += ` INNER JOIN ${join.table.render(compiler)} ON ${join.on.render(compiler)}`;
185
+ } else {
186
+ if (!(table instanceof Table))
187
+ throw PersistenceMappingError.make({
188
+ operation: "query",
189
+ cause: "Mutation requires a table",
190
+ });
191
+ const target = table.render(compiler);
192
+
193
+ const fields = Object.entries(state.values ?? {}).filter(
194
+ ([key, value]) => table.columns[key] !== undefined && value !== undefined,
195
+ );
196
+
197
+ if (state.operation === "insert") {
198
+ query = `INSERT INTO ${target} (${fields.map(([key]) => identifier(table.columns[key].options.name)).join(", ")}) VALUES (${fields.map(([, value]) => render(value, compiler)).join(", ")})`;
199
+ } else if (state.operation === "update") {
200
+ query = `UPDATE ${target} SET ${fields.map(([key, value]) => `${identifier(table.columns[key].options.name)} = ${render(value, compiler)}`).join(", ")}`;
201
+ } else query = `DELETE FROM ${target}`;
202
+ }
203
+ if (state.where !== undefined) query += ` WHERE ${state.where.render(compiler)}`;
204
+ if (state.order?.length)
205
+ query += ` ORDER BY ${state.order.map((field) => field.render(compiler)).join(", ")}`;
206
+ if (state.limit !== undefined) query += ` LIMIT ${render(state.limit, compiler)}`;
207
+ if (state.conflict) query += " ON CONFLICT DO NOTHING";
208
+ if (state.returning)
209
+ query += ` RETURNING ${projection(state.returning === true && table instanceof Table ? table.columns : state.returning === true ? {} : state.returning, compiler)}`;
210
+ if (state.lock && compiler.dialect === "pg") query += " FOR UPDATE";
211
+
212
+ return query;
213
+ };
214
+
215
+ /** Captures one Effect SQL client. Its transaction service remains the physical owner. */
216
+ export const makeSqlDatabase = (client: SqlClient, dialect: Dialect): SqlDatabase => {
217
+ const query = (state: QueryState): Query => {
218
+ const execute = Effect.suspend(() => {
219
+ const compiler: Compiler = { dialect, parameters: [] };
220
+ const text = compile(state, compiler);
221
+
222
+ const selected: Selection =
223
+ state.operation === "select"
224
+ ? selectionFor(state)
225
+ : state.returning === true
226
+ ? state.table instanceof Table
227
+ ? state.table.columns
228
+ : {}
229
+ : state.returning || {};
230
+
231
+ return client
232
+ .unsafe<Row>(text, compiler.parameters)
233
+ .pipe(Effect.map((rows) => rows.map((row) => decodeRow(selected, row))));
234
+ });
235
+
236
+ return Object.assign(execute, {
237
+ toSQL: () => {
238
+ const compiler: Compiler = { dialect, parameters: [] };
239
+
240
+ return { sql: compile(state, compiler), params: compiler.parameters };
241
+ },
242
+ getSQL: () => new Fragment((compiler) => compile(state, compiler)),
243
+ from: (table: Table | Fragment) => query({ ...state, table }),
244
+ innerJoin: (table: Table | Fragment, on: Fragment) =>
245
+ query({ ...state, joins: [...(state.joins ?? []), { table, on }] }),
246
+ where: (where: Fragment | undefined) => query({ ...state, where }),
247
+ limit: (limit: number) => query({ ...state, limit }),
248
+ orderBy: (...order: Fragment[]) => query({ ...state, order }),
249
+ for: (_lock: "update") => query({ ...state, lock: true }),
250
+ values: (values: Row) => query({ ...state, values }),
251
+ set: (values: Row) => query({ ...state, values }),
252
+ returning: (returning: Selection | true = true) => query({ ...state, returning }),
253
+ onConflictDoNothing: () => query({ ...state, conflict: true }),
254
+ });
255
+ };
256
+
257
+ const database: SqlDatabase = {
258
+ $client: client,
259
+ select: (selection?: Selection) => query({ operation: "select", selection }),
260
+ insert: (table: Table) => query({ operation: "insert", table }),
261
+ update: (table: Table) => query({ operation: "update", table }),
262
+ delete: (table: Table) => query({ operation: "delete", table }),
263
+ transaction: <A, E, R>(
264
+ body: (database: SqlDatabase) => Effect.Effect<A, E, R>,
265
+ ): Effect.Effect<A, E | SqlError, R> =>
266
+ client.withTransaction(Effect.suspend(() => body(database))),
267
+ };
268
+
269
+ return database;
270
+ };
271
+
272
+ interface Query extends Effect.Effect<Row[], SqlError> {
273
+ toSQL(): { readonly sql: string; readonly params: ReadonlyArray<unknown> };
274
+ getSQL(): Fragment;
275
+ from(table: Table | Fragment): Query;
276
+ innerJoin(table: Table | Fragment, on: Fragment): Query;
277
+ where(where: Fragment | undefined): Query;
278
+ limit(limit: number): Query;
279
+ orderBy(...order: Fragment[]): Query;
280
+ for(lock: "update"): Query;
281
+ values(values: Row): Query;
282
+ set(values: Row): Query;
283
+ returning(returning?: Selection | true): Query;
284
+ onConflictDoNothing(): Query;
285
+ }
286
+
287
+ export interface SqlDatabase {
288
+ readonly $client: SqlClient;
289
+ select(selection?: Selection): Query;
290
+ insert(table: Table): Query;
291
+ update(table: Table): Query;
292
+ delete(table: Table): Query;
293
+ transaction<A, E, R>(
294
+ body: (database: SqlDatabase) => Effect.Effect<A, E, R>,
295
+ ): Effect.Effect<A, E | SqlError, R>;
296
+ }
297
+
298
+ export const sql = template;
@@ -0,0 +1,104 @@
1
+ import { Option, Schema, SchemaGetter } from "effect";
2
+
3
+ import { PersistenceMappingError } from "./mapping-error";
4
+
5
+ const SqlInteger = Schema.Union([
6
+ Schema.Int,
7
+ Schema.BigInt,
8
+ Schema.String.check(Schema.isPattern(/^-?\d+$/)),
9
+ ]).pipe(
10
+ Schema.decodeTo(Schema.Int, {
11
+ decode: SchemaGetter.transform(Number),
12
+ encode: SchemaGetter.passthrough(),
13
+ }),
14
+ );
15
+
16
+ const decodeInteger = Schema.decodeUnknownOption(SqlInteger);
17
+
18
+ export type Dialect = "pg" | "sqlite";
19
+ export type Row = Readonly<Record<string, unknown>>;
20
+
21
+ export interface Compiler {
22
+ readonly dialect: Dialect;
23
+ readonly parameters: unknown[];
24
+ }
25
+
26
+ export const identifier = (name: string) => '"' + name.replaceAll('"', '""') + '"';
27
+
28
+ export class Fragment {
29
+ constructor(
30
+ readonly render: (compiler: Compiler) => string,
31
+ readonly decode: (value: unknown) => unknown = (value) => value,
32
+ ) {}
33
+
34
+ mapWith(decode: (value: unknown) => unknown): Fragment {
35
+ return new Fragment(this.render, decode);
36
+ }
37
+
38
+ as(_name: string): Fragment {
39
+ return this;
40
+ }
41
+
42
+ getSQL(): Fragment {
43
+ return this;
44
+ }
45
+ }
46
+
47
+ export interface ColumnOptions {
48
+ readonly name: string;
49
+ readonly type: "text" | "integer" | "boolean";
50
+ readonly nullable?: boolean;
51
+ }
52
+
53
+ export class Column extends Fragment {
54
+ constructor(
55
+ readonly tableName: string,
56
+ readonly options: ColumnOptions,
57
+ readonly schema?: string,
58
+ ) {
59
+ super(
60
+ () =>
61
+ `${schema === undefined ? "" : identifier(schema) + "."}${identifier(tableName)}.${identifier(options.name)}`,
62
+ (value) => {
63
+ if (value === null) return value;
64
+ if (options.type === "boolean") {
65
+ if (value === true || value === 1) return true;
66
+ if (value === false || value === 0) return false;
67
+ throw PersistenceMappingError.make({ operation: "decode", cause: "Invalid SQL boolean" });
68
+ }
69
+ if (options.type === "integer") {
70
+ const number = decodeInteger(value);
71
+
72
+ if (Option.isSome(number)) return number.value;
73
+ throw PersistenceMappingError.make({
74
+ operation: "decode",
75
+ cause: "SQL integer is outside the safe range",
76
+ });
77
+ }
78
+
79
+ return value;
80
+ },
81
+ );
82
+ }
83
+
84
+ mapToDriverValue(value: unknown): unknown {
85
+ return value;
86
+ }
87
+ }
88
+
89
+ /** Plain SQL identifiers and column representations, independent of an ORM. */
90
+ export class Table extends Fragment {
91
+ readonly columns: Readonly<Record<string, Column>>;
92
+
93
+ constructor(
94
+ readonly name: string,
95
+ columns: Readonly<Record<string, ColumnOptions>>,
96
+ readonly unique: ReadonlyArray<ReadonlyArray<string>>,
97
+ readonly schema?: string,
98
+ ) {
99
+ super(() => (schema === undefined ? "" : identifier(schema) + ".") + identifier(name));
100
+ this.columns = Object.fromEntries(
101
+ Object.entries(columns).map(([key, options]) => [key, new Column(name, options, schema)]),
102
+ );
103
+ }
104
+ }
@@ -0,0 +1,30 @@
1
+ import { type Context, Effect } from "effect";
2
+ import type * as SqlClient from "effect/unstable/sql/SqlClient";
3
+
4
+ /** Standalone operations must observe their own durable commit. */
5
+ export const requireStandalone = <Failure>(
6
+ unavailable: () => Failure,
7
+ client?: {
8
+ readonly transactionService: Context.Key<
9
+ SqlClient.TransactionConnection,
10
+ SqlClient.TransactionConnection.Service
11
+ >;
12
+ },
13
+ ): Effect.Effect<void, Failure> =>
14
+ Effect.withFiber((fiber) => {
15
+ const services = fiber.context.mapUnsafe;
16
+
17
+ if (client !== undefined && services.has(client.transactionService.key))
18
+ return Effect.fail(unavailable());
19
+
20
+ // rc.117 libSQL uses a private per-client marker that is not exposed through
21
+ // SqlClient.transactionService. Conservatively reject every ambient libSQL
22
+ // transaction, including another client's, before issuing standalone writes.
23
+ // Recheck this upstream detail whenever the Effect driver is upgraded.
24
+ for (const key of services.keys()) {
25
+ if (key.startsWith("@effect/sql-libsql/LibsqlClient/LibsqlTransaction/"))
26
+ return Effect.fail(unavailable());
27
+ }
28
+
29
+ return Effect.void;
30
+ });
@@ -0,0 +1,139 @@
1
+ import {
2
+ SessionStepUpInvalid,
3
+ SessionUnavailable,
4
+ AuthenticationEvidence,
5
+ type AuthenticationRevision,
6
+ SessionMetadata,
7
+ SessionStepUpIntent,
8
+ SessionStepUpRequirement,
9
+ type SessionStepUpCompletionPlan,
10
+ type StatefulSessionRecord,
11
+ } from "@yielded/auth/Sessions";
12
+ import { DateTime, Effect, Schema } from "effect";
13
+
14
+ export const stepUpIntentCodec = Schema.fromJsonString(SessionStepUpIntent);
15
+ const BoundedSnapshot = Schema.String.check(Schema.isMaxLength(131072));
16
+
17
+ export const encodeStepUpIntent = (intent: SessionStepUpIntent) =>
18
+ Schema.encodeEffect(stepUpIntentCodec)(intent).pipe(
19
+ Effect.flatMap(Schema.decodeEffect(BoundedSnapshot)),
20
+ Effect.mapError(() => SessionUnavailable.make({})),
21
+ );
22
+
23
+ export const decodeStepUpIntent = (snapshot: string) =>
24
+ Schema.decodeEffect(BoundedSnapshot)(snapshot).pipe(
25
+ Effect.flatMap(Schema.decodeEffect(stepUpIntentCodec)),
26
+ Effect.mapError(() => SessionUnavailable.make({})),
27
+ );
28
+
29
+ export const sameStepUpRevision = (left: AuthenticationRevision, right: AuthenticationRevision) =>
30
+ left.subjectId === right.subjectId &&
31
+ left.securityRevision === right.securityRevision &&
32
+ left.credentials.length === right.credentials.length &&
33
+ new Set(left.credentials.map((c) => c.credentialId)).size === left.credentials.length &&
34
+ left.credentials.every((item) =>
35
+ right.credentials.some(
36
+ (other) => item.credentialId === other.credentialId && item.revision === other.revision,
37
+ ),
38
+ );
39
+
40
+ export const stepUpIntentLive = (
41
+ intent: SessionStepUpIntent,
42
+ kind: SessionStepUpIntent["sourceKind"],
43
+ now: DateTime.Utc,
44
+ ) => {
45
+ const n = DateTime.toEpochMillis(now),
46
+ authenticated = DateTime.toEpochMillis(intent.sourceAuthenticatedAt),
47
+ idle = DateTime.toEpochMillis(intent.sourceExpiresAt),
48
+ absolute = DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt),
49
+ expires = DateTime.toEpochMillis(intent.expiresAt);
50
+
51
+ return (
52
+ intent.sourceKind === kind &&
53
+ authenticated <= n &&
54
+ n < expires &&
55
+ expires <= idle &&
56
+ idle <= absolute
57
+ );
58
+ };
59
+
60
+ const evidenceEncoding = Schema.encodeSync(Schema.fromJsonString(AuthenticationEvidence));
61
+ const requirementEncoding = Schema.encodeSync(Schema.fromJsonString(SessionStepUpRequirement));
62
+ const metadataEncoding = Schema.encodeSync(Schema.fromJsonString(SessionMetadata));
63
+
64
+ /** Validate core-owned plans before driver allocation/SQL expansion. No bearer or Claims decoding. */
65
+ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(function* <Claims>(
66
+ plan: SessionStepUpCompletionPlan<Claims>,
67
+ ) {
68
+ const { intent, source, replacement, evidence } = plan;
69
+ const original = source.inspection;
70
+
71
+ if (
72
+ requirementEncoding(plan.profileRequirement) !== requirementEncoding(intent.requirement) ||
73
+ replacement.inspection.session.subjectId !== evidence.revision.subjectId ||
74
+ replacement.inspection.session.securityRevision !== evidence.revision.securityRevision ||
75
+ intent.revision.credentials.some(
76
+ (item) =>
77
+ !evidence.revision.credentials.some(
78
+ (actual) =>
79
+ actual.credentialId === item.credentialId && actual.revision === item.revision,
80
+ ),
81
+ )
82
+ )
83
+ return yield* SessionStepUpInvalid.make({});
84
+ if (
85
+ intent.sourceKind !== source.guard._tag ||
86
+ intent.sourceKind !== replacement._tag ||
87
+ intent.sourceSessionId !== original.session.sessionId ||
88
+ intent.sourceCredentialVersion !== original.credentialVersion ||
89
+ !sameStepUpRevision(intent.revision, original.provenance.evidence.revision) ||
90
+ intent.revision.subjectId !== evidence.revision.subjectId ||
91
+ intent.revision.securityRevision !== evidence.revision.securityRevision ||
92
+ intent.flowId !== evidence.flowId ||
93
+ intent.bindingDigest !== evidence.bindingDigest ||
94
+ DateTime.toEpochMillis(intent.sourceAuthenticatedAt) !==
95
+ DateTime.toEpochMillis(original.session.assurance.authenticatedAt) ||
96
+ DateTime.toEpochMillis(intent.sourceExpiresAt) !==
97
+ DateTime.toEpochMillis(original.session.expiresAt) ||
98
+ DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt) !==
99
+ DateTime.toEpochMillis(original.session.absoluteExpiresAt) ||
100
+ DateTime.toEpochMillis(intent.sourceAuthenticatedAt) !==
101
+ DateTime.toEpochMillis(replacement.inspection.session.assurance.authenticatedAt) ||
102
+ DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt) !==
103
+ DateTime.toEpochMillis(replacement.inspection.session.absoluteExpiresAt) ||
104
+ evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence)
105
+ )
106
+ return yield* SessionStepUpInvalid.make({});
107
+ if (
108
+ replacement._tag === "Stateful" &&
109
+ (source.guard._tag !== "Stateful" ||
110
+ source.guard.digest !== replacement.expectedDigest ||
111
+ source.guard.rowVersion !== replacement.expectedRowVersion ||
112
+ replacement.inspection.session.sessionId !== intent.sourceSessionId)
113
+ )
114
+ return yield* SessionStepUpInvalid.make({});
115
+ if (
116
+ replacement._tag === "StateAssistedSigned" &&
117
+ (replacement.tombstoneSessionId !== intent.sourceSessionId ||
118
+ DateTime.toEpochMillis(replacement.tombstoneUntil) !==
119
+ DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt))
120
+ )
121
+ return yield* SessionStepUpInvalid.make({});
122
+ if (
123
+ replacement._tag !== "Stateful" &&
124
+ replacement.inspection.session.sessionId === intent.sourceSessionId
125
+ )
126
+ return yield* SessionStepUpInvalid.make({});
127
+ });
128
+
129
+ /** Compare a consumer-decoded encoded update/readback with the intended rotation.
130
+ * Claims are consumer-owned; their lossless round trip remains the codec contract. */
131
+ export const stepUpRotationMatches = <Claims>(
132
+ plan: SessionStepUpCompletionPlan<Claims>,
133
+ record: StatefulSessionRecord<Claims>,
134
+ ) =>
135
+ plan.replacement._tag === "Stateful" &&
136
+ record.digest === plan.replacement.nextDigest &&
137
+ record.credentialVersion === plan.replacement.inspection.credentialVersion &&
138
+ metadataEncoding(record) === metadataEncoding(plan.replacement.inspection.session) &&
139
+ evidenceEncoding(record.provenance.evidence) === evidenceEncoding(plan.evidence);