Repository: rocicorp/mono
Pull request #4179: feat!(mutators): re-add types to `push-processor`
Author: login: tantaman
Created: 2025-04-07T18:49:59Z | Merged: 2025-04-07T19:41:12Z

Users do not know what types to provide and keep getting errors about missing properties.

https://bugs.rocicorp.dev/issue/3704?f=1&d=2
https://bugs.rocicorp.dev/issue/3711
https://discord.com/channels/830183651022471199/1357260393176895508/1357835830256734381

While not fully typed (as in, not asking for the exact shape of the query string and body) we at least now specify that the query params need to be a record or a URLSearchParams object. We also specify that the request body must be provided as JSON.


Diff:
diff --git a/apps/zbugs/api/index.ts b/apps/zbugs/api/index.ts
index 0ce0bdbeec..0a8a750cc5 100644
--- a/apps/zbugs/api/index.ts
+++ b/apps/zbugs/api/index.ts
@@ -11,6 +11,7 @@ import {handlePush} from '../server/push-handler.ts';
 import {must} from '../../../packages/shared/src/must.ts';
 import assert from 'assert';
 import {authDataSchema, type AuthData} from '../shared/auth.ts';
+import type {ReadonlyJSONValue} from '@rocicorp/zero';
 
 declare module 'fastify' {
   interface FastifyInstance {
@@ -110,7 +111,10 @@ fastify.get<{
     );
 });
 
-fastify.post('/api/push', async function (request, reply) {
+fastify.post<{
+  Querystring: Record<string, string>;
+  Body: ReadonlyJSONValue;
+}>('/api/push', async function (request, reply) {
   let {authorization} = request.headers;
   if (authorization !== undefined) {
     assert(authorization.toLowerCase().startsWith('bearer '));
diff --git a/apps/zbugs/server/push-handler.ts b/apps/zbugs/server/push-handler.ts
index 9b37899004..42c0d1f067 100644
--- a/apps/zbugs/server/push-handler.ts
+++ b/apps/zbugs/server/push-handler.ts
@@ -4,6 +4,7 @@ import {schema} from '../shared/schema.ts';
 import {createServerMutators, type PostCommitTask} from './server-mutators.ts';
 import type {AuthData} from '../shared/auth.ts';
 import {connectionProvider} from '@rocicorp/zero/pg';
+import type {ReadonlyJSONValue} from '@rocicorp/zero';
 
 const processor = new PushProcessor(
   schema,
@@ -12,8 +13,8 @@ const processor = new PushProcessor(
 
 export async function handlePush(
   authData: AuthData | undefined,
-  params: unknown,
-  body: unknown,
+  params: Record<string, string> | URLSearchParams,
+  body: ReadonlyJSONValue,
 ) {
   const postCommitTasks: PostCommitTask[] = [];
   const mutators = createServerMutators(authData, postCommitTasks);
diff --git a/packages/zero-pg/src/web.test.ts b/packages/zero-pg/src/web.test.ts
new file mode 100644
index 0000000000..a801872747
--- /dev/null
+++ b/packages/zero-pg/src/web.test.ts
@@ -0,0 +1,82 @@
+import {describe, expect, test, vi} from 'vitest';
+import {PushProcessor} from './web.ts';
+import type {Schema} from '../../zero-schema/src/builder/schema-builder.ts';
+import type {CustomMutatorDefs} from './custom.ts';
+import type {DBConnection, DBTransaction} from '../../zql/src/mutate/custom.ts';
+import {type PushBody} from '../../zero-protocol/src/push.ts';
+import * as v from '../../shared/src/valita.ts';
+
+describe('PushProcessor', () => {
+  const body = {
+    pushVersion: 1,
+    requestID: 'test_request_id',
+    timestamp: 1234567890,
+    schemaVersion: 1,
+    clientGroupID: 'test_client_group',
+    mutations: [],
+  } satisfies PushBody;
+
+  const mockSchema = {
+    tables: {},
+    relationships: {},
+  } satisfies Schema;
+
+  const mockConnectionProvider = vi.fn().mockResolvedValue({
+    transaction: vi.fn().mockImplementation(callback => {
+      const mockTx = {} as DBTransaction<unknown>;
+      return callback(mockTx);
+    }),
+  } as unknown as DBConnection<unknown>);
+
+  // Mock mutators
+  const mockMutators = {} as CustomMutatorDefs<Schema, unknown>;
+
+  test('should accept Record<string, string> as params', async () => {
+    const processor = new PushProcessor(mockSchema, mockConnectionProvider);
+
+    const params: Record<string, string> = {
+      schema: 'test_schema',
+      appID: 'test_client_group',
+    };
+
+    const spy = vi.spyOn(v, 'parse');
+    await processor.process(mockMutators, params, body);
+
+    expect(spy.mock.calls[1][0]).toMatchInlineSnapshot(`
+      {
+        "appID": "test_client_group",
+        "schema": "test_schema",
+      }
+    `);
+  });
+
+  test('should accept URLSearchParams as params', async () => {
+    const processor = new PushProcessor(mockSchema, mockConnectionProvider);
+
+    const urlParams = new URLSearchParams();
+    urlParams.append('schema', 'test_schema');
+    urlParams.append('appID', 'test_client_group');
+
+    const spy = vi.spyOn(v, 'parse');
+    await processor.process(mockMutators, urlParams, body);
+
+    expect(spy.mock.calls[1][0]).toMatchInlineSnapshot(`
+      {
+        "appID": "test_client_group",
+        "schema": "test_schema",
+      }
+    `);
+  });
+
+  test('invalid params throw', async () => {
+    const processor = new PushProcessor(mockSchema, mockConnectionProvider);
+
+    const invalidParams: Record<string, string> = {
+      // Missing schema and clientGroupID
+    };
+
+    await expect(
+      processor.process(mockMutators, invalidParams, body),
+    ).rejects.toThrow('Missing property schema');
+  });
+});
diff --git a/packages/zero-pg/src/web.ts b/packages/zero-pg/src/web.ts
index 159020ba0b..fa6fd6583f 100644
--- a/packages/zero-pg/src/web.ts
+++ b/packages/zero-pg/src/web.ts
@@ -30,6 +30,7 @@ import {MutationAlreadyProcessedError} from '../../zero-cache/src/services/mutag
 import type {ServerSchema} from '../../z2s/src/schema.ts';
 import {getServerSchema} from './schema.ts';
 import {assert} from '../../shared/src/asserts.ts';
+import type {ReadonlyJSONValue} from '../../shared/src/json.ts';
 
 export type Params = v.Infer<typeof pushParamsSchema>;
 
@@ -62,12 +63,27 @@ export class PushProcessor<
     this.#schema = schema;
   }
 
+  /**
+   * Processes a push request from zero-cache.
+   * This function will parse the request, check the protocol version, and process each mutation in the request.
+   * - If a mutation is out of order: processing will stop and an error will be returned. The zero client will retry the mutation.
+   * - If a mutation has already been processed: it will be skipped and the processing will continue.
+   * - If a mutation receives an application error: it will be skipped, the error will be returned to the client, and processing will continue.
+   *
+   * @param mutators the custom mutators for the application
+   * @param queryString the query string from the request sent by zero-cache. This will include zero's postgres schema name and appID.
+   * @param body the body of the request sent by zero-cache as a JSON object.
+   * @returns
+   */
   async process(
     mutators: MD,
-    queryString: unknown,
-    body: unknown,
+    queryString: URLSearchParams | Record<string, string>,
+    body: ReadonlyJSONValue,
   ): Promise<PushResponse> {
     const req = v.parse(body, pushBodySchema);
+    if (queryString instanceof URLSearchParams) {
+      queryString = Object.fromEntries(queryString.entries());
+    }
     const params = v.parse(queryString, pushParamsSchema);
     const connection = await this.#dbConnectionProvider();
 


Reviews:
user: login: aboodman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T19:03:42Z
user: login: aboodman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T19:03:58Z
user: login: arv
body: LGTM

Thanks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:29:06Z

Review comments:
diff_hunk: @@ -64,10 +65,13 @@ export class PushProcessor<
 
   async process(
     mutators: MD,
-    queryString: unknown,
-    body: unknown,
+    queryString: URLSearchParams | Record<string, string>,
path: packages/zero-pg/src/web.ts
user: login: aboodman
body: I think it would be good to add some documentation too to this method (and generally any public entrypoints).
created_at: 2025-04-07T19:03:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,10 +65,13 @@ export class PushProcessor<
 
   async process(
     mutators: MD,
-    queryString: unknown,
-    body: unknown,
+    queryString: URLSearchParams | Record<string, string>,
path: packages/zero-pg/src/web.ts
user: login: aboodman
body: (I mean comment-based docs that will show up in intellisense)
created_at: 2025-04-07T19:03:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-07T18:50:00Z
body: [vc]: #vaDJJ6UZFd0TCvKWHI8tr6o+0mkg1AEzTzm/GCGb0Zg=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ6YnVncyIsInJvb3REaXJlY3RvcnkiOiJhcHBzL3pidWdzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3JvY2ljb3JwL3pidWdzL0dBVlE0bVM4c0txTGFRMURpcUJ2ZUprUEZXem8iLCJwcmV2aWV3VXJsIjoiemJ1Z3MtZ2l0LW1sYXctcHVzaC1oYW5kbGVyLXR5cGVzLXJvY2ljb3JwLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiemJ1Z3MtZ2l0LW1sYXctcHVzaC1oYW5kbGVyLXR5cGVzLXJvY2ljb3JwLnZlcmNlbC5hcHAifX0seyJuYW1lIjoicmVwbGljYWNoZS1kb2NzIiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3JvY2ljb3JwL3JlcGxpY2FjaGUtZG9jcy9CdGo4M3ZaM0xMd2NZZHVkS0Y3NVhGeVFFM29xIiwicHJldmlld1VybCI6InJlcGxpY2FjaGUtZG9jcy1naXQtbWxhdy1wdXNoLWhhbmRsZXItdHlwZXMtcm9jaWNvcnAudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJyZXBsaWNhY2hlLWRvY3MtZ2l0LW1sYXctcHVzaC1oYW5kbGVyLXR5cGVzLXJvY2ljb3JwLnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **replicache-docs** | ✅ Ready ([Inspect](https://vercel.com/rocicorp/replicache-docs/Btj83vZ3LLwcYdudKF75XFyQE3oq)) | [Visit Preview](https://replicache-docs-git-mlaw-push-handler-types-rocicorp.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/replicache-docs-git-mlaw-push-handler-types-rocicorp.vercel.app?via=pr-comment-feedback-link) | Apr 7, 2025 7:39pm |
| **zbugs** | ✅ Ready ([Inspect](https://vercel.com/rocicorp/zbugs/GAVQ4mS8sKqLaQ1DiqBveJkPFWzo)) | [Visit Preview](https://zbugs-git-mlaw-push-handler-types-rocicorp.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/zbugs-git-mlaw-push-handler-types-rocicorp.vercel.app?via=pr-comment-feedback-link) | Apr 7, 2025 7:39pm |



author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-07T18:51:46Z
body: <h2><a href="https://bencher.dev/perf/zero/reports/2fb2d268-8414-4a6a-93a5-60f23058024b?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero"><img src="https://bencher.dev/favicon.svg" width="24" height="24" alt="🐰" /> Bencher Report</a></h2><table><tr><td>Branch</td><td><a href="https://bencher.dev/perf/zero/branches/mlaw-push-handler-types?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">mlaw/push-handler-types</a></td></tr><tr><td>Testbed</td><td><a href="https://bencher.dev/perf/zero/testbeds/linux?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">Linux</a></td></tr></table><details><summary>Click to view all benchmark results</summary><br /><table><thead><tr><th>Benchmark</th><th><a href="https://bencher.dev/perf/zero/measures/file-size?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">File Size</a></th><th>Benchmark Result<br />kilobytes (KB)<br />(Result Δ%)</th><th>Upper Boundary<br />kilobytes (KB)<br />(Limit %)</th></tr></thead><tbody><tr><td><a href="https://bencher.dev/perf/zero/benchmarks/zero-package-tgz?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">zero-package.tgz</a></td><td>📈 <a href="https://bencher.dev/perf/zero?branches=0bf17f03-15cc-4b04-84a7-46930bdc00c2&heads=dfb0ccd5-08d2-41b9-9693-886ddf952a41&testbeds=7274290c-8365-4158-ba73-19904b7ef4ae&benchmarks=2bcdd867-a170-440b-8c18-ee15b6476b90&measures=8d8dbe98-8c7a-4838-b351-cd9d621320fe&start_time=1741462797000&end_time=1744054797000&report=2fb2d268-8414-4a6a-93a5-60f23058024b&upper_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/zero/thresholds/bf14bfaf-01f2-4d02-b859-cc70b6b09feb?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">view threshold</a></td><td>1,110.33 KB<br /><details><summary>(+0.03%)</summary>Baseline: 1,110.04 KB</details></td><td>1,132.25 KB<br />(98.06%)</td></tr><tr><td><a href="https://bencher.dev/perf/zero/benchmarks/zero-js?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">zero.js</a></td><td>📈 <a href="https://bencher.dev/perf/zero?branches=0bf17f03-15cc-4b04-84a7-46930bdc00c2&heads=dfb0ccd5-08d2-41b9-9693-886ddf952a41&testbeds=7274290c-8365-4158-ba73-19904b7ef4ae&benchmarks=4f172dbf-1d31-4809-9c2d-12c79ed4c9d4&measures=8d8dbe98-8c7a-4838-b351-cd9d621320fe&start_time=1741462797000&end_time=1744054797000&report=2fb2d268-8414-4a6a-93a5-60f23058024b&upper_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/zero/thresholds/bf14bfaf-01f2-4d02-b859-cc70b6b09feb?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">view threshold</a></td><td>191.67 KB<br /><details><summary>(0.00%)</summary>Baseline: 191.67 KB</details></td><td>195.50 KB<br />(98.04%)</td></tr><tr><td><a href="https://bencher.dev/perf/zero/benchmarks/zero-js-br?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">zero.js.br</a></td><td>📈 <a href="https://bencher.dev/perf/zero?branches=0bf17f03-15cc-4b04-84a7-46930bdc00c2&heads=dfb0ccd5-08d2-41b9-9693-886ddf952a41&testbeds=7274290c-8365-4158-ba73-19904b7ef4ae&benchmarks=f9d12b7c-f48c-41fc-a662-5019d902c9f9&measures=8d8dbe98-8c7a-4838-b351-cd9d621320fe&start_time=1741462797000&end_time=1744054797000&report=2fb2d268-8414-4a6a-93a5-60f23058024b&upper_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/zero/thresholds/bf14bfaf-01f2-4d02-b859-cc70b6b09feb?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">view threshold</a></td><td>53.74 KB<br /><details><summary>(0.00%)</summary>Baseline: 53.74 KB</details></td><td>54.81 KB<br />(98.04%)</td></tr></tbody></table></details><a href="https://bencher.dev/perf/zero/reports/2fb2d268-8414-4a6a-93a5-60f23058024b?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">🐰 View full continuous benchmarking report in Bencher</a><div id="bencher.dev/projects/zero/id/size"></div>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-07T18:52:37Z
body: <h2><a href="https://bencher.dev/perf/zero/reports/d9c05ab3-62c3-48e5-a11b-a3ba76694e13?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero"><img src="https://bencher.dev/favicon.svg" width="24" height="24" alt="🐰" /> Bencher Report</a></h2><table><tr><td>Branch</td><td><a href="https://bencher.dev/perf/zero/branches/mlaw-push-handler-types?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">mlaw/push-handler-types</a></td></tr><tr><td>Testbed</td><td><a href="https://bencher.dev/perf/zero/testbeds/linux?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">Linux</a></td></tr></table><details><summary>Click to view all benchmark results</summary><br /><table><thead><tr><th>Benchmark</th><th><a href="https://bencher.dev/perf/zero/measures/throughput?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">Throughput</a></th><th>Benchmark Result<br />operations / second (ops/s)<br />(Result Δ%)</th><th>Lower Boundary<br />operations / second (ops/s)<br />(Limit %)</th></tr></thead><tbody><tr><td><a href="https://bencher.dev/perf/zero/benchmarks/src-client-custom-bench-ts-big-schema?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">src/client/custom.bench.ts > big schema</a></td><td>📈 <a href="https://bencher.dev/perf/zero?branches=0bf17f03-15cc-4b04-84a7-46930bdc00c2&heads=1af66374-63ad-4ab4-bd66-8cfed10acadc&testbeds=7274290c-8365-4158-ba73-19904b7ef4ae&benchmarks=da25d28a-0b6b-4baa-8550-fb143072856d&measures=fc802276-c2ed-462d-9fa5-0c1fcf2deb8e&start_time=1741462837000&end_time=1744054837000&report=d9c05ab3-62c3-48e5-a11b-a3ba76694e13&lower_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/zero/thresholds/529055e8-2853-4ffb-9ae5-f39a758456d0?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">view threshold</a></td><td>0.00 ops/s<br /><details><summary>(+1.71%)</summary>Baseline: 0.00 ops/s</details></td><td>0.00 ops/s<br />(95.53%)</td></tr><tr><td><a href="https://bencher.dev/perf/zero/benchmarks/src-client-zero-bench-ts-basics-all-1000-rows-x-10-colu-rypc9f4e?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">src/client/zero.bench.ts > basics > All 1000 rows x 10 columns (numbers)</a></td><td>📈 <a href="https://bencher.dev/perf/zero?branches=0bf17f03-15cc-4b04-84a7-46930bdc00c2&heads=1af66374-63ad-4ab4-bd66-8cfed10acadc&testbeds=7274290c-8365-4158-ba73-19904b7ef4ae&benchmarks=8ff168e6-e598-4cc2-a9c7-e5d09c4aefec&measures=fc802276-c2ed-462d-9fa5-0c1fcf2deb8e&start_time=1741462837000&end_time=1744054837000&report=d9c05ab3-62c3-48e5-a11b-a3ba76694e13&lower_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/zero/thresholds/529055e8-2853-4ffb-9ae5-f39a758456d0?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">view threshold</a></td><td>42.40 ops/s<br /><details><summary>(+2.94%)</summary>Baseline: 41.19 ops/s</details></td><td>38.14 ops/s<br />(89.95%)</td></tr><tr><td><a href="https://bencher.dev/perf/zero/benchmarks/src-client-zero-bench-ts-with-filter-lower-rows-500-x-1-d9xjs9n1?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">src/client/zero.bench.ts > with filter > Lower rows 500 x 10 columns (numbers)</a></td><td>📈 <a href="https://bencher.dev/perf/zero?branches=0bf17f03-15cc-4b04-84a7-46930bdc00c2&heads=1af66374-63ad-4ab4-bd66-8cfed10acadc&testbeds=7274290c-8365-4158-ba73-19904b7ef4ae&benchmarks=ce024352-2a47-400d-b489-b9951bf428ac&measures=fc802276-c2ed-462d-9fa5-0c1fcf2deb8e&start_time=1741462837000&end_time=1744054837000&report=d9c05ab3-62c3-48e5-a11b-a3ba76694e13&lower_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/zero/thresholds/529055e8-2853-4ffb-9ae5-f39a758456d0?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">view threshold</a></td><td>42.10 ops/s<br /><details><summary>(+0.99%)</summary>Baseline: 41.69 ops/s</details></td><td>37.86 ops/s<br />(89.92%)</td></tr></tbody></table></details><a href="https://bencher.dev/perf/zero/reports/d9c05ab3-62c3-48e5-a11b-a3ba76694e13?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=zero">🐰 View full continuous benchmarking report in Bencher</a><div id="bencher.dev/projects/zero/id/perf"></div>
author_association: NONE