From b56aa61c8127fd5ddbc9a68075558a525ed75f34 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Tue, 27 May 2025 18:24:59 +0200 Subject: [PATCH 1/8] ZQL fundamentals overview for the reading data section --- contents/docs/zql-fundamentals.mdx | 142 +++++++++++++++++++++++++++++ lib/routes-config.ts | 73 ++++++++------- 2 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 contents/docs/zql-fundamentals.mdx diff --git a/contents/docs/zql-fundamentals.mdx b/contents/docs/zql-fundamentals.mdx new file mode 100644 index 0000000..1210885 --- /dev/null +++ b/contents/docs/zql-fundamentals.mdx @@ -0,0 +1,142 @@ +--- +title: ZQL Fundamentals +--- + +ZQL is Zero's query language for reading data from your database. + +Inspired by SQL, ZQL is expressed in TypeScript with heavy use of the builder pattern. If you have used [Drizzle](https://orm.drizzle.team/) or [Kysely](https://kysely.dev/), ZQL will feel familiar. + +## What Makes ZQL Different + +Unlike queries in classic databases, the result of a ZQL query is a _view_ that updates automatically and efficiently as the underlying data changes. You can call a query's `materialize()` method to get a view, but more typically you run queries via some framework-specific bindings. For example see `useQuery` for [React](react) or [SolidJS](solidjs). + +ZQL queries are composed of one or more _clauses_ that are chained together into a _query_. + +## Basic Query Structure + +ZQL queries start by selecting a table. There is no way to select a subset of columns; ZQL queries always return the entire row (modulo column permissions). + +```tsx +const z = new Zero(...); + +// Returns a query that selects all rows and columns from the issue table. +z.query.issue; +``` + +This is a design tradeoff that allows Zero to better reuse the row locally for future queries. This also makes it easier to share types between different parts of the code. + +You can then chain additional clauses to refine your query: + +```tsx +// Get the 10 most recently created issues +z.query.issue.orderBy('created', 'desc').limit(10); + +// Get a specific issue by ID +z.query.issue.where('id', 42).one(); +``` + +## Data Immutability + + +This means you should not modify the data directly. Instead, clone the data and modify the clone. + +ZQL caches values and returns them multiple times. If you modify a value returned from ZQL, you will modify it everywhere it is used. This can lead to subtle bugs. + +JavaScript and TypeScript lack true immutable types so we use `readonly` to help enforce it. But it's easy to cast away the `readonly` accidentally. + +In the future, we'll [`freeze`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) all returned data in `dev` mode to help prevent this. + + + +## TypeScript Integration + +ZQL is designed to work seamlessly with TypeScript. Column names, table names, and data types are all inferred from your [Zero Schema](/zero-schema), providing full type safety and IntelliSense support. + +```tsx +// TypeScript knows 'priority' is a valid column and suggests available values +z.query.issue.where('priority', 'high'); + +// TypeScript will error if you use an invalid column name +z.query.issue.where('invalidColumn', 'value'); // ❌ Type error + +// Return types are fully typed based on your schema +const issues: readonly IssueRow[] = await z.query.issue.run(); +``` + +## Running Queries + +There are several ways to execute ZQL queries depending on your use case: + +### Reactive Queries (Recommended) + +For UI components, use framework-specific hooks that automatically update when data changes: + +```tsx +// React +const [issues] = useQuery(z.query.issue.orderBy('created', 'desc')); + +// SolidJS +const issues = createQuery(() => z.query.issue.orderBy('created', 'desc')); +``` + +### One-time Queries + +For non-reactive use cases, use the `run()` method: + +```tsx +// Get current data available on client +const issues = await z.query.issue.run(); + +// Wait for server response to ensure completeness +const issues = await z.query.issue.run({type: 'complete'}); + +// Shorthand for run() with client data only +const issues = await z.query.issue; +``` + +### Materialized Views + +For advanced use cases, you can create a materialized view directly: + +```tsx +const view = z.query.issue.materialize(); +view.addListener((issues, result) => { + console.log('Issues updated:', issues); +}); + +// Don't forget to clean up +view.destroy(); +``` + +## Query Composition + +ZQL queries are composable, meaning you can build them up incrementally: + +```tsx +// Start with a base query +let query = z.query.issue; + +// Add conditions based on user input +if (priority) { + query = query.where('priority', priority); +} + +if (assignee) { + query = query.where('assignee', assignee); +} + +// Add ordering and limits +query = query.orderBy('created', 'desc').limit(50); + +// Execute the composed query +const [issues] = useQuery(query); +``` + +## Next Steps + +Now that you understand the fundamentals of ZQL, you can explore more specific topics: + +- [Query Clauses](/query-clauses) - Learn about ordering, limiting, and paging +- [Filtering Data](/filtering-data) - Master the `where` clause and comparison operators +- [Relationships](/relationships) - Query related data across tables +- [Query Lifecycle](/query-lifecycle) - Understand performance and caching behavior diff --git a/lib/routes-config.ts b/lib/routes-config.ts index 2d5a554..f15847e 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -15,11 +15,11 @@ export const ROUTES: EachRoute[] = [ noLink: true, new: false, items: [ - { title: 'Introduction', href: '/introduction' }, - { title: 'Quickstart', href: '/quickstart' }, - { title: 'Add to Existing Project', href: '/add-to-existing-project' }, - { title: 'Samples', href: '/samples' }, - { title: 'Release Notes', href: '/release-notes' }, + {title: 'Introduction', href: '/introduction'}, + {title: 'Quickstart', href: '/quickstart'}, + {title: 'Add to Existing Project', href: '/add-to-existing-project'}, + {title: 'Samples', href: '/samples'}, + {title: 'Release Notes', href: '/release-notes'}, ], }, @@ -30,29 +30,34 @@ export const ROUTES: EachRoute[] = [ items: [ //TODO //{title: 'How Zero Works', href: '/overview'}, - { title: 'Connecting to Postgres', href: '/connecting-to-postgres' }, - { title: 'Supported Postgres Features', href: '/postgres-support' }, - { title: 'Zero Schema', href: '/zero-schema' }, - { title: 'Reading Data with ZQL', href: '/reading-data' }, - { title: 'Writing Data with Mutators', href: '/writing-data' }, - { title: 'Custom Mutators', href: '/custom-mutators', new: true }, - { title: 'Authentication', href: '/auth' }, - { title: 'Permissions', href: '/permissions' }, - { title: 'ZQL on the Server', href: '/zql-on-the-server' }, - { title: 'Offline', href: '/offline' }, - { title: 'Deployment', href: '/deployment' }, - { title: '`zero-cache` Config', href: '/zero-cache-config' }, + {title: 'Connecting to Postgres', href: '/connecting-to-postgres'}, + {title: 'Supported Postgres Features', href: '/postgres-support'}, + {title: 'Zero Schema', href: '/zero-schema'}, + {title: 'Reading Data with ZQL', href: '/reading-data'}, + {title: 'Writing Data with Mutators', href: '/writing-data'}, + {title: 'Custom Mutators', href: '/custom-mutators', new: true}, + {title: 'Authentication', href: '/auth'}, + {title: 'Permissions', href: '/permissions'}, + {title: 'ZQL on the Server', href: '/zql-on-the-server'}, + {title: 'Offline', href: '/offline'}, + {title: 'Deployment', href: '/deployment'}, + {title: '`zero-cache` Config', href: '/zero-cache-config'}, ], }, - + { + title: 'Queries (Reading Data)', + href: '', + noLink: true, + items: [{title: 'ZQL Fundamentals', href: '/zql-fundamentals'}], + }, { title: 'Integrations', href: '', noLink: true, items: [ - { title: 'React', href: '/react' }, - { title: 'SolidJS', href: '/solidjs' }, - { title: 'Community', href: '/community' }, + {title: 'React', href: '/react'}, + {title: 'SolidJS', href: '/solidjs'}, + {title: 'Community', href: '/community'}, ], }, @@ -61,12 +66,12 @@ export const ROUTES: EachRoute[] = [ href: '', noLink: true, items: [ - { title: 'Inspector API', href: '/debug/inspector' }, - { title: 'Permissions', href: '/debug/permissions' }, - { title: 'Slow Queries', href: '/debug/slow-queries' }, - { title: 'Replication', href: '/debug/replication' }, - { title: 'Query ASTs', href: '/debug/query-asts' }, - { title: 'OpenTelemetry', href: '/debug/otel' }, + {title: 'Inspector API', href: '/debug/inspector'}, + {title: 'Permissions', href: '/debug/permissions'}, + {title: 'Slow Queries', href: '/debug/slow-queries'}, + {title: 'Replication', href: '/debug/replication'}, + {title: 'Query ASTs', href: '/debug/query-asts'}, + {title: 'OpenTelemetry', href: '/debug/otel'}, ], }, @@ -75,23 +80,23 @@ export const ROUTES: EachRoute[] = [ href: '', noLink: true, items: [ - { title: 'Roadmap', href: '/roadmap' }, - { title: 'Reporting Bugs', href: '/reporting-bugs' }, - { title: 'Open Source', href: '/open-source' }, - { title: 'LLMs', href: '/llms' }, + {title: 'Roadmap', href: '/roadmap'}, + {title: 'Reporting Bugs', href: '/reporting-bugs'}, + {title: 'Open Source', href: '/open-source'}, + {title: 'LLMs', href: '/llms'}, ], }, ]; -type Page = { title: string; href: string }; +type Page = {title: string; href: string}; function getRecurrsiveAllLinks(node: EachRoute) { const ans: Page[] = []; if (!node.noLink) { - ans.push({ title: node.title, href: node.href }); + ans.push({title: node.title, href: node.href}); } node.items?.forEach(subNode => { - const temp = { ...subNode, href: `${node.href}${subNode.href}` }; + const temp = {...subNode, href: `${node.href}${subNode.href}`}; ans.push(...getRecurrsiveAllLinks(temp)); }); return ans; From 40768cadfb761bec0a056303eb2944daedeaf968 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Tue, 27 May 2025 18:39:56 +0200 Subject: [PATCH 2/8] query clauses: orderBy, limit, start, one --- contents/docs/query-clauses.mdx | 198 ++++++++++++++++++++++++++++++++ lib/routes-config.ts | 5 +- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 contents/docs/query-clauses.mdx diff --git a/contents/docs/query-clauses.mdx b/contents/docs/query-clauses.mdx new file mode 100644 index 0000000..fa4ffec --- /dev/null +++ b/contents/docs/query-clauses.mdx @@ -0,0 +1,198 @@ +--- +title: Query Clauses +--- + +Query clauses are the building blocks of ZQL queries. They allow you to refine your data selection by ordering, limiting, paging, and controlling result types. All clauses can be chained together to build complex queries. + +## Ordering + +You can sort query results by adding an `orderBy` clause: + +```tsx +z.query.issue.orderBy('created', 'desc'); +``` + +### Multiple Order Clauses + +Multiple `orderBy` clauses can be present, in which case the data is sorted by those clauses in order: + +```tsx +// Order by priority descending. For any rows with same priority, +// then order by created desc. +z.query.issue.orderBy('priority', 'desc').orderBy('created', 'desc'); +``` + +### Default Ordering + +All queries in ZQL have a default final order of their primary key. Assuming the `issue` table has a primary key on the `id` column, then: + +```tsx +// Actually means: z.query.issue.orderBy('id', 'asc'); +z.query.issue; + +// Actually means: z.query.issue.orderBy('priority', 'desc').orderBy('id', 'asc'); +z.query.issue.orderBy('priority', 'desc'); +``` + +This ensures consistent, deterministic ordering even when your explicit order clauses result in ties. + +## Limiting Results + +You can limit the number of rows to return with `limit()`: + +```tsx +z.query.issue.orderBy('created', 'desc').limit(100); +``` + +The `limit()` clause is particularly useful for: + +- **Performance**: Reducing the amount of data transferred and processed +- **Pagination**: Working with `start()` to implement paging +- **Top-N queries**: Getting the most recent, highest priority, etc. + +```tsx +// Get the 10 most recent high-priority issues +z.query.issue.where('priority', 'high').orderBy('created', 'desc').limit(10); +``` + +## Paging with Start + +You can start the results at or after a particular row with `start()`: + +```tsx +let start: IssueRow | undefined; +while (true) { + let q = z.query.issue.orderBy('created', 'desc').limit(100); + if (start) { + q = q.start(start); + } + const batch = await q.run(); + console.log('got batch', batch); + + if (batch.length < 100) { + break; + } + start = batch[batch.length - 1]; +} +``` + +### Exclusive vs Inclusive Start + +By default `start()` is _exclusive_ - it returns rows starting **after** the supplied reference row. This is what you usually want for paging: + +```tsx +// Get next page of results after the last row from previous page +z.query.issue.orderBy('created', 'desc').start(lastRow).limit(50); +``` + +If you want _inclusive_ results, you can specify the `inclusive` option: + +```tsx +z.query.issue.start(row, {inclusive: true}); +``` + +### Cursor-based Pagination Example + +Here's a complete example of implementing cursor-based pagination: + +```tsx +function usePaginatedIssues(pageSize = 50) { + const [allIssues, setAllIssues] = useState([]); + const [cursor, setCursor] = useState(); + const [hasMore, setHasMore] = useState(true); + + const loadMore = async () => { + let query = z.query.issue.orderBy('created', 'desc').limit(pageSize); + + if (cursor) { + query = query.start(cursor); + } + + const newIssues = await query.run({type: 'complete'}); + + setAllIssues(prev => [...prev, ...newIssues]); + setHasMore(newIssues.length === pageSize); + + if (newIssues.length > 0) { + setCursor(newIssues[newIssues.length - 1]); + } + }; + + return {allIssues, loadMore, hasMore}; +} +``` + +## Getting a Single Result + +If you want exactly zero or one results, use the `one()` clause. This causes ZQL to return `Row|undefined` rather than `Row[]`. + +```tsx +const result = await z.query.issue.where('id', 42).one().run(); +if (!result) { + console.error('not found'); +} else { + console.log('Found issue:', result.title); +} +``` + +### One() Behavior + +- `one()` overrides any `limit()` clause that is also present +- Returns `Row | undefined` instead of `Row[]` +- Useful for lookups by unique identifiers +- Commonly used with `where` clauses for specific records + +```tsx +// Get the current user's profile +const profile = await z.query.user.where('id', currentUserId).one().run(); + +// Get the most recent issue +const latestIssue = await z.query.issue.orderBy('created', 'desc').one().run(); +``` + +## Combining Clauses + +All query clauses can be combined to create sophisticated queries: + +```tsx +// Get the 5 most recent high-priority issues assigned to a specific user, +// starting after a particular issue +z.query.issue + .where('priority', 'high') + .where('assignee', userId) + .orderBy('created', 'desc') + .start(lastSeenIssue) + .limit(5); +``` + +### Order of Operations + +While you can chain clauses in any order, they are conceptually applied in this sequence: + +1. **Selection**: Table selection (`z.query.issue`) +2. **Filtering**: `where` clauses +3. **Ordering**: `orderBy` clauses +4. **Paging**: `start` clause +5. **Limiting**: `limit` or `one` clause + +```tsx +// These are equivalent: +z.query.issue.limit(10).where('status', 'open').orderBy('created', 'desc'); +z.query.issue.orderBy('created', 'desc').where('status', 'open').limit(10); +``` + +## Performance Considerations + +- **Always use `orderBy`** when using `limit()` to ensure consistent results +- **Combine `orderBy` and `limit`** for efficient top-N queries +- **Use `start()` instead of `OFFSET`** for better performance in large datasets +- **Consider indexing** columns used in `orderBy` clauses in your database + +## Next Steps + +Now that you understand query clauses, explore these related topics: + +- [Filtering Data](/filtering-data) - Master the `where` clause and comparison operators +- [Relationships](/relationships) - Query related data across tables +- [Query Lifecycle](/query-lifecycle) - Understand performance and caching behavior +- [ZQL Fundamentals](/zql-fundamentals) - Review the basics if needed diff --git a/lib/routes-config.ts b/lib/routes-config.ts index f15847e..6151204 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -48,7 +48,10 @@ export const ROUTES: EachRoute[] = [ title: 'Queries (Reading Data)', href: '', noLink: true, - items: [{title: 'ZQL Fundamentals', href: '/zql-fundamentals'}], + items: [ + {title: 'ZQL Fundamentals', href: '/zql-fundamentals'}, + {title: 'Clauses', href: '/query-clauses'}, + ], }, { title: 'Integrations', From cdd7a2105a87c579a079429d40e9038718e63a75 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Tue, 27 May 2025 18:45:54 +0200 Subject: [PATCH 3/8] query filtering --- contents/docs/filtering-data.mdx | 252 +++++++++++++++++++++++++++++++ lib/routes-config.ts | 1 + 2 files changed, 253 insertions(+) create mode 100644 contents/docs/filtering-data.mdx diff --git a/contents/docs/filtering-data.mdx b/contents/docs/filtering-data.mdx new file mode 100644 index 0000000..ce9f2ab --- /dev/null +++ b/contents/docs/filtering-data.mdx @@ -0,0 +1,252 @@ +--- +title: Filtering Data +--- + +Filtering is one of the most important aspects of querying data. ZQL provides powerful and type-safe filtering capabilities through the `where` clause and related operators. + +## Basic Where Clause + +You can filter a query with `where()`: + +```tsx +z.query.issue.where('priority', '=', 'high'); +``` + +The first parameter is always a column name from the table being queried. IntelliSense will offer available options (sourced from your [Zero Schema](/zero-schema)). + +### Equals is the Default + +Because comparing by `=` is so common, you can leave it out and `where` defaults to `=`: + +```tsx +// These are equivalent: +z.query.issue.where('priority', '=', 'high'); +z.query.issue.where('priority', 'high'); +``` + +## Comparison Operators + +ZQL supports a comprehensive set of comparison operators with full TypeScript type checking: + +| Operator | Allowed Operand Types | Description | +| ---------------------------------------- | ----------------------------- | ------------------------------------------------------------------------ | +| `=` , `!=` | boolean, number, string | JS strict equal (===) semantics | +| `<` , `<=`, `>`, `>=` | number | JS number compare semantics | +| `LIKE`, `NOT LIKE`, `ILIKE`, `NOT ILIKE` | string | SQL-compatible `LIKE` / `ILIKE` | +| `IN` , `NOT IN` | boolean, number, string | RHS must be array. Returns true if rhs contains lhs by JS strict equals. | +| `IS` , `IS NOT` | boolean, number, string, null | Same as `=` but also works for `null` | + +TypeScript will restrict you from using operators with types that don't make sense – you can't use `>` with `boolean` for example. + +### Examples of Each Operator + +```tsx +// Equality operators +z.query.issue.where('status', '=', 'open'); +z.query.issue.where('status', '!=', 'closed'); + +// Numeric comparisons +z.query.issue.where('priority', '>', 3); +z.query.issue.where('votes', '<=', 100); + +// String pattern matching +z.query.issue.where('title', 'LIKE', '%bug%'); +z.query.issue.where('title', 'ILIKE', '%BUG%'); // case-insensitive + +// Array membership +z.query.issue.where('status', 'IN', ['open', 'in-progress']); +z.query.issue.where('priority', 'NOT IN', [1, 2]); + +// Null checking (see below for details) +z.query.issue.where('assignee', 'IS', null); +z.query.issue.where('assignee', 'IS NOT', null); +``` + + + If you don't see the comparison operator you need, let us know, many are easy + to add. + + +## Handling Null Values + +As in SQL, ZQL's `null` is not equal to itself (`null ≠ null`). + +This is required to make join semantics work: if you're joining `employee.orgID` on `org.id` you do **not** want an employee in no organization to match an org that hasn't yet been assigned an ID. + +When you purposely want to compare to `null`, ZQL supports `IS` and `IS NOT` operators that work just like in SQL: + +```tsx +// Find employees not in any org. +z.query.employee.where('orgID', 'IS', null); + +// Find employees that are assigned to an org +z.query.employee.where('orgID', 'IS NOT', null); +``` + +TypeScript will prevent you from comparing to `null` with other operators: + +```tsx +// ❌ TypeScript error - can't use = with null +z.query.employee.where('orgID', '=', null); + +// ✅ Correct way to check for null +z.query.employee.where('orgID', 'IS', null); +``` + +## Compound Filters + +The argument to `where` can also be a callback that returns a complex expression: + +```tsx +// Get all issues that have priority 'critical' or else have both +// priority 'medium' and not more than 100 votes. +z.query.issue.where(({cmp, and, or, not}) => + or( + cmp('priority', 'critical'), + and(cmp('priority', 'medium'), not(cmp('numVotes', '>', 100))), + ), +); +``` + +### Compound Filter Functions + +The compound filter callback provides these functions: + +- **`cmp`**: Short for _compare_, works the same as `where` at the top-level except that it can't be chained +- **`and`**: Logical AND operation +- **`or`**: Logical OR operation +- **`not`**: Logical NOT operation + +### Multiple Where Clauses + +Note that chaining `where()` is also a one-level `and`: + +```tsx +// Find issues with priority 3 or higher, owned by aa +z.query.issue.where('priority', '>=', 3).where('owner', 'aa'); + +// This is equivalent to: +z.query.issue.where(({cmp, and}) => + and(cmp('priority', '>=', 3), cmp('owner', 'aa')), +); +``` + +### Complex Filter Examples + +```tsx +// Issues that are either: +// - High priority and unassigned, OR +// - Medium priority with more than 50 votes +z.query.issue.where(({cmp, and, or}) => + or( + and(cmp('priority', 'high'), cmp('assignee', 'IS', null)), + and(cmp('priority', 'medium'), cmp('votes', '>', 50)), + ), +); + +// Issues that are NOT low priority AND NOT closed +z.query.issue.where(({cmp, and, not}) => + and(not(cmp('priority', 'low')), not(cmp('status', 'closed'))), +); +``` + +## Relationship Filters + +Your filter can also test properties of relationships. Currently the only supported test is existence: + +```tsx +// Find all orgs that have at least one employee +z.query.organization.whereExists('employees'); +``` + +### Refining Relationship Filters + +The argument to `whereExists` is a relationship, so just like other relationships it can be refined with a query: + +```tsx +// Find all orgs that have at least one cool employee +z.query.organization.whereExists('employees', q => + q.where('location', 'Hawaii'), +); +``` + +### Nested Relationship Filters + +As with querying relationships, relationship filters can be arbitrarily nested: + +```tsx +// Get all issues that have comments that have reactions +z.query.issue.whereExists('comments', q => q.whereExists('reactions')); +``` + +### Using Exists in Compound Filters + +The `exists` helper is also provided which can be used with `and`, `or`, `cmp`, and `not` to build compound filters that check relationship existence: + +```tsx +// Find issues that have at least one comment or are high priority +z.query.issue.where(({cmp, or, exists}) => + or(cmp('priority', 'high'), exists('comments')), +); + +// Find issues that have comments but no reactions +z.query.issue.where(({and, not, exists}) => + and( + exists('comments'), + not(exists('comments', q => q.whereExists('reactions'))), + ), +); +``` + +## Performance Tips + +- **Use indexes**: Ensure columns used in `where` clauses are indexed in your database +- **Filter early**: Apply the most selective filters first when chaining multiple `where` clauses +- **Relationship filters**: `whereExists` can be expensive; consider denormalizing frequently-checked relationship data +- **Compound filters**: Complex compound filters may benefit from database query plan analysis + +## Common Patterns + +### Status-based Filtering + +```tsx +// Active items only +z.query.issue.where('status', 'IN', ['open', 'in-progress']); + +// Everything except deleted +z.query.issue.where('status', '!=', 'deleted'); +``` + +### Date Range Filtering + +```tsx +// Issues created in the last week +const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); +z.query.issue.where('created', '>=', weekAgo); + +// Issues created between two dates +z.query.issue.where(({cmp, and}) => + and(cmp('created', '>=', startDate), cmp('created', '<=', endDate)), +); +``` + +### User-based Filtering + +```tsx +// Issues assigned to current user +z.query.issue.where('assignee', currentUserId); + +// Issues created by or assigned to current user +z.query.issue.where(({cmp, or}) => + or(cmp('creator', currentUserId), cmp('assignee', currentUserId)), +); +``` + +## Next Steps + +Now that you understand filtering, explore these related topics: + +- [Relationships](/relationships) - Query related data across tables +- [Clauses](/query-clauses) - Learn about ordering, limiting, and paging +- [Query Lifecycle](/query-lifecycle) - Understand performance and caching behavior +- [ZQL Fundamentals](/zql-fundamentals) - Review the basics if needed diff --git a/lib/routes-config.ts b/lib/routes-config.ts index 6151204..359b051 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -51,6 +51,7 @@ export const ROUTES: EachRoute[] = [ items: [ {title: 'ZQL Fundamentals', href: '/zql-fundamentals'}, {title: 'Clauses', href: '/query-clauses'}, + {title: 'Filtering', href: '/filtering-data'}, ], }, { From 231b1002e7b90c49525f2414e61145e3afef8954 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Fri, 30 May 2025 12:13:27 +0200 Subject: [PATCH 4/8] moving query relationships to solo section --- contents/docs/relationships.mdx | 308 ++++++++++++++++++++++++++++++++ lib/routes-config.ts | 1 + 2 files changed, 309 insertions(+) create mode 100644 contents/docs/relationships.mdx diff --git a/contents/docs/relationships.mdx b/contents/docs/relationships.mdx new file mode 100644 index 0000000..dfeee59 --- /dev/null +++ b/contents/docs/relationships.mdx @@ -0,0 +1,308 @@ +--- +title: Relationships +--- + +Relationships allow you to query related data across tables in a single query. ZQL returns relationship data as hierarchical structures, making it easy to work with related data in your application. + +## Basic Relationships + +You can query related rows using _relationships_ that are defined in your [Zero schema](/zero-schema). + +```tsx +// Get all issues and their related comments +z.query.issue.related('comments'); +``` + +Relationships are returned as hierarchical data. In the above example, each issue row will have a `comments` field which is itself an array of the corresponding comment rows. + +```tsx +const [issues] = useQuery(z.query.issue.related('comments')); + +issues.forEach(issue => { + console.log(`Issue: ${issue.title}`); + issue.comments.forEach(comment => { + console.log(` Comment: ${comment.text}`); + }); +}); +``` + +## Multiple Relationships + +You can fetch multiple relationships in a single query: + +```tsx +z.query.issue.related('comments').related('reactions').related('assignees'); +``` + +Each relationship becomes a property on the returned rows: + +```tsx +const [issues] = useQuery( + z.query.issue.related('comments').related('assignee').related('labels'), +); + +issues.forEach(issue => { + console.log(`Issue: ${issue.title}`); + console.log(`Assignee: ${issue.assignee?.name || 'Unassigned'}`); + console.log(`Labels: ${issue.labels.map(l => l.name).join(', ')}`); + console.log(`Comments: ${issue.comments.length}`); +}); +``` + +## Refining Relationships + +By default all matching relationship rows are returned, but this can be refined. The `related` method accepts an optional second function which is itself a query. + +```tsx +z.query.issue.related( + 'comments', + // It is common to use the 'q' shorthand variable for this parameter, + // but it is a _comment_ query in particular here, exactly as if you + // had done z.query.comment. + q => q.orderBy('modified', 'desc').limit(100).start(lastSeenComment), +); +``` + +This _relationship query_ can have all the same clauses that top-level queries can have: + +### Ordering Related Data + +```tsx +// Get issues with their most recent comments first +z.query.issue.related('comments', q => q.orderBy('created', 'desc')); +``` + +### Limiting Related Data + +```tsx +// Get issues with only their first 5 comments +z.query.issue.related('comments', q => q.limit(5)); +``` + +### Filtering Related Data + +```tsx +// Get issues with only their unresolved comments +z.query.issue.related('comments', q => q.where('status', 'unresolved')); + +// Get issues with comments from a specific user +z.query.issue.related('comments', q => q.where('authorId', specificUserId)); +``` + +### Combining Relationship Clauses + +```tsx +// Get issues with their 10 most recent comments from active users +z.query.issue.related('comments', q => + q.where('authorStatus', 'active').orderBy('created', 'desc').limit(10), +); +``` + +## Nested Relationships + +You can nest relationships arbitrarily deep: + +```tsx +// Get all issues, first 100 comments for each (ordered by modified desc), +// and for each comment all of its reactions. +z.query.issue.related('comments', q => + q.orderBy('modified', 'desc').limit(100).related('reactions'), +); +``` + +### Complex Nested Examples + +```tsx +// Get issues with their comments, comment authors, and comment reactions +z.query.issue.related('comments', q => + q + .related('author') + .related('reactions', reactionQ => reactionQ.related('user')), +); + +// Access the nested data +const [issues] = useQuery(query); +issues.forEach(issue => { + issue.comments.forEach(comment => { + console.log(`Comment by: ${comment.author.name}`); + comment.reactions.forEach(reaction => { + console.log(` ${reaction.emoji} by ${reaction.user.name}`); + }); + }); +}); +``` + +### Three-Level Nesting + +```tsx +// Issues -> Comments -> Reactions -> Users +z.query.issue.related('comments', commentQ => + commentQ.related('reactions', reactionQ => reactionQ.related('user')), +); +``` + +## Relationship Types + +### One-to-Many Relationships + +Most relationships are one-to-many, where one parent record has multiple child records: + +```tsx +// One issue has many comments +z.query.issue.related('comments'); + +// One user has many issues +z.query.user.related('issues'); +``` + +### Many-to-One Relationships + +You can also query in the reverse direction: + +```tsx +// Each comment belongs to one issue +z.query.comment.related('issue'); + +// Each issue has one assignee +z.query.issue.related('assignee'); +``` + +### Many-to-Many Relationships + +For many-to-many relationships, you typically go through a junction table: + +```tsx +// Issues with their labels (through issue_labels junction) +z.query.issue.related('labels'); + +// Users with their roles (through user_roles junction) +z.query.user.related('roles'); +``` + +## Performance Considerations + +### Relationship Query Efficiency + +- **Limit related data**: Always use `limit()` on relationships that could return many rows +- **Filter early**: Apply `where` clauses to relationships to reduce data transfer +- **Order thoughtfully**: Only use `orderBy` on relationships when necessary + +```tsx +// ❌ Could return thousands of comments per issue +z.query.issue.related('comments'); + +// ✅ Limits to recent comments only +z.query.issue.related('comments', q => q.orderBy('created', 'desc').limit(20)); +``` + +### Avoiding N+1 Queries + +ZQL relationships help avoid N+1 query problems by fetching related data in a single query: + +```tsx +// ❌ N+1 problem: One query for issues, then one query per issue for comments +const [issues] = useQuery(z.query.issue); +issues.forEach(async issue => { + const [comments] = useQuery(z.query.comment.where('issueId', issue.id)); + // Process comments... +}); + +// ✅ Single query gets everything +const [issues] = useQuery(z.query.issue.related('comments')); +issues.forEach(issue => { + issue.comments.forEach(comment => { + // Process comments... + }); +}); +``` + +### Memory Usage + +Be mindful of memory usage when querying large relationship trees: + +```tsx +// ❌ Could load massive amounts of data +z.query.issue.related('comments').related('reactions').related('attachments'); + +// ✅ Limit each relationship appropriately +z.query.issue + .related('comments', q => q.limit(10)) + .related('reactions', q => q.limit(50)) + .related('attachments', q => q.limit(5)); +``` + +## Common Patterns + +### Recent Activity Feed + +```tsx +// Get recent issues with their latest comments and reactions +z.query.issue + .orderBy('updated', 'desc') + .limit(50) + .related('comments', q => q.orderBy('created', 'desc').limit(3)) + .related('reactions', q => q.orderBy('created', 'desc').limit(10)); +``` + +### User Dashboard + +```tsx +// Get user with their recent issues and assigned tasks +z.query.user + .where('id', currentUserId) + .one() + .related('createdIssues', q => q.orderBy('created', 'desc').limit(10)) + .related('assignedIssues', q => q.where('status', '!=', 'closed').limit(20)); +``` + +### Hierarchical Data + +```tsx +// Get categories with their subcategories and items +z.query.category + .where('parentId', 'IS', null) // Top-level categories only + .related('subcategories', subQ => + subQ.related('items', itemQ => itemQ.where('active', true).limit(100)), + ); +``` + +## Relationship Filtering + +You can also filter the parent query based on relationship existence using `whereExists`: + +```tsx +// Get only issues that have comments +z.query.issue.whereExists('comments'); + +// Get only issues that have recent comments +z.query.issue.whereExists('comments', q => q.where('created', '>', recentDate)); +``` + +For more details on relationship filtering, see [Filtering Data](/filtering-data#relationship-filters). + +## TypeScript Support + +Relationships are fully typed based on your Zero schema: + +```tsx +// TypeScript knows the shape of related data +const [issues] = useQuery( + z.query.issue.related('comments').related('assignee'), +); + +// Full type safety and IntelliSense +issues.forEach(issue => { + // issue.comments is Comment[] + // issue.assignee is User | undefined + console.log(issue.assignee?.name); // TypeScript knows this is safe +}); +``` + +## Next Steps + +Now that you understand relationships, explore these related topics: + +- [Query Lifecycle](/query-lifecycle) - Understand performance and caching behavior +- [Filtering](/filtering-data) - Learn about relationship filters with `whereExists` +- [Clauses](/query-clauses) - Master ordering, limiting, and paging +- [ZQL Fundamentals](/zql-fundamentals) - Review the basics if needed diff --git a/lib/routes-config.ts b/lib/routes-config.ts index 359b051..83ba58d 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -52,6 +52,7 @@ export const ROUTES: EachRoute[] = [ {title: 'ZQL Fundamentals', href: '/zql-fundamentals'}, {title: 'Clauses', href: '/query-clauses'}, {title: 'Filtering', href: '/filtering-data'}, + {title: 'Relationships', href: '/relationships'}, ], }, { From af64fac4f251a1f33ab15c75be14871fe0dd8e91 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Fri, 30 May 2025 13:51:14 +0200 Subject: [PATCH 5/8] query lifecycle and performance --- contents/docs/query-lifecycle.mdx | 282 ++++++++++++++++++++++++++++++ lib/routes-config.ts | 1 + 2 files changed, 283 insertions(+) create mode 100644 contents/docs/query-lifecycle.mdx diff --git a/contents/docs/query-lifecycle.mdx b/contents/docs/query-lifecycle.mdx new file mode 100644 index 0000000..37fa8f2 --- /dev/null +++ b/contents/docs/query-lifecycle.mdx @@ -0,0 +1,282 @@ +--- +title: Query Lifecycle & Performance +--- + +Understanding how ZQL manages query lifecycles and performance is crucial for building efficient applications. This page covers query states, background synchronization, client capacity management, and performance optimization strategies. + +## Query Lifecycle + + + +Queries can be either _active_ or _backgrounded_. An active query is one that is currently being used by the application. Backgrounded queries are not currently in use, but continue syncing in case they are needed again soon. + +### Creating Active Queries + +Active queries are created one of three ways: + +1. **View Materialization**: The app calls `q.materialize()` to get a `View`. +2. **Framework Bindings**: The app uses a platform binding like React's `useQuery(q)`. +3. **Preloading**: The app calls [`preload()`](#preloading) to sync larger queries without a view. + +```tsx +// 1. Direct materialization +const view = z.query.issue.materialize(); + +// 2. Framework binding (React) +const [issues] = useQuery(z.query.issue.orderBy('created', 'desc')); + +// 3. Preloading +z.query.issue.limit(1000).preload(); +``` + +### Deactivating Queries + +Active queries sync until they are _deactivated_. The way this happens depends on how the query was created: + +1. **For `materialize()` queries**: The UI calls `destroy()` on the view. +2. **For `useQuery()`**: The UI unmounts the component (which calls `destroy()` under the covers). +3. **For `preload()`**: The UI calls `cleanup()` on the return value of `preload()`. + +```tsx +// Manual cleanup for materialized views +const view = z.query.issue.materialize(); +// ... use the view +view.destroy(); // Cleanup + +// Automatic cleanup for React hooks +function MyComponent() { + const [issues] = useQuery(z.query.issue); // Active while mounted + return
{/* render issues */}
; +} // Automatically deactivated when unmounted + +// Manual cleanup for preloaded queries +const cleanup = z.query.issue.limit(1000).preload(); +// ... later +cleanup(); // Stop syncing +``` + +## Background Queries + +By default a deactivated query stops syncing immediately. But it's often useful to keep queries syncing beyond deactivation in case the UI needs the same or a similar query in the near future. + +### Time-To-Live (TTL) + +This is accomplished with the `ttl` parameter: + +```ts +const [user] = useQuery(z.query.user.where('id', userId), {ttl: '1d'}); +``` + +The `ttl` parameter specifies how long the app developer wishes the query to run in the background. The following formats are allowed (where `%d` is a positive integer): + +| Format | Meaning | +| --------- | ------------------------------------------------------------------------------------ | +| `none` | No backgrounding. Query will immediately stop when deactivated. This is the default. | +| `%ds` | Number of seconds. | +| `%dm` | Number of minutes. | +| `%dh` | Number of hours. | +| `%dd` | Number of days. | +| `%dy` | Number of years. | +| `forever` | Query will never be stopped. | + +### Background Query Benefits + +If the UI re-requests a background query, it becomes an active query again. Since the query was syncing in the background, the very first synchronous result that the UI receives after reactivation will be up-to-date with the server (i.e., it will have `resultType` of `complete`). + +```tsx +// Common pattern: preload with forever TTL, then use shorter TTLs in UI +z.query.issue + .related('creator') + .related('assignee') + .related('labels') + .orderBy('created', 'desc') + .limit(1000) + .preload({ttl: 'forever'}); // Keep syncing forever + +// Later in UI components +const [issues] = useQuery( + z.query.issue.where('assignee', currentUser), + {ttl: '1d'}, // Background for a day after unmount +); +``` + +## Client Capacity Management + +Zero has a default soft limit of 20,000 rows on the client-side, or about 20MB of data assuming 1KB rows. + +This limit can be increased with the [`--target-client-row-count`](./zero-cache-config#target-client-row-count) flag, but we do not recommend setting it higher than 100,000. + +### Why Store So Little Data Client-Side? + + +Contrary to the design of other sync engines, we believe that storing tons of data client-side doesn't make sense. Here are some reasons why: + +- Initial sync will be slow, slowing down initial app load. +- Because storage in browser tabs is unreliable, initial sync can occur surprisingly often. +- We want to answer queries _instantly_ as often as possible. This requires client-side data in memory on the main thread. If we have to page to disk, we may as well go to the network and reduce complexity. +- Even though Zero's queries are very efficient, they do still have some cost, especially hydration. Massive client-side storage would result in hydrating tons of queries that are unlikely to be used every time the app starts. + +Most importantly, no matter how much data you store on the client, there will be cases where you have to fallback to the server: + +- Some users might have huge amounts of data. +- Some users might have tiny amounts of available client storage. +- You will likely want the app to start fast and sync in the background. + +Because you have to be able to fallback to server the question becomes _what is the **right** amount of data to store on the client?_, not _how can I store the absolute max possible data on the client?_ + +The goal with Zero is to answer 99% of queries on the client from memory. The remaining 1% of queries can fallback gracefully to the server. 20,000 rows was chosen somewhat arbitrarily as a number of rows that was likely to be able to do this for many applications. + + + +### Capacity Management Rules + +Here is how this limit is managed: + +1. **Active queries are never destroyed**, even if the limit is exceeded. Developers are expected to keep active queries well under the limit. +2. **The `ttl` value counts from the moment a query deactivates**. Backgrounded queries are destroyed immediately when the `ttl` is reached, even if the limit hasn't been reached. +3. **If the client exceeds its limit**, Zero will destroy backgrounded queries, least-recently-used first, until the store is under the limit again. + +```tsx +// Good: Keep active queries small and focused +const [recentIssues] = useQuery( + z.query.issue.orderBy('created', 'desc').limit(50), +); + +// Be careful: Large active queries can exceed capacity +const [allIssues] = useQuery(z.query.issue); // Could be 100k+ rows + +// Good: Use preloading with appropriate limits for large datasets +z.query.issue.limit(5000).preload({ttl: '1h'}); +``` + +## Data Lifetime and Reuse + +Zero reuses data synced from prior queries to answer new queries when possible. This is what enables instant UI transitions. + +### What Controls Data Lifetime? + +The data on the client is simply the union of rows returned from queries which are currently syncing. Once a row is no longer returned by any syncing query, it is removed from the client. Thus, there is never any stale data in Zero. + +```tsx +// These queries share data - only one copy of each issue is stored +const activeQuery1 = z.query.issue.where('status', 'open'); +const activeQuery2 = z.query.issue.where('priority', 'high'); +// Issues that are both open AND high priority exist in both result sets +// but are stored only once on the client +``` + +### Caches vs Replicas + + + This is why we often say that despite the name `zero-cache`, Zero is not technically a cache. It's a *replica*. + +A cache has a random set of rows with a random set of versions. There is no expectation that the cache contains any particular rows, or that the rows have matching versions. Rows are simply updated as they are fetched. + +A replica by contrast is eagerly updated, whether or not any client has requested a row. A replica is always very close to up-to-date, and always self-consistent. + +Zero is a _partial_ replica because it only replicates rows that are returned by syncing queries. + + + +## Performance Optimization + +### Thinking in Queries + +Although IVM (Incremental View Maintenance) is a very efficient way to keep queries up to date relative to re-running them, it isn't free. You still need to think about how many queries you are creating, how long they are kept alive, and how expensive they are. + +This is why Zero defaults to _not_ backgrounding queries and doesn't try to aggressively fill its client datastore to capacity. You should put some thought into what queries you want to run in the background, and for how long. + +### Performance Monitoring + +Zero currently provides a few basic tools to understand the cost of your queries: + +- **Client slow query warnings**: The client logs a warning for slow query materializations. Look for `Slow query materialization` in your logs. The default threshold is `5s` (including network) but this is configurable with the `slowMaterializeThreshold` parameter. +- **Client debug logs**: The client logs the materialization time of all queries at the `debug` level. Look for `Materialized query` in your logs. +- **Server slow query warnings**: The server logs a warning for slow query materializations. Look for `Slow query materialization` in your logs. The default threshold is `5s` but this is configurable with the `log-slow-materialize-threshold` configuration parameter. + +```tsx +// Configure slow query thresholds +const z = new Zero({ + slowMaterializeThreshold: 3000, // 3 seconds (client) + // ... other options +}); +``` + +### Optimization Strategies + +#### 1. Strategic Preloading + +```tsx +// Preload common data patterns to maximize instant results +z.query.issue + .related('creator') + .related('assignee') + .related('labels') + .orderBy('created', 'desc') + .limit(1000) + .preload({ttl: 'forever'}); + +// Preload different sort orders for instant UI transitions +z.query.issue.orderBy('priority', 'desc').limit(500).preload({ttl: '1d'}); +z.query.issue.orderBy('updated', 'desc').limit(500).preload({ttl: '1d'}); +``` + +#### 2. Appropriate TTL Management + +```tsx +// Forever TTL for core data +z.query.user.where('id', currentUserId).preload({ttl: 'forever'}); + +// Medium TTL for frequently accessed data +const [issues] = useQuery(z.query.issue.where('assignee', currentUserId), { + ttl: '1d', +}); + +// Short TTL for specific views +const [issue] = useQuery(z.query.issue.where('id', issueId), {ttl: '1h'}); + +// No TTL for one-off queries +const [searchResults] = useQuery( + z.query.issue.where('title', 'LIKE', searchTerm), +); +``` + +#### 3. Query Composition + +```tsx +// Build queries incrementally to reuse logic +function useIssueQuery(filters: IssueFilters, options?: {ttl?: string}) { + let query = z.query.issue; + + if (filters.assignee) { + query = query.where('assignee', filters.assignee); + } + + if (filters.status) { + query = query.where('status', 'IN', filters.status); + } + + return useQuery(query.orderBy('created', 'desc').limit(100), options); +} +``` + +## Best Practices + +1. **Keep active queries focused**: Limit active queries to what's actually displayed +2. **Use preloading strategically**: Preload common patterns, not everything +3. **Set appropriate TTLs**: Longer for frequently re-accessed data, shorter for specific views +4. **Monitor performance**: Watch for slow query warnings in your logs +5. **Design for 99% instant**: Accept that some queries will need server round trips +6. **Compose queries thoughtfully**: Build reusable query patterns + +## Next Steps + +Now that you understand query lifecycle and performance, explore these related topics: + +- [Data Synchronization](/data-synchronization) - Learn about completeness and consistency +- [Advanced Query Patterns](/advanced-query-patterns) - Master preloading and optimization techniques +- [Relationships](/relationships) - Understand relationship performance implications +- [ZQL Fundamentals](/zql-fundamentals) - Review the basics if needed diff --git a/lib/routes-config.ts b/lib/routes-config.ts index 83ba58d..4819726 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -53,6 +53,7 @@ export const ROUTES: EachRoute[] = [ {title: 'Clauses', href: '/query-clauses'}, {title: 'Filtering', href: '/filtering-data'}, {title: 'Relationships', href: '/relationships'}, + {title: 'Lifecycle & Performance', href: '/query-lifecycle'}, ], }, { From 482a92ca2f779ce7368996328266926280c9bb17 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Fri, 30 May 2025 13:57:14 +0200 Subject: [PATCH 6/8] data synchroniziation --- contents/docs/data-synchronization.mdx | 329 +++++++++++++++++++++++++ lib/routes-config.ts | 1 + 2 files changed, 330 insertions(+) create mode 100644 contents/docs/data-synchronization.mdx diff --git a/contents/docs/data-synchronization.mdx b/contents/docs/data-synchronization.mdx new file mode 100644 index 0000000..9f61ca3 --- /dev/null +++ b/contents/docs/data-synchronization.mdx @@ -0,0 +1,329 @@ +--- +title: Data Synchronization +--- + +Zero's data synchronization model is designed to provide instant UI updates while gracefully handling cases where data isn't immediately available. Understanding how Zero synchronizes data helps you build robust applications that feel fast and reliable. + +## How Zero Synchronizes Data + +Zero returns whatever data it has on the client immediately for a query, then falls back to the server for any missing data. This two-phase approach enables instant UI updates while ensuring completeness. + +### The Synchronization Process + +1. **Immediate Response**: Zero first returns any matching data that's already available on the client +2. **Server Fallback**: If data is missing or potentially incomplete, Zero fetches from the server +3. **Live Updates**: Once synchronized, the data automatically updates as changes occur on the server + +```tsx +const [issues, issuesResult] = useQuery( + z.query.issue.where('priority', 'high'), +); + +// issues contains data immediately (may be partial) +// issuesResult tells you about completeness +``` + +## Completeness + +Sometimes it's useful to know the difference between data that's immediately available and data that's been confirmed complete by the server. Zero provides this information through the result type. + +### Result Types + +```tsx +const [issues, issuesResult] = useQuery(z.query.issue); +if (issuesResult.type === 'complete') { + console.log('All data is present'); +} else { + console.log('Some data may be missing'); +} +``` + +The possible values of `result.type` are currently: + +- **`complete`**: Zero has received the server result and all data is present +- **`unknown`**: Zero returned local data but hasn't confirmed completeness with the server + +### Future Result Types + +The `complete` value is currently only returned when Zero has received the server result. But in the future, Zero will be able to return this result type when it _knows_ that all possible data for this query is already available locally. + +Additionally, we plan to add a `prefix` result for when the data is known to be a prefix of the complete result. See [Consistency](#consistency) for more information. + +### Using Completeness Information + +```tsx +function IssueList() { + const [issues, result] = useQuery(z.query.issue.orderBy('created', 'desc')); + + return ( +
+ {issues.map(issue => ( + + ))} + + {result.type !== 'complete' && ( +
Loading more issues...
+ )} +
+ ); +} +``` + +## Handling Missing Data + +It is inevitable that there will be cases where the requested data cannot be found. Because Zero returns local results immediately, and server results asynchronously, displaying "not found" / 404 UI can be slightly tricky. + +### The Flickering Problem + +If you just use a simple existence check, you will often see the 404 UI flicker while the server result loads: + +```tsx +const [issue, issueResult] = useQuery( + z.query.issue.where('id', 'some-id').one(), +); + +// ❌ This causes flickering of the UI +if (!issue) { + return
404 Not Found
; +} else { + return
{issue.title}
; +} +``` + +### The Correct Approach + +The way to do this correctly is to only display the "not found" UI when the result type is `complete`. This way the 404 page is slow but pages with data are still just as fast. + +```tsx +const [issue, issueResult] = useQuery( + z.query.issue.where('id', 'some-id').one(), +); + +if (!issue && issueResult.type === 'complete') { + return
404 Not Found
; +} + +if (!issue) { + return
Loading...
; +} + +return
{issue.title}
; +``` + +### Loading States Pattern + +Here's a comprehensive pattern for handling different loading states: + +```tsx +function IssueDetail({issueId}: {issueId: string}) { + const [issue, result] = useQuery(z.query.issue.where('id', issueId).one()); + + // Show loading while we don't have data and haven't confirmed it's missing + if (!issue && result.type !== 'complete') { + return ( +
+ Loading issue... +
+ ); + } + + // Show 404 only when we're sure the data doesn't exist + if (!issue && result.type === 'complete') { + return ( +
+

Issue Not Found

+

The issue you're looking for doesn't exist or has been deleted.

+
+ ); + } + + // Render the issue data + return ( +
+

{issue.title}

+

{issue.description}

+ {/* Show a subtle indicator if data might not be complete */} + {result.type !== 'complete' && ( +
Synchronizing...
+ )} +
+ ); +} +``` + +## Consistency + +Zero always syncs a consistent partial replica of the backend database to the client. This avoids many common consistency issues that come up in classic web applications. But there are still some consistency issues to be aware of when using Zero. + +### The Prefix Problem + +Consider this example: you have a bug database with 10k issues. You preload the first 1k issues sorted by created date. + +The user then does a query of issues assigned to themselves, sorted by created date. Among the 1k issues that were preloaded, imagine 100 are found that match the query. Since the data we preloaded is in the same order as this query, we are guaranteed that any local results found will be a _prefix_ of the server results. + +```tsx +// Preloaded data (sorted by created desc) +z.query.issue.orderBy('created', 'desc').limit(1000).preload(); + +// User query (same sort order) - local results will be a prefix +const [myIssues] = useQuery( + z.query.issue.where('assignee', currentUserId).orderBy('created', 'desc'), // Same sort! +); +``` + +**Good UX**: The user will see initial results to the query instantly. If more results are found server-side, those results are guaranteed to sort below the local results. There's no shuffling of results when the server response comes in. + +### When Consistency Breaks + +Now imagine that the user switches the sort to 'sort by modified'. This new query will run locally, and will again find some local matches. But it is now unlikely that the local results found are a prefix of the server results. When the server result comes in, the user will probably see the results shuffle around. + +```tsx +// Same preloaded data (sorted by created desc) +z.query.issue.orderBy('created', 'desc').limit(1000).preload(); + +// User query (different sort order) - local results may not be a prefix +const [myIssues] = useQuery( + z.query.issue.where('assignee', currentUserId).orderBy('modified', 'desc'), // Different sort! +); +``` + +**Poor UX**: Results may shuffle when server data arrives. + +### Solving Consistency Issues + +To avoid this annoying effect, what you should do in this example is also preload the first 1k issues sorted by modified desc. In general for any query shape you intend to do, you should preload the first `n` results for that query shape with no filters, in each sort you intend to use. + +```tsx +// Preload different sort orders +z.query.issue.orderBy('created', 'desc').limit(1000).preload({ttl: 'forever'}); +z.query.issue.orderBy('modified', 'desc').limit(1000).preload({ttl: 'forever'}); +z.query.issue.orderBy('priority', 'desc').limit(1000).preload({ttl: 'forever'}); + +// Now all these queries will have consistent, non-shuffling results +const [byCreated] = useQuery( + z.query.issue.where('assignee', user).orderBy('created', 'desc'), +); +const [byModified] = useQuery( + z.query.issue.where('assignee', user).orderBy('modified', 'desc'), +); +const [byPriority] = useQuery( + z.query.issue.where('assignee', user).orderBy('priority', 'desc'), +); +``` + +### No Duplicate Rows + + + Zero syncs the *union* of all active queries' results. You don't have to worry + about syncing many sorts of the same query when it's likely the results will + overlap heavily. + + +### Future Consistency Model + +In the future, we will be implementing a consistency model that fixes these issues automatically. We will prevent Zero from returning local data when that data is not known to be a prefix of the server result. Once the consistency model is implemented, preloading can be thought of as purely a performance thing, and not required to avoid unsightly flickering. + +## Advanced Synchronization Patterns + +### Optimistic Updates + +While Zero automatically handles synchronization for reads, you can implement optimistic updates for writes: + +```tsx +function useOptimisticIssueUpdate() { + const [issues, setIssues] = useState([]); + + const updateIssue = async (id: string, updates: Partial) => { + // Optimistically update the UI + setIssues(prev => + prev.map(issue => (issue.id === id ? {...issue, ...updates} : issue)), + ); + + try { + // Sync with server + await z.mutate.updateIssue({id, ...updates}); + } catch (error) { + // Revert on error + console.error('Update failed:', error); + // The actual server state will sync back automatically + } + }; + + return {issues, updateIssue}; +} +``` + +### Conditional Rendering Based on Sync State + +```tsx +function DataDrivenUI() { + const [issues, result] = useQuery(z.query.issue.limit(50)); + + return ( +
+ {/* Always show available data */} + + + {/* Conditional UI based on sync state */} + {result.type === 'complete' ? ( +
+ ✓ All data loaded ({issues.length} issues) +
+ ) : ( +
+ 🔄 Synchronizing... ({issues.length} issues so far) +
+ )} +
+ ); +} +``` + +### Progressive Loading + +```tsx +function ProgressiveIssueLoader() { + const [limit, setLimit] = useState(20); + const [issues, result] = useQuery( + z.query.issue.orderBy('created', 'desc').limit(limit), + ); + + const loadMore = () => { + if (result.type === 'complete' && issues.length === limit) { + setLimit(prev => prev + 20); + } + }; + + return ( +
+ + + {result.type === 'complete' && issues.length === limit && ( + + )} + + {result.type !== 'complete' &&
Loading...
} +
+ ); +} +``` + +## Best Practices + +1. **Use completeness information**: Check `result.type` before showing 404 or "no data" states +2. **Preload consistent sorts**: Preload data in the same sort orders you'll query +3. **Progressive disclosure**: Show available data immediately, then enhance with complete data +4. **Graceful degradation**: Design UI to work well with partial data +5. **Avoid result shuffling**: Match preload and query sort orders for smooth UX +6. **Communicate sync state**: Let users know when data is still synchronizing + +## Next Steps + +Now that you understand data synchronization, explore these related topics: + +- [Advanced Query Patterns](/advanced-query-patterns) - Master preloading and optimization techniques +- [Query Lifecycle](/query-lifecycle) - Understand performance and caching behavior +- [Handling Missing Data Patterns](/handling-missing-data) - Advanced patterns for robust UIs +- [ZQL Fundamentals](/zql-fundamentals) - Review the basics if needed diff --git a/lib/routes-config.ts b/lib/routes-config.ts index 4819726..f13dd7d 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -54,6 +54,7 @@ export const ROUTES: EachRoute[] = [ {title: 'Filtering', href: '/filtering-data'}, {title: 'Relationships', href: '/relationships'}, {title: 'Lifecycle & Performance', href: '/query-lifecycle'}, + {title: 'Data Synchronization', href: '/data-synchronization'}, ], }, { From 592a74f9eb7469b8ab299896083cbe036dae0380 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Fri, 30 May 2025 14:10:16 +0200 Subject: [PATCH 7/8] advanced query patterns --- contents/docs/advanced-query-patterns.mdx | 437 ++++++++++++++++++++++ lib/routes-config.ts | 1 + 2 files changed, 438 insertions(+) create mode 100644 contents/docs/advanced-query-patterns.mdx diff --git a/contents/docs/advanced-query-patterns.mdx b/contents/docs/advanced-query-patterns.mdx new file mode 100644 index 0000000..8e685cf --- /dev/null +++ b/contents/docs/advanced-query-patterns.mdx @@ -0,0 +1,437 @@ +--- +title: Advanced Query Patterns +--- + +Once you're comfortable with basic ZQL queries, there are several advanced patterns that can help you build more efficient and responsive applications. This page covers preloading strategies, one-time queries, change listeners, and advanced optimization techniques. + +## Preloading + +Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. In Zero, preloading is done via queries – the same queries you use in the UI and for auth. + +### Basic Preloading + +Because preload queries are usually much larger than a screenful of UI, Zero provides a special `preload()` helper to avoid the overhead of materializing the result into JS objects: + +```tsx +// Preload the first 1k issues + their creator, assignee, labels, and +// the view state for the active user. +// +// There's no need to render this data, so we don't use `useQuery()`: +// this avoids the overhead of pulling all this data into JS objects. +z.query.issue + .related('creator') + .related('assignee') + .related('labels') + .related('viewState', q => q.where('userID', z.userID).one()) + .orderBy('created', 'desc') + .limit(1000) + .preload(); +``` + +### Strategic Preloading Patterns + +#### 1. Core Data Pattern + +Preload the most essential data that users will need immediately: + +```tsx +// Preload user profile and settings +z.query.user + .where('id', currentUserId) + .related('settings') + .related('preferences') + .preload({ttl: 'forever'}); + +// Preload user's organizations and roles +z.query.organization + .whereExists('members', q => q.where('userId', currentUserId)) + .related('roles', q => q.where('userId', currentUserId)) + .preload({ttl: 'forever'}); +``` + +#### 2. Multiple Sort Orders Pattern + +Preload data in different sort orders to enable instant UI transitions: + +```tsx +// Preload issues in different sort orders +const commonIssueFields = (q: IssueQuery) => + q.related('creator').related('assignee').related('labels').limit(500); + +// Different sort orders for instant switching +commonIssueFields(z.query.issue.orderBy('created', 'desc')).preload({ + ttl: '1d', +}); +commonIssueFields(z.query.issue.orderBy('updated', 'desc')).preload({ + ttl: '1d', +}); +commonIssueFields(z.query.issue.orderBy('priority', 'desc')).preload({ + ttl: '1d', +}); +``` + +#### 3. Hierarchical Preloading + +Preload nested data structures that users commonly navigate: + +```tsx +// Preload project hierarchy +z.query.project + .where('archived', false) + .related('teams', teamQ => + teamQ.related('members', memberQ => memberQ.related('user').limit(50)), + ) + .related('issues', issueQ => + issueQ.where('status', 'IN', ['open', 'in-progress']).limit(100), + ) + .preload({ttl: '1h'}); +``` + +### Preloading Best Practices + +1. **Use appropriate TTLs**: Forever for user data, shorter for dynamic content +2. **Limit preloaded data**: Don't preload everything, focus on high-probability needs +3. **Match query patterns**: Preload data in the same shape you'll query +4. **Consider data overlap**: Zero deduplicates, so overlapping preloads are efficient + +```tsx +// Good: Focused preloading with appropriate limits +z.query.issue + .where('status', '!=', 'archived') + .orderBy('updated', 'desc') + .limit(200) + .preload({ttl: '30m'}); + +// Avoid: Unlimited preloading +z.query.issue.preload(); // Could load millions of records +``` + +## Running Queries Once + +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. + +### Basic One-time Queries + +Use the `run()` method for non-reactive queries: + +```tsx +const results = await z.query.issue.where('foo', 'bar').run(); +``` + +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'`. + +### Waiting for Complete Results + +If you want to wait for the server to return results, pass `{type: 'complete'}` to `run`: + +```tsx +const results = await z.query.issue.where('foo', 'bar').run({type: 'complete'}); +``` + +### Shorthand Syntax + + +As a convenience you can also directly await queries: + +```ts +await z.query.issue.where('foo', 'bar'); +``` + +This is the same as saying `run()` or `run({type: 'unknown'})`. + + + +### One-time Query Patterns + +#### Data Validation + +```tsx +async function validateUniqueEmail(email: string) { + const existingUser = await z.query.user + .where('email', email) + .one() + .run({type: 'complete'}); + + return !existingUser; +} +``` + +#### Analytics and Reporting + +```tsx +async function generateUserReport(userId: string) { + const [user, issues, comments] = await Promise.all([ + z.query.user.where('id', userId).one().run({type: 'complete'}), + z.query.issue.where('creator', userId).run({type: 'complete'}), + z.query.comment.where('author', userId).run({type: 'complete'}), + ]); + + return { + user, + totalIssues: issues.length, + totalComments: comments.length, + // ... more analytics + }; +} +``` + +#### Background Data Processing + +```tsx +async function syncDataInBackground() { + // Get all unsync'd records + const unsyncedRecords = await z.query.syncQueue + .where('status', 'pending') + .run({type: 'complete'}); + + // Process each record + for (const record of unsyncedRecords) { + await processRecord(record); + } +} +``` + +## Listening to Changes + +For advanced use cases where you need granular control over data changes, you can work directly with materialized views and change listeners. + +### Basic Change Listening + +Currently, the way to listen for changes in query results is through materialized views: + +```ts +const view = z.query.issue.materialize(); +view.addListener((issues, issuesResult) => { + console.log('Issues updated:', issues.length); + console.log('Result type:', issuesResult.type); +}); + +// Don't forget to clean up +view.destroy(); +``` + +### Custom View Implementation + +For more granular event handling, you can create custom view implementations. Here's an example pattern: + +```tsx +class CustomIssueView { + private view: MaterializedView; + private listeners: { + add?: (issue: Issue) => void; + remove?: (issue: Issue) => void; + update?: (issue: Issue) => void; + } = {}; + + constructor(query: IssueQuery) { + this.view = query.materialize(); + this.view.addListener((issues, result) => { + // Custom logic to detect what changed + this.handleChanges(issues, result); + }); + } + + onAdd(callback: (issue: Issue) => void) { + this.listeners.add = callback; + return this; + } + + onRemove(callback: (issue: Issue) => void) { + this.listeners.remove = callback; + return this; + } + + onUpdate(callback: (issue: Issue) => void) { + this.listeners.update = callback; + return this; + } + + private handleChanges(issues: Issue[], result: QueryResult) { + // Implementation would track previous state and detect changes + // This is simplified - real implementation would be more complex + } + + destroy() { + this.view.destroy(); + } +} + +// Usage +const issueView = new CustomIssueView(z.query.issue.where('status', 'open')) + .onAdd(issue => console.log('New issue:', issue.title)) + .onRemove(issue => console.log('Issue removed:', issue.title)) + .onUpdate(issue => console.log('Issue updated:', issue.title)); +``` + +### Framework-Specific Change Handling + +#### React Custom Hook + +```tsx +function useQueryChanges( + query: Query, + handlers: { + onAdd?: (item: T) => void; + onRemove?: (item: T) => void; + onChange?: (items: T[]) => void; + }, +) { + const [data, result] = useQuery(query); + const previousDataRef = useRef([]); + + useEffect(() => { + const previous = previousDataRef.current; + const current = data; + + // Detect changes (simplified) + if (handlers.onChange && previous !== current) { + handlers.onChange(current); + } + + // Update ref for next comparison + previousDataRef.current = current; + }, [data, handlers]); + + return [data, result] as const; +} + +// Usage +function IssueListWithHandlers() { + const [issues] = useQueryChanges(z.query.issue.where('status', 'open'), { + onAdd: issue => toast.success(`New issue: ${issue.title}`), + onRemove: issue => toast.info(`Issue closed: ${issue.title}`), + onChange: issues => console.log(`${issues.length} open issues`), + }); + + return ; +} +``` + +## Advanced Optimization Patterns + +### Query Deduplication + +```tsx +// Create a query cache to avoid duplicate queries +const queryCache = new Map>(); + +function getCachedQuery(key: string, queryFn: () => Promise): Promise { + if (!queryCache.has(key)) { + const promise = queryFn().finally(() => { + // Clean up cache after some time + setTimeout(() => queryCache.delete(key), 5000); + }); + queryCache.set(key, promise); + } + return queryCache.get(key)!; +} + +// Usage +const getIssues = (status: string) => + getCachedQuery(`issues-${status}`, () => + z.query.issue.where('status', status).run({type: 'complete'}), + ); +``` + +### Conditional Query Loading + +```tsx +function useConditionalQuery( + condition: boolean, + query: () => Query, + options?: QueryOptions, +) { + const conditionalQuery = useMemo(() => { + return condition ? query() : null; + }, [condition, query]); + + return useQuery(conditionalQuery, options); +} + +// Usage +function UserDashboard({userId}: {userId?: string}) { + // Only load user data when userId is available + const [user] = useConditionalQuery( + !!userId, + () => z.query.user.where('id', userId!).one(), + {ttl: '1h'}, + ); + + if (!userId) return
Please select a user
; + if (!user) return
Loading user...
; + + return
Welcome, {user.name}!
; +} +``` + +### Batch Query Operations + +```tsx +async function batchQueryOperations() { + // Run multiple independent queries in parallel + const [users, issues, projects] = await Promise.all([ + z.query.user.where('active', true).run({type: 'complete'}), + z.query.issue.where('status', 'open').run({type: 'complete'}), + z.query.project.where('archived', false).run({type: 'complete'}), + ]); + + // Process results together + return { + activeUsers: users.length, + openIssues: issues.length, + activeProjects: projects.length, + summary: generateSummary(users, issues, projects), + }; +} +``` + +### Intelligent Preloading + +```tsx +function useIntelligentPreloader() { + const [userActivity, setUserActivity] = useState([]); + + // Track user navigation patterns + const trackActivity = useCallback((page: string) => { + setUserActivity(prev => [...prev.slice(-10), page]); // Keep last 10 pages + }, []); + + // Preload based on activity patterns + useEffect(() => { + if (userActivity.includes('issues') && userActivity.includes('projects')) { + // User frequently views both - preload the connection + z.query.issue.related('project').limit(100).preload({ttl: '10m'}); + } + + if (userActivity.filter(p => p === 'settings').length > 2) { + // User is in settings mode - preload all settings + z.query.user + .where('id', currentUserId) + .related('settings') + .related('preferences') + .related('notifications') + .preload({ttl: '5m'}); + } + }, [userActivity]); + + return {trackActivity}; +} +``` + +## Best Practices + +1. **Preload strategically**: Focus on high-probability user paths, not everything +2. **Use appropriate query types**: Reactive for UI, one-time for operations +3. **Clean up resources**: Always destroy views and clear listeners +4. **Consider data freshness**: Use TTLs that match your data update frequency +5. **Monitor performance**: Track query performance and adjust preloading accordingly +6. **Batch related operations**: Group related queries for better performance + +## Next Steps + +Now that you understand advanced query patterns, explore these related topics: + +- [Query Lifecycle](/query-lifecycle) - Deep dive into performance optimization +- [Data Synchronization](/data-synchronization) - Master completeness and consistency +- [Custom Mutators](/custom-mutators) - Learn about writing data with advanced patterns +- [ZQL Reference](/zql-reference) - Complete API reference for all query methods diff --git a/lib/routes-config.ts b/lib/routes-config.ts index f13dd7d..4924161 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -55,6 +55,7 @@ export const ROUTES: EachRoute[] = [ {title: 'Relationships', href: '/relationships'}, {title: 'Lifecycle & Performance', href: '/query-lifecycle'}, {title: 'Data Synchronization', href: '/data-synchronization'}, + {title: 'Advanced Patterns', href: '/advanced-query-patterns'}, ], }, { From 8b8cc4b53070802ca8f86bc2a262023781d5c9b0 Mon Sep 17 00:00:00 2001 From: l0g1x Date: Fri, 30 May 2025 14:23:17 +0200 Subject: [PATCH 8/8] ZQL api reference --- contents/docs/zql-reference.mdx | 616 ++++++++++++++++++++++++++++++++ lib/routes-config.ts | 1 + 2 files changed, 617 insertions(+) create mode 100644 contents/docs/zql-reference.mdx diff --git a/contents/docs/zql-reference.mdx b/contents/docs/zql-reference.mdx new file mode 100644 index 0000000..83ad531 --- /dev/null +++ b/contents/docs/zql-reference.mdx @@ -0,0 +1,616 @@ +--- +title: ZQL Reference +--- + +This page serves as a comprehensive API reference for ZQL (Zero Query Language). Use it to quickly look up methods, operators, types, and troubleshooting information. + +## Query Construction + +### Table Selection + +```tsx +z.query.tableName; +``` + +Starts a query by selecting a table. Returns all columns and rows (subject to subsequent clauses and permissions). + +**Example:** + +```tsx +z.query.issue; // Selects from the 'issue' table +z.query.user; // Selects from the 'user' table +``` + +## Query Clauses + +### where(column, operator?, value) + +Filters query results based on column values. + +```tsx +.where(column: string, value: any) +.where(column: string, operator: ComparisonOperator, value: any) +.where(filterFunction: (helpers: FilterHelpers) => FilterExpression) +``` + +**Parameters:** + +- `column`: Column name (TypeScript will suggest valid options) +- `operator`: Comparison operator (optional, defaults to `=`) +- `value`: Value to compare against +- `filterFunction`: Function for complex filtering + +**Examples:** + +```tsx +// Simple equality (operator defaults to '=') +.where('status', 'open') + +// Explicit operator +.where('priority', '>', 3) + +// Complex filtering +.where(({cmp, and, or}) => + or( + cmp('priority', 'high'), + and(cmp('assignee', userId), cmp('status', 'urgent')) + ) +) +``` + +### orderBy(column, direction?) + +Sorts query results by one or more columns. + +```tsx +.orderBy(column: string, direction?: 'asc' | 'desc') +``` + +**Parameters:** + +- `column`: Column name to sort by +- `direction`: Sort direction (`'asc'` or `'desc'`, defaults to `'asc'`) + +**Examples:** + +```tsx +.orderBy('created') // Ascending (default) +.orderBy('created', 'desc') // Descending +.orderBy('priority', 'desc').orderBy('created', 'asc') // Multiple sorts +``` + +**Note:** All queries have an implicit final sort by primary key for deterministic ordering. + +### limit(count) + +Limits the number of rows returned. + +```tsx +.limit(count: number) +``` + +**Parameters:** + +- `count`: Maximum number of rows to return + +**Examples:** + +```tsx +.limit(50) // Return at most 50 rows +.limit(1) // Return at most 1 row +``` + +### start(row, options?) + +Sets a starting point for pagination (cursor-based). + +```tsx +.start(row: Row, options?: {inclusive?: boolean}) +``` + +**Parameters:** + +- `row`: The row to start from (obtained from a previous query) +- `options.inclusive`: Whether to include the start row (default: `false`) + +**Examples:** + +```tsx +.start(lastRow) // Start after lastRow (exclusive) +.start(startRow, {inclusive: true}) // Start from startRow (inclusive) +``` + +### one() + +Returns a single row instead of an array. Changes return type from `Row[]` to `Row | undefined`. + +```tsx +.one() +``` + +**Examples:** + +```tsx +.where('id', 42).one() // Returns Issue | undefined +.orderBy('created', 'desc').one() // Returns the most recent issue +``` + +**Note:** `one()` overrides any `limit()` clause. + +### related(relationship, queryFn?) + +Includes related data in the query results. + +```tsx +.related(relationship: string, queryFn?: (q: RelatedQuery) => RelatedQuery) +``` + +**Parameters:** + +- `relationship`: Name of the relationship (defined in your Zero schema) +- `queryFn`: Optional function to refine the related query + +**Examples:** + +```tsx +.related('comments') // Include all related comments + +// Refine related data +.related('comments', q => + q.where('status', 'active') + .orderBy('created', 'desc') + .limit(10) +) + +// Nested relationships +.related('comments', q => + q.related('author') + .related('reactions') +) +``` + +### whereExists(relationship, queryFn?) + +Filters the main query based on the existence of related data. + +```tsx +.whereExists(relationship: string, queryFn?: (q: RelatedQuery) => RelatedQuery) +``` + +**Parameters:** + +- `relationship`: Name of the relationship to check +- `queryFn`: Optional function to refine what constitutes "existence" + +**Examples:** + +```tsx +// Issues that have at least one comment +.whereExists('comments') + +// Issues that have recent comments +.whereExists('comments', q => + q.where('created', '>', lastWeek) +) + +// Issues that have comments with reactions +.whereExists('comments', q => + q.whereExists('reactions') +) +``` + +## Comparison Operators + +| Operator | Types | Description | Example | +| ----------- | ----------------------- | ------------------------- | --------------------------------------------- | +| `=` | boolean, number, string | Strict equality | `.where('status', '=', 'open')` | +| `!=` | boolean, number, string | Strict inequality | `.where('status', '!=', 'closed')` | +| `<` | number | Less than | `.where('priority', '<', 5)` | +| `<=` | number | Less than or equal | `.where('votes', '<=', 100)` | +| `>` | number | Greater than | `.where('priority', '>', 3)` | +| `>=` | number | Greater than or equal | `.where('created', '>=', startDate)` | +| `LIKE` | string | SQL LIKE pattern | `.where('title', 'LIKE', '%bug%')` | +| `NOT LIKE` | string | SQL NOT LIKE pattern | `.where('title', 'NOT LIKE', '%test%')` | +| `ILIKE` | string | Case-insensitive LIKE | `.where('title', 'ILIKE', '%BUG%')` | +| `NOT ILIKE` | string | Case-insensitive NOT LIKE | `.where('title', 'NOT ILIKE', '%TEST%')` | +| `IN` | boolean, number, string | Value in array | `.where('status', 'IN', ['open', 'pending'])` | +| `NOT IN` | boolean, number, string | Value not in array | `.where('priority', 'NOT IN', [1, 2])` | +| `IS` | any, null | Null-safe equality | `.where('assignee', 'IS', null)` | +| `IS NOT` | any, null | Null-safe inequality | `.where('assignee', 'IS NOT', null)` | + +### LIKE Pattern Syntax + +- `%`: Matches any sequence of characters +- `_`: Matches any single character +- `\%`: Literal percent sign +- `\_`: Literal underscore + +**Examples:** + +```tsx +.where('title', 'LIKE', 'Bug%') // Starts with "Bug" +.where('title', 'LIKE', '%error%') // Contains "error" +.where('title', 'LIKE', 'Issue_#%') // "Issue" + any char + "#" + anything +``` + +## Filter Helpers + +When using complex filters with `.where()`, these helpers are available: + +### cmp(column, operator?, value) + +Basic comparison within complex filters. + +```tsx +cmp(column: string, value: any) +cmp(column: string, operator: ComparisonOperator, value: any) +``` + +### and(...expressions) + +Logical AND operation. + +```tsx +and(expr1: FilterExpression, expr2: FilterExpression, ...moreExpressions) +``` + +### or(...expressions) + +Logical OR operation. + +```tsx +or(expr1: FilterExpression, expr2: FilterExpression, ...moreExpressions) +``` + +### not(expression) + +Logical NOT operation. + +```tsx +not(expr: FilterExpression) +``` + +### exists(relationship, queryFn?) + +Relationship existence check within complex filters. + +```tsx +exists(relationship: string, queryFn?: (q: RelatedQuery) => RelatedQuery) +``` + +**Example:** + +```tsx +.where(({cmp, and, or, not, exists}) => + or( + and( + cmp('priority', 'high'), + not(cmp('status', 'closed')) + ), + exists('comments', q => + q.where('urgent', true) + ) + ) +) +``` + +## Query Execution + +### run(options?) + +Executes the query once and returns a Promise. + +```tsx +.run(options?: {type?: 'unknown' | 'complete'}): Promise +``` + +**Parameters:** + +- `options.type`: + - `'unknown'` (default): Return immediately with local data + - `'complete'`: Wait for server confirmation + +**Examples:** + +```tsx +const issues = await z.query.issue.run(); // Local data +const issues = await z.query.issue.run({type: 'complete'}); // Server-confirmed +``` + +### Shorthand Execution + +```tsx +await z.query.issue.where('status', 'open'); // Same as .run() +``` + +### materialize() + +Creates a materialized view for advanced use cases. + +```tsx +.materialize(): MaterializedView +``` + +**Example:** + +```tsx +const view = z.query.issue.materialize(); +view.addListener((issues, result) => { + console.log('Data updated:', issues.length); +}); + +// Clean up when done +view.destroy(); +``` + +### preload(options?) + +Preloads data without materializing it into JavaScript objects. + +```tsx +.preload(options?: {ttl?: TTL}): () => void +``` + +**Parameters:** + +- `options.ttl`: Time-to-live for background syncing + +**Returns:** Cleanup function + +**Examples:** + +```tsx +// Preload with default TTL +z.query.issue.limit(1000).preload(); + +// Preload with custom TTL +const cleanup = z.query.issue.limit(500).preload({ttl: '1d'}); + +// Clean up manually +cleanup(); +``` + +## TTL (Time-To-Live) Values + +| Format | Description | Example | +| ----------- | ------------------------------- | ------------------ | +| `'none'` | No background syncing (default) | `{ttl: 'none'}` | +| `'Ns'` | N seconds | `{ttl: '30s'}` | +| `'Nm'` | N minutes | `{ttl: '10m'}` | +| `'Nh'` | N hours | `{ttl: '2h'}` | +| `'Nd'` | N days | `{ttl: '7d'}` | +| `'Ny'` | N years | `{ttl: '1y'}` | +| `'forever'` | Never stop syncing | `{ttl: 'forever'}` | + +## Framework Integration + +### React + +```tsx +import { useQuery } from '@rocicorp/zero/react'; + +function MyComponent() { + const [data, result] = useQuery(query, options?); + + // data: Row[] | Row | undefined (depends on query) + // result: {type: 'complete' | 'unknown'} +} +``` + +**Options:** + +```tsx +{ + ttl?: TTL, // Background sync duration + // ... other framework-specific options +} +``` + +### SolidJS + +```tsx +import { createQuery } from '@rocicorp/zero/solid'; + +function MyComponent() { + const data = createQuery(() => query, options?); + + // data(): Row[] | Row | undefined +} +``` + +## TypeScript Types + +### Core Types + +```tsx +// Table row type (generated from your schema) +type IssueRow = { + readonly id: string; + readonly title: string; + readonly status: 'open' | 'closed' | 'pending'; + readonly priority: number; + readonly created: Date; + // ... other columns +}; + +// Query result information +type QueryResult = { + type: 'complete' | 'unknown'; +}; + +// Query options +type QueryOptions = { + ttl?: TTL; +}; + +// TTL specification +type TTL = 'none' | 'forever' | `${number}${'s' | 'm' | 'h' | 'd' | 'y'}`; +``` + +### Query Types + +```tsx +// Basic query type +type Query = { + where(column: keyof T, value: any): Query; + where(column: keyof T, op: ComparisonOperator, value: any): Query; + where(fn: FilterFunction): Query; + orderBy(column: keyof T, direction?: 'asc' | 'desc'): Query; + limit(count: number): Query; + start(row: T, options?: {inclusive?: boolean}): Query; + one(): Query & {run(): Promise}; + related(rel: string, fn?: (q: any) => any): Query; + whereExists(rel: string, fn?: (q: any) => any): Query; + run(options?: {type?: 'unknown' | 'complete'}): Promise; + materialize(): MaterializedView; + preload(options?: {ttl?: TTL}): () => void; +}; +``` + +## Error Handling + +### Common Errors + +#### Invalid Column Name + +```tsx +// ❌ TypeScript Error +z.query.issue.where('invalidColumn', 'value'); +// Error: Argument of type '"invalidColumn"' is not assignable to parameter +``` + +#### Invalid Operator for Type + +```tsx +// ❌ TypeScript Error +z.query.issue.where('isActive', '>', true); +// Error: Operator '>' cannot be used with boolean values +``` + +#### Null Comparison with Wrong Operator + +```tsx +// ❌ TypeScript Error +z.query.issue.where('assignee', '=', null); +// Error: Use 'IS' or 'IS NOT' for null comparisons +``` + +### Runtime Errors + +#### Query Execution Errors + +```tsx +try { + const issues = await z.query.issue.run({type: 'complete'}); +} catch (error) { + if (error.code === 'NETWORK_ERROR') { + // Handle network issues + } else if (error.code === 'PERMISSION_DENIED') { + // Handle permission issues + } +} +``` + +#### View Lifecycle Errors + +```tsx +const view = z.query.issue.materialize(); + +// ❌ Using destroyed view +view.destroy(); +view.addListener(() => {}); // Error: View has been destroyed +``` + +### Debugging Tips + +1. **Check Network Tab**: Look for failed requests to Zero's sync endpoint +2. **Enable Debug Logging**: Set log level to see query materialization times +3. **Verify Schema**: Ensure relationships and columns exist in your Zero schema +4. **Check Permissions**: Verify user has access to queried tables and columns + +```tsx +// Enable debug logging +const z = new Zero({ + logLevel: 'debug', + slowMaterializeThreshold: 1000, // Log slow queries +}); +``` + +## Performance Considerations + +### Query Optimization + +- **Use `limit()`** for large result sets +- **Add `orderBy()`** when using `limit()` for consistent results +- **Prefer `start()`** over offset-based pagination +- **Use appropriate TTLs** for background queries +- **Preload strategically** based on user patterns + +### Memory Management + +- **Destroy materialized views** when components unmount +- **Use framework hooks** instead of manual view management +- **Set reasonable limits** on preloaded data +- **Monitor client capacity** (default: 20,000 rows) + +### Network Efficiency + +- **Batch related queries** when possible +- **Use relationships** instead of separate queries for related data +- **Consider data freshness** requirements when setting TTLs + +## Best Practices + +1. **Type Safety**: Let TypeScript guide you - it knows your schema +2. **Consistent Patterns**: Use the same query patterns throughout your app +3. **Error Boundaries**: Wrap query components in error boundaries +4. **Resource Cleanup**: Always clean up materialized views and preload functions +5. **Performance Monitoring**: Watch for slow query warnings in your logs +6. **Schema Design**: Design relationships to match your query patterns + +## Migration from Other ORMs + +### From Prisma + +```tsx +// Prisma +const issues = await prisma.issue.findMany({ + where: {status: 'open'}, + include: {comments: true}, + orderBy: {created: 'desc'}, + take: 10, +}); + +// ZQL equivalent +const [issues] = useQuery( + z.query.issue + .where('status', 'open') + .related('comments') + .orderBy('created', 'desc') + .limit(10), +); +``` + +### From Drizzle + +```tsx +// Drizzle +const issues = await db + .select() + .from(issueTable) + .where(eq(issueTable.status, 'open')) + .orderBy(desc(issueTable.created)) + .limit(10); + +// ZQL equivalent +const [issues] = useQuery( + z.query.issue.where('status', 'open').orderBy('created', 'desc').limit(10), +); +``` + +## Next Steps + +- [ZQL Fundamentals](/zql-fundamentals) - Start with the basics +- [Query Lifecycle](/query-lifecycle) - Understand performance implications +- [Data Synchronization](/data-synchronization) - Master completeness and consistency +- [Zero Schema](/zero-schema) - Learn how to define relationships and permissions diff --git a/lib/routes-config.ts b/lib/routes-config.ts index 4924161..8a73183 100644 --- a/lib/routes-config.ts +++ b/lib/routes-config.ts @@ -56,6 +56,7 @@ export const ROUTES: EachRoute[] = [ {title: 'Lifecycle & Performance', href: '/query-lifecycle'}, {title: 'Data Synchronization', href: '/data-synchronization'}, {title: 'Advanced Patterns', href: '/advanced-query-patterns'}, + {title: 'ZQL Reference', href: '/zql-reference'}, ], }, {