Skip to content

Commit 46795be

Browse files
committed
Merge pull request #120 from rocicorp/aa/v0.19
0.19
2 parents 2f79ca6 + 569565a commit 46795be

File tree

9 files changed

+394
-221
lines changed

9 files changed

+394
-221
lines changed

contents/docs/custom-mutators.mdx

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: Custom Mutators
44

55
_Custom Mutators_ are a new way to write data in Zero that is much more powerful than the original ["CRUD" mutator API](./writing-data).
66

7-
Instead of having only the few built-in `insert`/`update`/`delete` write operations for each table, custom mutators _allow you to create your own write operations_ using arbitrary code. This makes it possible to do things that are impossible or awkward with other sync engines.
7+
Instead of having only the few built-in `insert`/`update`/`delete` write operations for each table, custom mutators allow you to _create your own write operations_ using arbitrary code. This makes it possible to do things that are impossible or awkward with other sync engines.
88

99
For example, you can create custom mutators that:
1010

@@ -47,7 +47,7 @@ Zero's custom mutators are based on [_server reconciliation_](https://www.gabrie
4747
Our previous sync engine, [Replicache](https://replicache.dev/), also used
4848
server reconciliation. The ability to implement arbitrary mutators was one of
4949
Replicache's most popular features. Custom mutators bring this same power to
50-
Zero, but with a *much* better developer experience.
50+
Zero, but with a much better developer experience.
5151
</Note>
5252

5353
A custom mutator is just a function that runs within a database transaction, and which can read and write to the database. Here's an example of a very simple custom mutator written in TypeScript:
@@ -102,7 +102,7 @@ async function updateIssueOnServer(
102102
```
103103

104104
<Note heading="Code sharing in mutators is optional">
105-
Even in TypeScript, you can do as little or as much code sharing as you like. In your server mutator, you can [use raw SQL](TODO), any data access libraries you prefer, or add as much extra server-specific logic as you need.
105+
Even in TypeScript, you can do as little or as much code sharing as you like. In your server mutator, you can [use raw SQL](#dropping-down-to-raw-sql), any data access libraries you prefer, or add as much extra server-specific logic as you need.
106106

107107
Reusing ZQL on the server is a handy – and we expect frequently used – option, but not a requirement.
108108

@@ -141,7 +141,7 @@ Now that we understand what client and server mutations are, let's walk through
141141

142142
1. When you call a custom mutator on the client, Zero runs your client-side mutator immediately on the local device, updating all active queries instantly.
143143
2. In the background, Zero then sends a _mutation_ (a record of the mutator having run with certain arguments) to your server's push endpoint.
144-
3. Your push endpoint runs the [push protocol](#custom-push-implementation), executing the server-side mutator in a transaction against your database and recording the fact that the mutation ran.
144+
3. Your push endpoint runs the [push protocol](#custom-push-implementation), executing the server-side mutator in a transaction against your database and recording the fact that the mutation ran. Optionally, you use our `PushProcessor` class to handle this for you, but you can also implement it yourself.
145145
4. The changes to the database are replicated to `zero-cache` as normal.
146146
5. `zero-cache` calculates the updates to active queries and sends rows that have changed to each client. It also sends information about the mutations that have been applied to the database.
147147
6. Clients receive row updates and apply them to their local cache. Any pending mutations which have been applied to the server have their local effects rolled back.
@@ -234,7 +234,7 @@ export function createMutators() {
234234
issue: {
235235
update: async (tx, {id, title}: {id: string; title: string}) => {
236236
// Read existing issue
237-
const prev = await tx.query.issue.where('id', id).one().run();
237+
const prev = await tx.query.issue.where('id', id).one();
238238

239239
// Validate title length. Legacy issues are exempt.
240240
if (!prev.isLegacy && title.length > 100) {
@@ -252,6 +252,16 @@ You have the full power of ZQL at your disposal, including relationships, filter
252252

253253
Reads and writes within a mutator are transactional, meaning that the datastore is guaranteed to not change while your mutator is running. And if the mutator throws, the entire mutation is rolled back.
254254

255+
<Note type="note" heading="No server reads in optimistic mutators">
256+
Outside of mutators, the `run()` method has a [`type` parameter](reading-data#running-queries-once) that can be used to wait for server results.
257+
258+
This parameter isn't supported within mutators, because waiting for server results makes no sense in an optimistic mutation – it defeats the purpose of running optimistically to begin with.
259+
260+
When a mutator runs on the client (`tx.location === "client"`), ZQL reads only return data already cached on the client. When mutators run on the server (`tx.location === "server"`), ZQL reads always return all data.
261+
262+
You can use `run()` within custom mutators, but the `type` argument does nothing. In the future, passing `type` in this situation will throw an error.
263+
</Note>
264+
255265
### Invoking Client Mutators
256266

257267
Once you have registered your client mutators, you can call them from your client-side application:
@@ -263,27 +273,60 @@ zero.mutate.issue.update({
263273
});
264274
```
265275

266-
Mutations execute instantly on the client, but it is sometimes useful to know when the server has applied the mutation (or experienced an error doing so).
276+
The result of a call to a mutator is a `Promise`. You do not usually need to `await` this promise as Zero mutators run very fast, usually completing in a tiny fraction of one frame.
277+
278+
However because mutators ocassionally need to access browser storage, they are technically `async`. Reading a row that was written by a mutator immediately after it is written may not return the new data, because the mutator may not have completed writing to storage yet.
279+
280+
### Waiting for Mutator Result
281+
282+
We typically recommend that you "fire and forget" mutators.
267283

268-
You can get the server result of a mutation with the `server` property of a mutator's return value:
284+
Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result doesn't make sense, because it will likely be wrong.
285+
286+
That said there are cases where it is useful to know when a write succeeded on either the client or server.
287+
288+
One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still *technically* asynchronous and reads directly after a write may not return the new data.
289+
290+
You can use the `.client` promise in this case to wait for a write to complete on the client side:
269291

270292
```ts
271-
const { server } = await zero.mutate.issue.update({
272-
id: 'issue-123',
273-
title: 'New title',
274-
}).server;
293+
try {
294+
const write = zero.mutate.issue.update({
295+
id: 'issue-123',
296+
title: 'New title',
297+
});
275298

276-
if (server.error) {
277-
console.error('Server mutation went boom', server.error);
278-
} else {
279-
console.log('Server mutation complete');
299+
// issue-123 not guaranteed to be present here. read1 may be undefined.
300+
const read1 = await zero.query.issue.where('id', 'issue-123').one();
301+
302+
// Await client write – almost always less than 1 frame, and same
303+
// macrotask, so no browser paint will occur here.
304+
await write.client;
305+
306+
// issue-123 definitely can be read now.
307+
const read2 = await zero.query.issue.where('id', 'issue-123').one();
308+
} catch (e) {
309+
console.error("Mutator failed on client", e);
310+
}
311+
```
312+
313+
You can also wait for the server write to succeed:
314+
315+
```ts
316+
try {
317+
await zero.mutate.issue.update({
318+
id: 'issue-123',
319+
title: 'New title',
320+
}).server;
321+
322+
// issue-123 is written to server
323+
} catch (e) {
324+
console.error("Mutator failed on server", e);
280325
}
281326
```
282327

283328
<Note heading="Returning data from mutators">
284-
You will soon be able to use `server.data` to return data from mutators in the
285-
success case. [Let us know](https://discord.rocicorp.dev/) if you need this
286-
soon.
329+
There is not yet a way to return data from mutators in the success case – the type of `.clent` and `.server` is always `Promise<void>`. [Let us know](https://discord.rocicorp.dev/) if you need this.
287330
</Note>
288331

289332
### Setting Up the Server
@@ -299,33 +342,40 @@ If you are implementing your server in TypeScript, you can use the `PushProcesso
299342
```ts
300343
import {Hono} from 'hono';
301344
import {handle} from 'hono/vercel';
302-
import {connectionProvider, PushProcessor} from '@rocicorp/zero/pg';
345+
import {PushProcessor, ZQLDatabase, PostgresJSConnection} from '@rocicorp/zero/pg';
303346
import postgres from 'postgres';
304347
import {schema} from '../shared/schema';
305348
import {createMutators} from '../shared/mutators';
306349

307350
// PushProcessor is provided by Zero to encapsulate a standard
308351
// implementation of the push protocol.
309352
const processor = new PushProcessor(
310-
schema,
311-
connectionProvider(postgres(process.env.ZERO_UPSTREAM_DB as string)),
353+
new ZQLDatabase(
354+
new PostgresJSConnection(
355+
postgres(process.env.ZERO_UPSTREAM_DB! as string)
356+
),
357+
schema
358+
)
312359
);
313360

314361
export const app = new Hono().basePath('/api');
315362

316363
app.post('/push', async c => {
317364
const result = await processor.process(
318365
createMutators(),
319-
c.req.query(),
320-
await c.req.json(),
366+
c.req.raw,
321367
);
322368
return await c.json(result);
323369
});
324370

325371
export default handle(app);
326372
```
327373

328-
The `connectionProvider` argument allows `PushProcessor` to create a connection and run transactions against your database. We provide an implementation for the excellent [`postgres.js`](https://www.npmjs.com/package/postgres) library, but you can [implement an adapter](https://github.com/rocicorp/mono/blob/bb9ba1bde0b11689ed3be8814c2acabb145b951b/packages/zql/src/mutate/custom.ts#L67) for a different Postgres library if you prefer.
374+
`PushProcessor` depends on an abstract `Database`. This allows it to implement the push algorithm against any database.
375+
376+
`@rocicorp/zero/pg` includes a `ZQLDatabase` implementation of this interface backed by Postgres. The implementation allows the same mutator functions to run on client and server, by providing an implementation of the ZQL APIs that custom mutators run on the client.
377+
378+
`ZQLDatabase` in turn relies on an abstract `DBConnection` that provides raw access to a Postgres database. This allows you to use any Postgres library you like, as long as you provide a `DBConnection` implementation for it. The `PostgresJSConnection` class implements `DBConnection` for the excellent [`postgres.js`](https://www.npmjs.com/package/postgres) library to connect to Postgres.
329379

330380
To reuse the client mutators exactly as-is on the server just pass the result of the same `createMutators` function to `PushProcessor`.
331381

@@ -409,8 +459,7 @@ export function createMutators(authData: AuthData | undefined) {
409459
const hasPermission = await tx.query.user
410460
.where('id', authData.sub)
411461
.whereExists('permissions', q => q.where('name', 'launch-missiles'))
412-
.one()
413-
.run();
462+
.one();
414463
if (!hasPermission) {
415464
throw new Error('User does not have permission to launch missiles');
416465
}

contents/docs/debug/permissions.mdx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,30 @@ Given that permissions are defined in their own file and internally applied to q
66

77
## Read Permissions
88

9-
The `transform-query` utility is provided to transform a query by applying permissions to it. As of today you'll need to provide the hash of the query you want to transform. You can find this in server logs, websocket network inspector, or in the CVR database. In a future release, you'll be able to write ZQL directly.
10-
11-
```ts
12-
npx transform-query --hash=2i81bazy03a00 --schema=./shared/schema.ts
9+
You can use the `analyze-query` utility with the `--apply-permissions` flag to see the complete query Zero runs, including read permissions.
10+
11+
```bash
12+
npx analyze-query
13+
--schema='./shared/schema.ts'
14+
--query='issue.related("comments")'
15+
--apply-permissions
16+
--auth-data='{"userId":"user-123"}'
1317
```
1418

15-
The output will be the ZQL query with permissions applied as well as the AST of that query.
19+
If the result looks right, the problem may be that Zero is not receiving the `AuthData` that you think it is. You can retrieve a query hash from websocket or server logs, then ask Zero for the details on that specific query.
20+
21+
Run this command with the same environment you run `zero-cache` with. It will use your `upstream` or `cvr` configuration to look up the query hash in the cvr database.
22+
23+
```bash
24+
npx analyze-query
25+
--schema='./shared/schema.ts'
26+
--hash='3rhuw19xt9vry'
27+
--apply-permissions
28+
--auth-data='{"userId":"user-123"}'
29+
```
1630

1731
<Note type="note">
18-
The printed AST is slightly different than the source ZQL string as it
19-
leverages internal APIs to prevent syncing rows that are used strictly for
20-
permission checks.
32+
The printed query can be different than the source ZQL string, because it is rebuilt from the query AST. But it should be logically equivalent to the query you wrote.
2133
</Note>
2234

2335
## Write Permissions

contents/docs/debug/slow-queries.mdx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,8 @@ npx analyze-query \
6767

6868
This will output the query plan and time to execute each phase of that plan.
6969

70-
If you're unsure which query is slow, or you do not know what it looks like after permissions have been applied, you can obtain the query from the hash that is output in the server logs (e.g., `hash=3rhuw19xt9vry`):
70+
Note that query performance can also be affected by read permissions. See [Debugging Permissions](./permissions) for information on how to analyze queries with read permissions applied.
7171

72-
```shell
73-
npx transform-query --hash=2i81bazy03a00 --schema=./shared/schema.ts
74-
```
72+
## /statz
7573

76-
This will find the query with that hash in the CVR DB and output the ZQL for that query, with all read permissions applied. You can then feed this output into `analyze-query`.
74+
`zero-cache` makes some internal health statistics available via the `/statz` endpoint of `zero-cache`. In order to access this, you must configure an [admin password](/docs/zero-cache-config#admin-password).

contents/docs/errors.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
title: Error Handling
3+
---
4+
5+
Errors from mutators and queries are thrown in response to method calls where possible, but many Zero errors occur asynchronously, during sync.
6+
7+
You can catch these errors with the `onError` constructor parameter:
8+
9+
```ts
10+
const z = new Zero({
11+
upstream: 'https://my-upstream-db.com',
12+
onError: (msg, ...rest) => {
13+
reportToSentry('Zero error:', msg, ...rest);
14+
},
15+
});
16+
```
17+
18+
You can use this to send errors to Sentry, show custom UI, etc.
19+
20+
The first parameter to `onError` is a descriptive message. Additional parameters provide more detail, for example an `Error` object (with a stack), or a JSON object.
21+
22+
<Note>
23+
If you implement `onError`, errors will no longer be sent to devtools by default. If you also want errors sent to the devtools console, you must call `console.error()` inside your `onError` handler.
24+
</Note>

contents/docs/reading-data.mdx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,16 +432,29 @@ z.query.issue
432432

433433
## Running Queries Once
434434

435-
Usually subscribing to a query is what you want in a reactive UI but every so often running a query once is all that’s needed.
435+
Usually subscribing to a query is what you want in a reactive UI, but every so often you'll need to run a query just once. To do this, use the `run()` method:
436436

437437
```tsx
438-
const results = z.query.issue.where('foo', 'bar').run();
438+
const results = await z.query.issue.where('foo', 'bar').run();
439439
```
440440

441-
<Note type="note">
442-
The `run()` method does not yet expose [`resultType`](#completeness) in any
443-
way, so you can't use this method to wait for an authoritative result from the
444-
server. See [bug 3243](https://bugs.rocicorp.dev/issue/3243) for a workaround.
441+
By default, `run()` only returns results that are currently available on the client. That is, it returns the data that would be given for [`result.type === 'unknown'`](#completeness).
442+
443+
If you want to wait for the server to return results, pass `{type: 'complete'}` to `run`:
444+
445+
```tsx
446+
const results = await z.query.issue.where('foo', 'bar').run(
447+
{type: 'complete'});
448+
```
449+
450+
<Note>
451+
As a convenience you can also directly await queries:
452+
453+
```ts
454+
await z.query.issue.where('foo','bar');
455+
```
456+
457+
This is the same as saying `run()` or `run({type: 'unknown'})`.
445458
</Note>
446459

447460
## Consistency

0 commit comments

Comments
 (0)