Skip to content

docs: add Postgres + D1 session persistence guides#87

Open
ketankhairnar wants to merge 4 commits intowithastro:mainfrom
ketankhairnar:feat/persist-postgres-recipe
Open

docs: add Postgres + D1 session persistence guides#87
ketankhairnar wants to merge 4 commits intowithastro:mainfrom
ketankhairnar:feat/persist-postgres-recipe

Conversation

@ketankhairnar
Copy link
Copy Markdown
Contributor

@ketankhairnar ketankhairnar commented May 8, 2026

Two parallel session-persistence guides for Flue's two main deploy targets, both implementing the existing SessionStore contract — no SDK changes.

What this adds

  • docs/persist-postgres.md — Node deploys (Render, Fly, Railway, EC2) where the in-memory default loses state on restart. The existing docs/deploy-node.md "Session persistence" section already gestures at this ("You can back this with any database: SQLite, Postgres, Redis, etc.") but had no worked example; this fills that gap.
  • docs/persist-d1.md — Cloudflare deploys where you want sessions queryable from outside the agent process (admin dashboards, separate UI Workers, BI exports). Complements the default DO-SQLite store rather than replacing it.

Each guide ships:

  • A copy-pasteable store function (~50 lines) that adapts a user-owned client/binding to SessionStore.
  • The minimal CREATE TABLE schema, with data treated as opaque so Flue can evolve SessionData without breaking deployed schemas.
  • A "Schema choices" section calling out two real alternatives that other coding-agent CLIs converged on — append-log + index (Claude Code, Codex, OpenCode 1.2+) and a hot/cold split — with use-when guidance.
  • Concurrency notes (last-writer-wins; sticky-routing or app-level locks for multi-instance workloads).
  • Worked example agents under examples/hello-world.
flowchart LR
  Node["Node deploy<br/>(Render / Fly / Railway / EC2)"] --> PG[(Postgres<br/>persistPostgres)]
  CF["Cloudflare deploy"] --> DO[(Default<br/>DO-SQLite)]
  CF --> D1[(D1<br/>queryable<br/>persistD1)]
  PG -. "same SessionStore<br/>contract" .-> Flue
  DO -. "same SessionStore<br/>contract" .-> Flue
  D1 -. "same SessionStore<br/>contract" .-> Flue
Loading

Conventions matched

  • D1 store accepts db: unknown and casts internally — matches getVirtualSandbox(bucket: unknown) (packages/sdk/src/cloudflare/virtual-sandbox.ts). No @cloudflare/workers-types required to typecheck the recipe.
  • No new connectors/ category. The README explicitly asks contributors not to add categories without prior discussion, so the persistence work lives in docs/ and examples/ instead — same shape as docs/connect-daytona.md.
  • Imports use @flue/sdk (bare) — matches Fred's with-request.ts and the README support-agent example, not @flue/sdk/client.

What I'm not changing

  • The SessionStore interface itself — these recipes work with today's blob-overwrite shape. Filing a separate issue surfacing the append-log pattern that Claude Code, Codex, and OpenCode 1.2+ converged on, with a small opt-in saveDelta hook proposal — that's an architectural conversation, separate from these recipes.
  • The default Cloudflare DO-SQLite store. D1 is offered as an alternative for specific use cases (queryability across Workers / external tooling), not a replacement.
  • The default Cloudflare DO-SQLite store schema. After hardening, the D1 recipe schema (id TEXT, data TEXT, updated_at INTEGER) intentionally matches what the existing createDOStore in build-plugin-cloudflare.ts:205 creates — same column names, same types — so future tooling can move rows between the two without translation.

The example workspace gains pg and @types/pg as deps (matching the precedent of @daytona/sdk for with-sandbox.ts).

Verification

  • pnpm --filter @flue/sdk build
  • pnpm --filter @flue/cli build
  • flue build --target node on the example workspace registers both new agents ✓
  • npx tsc --noEmit clean across the example workspace ✓
  • Lint warnings same shape as existing example agents (no new errors)

Live smoke against a real Postgres / D1 — Docker daemon wasn't running locally; the SQL is canonical INSERT ... ON CONFLICT DO UPDATE mirroring InMemorySessionStore's shape. Happy to verify with a docker-compose run if maintainers want it as part of review.

Files

File Purpose
docs/persist-postgres.md Postgres guide (252 lines)
docs/persist-d1.md D1 guide (158 lines)
examples/hello-world/.flue/persist/postgres.ts postgresStore(client, options?)
examples/hello-world/.flue/persist/d1.ts d1Store(db, options?)
examples/hello-world/.flue/agents/with-postgres-persist.ts Example agent (webhook)
examples/hello-world/.flue/agents/with-d1-persist.ts Example agent (webhook)
examples/hello-world/package.json Adds pg and @types/pg
docs/deploy-node.md +1 line linking to Postgres guide
docs/deploy-cloudflare.md +1 line linking to D1 guide

Cross-context: this builds on the conversation in #52 — when flue.config.ts lands with a persist field, both stores work zero-migration via flue.config.ts { persist: postgresStore(client) } because the API surface doesn't change.

@ketankhairnar ketankhairnar force-pushed the feat/persist-postgres-recipe branch 2 times, most recently from 4547547 to 6359cf2 Compare May 8, 2026 09:23
@ketankhairnar
Copy link
Copy Markdown
Contributor Author

Update on direction:

@FredKSchott replied on the architectural question over in discussion #89 and gave the green light for "session persistence as a flue add-discoverable connector category" — same shape as sandbox connectors, with flue add postgres etc. as the install path.

That changes this PR's natural home. The current diff puts the recipes under examples/hello-world/.flue/persist/ and the guides under docs/. The right shape now is connectors/persist--postgres.md and connectors/persist--d1.md under a new category: "persist", with the connectors index + flue add recognizing the category.

Two ways to land it:

  1. Restructure this PR to ship as category: "persist" connectors (move the recipe files, add the category to connectors/README.md and packages/cli/scripts/generate-connector-index.ts, keep the docs/ guides as how-tos that link to the connectors). Same total diff, organized for the destination Fred described.

  2. Keep this PR docs-only, file a follow-up that introduces the category + wires it through flue add.

(1) feels right — the connectors and the guides are coherent enough that splitting them adds friction without saving review time. Happy to do (2) if you'd rather review the category-introduction work in isolation.

Will hold off on more changes here until you weigh in. The current diff is still useful as docs-only if (1) is the wrong call.

@FredKSchott
Copy link
Copy Markdown
Member

definitely #1, thanks!

@ketankhairnar ketankhairnar force-pushed the feat/persist-postgres-recipe branch from 6359cf2 to 2851f68 Compare May 9, 2026 06:39
@ketankhairnar
Copy link
Copy Markdown
Contributor Author

Restructured per @FredKSchott's "definitely #1" — the recipes now ship as category: "persist" connectors, with the docs/ guides reduced to how-tos that link to them. Same total footprint, organized for the destination.

What's new on the branch (two commits on top of the original):

  1. feat(connectors): add persist category with postgres and d1

    • connectors/persist.md — generic category root, addressable as flue add <url> --category persist. Inlines the three-method SessionStore shape so the agent doesn't need to import the SDK to know what it's writing.
    • connectors/persist--postgres.mdflue add postgres
    • connectors/persist--d1.mdflue add d1
    • connectors/README.md — declares persist as a supported category and updates body conventions to cover both sandbox and persist shapes.
  2. docs(persist): align how-tos with sibling sandbox guides

    • docs/persist-postgres.md, docs/persist-d1.md — restructured to match docs/connect-daytona.md: prerequisites → install the connector → use → verify, with schema-choice analysis kept as reference material after verify rather than interleaved with setup.

Connector / doc / example consistency. The TS code block embedded in each connector is byte-identical to the example file at examples/hello-world/.flue/persist/<name>.ts, so the recipe and the runnable example don't drift. The schemas in the connector recipe and the docs guide are byte-identical too.

No CLI/runtime changes needed. packages/cli/scripts/generate-connector-index.ts is already category-agnostic — pnpm --filter @flue/cli prebuild picks up the new files without modification (10 connectors, 2 category roots).

Smoke-tested locally against a static file server (registry stand-in):

  • flue add postgres → returns the persist--postgres connector ✅
  • flue add d1 → returns the persist--d1 connector ✅
  • flue add https://example.com --category persist → returns the generic persist root with {{URL}} substituted ✅

Aliases were dropped (pg, cloudflare-d1) — postgres and d1 are unambiguous, so the README's "use aliases sparingly" guidance applies.

Branch is rebased onto current main. Happy to split further or adjust shape if anything looks off.

@ketankhairnar
Copy link
Copy Markdown
Contributor Author

End-to-end proof

Built apps/www locally and ran the CLI against it as a registry stand-in.
The site's static-route generator (apps/www/src/pages/cli/connectors/[name].md.ts)
picks up the new connector files automatically — no website changes needed.

Build output

λ src/pages/cli/connectors/[name].md.ts
  ├─ /cli/connectors/d1.md
  ├─ /cli/connectors/postgres.md
  ├─ /cli/connectors/persist.md
  ├─ /cli/connectors/boxd.md
  ├─ /cli/connectors/cloudflare.md
  ├─ /cli/connectors/daytona.md
  ├─ /cli/connectors/e2b.md
  ├─ /cli/connectors/exedev.md
  ├─ /cli/connectors/islo.md
  ├─ /cli/connectors/modal.md
  ├─ /cli/connectors/vercel.md
  └─ /cli/connectors/sandbox.md

CLI ↔ registry round-trip

$ FLUE_REGISTRY_URL=http://127.0.0.1:4322/cli/connectors flue add postgres --print | head -3
# Add a Flue Connector: Postgres Session Store

You are an AI coding agent installing the Postgres session-store connector

$ FLUE_REGISTRY_URL=http://127.0.0.1:4322/cli/connectors flue add d1 --print | head -3
# Add a Flue Connector: Cloudflare D1 Session Store

You are an AI coding agent installing the Cloudflare D1 session-store

$ flue add https://www.postgresql.org/docs --category persist --print | head -10
# Generic Persist Connector

## Goal

You are an AI coding agent being asked to build a Flue **persist** connector
for a database that Flue does not have a built-in recipe for. The deliverable
is one file in the user's project that exports a `SessionStore` for the
backend, satisfying Flue's published contract.

There's no fixed procedure for getting there — your backend's shape (typed

Listing

$ flue add
flue add <name>

Available connectors:
  flue add d1             persist     https://developers.cloudflare.com/d1
  flue add postgres       persist     https://www.postgresql.org
  flue add boxd           sandbox     https://boxd.sh
  flue add cloudflare     sandbox     https://developers.cloudflare.com/containers
  flue add daytona        sandbox     https://daytona.io
  flue add e2b            sandbox     https://e2b.dev
  flue add exedev         sandbox     https://exe.dev
  flue add islo           sandbox     https://islo.dev
  flue add modal          sandbox     https://modal.com
  flue add vercel         sandbox     https://vercel.com/sandbox

@ketankhairnar ketankhairnar force-pushed the feat/persist-postgres-recipe branch from 2851f68 to 1b1235b Compare May 9, 2026 19:47
ketankhairnar added a commit to ketankhairnar/flue that referenced this pull request May 10, 2026
Adds `SessionDelta` interface and optional `saveDelta?` method on
`SessionStore`. Adapters that implement it persist O(delta) per turn
instead of O(history). Existing implementations (in-memory, Cloudflare
DO) work unchanged because the method is optional.

The hook is non-breaking: if `saveDelta` is absent, the SDK falls
through to the existing `save(full)` path. If present, the SDK passes
only entries appended since the last save.

`SessionDelta` carries `{ newEntries, leafId, metadata }`. No
`supersedes` field in v1 — `appendCompaction` is append-only (it pushes
a new `CompactionEntry` without mutating prior history) so the
recipe-side adapter recovers prior entries from its own delta records on
load(). A `supersedes` field can be added later non-breakingly if a
future entry kind needs it.

Closes the SDK-side ask in withastro#89 (the recipe-side restructure landed as
part of withastro#87).
Two parallel session-persistence guides for Flue's two main deploy
targets, both implementing the existing SessionStore contract:

- docs/persist-postgres.md - Node deploys (Render, Fly, Railway, EC2)
  where the in-memory default loses state on restart.
- docs/persist-d1.md - Cloudflare deploys where you want sessions
  queryable from outside the agent process (admin dashboards, separate
  Workers, BI exports). Complements the default DO-SQLite store rather
  than replacing it.

Each guide ships:

- A copy-pasteable store function (~50 lines) that adapts a user-owned
  client/binding to SessionStore.
- The minimal CREATE TABLE schema, with `data` treated as opaque so
  Flue can evolve SessionData without breaking deployed schemas.
- A "Schema choices" section calling out the append-log + index pattern
  Claude Code, Codex, and OpenCode 1.2+ converged on for long sessions,
  plus a hot/cold split, with use-when guidance.
- Concurrency notes (last-writer-wins; sticky-routing or app-level
  locks for multi-instance workloads).
- Worked example agents under examples/hello-world.

D1 schema and the Cloudflare DO-SQLite store flue ships by default now
share the same column types (id TEXT, data TEXT, updated_at INTEGER)
so a future migration tool could move rows between them without
translation.

D1 store accepts `db: unknown` and casts internally, matching the
convention `getVirtualSandbox(bucket: unknown)` already uses.

Cross-linked from docs/deploy-node.md and docs/deploy-cloudflare.md.
Introduce a new connector category for session-store backends, alongside
the existing sandbox category. Two named connectors ship initially:
postgres and d1, both single-blob shape (one row per session, the entire
SessionData blob in one column, rewritten on every save).

Files:
  connectors/persist.md           — generic category root, addressable as
                                    `flue add <url> --category persist`
  connectors/persist--postgres.md — `flue add postgres`
  connectors/persist--d1.md       — `flue add d1`

Each named connector follows the same section structure as the sandbox
siblings: What this connector does → Where to write the file → File
contents → Required dependencies → Credentials → Schema → Wiring it into
an agent → Verify. The TS code block in each is byte-identical to the
example file that lives at examples/hello-world/.flue/persist/<name>.ts,
so the recipe and the example don't drift.

The connectors/README.md table is updated to declare persist as a
supported category and to document body conventions for both categories.

The CLI prebuild script (generate-connector-index.ts) requires no
changes — it's already category-agnostic. Smoke-tested against a local
file server: `flue add postgres`, `flue add d1`, and
`flue add <url> --category persist` all resolve and pipe correctly.
Restructure the Postgres and D1 persistence guides to match the shape
of the sibling sandbox how-tos (e.g. docs/connect-daytona.md):
prerequisites → install the connector → use the store → verify, with
deeper reference material (schema choices, concurrency, troubleshooting)
following the verify section instead of interleaving with setup.

The recipe content that was inlined into these guides previously now
lives in the connector files (connectors/persist--postgres.md,
connectors/persist--d1.md) and is fetched via `flue add postgres` /
`flue add d1`. The docs link to the connectors and stay focused on the
how-to: when you'd want this, what to install, how to verify it, what
to revisit when scaling.

Schema-choice analysis (single-blob vs append-log + index vs hot/cold
split) is preserved as reference material — it explains the trade-off
the connectors are making, not part of the install path.
@ketankhairnar ketankhairnar force-pushed the feat/persist-postgres-recipe branch from 1b1235b to 1a9ed4b Compare May 10, 2026 04:29
@ketankhairnar
Copy link
Copy Markdown
Contributor Author

Rebased onto current main (was 6 commits behind, conflict in connectors/README.md):

  • Conflict was the <workspace><root> rename from Treat .flue/ as a source folder #98 colliding with my persist-category prose addition. Resolved by keeping the persist semantics (per-category subdir guidance for connectors/ vs persist/) under the new <root> terminology — single line, no semantic loss.
  • 3 commits preserved as-is on top.

Verification re-ran clean:

  • pnpm --filter @flue/sdk build
  • pnpm --filter @flue/cli build
  • npx tsc --noEmit on the example workspace — zero errors in any of the 4 persist files (postgres.ts, d1.ts, with-postgres-persist.ts, with-d1-persist.ts). One pre-existing error in with-sandbox.ts is unrelated to this PR (present on upstream/main directly; from a sibling refactor).

Sibling-PR note: #95 (the saveDelta? SDK hook) was also rebased onto main today and is CLEAN / MERGEABLE. Once that lands, I'll file a small follow-up appending an "Optional: append-log shape with saveDelta" section to connectors/persist--postgres.md — keeping that out of #87 so this PR stays focused on the recipe shape contributors actually need today.

@ketankhairnar
Copy link
Copy Markdown
Contributor Author

Dry-run update for this branch:

Used a real Postgres 16 Docker container on a unique local port:

docker run -d --rm --name flue-pg-smoke-55432 \
  -e POSTGRES_PASSWORD=flue \
  -p 127.0.0.1:55432:5432 \
  postgres:16

Readiness check passed:

/var/run/postgresql:5432 - accepting connections

What passed:

  • pnpm --filter @flue/sdk build
  • pnpm --filter @flue/sdk check:types when rerun serially after build
  • pnpm --filter @flue/cli build
  • node packages/cli/dist/flue.js add
    • listed d1 and postgres under the new persist category
    • listed generic flue add <url> --category persist
  • Live Postgres adapter CRUD smoke against examples/hello-world/.flue/persist/postgres.ts
    • created flue_sessions
    • saved a SessionData blob
    • loaded it back
    • overwrote with another entry + metadata update
    • loaded updated data
    • deleted the session
    • confirmed load() returned null
  • Live Postgres + Anthropic resume smoke with with-postgres-persist
    • turn 1 saved a nonce into session llm-pg-smoke-2
    • turn 2 used the same persisted session id/thread id and recalled the nonce
    • Postgres row had 4 entries and a non-null leafId
  • D1 adapter smoke against examples/hello-world/.flue/persist/d1.ts
    • used an in-process SQLite/sql.js D1-like wrapper with prepare().bind().first()/run()
    • covered save/load/update/delete/null-after-delete
  • Selected persist files typechecked cleanly:
    • .flue/persist/postgres.ts
    • .flue/persist/d1.ts
    • .flue/agents/with-postgres-persist.ts
    • .flue/agents/with-d1-persist.ts

Live Postgres smoke result:

{"id":"smoke-1778389552223","savedEntries":2,"deleted":true}

D1-like smoke result:

{"id":"d1-smoke-1778389576157","savedEntries":1,"deleted":true}

Live LLM resume smoke:

Turn 1:

{
  "threadId": "llm-pg-smoke-2",
  "response": "OK"
}

Turn 2:

{
  "threadId": "llm-pg-smoke-2",
  "response": "FLUE-CERULEAN-55432"
}

Postgres inspection included:

agent-session:["llm-pg-smoke-2","llm-pg-smoke-2"]|4|9a39deab

Notes / caveats:

  • The full examples/hello-world typecheck still fails on the known unrelated upstream issue:
.flue/agents/with-sandbox.ts(13,29): error TS2554: Expected 1 arguments, but got 2.
  • An initial concurrent SDK build + typecheck produced bogus TS6053 missing dist/* errors because the build cleaned dist/ while TypeScript was reading it. Serial rerun passed.
  • The D1 smoke was SQLite/D1-compatible adapter coverage, not a live Wrangler/D1 binding test.
  • Anthropic key was provided interactively for the smoke, read via stdin with terminal echo disabled, and not written to disk.
  • The local shell used Node v22.17.1; the repo packages request >=22.18.0, so pnpm printed engine warnings.
  • The Postgres container was stopped after the smoke.

Remaining review concern from the dry run: the single-blob recipes round-trip correctly. This does not prove the append-log guidance is safe against the current SDK history-deletion path during overflow recovery.

@ketankhairnar
Copy link
Copy Markdown
Contributor Author

ketankhairnar commented May 10, 2026

Follow-up fix pushed to ketankhairnar/flue@39a5101 (fix(docs): harden persist recipes).

What changed after the review pass:

  • Moved the D1 worked example from examples/hello-world to examples/cloudflare, where the Cloudflare target and Wrangler bindings actually apply.
  • Added .flue/**/*.ts to examples/cloudflare/tsconfig.json; TypeScript was previously seeing zero inputs there because .flue/ is hidden.
  • Added a runtime shape guard to d1Store(db: unknown) so a missing/misnamed binding fails with a Flue-specific error instead of crashing later on .prepare.
  • Corrected local D1 setup docs/connector instructions: run flue build --target cloudflare, then run the local schema command against dist/wrangler.jsonc, which is the config Flue passes to Wrangler. Also corrected local dev curl examples to port 3583.
  • Replaced unsafe append-log guidance with authoritative-snapshot reconciliation wording: append-log adapters must insert new ids and remove/tombstone entries absent from the latest SessionData.
  • Replaced the Postgres verification query that reached into data->'entries' with pg_column_size(data), keeping SessionData opaque as documented.

Verification was not dry-run-only:

  • Actual Postgres 16 Docker container on 127.0.0.1:55432, with flue_sessions schema created and rows inspected.
  • Live Postgres adapter CRUD smoke against examples/hello-world/.flue/persist/postgres.ts
  • Actual with-postgres-persist agent run twice through flue run using Anthropic claude-haiku-4-5: first turn stored nonce FLUE-CERULEAN-55432, second turn resumed the same persisted session and returned that nonce. The Postgres row had 4 entries and a non-null leafId
  • Actual Wrangler/D1 path: authenticated Wrangler, created a temporary remote D1 database (flue-d1-smoke-20260510112922, APAC, deleted after), ran flue dev --target cloudflare against Flue's generated dist/wrangler.jsonc, initialized the local D1 schema against that generated config, then verified two real requests persisted/resumed FLUE-D1-APAC-55831 and inspected the D1 row ✅

Additional local verification:

  • pnpm --filter @flue/sdk build
  • pnpm --filter @flue/cli build ✅ (12 connectors, 2 category roots)
  • pnpm --dir examples/cloudflare exec tsc --noEmit --pretty false
  • node ../../packages/cli/dist/flue.js build --target cloudflare from examples/cloudflare ✅ (found with-cloudflare-binding, with-d1-persist)
  • Focused D1 adapter guard/save/load smoke from examples/cloudflare ✅ ({"ok":true,"calls":5})
  • pnpm --dir examples/hello-world exec tsc --noEmit --pretty false still fails only the known upstream with-sandbox.ts(13,29) arity error.

ketankhairnar added a commit to ketankhairnar/flue that referenced this pull request May 11, 2026
Adds `SessionDelta` interface and optional `saveDelta?` method on
`SessionStore`. Adapters that implement it persist O(delta) per turn
instead of O(history). Existing implementations (in-memory, Cloudflare
DO) work unchanged because the method is optional.

The hook is non-breaking: if `saveDelta` is absent, the SDK falls
through to the existing `save(full)` path. If present, the SDK passes
only entries appended since the last save.

`SessionDelta` carries `{ newEntries, leafId, metadata }`. No
`supersedes` field in v1 — `appendCompaction` is append-only (it pushes
a new `CompactionEntry` without mutating prior history) so the
recipe-side adapter recovers prior entries from its own delta records on
load(). A `supersedes` field can be added later non-breakingly if a
future entry kind needs it.

Closes the SDK-side ask in withastro#89 (the recipe-side restructure landed as
part of withastro#87).
ketankhairnar added a commit to ketankhairnar/flue that referenced this pull request May 11, 2026
Adds `SessionDelta` interface and optional `saveDelta?` method on
`SessionStore`. Adapters that implement it persist O(delta) per turn
instead of O(history). Existing implementations (in-memory, Cloudflare
DO) work unchanged because the method is optional.

The hook is non-breaking: if `saveDelta` is absent, the SDK falls
through to the existing `save(full)` path. If present, the SDK passes
only entries appended since the last save.

`SessionDelta` carries `{ newEntries, leafId, metadata }`. No
`supersedes` field in v1 — `appendCompaction` is append-only (it pushes
a new `CompactionEntry` without mutating prior history) so the
recipe-side adapter recovers prior entries from its own delta records on
load(). A `supersedes` field can be added later non-breakingly if a
future entry kind needs it.

Closes the SDK-side ask in withastro#89 (the recipe-side restructure landed as
part of withastro#87).
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.

2 participants