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

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.