Skip to content
90 changes: 90 additions & 0 deletions apps/zbugs/src/local-mutate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {expect, test, vi, beforeEach, afterEach} from 'vitest';
import {zeroForTest} from '@rocicorp/zero/testing';
import {builder, schema} from '../shared/schema.ts';
import {mutators} from '../shared/mutators.ts';
import {defineMutator, defineMutators} from '@rocicorp/zero';
import {z} from 'zod/mini';

const userSchema = z.object({
id: z.string(),
login: z.string(),
name: z.string(),
role: z.union([z.literal('user'), z.literal('crew')]),
});

// Use the merge overload to inherit context type from the base mutators
const mutatorsForTest = defineMutators(mutators, {
user: {
create: defineMutator(userSchema, async ({tx, args}) => {
await tx.mutate.user.insert({
id: args.id,
login: args.login,
name: args.name,
role: args.role,
avatar: '',
});
}),
},
});

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T12:00:00Z'));
});

afterEach(() => {
vi.useRealTimers();
});

test('local mutate', async () => {
const zero = zeroForTest({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example of how you could use zeroForTest

cacheURL: null,
kvStore: 'mem',
schema,
mutators: mutatorsForTest,
userID: 'user-1',
// oxlint-disable-next-line no-explicit-any
context: {sub: 'user-1', role: 'user'} as any,
});

await zero.mutate(
mutatorsForTest.user.create({
id: 'user-1',
login: 'holden',
name: 'James Holden',
role: 'user',
}),
).client;

await zero.mutate(
mutatorsForTest.issue.create({
id: 'issue-1',
title: 'Test Issue',
description: 'This is a test issue',
projectID: 'project-1',
created: Date.now(),
modified: Date.now(),
}),
).client;

const issues = await zero.run(builder.issue);

expect(issues).toMatchInlineSnapshot(`
[
{
"assigneeID": null,
"created": 1736942400000,
"creatorID": "user-1",
"description": "This is a test issue",
"id": "issue-1",
"modified": 1736942400000,
"open": true,
"projectID": "project-1",
"shortID": null,
"title": "Test Issue",
"visibility": "public",
Symbol(rc): 1,
},
]
`);
});
3 changes: 2 additions & 1 deletion apps/zbugs/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"jsx": "react-jsx",
"paths": {
"@rocicorp/zero": ["../../packages/zero/src/zero.ts"],
"@rocicorp/zero/react": ["../../packages/zero/src/react.ts"]
"@rocicorp/zero/react": ["../../packages/zero/src/react.ts"],
"@rocicorp/zero/testing": ["../../packages/zero/src/testing.ts"]
},
"declaration": true
},
Expand Down
6 changes: 6 additions & 0 deletions apps/zbugs/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export function configForVersion(version: number, url: string) {
plugins: [tsconfigPaths()],
resolve: {
alias: [
{
find: '@rocicorp/zero/testing',
replacement: resolve(packagesDir, 'zero/src/testing.ts'),
},
{
find: '@rocicorp/zero',
replacement: resolve(packagesDir, 'zero/src/zero.ts'),
Expand Down Expand Up @@ -47,7 +51,9 @@ export function configForVersion(version: number, url: string) {

export function configForNoPg(url: string) {
const name = nameFromURL(url);

return mergeConfig(config, {
plugins: [tsconfigPaths()],
test: {
name: `${name}/no-pg`,
browser: {enabled: false},
Expand Down
4 changes: 4 additions & 0 deletions packages/zero/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@
"types": "./out/zero/src/sqlite.d.ts",
"default": "./out/zero/src/sqlite.js"
},
"./testing": {
"types": "./out/zero/src/testing.d.ts",
"default": "./out/zero/src/testing.js"
},
"./zqlite": {
"types": "./out/zero/src/zqlite.d.ts",
"default": "./out/zero/src/zqlite.js"
Expand Down
1 change: 1 addition & 0 deletions packages/zero/src/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {zeroForTest} from '../../zero-client/src/client/test-utils.ts';
89 changes: 87 additions & 2 deletions packages/zero/tool/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const define = {
'process.env.DISABLE_MUTATION_RECOVERY': 'true',
};

const testingDefine = {
...define,
TESTING: 'true',
};

// Vite config helper functions
async function getPackageJSON() {
const content = await readFile(resolve('package.json'), 'utf-8');
Expand All @@ -53,10 +58,14 @@ function extractOutPath(path: string): string | undefined {
function extractEntries(
entries: Record<string, unknown>,
getEntryName: (key: string, outPath: string) => string,
excludeKeys: Set<string> = new Set(),
): Record<string, string> {
const entryPoints: Record<string, string> = {};

for (const [key, value] of Object.entries(entries)) {
if (excludeKeys.has(key)) {
continue;
}
const path =
typeof value === 'string' ? value : (value as {default?: string}).default;

Expand Down Expand Up @@ -94,18 +103,37 @@ function getWorkerEntryPoints(): Record<string, string> {
return entryPoints;
}

// Entry points that need TESTING=true
const testingExports = new Set(['./testing']);

async function getAllEntryPoints(): Promise<Record<string, string>> {
const packageJSON = await getPackageJSON();

return {
...extractEntries(packageJSON.exports ?? {}, (key, outPath) =>
key === '.' ? 'zero/src/zero' : outPath,
...extractEntries(
packageJSON.exports ?? {},
(key, outPath) => (key === '.' ? 'zero/src/zero' : outPath),
testingExports,
),
...extractEntries(packageJSON.bin ?? {}, (_, outPath) => outPath),
...getWorkerEntryPoints(),
};
}

async function getTestingEntryPoints(): Promise<Record<string, string>> {
const packageJSON = await getPackageJSON();

return extractEntries(
packageJSON.exports ?? {},
(key, outPath) => (key === '.' ? 'zero/src/zero' : outPath),
new Set(
Object.keys(packageJSON.exports ?? {}).filter(
k => !testingExports.has(k),
),
),
);
}

const baseConfig: InlineConfig = {
configFile: false,
logLevel: 'warn',
Expand Down Expand Up @@ -143,6 +171,58 @@ async function getViteConfig(): Promise<InlineConfig> {
};
}

// Modules that should be kept external in the testing build to share
// symbol instances with the main bundle
const testingExternalModules = ['query-internals'];
Comment on lines +174 to +176
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is correct? Don't we want to share everything?

It is a bit strange because we do not want to get copies of the code but the code needs have have TESTING true.

I'm really not sure how to square this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should get rid of the TESTING flag and use import.meta.env or process.env?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should get rid of the TESTING flag and use import.meta.env or process.env?


async function getTestingViteConfig(): Promise<InlineConfig> {
return {
...baseConfig,
define: testingDefine,
build: {
...baseConfig.build,
rollupOptions: {
external: (id: string) => {
// Check standard externals
if (external.some(ext => id === ext || id.startsWith(ext + '/'))) {
return true;
}
// Keep certain modules external so testing bundle shares symbols
// with the main bundle (e.g., queryInternalsTag)
return testingExternalModules.some(mod => id.includes(mod));
},
input: await getTestingEntryPoints(),
output: {
format: 'es',
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
// Don't preserve modules for testing - bundle dependencies so they
// get the TESTING=true define
preserveModules: false,
// Rewrite external module paths to point to the built output
paths: (id: string) => {
for (const mod of testingExternalModules) {
if (id.includes(mod)) {
// Convert absolute path to relative path from testing.js output
// testing.js is at out/zero/src/testing.js
// query-internals.js is at out/zql/src/query/query-internals.js
// Correct relative path: ../../zql/src/query/query-internals.js
// Vite prepends ../../ for output at zero/src/, so we just return
// the package-relative path
const match = id.match(/packages\/([^/]+\/src\/.+)\.ts$/);
if (match) {
return `${match[1]}.js`;
}
}
}
return id;
},
},
},
},
};
}

// Bundle size dashboard config: single entry, no code splitting, minified
// Uses esbuild's dropLabels to strip BUNDLE_SIZE labeled code blocks
const bundleSizeConfig: InlineConfig = {
Expand Down Expand Up @@ -233,8 +313,11 @@ async function build() {
// Watch mode: run vite and tsc in watch mode
const viteConfig = await getViteConfig();
viteConfig.build = {...viteConfig.build, watch: {}};
const testingConfig = await getTestingViteConfig();
testingConfig.build = {...testingConfig.build, watch: {}};
await Promise.all([
runViteBuild(viteConfig, 'vite build (watch)'),
runViteBuild(testingConfig, 'vite build (testing watch)'),
exec(
'tsc -p tsconfig.client.json --watch --preserveWatchOutput',
'client dts (watch)',
Expand All @@ -247,8 +330,10 @@ async function build() {
} else {
// Normal build: use inline vite config + type declarations
const viteConfig = await getViteConfig();
const testingConfig = await getTestingViteConfig();
await Promise.all([
runViteBuild(viteConfig, 'vite build'),
runViteBuild(testingConfig, 'vite build (testing)'),
exec('tsc -p tsconfig.client.json', 'client dts'),
exec('tsc -p tsconfig.server.json', 'server dts'),
]);
Expand Down
3 changes: 2 additions & 1 deletion packages/zero/tsconfig.client.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"src/react-native.ts",
"src/sqlite.ts",
"src/expo-sqlite.ts",
"src/op-sqlite.ts"
"src/op-sqlite.ts",
"src/testing.ts"
]
}
Loading