Build SeaTable Cloud
KINDAreplaces $10.24/mosaves $122.88/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
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. open ↗
Verify
docker compose up brings the app to a login page - installfree 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. open ↗
Verify
A version query returns 16 or higher and an expression index can be created - have readyfree
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 - decidefree
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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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)
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://app:...@db:5432/records | The whole application state. This is the thing you back up. |
DUMP_DIR | required | ./backups | Scheduled dumps land here. Copy them somewhere else as well. |
DUMP_KEEP | required | 14 | How many dumps to retain before the oldest is removed. |
TRASH_RETENTION_DAYS | required | 30 | How long a soft-deleted record stays restorable. |
REVISION_CAP | required | 50 | Revisions kept per record. Storage grows with how much people edit. |
MAX_RECORDS_PER_TABLE | required | 500000 | A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly. |
API_TOKEN_PEPPERsecret | required | base64:...redacted | Mixed into stored token hashes. Changing it invalidates every token. |
The build, in order
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.
Display names change; the key never does.
Files
src/schema/fields.tstests/field-keys.test.tsThe alternative meets the database's own limits inside a year.
Files
src/db/migrations/001_records.sqlSomeone deletes a column on Friday and wants it back on Monday.
Files
src/schema/delete.ts
done when · tick each as it passeswatch 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.
Four views, one catalogue
Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast.
Four hand-maintained views drift, and the form is always the stale one.
Files
src/views/render.tsxThe index has to exist before the table gets big.
Files
src/views/indexes.tstests/index-plan.test.tsTwo hundred rows will not tell you anything.
Files
scripts/seed-large.ts
done when · tick each as it passeswatch 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.
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.
You are about to rewrite a column somebody typed by hand.
Files
src/schema/convert.tstests/convert.test.tsA null is the loudest possible way to lose something quietly.
Files
src/schema/guard.tsReversibility is worth one extra column key.
Files
src/schema/shadow.ts
done when · tick each as it passeswatch 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.
Two people, one record
Simultaneous edits do not silently discard one of them, and a deletion is recoverable.
Last write wins is a data loss policy with a friendly name.
Files
src/records/update.tstests/concurrency.test.tsThe user needs to see what they would have overwritten.
Files
src/records/conflict.tsUndo for the one action nobody means to take.
Files
src/records/trash.ts
done when · tick each as it passeswatch 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.
A way in, a way out, and a way backproduct builder
There is a documented API, an import that maps columns, an export of both data and schema, and a restore you have actually run.
A token that reads every base is not a token, it is a password.
Files
src/api/generate.tstests/token-scope.test.tsLeading zeros and date order are where this always goes wrong.
Files
src/import/map.tsxA backup nobody has restored is a hope with a cron entry.
Files
tests/restore.test.ts
done when · tick each as it passeswatch 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.
That is the whole plan for SeaTable Cloud. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more databases.