Skip to content

feat(graphile-history): PostGraphile v5 history plugin (version reads + restore) - #1531

Merged
pyramation merged 4 commits into
mainfrom
feat/graphile-history
Jul 30, 2026
Merged

feat(graphile-history): PostGraphile v5 history plugin (version reads + restore)#1531
pyramation merged 4 commits into
mainfrom
feat/graphile-history

Conversation

@pyramation

@pyramation pyramation commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

New graphile/graphile-history PostGraphile v5 plugin — the GraphQL half of the table-history feature. It surfaces the <table>_history companions produced by the constructive-db DataHistory module (companion DB PR: constructive-io/constructive-db#2577) so clients can read a row's version stream, do point-in-time and time-window reads, and roll a row back.

The plugin discovers source tables via the @history smart tag (same mechanism graphile-i18n uses for @i18n) and, on each source row type, adds:

type Post {
  # ...existing fields
  history: [PostHistoryVersion!]!                       # full stream, recorded_at DESC
  versionAt(at: Datetime!): PostHistoryVersion          # version current at an instant
  versionsBetween(from: Datetime!, to: Datetime!): [PostHistoryVersion!]!  # window, newest-first
}

type Mutation {
  restorePostVersion(input: RestorePostVersionInput!): RestorePostVersionPayload
}
input RestorePostVersionInput { id: Int!, recordedAt: Datetime!, reinsert: Boolean }

Key design point — everything runs through the request's withPgClient + pgSettings (the pattern graphile-presigned-url / graphile-i18n use), so RLS and mutation policies are enforced identically to any other operation; there is no elevated path. restore semantics:

  • select the requested version (point-in-time: latest row at recorded_at <= recordedAt);
  • if the live row exists → UPDATE it to the version's values, skipping the PK and immutableColumns (default created_at/updated_at);
  • else if reinsert: true → re-INSERT it; otherwise no-op (returns the version, restored: null);
  • because the write goes through the source table, the source DataHistory trigger records the restore itself as a fresh version — no special-casing.

Composite primary keys are fully supported — the source PK comes from the resource's uniques and every field (history, versionAt, versionsBetween, restore predicates + the restore input args) iterates all PK columns, producing WHERE k1 = $1 AND k2 = $2 …. Tables with no PK are skipped (no history fields emitted). versionsBetween filters recorded_at BETWEEN from AND to (inclusive) on the same PK-keyed path, ordered newest-first — an index range scan against the companion (pk…, recorded_at) index added in the DB PR.

Discovery is resilient to how the smart tag surfaces: @history may be an explicit history-table name, a JSON object ({ "history_table": ... }), or a bare marker (then the history table is derived by historySuffix, default _history). Copied columns are computed as history_attrs ∩ source_attrs minus the reserved recorded_at/history_op. The PostHistoryVersion output type maps each copied column's pg codec to the matching GraphQL scalar (uuid→UUID, jsonb→JSON, timestamptz→Datetime, …), all nullable to mirror the zero-constraint history table.

Wiring

  • graphile-settings createConstructivePreset gains an enableHistory flag (off by default, mirroring enableI18n/enableBulk) that pushes HistoryPreset().
  • Package exports: HistoryPlugin, createHistoryPlugin, HistoryPreset, and the option/info types.

Note: plumbing a resolved_enable_history column through the database_settings cascade (like resolved_enable_i18n) is intentionally not in this PR — that needs a constructive-db schema change + SDK regen and is a follow-up. For now history is enabled via createConstructivePreset({ enableHistory: true }).

Tests

graphile/graphile-history/src/__tests__ (graphile-test + real Postgres, all green): @history discovery, history ordering (newest-first, 3 versions), versionAt point-in-time resolution, versionsBetween time-window (narrow → 1 version, wide → all 3 newest-first), restore-to-earlier-version (and verifying the restore appended a 4th version via the trigger), and reinsert of a deleted row. The package runs in the pg-graphql CI batch.

Link to Devin session: https://app.devin.ai/sessions/42ba449f34cb4a1687a53756f860af76
Requested by: @pyramation

Discovers @history tables (the constructive-db DataHistory companions) and adds:
- a `history` field on each source row type (version stream, recorded_at DESC)
- `versionAt(at: Datetime!)` point-in-time reads
- a `restore<Table>Version(input: { <pk>, recordedAt, reinsert })` mutation
  that rewrites the live row from a historical version (optionally reinserting
  a deleted row); the restore writes through the source table so the source
  trigger records it as a new version

All reads/writes go through withPgClient + pgSettings so RLS and mutation
policies are enforced. Wired into graphile-settings via an enableHistory flag
(off by default). Integration tests cover history ordering, versionAt, restore
update, and reinsert.
@pyramation pyramation self-assigned this Jul 30, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Replace the accidental full lockfile reserialization with a surgical addition
of only the graphile-history importer and the graphile-settings dependency
edge, so the diff is limited to the new package. Verified with
pnpm install --frozen-lockfile (pnpm 10).
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Golden-path verified end-to-end against real Postgres 18.4 + PostGraphile v5

Ran both integration suites and a full browser E2E through createConstructivePreset({ enableHistory: true }).

Suites

  • graphile-history4/4 passed (@history discovery, recorded_at DESC ordering, versionAt, restore update + reinsert, restore-appends-a-version).
  • data-history.test.ts (generator) — 5/5 passed (zero-constraint NEW-append table, exclusions, range-partition + partman metadata, idempotency, history-of-history guard).
Browser E2E over real HTTP (GraphiQL)
  • history → 3 versions newest-first: v3 UPDATE → v2 UPDATE → v1 INSERT
    history
  • versionAt(2024-02-15) → "Hello World v2" (UPDATE), not live v3 — point-in-time proven
    versionAt
  • restorePostVersion → restored title "Hello World v2", body "Body v2"
    restore
  • After restore → history grows to 4 entries, newest = restored v2 (recorded via source trigger)
    after restore
Scope note A single-process "generator-output → server" cross-repo pipeline was not stood up (heavy to provision). Coverage is split: the generator suite proves the produced table shape/semantics; the plugin suite + browser E2E prove the plugin against that same shape via the real `enableHistory` preset wiring.

@pyramation
pyramation merged commit 4087c18 into main Jul 30, 2026
16 checks passed
@pyramation
pyramation deleted the feat/graphile-history branch July 30, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant