# graview-sync

`graview-sync` is one of Graview's 31 authoring skills: instructions an AI coding assistant such as Claude Code or Codex reads before it changes a Graview app. What it teaches: Keep a Graview app in step with another system — a graph projected from it as a snapshot, a SyncEngine that sends this app's acts back and holds a field both sides changed, a remote that speaks MCP behind a door on the dev server, and a Google calendar or a Notion database declared as a connection — and verify the first run sends nothing and a conflict is held rather than assuming the two agree.

Installed into a project by `graview skills install .`, and read by whichever assistant is working beside you.

## The skill

## Keep an app in step with another system

Often the records already live somewhere: a tracker, an analysis, a calendar. The app is then a projection of them that people act on, and every act has to reach the system that owns the record — or be refused, in a sentence, where that system owns it. Storing, migrating, serving and exporting the app itself is `graview-ship`.

### Do this

1. **Project the other system as a snapshot.** Write the projection with `writeSnapshot(graph)` from `@graview/core/snapshot`: a header line (`generatedAt`, records per kind, links per kind, where each kind's lines are), then a record per line. `readSnapshot(text, { kinds })` parses only those kinds, and `fetchSnapshot(url, { kinds })` fetches only their bytes; an old `{ nodes, edges }` file still reads, and `--seed` takes either. Open it with `openStore({ seed, storeOptions: { principal: rememberedPrincipal() } })` so the page's acts are its reader's.

1. **Sync back with `SyncEngine`** from `@graview/core/sync`:

```ts const sync = new SyncEngine({ store, remote, applyInbound, conflicts: "report", mapping: { system: "n-dx", resources: [ { kind: "task", resource: "task", fields: { status: "status" }, version: "lastModified" }, { kind: "zone", resource: "zone", fields: {}, readonly: true, because: "Sourcevision owns the zones." }, ] } }); sync.guard(); // an act on a zone is not offered, and refused, with that sentence ```

`version` links every projected record at the version it was made from, so the first run sends nothing. `report` holds a field both sides changed and sends neither until `resolve`; `theirs` lands theirs through `applyInbound`; `ours` sends ours — `report.resolved` says which it took. A refused delete is said once; one made there goes through `removeInbound`, or, without it, stays here unsynced (`report.gone`).

1. **A remote that speaks MCP** is `mcpRemote({ call, pull, push })`: `call` is `mcpClient({ url, allow })` in Node, or `doorClient()` in a page behind `mcpDoor({ call, allow, admit: fromThisApp })` on the dev server (`fromThisApp`, `openDoors` from `@graview/ship/dev`) — the door holds the token, takes only JSON from its own origin on this machine (`hosts` names others), from the page opened at the address the dev server printed, and only the allowed tools. It knows no seat: never put it where sight is a boundary.

1. **Keep it in step with a Google calendar by declaring it.** A document's `connections.<name>`: `system: "google-calendar"`, `calendar` (or `{ env }`), `kind`, `fields` (`summary`, `start`, `end`, `location`, `description`; a `date` field is an all-day day; one with `repeats: true` is a recurring event, its repeat the RRULE and each day set apart an EXDATE, kept as a wall time in `brand.timeZone`; with `days` — an enum of weekdays or a list holding `mon`…`sun` — the record is weekly and `start`/`end` are `"HH:MM"` strings there, one event with a weekly rule from the week it is first sent; a one-off or one week's exception in Google is not a record here), `filter` (`{ color: "Basil" }` or `{ tag: "#graview", keep? }`, absent for all), `direction` (`"read"` refuses acts on the kind; `"both"`), `conflicts`, `every`, `paused`. `serve` and `preview` run it with `GOOGLE_CLIENT_ID`/`_CLIENT_SECRET`/`_REFRESH_TOKEN` (or `auth: { env, file }`), the cursor in `connections.json` beside the data; `--no-sync` leaves it off. A host calls `runConnections({ app, store, state, token, onReport })`: each pass's reports, whole; `firstPass`.

**"Invite my email on events I'm on."** `fields.guests` is a field of addresses (a `list`, or one `email`), or `{ "via": "who" }`, the relation to the people: each person's `emails` is invited while their `inviteMe` is on (`emails` and `invite` name other fields; `invite: false` says the app has no toggle). The recipe:

1. Ask for the addresses; never guess them. 2. Set `emails` and `inviteMe` on that person's record (`add-field` a list of strings and a boolean on the people kind first if it lacks them). 3. Make sure the synced kind has a relation to the people (`add-relation` if not) and link the records already there (a run's driver → its event's who). 4. Put `guests: { via: "<relation>" }` on the connection (`set-connection`). 5. Say the default is quiet — no email is sent (`sendUpdates=none` on every write); `notify: true` on `guests` switches on Google's invitations. 6. Never invite anyone without Invite me; the framework refuses to: an attendee matching them is held as an address, never linked, and one matching nobody is kept and sent back as it was. 7. `"max": 20` on `guests` holds an event over it unsent; a field's form takes it as `{ "field": "<field>", "max": 20 }`. 8. Check: `report.guests`, the history line, `guestsOf`.

1. **Keep it in step with a Notion database by declaring it.** `system: "notion"`, `database` (its 32-character id, or `{ env }`), `source` when the database holds several data sources (name or id), `kind`, `properties` (field → property name, or `{ name, type }` for the checker to judge the type; absent, each field finds the property of its name and the name field the title), `direction`, `conflicts`, `every`, `paused`. The secret is an internal integration's, read from `NOTION_TOKEN` (`<auth.env>_TOKEN`) or handed by the host's `token`; share the database with the integration. Title and rich text, number, select and status (an `enum`), multi-select (a `list`), date (`date` or `datetime`), checkbox, URL, email and phone go both ways; people are read as names; formulas, rollups and files are left alone and said once. A page archived there is gone here, and one removed here is archived there. A mapped property the source lacks is made on the first pass (`report.said`), never renamed or deleted (`create: false`: none). A computed field may be mapped: sent, never read back. `scriptedNotion()` is a Notion in memory to test against.

What a change there lands through, credited to the system: a page or event made there becomes a record through `add-<kind>`, or else the first act that creates the kind with no `on` and requires only mapped fields — never one that logs it on another record. When none fits, `connection-creates` says so in the person's words ("Blocks need a Type, and Google events don't have one.") with `fixes` a host offers as buttons — a default, the field left blank, an event part mapped, another kind, a kind made for events — each with its edits (`set-default`, `set-required`, `set-connection`, `add-kind`) where one can be written, and the framework's sentence as `details`; a change lands through the derived `edit-<kind>`, which writes every mapped field even where a named step (a status moved along) writes it too, so declare no `edit-<kind>` of your own unless it writes them all (`connection-edit`). One this store refuses (a blank row) is said once, kept as `skipped`, and tried again when it changes there.

1. **Several kinds: `sources` in one database, or one connection per database; relations as edges.** `"sources": [{ "source": "Tasks", "kind": "task", "properties"? }, …]` in place of `source`, `kind`, `properties`. Declare the edge on the kind (`"reindeer": { "to": ["reindeer"], "cardinality": "one" }`) and map it to the relation property in `properties` (`"reindeer": "Reindeer Fleet"`, or `{ "name", "type": "relation" }`); the kind it reaches must be kept by a Notion source. A link in Notion lands through `connect-<kind>-<edge>` / `disconnect-…`. A page not read yet is held and linked once its table is, whatever order the connections run in (`runConnections` shares one `sharedLinks()`; a host making engines itself passes one `links` to every `connection()`). A relation of one keeps the first page and says the rest once. Declare a dual relation on either side or both; an edge conflicts like a field. Each sentence of `report.said` names its kind; `report.saidBy` holds `{ kind, sentence }`.

### Then find out whether it worked

```sh
npx graview check --document app.json
```

`graview check` judges a connection before anything is sent: a kind or a field it does not have (`connection-kind`, `connection-field`), a field of the wrong type for the event's part, a start and an end that are not both days or both moments (`connection-when`), a filter it cannot read (`connection-filter`, `connection-color`, `connection-tag`), guests in a field that holds no addresses or through a relation that reaches no people (`connection-guests-field`, `connection-guests-via`, each offering the `add-field` that fixes it), and it warns `connection-remove` when no act removes the kind, so an event deleted in the calendar would stay here. For Notion it refuses a secret written in the declaration (`connection-secret`), a field that cannot hold the property's type, a relation property mapped to a field or another type mapped to an edge (`connection-relation`), an edge to a kind no Notion source keeps (`connection-relation-target`), and two sources for one kind (`connection-sources`); it warns a people property is only read (`connection-read-only`). Report what it said; a host's line says why it could not reach the other side (`report.problem`).

Then run the engine against a scripted remote in a test: the first run after a projection sends nothing; a field changed on both sides is in the report and sent by neither until `resolve`; an act on a read-only kind is not offered and is refused with its `because`. Against Google itself, `node scripts/verify-google-calendar.mjs` (credentials of your own, never in CI) writes its claims to `docs/google-calendar.json`, and `node scripts/verify-notion.mjs` does the same against a Notion workspace (`docs/notion.json`; `--stub` against the scripted one).

### What the check cannot see

- Whether the mapping is the right one: a field mapped to the wrong field on the far side syncs faithfully and wrongly. Change one record on each side by hand and read both.
- Whether `theirs` or `ours` is the policy the people on both sides would choose. `report` is the one that asks them.
- Whether the door's `allow` is narrow enough. It is the token's reach on this machine; name the tools the sync calls and no others.

---

Keep a Graview app in step with another system — a graph projected from it as a snapshot, a SyncEngine that sends this app's acts back and holds a field both sides changed, a remote that speaks MCP behind a door on the dev server, and a Google calendar or a Notion database declared as a connection — and verify the first run sends nothing and a conflict is held rather than assuming the two agree.

The page: https://graview.dev/docs/skills/graview-sync.html · Every Graview docs page, for a model: https://graview.dev/llms.txt
