Build SeaTable Cloud

KINDAreplaces $10.24/mosaves $122.88/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

RuntimeNext.js 15 with TypeScript, PostgreSQL through Drizzle, in Docker ComposePhysical schemaOne records table holding a JSON document keyed on stable field identifiers, plus a fields catalogueIndexesExpression indexes created per field that people actually filter or sort onViewsGrid, form, kanban and detail all generated from the same field catalogueConcurrencyA version number per record, checked on every write

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://app:...@db:5432/recordsThe whole application state. This is the thing you back up.
DUMP_DIRrequired./backupsScheduled dumps land here. Copy them somewhere else as well.
DUMP_KEEPrequired14How many dumps to retain before the oldest is removed.
TRASH_RETENTION_DAYSrequired30How long a soft-deleted record stays restorable.
REVISION_CAPrequired50Revisions kept per record. Storage grows with how much people edit.
MAX_RECORDS_PER_TABLErequired500000A stated ceiling with a warning, because an unbounded table becomes an unusable one quietly.
API_TOKEN_PEPPERsecretrequiredbase64:...redactedMixed into stored token hashes. Changing it invalidates every token.

The build, in order

  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.

    1. Display names change; the key never does.

      Files src/schema/fields.tstests/field-keys.test.ts

    2. The alternative meets the database's own limits inside a year.

      Files src/db/migrations/001_records.sql

    3. Someone deletes a column on Friday and wants it back on Monday.

      Files src/schema/delete.ts

    done when · tick each as it passes
    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.
  2. Four views, one catalogue

    Grid, form, kanban and detail are all rendered from the field catalogue, and a filtered view stays fast.

    1. Four hand-maintained views drift, and the form is always the stale one.

      Files src/views/render.tsx

    2. The index has to exist before the table gets big.

      Files src/views/indexes.tstests/index-plan.test.ts

    3. Two hundred rows will not tell you anything.

      Files scripts/seed-large.ts

    done when · tick each as it passes
    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.
  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.

    1. You are about to rewrite a column somebody typed by hand.

      Files src/schema/convert.tstests/convert.test.ts

    2. A null is the loudest possible way to lose something quietly.

      Files src/schema/guard.ts

    3. Reversibility is worth one extra column key.

      Files src/schema/shadow.ts

    done when · tick each as it passes
    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.
  4. Two people, one record

    Simultaneous edits do not silently discard one of them, and a deletion is recoverable.

    1. Last write wins is a data loss policy with a friendly name.

      Files src/records/update.tstests/concurrency.test.ts

    2. The user needs to see what they would have overwritten.

      Files src/records/conflict.ts

    3. Undo for the one action nobody means to take.

      Files src/records/trash.ts

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more databases.