Restore durable application objects

Save the state needed to call one registered constructor again. Keep services and connections out of the payload.

On this pageSource-backed Markdown

Register a stable contract

Configure serializer.classes in App options: new App({ serializer: { classes: { "notes.export.v1": ExportRequest } } }). The service is app().serializer, with an application-scoped registry. Register the same names and constructors in every producer and worker at boot.

Names are durable wire contracts, not filenames. The example uses its own state version in addition to the framework envelope version. Renaming or changing state requires an explicit stored-payload migration or discard policy.

Copy the request and runner

This first lab uses a scalar owner ID, so it has no database dependency. It deliberately does not claim that an ID is authorization.

Copy the shipped example
bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/serialization/examples/. examples/

Run it

Expect label: "ada:25", privateStateRestored: true, wire version 1, both rejection flags true and the repaired label. The runner round-trips through JSON.stringify() and JSON.parse() rather than passing live objects between calls.

Run the lab
bash
npx tsx examples/runExportSerialization.ts

Make the constructor the state contract

Implement Serializable<State>. toJSON() returns the full input accepted by the one-argument constructor. Validation runs during both ordinary construction and restoration; private fields are initialized normally.

Use plain JSON values deliberately. undefined, dates, bigint, functions, non-finite numbers, negative zero, sparse arrays, cycles and nested application-class instances are rejected. Convert special values explicitly. $platform is reserved for framework reference markers.

examples/ExportRequest.ts
ts
import type { Serializable } from '@db3.ai/app/serialization';

/** Durable, explicitly versioned application state for a bounded export. */
export interface ExportRequestState {
	/** Application contract version, separate from the framework envelope version. */
	version: 1;
	/** Validated scalar identity; the worker must authorize its own data reads. */
	ownerId: string;
	/** Maximum number of records requested for the export. */
	limit: number;
}

/** A registered root that validates the same state on direct and restored construction. */
export class ExportRequest implements Serializable<ExportRequestState> {
	readonly #state: ExportRequestState;
	/** Validates all state needed to recreate this request in a worker. */
	constructor(state: ExportRequestState) {
		if (!state || state.version !== 1 || typeof state.ownerId !== 'string' || !state.ownerId.trim() || !Number.isInteger(state.limit) || state.limit < 1 || state.limit > 100) throw new Error('ExportRequest requires version 1, an owner and limit 1–100.');
		this.#state = { version: 1, ownerId: state.ownerId.trim(), limit: state.limit };
	}
	/** Returns plain constructor state, never a request, connection or service. */
	toJSON(): ExportRequestState { return { ...this.#state }; }
	/** Demonstrates private state was initialized by the restored constructor. */
	label(): string { return `${this.#state.ownerId}:${this.#state.limit}`; }
}

Restore in another process

serialize() requires the exact registered root class. deserialize() validates the envelope, resolves registrations and calls the constructor. Registering a class only while dispatching does not register it in a worker.

The generic deserialize<ExportRequest>() annotates a result; it is not a separate schema or authorization check. Keep the payload’s expected registered name under application control. The registry is an allowlist, not a safe endpoint for arbitrary untrusted object execution.

examples/runExportSerialization.ts
ts
import { pathToFileURL } from 'node:url';
import { SerializationError, Serializer } from '@db3.ai/app/serialization';
import { App } from '@db3.ai/app/server';
import { ExportRequest } from './ExportRequest';

/** Exercises the JSON boundary, unknown registration, invalid state and recovery. */
export async function runExportSerialization() {
	const application = new App({ serializer: { classes: { 'notes.export.v1': ExportRequest } } });
	try {
		const payload = application.serializer.serialize(new ExportRequest({ version: 1, ownerId: 'ada', limit: 25 }));
		const durable = JSON.parse(JSON.stringify(payload));
		const restored = await application.serializer.deserialize<ExportRequest>(durable);
		const worker = new Serializer();
		let unknownRejected = false;
		try { await worker.deserialize(durable); } catch (error) { if (!(error instanceof SerializationError)) throw error; unknownRejected = true; }
		worker.registry.registerClass('notes.export.v1', ExportRequest);
		let invalidStateRejected = false;
		try { await worker.deserialize({ ...durable, state: { ...durable.state, limit: 0 } }); } catch (error) { if (!(error instanceof SerializationError)) throw error; invalidStateRejected = true; }
		const repaired = await worker.deserialize<ExportRequest>(durable);
		return { label: restored.label(), privateStateRestored: restored instanceof ExportRequest, format: payload.format, wireVersion: payload.version, unknownRejected, invalidStateRejected, repaired: repaired.label() };
	} finally { await application.close(); }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runExportSerialization(), null, 2));

Use model references when you want current data

Register model names in serializer.models. A clean, persisted ActiveRecord inside constructor state becomes a model name and logical primary key, not an attribute snapshot. Restoration reloads the current row through the active application database.

Unsaved, dirty, unregistered or soft-deleted records are rejected. Repeated references share a lookup and restored instance. A missing row fails restoration. If deletion should make the work a no-op, store a scalar ID and perform an optional, scoped lookup inside the worker.

Authorization still belongs to the workflow. The model-reference lookup is not an organization membership check. This first lab does not exercise model restoration; the service-owned SQL tests cover its contract.

Handle failures without hiding them

SerializationError carries operation, code, path and an optional cause. SerializationRegistryError identifies invalid or conflicting registrations. Return safe application errors rather than exposing constructor state or stack traces.

The lab repairs a missing registration, then proves a rejected payload did not stop later valid work. Constructors should validate and initialize state; avoid irreversible side effects while restoring. Ordinary queue jobs already use their own JSON data constructor contract and do not automatically use this service.

Testing

Create a tests/serialization directory and save the test below as runExportSerialization.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.

tests/serialization/runExportSerialization.test.ts
ts
import { expect, it } from 'vitest';
import { Serializer } from '@db3.ai/app/serialization';
import { ExportRequest } from '../../examples/ExportRequest';
import { runExportSerialization } from '../../examples/runExportSerialization';

it('restores private state through a JSON boundary and recovers missing worker registration', async () => {
	expect(await runExportSerialization()).toEqual({ label: 'ada:25', privateStateRestored: true, format: 'platform.serialized-object', wireVersion: 1, unknownRejected: true, invalidStateRejected: true, repaired: 'ada:25' });
});

it('validates constructor state and rejects incompatible envelope versions', async () => {
	expect(() => new ExportRequest({ version: 1, ownerId: ' ', limit: 25 })).toThrow();
	const serializer = new Serializer({ classes: { 'notes.export.v1': ExportRequest } });
	const payload = serializer.serialize(new ExportRequest({ version: 1, ownerId: 'ada', limit: 1 }));
	await expect(serializer.deserialize({ ...payload, version: 99 })).rejects.toThrow();
	expect((await serializer.deserialize<ExportRequest>(payload)).label()).toBe('ada:1');
});

Run and extend the test

Change the export limit and add a state field with constructor validation. Verify old payload handling deliberately. The copied tests need no SQL; the complete contributor service suite also includes real database restoration tests.

Run your copied test and check types
bash
npx vitest run tests/serialization/runExportSerialization.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts

Coverage and API

Tested here: registration, JSON durability, private-state reconstruction, invalid state/version, missing worker registration and recovery. Explained: model identity, supported values, duplicate registration, versioning, worker boot and trust boundaries. A model-restoration cookbook with missing-row recovery remains a follow-up.

Behaviour tested Executed by the documentation maintenance gate
What this does

Reconstructs a registered export request through JSON, rejects an unknown worker registration and invalid state, then repairs both.

Expected outputThe guide test passes against the real framework components.
Behaviour testpackages/app/src/serialization/tests/examples/runExportSerialization.test.ts

This test command requires the framework repository. Use the walkthrough commands in an installed application.

Environment: Node.js; the scalar-identity lab does not need SQL or a worker process.