authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 16:15:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 16:15:16-07:00
log57d25c934b74986423896a7dc433595ed09b9c8d
treef44703de106959cfe7d190f811cce0fdbcd92fb1
parenta26f0dc89fd46f88529823511cdd068c9cd2c151
signature Signed by PGP key B5690EEEBB952194

[codex] Structure persistence error correlation (#3439)

Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 451 insertions(+), 32 deletions(-)

apps/server/src/persistence/AuthPairingLinks.ts+78-6
...@@ -11,6 +11,7 @@ import { AuthEnvironmentScopes } from "@t3tools/contracts";...@@ -11,6 +11,7 @@ import { AuthEnvironmentScopes } from "@t3tools/contracts";
11import {11import {
12 type AuthPairingLinkRepositoryError,12 type AuthPairingLinkRepositoryError,
13 PersistenceDecodeError,13 PersistenceDecodeError,
14 type PersistenceErrorCorrelation,
14 PersistenceSqlError,15 PersistenceSqlError,
15} from "./Errors.ts";16} from "./Errors.ts";
1617
...@@ -66,6 +67,22 @@ export const GetAuthPairingLinkByCredentialInput = Schema.Struct({...@@ -66,6 +67,22 @@ export const GetAuthPairingLinkByCredentialInput = Schema.Struct({
66});67});
67export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type;68export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type;
6869
70const AuthPairingLinkRawDbRow = Schema.Struct({
71 id: Schema.String,
72 credential: Schema.Unknown,
73 method: Schema.Unknown,
74 scopes: Schema.Unknown,
75 subject: Schema.Unknown,
76 label: Schema.Unknown,
77 proofKeyThumbprint: Schema.Unknown,
78 createdAt: Schema.Unknown,
79 expiresAt: Schema.Unknown,
80 consumedAt: Schema.Unknown,
81 revokedAt: Schema.Unknown,
82});
83
84const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord);
85
69export class AuthPairingLinkRepository extends Context.Service<86export class AuthPairingLinkRepository extends Context.Service<
70 AuthPairingLinkRepository,87 AuthPairingLinkRepository,
71 {88 {
...@@ -87,11 +104,19 @@ export class AuthPairingLinkRepository extends Context.Service<...@@ -87,11 +104,19 @@ export class AuthPairingLinkRepository extends Context.Service<
87 }104 }
88>()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {}105>()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {}
89106
90function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {107function toPersistenceSqlOrDecodeError(
108 sqlOperation: string,
109 decodeOperation: string,
110 correlation?: PersistenceErrorCorrelation,
111) {
91 return (cause: unknown): AuthPairingLinkRepositoryError =>112 return (cause: unknown): AuthPairingLinkRepositoryError =>
92 Schema.isSchemaError(cause)113 Schema.isSchemaError(cause)
93 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)114 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)
94 : new PersistenceSqlError({ operation: sqlOperation, cause });115 : new PersistenceSqlError({
116 operation: sqlOperation,
117 ...(correlation === undefined ? {} : { correlation }),
118 cause,
119 });
95}120}
96121
97export const make = Effect.gen(function* () {122export const make = Effect.gen(function* () {
...@@ -132,7 +157,7 @@ export const make = Effect.gen(function* () {...@@ -132,7 +157,7 @@ export const make = Effect.gen(function* () {
132157
133 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({158 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
134 Request: ConsumeAuthPairingLinkInput,159 Request: ConsumeAuthPairingLinkInput,
135 Result: AuthPairingLinkRecord,160 Result: AuthPairingLinkRawDbRow,
136 execute: ({ credential, proofKeyThumbprint, consumedAt, now }) =>161 execute: ({ credential, proofKeyThumbprint, consumedAt, now }) =>
137 sql`162 sql`
138 UPDATE auth_pairing_links163 UPDATE auth_pairing_links
...@@ -162,7 +187,7 @@ export const make = Effect.gen(function* () {...@@ -162,7 +187,7 @@ export const make = Effect.gen(function* () {
162187
163 const listActivePairingLinkRows = SqlSchema.findAll({188 const listActivePairingLinkRows = SqlSchema.findAll({
164 Request: ListActiveAuthPairingLinksInput,189 Request: ListActiveAuthPairingLinksInput,
165 Result: AuthPairingLinkRecord,190 Result: AuthPairingLinkRawDbRow,
166 execute: ({ now }) =>191 execute: ({ now }) =>
167 sql`192 sql`
168 SELECT193 SELECT
...@@ -201,7 +226,7 @@ export const make = Effect.gen(function* () {...@@ -201,7 +226,7 @@ export const make = Effect.gen(function* () {
201226
202 const getPairingLinkRowByCredential = SqlSchema.findOneOption({227 const getPairingLinkRowByCredential = SqlSchema.findOneOption({
203 Request: GetAuthPairingLinkByCredentialInput,228 Request: GetAuthPairingLinkByCredentialInput,
204 Result: AuthPairingLinkRecord,229 Result: AuthPairingLinkRawDbRow,
205 execute: ({ credential }) =>230 execute: ({ credential }) =>
206 sql`231 sql`
207 SELECT232 SELECT
...@@ -227,6 +252,7 @@ export const make = Effect.gen(function* () {...@@ -227,6 +252,7 @@ export const make = Effect.gen(function* () {
227 toPersistenceSqlOrDecodeError(252 toPersistenceSqlOrDecodeError(
228 "AuthPairingLinkRepository.create:query",253 "AuthPairingLinkRepository.create:query",
229 "AuthPairingLinkRepository.create:encodeRequest",254 "AuthPairingLinkRepository.create:encodeRequest",
255 { pairingLinkId: input.id },
230 ),256 ),
231 ),257 ),
232 );258 );
...@@ -239,6 +265,22 @@ export const make = Effect.gen(function* () {...@@ -239,6 +265,22 @@ export const make = Effect.gen(function* () {
239 "AuthPairingLinkRepository.consumeAvailable:decodeRow",265 "AuthPairingLinkRepository.consumeAvailable:decodeRow",
240 ),266 ),
241 ),267 ),
268 Effect.flatMap((rowOption) =>
269 Option.match(rowOption, {
270 onNone: () => Effect.succeed(Option.none()),
271 onSome: (row) =>
272 decodeAuthPairingLinkDbRow(row).pipe(
273 Effect.mapError((cause) =>
274 PersistenceDecodeError.fromSchemaError(
275 "AuthPairingLinkRepository.consumeAvailable:decodeRow",
276 cause,
277 { pairingLinkId: row.id },
278 ),
279 ),
280 Effect.map(Option.some),
281 ),
282 }),
283 ),
242 );284 );
243285
244 const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) =>286 const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) =>
...@@ -249,6 +291,19 @@ export const make = Effect.gen(function* () {...@@ -249,6 +291,19 @@ export const make = Effect.gen(function* () {
249 "AuthPairingLinkRepository.listActive:decodeRows",291 "AuthPairingLinkRepository.listActive:decodeRows",
250 ),292 ),
251 ),293 ),
294 Effect.flatMap((rows) =>
295 Effect.forEach(rows, (row) =>
296 decodeAuthPairingLinkDbRow(row).pipe(
297 Effect.mapError((cause) =>
298 PersistenceDecodeError.fromSchemaError(
299 "AuthPairingLinkRepository.listActive:decodeRows",
300 cause,
301 { pairingLinkId: row.id },
302 ),
303 ),
304 ),
305 ),
306 ),
252 );307 );
253308
254 const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) =>309 const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) =>
...@@ -257,6 +312,7 @@ export const make = Effect.gen(function* () {...@@ -257,6 +312,7 @@ export const make = Effect.gen(function* () {
257 toPersistenceSqlOrDecodeError(312 toPersistenceSqlOrDecodeError(
258 "AuthPairingLinkRepository.revoke:query",313 "AuthPairingLinkRepository.revoke:query",
259 "AuthPairingLinkRepository.revoke:decodeRows",314 "AuthPairingLinkRepository.revoke:decodeRows",
315 { pairingLinkId: input.id },
260 ),316 ),
261 ),317 ),
262 Effect.map((rows) => rows.length > 0),318 Effect.map((rows) => rows.length > 0),
...@@ -270,6 +326,22 @@ export const make = Effect.gen(function* () {...@@ -270,6 +326,22 @@ export const make = Effect.gen(function* () {
270 "AuthPairingLinkRepository.getByCredential:decodeRow",326 "AuthPairingLinkRepository.getByCredential:decodeRow",
271 ),327 ),
272 ),328 ),
329 Effect.flatMap((rowOption) =>
330 Option.match(rowOption, {
331 onNone: () => Effect.succeed(Option.none()),
332 onSome: (row) =>
333 decodeAuthPairingLinkDbRow(row).pipe(
334 Effect.mapError((cause) =>
335 PersistenceDecodeError.fromSchemaError(
336 "AuthPairingLinkRepository.getByCredential:decodeRow",
337 cause,
338 { pairingLinkId: row.id },
339 ),
340 ),
341 Effect.map(Option.some),
342 ),
343 }),
344 ),
273 );345 );
274346
275 return {347 return {
apps/server/src/persistence/AuthSessions.ts+63-7
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16import {16import {
17 type AuthSessionRepositoryError,17 type AuthSessionRepositoryError,
18 PersistenceDecodeError,18 PersistenceDecodeError,
19 type PersistenceErrorCorrelation,
19 PersistenceSqlError,20 PersistenceSqlError,
20} from "./Errors.ts";21} from "./Errors.ts";
2122
...@@ -122,6 +123,25 @@ const AuthSessionDbRow = Schema.Struct({...@@ -122,6 +123,25 @@ const AuthSessionDbRow = Schema.Struct({
122 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),123 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
123});124});
124125
126const AuthSessionRawDbRow = Schema.Struct({
127 sessionId: Schema.String,
128 subject: Schema.Unknown,
129 scopes: Schema.Unknown,
130 method: Schema.Unknown,
131 clientLabel: Schema.Unknown,
132 clientIpAddress: Schema.Unknown,
133 clientUserAgent: Schema.Unknown,
134 clientDeviceType: Schema.Unknown,
135 clientOs: Schema.Unknown,
136 clientBrowser: Schema.Unknown,
137 issuedAt: Schema.Unknown,
138 expiresAt: Schema.Unknown,
139 lastConnectedAt: Schema.Unknown,
140 revokedAt: Schema.Unknown,
141});
142
143const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow);
144
125function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord {145function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord {
126 return {146 return {
127 sessionId: row.sessionId,147 sessionId: row.sessionId,
...@@ -143,11 +163,19 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco...@@ -143,11 +163,19 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco
143 };163 };
144}164}
145165
146function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {166function toPersistenceSqlOrDecodeError(
167 sqlOperation: string,
168 decodeOperation: string,
169 correlation?: PersistenceErrorCorrelation,
170) {
147 return (cause: unknown): AuthSessionRepositoryError =>171 return (cause: unknown): AuthSessionRepositoryError =>
148 Schema.isSchemaError(cause)172 Schema.isSchemaError(cause)
149 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)173 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)
150 : new PersistenceSqlError({ operation: sqlOperation, cause });174 : new PersistenceSqlError({
175 operation: sqlOperation,
176 ...(correlation === undefined ? {} : { correlation }),
177 cause,
178 });
151}179}
152180
153export const make = Effect.gen(function* () {181export const make = Effect.gen(function* () {
...@@ -192,7 +220,7 @@ export const make = Effect.gen(function* () {...@@ -192,7 +220,7 @@ export const make = Effect.gen(function* () {
192220
193 const getSessionRowById = SqlSchema.findOneOption({221 const getSessionRowById = SqlSchema.findOneOption({
194 Request: GetAuthSessionByIdInput,222 Request: GetAuthSessionByIdInput,
195 Result: AuthSessionDbRow,223 Result: AuthSessionRawDbRow,
196 execute: ({ sessionId }) =>224 execute: ({ sessionId }) =>
197 sql`225 sql`
198 SELECT226 SELECT
...@@ -217,7 +245,7 @@ export const make = Effect.gen(function* () {...@@ -217,7 +245,7 @@ export const make = Effect.gen(function* () {
217245
218 const listActiveSessionRows = SqlSchema.findAll({246 const listActiveSessionRows = SqlSchema.findAll({
219 Request: ListActiveAuthSessionsInput,247 Request: ListActiveAuthSessionsInput,
220 Result: AuthSessionDbRow,248 Result: AuthSessionRawDbRow,
221 execute: ({ now }) =>249 execute: ({ now }) =>
222 sql`250 sql`
223 SELECT251 SELECT
...@@ -285,6 +313,7 @@ export const make = Effect.gen(function* () {...@@ -285,6 +313,7 @@ export const make = Effect.gen(function* () {
285 toPersistenceSqlOrDecodeError(313 toPersistenceSqlOrDecodeError(
286 "AuthSessionRepository.create:query",314 "AuthSessionRepository.create:query",
287 "AuthSessionRepository.create:encodeRequest",315 "AuthSessionRepository.create:encodeRequest",
316 { sessionId: input.sessionId },
288 ),317 ),
289 ),318 ),
290 );319 );
...@@ -295,12 +324,23 @@ export const make = Effect.gen(function* () {...@@ -295,12 +324,23 @@ export const make = Effect.gen(function* () {
295 toPersistenceSqlOrDecodeError(324 toPersistenceSqlOrDecodeError(
296 "AuthSessionRepository.getById:query",325 "AuthSessionRepository.getById:query",
297 "AuthSessionRepository.getById:decodeRow",326 "AuthSessionRepository.getById:decodeRow",
327 { sessionId: input.sessionId },
298 ),328 ),
299 ),329 ),
300 Effect.flatMap((rowOption) =>330 Effect.flatMap((rowOption) =>
301 Option.match(rowOption, {331 Option.match(rowOption, {
302 onNone: () => Effect.succeed(Option.none()),332 onNone: () => Effect.succeed(Option.none()),
303 onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))),333 onSome: (row) =>
334 decodeAuthSessionDbRow(row).pipe(
335 Effect.mapError((cause) =>
336 PersistenceDecodeError.fromSchemaError(
337 "AuthSessionRepository.getById:decodeRow",
338 cause,
339 { sessionId: input.sessionId },
340 ),
341 ),
342 Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))),
343 ),
304 }),344 }),
305 ),345 ),
306 );346 );
...@@ -313,7 +353,20 @@ export const make = Effect.gen(function* () {...@@ -313,7 +353,20 @@ export const make = Effect.gen(function* () {
313 "AuthSessionRepository.listActive:decodeRows",353 "AuthSessionRepository.listActive:decodeRows",
314 ),354 ),
315 ),355 ),
316 Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))),356 Effect.flatMap((rows) =>
357 Effect.forEach(rows, (row) =>
358 decodeAuthSessionDbRow(row).pipe(
359 Effect.mapError((cause) =>
360 PersistenceDecodeError.fromSchemaError(
361 "AuthSessionRepository.listActive:decodeRows",
362 cause,
363 { sessionId: row.sessionId },
364 ),
365 ),
366 Effect.map(toAuthSessionRecord),
367 ),
368 ),
369 ),
317 );370 );
318371
319 const revoke: AuthSessionRepository["Service"]["revoke"] = (input) =>372 const revoke: AuthSessionRepository["Service"]["revoke"] = (input) =>
...@@ -322,6 +375,7 @@ export const make = Effect.gen(function* () {...@@ -322,6 +375,7 @@ export const make = Effect.gen(function* () {
322 toPersistenceSqlOrDecodeError(375 toPersistenceSqlOrDecodeError(
323 "AuthSessionRepository.revoke:query",376 "AuthSessionRepository.revoke:query",
324 "AuthSessionRepository.revoke:decodeRows",377 "AuthSessionRepository.revoke:decodeRows",
378 { sessionId: input.sessionId },
325 ),379 ),
326 ),380 ),
327 Effect.map((rows) => rows.length > 0),381 Effect.map((rows) => rows.length > 0),
...@@ -333,6 +387,7 @@ export const make = Effect.gen(function* () {...@@ -333,6 +387,7 @@ export const make = Effect.gen(function* () {
333 toPersistenceSqlOrDecodeError(387 toPersistenceSqlOrDecodeError(
334 "AuthSessionRepository.revokeAllExcept:query",388 "AuthSessionRepository.revokeAllExcept:query",
335 "AuthSessionRepository.revokeAllExcept:decodeRows",389 "AuthSessionRepository.revokeAllExcept:decodeRows",
390 { currentSessionId: input.currentSessionId },
336 ),391 ),
337 ),392 ),
338 Effect.map((rows) => rows.map((row) => row.sessionId)),393 Effect.map((rows) => rows.map((row) => row.sessionId)),
...@@ -344,6 +399,7 @@ export const make = Effect.gen(function* () {...@@ -344,6 +399,7 @@ export const make = Effect.gen(function* () {
344 toPersistenceSqlOrDecodeError(399 toPersistenceSqlOrDecodeError(
345 "AuthSessionRepository.setLastConnectedAt:query",400 "AuthSessionRepository.setLastConnectedAt:query",
346 "AuthSessionRepository.setLastConnectedAt:encodeRequest",401 "AuthSessionRepository.setLastConnectedAt:encodeRequest",
402 { sessionId: input.sessionId },
347 ),403 ),
348 ),404 ),
349 );405 );
apps/server/src/persistence/Errors.ts+16-1
...@@ -19,11 +19,20 @@ function summarizeSchemaIssue(issue: SchemaIssue.Issue): string {...@@ -19,11 +19,20 @@ function summarizeSchemaIssue(issue: SchemaIssue.Issue): string {
19// Core Persistence Errors19// Core Persistence Errors
20// ===============================20// ===============================
2121
22export const PersistenceErrorCorrelation = Schema.Union([
23 Schema.Struct({ sessionId: Schema.String }),
24 Schema.Struct({ currentSessionId: Schema.String }),
25 Schema.Struct({ pairingLinkId: Schema.String }),
26 Schema.Struct({ threadId: Schema.String }),
27]);
28export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type;
29
22export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlError>()(30export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlError>()(
23 "PersistenceSqlError",31 "PersistenceSqlError",
24 {32 {
25 operation: Schema.String,33 operation: Schema.String,
26 detail: Schema.optional(Schema.String),34 detail: Schema.optional(Schema.String),
35 correlation: Schema.optional(PersistenceErrorCorrelation),
27 cause: Schema.optional(Schema.Defect()),36 cause: Schema.optional(Schema.Defect()),
28 },37 },
29) {38) {
...@@ -39,13 +48,19 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD...@@ -39,13 +48,19 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD
39 {48 {
40 operation: Schema.String,49 operation: Schema.String,
41 issue: Schema.String,50 issue: Schema.String,
51 correlation: Schema.optional(PersistenceErrorCorrelation),
42 cause: Schema.optional(Schema.Defect()),52 cause: Schema.optional(Schema.Defect()),
43 },53 },
44) {54) {
45 static fromSchemaError(operation: string, cause: Schema.SchemaError): PersistenceDecodeError {55 static fromSchemaError(
56 operation: string,
57 cause: Schema.SchemaError,
58 correlation?: PersistenceErrorCorrelation,
59 ): PersistenceDecodeError {
46 return new PersistenceDecodeError({60 return new PersistenceDecodeError({
47 operation,61 operation,
48 issue: summarizeSchemaIssue(cause.issue),62 issue: summarizeSchemaIssue(cause.issue),
63 ...(correlation === undefined ? {} : { correlation }),
49 cause,64 cause,
50 });65 });
51 }66 }
apps/server/src/persistence/ProviderSessionRuntime.ts+41-18
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
1717
18import {18import {
19 PersistenceDecodeError,19 PersistenceDecodeError,
20 type PersistenceErrorCorrelation,
20 PersistenceSqlError,21 PersistenceSqlError,
21 type ProviderSessionRuntimeRepositoryError,22 type ProviderSessionRuntimeRepositoryError,
22} from "./Errors.ts";23} from "./Errors.ts";
...@@ -106,7 +107,19 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields(...@@ -106,7 +107,19 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields(
106 }),107 }),
107);108);
108109
109const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime);110const ProviderSessionRuntimeRawDbRowSchema = Schema.Struct({
111 threadId: Schema.String,
112 providerName: Schema.Unknown,
113 providerInstanceId: Schema.Unknown,
114 adapterKey: Schema.Unknown,
115 runtimeMode: Schema.Unknown,
116 status: Schema.Unknown,
117 lastSeenAt: Schema.Unknown,
118 resumeCursor: Schema.Unknown,
119 runtimePayload: Schema.Unknown,
120});
121
122const decodeRuntimeRow = Schema.decodeUnknownEffect(ProviderSessionRuntimeDbRowSchema);
110123
111const GetRuntimeRequestSchema = Schema.Struct({124const GetRuntimeRequestSchema = Schema.Struct({
112 threadId: ThreadId,125 threadId: ThreadId,
...@@ -114,11 +127,19 @@ const GetRuntimeRequestSchema = Schema.Struct({...@@ -114,11 +127,19 @@ const GetRuntimeRequestSchema = Schema.Struct({
114127
115const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;128const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
116129
117function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {130function toPersistenceSqlOrDecodeError(
131 sqlOperation: string,
132 decodeOperation: string,
133 correlation?: PersistenceErrorCorrelation,
134) {
118 return (cause: unknown): ProviderSessionRuntimeRepositoryError =>135 return (cause: unknown): ProviderSessionRuntimeRepositoryError =>
119 Schema.isSchemaError(cause)136 Schema.isSchemaError(cause)
120 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)137 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)
121 : new PersistenceSqlError({ operation: sqlOperation, cause });138 : new PersistenceSqlError({
139 operation: sqlOperation,
140 ...(correlation === undefined ? {} : { correlation }),
141 cause,
142 });
122}143}
123144
124export const make = Effect.gen(function* () {145export const make = Effect.gen(function* () {
...@@ -165,7 +186,7 @@ export const make = Effect.gen(function* () {...@@ -165,7 +186,7 @@ export const make = Effect.gen(function* () {
165186
166 const getRuntimeRowByThreadId = SqlSchema.findOneOption({187 const getRuntimeRowByThreadId = SqlSchema.findOneOption({
167 Request: GetRuntimeRequestSchema,188 Request: GetRuntimeRequestSchema,
168 Result: ProviderSessionRuntimeDbRowSchema,189 Result: ProviderSessionRuntimeRawDbRowSchema,
169 execute: ({ threadId }) =>190 execute: ({ threadId }) =>
170 sql`191 sql`
171 SELECT192 SELECT
...@@ -185,7 +206,7 @@ export const make = Effect.gen(function* () {...@@ -185,7 +206,7 @@ export const make = Effect.gen(function* () {
185206
186 const listRuntimeRows = SqlSchema.findAll({207 const listRuntimeRows = SqlSchema.findAll({
187 Request: Schema.Void,208 Request: Schema.Void,
188 Result: ProviderSessionRuntimeDbRowSchema,209 Result: ProviderSessionRuntimeRawDbRowSchema,
189 execute: () =>210 execute: () =>
190 sql`211 sql`
191 SELECT212 SELECT
...@@ -218,6 +239,7 @@ export const make = Effect.gen(function* () {...@@ -218,6 +239,7 @@ export const make = Effect.gen(function* () {
218 toPersistenceSqlOrDecodeError(239 toPersistenceSqlOrDecodeError(
219 "ProviderSessionRuntimeRepository.upsert:query",240 "ProviderSessionRuntimeRepository.upsert:query",
220 "ProviderSessionRuntimeRepository.upsert:encodeRequest",241 "ProviderSessionRuntimeRepository.upsert:encodeRequest",
242 { threadId: runtime.threadId },
221 ),243 ),
222 ),244 ),
223 );245 );
...@@ -228,17 +250,19 @@ export const make = Effect.gen(function* () {...@@ -228,17 +250,19 @@ export const make = Effect.gen(function* () {
228 toPersistenceSqlOrDecodeError(250 toPersistenceSqlOrDecodeError(
229 "ProviderSessionRuntimeRepository.getByThreadId:query",251 "ProviderSessionRuntimeRepository.getByThreadId:query",
230 "ProviderSessionRuntimeRepository.getByThreadId:decodeRow",252 "ProviderSessionRuntimeRepository.getByThreadId:decodeRow",
253 { threadId: input.threadId },
231 ),254 ),
232 ),255 ),
233 Effect.flatMap((runtimeRowOption) =>256 Effect.flatMap((runtimeRowOption) =>
234 Option.match(runtimeRowOption, {257 Option.match(runtimeRowOption, {
235 onNone: () => Effect.succeed(Option.none()),258 onNone: () => Effect.succeed(Option.none()),
236 onSome: (row) =>259 onSome: (row) =>
237 decodeRuntime(row).pipe(260 decodeRuntimeRow(row).pipe(
238 Effect.mapError((cause) =>261 Effect.mapError((cause) =>
239 PersistenceDecodeError.fromSchemaError(262 PersistenceDecodeError.fromSchemaError(
240 "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime",263 "ProviderSessionRuntimeRepository.getByThreadId:decodeRow",
241 cause,264 cause,
265 { threadId: input.threadId },
242 ),266 ),
243 ),267 ),
244 Effect.map((runtime) => Option.some(runtime)),268 Effect.map((runtime) => Option.some(runtime)),
...@@ -256,18 +280,16 @@ export const make = Effect.gen(function* () {...@@ -256,18 +280,16 @@ export const make = Effect.gen(function* () {
256 ),280 ),
257 ),281 ),
258 Effect.flatMap((rows) =>282 Effect.flatMap((rows) =>
259 Effect.forEach(283 Effect.forEach(rows, (row) =>
260 rows,284 decodeRuntimeRow(row).pipe(
261 (row) =>285 Effect.mapError((cause) =>
262 decodeRuntime(row).pipe(286 PersistenceDecodeError.fromSchemaError(
263 Effect.mapError((cause) =>287 "ProviderSessionRuntimeRepository.list:decodeRows",
264 PersistenceDecodeError.fromSchemaError(288 cause,
265 "ProviderSessionRuntimeRepository.list:rowToRuntime",289 { threadId: row.threadId },
266 cause,
267 ),
268 ),290 ),
269 ),291 ),
270 { concurrency: "unbounded" },292 ),
271 ),293 ),
272 ),294 ),
273 );295 );
...@@ -280,6 +302,7 @@ export const make = Effect.gen(function* () {...@@ -280,6 +302,7 @@ export const make = Effect.gen(function* () {
280 (cause) =>302 (cause) =>
281 new PersistenceSqlError({303 new PersistenceSqlError({
282 operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query",304 operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query",
305 correlation: { threadId: input.threadId },
283 cause,306 cause,
284 }),307 }),
285 ),308 ),
apps/server/src/persistence/RepositoryErrorCorrelation.test.ts created+253
...@@ -0,0 +1,253 @@
1import { AuthSessionId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts";
2import { assert, describe, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as SqlClient from "effect/unstable/sql/SqlClient";
7
8import * as AuthPairingLinks from "./AuthPairingLinks.ts";
9import * as AuthSessions from "./AuthSessions.ts";
10import * as PersistenceErrors from "./Errors.ts";
11import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts";
12import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts";
13
14const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
15const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z");
16const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z");
17const scopes: ReadonlyArray<AuthEnvironmentScope> = ["access:read"];
18
19const authSessionLayer = AuthSessions.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory));
20const authPairingLinkLayer = AuthPairingLinks.layer.pipe(
21 Layer.provideMerge(SqlitePersistenceMemory),
22);
23const providerSessionRuntimeLayer = ProviderSessionRuntime.layer.pipe(
24 Layer.provideMerge(SqlitePersistenceMemory),
25);
26
27describe("persistence error correlation", () => {
28 it.effect("correlates auth session SQL and row-decode failures without sensitive fields", () =>
29 Effect.gen(function* () {
30 const sessions = yield* AuthSessions.AuthSessionRepository;
31 const sql = yield* SqlClient.SqlClient;
32 const sessionId = AuthSessionId.make("session-correlation");
33 const currentSessionId = AuthSessionId.make("current-session-correlation");
34 const subject = "session-subject-secret-sentinel";
35
36 yield* sessions.create({
37 sessionId,
38 subject,
39 scopes,
40 method: "browser-session-cookie",
41 client: {
42 label: null,
43 ipAddress: null,
44 userAgent: null,
45 deviceType: "desktop",
46 os: null,
47 browser: null,
48 },
49 issuedAt,
50 expiresAt,
51 });
52 yield* sql`
53 UPDATE auth_sessions
54 SET scopes = ${"session-scopes-secret-sentinel"}
55 WHERE session_id = ${sessionId}
56 `;
57
58 const decodeError = yield* Effect.flip(sessions.listActive({ now }));
59 assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError);
60 assert.deepStrictEqual(decodeError.correlation, { sessionId });
61 assert.equal(
62 decodeError.message,
63 `Decode error in AuthSessionRepository.listActive:decodeRows: ${decodeError.issue}`,
64 );
65 assert.notInclude(decodeError.issue, subject);
66 assert.notInclude(decodeError.issue, "session-scopes-secret-sentinel");
67 assert.notInclude(decodeError.message, subject);
68
69 yield* sql`DROP TABLE auth_sessions`;
70 const createError = yield* Effect.flip(
71 sessions.create({
72 sessionId,
73 subject,
74 scopes,
75 method: "browser-session-cookie",
76 client: {
77 label: null,
78 ipAddress: null,
79 userAgent: null,
80 deviceType: "desktop",
81 os: null,
82 browser: null,
83 },
84 issuedAt,
85 expiresAt,
86 }),
87 );
88 assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError);
89 assert.deepStrictEqual(createError.correlation, { sessionId });
90 assert.equal(createError.message, "SQL error in AuthSessionRepository.create:query");
91 assert.notInclude(createError.message, subject);
92 assert.notInclude(createError.message, DateTime.formatIso(issuedAt));
93
94 const revokeOtherError = yield* Effect.flip(
95 sessions.revokeAllExcept({ currentSessionId, revokedAt: now }),
96 );
97 assert.instanceOf(revokeOtherError, PersistenceErrors.PersistenceSqlError);
98 assert.deepStrictEqual(revokeOtherError.correlation, { currentSessionId });
99 assert.equal(
100 revokeOtherError.message,
101 "SQL error in AuthSessionRepository.revokeAllExcept:query",
102 );
103 assert.notInclude(revokeOtherError.message, DateTime.formatIso(now));
104 }).pipe(Effect.provide(authSessionLayer)),
105 );
106
107 it.effect("correlates pairing-link create and revoke failures by id only", () =>
108 Effect.gen(function* () {
109 const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository;
110 const sql = yield* SqlClient.SqlClient;
111 const id = "pairing-link-correlation";
112 const credential = "pairing-credential-secret-sentinel";
113 const subject = "pairing-subject-secret-sentinel";
114 const scopesPayload = "pairing-scopes-secret-sentinel";
115
116 yield* sql`
117 INSERT INTO auth_pairing_links (
118 id,
119 credential,
120 method,
121 scopes,
122 subject,
123 label,
124 proof_key_thumbprint,
125 created_at,
126 expires_at,
127 consumed_at,
128 revoked_at
129 )
130 VALUES (
131 ${id},
132 ${credential},
133 ${"one-time-token"},
134 ${scopesPayload},
135 ${subject},
136 NULL,
137 NULL,
138 ${DateTime.formatIso(issuedAt)},
139 ${DateTime.formatIso(expiresAt)},
140 NULL,
141 NULL
142 )
143 `;
144
145 const decodeError = yield* Effect.flip(pairingLinks.getByCredential({ credential }));
146 assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError);
147 assert.deepStrictEqual(decodeError.correlation, { pairingLinkId: id });
148 assert.equal(
149 decodeError.message,
150 `Decode error in AuthPairingLinkRepository.getByCredential:decodeRow: ${decodeError.issue}`,
151 );
152 assert.notInclude(decodeError.issue, credential);
153 assert.notInclude(decodeError.issue, subject);
154 assert.notInclude(decodeError.issue, scopesPayload);
155 assert.notInclude(decodeError.message, DateTime.formatIso(issuedAt));
156
157 yield* sql`DROP TABLE auth_pairing_links`;
158 const createError = yield* Effect.flip(
159 pairingLinks.create({
160 id,
161 credential,
162 method: "one-time-token",
163 scopes,
164 subject,
165 label: null,
166 proofKeyThumbprint: null,
167 createdAt: issuedAt,
168 expiresAt,
169 }),
170 );
171 assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError);
172 assert.deepStrictEqual(createError.correlation, { pairingLinkId: id });
173 assert.notInclude(createError.message, credential);
174 assert.notInclude(createError.message, subject);
175 assert.notInclude(createError.message, DateTime.formatIso(issuedAt));
176
177 const revokeError = yield* Effect.flip(pairingLinks.revoke({ id, revokedAt: now }));
178 assert.instanceOf(revokeError, PersistenceErrors.PersistenceSqlError);
179 assert.deepStrictEqual(revokeError.correlation, { pairingLinkId: id });
180 assert.notInclude(revokeError.message, credential);
181 assert.notInclude(revokeError.message, DateTime.formatIso(now));
182 }).pipe(Effect.provide(authPairingLinkLayer)),
183 );
184
185 it.effect("correlates provider runtime SQL and per-row decode failures by thread", () =>
186 Effect.gen(function* () {
187 const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
188 const sql = yield* SqlClient.SqlClient;
189 const threadId = ThreadId.make("thread-correlation");
190 const runtimePayload = "runtime-payload-secret-sentinel";
191 const lastSeenAt = "2026-06-20T00:00:00.000Z";
192
193 yield* sql`
194 INSERT INTO provider_session_runtime (
195 thread_id,
196 provider_name,
197 provider_instance_id,
198 adapter_key,
199 runtime_mode,
200 status,
201 last_seen_at,
202 resume_cursor_json,
203 runtime_payload_json
204 )
205 VALUES (
206 ${threadId},
207 ${"codex"},
208 NULL,
209 ${"codex"},
210 ${"invalid-runtime-mode"},
211 ${"running"},
212 ${lastSeenAt},
213 NULL,
214 ${`{"secret":"${runtimePayload}"}`}
215 )
216 `;
217
218 const decodeError = yield* Effect.flip(runtimes.list());
219 assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError);
220 assert.deepStrictEqual(decodeError.correlation, { threadId });
221 assert.equal(
222 decodeError.message,
223 `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`,
224 );
225 assert.notInclude(decodeError.issue, runtimePayload);
226 assert.notInclude(decodeError.message, runtimePayload);
227 assert.notInclude(decodeError.message, lastSeenAt);
228
229 yield* sql`DROP TABLE provider_session_runtime`;
230 const sqlFailure = yield* Effect.flip(
231 runtimes.upsert({
232 threadId,
233 providerName: "codex",
234 providerInstanceId: null,
235 adapterKey: "codex",
236 runtimeMode: "full-access",
237 status: "running",
238 lastSeenAt,
239 resumeCursor: null,
240 runtimePayload: { secret: runtimePayload },
241 }),
242 );
243 assert.instanceOf(sqlFailure, PersistenceErrors.PersistenceSqlError);
244 assert.deepStrictEqual(sqlFailure.correlation, { threadId });
245 assert.equal(
246 sqlFailure.message,
247 "SQL error in ProviderSessionRuntimeRepository.upsert:query",
248 );
249 assert.notInclude(sqlFailure.message, runtimePayload);
250 assert.notInclude(sqlFailure.message, lastSeenAt);
251 }).pipe(Effect.provide(providerSessionRuntimeLayer)),
252 );
253});