Skip to content

Commit e46ae58

Browse files
committed
refactor: drop per-record serialization overrides from static dump
`serializeDumpError` already flattens the common rich-error shape (`message`, `name`, recursive `cause`) into a JSON-safe object, so `jsonSerializable: true` functions that throw ordinary errors round-trip through strict JSON without any per-record promotion. The edge case — an error with non-JSON own properties (e.g. a `Map` attached to the thrown error) — now surfaces as `DF0020` at build time, consistent with the rest of the `jsonSerializable: true` contract.
1 parent 076ffe7 commit e46ae58

4 files changed

Lines changed: 58 additions & 100 deletions

File tree

packages/devframe/src/client/static-rpc.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,6 @@ export interface StaticRpcManifestQueryEntry {
1818
fallback?: string
1919
/** Encoder used when each record/fallback file was written. Default: `'json'`. */
2020
serialization?: StaticRpcSerialization
21-
/**
22-
* Per-record encoder override. When a record file was written with a
23-
* different serializer than {@link serialization} (e.g. an error-bearing
24-
* record promoted to `'structured-clone'` for a `jsonSerializable: true`
25-
* function), the override is recorded here.
26-
*/
27-
recordSerializations?: Record<string, StaticRpcSerialization>
28-
/** Encoder override for the fallback shard. */
29-
fallbackSerialization?: StaticRpcSerialization
3021
}
3122

3223
export type StaticRpcManifestEntry
@@ -129,14 +120,12 @@ export function createStaticRpcCaller(
129120
const recordPath = entry.records[argsHash]
130121

131122
if (recordPath) {
132-
const recordSerialization = entry.recordSerializations?.[argsHash] ?? entry.serialization
133-
const record = await loadQueryRecord(recordPath, recordSerialization)
123+
const record = await loadQueryRecord(recordPath, entry.serialization)
134124
return resolveRecordOutput(record)
135125
}
136126

137127
if (entry.fallback) {
138-
const fallbackSerialization = entry.fallbackSerialization ?? entry.serialization
139-
const fallback = await loadQueryRecord(entry.fallback, fallbackSerialization)
128+
const fallback = await loadQueryRecord(entry.fallback, entry.serialization)
140129
return resolveRecordOutput(fallback)
141130
}
142131

packages/devframe/src/node/__tests__/static-dump.test.ts

Lines changed: 54 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -218,109 +218,104 @@ describe('collectStaticRpcDump', () => {
218218
})
219219

220220
describe('error-bearing records', () => {
221-
it('promotes error-bearing query records to structured-clone for jsonSerializable: true', async () => {
221+
it('writes JSON-safe error shape (message + name + cause) for jsonSerializable: true', async () => {
222222
const flaky = defineRpcFunction({
223223
name: 'test:flaky',
224224
type: 'query',
225225
jsonSerializable: true,
226-
handler: (id: string) => {
227-
if (id === 'bad')
228-
throw new Error('boom')
229-
return { id }
226+
handler: () => {
227+
throw new TypeError('boom', { cause: new Error('inner') })
230228
},
231229
dump: {
232-
inputs: [['good'], ['bad']],
230+
inputs: [[]] as [][],
233231
},
234232
})
235233

236234
const result = await collectStaticRpcDump([flaky], {})
237235
const entry = result.manifest['test:flaky'] as {
238-
type: 'query'
239236
records: Record<string, string>
240237
serialization: 'json'
241-
recordSerializations?: Record<string, 'json' | 'structured-clone'>
242238
}
243239

244240
expect(entry.serialization).toBe('json')
245-
expect(entry.recordSerializations).toBeDefined()
246241

247-
const erroredKeys = Object.keys(entry.recordSerializations!)
248-
expect(erroredKeys).toHaveLength(1)
249-
250-
const erroredPath = entry.records[erroredKeys[0]!]!
251-
expect(result.files[erroredPath]!.serialization).toBe('structured-clone')
242+
const recordPath = Object.values(entry.records)[0]!
243+
const file = result.files[recordPath]!
244+
expect(file.serialization).toBe('json')
252245

253-
const goodPath = entry.records[Object.keys(entry.records).find(k => k !== erroredKeys[0])!]!
254-
expect(result.files[goodPath]!.serialization).toBe('json')
246+
// serializeDumpError flattens Error.cause into a plain object, so
247+
// strict-JSON encoding succeeds without any per-record promotion.
248+
const wireText = strictJsonStringify(file.data, file.fnName)
249+
const parsed = JSON.parse(wireText) as {
250+
error: { name: string, message: string, cause: { name: string, message: string } }
251+
}
252+
expect(parsed.error.name).toBe('TypeError')
253+
expect(parsed.error.message).toBe('boom')
254+
expect(parsed.error.cause.name).toBe('Error')
255+
expect(parsed.error.cause.message).toBe('inner')
255256
})
256257

257-
it('keeps the entry serialization on error-bearing records when default is structured-clone', async () => {
258+
it('preserves rich error info end-to-end for default (structured-clone) entries', async () => {
259+
const tags = new Map<string, number>([['a', 1]])
258260
const flaky = defineRpcFunction({
259-
name: 'test:flaky-sc',
261+
name: 'test:flaky-roundtrip',
260262
type: 'query',
261263
// default jsonSerializable: false → structured-clone shards
262-
handler: (id: string) => {
263-
if (id === 'bad')
264-
throw new Error('boom')
265-
return { id }
264+
handler: () => {
265+
const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown }
266+
err.tags = tags
267+
throw err
266268
},
267269
dump: {
268-
inputs: [['good'], ['bad']],
270+
inputs: [[]] as [][],
269271
},
270272
})
271273

272274
const result = await collectStaticRpcDump([flaky], {})
273-
const entry = result.manifest['test:flaky-sc'] as {
274-
serialization: 'json' | 'structured-clone'
275-
recordSerializations?: Record<string, 'json' | 'structured-clone'>
275+
const entry = result.manifest['test:flaky-roundtrip'] as {
276+
records: Record<string, string>
277+
serialization: 'structured-clone'
276278
}
277-
278279
expect(entry.serialization).toBe('structured-clone')
279-
// No overrides — everything is already SC.
280-
expect(entry.recordSerializations).toBeUndefined()
280+
281+
const recordPath = Object.values(entry.records)[0]!
282+
const file = result.files[recordPath]!
283+
const revived = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(file.data))) as {
284+
error: { name: string, message: string, cause: { message: string }, tags: Map<string, number> }
285+
}
286+
expect(revived.error.name).toBe('TypeError')
287+
expect(revived.error.message).toBe('boom')
288+
expect(revived.error.cause.message).toBe('inner')
289+
expect(revived.error.tags).toBeInstanceOf(Map)
290+
expect(revived.error.tags.get('a')).toBe(1)
281291
})
282292

283-
it('preserves rich error info (cause + custom props) through a full read round-trip', async () => {
284-
const tags = new Map<string, number>([['a', 1]])
293+
it('throws DF0020 when a jsonSerializable: true function attaches non-JSON to an error', async () => {
285294
const flaky = defineRpcFunction({
286-
name: 'test:flaky-roundtrip',
295+
name: 'test:flaky-non-json',
287296
type: 'query',
288297
jsonSerializable: true,
289-
handler: (id: string) => {
290-
if (id === 'bad') {
291-
const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown }
292-
err.tags = tags
293-
throw err
294-
}
295-
return { id }
298+
handler: () => {
299+
const err = new Error('boom') as Error & { tags?: unknown }
300+
err.tags = new Map([['a', 1]])
301+
throw err
296302
},
297303
dump: {
298-
inputs: [['bad']],
304+
inputs: [[]] as [][],
299305
},
300306
})
301307

302308
const result = await collectStaticRpcDump([flaky], {})
303-
const entry = result.manifest['test:flaky-roundtrip'] as {
304-
records: Record<string, string>
305-
recordSerializations?: Record<string, 'json' | 'structured-clone'>
306-
}
307-
308-
const recordKey = Object.keys(entry.records)[0]!
309-
const recordPath = entry.records[recordKey]!
309+
const recordPath = Object.values(
310+
(result.manifest['test:flaky-non-json'] as { records: Record<string, string> }).records,
311+
)[0]!
310312
const file = result.files[recordPath]!
311313

312-
// The shard was promoted to structured-clone — verify the Error
313-
// (and its Map property) round-trips losslessly through the wire format.
314-
expect(file.serialization).toBe('structured-clone')
315-
const wireText = structuredCloneStringify(file.data)
316-
const revived = structuredCloneDeserialize(JSON.parse(wireText)) as {
317-
error: { name: string, message: string, cause: { message: string }, tags: Map<string, number> }
318-
}
319-
expect(revived.error.name).toBe('TypeError')
320-
expect(revived.error.message).toBe('boom')
321-
expect(revived.error.cause.message).toBe('inner')
322-
expect(revived.error.tags).toBeInstanceOf(Map)
323-
expect(revived.error.tags.get('a')).toBe(1)
314+
// Attaching a Map to the thrown Error violates the `jsonSerializable: true`
315+
// contract; the strict serializer surfaces this at build time, same as
316+
// if the function had returned a Map.
317+
expect(() => strictJsonStringify(file.data, file.fnName))
318+
.toThrowError(/jsonSerializable: true.*is a Map/)
324319
})
325320
})
326321
})

packages/devframe/src/node/static-dump.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,6 @@ export interface StaticRpcDumpManifestQueryEntry {
1919
fallback?: string
2020
/** Encoder used when each record/fallback file was written. Default: `'json'`. */
2121
serialization?: StaticRpcDumpSerialization
22-
/**
23-
* Per-record encoder override. When a record file is written with a
24-
* different serializer than {@link serialization} (e.g. an error-bearing
25-
* record promoted to `'structured-clone'` for a `jsonSerializable: true`
26-
* function), the override is recorded here so the client picks the
27-
* right decoder.
28-
*/
29-
recordSerializations?: Record<string, StaticRpcDumpSerialization>
30-
/** Encoder override for the fallback shard, with the same semantics as {@link recordSerializations}. */
31-
fallbackSerialization?: StaticRpcDumpSerialization
3222
}
3323

3424
export type StaticRpcDumpManifestValue
@@ -125,29 +115,15 @@ export async function collectStaticRpcDump(
125115
const key = recordKey.slice(prefix.length)
126116
const record = await resolveRecord(recordOrGetter)
127117

128-
// Error-bearing records can contain non-JSON values (e.g. an
129-
// `Error.cause` chain, or a `Map` attached to the thrown error).
130-
// For `jsonSerializable: true` functions, promote just this one
131-
// file to structured-clone so the rich error round-trips losslessly.
132-
const recordSerialization: StaticRpcDumpSerialization
133-
= record.error !== undefined && serialization === 'json'
134-
? 'structured-clone'
135-
: serialization
136-
137118
if (key === 'fallback') {
138119
const path = makeQueryFallbackPath(definition.name)
139-
files[path] = { serialization: recordSerialization, fnName: definition.name, data: record }
120+
files[path] = { serialization, fnName: definition.name, data: record }
140121
queryEntry.fallback = path
141-
if (recordSerialization !== serialization)
142-
queryEntry.fallbackSerialization = recordSerialization
143122
}
144123
else {
145124
const path = makeQueryRecordPath(definition.name, key)
146-
files[path] = { serialization: recordSerialization, fnName: definition.name, data: record }
125+
files[path] = { serialization, fnName: definition.name, data: record }
147126
queryEntry.records[key] = path
148-
if (recordSerialization !== serialization) {
149-
;(queryEntry.recordSerializations ??= {})[key] = recordSerialization
150-
}
151127
}
152128
}
153129

tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ export interface StaticRpcDumpManifestQueryEntry {
3636
records: Record<string, string>;
3737
fallback?: string;
3838
serialization?: StaticRpcDumpSerialization;
39-
recordSerializations?: Record<string, StaticRpcDumpSerialization>;
40-
fallbackSerialization?: StaticRpcDumpSerialization;
4139
}
4240
export interface StaticRpcDumpManifestStaticEntry {
4341
type: 'static';

0 commit comments

Comments
 (0)