Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
87c5b4d
test(zero-swift): hardening tests for the connection and protocol
ampagent Oct 26, 2026
9298684
fix(zero-swift): apply desiredQueriesPatches in the server's order; s…
ampagent Oct 26, 2026
a0570a3
test(zero-swift): hardening tests for mutations
ampagent Oct 26, 2026
d6657b1
fix(zero-swift): a mutate inside a mutator waits for it; drop falsy a…
ampagent Oct 26, 2026
c354764
test(zero-swift): hardening tests for crashes on untrusted input
ampagent Oct 26, 2026
7d001d6
fix(zero-swift): don't trap on bad server data, storage, or timer delays
ampagent Oct 26, 2026
bc4ca26
fix(zero-swift): report invalid queries instead of trapping
ampagent Oct 26, 2026
307b433
fix(zero-swift): clamp ZeroVirtual's page size
ampagent Oct 26, 2026
3ff5a03
test(zero-swift): hardening tests for storage and the client lifecycle
ampagent Oct 26, 2026
b25be3f
fix(zero-swift): keep newer snapshots and busy writes in storage
ampagent Oct 26, 2026
208d59c
fix(zero-swift): collect only unused databases of ours
ampagent Oct 26, 2026
9fcd533
fix(zero-swift): recover from corrupt data, drop fallbacks, drop once
ampagent Oct 26, 2026
b477862
test(zero-swift): end-to-end chaos tests
ampagent Oct 26, 2026
f67acc4
fix(zero-swift): closing a connecting socket aborts its handshake
ampagent Oct 26, 2026
00b92ab
test(zero-swift): hardening tests for queries, views, and IVM
ampagent Oct 26, 2026
4dadb39
fix(zero-swift): throw rather than trap on a partitioned take fetched…
ampagent Oct 26, 2026
99b3cd7
fix(zero-swift): notify view listeners in JS's order when a listener …
ampagent Oct 26, 2026
be98d60
fix(zero-swift): fail the poke or mutation, rather than trap, when a …
ampagent Oct 26, 2026
7e47e14
fix(zero-swift): word decoding errors as valita does
ampagent Oct 26, 2026
9ab2cd1
docs(zero-swift): values of the wrong type and JSON.parse messages di…
ampagent Oct 26, 2026
cb1cfeb
chore(zero-swift): changeset for the client hardening fixes
ampagent Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harden-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

A mutate from inside a mutator runs after it, as in JS, instead of sharing its mutation ID and dropping it from the push. A poke's `desiredQueriesPatches` apply in the server's order. Closing a connecting socket aborts its handshake, so a timed-out attempt no longer leaks a socket. App errors drop falsy `details`, as JS does.
5 changes: 5 additions & 0 deletions .changeset/harden-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

A flipped `whereExists` over a subquery with a `limit` no longer crashes the app: `materialize` and `liveQuery` throw "Partitioned take does not allow unpartitioned fetches", and `run` rejects with it, as in JS. View listeners are notified as in JS when one of them changes the client: a `mutate` from a listener runs once the notification is done, so listeners after it no longer end on stale rows and other views see every change; a listener removed during a notification is skipped, one added is called in the same pass, and destroying a view from its own listener still lets its other listeners hear that change. A change a view can't take where JS throws, such as two rows for a `one` relationship or a new row reaching a flipped `whereExists` over a `limit`, no longer crashes the app either: as in JS, the poke fails and the connection goes to the `error` state, or the mutation's `client` outcome is the error, and later mutations, `materialize`, and `run` fail with it.
5 changes: 5 additions & 0 deletions .changeset/harden-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

Storage hardening. Collecting old databases no longer deletes one a client opens while the collection runs, files of a newer layout or format, or files that aren't Zero databases. A client of a group that is behind another no longer writes an older snapshot over a newer one. `zero.delete()` and `dropAllDatabases` also drop the memory databases clients fell back to after their SQLite store failed. Stored data that doesn't decode now makes the client delete the database and start a new one, calling `onClientStateNotFound` if set, instead of running on memory at every launch. Writes kept because the database was busy are written by `flush()` and `close()`, or reported as a storage failure, instead of being lost. Dropping a database twice no longer deletes the new one a replaced client started.
5 changes: 5 additions & 0 deletions .changeset/harden-untrusted-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

Bad server data, storage contents, and options no longer crash the app. A poke whose last mutation ID isn't a safe integer, or whose row update moves a row off its primary key, is rejected and the client disconnects; stored mutations with invalid IDs are dropped at launch. Huge or non-finite timer delays fire at once, as `setTimeout` does. A query built with a negative or out-of-range `limit`, a `limit` or `orderBy` in a junction relationship, or `start` or `related` inside `exists` throws JS's message when materialized, run, or preloaded. An `Int` column holding -2^63 fails to read instead of trapping, and `minPageSize` is clamped.
60 changes: 60 additions & 0 deletions packages/zero-conformance/src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ export const queries = defineQueries({
zql.issue.where('id', args).related('owner').one(),
),
firstTwo: defineQuery(() => zql.issue.orderBy('title', 'asc').limit(2)),
// A flipped EXISTS over a limited subquery: fetching it throws
// `Partitioned take does not allow unpartitioned fetches`.
// Hydrates, but adding an issue then fetches the limited flipped
// subquery without a constraint, which throws from the push.
withAuthoredComments: defineQuery(() =>
zql.issue
.whereExists('comments', q =>
q.whereExists('author', a => a.limit(3), {flip: true}),
)
.orderBy('id', 'asc'),
),
// `oneComment` is a `one` relationship that several comments can match.
withOneComment: defineQuery(() =>
zql.issue.related('oneComment').orderBy('id', 'asc'),
),
withFirstComment: defineQuery(() =>
zql.issue
.whereExists('comments', q => q.orderBy('id', 'asc').limit(1), {
flip: true,
})
.orderBy('id', 'asc'),
),
},
users: {
all: defineQuery(() => zql.user.orderBy('id', 'asc')),
Expand Down Expand Up @@ -118,8 +140,46 @@ const issue = {
),
};

type CRUDOp = {
op: 'insert' | 'upsert' | 'update' | 'delete';
table: string;
value: JSONObject;
};

const harden = {
/**
* Runs `ops` (CRUD operations on any table, values as given), then throws
* `fail` if set, or reads the `read` tables back through the transaction
* and returns them with the mutation ID, or returns `return`. With
* `failOnRebase` it throws before writing when replayed.
*/
ops: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => {
if (args['failOnRebase'] === true && tx.reason === 'rebase') {
throw new ApplicationError('fails on rebase');
}
for (const o of (args['ops'] ?? []) as CRUDOp[]) {
await tx.mutate[o.table][o.op](o.value);
}
if (typeof args['fail'] === 'string') {
throw new ApplicationError(args['fail'], {details: args['details']});
}
const read = args['read'] as string[] | undefined;
if (read) {
const out: Record<string, unknown> = {};
for (const t of read) {
out[t] = await tx.run(
(zql as unknown as Record<string, typeof zql.issue>)[t],
);
}
return {mutationID: tx.mutationID, read: out} as ReadonlyJSONValue;
}
return args['return'];
}),
};

export const mutators = defineMutators({
issue,
harden,
comment: {
add: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => {
await tx.mutate.comment.insert({
Expand Down
26 changes: 26 additions & 0 deletions packages/zero-conformance/src/client/harden-connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {test} from 'vitest';
import {emitVectors} from '../vectors.ts';
import {
runScenario,
START_TIME,
type Scenario,
type StepResult,
} from './harness.ts';
import {hardenConnectionScenarios} from './scenarios/harden-connection.ts';

type Case = Scenario & {expect: StepResult[]};

test('client hardening scenarios: connection', {timeout: 600_000}, async () => {
const cases: Case[] = [];
for (const scenario of hardenConnectionScenarios) {
cases.push({...scenario, expect: await runScenario(scenario)});
}
emitVectors(
'client-harden-connection',
'client-scenarios',
'Hardening scenarios for the connection and the sync protocol, run against the JS zero-client like vectors/client.json. See src/client/scenarios/harden-connection.ts.',
cases,
{startTime: START_TIME},
{compact: true},
);
});
30 changes: 30 additions & 0 deletions packages/zero-conformance/src/client/harden-mutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {test} from 'vitest';
import {emitVectors} from '../vectors.ts';
import {
runScenario,
START_TIME,
type Scenario,
type StepResult,
} from './harness.ts';
import {hardenMutationScenarios} from './scenarios/harden-mutations.ts';

type Case = Scenario & {expect: StepResult[]};

test(
'client hardening scenarios for mutations',
{timeout: 600_000},
async () => {
const cases: Case[] = [];
for (const scenario of hardenMutationScenarios) {
cases.push({...scenario, expect: await runScenario(scenario)});
}
emitVectors(
'client-harden-mutations',
'client-scenarios',
'Hardening scenarios for mutations, run against the JS zero-client like client.json (see src/client/harness.ts and src/client/scenarios/harden-mutations.ts).',
cases,
{startTime: START_TIME},
{compact: true},
);
},
);
29 changes: 29 additions & 0 deletions packages/zero-conformance/src/client/harden-queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Hardening client scenarios for views and queries, kept apart from
// `client.json`. `swift/Tests/ConformanceTests/HardenQueryTests.swift`
// replays them.
import {test} from 'vitest';
import {emitVectors} from '../vectors.ts';
import {
runScenario,
START_TIME,
type Scenario,
type StepResult,
} from './harness.ts';
import {hardenQueryScenarios} from './scenarios/harden-queries.ts';

type Case = Scenario & {expect: StepResult[]};

test('harden query scenarios', {timeout: 600_000}, async () => {
const cases: Case[] = [];
for (const scenario of hardenQueryScenarios) {
cases.push({...scenario, expect: await runScenario(scenario)});
}
emitVectors(
'harden-queries',
'client-scenarios',
'Hardening client scenarios: re-entrant listeners, odd TTLs, query change throttling, pokes that leave views unchanged. See src/client/harness.ts.',
cases,
{startTime: START_TIME},
{compact: true},
);
});
113 changes: 112 additions & 1 deletion packages/zero-conformance/src/client/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export type Step =
bytes?: number[];
socket?: number;
}
/** A text frame of exactly `text`, which need not be JSON. */
| {op: 'receiveText'; text: string; socket?: number}
/** A binary frame of exactly `bytes`, the message type byte included. */
| {op: 'receiveBinary'; bytes: number[]; socket?: number}
/** The socket closes from the server or network side. */
| {
op: 'socketClose';
Expand All @@ -95,6 +99,15 @@ export type Step =
* tab does (`document.visibilityState`).
*/
| {op: 'visibility'; state: 'visible' | 'hidden'}
/**
* Once `connection.state` next becomes `state`, runs `then` (a `connect`
* or `close` step) synchronously from inside the state listener.
*/
| {
op: 'onState';
state: string;
then: {op: 'connect'; auth?: string} | {op: 'close'};
}
/** `zero.connection.connect()`. */
| {op: 'connect'; auth?: string}
| {op: 'close'}
Expand All @@ -113,7 +126,22 @@ export type Step =
advance?: number;
options?: ScenarioOptions;
mutators?: 'all' | 'issues';
};
}
/**
* Adds the listener `listener` to `view`. Its calls are reported in
* `listeners`. Once it has been called more than `after` times (default
* 1: the call `addListener` makes), its next call performs `actions`,
* from inside the notification, once.
*/
| {
op: 'listen';
view: string;
listener: string;
after?: number;
actions?: Step[];
}
/** Removes a listener added by `listen`. */
| {op: 'unlisten'; listener: string};

type Args = ReadonlyJSONValue | undefined;

Expand Down Expand Up @@ -172,6 +200,14 @@ export type StepResult = {
preloads?: Record<string, {cached: Settled; complete: Settled}>;
/** `onUpdateNeeded` and `onClientStateNotFound` calls. */
events?: ReadonlyJSONValue[];
/**
* Listeners added by `listen`, in order: the data and result type of
* each call during the step.
*/
listeners?: Record<
string,
{calls: {data: ReadonlyJSONValue; resultType: string}[]; threw?: string}
>;
};

class CloseEventPolyfill extends Event {
Expand Down Expand Up @@ -355,6 +391,14 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
>();
const sentCounts: number[] = [];
const closedBefore: boolean[] = [];
const listeners = new Map<
string,
{
remove: () => void;
calls: {data: ReadonlyJSONValue; resultType: string}[];
threw?: string | undefined;
}
>();

const targetSocket = (index: number | undefined) => {
const socket = index === undefined ? sockets.at(-1) : sockets[index];
Expand Down Expand Up @@ -474,6 +518,18 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
);
return;
}
case 'receiveText':
targetSocket(step.socket).dispatchEvent(
new MessageEvent('message', {data: step.text}),
);
return;
case 'receiveBinary':
targetSocket(step.socket).dispatchEvent(
new MessageEvent('message', {
data: Uint8Array.from(step.bytes).buffer,
}),
);
return;
case 'socketClose':
targetSocket(step.socket).dispatchEvent(
new CloseEvent('close', {
Expand All @@ -489,6 +545,21 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
case 'visibility':
document.setVisibility(step.state);
return;
case 'onState': {
const {then} = step;
const unsubscribe = z.connection.state.subscribe(state => {
if (state.name !== step.state) return;
unsubscribe();
if (then.op === 'close') {
void z.close();
} else {
void z.connection.connect(
then.auth === undefined ? undefined : {auth: then.auth},
);
}
});
return;
}
case 'connect':
void z.connection.connect(
step.auth === undefined ? undefined : {auth: step.auth},
Expand All @@ -497,6 +568,36 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
case 'close':
void z.close();
return;
case 'listen': {
const view = views.get(step.view)?.view;
if (!view) throw new Error(`No view ${step.view}`);
const after = step.after ?? 1;
let pending = step.actions;
const entry: {
remove: () => void;
calls: {data: ReadonlyJSONValue; resultType: string}[];
threw?: string | undefined;
} = {remove: () => {}, calls: []};
listeners.set(step.listener, entry);
let count = 0;
entry.remove = view.addListener((data, resultType) => {
entry.calls.push({data: json(data), resultType});
if (++count > after && pending) {
const actions = pending;
pending = undefined;
for (const action of actions) {
// The synchronous part of each step runs inside the listener.
perform(action).catch(e => {
entry.threw ??= errorMessage(e);
});
}
}
});
return;
}
case 'unlisten':
listeners.get(step.listener)?.remove();
return;
case 'restart': {
await z.close();
await settle();
Expand All @@ -517,6 +618,7 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
placeholders.set(clientGroupID, '$clientGroupID');
ids.set('$clientID', clientID);
views.clear();
listeners.clear();
mutations.clear();
runs.clear();
preloads.clear();
Expand Down Expand Up @@ -583,6 +685,15 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
if (events.length > 0) {
result.events = events.splice(0);
}
if (listeners.size > 0) {
result.listeners = Object.fromEntries(
Array.from(listeners, ([k, v]) => [
k,
{calls: v.calls.splice(0), ...(v.threw ? {threw: v.threw} : {})},
]),
);
for (const v of listeners.values()) v.threw = undefined;
}
results.push(result as StepResult);
}
} finally {
Expand Down
Loading
Loading