Skip to content

Commit 43bb5cd

Browse files
committed
docs: change layout
1 parent 2ad15b2 commit 43bb5cd

17 files changed

+809
-9
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ You are working on a Prisma generator that creates a familiar, type-safe client
77
- **Generator-Centric**: Most core logic resides in `packages/generator`. If you need to change client behavior, edit the generator's `fileCreators`.
88
- **Sync Flow**: Uses an **Outbox Pattern** on the client and a **Changelog Materialization** on the server.
99
- **Ownership Invariants**: Syncability is gated by an ownership DAG. Every syncable record must be traceable to a `rootModel` (e.g., `User`) via `@db.map("ownerId")` or similar ownership fields. Refer to `todo.md` for the 4 core invariants.
10-
- **Client-Side IDs**: All syncable models **must** use client-generated IDs (`uuid`, `cuid2`). Auto-incrementing IDs are strictly forbidden for syncable data.
10+
- **Client-Side IDs**: All syncable models **must** use client-generated IDs (`uuid`, `cuid`). Auto-incrementing IDs are strictly forbidden for syncable data.
1111

1212
## Critical Workflows
1313

Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"title": "Basics",
3-
"icon": "HouseIcon",
4-
"pages": ["---Home---", "index", "why-prisma-idb", "quick-start", "---Sync---", "...(sync)"],
3+
"description": "Get started with the Prisma IDB client",
4+
"icon": "Pyramid",
5+
"pages": ["---Home---", "index", "why-prisma-idb", "quick-start"],
56
"root": true
67
}

apps/docs/content/docs/quick-start.mdx renamed to apps/docs/content/docs/(index)/quick-start.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ generator prismaIDB {
4141

4242
```prisma
4343
model User {
44-
id String @id @default(cuid2()) // Client-generated IDs required for sync
44+
id String @id @default(cuid()) // Client-generated IDs required for sync
4545
name String
4646
email String @unique
4747
4848
todos Todo[]
4949
}
5050
5151
model Todo {
52-
id String @id @default(cuid2())
52+
id String @id @default(cuid())
5353
title String
5454
done Boolean @default(false)
5555
@@ -99,7 +99,7 @@ await client.todo.delete({
9999

100100
<Cards>
101101
<Card title="API Reference" href="/docs/api" />
102-
<Card title="Sync Engine" href="/docs/sync-engine" />
102+
<Card title="Sync Engine" href="/docs/sync" />
103103
<Card title="Ownership & Auth" href="/docs/ownership" />
104104
<Card title="Examples" href="/docs/examples" />
105105
</Cards>
File renamed without changes.

apps/docs/content/docs/(sync)/meta.json

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"pages": ["step-1-generator-config", "step-2-changelog-schema", "step-3-push-endpoint", "step-4-pull-endpoint", "step-5-sync-worker"]
3+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
title: "Step 1: Generator Config"
3+
description: "Configure the Prisma IDB generator for sync"
4+
icon: Settings
5+
---
6+
7+
## Step 1: Configure the Generator
8+
9+
Before implementing sync, you need to configure the Prisma IDB generator in your `schema.prisma` file. This tells the generator which models to sync and how to scope the data.
10+
11+
### Configuration Options
12+
13+
Add the generator block to your `prisma/schema.prisma`:
14+
15+
```prisma
16+
generator prismaIDB {
17+
provider = "idb-client-generator"
18+
output = "./prisma-idb"
19+
20+
// Enable sync capabilities
21+
outboxSync = true
22+
23+
// Anchor model for the ownership DAG
24+
rootModel = "User"
25+
26+
// Models to exclude from sync (optional)
27+
exclude = ["Session", "Verification", "Account", "ChangeLog"]
28+
}
29+
```
30+
31+
### Required Settings
32+
33+
- **`outboxSync = true`**: Enables the bidirectional sync engine. Without this, the generated client will be read-only.
34+
- **`rootModel`**: The primary entity that acts as the anchor for all other models. This must be a model in your schema that represents the data owner (typically `User`).
35+
36+
### Optional Settings
37+
38+
- **`exclude`**: Array of model names to skip during client generation. Use this for:
39+
- Authentication models (`Session`, `Account`, `Verification`)
40+
- Changelog models (excluded automatically, but good practice to list explicitly)
41+
- Server-only models that shouldn't be synced to clients
42+
- Large or temporary tables
43+
44+
### Scoping Rules
45+
46+
The `rootModel` and `exclude` settings work together to define which data gets synced:
47+
48+
- Only the `rootModel` and its descendants (models that reference it, directly or indirectly) are synced
49+
- Excluded models are never generated in the client
50+
- The generated client only syncs data relevant to the current user (scoped by `rootModel`)
51+
52+
### Example: Multi-User Blog
53+
54+
```prisma
55+
model User {
56+
id String @id @default(cuid())
57+
email String @unique
58+
posts Post[]
59+
}
60+
61+
model Post {
62+
id String @id @default(cuid())
63+
title String
64+
userId String
65+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
66+
}
67+
68+
generator prismaIDB {
69+
provider = "idb-client-generator"
70+
output = "./prisma-idb"
71+
outboxSync = true
72+
rootModel = "User"
73+
exclude = ["ChangeLog", "AuditLog"]
74+
}
75+
```
76+
77+
In this setup:
78+
- `User` is the root model
79+
- `Post` syncs because it references `User`
80+
- `AuditLog` is excluded and won't appear in the client
81+
82+
### Next Steps
83+
84+
After configuring the generator, you need to:
85+
1. Add the `ChangeLog` table to your schema
86+
2. Implement the push endpoint
87+
3. Implement the pull endpoint
88+
4. Initialize the sync worker on the client
89+
90+
Continue to [Step 2: Changelog Schema](./step-2-changelog-schema).
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
title: "Step 2: Changelog Schema"
3+
description: "Add the Changelog table to your Prisma schema"
4+
icon: Database
5+
---
6+
7+
## Step 2: Create the Changelog Table
8+
9+
The changelog table is essential for the sync engine. It tracks all changes made on the server so they can be materialized to clients during pull operations.
10+
11+
### Required Schema
12+
13+
Add the following to your `prisma/schema.prisma`:
14+
15+
```prisma
16+
model ChangeLog {
17+
id String @id @default(uuid(7))
18+
model String
19+
keyPath Json
20+
operation ChangeOperation
21+
scopeKey String
22+
outboxEventId String @unique
23+
24+
@@index([model, id])
25+
}
26+
27+
enum ChangeOperation {
28+
create
29+
update
30+
delete
31+
}
32+
```
33+
34+
### Field Explanations
35+
36+
- **`id`**: Unique identifier using `uuid(7)` for sortable timestamps
37+
- **`model`**: The name of the model that was modified (e.g., "Post", "Comment")
38+
- **`keyPath`**: JSON representation of the primary key(s) of the affected record
39+
- **`operation`**: The type of change—`create`, `update`, or `delete`
40+
- **`scopeKey`**: The owner identifier (e.g., `userId`) to scope changes per user
41+
- **`outboxEventId`**: Links to the client's outbox event that triggered this change
42+
43+
### Important Requirements
44+
45+
#### Database-Level Index
46+
47+
The `@@index([model, id])` is crucial for performance:
48+
- The pull endpoint queries changelogs in order: `WHERE model IN (...) AND id > lastChangelogId`
49+
- Without this index, pulls on large tables will be slow
50+
- Make sure to run migrations after adding this table
51+
52+
#### UUID v7 for Natural Ordering
53+
54+
Using `uuid(7)` ensures:
55+
- Changelog entries are naturally ordered by creation time
56+
- Cursoring through changes is efficient (no need to sort by timestamp)
57+
- Clients can request changes since a specific point in time
58+
59+
#### No Manual Deletes
60+
61+
Never manually delete from the `ChangeLog` table. It's the source of truth for what changed on the server.
62+
63+
### Migration
64+
65+
After adding the schema, create a migration:
66+
67+
```bash
68+
pnpm exec prisma migrate dev --name add_changelog
69+
```
70+
71+
This will:
72+
1. Create the `ChangeLog` table in your database
73+
2. Update your `schema.prisma`
74+
75+
### Next Steps
76+
77+
With the changelog table in place, you can now implement the endpoints that use it:
78+
1. Push endpoint to accept client mutations
79+
2. Pull endpoint to send server changes to clients
80+
81+
Continue to [Step 3: Push Endpoint](./step-3-push-endpoint).
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
---
2+
title: "Step 3: Push Endpoint"
3+
description: "Implement the endpoint that accepts and applies client mutations"
4+
icon: Upload
5+
---
6+
7+
## Step 3: Create the Push Endpoint
8+
9+
The push endpoint receives mutations from the client (stored in the outbox) and applies them to the server database. The sync worker will automatically send outbox events to this endpoint.
10+
11+
### Endpoint Setup
12+
13+
Create a new API route in your framework (e.g., `routes/api/sync/push/+server.ts` for SvelteKit):
14+
15+
```typescript
16+
import { applyPush } from "$lib/prisma-idb/server/batch-processor";
17+
import { outboxEventSchema } from "$lib/prisma-idb/validators";
18+
import { auth } from "$lib/server/auth";
19+
import { prisma } from "$lib/server/prisma";
20+
import z from "zod";
21+
22+
export async function POST({ request }) {
23+
// Parse and validate request body
24+
let pushRequestBody;
25+
try {
26+
pushRequestBody = await request.json();
27+
} catch {
28+
return new Response(JSON.stringify({ error: "Malformed JSON" }), { status: 400 });
29+
}
30+
31+
const parsed = z.object({ events: z.array(outboxEventSchema) }).safeParse({
32+
events: pushRequestBody.events,
33+
});
34+
35+
if (!parsed.success) {
36+
return new Response(JSON.stringify({ error: "Invalid request", details: parsed.error }), {
37+
status: 400,
38+
});
39+
}
40+
41+
// Authenticate the request
42+
const authResult = await auth.api.getSession({ headers: request.headers });
43+
if (!authResult?.user.id) {
44+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
45+
}
46+
47+
// Apply the mutations
48+
let pushResults;
49+
try {
50+
pushResults = await applyPush({
51+
events: parsed.data.events,
52+
scopeKey: authResult.user.id,
53+
prisma,
54+
});
55+
} catch (error) {
56+
const message = error instanceof Error ? error.message : "Unknown error";
57+
const status = message.startsWith("Batch size") ? 413 : 500;
58+
return new Response(JSON.stringify({ error: message }), {
59+
status,
60+
headers: { "Content-Type": "application/json" },
61+
});
62+
}
63+
64+
return new Response(JSON.stringify(pushResults), {
65+
status: 200,
66+
headers: { "Content-Type": "application/json" },
67+
});
68+
}
69+
```
70+
71+
### How It Works
72+
73+
1. **Validate Input**: Parse the request as an array of `OutboxEvent` objects
74+
2. **Authenticate**: Get the user ID from the session/auth context
75+
3. **Apply Mutations**: Call `applyPush()` with the events
76+
4. **Return Results**: Each event gets a `PushResult` indicating success or failure
77+
78+
### Key Parameters
79+
80+
#### `applyPush()` Options
81+
82+
- **`events`**: Array of outbox mutations from the client
83+
- **`scopeKey`**: User ID or function that extracts the owner from each event. This ensures users can only modify their own data.
84+
- **`prisma`**: Prisma Client instance to access your database
85+
- **`customValidation`** (optional): Add business logic validation before applying changes
86+
87+
### Error Handling
88+
89+
The endpoint catches two main error types:
90+
91+
- **Batch Size Exceeded** (413): Client sent more than 100 events in one request. The client's sync worker automatically batches events, but this prevents denial-of-service attacks.
92+
- **Other Errors** (500): Database errors or validation failures. The `PushResult` for each event indicates which ones succeeded and which ones failed (with details for retries).
93+
94+
### Custom Validation (Optional)
95+
96+
For advanced use cases, add custom business logic:
97+
98+
```typescript
99+
pushResults = await applyPush({
100+
events: parsed.data.events,
101+
scopeKey: authResult.user.id,
102+
prisma,
103+
customValidation: async (event) => {
104+
// Example: Reject if user isn't a board admin
105+
if (event.entityType === "Task") {
106+
const board = await prisma.board.findUnique({
107+
where: { id: event.payload.boardId },
108+
include: { admins: { where: { id: authResult.user.id } } },
109+
});
110+
if (!board?.admins.length) {
111+
return { errorMessage: "Only board admins can create tasks" };
112+
}
113+
}
114+
return { errorMessage: null };
115+
},
116+
});
117+
```
118+
119+
### Scope Key Enforcement
120+
121+
The `scopeKey` ensures data isolation:
122+
- For a single-user app: `scopeKey: authResult.user.id`
123+
- For a multi-tenant app: `scopeKey: (event) => event.payload.tenantId` or similar
124+
125+
The `applyPush` function will:
126+
1. Validate that the event's ownership chain traces back to the scope key
127+
2. Reject any mutations that attempt to change a different user's data
128+
3. Create changelog entries with the correct `scopeKey` for pull operations
129+
130+
### Next Steps
131+
132+
Once the push endpoint is working, implement the complementary pull endpoint to send server changes back to clients.
133+
134+
Continue to [Step 4: Pull Endpoint](./step-4-pull-endpoint).

0 commit comments

Comments
 (0)