Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d3367c6
fix(server): rpc serialization
sanny-io Sep 1, 2026
8ddadce
fix(client-helpers): serialization
sanny-io Sep 1, 2026
02a1969
fix(fetch-client): transaction serialization
sanny-io Sep 1, 2026
2501652
chore(fetch-client): adjust tests
sanny-io Sep 1, 2026
751f1db
chore(fetch-client): add `$transaction` superjson test
sanny-io Sep 1, 2026
dbe80db
chore(fetch-client): regenerate schema
sanny-io Sep 1, 2026
12f8fa3
chore(tanstack-query): adjust tests
sanny-io Sep 1, 2026
e86ca4f
chore(client-helpers): adjust tests
sanny-io Sep 1, 2026
6a09e42
chore(server): adjust rest and rpc query params
sanny-io Sep 1, 2026
58fb5d1
chore(server): adjust adapter tests
sanny-io Sep 1, 2026
17e8fa6
chore(server): adjust rpc open api tests
sanny-io Sep 1, 2026
8083594
chore(cli): adjust tests
sanny-io Sep 1, 2026
2881fb5
chore: regenerate schemas
sanny-io Sep 1, 2026
fe9ce9e
chore(fetch-client): fix test
sanny-io Sep 1, 2026
c34a96c
Merge remote-tracking branch 'upstream/dev' into fix/rpc-serialization
sanny-io Sep 4, 2026
fc050e7
fix(fetch-client): only serialize `args`
sanny-io Sep 4, 2026
72ec3e1
fix(client-helpers): catch errors as plain json
sanny-io Sep 4, 2026
bafb479
(server): pass `argsPayload` as-is
sanny-io Sep 4, 2026
e63e2ff
chore(cli): update tests
sanny-io Sep 4, 2026
eeb3119
chore(server): update tests
sanny-io Sep 4, 2026
136c04a
fix(client-helpers): use `res.text()`
sanny-io Sep 4, 2026
8832111
chore(client-helpers): fix broken tests
sanny-io Sep 4, 2026
739a4be
(fetch-client): fix failing test
sanny-io Sep 4, 2026
2d2b1b5
(fetch-client): fix failing tests
sanny-io Sep 4, 2026
6b3228f
(server): fix failing tests
sanny-io Sep 4, 2026
2ccbacb
chore(server): add `meta` ext query args test
sanny-io Sep 4, 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
58 changes: 31 additions & 27 deletions packages/cli/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const TEST_PUBLIC_KEY_DER = 'MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq
function buildSignatureHeader(options: {
privateKey: string;
method: string;
/** Path + optional query string, e.g. `/api/model/user/findMany?q=%7B%7D` */
/** Path + optional query string, e.g. `/api/model/user/findMany?data=%7B%7D` */
pathWithQuery: string;
body?: unknown;
authorizationToken?: string;
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('CLI proxy tests', () => {
const createRes = await fetch(`${baseUrl}/api/model/user/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { name: 'Alice' } }),
body: JSON.stringify({ data: { data: { name: 'Alice' } } }),
});
expect(createRes.status).toBe(201);
const created = await createRes.json();
Expand Down Expand Up @@ -240,23 +240,25 @@ describe('CLI proxy tests', () => {
const txRes = await fetch(`${baseUrl}/api/model/$transaction/sequential`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([
{
model: 'User',
op: 'create',
args: { data: { id: 'u1', email: 'alice@example.com' } },
},
{
model: 'Post',
op: 'create',
args: { data: { id: 'p1', title: 'Hello World', authorId: 'u1' } },
},
{
model: 'Post',
op: 'findMany',
args: { where: { authorId: 'u1' } },
},
]),
body: JSON.stringify({
data: [
{
model: 'User',
op: 'create',
args: { data: { id: 'u1', email: 'alice@example.com' } },
},
{
model: 'Post',
op: 'create',
args: { data: { id: 'p1', title: 'Hello World', authorId: 'u1' } },
},
{
model: 'Post',
op: 'findMany',
args: { where: { authorId: 'u1' } },
},
],
}),
});
expect(txRes.status).toBe(200);
const tx = await txRes.json();
Expand All @@ -276,7 +278,7 @@ describe('CLI proxy tests', () => {

// Confirm persisted outside transaction too.
const userRes = await fetch(
`${baseUrl}/api/model/user/findUnique?q=${encodeURIComponent(JSON.stringify({ where: { id: 'u1' } }))}`,
`${baseUrl}/api/model/user/findUnique?data=${encodeURIComponent(JSON.stringify({ where: { id: 'u1' } }))}`,
);
expect(userRes.status).toBe(200);
const user = await userRes.json();
Expand Down Expand Up @@ -334,8 +336,8 @@ describe('CLI proxy tests', () => {
// Pre-seed a record directly via client
await client.user.create({ data: { id: 'u1', email: 'alice@example.com' } });

const q = encodeURIComponent(JSON.stringify({ where: { id: 'u1' } }));
const pathWithQuery = `/api/model/user/findUnique?q=${q}`;
const data = encodeURIComponent(JSON.stringify({ where: { id: 'u1' } }));
const pathWithQuery = `/api/model/user/findUnique?data=${data}`;
const sig = buildSignatureHeader({
privateKey: TEST_PRIVATE_KEY,
method: 'GET',
Expand All @@ -353,7 +355,7 @@ describe('CLI proxy tests', () => {
it('should allow POST (create) requests with a valid signature', async () => {
const { app } = await createPolicyApp(zmodel);
const baseUrl = await startAt(app);
const reqBody = { data: { email: 'bob@example.com' } };
const reqBody = { data: { data: { email: 'bob@example.com' } } };
const pathWithQuery = '/api/model/user/create';
const sig = buildSignatureHeader({
privateKey: TEST_PRIVATE_KEY,
Expand All @@ -377,7 +379,7 @@ describe('CLI proxy tests', () => {
const baseUrl = await startAt(app);
// Seed a record
await client.user.create({ data: { id: 'u1', email: 'old@example.com' } });
const reqBody = { where: { id: 'u1' }, data: { email: 'new@example.com' } };
const reqBody = { data: { where: { id: 'u1' }, data: { email: 'new@example.com' } } };
const pathWithQuery = '/api/model/user/update';
const sig = buildSignatureHeader({
privateKey: TEST_PRIVATE_KEY,
Expand Down Expand Up @@ -808,7 +810,7 @@ describe('CLI proxy tests', () => {
await client.user.create({ data: { id: 'u2', email: 'user2@example.com' } });

// Authenticated as u2 trying to update u1
const reqBody = { where: { id: 'u1' }, data: { email: 'hacked@example.com' } };
const reqBody = { data: { where: { id: 'u1' }, data: { email: 'hacked@example.com' } } };
const authToken = makeUserToken({ type: 'user', data: { id: 'u2' } });
const pathWithQuery = '/api/model/user/update';
const r = await signedFetch(baseUrl, pathWithQuery, {
Expand All @@ -825,7 +827,7 @@ describe('CLI proxy tests', () => {
const { client: _client, app } = await createPolicyApp(zmodel);
const baseUrl = await startAt(app);

const reqBody = { data: { id: 'u1', email: 'user1@example.com' } };
const reqBody = { data: { data: { id: 'u1', email: 'user1@example.com' } } };
const authToken = makeUserToken({ type: 'superUser' });
const pathWithQuery = '/api/model/user/create';
const r = await signedFetch(baseUrl, pathWithQuery, {
Expand Down Expand Up @@ -873,7 +875,9 @@ describe('CLI proxy tests', () => {
const r = await signedFetch(baseUrl, pathWithQuery, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
body: JSON.stringify(txBody),
body: JSON.stringify({
data: txBody,
}),
});
expect(r.status).toBe(200);
const body = await r.json();
Expand Down
25 changes: 11 additions & 14 deletions packages/clients/client-helpers/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ export async function fetcher<R>(url: string, options?: RequestInit, customFetch
const _fetch = customFetch ?? fetch;
const res = await _fetch(url, options);
if (!res.ok) {
const errData = unmarshal(await res.text());
if (errData.error?.rejectedByPolicy && errData.error?.rejectReason === 'cannot-read-back') {
const json = JSON.parse(await res.text());
if (json.error?.rejectedByPolicy && json.error?.rejectReason === 'cannot-read-back') {
// policy doesn't allow mutation result to be read back, just return undefined
return undefined as any;
}
const error: QueryError = new Error('An error occurred while fetching the data.');
error.info = errData.error;
error.info = json.error;
error.status = res.status;
throw error;
}

const textResult = await res.text();
try {
return unmarshal(textResult).data as R;
return unmarshal(textResult) as R;
} catch (err) {
console.error(`Unable to deserialize data:`, textResult);
throw err;
Expand All @@ -47,7 +47,7 @@ export function makeUrl(endpoint: string, model: string, operation: string, args
}

const { data, meta } = serialize(args);
let result = `${baseUrl}?q=${encodeURIComponent(JSON.stringify(data))}`;
let result = `${baseUrl}?data=${encodeURIComponent(JSON.stringify(data))}`;
if (meta) {
result += `&meta=${encodeURIComponent(JSON.stringify({ serialization: meta }))}`;
}
Expand Down Expand Up @@ -113,11 +113,10 @@ export function deserialize(value: unknown, meta: any): unknown {
*/
export function marshal(value: unknown) {
const { data, meta } = serialize(value);
if (meta) {
return JSON.stringify({ ...(data as any), meta: { serialization: meta } });
} else {
return JSON.stringify(data);
if (!meta) {
return JSON.stringify({ data });
}
return JSON.stringify({ data, meta: { serialization: meta } });
}

/**
Expand All @@ -126,10 +125,8 @@ export function marshal(value: unknown) {
*/
export function unmarshal(value: string) {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed?.data && parsed?.meta?.serialization) {
const deserializedData = deserialize(parsed.data, parsed.meta.serialization);
return { ...parsed, data: deserializedData };
} else {
return parsed;
if (!parsed.meta?.serialization) {
return parsed.data;
Comment thread
sanny-io marked this conversation as resolved.
}
return deserialize(parsed.data, parsed.meta.serialization);
}
43 changes: 13 additions & 30 deletions packages/clients/client-helpers/test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,28 +75,18 @@ describe('Fetcher and serialization tests', () => {
expect(result).toEqual(input);
});

it('marshals objects without metadata when not needed', () => {
const input = { name: 'John', age: 30 };
const marshaled = marshal(input);
const parsed = JSON.parse(marshaled);
expect(parsed.meta).toBeUndefined();
});

it('marshals and unmarshals objects with Decimal values', () => {
const input = { price: new Decimal('123.45') };
const marshaled = marshal(input);
const parsed = JSON.parse(marshaled);

// marshal spreads the data into the root object with meta
expect(parsed.price).toBeDefined();
expect(parsed.data.price).toBeDefined();
expect(parsed.meta).toBeDefined();
expect(parsed.meta.serialization).toBeDefined();

// unmarshal doesn't automatically deserialize this format
// It only deserializes objects with explicit 'data' and 'meta.serialization' fields
const result = unmarshal(marshaled);
expect(result).toHaveProperty('price');
expect(result).toHaveProperty('meta');
});

it('includes metadata when serialization is needed', () => {
Expand All @@ -120,17 +110,10 @@ describe('Fetcher and serialization tests', () => {
const marshaled = JSON.stringify(responseFormat);

const result = unmarshal(marshaled);
expect(result.data).toBeDefined();
expect((result.data as any).value).toBeInstanceOf(Decimal);
expect(result).toBeDefined();
expect((result as any).value).toBeInstanceOf(Decimal);
// Decimal normalizes '100.00' to '100'
expect((result.data as any).value.toString()).toBe('100');
});

it('unmarshals plain values without data wrapper', () => {
const plainValue = { name: 'test' };
const marshaled = JSON.stringify(plainValue);
const result = unmarshal(marshaled);
expect(result).toEqual(plainValue);
expect((result as any).value.toString()).toBe('100');
});
});

Expand All @@ -143,7 +126,7 @@ describe('Fetcher and serialization tests', () => {
it('creates URL with simple args', () => {
const args = { where: { id: '1' } };
const url = makeUrl('/api', 'User', 'findUnique', args);
expect(url).toContain('/api/user/findUnique?q=');
expect(url).toContain('/api/user/findUnique?data=');
expect(url).toContain(encodeURIComponent(JSON.stringify(args)));
});

Expand All @@ -161,12 +144,12 @@ describe('Fetcher and serialization tests', () => {
};
const url = makeUrl('/api', 'Product', 'findFirst', args);

expect(url).toContain('/api/product/findFirst?q=');
expect(url).toContain('/api/product/findFirst?data=');
expect(url).toContain('&meta=');

// Verify we can reconstruct the args from the URL
const urlObj = new URL(url, 'http://localhost');
const qParam = urlObj.searchParams.get('q');
const qParam = urlObj.searchParams.get('data');
const metaParam = urlObj.searchParams.get('meta');

expect(qParam).toBeDefined();
Expand All @@ -179,7 +162,7 @@ describe('Fetcher and serialization tests', () => {

it('handles empty args object', () => {
const url = makeUrl('/api', 'User', 'findMany', {});
expect(url).toContain('/api/user/findMany?q=');
expect(url).toContain('/api/user/findMany?data=');
});

it('handles complex nested args', () => {
Expand All @@ -188,7 +171,7 @@ describe('Fetcher and serialization tests', () => {
where: { AND: [{ active: true }, { verified: true }] },
};
const url = makeUrl('/api', 'User', 'findMany', args);
expect(url).toContain('/api/user/findMany?q=');
expect(url).toContain('/api/user/findMany?data=');
expect(url).toContain(encodeURIComponent(JSON.stringify(args)));
});
});
Expand All @@ -211,7 +194,7 @@ describe('Fetcher and serialization tests', () => {
const responseData = { id: '1', name: 'Alice' };
mockFetch.mockResolvedValue({
ok: true,
text: async () => marshal({ data: responseData }),
text: async () => marshal(responseData),
});

const result = await fetcher('/api/user/findUnique', {});
Expand Down Expand Up @@ -300,7 +283,7 @@ describe('Fetcher and serialization tests', () => {
it('use custom fetch if provided', async () => {
const customFetch = vi.fn().mockResolvedValue({
ok: true,
text: async () => marshal({ data: { id: '1', name: 'Custom' } }),
text: async () => marshal({ id: '1', name: 'Custom' }),
});

const result = await fetcher('/api/user/findUnique', {}, customFetch);
Expand Down Expand Up @@ -333,7 +316,7 @@ describe('Fetcher and serialization tests', () => {
it('handles empty response body', async () => {
mockFetch.mockResolvedValue({
ok: true,
text: async () => marshal({ data: null }),
text: async () => marshal(null),
});

const result = await fetcher('/api/user/delete', {});
Expand All @@ -347,7 +330,7 @@ describe('Fetcher and serialization tests', () => {
];
mockFetch.mockResolvedValue({
ok: true,
text: async () => marshal({ data: responseData }),
text: async () => marshal(responseData),
});

const result = await fetcher<typeof responseData>('/api/user/findMany', {});
Expand Down
18 changes: 16 additions & 2 deletions packages/clients/fetch-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type TransactionOperation,
type TransactionResults,
} from '@zenstackhq/client-helpers';
import { fetcher, makeUrl, marshal, type FetchFn } from '@zenstackhq/client-helpers/fetch';
import { fetcher, makeUrl, marshal, type FetchFn, serialize } from '@zenstackhq/client-helpers/fetch';
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
import type {
AllModelOperations,
Expand Down Expand Up @@ -301,7 +301,21 @@ export function createClient<SchemaOrClient extends SchemaDef | ClientContract<a
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: marshal(operations),
body: JSON.stringify({
data: operations.map((op) => {
const { data: serializedArgs, meta } = serialize(op.args);
if (!meta) {
return op;
}
return {
...op,
args: serializedArgs,
meta: {
serialization: meta,
},
};
}),
}),
},
customFetch,
);
Expand Down
Loading
Loading