Vibecode SeaTable Cloud
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For SeaTable Cloud, manage structured records in grids, forms, and simple views. The hard boundary is managed collaboration, automations, plugins, storage, and support, plus data model flexibility, collaboration, and integrations.
You are building a lean indie version of SeaTable Cloud. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # SeaTable Cloud · indie build A self-hosted records database whose hard problems are letting people add and remove fields all year without destroying the physical schema, keeping filtered views fast once the data stops being a toy, and changing a field's type without quietly losing what was in it. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose | one command to a working instance is the difference between a tool and a project | | Physical schema | One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue | a real column per user-defined field puts you into the database's own limits within a year | | Indexes | Expression indexes created per field that people actually filter or sort on | a document column with no index is a sequential scan wearing a JSON hat | | Views | Grid, form, kanban and detail all generated from the same field catalogue | four views maintained separately drift, and the drift is always in the form | | Concurrency | A version number per record, checked on every write | two people editing the same row is the normal case, not the edge case | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - Why: Brings up the application and PostgreSQL together, which is the whole first-run experience. - Get it: Install Docker Desktop or Docker Engine with the compose plugin. - Verify: docker compose up brings the app to a login page - [ ] **PostgreSQL 16 or newer** · free locally - Why: The document column, the expression indexes and the conditional updates all rest on it, and the version matters for the indexing behaviour. - Get it: Through the compose file, or a managed instance. - Verify: A version query returns 16 or higher and an expression index can be created - [ ] **A realistic dataset of at least fifty thousand rows** · free - Why: Everything in this build is instant on two hundred rows, and every performance decision in phase two is invisible until you have real volume. - Get it: Generate it, or import a real export with the personal fields stripped. - Verify: A filtered view on that dataset returns in a time you would accept - [ ] **The field types you will support, written down** · free - Why: Each type is a parser, a renderer, a filter operator set and a conversion matrix, and the list grows faster than you expect. - Get it: Pick the smallest set that covers your actual tables, and write the conversion rules between them before building any. - Verify: Every type has documented conversions to and from every other - [ ] **Trash retention and revision depth** · free - Why: Both consume storage proportional to how much people edit, and both are useless if set to zero and expensive if set to forever. - Get it: Choose a retention period and a revision cap and write them into the configuration. - Verify: Both values are configured and enforced by a test ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. - managed collaboration, automations, plugins, storage, and support - real-time multiplayer - automation and integration breadth - scale and formula depth - enterprise permissions If one of those is essential to you, that is the reason to keep paying for SeaTable Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SeaTable Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small structured-records app with grid, form and kanban views over a schema you can change without writing a migration to replace SeaTable Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose · one command to a working instance is the difference between a tool and a project - Physical schema: One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue · a real column per user-defined field puts you into the database's own limits within a year - Indexes: Expression indexes created per field that people actually filter or sort on · a document column with no index is a sequential scan wearing a JSON hat - Views: Grid, form, kanban and detail all generated from the same field catalogue · four views maintained separately drift, and the drift is always in the form - Concurrency: A version number per record, checked on every write · two people editing the same row is the normal case, not the edge case ### Data model (create this before Phase 1) - bases(id, name, created_at) and tables(id, base_id, name, created_at) · the two levels of grouping, and no more than two - fields(id, table_id, key, display_name, type, options_json, position, created_at, deleted_at) · key is a short identifier generated once and never changed, display_name is the thing people rename twice a week - records(id, table_id, data, version, created_at, updated_at, deleted_at) · data is a JSON document keyed on field keys, and version is what makes a conditional update possible - revisions(record_id, version, data, changed_by, at) · a capped history per record, which is what makes a trash restore mean something - views(id, table_id, kind, filter_json, sort_json, group_field, visible_fields, created_at) - field_indexes(field_id, index_name, created_at, last_used_at) · which expression indexes exist and whether anything still uses them - api_tokens(id, base_id, name, scope, hashed_token, created_at, last_used_at, revoked_at) ### Phase 1 · A catalogue of fields, not a column per field Build: Store records as a JSON document keyed on short generated field keys. A field row carries the key, the display name, the type and its options. Renaming changes the display name only. Deleting is a soft delete that keeps the data in the document until a purge removes it. Trap: Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. Done when: Adding a hundred fields and removing ninety of them leaves the physical schema unchanged, tested by inspecting the catalogue before and after. Renaming a field changes no stored record, verified by comparing documents. Two fields with display names that differ only after the sixtieth character are both usable, tested. A soft-deleted field's data survives until an explicit purge and is restorable, tested. Do not build yet: No views, no type changes, no concurrency handling and no API yet. ### Phase 2 · Four views, one catalogue Build: Generate every view from the catalogue so a new field appears in all four without code. Translate a view's filter and sort into predicates over the document column. When a view filters or sorts on a field, create an expression index for it and record that you did. Trap: Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. Done when: Adding a field makes it available in all four views without a code change, tested. A view filtering on a field has a matching expression index, asserted by querying the catalogue and the database's own index list. A query plan for a filtered view uses that index rather than a sequential scan, checked by inspecting the plan on a table of at least fifty thousand rows. A kanban view groups by a single-select field and refuses to group by a free text field. Do not build yet: No type conversion, no concurrency and no API yet. ### Phase 3 · Changing a field's type without losing what was in it Build: A conversion runs first as a dry run over every record, reporting how many values convert cleanly, how many would change meaning and how many cannot convert at all. The change is refused while any value cannot convert, unless the user explicitly chooses to keep the originals in a shadow field. Trap: Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. Done when: A dry run reports clean, changed and failed counts with a sample of each before anything is written, tested with a mixed column. A conversion with any failing value is refused unless originals are preserved in a shadow field, tested. Converting and converting back returns the original values where preservation was chosen, tested. A conversion is recorded in the revisions of every record it touched. Do not build yet: No concurrency handling, no trash, no API and no import yet. ### Phase 4 · Two people, one record Build: Every write carries the version the client last read and updates conditionally on it. A stale version returns a conflict with the current record so the interface can show both. Deletion is a soft delete into a trash with a retention period and a restore that returns the record intact. Trap: Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. Done when: Two writes from the same read version produce one success and one conflict carrying the current record, tested concurrently. The conflict response is rendered as a choice rather than an error toast, checked by review. A soft-deleted record is restorable with its field values intact for the retention period, tested. Every accepted write appends a revision with the author and timestamp. Do not build yet: No API, no import and no backups yet. ### Phase 5 · A way in, a way out, and a way back Build: Generate a token-authorised REST surface from the catalogue, with tokens scoped to a base and revocable. Import CSV through an explicit column mapping step that previews types before writing. Export records as CSV and the schema as its own document. Take scheduled database dumps and test the restore in the suite rather than in a runbook. Trap: Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. Done when: A token scoped to one base cannot read another, tested. The import previews inferred types per column and requires confirmation before writing, and a column of identifiers with leading zeros survives as text, tested. Export produces CSV for records and a schema document, and importing both into an empty instance reproduces the tables, fields and views. A restore from a scheduled dump is exercised in the test suite and the row counts match. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time multiplayer with live cursors, which needs a presence protocol and operational transforms and is a project on its own rather than a feature of this one. - A full spreadsheet formula language, because compatibility with the long tail of functions is years of work and partial compatibility is worse than none. - Hundreds of automations and integrations, which is the maintained connector catalogue the subscription actually buys. - Running user-supplied scripts server-side, which turns a records app into a sandbox problem with a very different threat model. ### README must contain - That records live in one table as documents keyed on stable field identifiers, and why a column per field runs into the database's own limits - That a field rename changes only the display name, so external references keep working - That filtering a view creates an expression index, and how to see which indexes exist and which are unused - That a type conversion is dry run first and refuses to null a value it cannot convert - Where the data lives, how the scheduled dumps work, and that the restore is exercised by the test suite ===== AGENTS.md ===== # Agent instructions · SeaTable Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose, One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue, Expression indexes created per field that people actually filter or sort on, Grid, form, kanban and detail all generated from the same field catalogue, A version number per record, checked on every write. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ===== BUILD_PLAN.md ===== # Build plan · SeaTable Cloud A self-hosted records database whose hard problems are letting people add and remove fields all year without destroying the physical schema, keeping filtered views fast once the data stops being a toy, and changing a field's type without quietly losing what was in it. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A catalogue of fields, not a column per field Adding, renaming and removing a field is a row in a catalogue and never a change to the physical schema. ### Steps 1. Generate a short stable key per field Display names change; the key never does. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Keep records as one document column The alternative meets the database's own limits inside a year. Files: `src/db/migrations/001_records.sql` 3. Soft delete fields and purge separately Someone deletes a column on Friday and wants it back on Monday. Files: `src/schema/delete.ts` ### Done when - [ ] Adding and removing fields never alters the physical schema - [ ] Renaming a field changes no stored record - [ ] Long, similar field names do not collide - [ ] A deleted field's data is restorable before purge ### Watch out - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. ## Phase 2 · Four views, one catalogue Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast. ### Steps 1. Render every view from the catalogue Four hand-maintained views drift, and the form is always the stale one. Files: `src/views/render.tsx` 2. Create expression indexes when a view needs one The index has to exist before the table gets big. Files: `src/views/indexes.ts`, `tests/index-plan.test.ts` 3. Check the plan on a realistic row count Two hundred rows will not tell you anything. Files: `scripts/seed-large.ts` ### Done when - [ ] A new field appears in all four views automatically - [ ] Filtered fields have expression indexes - [ ] Query plans use the index on a large table - [ ] Kanban refuses to group on a free text field ### Watch out - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. ## Phase 3 · Changing a field's type without losing what was in it A type change is previewed, and anything that will not convert stops the change rather than becoming null. ### Steps 1. Dry run every conversion first You are about to rewrite a column somebody typed by hand. Files: `src/schema/convert.ts`, `tests/convert.test.ts` 2. Refuse rather than null A null is the loudest possible way to lose something quietly. Files: `src/schema/guard.ts` 3. Keep originals in a shadow field by default Reversibility is worth one extra column key. Files: `src/schema/shadow.ts` ### Done when - [ ] A dry run reports clean, changed and failed counts - [ ] Failing values block the conversion - [ ] Converting back restores the originals - [ ] Conversions appear in record revisions ### Watch out - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. ## Phase 4 · Two people, one record Simultaneous edits do not silently discard one of them, and a deletion is recoverable. ### Steps 1. Send the read version with every write Last write wins is a data loss policy with a friendly name. Files: `src/records/update.ts`, `tests/concurrency.test.ts` 2. Return the current record on conflict The user needs to see what they would have overwritten. Files: `src/records/conflict.ts` 3. Soft delete into a trash with retention Undo for the one action nobody means to take. Files: `src/records/trash.ts` ### Done when - [ ] Concurrent writes give one success and one conflict - [ ] The conflict shows the current record - [ ] Soft-deleted records restore intact - [ ] Every write appends a revision ### Watch out - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ## Not in this build - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. ## After v1, if you want it - A link field between tables, which is the first thing anyone asks for and the first thing that complicates every view - Row-level permissions by field value, once you know which tables people actually want to share - A saved import profile, so the monthly CSV from the same source does not need remapping every time ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole application state. This is the thing you back up. DATABASE_URL=postgres://app:...@db:5432/records # Required. Scheduled dumps land here. Copy them somewhere else as well. DUMP_DIR=./backups # Required. How many dumps to retain before the oldest is removed. DUMP_KEEP=14 # Required. How long a soft-deleted record stays restorable. TRASH_RETENTION_DAYS=30 # Required. Revisions kept per record. Storage grows with how much people edit. REVISION_CAP=50 # Required. A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. MAX_RECORDS_PER_TABLE=500000 # Required · secret. Mixed into stored token hashes. Changing it invalidates every token. API_TOKEN_PEPPER=base64:...redacted
You are building a lean indie version of SeaTable Cloud. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # SeaTable Cloud · indie build A self-hosted records database whose hard problems are letting people add and remove fields all year without destroying the physical schema, keeping filtered views fast once the data stops being a toy, and changing a field's type without quietly losing what was in it. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose | one command to a working instance is the difference between a tool and a project | | Physical schema | One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue | a real column per user-defined field puts you into the database's own limits within a year | | Indexes | Expression indexes created per field that people actually filter or sort on | a document column with no index is a sequential scan wearing a JSON hat | | Views | Grid, form, kanban and detail all generated from the same field catalogue | four views maintained separately drift, and the drift is always in the form | | Concurrency | A version number per record, checked on every write | two people editing the same row is the normal case, not the edge case | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - Why: Brings up the application and PostgreSQL together, which is the whole first-run experience. - Get it: Install Docker Desktop or Docker Engine with the compose plugin. - Verify: docker compose up brings the app to a login page - [ ] **PostgreSQL 16 or newer** · free locally - Why: The document column, the expression indexes and the conditional updates all rest on it, and the version matters for the indexing behaviour. - Get it: Through the compose file, or a managed instance. - Verify: A version query returns 16 or higher and an expression index can be created - [ ] **A realistic dataset of at least fifty thousand rows** · free - Why: Everything in this build is instant on two hundred rows, and every performance decision in phase two is invisible until you have real volume. - Get it: Generate it, or import a real export with the personal fields stripped. - Verify: A filtered view on that dataset returns in a time you would accept - [ ] **The field types you will support, written down** · free - Why: Each type is a parser, a renderer, a filter operator set and a conversion matrix, and the list grows faster than you expect. - Get it: Pick the smallest set that covers your actual tables, and write the conversion rules between them before building any. - Verify: Every type has documented conversions to and from every other - [ ] **Trash retention and revision depth** · free - Why: Both consume storage proportional to how much people edit, and both are useless if set to zero and expensive if set to forever. - Get it: Choose a retention period and a revision cap and write them into the configuration. - Verify: Both values are configured and enforced by a test ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. - managed collaboration, automations, plugins, storage, and support - real-time multiplayer - automation and integration breadth - scale and formula depth - enterprise permissions If one of those is essential to you, that is the reason to keep paying for SeaTable Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SeaTable Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small structured-records app with grid, form and kanban views over a schema you can change without writing a migration to replace SeaTable Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose · one command to a working instance is the difference between a tool and a project - Physical schema: One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue · a real column per user-defined field puts you into the database's own limits within a year - Indexes: Expression indexes created per field that people actually filter or sort on · a document column with no index is a sequential scan wearing a JSON hat - Views: Grid, form, kanban and detail all generated from the same field catalogue · four views maintained separately drift, and the drift is always in the form - Concurrency: A version number per record, checked on every write · two people editing the same row is the normal case, not the edge case ### Data model (create this before Phase 1) - bases(id, name, created_at) and tables(id, base_id, name, created_at) · the two levels of grouping, and no more than two - fields(id, table_id, key, display_name, type, options_json, position, created_at, deleted_at) · key is a short identifier generated once and never changed, display_name is the thing people rename twice a week - records(id, table_id, data, version, created_at, updated_at, deleted_at) · data is a JSON document keyed on field keys, and version is what makes a conditional update possible - revisions(record_id, version, data, changed_by, at) · a capped history per record, which is what makes a trash restore mean something - views(id, table_id, kind, filter_json, sort_json, group_field, visible_fields, created_at) - field_indexes(field_id, index_name, created_at, last_used_at) · which expression indexes exist and whether anything still uses them - api_tokens(id, base_id, name, scope, hashed_token, created_at, last_used_at, revoked_at) ### Phase 1 · A catalogue of fields, not a column per field Build: Store records as a JSON document keyed on short generated field keys. A field row carries the key, the display name, the type and its options. Renaming changes the display name only. Deleting is a soft delete that keeps the data in the document until a purge removes it. Trap: Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. Done when: Adding a hundred fields and removing ninety of them leaves the physical schema unchanged, tested by inspecting the catalogue before and after. Renaming a field changes no stored record, verified by comparing documents. Two fields with display names that differ only after the sixtieth character are both usable, tested. A soft-deleted field's data survives until an explicit purge and is restorable, tested. Do not build yet: No views, no type changes, no concurrency handling and no API yet. ### Phase 2 · Four views, one catalogue Build: Generate every view from the catalogue so a new field appears in all four without code. Translate a view's filter and sort into predicates over the document column. When a view filters or sorts on a field, create an expression index for it and record that you did. Trap: Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. Done when: Adding a field makes it available in all four views without a code change, tested. A view filtering on a field has a matching expression index, asserted by querying the catalogue and the database's own index list. A query plan for a filtered view uses that index rather than a sequential scan, checked by inspecting the plan on a table of at least fifty thousand rows. A kanban view groups by a single-select field and refuses to group by a free text field. Do not build yet: No type conversion, no concurrency and no API yet. ### Phase 3 · Changing a field's type without losing what was in it Build: A conversion runs first as a dry run over every record, reporting how many values convert cleanly, how many would change meaning and how many cannot convert at all. The change is refused while any value cannot convert, unless the user explicitly chooses to keep the originals in a shadow field. Trap: Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. Done when: A dry run reports clean, changed and failed counts with a sample of each before anything is written, tested with a mixed column. A conversion with any failing value is refused unless originals are preserved in a shadow field, tested. Converting and converting back returns the original values where preservation was chosen, tested. A conversion is recorded in the revisions of every record it touched. Do not build yet: No concurrency handling, no trash, no API and no import yet. ### Phase 4 · Two people, one record Build: Every write carries the version the client last read and updates conditionally on it. A stale version returns a conflict with the current record so the interface can show both. Deletion is a soft delete into a trash with a retention period and a restore that returns the record intact. Trap: Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. Done when: Two writes from the same read version produce one success and one conflict carrying the current record, tested concurrently. The conflict response is rendered as a choice rather than an error toast, checked by review. A soft-deleted record is restorable with its field values intact for the retention period, tested. Every accepted write appends a revision with the author and timestamp. Do not build yet: No API, no import and no backups yet. ### Phase 5 · A way in, a way out, and a way back Build: Generate a token-authorised REST surface from the catalogue, with tokens scoped to a base and revocable. Import CSV through an explicit column mapping step that previews types before writing. Export records as CSV and the schema as its own document. Take scheduled database dumps and test the restore in the suite rather than in a runbook. Trap: Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. Done when: A token scoped to one base cannot read another, tested. The import previews inferred types per column and requires confirmation before writing, and a column of identifiers with leading zeros survives as text, tested. Export produces CSV for records and a schema document, and importing both into an empty instance reproduces the tables, fields and views. A restore from a scheduled dump is exercised in the test suite and the row counts match. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time multiplayer with live cursors, which needs a presence protocol and operational transforms and is a project on its own rather than a feature of this one. - A full spreadsheet formula language, because compatibility with the long tail of functions is years of work and partial compatibility is worse than none. - Hundreds of automations and integrations, which is the maintained connector catalogue the subscription actually buys. - Running user-supplied scripts server-side, which turns a records app into a sandbox problem with a very different threat model. ### README must contain - That records live in one table as documents keyed on stable field identifiers, and why a column per field runs into the database's own limits - That a field rename changes only the display name, so external references keep working - That filtering a view creates an expression index, and how to see which indexes exist and which are unused - That a type conversion is dry run first and refuses to null a value it cannot convert - Where the data lives, how the scheduled dumps work, and that the restore is exercised by the test suite ===== AGENTS.md ===== # Agent instructions · SeaTable Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose, One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue, Expression indexes created per field that people actually filter or sort on, Grid, form, kanban and detail all generated from the same field catalogue, A version number per record, checked on every write. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ===== BUILD_PLAN.md ===== # Build plan · SeaTable Cloud A self-hosted records database whose hard problems are letting people add and remove fields all year without destroying the physical schema, keeping filtered views fast once the data stops being a toy, and changing a field's type without quietly losing what was in it. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A catalogue of fields, not a column per field Adding, renaming and removing a field is a row in a catalogue and never a change to the physical schema. ### Steps 1. Generate a short stable key per field Display names change; the key never does. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Keep records as one document column The alternative meets the database's own limits inside a year. Files: `src/db/migrations/001_records.sql` 3. Soft delete fields and purge separately Someone deletes a column on Friday and wants it back on Monday. Files: `src/schema/delete.ts` ### Done when - [ ] Adding and removing fields never alters the physical schema - [ ] Renaming a field changes no stored record - [ ] Long, similar field names do not collide - [ ] A deleted field's data is restorable before purge ### Watch out - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. ## Phase 2 · Four views, one catalogue Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast. ### Steps 1. Render every view from the catalogue Four hand-maintained views drift, and the form is always the stale one. Files: `src/views/render.tsx` 2. Create expression indexes when a view needs one The index has to exist before the table gets big. Files: `src/views/indexes.ts`, `tests/index-plan.test.ts` 3. Check the plan on a realistic row count Two hundred rows will not tell you anything. Files: `scripts/seed-large.ts` ### Done when - [ ] A new field appears in all four views automatically - [ ] Filtered fields have expression indexes - [ ] Query plans use the index on a large table - [ ] Kanban refuses to group on a free text field ### Watch out - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. ## Phase 3 · Changing a field's type without losing what was in it A type change is previewed, and anything that will not convert stops the change rather than becoming null. ### Steps 1. Dry run every conversion first You are about to rewrite a column somebody typed by hand. Files: `src/schema/convert.ts`, `tests/convert.test.ts` 2. Refuse rather than null A null is the loudest possible way to lose something quietly. Files: `src/schema/guard.ts` 3. Keep originals in a shadow field by default Reversibility is worth one extra column key. Files: `src/schema/shadow.ts` ### Done when - [ ] A dry run reports clean, changed and failed counts - [ ] Failing values block the conversion - [ ] Converting back restores the originals - [ ] Conversions appear in record revisions ### Watch out - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. ## Phase 4 · Two people, one record Simultaneous edits do not silently discard one of them, and a deletion is recoverable. ### Steps 1. Send the read version with every write Last write wins is a data loss policy with a friendly name. Files: `src/records/update.ts`, `tests/concurrency.test.ts` 2. Return the current record on conflict The user needs to see what they would have overwritten. Files: `src/records/conflict.ts` 3. Soft delete into a trash with retention Undo for the one action nobody means to take. Files: `src/records/trash.ts` ### Done when - [ ] Concurrent writes give one success and one conflict - [ ] The conflict shows the current record - [ ] Soft-deleted records restore intact - [ ] Every write appends a revision ### Watch out - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ## Not in this build - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. ## After v1, if you want it - A link field between tables, which is the first thing anyone asks for and the first thing that complicates every view - Row-level permissions by field value, once you know which tables people actually want to share - A saved import profile, so the monthly CSV from the same source does not need remapping every time ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole application state. This is the thing you back up. DATABASE_URL=postgres://app:...@db:5432/records # Required. Scheduled dumps land here. Copy them somewhere else as well. DUMP_DIR=./backups # Required. How many dumps to retain before the oldest is removed. DUMP_KEEP=14 # Required. How long a soft-deleted record stays restorable. TRASH_RETENTION_DAYS=30 # Required. Revisions kept per record. Storage grows with how much people edit. REVISION_CAP=50 # Required. A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. MAX_RECORDS_PER_TABLE=500000 # Required · secret. Mixed into stored token hashes. Changing it invalidates every token. API_TOKEN_PEPPER=base64:...redacted
You are building a production product version of SeaTable Cloud. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # SeaTable Cloud · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For SeaTable Cloud, manage structured records in grids, forms, and simple views. The hard boundary is managed collaboration, automations, plugins, storage, and support, plus data model flexibility, collaboration, and integrations. ## Product outcome A records app where a year of schema changes leaves the database untouched, a filtered view on real volume uses an index, a type change cannot silently null a column, and two people editing the same row both find out. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - self-hosted server - optional object storage for attachments ## Explicit non-goals for v1 - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. - managed collaboration, automations, plugins, storage, and support - real-time multiplayer - automation and integration breadth - scale and formula depth - enterprise permissions ## Success criteria - A year of field churn is confirmed to leave the physical schema unchanged - A filtered view on a large table is confirmed to use an expression index - A failing type conversion is confirmed refused rather than nulled - Concurrent writes are confirmed to produce a conflict, not a silent loss - A restore from a dump is confirmed in the test suite ===== BRIEF.md ===== # Build brief · SeaTable Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small structured-records app with grid, form and kanban views over a schema you can change without writing a migration to replace SeaTable Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose · one command to a working instance is the difference between a tool and a project - Physical schema: One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue · a real column per user-defined field puts you into the database's own limits within a year - Indexes: Expression indexes created per field that people actually filter or sort on · a document column with no index is a sequential scan wearing a JSON hat - Views: Grid, form, kanban and detail all generated from the same field catalogue · four views maintained separately drift, and the drift is always in the form - Concurrency: A version number per record, checked on every write · two people editing the same row is the normal case, not the edge case ### Data model (create this before Phase 1) - bases(id, name, created_at) and tables(id, base_id, name, created_at) · the two levels of grouping, and no more than two - fields(id, table_id, key, display_name, type, options_json, position, created_at, deleted_at) · key is a short identifier generated once and never changed, display_name is the thing people rename twice a week - records(id, table_id, data, version, created_at, updated_at, deleted_at) · data is a JSON document keyed on field keys, and version is what makes a conditional update possible - revisions(record_id, version, data, changed_by, at) · a capped history per record, which is what makes a trash restore mean something - views(id, table_id, kind, filter_json, sort_json, group_field, visible_fields, created_at) - field_indexes(field_id, index_name, created_at, last_used_at) · which expression indexes exist and whether anything still uses them - api_tokens(id, base_id, name, scope, hashed_token, created_at, last_used_at, revoked_at) ### Phase 1 · A catalogue of fields, not a column per field Build: Store records as a JSON document keyed on short generated field keys. A field row carries the key, the display name, the type and its options. Renaming changes the display name only. Deleting is a soft delete that keeps the data in the document until a purge removes it. Trap: Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. Done when: Adding a hundred fields and removing ninety of them leaves the physical schema unchanged, tested by inspecting the catalogue before and after. Renaming a field changes no stored record, verified by comparing documents. Two fields with display names that differ only after the sixtieth character are both usable, tested. A soft-deleted field's data survives until an explicit purge and is restorable, tested. Do not build yet: No views, no type changes, no concurrency handling and no API yet. ### Phase 2 · Four views, one catalogue Build: Generate every view from the catalogue so a new field appears in all four without code. Translate a view's filter and sort into predicates over the document column. When a view filters or sorts on a field, create an expression index for it and record that you did. Trap: Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. Done when: Adding a field makes it available in all four views without a code change, tested. A view filtering on a field has a matching expression index, asserted by querying the catalogue and the database's own index list. A query plan for a filtered view uses that index rather than a sequential scan, checked by inspecting the plan on a table of at least fifty thousand rows. A kanban view groups by a single-select field and refuses to group by a free text field. Do not build yet: No type conversion, no concurrency and no API yet. ### Phase 3 · Changing a field's type without losing what was in it Build: A conversion runs first as a dry run over every record, reporting how many values convert cleanly, how many would change meaning and how many cannot convert at all. The change is refused while any value cannot convert, unless the user explicitly chooses to keep the originals in a shadow field. Trap: Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. Done when: A dry run reports clean, changed and failed counts with a sample of each before anything is written, tested with a mixed column. A conversion with any failing value is refused unless originals are preserved in a shadow field, tested. Converting and converting back returns the original values where preservation was chosen, tested. A conversion is recorded in the revisions of every record it touched. Do not build yet: No concurrency handling, no trash, no API and no import yet. ### Phase 4 · Two people, one record Build: Every write carries the version the client last read and updates conditionally on it. A stale version returns a conflict with the current record so the interface can show both. Deletion is a soft delete into a trash with a retention period and a restore that returns the record intact. Trap: Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. Done when: Two writes from the same read version produce one success and one conflict carrying the current record, tested concurrently. The conflict response is rendered as a choice rather than an error toast, checked by review. A soft-deleted record is restorable with its field values intact for the retention period, tested. Every accepted write appends a revision with the author and timestamp. Do not build yet: No API, no import and no backups yet. ### Phase 5 · A way in, a way out, and a way back Build: Generate a token-authorised REST surface from the catalogue, with tokens scoped to a base and revocable. Import CSV through an explicit column mapping step that previews types before writing. Export records as CSV and the schema as its own document. Take scheduled database dumps and test the restore in the suite rather than in a runbook. Trap: Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. Done when: A token scoped to one base cannot read another, tested. The import previews inferred types per column and requires confirmation before writing, and a column of identifiers with leading zeros survives as text, tested. Export produces CSV for records and a schema document, and importing both into an empty instance reproduces the tables, fields and views. A restore from a scheduled dump is exercised in the test suite and the row counts match. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time multiplayer with live cursors, which needs a presence protocol and operational transforms and is a project on its own rather than a feature of this one. - A full spreadsheet formula language, because compatibility with the long tail of functions is years of work and partial compatibility is worse than none. - Hundreds of automations and integrations, which is the maintained connector catalogue the subscription actually buys. - Running user-supplied scripts server-side, which turns a records app into a sandbox problem with a very different threat model. ### README must contain - That records live in one table as documents keyed on stable field identifiers, and why a column per field runs into the database's own limits - That a field rename changes only the display name, so external references keep working - That filtering a view creates an expression index, and how to see which indexes exist and which are unused - That a type conversion is dry run first and refuses to null a value it cannot convert - Where the data lives, how the scheduled dumps work, and that the restore is exercised by the test suite ===== ARCHITECTURE.md ===== # Architecture · SeaTable Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose | one command to a working instance is the difference between a tool and a project | | Physical schema | One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue | a real column per user-defined field puts you into the database's own limits within a year | | Indexes | Expression indexes created per field that people actually filter or sort on | a document column with no index is a sequential scan wearing a JSON hat | | Views | Grid, form, kanban and detail all generated from the same field catalogue | four views maintained separately drift, and the drift is always in the form | | Concurrency | A version number per record, checked on every write | two people editing the same row is the normal case, not the edge case | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Catalogue | Bases, tables, fields and stable keys. | Any store, provided a rename never touches the data. | | Records | Documents, versions and revisions. | Any representation, provided writes are conditional on a version. | | Views | Grid, form, kanban, detail and their indexes. | Any renderer, provided all four come from the same catalogue. | | Conversions | Dry runs, refusals and shadow fields. | Not swappable for a conversion that writes nulls on failure. | | Interchange | API, import mapping, export and dumps. | Any format, provided import is explicit rather than inferred. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · The whole application state. This is the thing you back up. - `DUMP_DIR` · required · Scheduled dumps land here. Copy them somewhere else as well. - `DUMP_KEEP` · required · How many dumps to retain before the oldest is removed. - `TRASH_RETENTION_DAYS` · required · How long a soft-deleted record stays restorable. - `REVISION_CAP` · required · Revisions kept per record. Storage grows with how much people edit. - `MAX_RECORDS_PER_TABLE` · required · A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. - `API_TOKEN_PEPPER` · required, secret · Mixed into stored token hashes. Changing it invalidates every token. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · SeaTable Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose, One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue, Expression indexes created per field that people actually filter or sort on, Grid, form, kanban and detail all generated from the same field catalogue, A version number per record, checked on every write. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. - Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. ===== MILESTONES.md ===== # Delivery milestones · SeaTable Cloud Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A catalogue of fields, not a column per field Adding, renaming and removing a field is a row in a catalogue and never a change to the physical schema. ### Steps 1. Generate a short stable key per field Display names change; the key never does. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Keep records as one document column The alternative meets the database's own limits inside a year. Files: `src/db/migrations/001_records.sql` 3. Soft delete fields and purge separately Someone deletes a column on Friday and wants it back on Monday. Files: `src/schema/delete.ts` ### Done when - [ ] Adding and removing fields never alters the physical schema - [ ] Renaming a field changes no stored record - [ ] Long, similar field names do not collide - [ ] A deleted field's data is restorable before purge ### Watch out - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. ## M2 · Four views, one catalogue Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast. ### Steps 1. Render every view from the catalogue Four hand-maintained views drift, and the form is always the stale one. Files: `src/views/render.tsx` 2. Create expression indexes when a view needs one The index has to exist before the table gets big. Files: `src/views/indexes.ts`, `tests/index-plan.test.ts` 3. Check the plan on a realistic row count Two hundred rows will not tell you anything. Files: `scripts/seed-large.ts` ### Done when - [ ] A new field appears in all four views automatically - [ ] Filtered fields have expression indexes - [ ] Query plans use the index on a large table - [ ] Kanban refuses to group on a free text field ### Watch out - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. ## M3 · Changing a field's type without losing what was in it A type change is previewed, and anything that will not convert stops the change rather than becoming null. ### Steps 1. Dry run every conversion first You are about to rewrite a column somebody typed by hand. Files: `src/schema/convert.ts`, `tests/convert.test.ts` 2. Refuse rather than null A null is the loudest possible way to lose something quietly. Files: `src/schema/guard.ts` 3. Keep originals in a shadow field by default Reversibility is worth one extra column key. Files: `src/schema/shadow.ts` ### Done when - [ ] A dry run reports clean, changed and failed counts - [ ] Failing values block the conversion - [ ] Converting back restores the originals - [ ] Conversions appear in record revisions ### Watch out - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. ## M4 · Two people, one record Simultaneous edits do not silently discard one of them, and a deletion is recoverable. ### Steps 1. Send the read version with every write Last write wins is a data loss policy with a friendly name. Files: `src/records/update.ts`, `tests/concurrency.test.ts` 2. Return the current record on conflict The user needs to see what they would have overwritten. Files: `src/records/conflict.ts` 3. Soft delete into a trash with retention Undo for the one action nobody means to take. Files: `src/records/trash.ts` ### Done when - [ ] Concurrent writes give one success and one conflict - [ ] The conflict shows the current record - [ ] Soft-deleted records restore intact - [ ] Every write appends a revision ### Watch out - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ## M5 · A way in, a way out, and a way back (production only) There is a documented API, an import that maps columns, an export of both data and schema, and a restore you have actually run. ### Steps 1. Generate the API from the catalogue and scope the tokens A token that reads every base is not a token, it is a password. Files: `src/api/generate.ts`, `tests/token-scope.test.ts` 2. Make CSV import an explicit mapping, never a guess Leading zeros and date order are where this always goes wrong. Files: `src/import/map.tsx` 3. Exercise the restore in the suite A backup nobody has restored is a hope with a cron entry. Files: `tests/restore.test.ts` ### Done when - [ ] A base-scoped token cannot read another base - [ ] Import requires per-column confirmation - [ ] Identifiers with leading zeros survive import - [ ] A restore is exercised in the suite ### Watch out - Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. ===== OPERATIONS.md ===== # Operations · SeaTable Cloud ## Backup Scheduled database dumps, kept for a stated number of generations and copied off the machine. The schema and the data are in the same dump, which matters here because a records export without its field catalogue is a pile of documents keyed on identifiers nobody can read. ## Restore Restore the dump, bring the app up, and check three things: the field catalogue matches, a filtered view still uses its index, and a record restored from trash still has its values. Expression indexes are the thing most likely to be missing after a restore that went through a partial dump. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Row counts per table against the stated ceiling, the list of expression indexes with nothing using them, and the rate of write conflicts. A rising conflict rate usually means a view is inviting two people into the same row rather than that anything is broken. ## Incident checklist The incident here is a column that lost its values, and it is almost always a type conversion rather than a crash. Revisions per record are the recovery path, which is why a conversion writes a revision for every record it touched rather than treating itself as a schema operation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A year of field churn is confirmed to leave the physical schema unchanged - [ ] A filtered view on a large table is confirmed to use an expression index - [ ] A failing type conversion is confirmed refused rather than nulled - [ ] Concurrent writes are confirmed to produce a conflict, not a silent loss - [ ] A restore from a dump is confirmed in the test suite ## Launch constraint Do not market omitted SeaTable Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole application state. This is the thing you back up. DATABASE_URL=postgres://app:...@db:5432/records # Required. Scheduled dumps land here. Copy them somewhere else as well. DUMP_DIR=./backups # Required. How many dumps to retain before the oldest is removed. DUMP_KEEP=14 # Required. How long a soft-deleted record stays restorable. TRASH_RETENTION_DAYS=30 # Required. Revisions kept per record. Storage grows with how much people edit. REVISION_CAP=50 # Required. A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. MAX_RECORDS_PER_TABLE=500000 # Required · secret. Mixed into stored token hashes. Changing it invalidates every token. API_TOKEN_PEPPER=base64:...redacted
# SeaTable Cloud · indie build A self-hosted records database whose hard problems are letting people add and remove fields all year without destroying the physical schema, keeping filtered views fast once the data stops being a toy, and changing a field's type without quietly losing what was in it. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose | one command to a working instance is the difference between a tool and a project | | Physical schema | One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue | a real column per user-defined field puts you into the database's own limits within a year | | Indexes | Expression indexes created per field that people actually filter or sort on | a document column with no index is a sequential scan wearing a JSON hat | | Views | Grid, form, kanban and detail all generated from the same field catalogue | four views maintained separately drift, and the drift is always in the form | | Concurrency | A version number per record, checked on every write | two people editing the same row is the normal case, not the edge case | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - Why: Brings up the application and PostgreSQL together, which is the whole first-run experience. - Get it: Install Docker Desktop or Docker Engine with the compose plugin. - Verify: docker compose up brings the app to a login page - [ ] **PostgreSQL 16 or newer** · free locally - Why: The document column, the expression indexes and the conditional updates all rest on it, and the version matters for the indexing behaviour. - Get it: Through the compose file, or a managed instance. - Verify: A version query returns 16 or higher and an expression index can be created - [ ] **A realistic dataset of at least fifty thousand rows** · free - Why: Everything in this build is instant on two hundred rows, and every performance decision in phase two is invisible until you have real volume. - Get it: Generate it, or import a real export with the personal fields stripped. - Verify: A filtered view on that dataset returns in a time you would accept - [ ] **The field types you will support, written down** · free - Why: Each type is a parser, a renderer, a filter operator set and a conversion matrix, and the list grows faster than you expect. - Get it: Pick the smallest set that covers your actual tables, and write the conversion rules between them before building any. - Verify: Every type has documented conversions to and from every other - [ ] **Trash retention and revision depth** · free - Why: Both consume storage proportional to how much people edit, and both are useless if set to zero and expensive if set to forever. - Get it: Choose a retention period and a revision cap and write them into the configuration. - Verify: Both values are configured and enforced by a test ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. - managed collaboration, automations, plugins, storage, and support - real-time multiplayer - automation and integration breadth - scale and formula depth - enterprise permissions If one of those is essential to you, that is the reason to keep paying for SeaTable Cloud, and the README should say so rather than pretend.
# Build brief · SeaTable Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small structured-records app with grid, form and kanban views over a schema you can change without writing a migration to replace SeaTable Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose · one command to a working instance is the difference between a tool and a project - Physical schema: One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue · a real column per user-defined field puts you into the database's own limits within a year - Indexes: Expression indexes created per field that people actually filter or sort on · a document column with no index is a sequential scan wearing a JSON hat - Views: Grid, form, kanban and detail all generated from the same field catalogue · four views maintained separately drift, and the drift is always in the form - Concurrency: A version number per record, checked on every write · two people editing the same row is the normal case, not the edge case ### Data model (create this before Phase 1) - bases(id, name, created_at) and tables(id, base_id, name, created_at) · the two levels of grouping, and no more than two - fields(id, table_id, key, display_name, type, options_json, position, created_at, deleted_at) · key is a short identifier generated once and never changed, display_name is the thing people rename twice a week - records(id, table_id, data, version, created_at, updated_at, deleted_at) · data is a JSON document keyed on field keys, and version is what makes a conditional update possible - revisions(record_id, version, data, changed_by, at) · a capped history per record, which is what makes a trash restore mean something - views(id, table_id, kind, filter_json, sort_json, group_field, visible_fields, created_at) - field_indexes(field_id, index_name, created_at, last_used_at) · which expression indexes exist and whether anything still uses them - api_tokens(id, base_id, name, scope, hashed_token, created_at, last_used_at, revoked_at) ### Phase 1 · A catalogue of fields, not a column per field Build: Store records as a JSON document keyed on short generated field keys. A field row carries the key, the display name, the type and its options. Renaming changes the display name only. Deleting is a soft delete that keeps the data in the document until a purge removes it. Trap: Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. Done when: Adding a hundred fields and removing ninety of them leaves the physical schema unchanged, tested by inspecting the catalogue before and after. Renaming a field changes no stored record, verified by comparing documents. Two fields with display names that differ only after the sixtieth character are both usable, tested. A soft-deleted field's data survives until an explicit purge and is restorable, tested. Do not build yet: No views, no type changes, no concurrency handling and no API yet. ### Phase 2 · Four views, one catalogue Build: Generate every view from the catalogue so a new field appears in all four without code. Translate a view's filter and sort into predicates over the document column. When a view filters or sorts on a field, create an expression index for it and record that you did. Trap: Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. Done when: Adding a field makes it available in all four views without a code change, tested. A view filtering on a field has a matching expression index, asserted by querying the catalogue and the database's own index list. A query plan for a filtered view uses that index rather than a sequential scan, checked by inspecting the plan on a table of at least fifty thousand rows. A kanban view groups by a single-select field and refuses to group by a free text field. Do not build yet: No type conversion, no concurrency and no API yet. ### Phase 3 · Changing a field's type without losing what was in it Build: A conversion runs first as a dry run over every record, reporting how many values convert cleanly, how many would change meaning and how many cannot convert at all. The change is refused while any value cannot convert, unless the user explicitly chooses to keep the originals in a shadow field. Trap: Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. Done when: A dry run reports clean, changed and failed counts with a sample of each before anything is written, tested with a mixed column. A conversion with any failing value is refused unless originals are preserved in a shadow field, tested. Converting and converting back returns the original values where preservation was chosen, tested. A conversion is recorded in the revisions of every record it touched. Do not build yet: No concurrency handling, no trash, no API and no import yet. ### Phase 4 · Two people, one record Build: Every write carries the version the client last read and updates conditionally on it. A stale version returns a conflict with the current record so the interface can show both. Deletion is a soft delete into a trash with a retention period and a restore that returns the record intact. Trap: Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. Done when: Two writes from the same read version produce one success and one conflict carrying the current record, tested concurrently. The conflict response is rendered as a choice rather than an error toast, checked by review. A soft-deleted record is restorable with its field values intact for the retention period, tested. Every accepted write appends a revision with the author and timestamp. Do not build yet: No API, no import and no backups yet. ### Phase 5 · A way in, a way out, and a way back Build: Generate a token-authorised REST surface from the catalogue, with tokens scoped to a base and revocable. Import CSV through an explicit column mapping step that previews types before writing. Export records as CSV and the schema as its own document. Take scheduled database dumps and test the restore in the suite rather than in a runbook. Trap: Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. Done when: A token scoped to one base cannot read another, tested. The import previews inferred types per column and requires confirmation before writing, and a column of identifiers with leading zeros survives as text, tested. Export produces CSV for records and a schema document, and importing both into an empty instance reproduces the tables, fields and views. A restore from a scheduled dump is exercised in the test suite and the row counts match. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time multiplayer with live cursors, which needs a presence protocol and operational transforms and is a project on its own rather than a feature of this one. - A full spreadsheet formula language, because compatibility with the long tail of functions is years of work and partial compatibility is worse than none. - Hundreds of automations and integrations, which is the maintained connector catalogue the subscription actually buys. - Running user-supplied scripts server-side, which turns a records app into a sandbox problem with a very different threat model. ### README must contain - That records live in one table as documents keyed on stable field identifiers, and why a column per field runs into the database's own limits - That a field rename changes only the display name, so external references keep working - That filtering a view creates an expression index, and how to see which indexes exist and which are unused - That a type conversion is dry run first and refuses to null a value it cannot convert - Where the data lives, how the scheduled dumps work, and that the restore is exercised by the test suite
# Agent instructions · SeaTable Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose, One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue, Expression indexes created per field that people actually filter or sort on, Grid, form, kanban and detail all generated from the same field catalogue, A version number per record, checked on every write. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently.
# Build plan · SeaTable Cloud A self-hosted records database whose hard problems are letting people add and remove fields all year without destroying the physical schema, keeping filtered views fast once the data stops being a toy, and changing a field's type without quietly losing what was in it. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A catalogue of fields, not a column per field Adding, renaming and removing a field is a row in a catalogue and never a change to the physical schema. ### Steps 1. Generate a short stable key per field Display names change; the key never does. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Keep records as one document column The alternative meets the database's own limits inside a year. Files: `src/db/migrations/001_records.sql` 3. Soft delete fields and purge separately Someone deletes a column on Friday and wants it back on Monday. Files: `src/schema/delete.ts` ### Done when - [ ] Adding and removing fields never alters the physical schema - [ ] Renaming a field changes no stored record - [ ] Long, similar field names do not collide - [ ] A deleted field's data is restorable before purge ### Watch out - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. ## Phase 2 · Four views, one catalogue Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast. ### Steps 1. Render every view from the catalogue Four hand-maintained views drift, and the form is always the stale one. Files: `src/views/render.tsx` 2. Create expression indexes when a view needs one The index has to exist before the table gets big. Files: `src/views/indexes.ts`, `tests/index-plan.test.ts` 3. Check the plan on a realistic row count Two hundred rows will not tell you anything. Files: `scripts/seed-large.ts` ### Done when - [ ] A new field appears in all four views automatically - [ ] Filtered fields have expression indexes - [ ] Query plans use the index on a large table - [ ] Kanban refuses to group on a free text field ### Watch out - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. ## Phase 3 · Changing a field's type without losing what was in it A type change is previewed, and anything that will not convert stops the change rather than becoming null. ### Steps 1. Dry run every conversion first You are about to rewrite a column somebody typed by hand. Files: `src/schema/convert.ts`, `tests/convert.test.ts` 2. Refuse rather than null A null is the loudest possible way to lose something quietly. Files: `src/schema/guard.ts` 3. Keep originals in a shadow field by default Reversibility is worth one extra column key. Files: `src/schema/shadow.ts` ### Done when - [ ] A dry run reports clean, changed and failed counts - [ ] Failing values block the conversion - [ ] Converting back restores the originals - [ ] Conversions appear in record revisions ### Watch out - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. ## Phase 4 · Two people, one record Simultaneous edits do not silently discard one of them, and a deletion is recoverable. ### Steps 1. Send the read version with every write Last write wins is a data loss policy with a friendly name. Files: `src/records/update.ts`, `tests/concurrency.test.ts` 2. Return the current record on conflict The user needs to see what they would have overwritten. Files: `src/records/conflict.ts` 3. Soft delete into a trash with retention Undo for the one action nobody means to take. Files: `src/records/trash.ts` ### Done when - [ ] Concurrent writes give one success and one conflict - [ ] The conflict shows the current record - [ ] Soft-deleted records restore intact - [ ] Every write appends a revision ### Watch out - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ## Not in this build - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. ## After v1, if you want it - A link field between tables, which is the first thing anyone asks for and the first thing that complicates every view - Row-level permissions by field value, once you know which tables people actually want to share - A saved import profile, so the monthly CSV from the same source does not need remapping every time
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole application state. This is the thing you back up. DATABASE_URL=postgres://app:...@db:5432/records # Required. Scheduled dumps land here. Copy them somewhere else as well. DUMP_DIR=./backups # Required. How many dumps to retain before the oldest is removed. DUMP_KEEP=14 # Required. How long a soft-deleted record stays restorable. TRASH_RETENTION_DAYS=30 # Required. Revisions kept per record. Storage grows with how much people edit. REVISION_CAP=50 # Required. A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. MAX_RECORDS_PER_TABLE=500000 # Required · secret. Mixed into stored token hashes. Changing it invalidates every token. API_TOKEN_PEPPER=base64:...redacted
# SeaTable Cloud · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For SeaTable Cloud, manage structured records in grids, forms, and simple views. The hard boundary is managed collaboration, automations, plugins, storage, and support, plus data model flexibility, collaboration, and integrations. ## Product outcome A records app where a year of schema changes leaves the database untouched, a filtered view on real volume uses an index, a type change cannot silently null a column, and two people editing the same row both find out. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - self-hosted server - optional object storage for attachments ## Explicit non-goals for v1 - Real-time multiplayer with live cursors. - A full spreadsheet formula language. - Hundreds of automations and integrations. - Server-side user scripts. - managed collaboration, automations, plugins, storage, and support - real-time multiplayer - automation and integration breadth - scale and formula depth - enterprise permissions ## Success criteria - A year of field churn is confirmed to leave the physical schema unchanged - A filtered view on a large table is confirmed to use an expression index - A failing type conversion is confirmed refused rather than nulled - Concurrent writes are confirmed to produce a conflict, not a silent loss - A restore from a dump is confirmed in the test suite
# Build brief · SeaTable Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small structured-records app with grid, form and kanban views over a schema you can change without writing a migration to replace SeaTable Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose · one command to a working instance is the difference between a tool and a project - Physical schema: One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue · a real column per user-defined field puts you into the database's own limits within a year - Indexes: Expression indexes created per field that people actually filter or sort on · a document column with no index is a sequential scan wearing a JSON hat - Views: Grid, form, kanban and detail all generated from the same field catalogue · four views maintained separately drift, and the drift is always in the form - Concurrency: A version number per record, checked on every write · two people editing the same row is the normal case, not the edge case ### Data model (create this before Phase 1) - bases(id, name, created_at) and tables(id, base_id, name, created_at) · the two levels of grouping, and no more than two - fields(id, table_id, key, display_name, type, options_json, position, created_at, deleted_at) · key is a short identifier generated once and never changed, display_name is the thing people rename twice a week - records(id, table_id, data, version, created_at, updated_at, deleted_at) · data is a JSON document keyed on field keys, and version is what makes a conditional update possible - revisions(record_id, version, data, changed_by, at) · a capped history per record, which is what makes a trash restore mean something - views(id, table_id, kind, filter_json, sort_json, group_field, visible_fields, created_at) - field_indexes(field_id, index_name, created_at, last_used_at) · which expression indexes exist and whether anything still uses them - api_tokens(id, base_id, name, scope, hashed_token, created_at, last_used_at, revoked_at) ### Phase 1 · A catalogue of fields, not a column per field Build: Store records as a JSON document keyed on short generated field keys. A field row carries the key, the display name, the type and its options. Renaming changes the display name only. Deleting is a soft delete that keeps the data in the document until a purge removes it. Trap: Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. Done when: Adding a hundred fields and removing ninety of them leaves the physical schema unchanged, tested by inspecting the catalogue before and after. Renaming a field changes no stored record, verified by comparing documents. Two fields with display names that differ only after the sixtieth character are both usable, tested. A soft-deleted field's data survives until an explicit purge and is restorable, tested. Do not build yet: No views, no type changes, no concurrency handling and no API yet. ### Phase 2 · Four views, one catalogue Build: Generate every view from the catalogue so a new field appears in all four without code. Translate a view's filter and sort into predicates over the document column. When a view filters or sorts on a field, create an expression index for it and record that you did. Trap: Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. Done when: Adding a field makes it available in all four views without a code change, tested. A view filtering on a field has a matching expression index, asserted by querying the catalogue and the database's own index list. A query plan for a filtered view uses that index rather than a sequential scan, checked by inspecting the plan on a table of at least fifty thousand rows. A kanban view groups by a single-select field and refuses to group by a free text field. Do not build yet: No type conversion, no concurrency and no API yet. ### Phase 3 · Changing a field's type without losing what was in it Build: A conversion runs first as a dry run over every record, reporting how many values convert cleanly, how many would change meaning and how many cannot convert at all. The change is refused while any value cannot convert, unless the user explicitly chooses to keep the originals in a shadow field. Trap: Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. Done when: A dry run reports clean, changed and failed counts with a sample of each before anything is written, tested with a mixed column. A conversion with any failing value is refused unless originals are preserved in a shadow field, tested. Converting and converting back returns the original values where preservation was chosen, tested. A conversion is recorded in the revisions of every record it touched. Do not build yet: No concurrency handling, no trash, no API and no import yet. ### Phase 4 · Two people, one record Build: Every write carries the version the client last read and updates conditionally on it. A stale version returns a conflict with the current record so the interface can show both. Deletion is a soft delete into a trash with a retention period and a restore that returns the record intact. Trap: Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. Done when: Two writes from the same read version produce one success and one conflict carrying the current record, tested concurrently. The conflict response is rendered as a choice rather than an error toast, checked by review. A soft-deleted record is restorable with its field values intact for the retention period, tested. Every accepted write appends a revision with the author and timestamp. Do not build yet: No API, no import and no backups yet. ### Phase 5 · A way in, a way out, and a way back Build: Generate a token-authorised REST surface from the catalogue, with tokens scoped to a base and revocable. Import CSV through an explicit column mapping step that previews types before writing. Export records as CSV and the schema as its own document. Take scheduled database dumps and test the restore in the suite rather than in a runbook. Trap: Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written. Done when: A token scoped to one base cannot read another, tested. The import previews inferred types per column and requires confirmation before writing, and a column of identifiers with leading zeros survives as text, tested. Export produces CSV for records and a schema document, and importing both into an empty instance reproduces the tables, fields and views. A restore from a scheduled dump is exercised in the test suite and the row counts match. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time multiplayer with live cursors, which needs a presence protocol and operational transforms and is a project on its own rather than a feature of this one. - A full spreadsheet formula language, because compatibility with the long tail of functions is years of work and partial compatibility is worse than none. - Hundreds of automations and integrations, which is the maintained connector catalogue the subscription actually buys. - Running user-supplied scripts server-side, which turns a records app into a sandbox problem with a very different threat model. ### README must contain - That records live in one table as documents keyed on stable field identifiers, and why a column per field runs into the database's own limits - That a field rename changes only the display name, so external references keep working - That filtering a view creates an expression index, and how to see which indexes exist and which are unused - That a type conversion is dry run first and refuses to null a value it cannot convert - Where the data lives, how the scheduled dumps work, and that the restore is exercised by the test suite
# Architecture · SeaTable Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose | one command to a working instance is the difference between a tool and a project | | Physical schema | One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue | a real column per user-defined field puts you into the database's own limits within a year | | Indexes | Expression indexes created per field that people actually filter or sort on | a document column with no index is a sequential scan wearing a JSON hat | | Views | Grid, form, kanban and detail all generated from the same field catalogue | four views maintained separately drift, and the drift is always in the form | | Concurrency | A version number per record, checked on every write | two people editing the same row is the normal case, not the edge case | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Catalogue | Bases, tables, fields and stable keys. | Any store, provided a rename never touches the data. | | Records | Documents, versions and revisions. | Any representation, provided writes are conditional on a version. | | Views | Grid, form, kanban, detail and their indexes. | Any renderer, provided all four come from the same catalogue. | | Conversions | Dry runs, refusals and shadow fields. | Not swappable for a conversion that writes nulls on failure. | | Interchange | API, import mapping, export and dumps. | Any format, provided import is explicit rather than inferred. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · The whole application state. This is the thing you back up. - `DUMP_DIR` · required · Scheduled dumps land here. Copy them somewhere else as well. - `DUMP_KEEP` · required · How many dumps to retain before the oldest is removed. - `TRASH_RETENTION_DAYS` · required · How long a soft-deleted record stays restorable. - `REVISION_CAP` · required · Revisions kept per record. Storage grows with how much people edit. - `MAX_RECORDS_PER_TABLE` · required · A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. - `API_TOKEN_PEPPER` · required, secret · Mixed into stored token hashes. Changing it invalidates every token. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · SeaTable Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker Compose, One records table holding a JSON document keyed on stable field identifiers, plus a fields catalogue, Expression indexes created per field that people actually filter or sort on, Grid, form, kanban and detail all generated from the same field catalogue, A version number per record, checked on every write. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. - Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written.
# Delivery milestones · SeaTable Cloud Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A catalogue of fields, not a column per field Adding, renaming and removing a field is a row in a catalogue and never a change to the physical schema. ### Steps 1. Generate a short stable key per field Display names change; the key never does. Files: `src/schema/fields.ts`, `tests/field-keys.test.ts` 2. Keep records as one document column The alternative meets the database's own limits inside a year. Files: `src/db/migrations/001_records.sql` 3. Soft delete fields and purge separately Someone deletes a column on Friday and wants it back on Monday. Files: `src/schema/delete.ts` ### Done when - [ ] Adding and removing fields never alters the physical schema - [ ] Renaming a field changes no stored record - [ ] Long, similar field names do not collide - [ ] A deleted field's data is restorable before purge ### Watch out - Creating a real PostgreSQL column for every user-defined field walks straight into three documented limits at once: an identifier is truncated at 63 bytes so two long field names collide into one column, a table caps at 1600 columns and dropped columns still count against that cap so a year of adding and removing fields exhausts it while forty are live, and the practical column count is lower still because the row has to fit in an 8 kilobyte page. Keep one records table with a document column and a fields catalogue beside it. ## M2 · Four views, one catalogue Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast. ### Steps 1. Render every view from the catalogue Four hand-maintained views drift, and the form is always the stale one. Files: `src/views/render.tsx` 2. Create expression indexes when a view needs one The index has to exist before the table gets big. Files: `src/views/indexes.ts`, `tests/index-plan.test.ts` 3. Check the plan on a realistic row count Two hundred rows will not tell you anything. Files: `scripts/seed-large.ts` ### Done when - [ ] A new field appears in all four views automatically - [ ] Filtered fields have expression indexes - [ ] Query plans use the index on a large table - [ ] Kanban refuses to group on a free text field ### Watch out - Filtering a document column without an expression index means every view scans the whole table, which is instant on the two hundred rows you tested with and takes seconds on the fifty thousand rows the tool actually accumulates, by which point the fix is a migration on a table people are using. Create the expression index when a view first filters on a field, and record which indexes exist so you can drop the ones nothing uses. ## M3 · Changing a field's type without losing what was in it A type change is previewed, and anything that will not convert stops the change rather than becoming null. ### Steps 1. Dry run every conversion first You are about to rewrite a column somebody typed by hand. Files: `src/schema/convert.ts`, `tests/convert.test.ts` 2. Refuse rather than null A null is the loudest possible way to lose something quietly. Files: `src/schema/guard.ts` 3. Keep originals in a shadow field by default Reversibility is worth one extra column key. Files: `src/schema/shadow.ts` ### Done when - [ ] A dry run reports clean, changed and failed counts - [ ] Failing values block the conversion - [ ] Converting back restores the originals - [ ] Conversions appear in record revisions ### Watch out - Converting a text field to a number by writing null wherever parsing fails destroys the exact values somebody typed carefully, and because the affected rows are a minority nobody notices until a report is short. Dry run the conversion, show the count and a sample of what will not convert, and make keeping the originals the default path rather than an option in a menu. ## M4 · Two people, one record Simultaneous edits do not silently discard one of them, and a deletion is recoverable. ### Steps 1. Send the read version with every write Last write wins is a data loss policy with a friendly name. Files: `src/records/update.ts`, `tests/concurrency.test.ts` 2. Return the current record on conflict The user needs to see what they would have overwritten. Files: `src/records/conflict.ts` 3. Soft delete into a trash with retention Undo for the one action nobody means to take. Files: `src/records/trash.ts` ### Done when - [ ] Concurrent writes give one success and one conflict - [ ] The conflict shows the current record - [ ] Soft-deleted records restore intact - [ ] Every write appends a revision ### Watch out - Saving a whole record on blur with no version check means two people editing different fields of the same row overwrite each other's work, and since both saves succeed the loser gets no error and finds out days later. Send the version with every write, reject on mismatch, and show the other version rather than merging silently. ## M5 · A way in, a way out, and a way back (production only) There is a documented API, an import that maps columns, an export of both data and schema, and a restore you have actually run. ### Steps 1. Generate the API from the catalogue and scope the tokens A token that reads every base is not a token, it is a password. Files: `src/api/generate.ts`, `tests/token-scope.test.ts` 2. Make CSV import an explicit mapping, never a guess Leading zeros and date order are where this always goes wrong. Files: `src/import/map.tsx` 3. Exercise the restore in the suite A backup nobody has restored is a hope with a cron entry. Files: `tests/restore.test.ts` ### Done when - [ ] A base-scoped token cannot read another base - [ ] Import requires per-column confirmation - [ ] Identifiers with leading zeros survive import - [ ] A restore is exercised in the suite ### Watch out - Importing a CSV by guessing types from the first few rows gets the postcode column wrong, turns an identifier with leading zeros into a number, and reads dates in the wrong order, all silently, and the damage is spread across thousands of rows you now cannot tell from correct ones. Make the mapping step explicit, preview the parsed values with their inferred types, and require confirmation per column before a single row is written.
# Operations · SeaTable Cloud ## Backup Scheduled database dumps, kept for a stated number of generations and copied off the machine. The schema and the data are in the same dump, which matters here because a records export without its field catalogue is a pile of documents keyed on identifiers nobody can read. ## Restore Restore the dump, bring the app up, and check three things: the field catalogue matches, a filtered view still uses its index, and a record restored from trash still has its values. Expression indexes are the thing most likely to be missing after a restore that went through a partial dump. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Row counts per table against the stated ceiling, the list of expression indexes with nothing using them, and the rate of write conflicts. A rising conflict rate usually means a view is inviting two people into the same row rather than that anything is broken. ## Incident checklist The incident here is a column that lost its values, and it is almost always a type conversion rather than a crash. Revisions per record are the recovery path, which is why a conversion writes a revision for every record it touched rather than treating itself as a schema operation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A year of field churn is confirmed to leave the physical schema unchanged - [ ] A filtered view on a large table is confirmed to use an expression index - [ ] A failing type conversion is confirmed refused rather than nulled - [ ] Concurrent writes are confirmed to produce a conflict, not a silent loss - [ ] A restore from a dump is confirmed in the test suite ## Launch constraint Do not market omitted SeaTable Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole application state. This is the thing you back up. DATABASE_URL=postgres://app:...@db:5432/records # Required. Scheduled dumps land here. Copy them somewhere else as well. DUMP_DIR=./backups # Required. How many dumps to retain before the oldest is removed. DUMP_KEEP=14 # Required. How long a soft-deleted record stays restorable. TRASH_RETENTION_DAYS=30 # Required. Revisions kept per record. Storage grows with how much people edit. REVISION_CAP=50 # Required. A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. MAX_RECORDS_PER_TABLE=500000 # Required · secret. Mixed into stored token hashes. Changing it invalidates every token. API_TOKEN_PEPPER=base64:...redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for SeaTable Cloud because teams pay when a flexible database becomes a shared operational system with integrations and years of accumulated business logic. The recurring cost buys schema changes, formulas, permissions, imports, attachments, views, APIs, concurrency, backups, and migrations, not just the visible interface.
xmanaged collaboration, automations, plugins, storage, and support
xreal-time multiplayer
xautomation and integration breadth
xscale and formula depth
xenterprise permissions
Don't feel like building it? These folks already made it free.
all 5 free alternatives to SeaTable Cloud →· no votes, no pay-to-list · just what's real
SeaTable Cloud pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Up to 25 users; unlimited bases and tables; 10,000 total rows and 10,000 rows/base; 2 GB storage; 1-month snapshots; 100 Python runs, 3,000 API calls, 100 automations and 12.5 AI credits/month. |
| plus | $10.24/user | $7.97/user | 50,000 total rows and 50,000 rows/base; 50 GB storage; 6-month snapshots; per user/month: 250 Python runs, 10,000 API calls, 500 automations and 50 AI credits. |
| enterprise | $20.48/user | $15.93/user | Unlimited total rows but 100,000 rows/base; 100 GB storage; 12-month snapshots; per user/month: 1,000 Python runs, 50,000 API calls, 5,000 automations and 500 AI credits. |
| dedicated cloud | custom | — | Minimum 100 users; unlimited total rows but 200,000 rows/base; 1 TB storage; 24-month snapshots; unlimited Python, API, automation and AI usage. |
free tier25 users, unlimited bases/tables, 10,000 total rows, 10,000 rows/base, 2 GB storage, 1-month snapshots, 100 Python runs/month, 3,000 API calls/month, 100 automations/month and 12.5 AI credits/month
billingPlus and Enterprise offer monthly + annual billing; Dedicated Cloud is annual only; every team member uses the same plan and billing period
hidden costsEvery team member is billable on a paid team because subscriptions cannot be mixed; row/storage limits block new rows or uploads; bank transfer requires Enterprise annual billing with at least 10 users; Dedicated Cloud has a 100-user minimum; VAT and currency movement are extra.
verified 2026-08-11 · source ↗
Vibecode SeaTable Cloud
Kinda. The core of SeaTable Cloud is buildable in a weekend with the prompt on this page, but there are real gaps: managed collaboration, automations, plugins, storage, and support, real-time multiplayer. Read the honest list above before committing.
How much does SeaTable Cloud cost?
SeaTable Cloud costs about $10.24/month (Plus, checked 2026-08-11), which is $122.88 per year.
What do I lose by replacing SeaTable Cloud?
Honestly: managed collaboration, automations, plugins, storage, and support; real-time multiplayer; automation and integration breadth; scale and formula depth; enterprise permissions. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to SeaTable Cloud?
Yes: Grist (A relational spreadsheet with formulas that stays intelligible after column C stops being enough.) Baserow Free (Airtable with fewer ornaments: 3,000 rows, forms, views, automations, and a small app builder for $0.) NocoDB Free (A managed database grid for three editors and 1,000 records; enough for a small workspace, not a growing one.) All 5 curated free alternatives are at vibecodeit.com/seatable-cloud/alternatives. The prompt is for when you want it exactly your way.