Linking a Database to a playground
Data sheets are no longer stored inside the playground. Tables live in the standalone Data Sheets feature — a Database (team + app scoped) that holds sheets, columns, and rows. A playground attaches a Database and reads or writes its sheets at runtime; it no longer owns the rows itself.
Attaching the Database
- Open the playground editor and switch to the Data view in the editor toolbar.
- Pick a Database from the same app to link it (this sets the playground's
linked_database_id). Only databases in the playground's app are offered. - The Data view then lists the linked Database's sheets read-only for reference. Create databases, add sheets, edit columns, and manage rows in the Data Sheets section of the dashboard — see Creating a database and Managing sheets, columns, and rows.
A playground with no linked Database simply has no sheets to read — every flowfn.data.* call returns an empty result. Link one to wire data in.
The SDK still works the same way
Existing code keeps running unchanged. The flowfn.data.* API resolves against the linked Database instead of playground-local tables, but the method signatures, sheet slugs, row shapes, and the _id handle are identical. Migrated playgrounds that already linked their old sheets into a Database need no code edits.
Reading a sheet from JS
// list every sheet you've defined
flowfn.data.sheets() // → ['users', 'products']
// read all rows from one sheet
const rows = flowfn.data.get('users');
// rows is an array of plain objects keyed by column.key:
// [{ _id, name: 'Alice', age: 32 }, ...]
// inspect column definitions
flowfn.data.columns('users')
// → [{ key: 'name', name: 'Name', type: 'text' }, ...]
Paginating large sheets
flowfn.data.get(slug) only returns the rows currently cached in the iframe — for big sheets that's the first page (~500 rows), not the whole table. To find out whether there's more on the server, check flowfn.data.rowCount(slug) (the server's total as of the iframe load) against get(slug).length, and call await flowfn.data.fetch(slug) to page the rest into the cache:
const cached = flowfn.data.get('users');
const total = flowfn.data.rowCount('users');
const rows = cached.length < total
? await flowfn.data.fetch('users')
: cached;
flowfn.data.fetch resolves with the full row array (same shape as get) and de-duplicates concurrent calls for the same slug. rowCount is frozen at iframe-load time, so rows inserted later by other visitors or workflows won't show up until the iframe reloads.
Querying a sheet (server-side filter)
For large sheets, don't download everything and filter in the browser — query on the server and get back only the rows you need. Three helpers, all returning rows in the same shape as get:
flowfn.data.findRow(slug, where)— the first matching row, ornull. Perfect for a detail page that reads?id=.flowfn.data.findRows(slug, where, opts?)— all matching rows;optstakessort/limit/offset.flowfn.data.query(slug, { where, or, sort, limit, offset })— full control; resolves with{ items, total }.
// detail page: fetch just the one product
const id = flowfn.location.query.id;
const product = await flowfn.data.findRow('products', { id: id });
// filtered + sorted list
const top = await flowfn.data.findRows('products',
{ status: 'active' },
{ sort: { rating: 'desc' }, limit: 10 });
// complex query
const { items, total } = await flowfn.data.query('products', {
where: { price: { gte: 10, lt: 100 }, name: { contains: 'pro' } },
or: [{ featured: true }, { rating: { gte: 4 } }],
sort: { price: 'asc' }, limit: 25,
});
where operators: a bare value means equals; or use an operator object — eq, ne, gt / gte / lt / lte (number & date columns), in / nin (arrays), contains / startsWith (text columns), and exists (true/false). Multiple keys are AND-ed; use or for alternatives. Values are matched against the column's type, and limit is capped (default 50). Server code can run the same query with await ctx.sheets.query(slug, { where, ... }). The full DSL is documented in Managing sheets, columns, and rows.
Editing rows and importing CSV
The grid editor, the CSV import/export, the typed cell editors, and the Recently deleted restore panel all live in the Data Sheets feature now — open the linked Database there to manage its rows. The playground's Data view is a read-only mirror so you can confirm which sheets and columns your code will see. See Managing sheets, columns, and rows.
Writes from public visitors
Each sheet has three opt-in toggles on the Database sheet — Allow inserts, Allow updates, Allow deletes (the allow_public_* flags). All three default to off, so sheets stay read-only from the iframe SDK unless the team explicitly enables each operation. Public writes are also plan-gated and only flow through a published playground that links the Database — see Public sheet writes for the full gate.
Once enabled the corresponding flowfn.data.* methods become callable from the public playground page:
// Append a row from a visitor's form input
await flowfn.data.insert('users', { name: 'Alice', age: 32 });
// Edit a row by id (the id is returned by insert / present on every flowfn.data.get row)
await flowfn.data.update('users', row._id, { age: 33 });
// Remove a row
await flowfn.data.delete('users', row._id);
No per-row ownership. When updates or deletes are enabled, any visitor with the playground link can mutate any row. If you need authenticated-per-user semantics, keep updates / deletes off and route mutations through a workflow trigger or a fetchData entry that you've gated server-side.
Deletes are recoverable. A visitor flowfn.data.delete is a soft delete — the row drops out of all reads immediately, but you, the owner, can restore it from Recently deleted on the sheet in the Data Sheets feature within the 90-day window. There is no public/SDK restore.
Disabled writes fast-fail in the SDK with err.code === 'sheet_write_disabled' before any server round-trip.
Sheet-full ("sheet_full") errors
Each sheet has a per-plan row cap (plan-tuned via plan.settings.max_data_sheet_rows). When a public insert tries to add a row at the cap, the SDK rejects with err.code === 'sheet_full' — handle this in user JS to show a friendly message and stop hammering the server.
Limits
Limits (databases per app, sheets per database, rows per sheet, cell sizes) are documented with the feature in Managing sheets, columns, and rows. The row cap is plan-tuned (max_data_sheet_rows); the structural ceilings are fixed defensive limits.