Table of Contents
- Open issues
- 1. typeOptions does not round-trip — one bug behind every Bases symptom
- Symptoms
- Why these are one bug
- CONFIRMED
- Cause: the driver is postgres.js, not node-postgres
- Why writes silently no-op rather than erroring
- RETRACTION: no data was destroyed
- Every affected write site
- Fix
- Why the whole bundle got this wrong at once
- Note on the 413
- Reproduction notes
- RESOLVED — jsonb round-trip
- 2. defaultValue was configured but never applied
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Mirrored from
ISSUES.mdat commitf31ec6a. Edit the file in the repository: this page is regenerated from it, and edits made here are overwritten.
Open issues
Found in live testing.
1. typeOptions does not round-trip — one bug behind every Bases symptom
Status: FIXED — writes corrected in ee/shared/jsonb.ts; existing rows
need ee/maintenance/repair-jsonb.ts run once. See "RESOLVED" below.
Found: first live Bases test
Symptoms
- Date column ignores "include time." Toggle it on, pick 24h, save — the column stays date-only, and reopening the editor shows the toggle off again.
- Updating a select column 413s.
/api/bases/properties/updatereturnsFST_ERR_CTP_BODY_TOO_LARGE, and the request body containstypeOptions: {"0":"{", "1":"\"", "2":"0", …}— a JSON string that has been spread into an object keyed by character index. - Select options aren't created (multiSelect create appears to work, but its payload is built fresh client-side and has not survived a reload).
- Row cell edits don't save, and corrupt the row. Sending
cells: {"prp3iqbku0r4":"test"}returns 200 with the row's cells as{"0":"{","1":"}"}- the two characters of{}. Cell data is being destroyed, not merely mis-read. - Formula columns save blank. Create returns 200 and the server-side
compile is plainly correct - source, ast, resultType and dependencies are
all present and right - but
typeOptionscomes back as a string, so the editor reads no source and shows an empty formula.
Why these are one bug
Symptom 2 is the tell. {"0":"{", "1":"\""} is what {...someString}
produces. So the client received typeOptions as a string where it
expected an object. That single fact explains all three:
opts?.includeTimeon a string →undefined→ toggle renders off (1){...opts}on a string → character-indexed object → enormous body (2)typeOptions.choiceson a string →undefined→ no options (3)opts?.sourceon a string →undefined→ formula renders blank (4)
So typeOptions is being persisted such that reading it back yields a JSON
string rather than a parsed object.
CONFIRMED
Create responses return it as a string, so no DB query is needed:
"typeOptions": "{\"choices\":[{\"id\":\"optg1inv4pa5\", …}]}"
"typeOptions": "{\"source\":\"prop(\\\"Checkbox\\\") == true\",\"ast\":{…},\"resultType\":\"boolean\"}"
The second is a formula, and it shows the compiler working correctly - the AST, resultType and dependencies are all right. Only persistence is broken.
Stored as a jsonb string scalar; pg faithfully parses it back to a JS
string on read.
Cause: the driver is postgres.js, not node-postgres
DatabaseModule uses PostgresJSDialect from kysely-postgres-js, wrapping
the postgres package.
Correction. An earlier version of this section claimed postgres.js sends a
JS string as text (OID 25) and that text→jsonb wraps rather than
parses. Reading the installed driver does not support that: inferType()
returns 0 (unspecified) for a string, which should let the server infer
jsonb from the target column and parse it. The precise mechanism is
therefore still unexplained.
What IS established, by experiment rather than reasoning — a genuine object
written through INSERT ... RETURNING on the live stack came back as a
string:
sent: typeOptions: JSON.stringify({probe:123,nested:{a:[1,2]}})
returned: "typeOptions":"{\"probe\":123,\"nested\":{\"a\":[1,2]}}"
Reads are not the culprit: postgres.js registers a jsonb parser
(from: [114, 3802], parse: JSON.parse) and mergeUserTypes merges rather
than replaces it, so the custom bigint type in DatabaseModule leaves it
intact. The value really is stored as a jsonb string scalar.
The rule that matters in practice, and which the codebase itself
demonstrates: direct column assignment wraps; an explicit cast parses.
Every site that worked (cell and view-config merges) went through
sql\...::jsonb``; every broken site used direct assignment.
This is why core's jsonb writes look the way they do:
settings: sql`${JSON.stringify({...})}::text::jsonb` // user.repo.ts:137
The ::text::jsonb double cast is not redundant — it forces an explicit
parse instead of the wrapping conversion. Core knew about this; the bundle
didn't.
Why writes silently no-op rather than erroring
jsonb_set_many (core's helper, used for every cell and view-config merge)
opens with:
IF patches IS NULL OR jsonb_typeof(patches) <> 'object' THEN
RETURN result; -- target unchanged
END IF;
A patch that arrived as a jsonb string is not an object, so the function returns the target untouched. No error, no write, HTTP 200 — exactly the "it just doesn't save" behaviour.
RETRACTION: no data was destroyed
An earlier version of this file said corrupted cells were unrecoverable and
advised re-entering data. That was wrong. jsonb_set_many returns the
target unchanged, so it never wrote garbage over anything — the writes
simply never landed. The {"0":"{","1":"}"} seen in the response is not the
stored value; it is produced in JavaScript by this line:
return { ...row, cells: { ...(row.cells as any), ...formulaValues } };
Spreading row.cells when it is the string "{}" yields character-indexed
keys. That is a second, independent defect: the spread assumes an object
and should be defensive regardless of how storage is fixed.
Every affected write site
All of these pre-stringify into a jsonb column and need the same treatment:
| File | Line | Column |
|---|---|---|
base/services/base-property.service.ts |
69, 144, 240 | base_properties.type_options |
base/services/base.service.ts |
361, 378, 393 | type_options, base_views.config |
base/services/base-view.service.ts |
46 | base_views.config |
base/services/base-schema.service.ts |
62 | pending_type_options |
base/services/base-row.service.ts |
58 | base_rows.cells (insert) |
audit/services/audit.service.ts |
194–195, 218–219 | audit.changes, audit.metadata |
page-verification/services/…service.ts |
261, 290 | page_verifications.data |
pdf-export/pdf-export.controller.ts |
89 | file_tasks.metadata |
Also affected: every sql\…${JSON.stringify(x)}::jsonb`interpolation, since the parameter is still a JS string. These need::text::jsonb`.
That "correction" was itself wrong — the ORIGINAL note above is right.
::jsonb alone does NOT save these sites. I claimed it did because cell
edits appeared to save in the UI; they were optimistic renders that never
persisted. Live proof: cells: {"prp3iqbku0r4":"test"} returned 200 with
updatedAt advanced and cells still {} — the UPDATE ran and
jsonb_set_many no-opped on a non-object patch.
The mechanism, from the driver source:
Binddoesparameters[i] = options.serializers[type](x)(connection.js:960), wheretypeis the parameter type the SERVER resolved — notinferType's client-side guess.typeHandlerskeys serializers by everyfromOID as well asto(types.js:206), andtypes.json.from = [114, 3802], soserializers[3802]isJSON.stringify.
So $1::jsonb makes the server type $1 as jsonb, and the driver then
JSON.stringifies an already-serialized string — double-encoding it.
$1::text::jsonb types $1 as text, whose serializer is '' + x, so the
string passes through and text→jsonb parses it.
That single fact explains every symptom uniformly: direct assignment and
::jsonb both resolve the parameter to jsonb and double-encode; only the
double cast avoids it. Every site now goes through toJsonb().
Genuinely unaffected: JSON.stringify in engine/filter.ts and
base-export.service.ts, which build SQL literals and CSV text rather than
column values.
This means audit, page verification and PDF export metadata are silently affected too — they just aren't read back into a UI that spreads them, so nobody noticed.
Fix
Pass the object directly and let the driver serialise it, or use core's
explicit cast (sql\${JSON.stringify(x)}::text::jsonb``). Apply consistently,
then verify:
SELECT jsonb_typeof(type_options) FROM base_properties; -- expect: object
SELECT jsonb_typeof(config) FROM base_views;
SELECT jsonb_typeof(changes) FROM audit WHERE changes IS NOT NULL;
Existing rows already written the broken way need repairing, not just new writes — something like:
UPDATE base_properties
SET type_options = (type_options #>> '{}')::jsonb
WHERE jsonb_typeof(type_options) = 'string';
Why the whole bundle got this wrong at once
The pattern was copied between services without ever being checked against a
running database. tsc accepts it (Kysely types the column as Json, and a
string is valid JSON), and the DI check added earlier does not exercise
queries. It is exactly the class of bug that only a live request surfaces.
Note on the 413
Raising Fastify's body limit would be treating the symptom — the payload is only large because it's a corrupted string-as-object. Fix the root cause and the body returns to a few hundred bytes.
Reproduction notes
- Date: create a date column → edit → enable "include time" + 24h → save → reopen the editor.
- Select: create a select column with two options → edit it → observe the 413.
- multiSelect create payload that appeared correct on the way in:
{"pageId":"…","name":"Multi-select","type":"multiSelect",
"typeOptions":{"choices":[{"id":"optg1inv4pa5","name":"tt","color":"gray"},
{"id":"opttcgfd7c64","name":"ttt","color":"red"}],
"choiceOrder":["optg1inv4pa5","opttcgfd7c64"],"defaultValue":null}}
RESOLVED — jsonb round-trip
Fix (code): ee/shared/jsonb.ts provides toJsonb() / toJsonbOrNull(),
emitting ${JSON.stringify(v)}::text::jsonb. Every direct column assignment
was converted:
| file | column |
|---|---|
base/services/base-property.service.ts (×3) |
type_options |
base/services/base-row.service.ts |
cells (row create) |
base/services/base-schema.service.ts |
pending_type_options |
base/services/base-view.service.ts |
config (view create) |
base/services/base.service.ts (×3) |
type_options, config |
audit/services/audit.service.ts (×4) |
changes, metadata |
page-verification/services/page-verification.service.ts (×2) |
data |
pdf-export/pdf-export.controller.ts |
metadata |
Sites already inside sql\...::jsonb`were left alone — they were never broken.base-export.service.ts:196` is CSV formatting, not a DB write.
toJsonbOrNull exists because audit.changes/metadata and
page_verifications.data are nullable: SQL NULL and the jsonb value null
are different, and only the former matches IS NULL.
Fix (data): ee/maintenance/repair-jsonb.ts. Run once after deploying:
docker compose exec -w /app/apps/server docmost \
node dist/ee/maintenance/repair-jsonb.js
Idempotent, reports rows touched per column, and conservative — it only
replaces a value when the unwrap yields an object, so a legitimately
stored scalar (a jsonb string "123") is never "repaired" into something
else. Anything it cannot decode is left untouched.
Not verified end-to-end. The write fix and repair script are typechecked but were not executed against a database in this session (no local Postgres, and the deployed build predates the change). After deploying, confirm with:
curl -sS -X POST https://<host>/api/bases/info -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"pageId":"<id>"}'
typeOptions and cells must render as JSON objects, not quoted strings.
Follow-up: the first repair missed every damaged row
The original repair function bailed out whenever a character-indexed object carried a non-numeric key. That rejected every genuinely damaged row: a value only got damaged because someone saved a change, so the stale numeric run always sits alongside named keys holding the new setting.
Date: {"0":"{", … 2110 keys …, "timeFormat":"24h", "includeTime":true}
test: {"0":"{", … 27732 keys …, "choices":[…], "choiceOrder":[…]}
The named keys are the newest truth and the numeric run is history. Correct
repair decodes the numeric run as a base, recurses into it, then merges the
named keys over the top — so older-only fields (defaultValue) survive and
the user's actual edit is never discarded.
Verified by running the shipped healJsonObject against the live damaged
data: 6/6 recovered, idempotent, scalars and clean objects untouched.
test went from 334,051 characters to 115 — that size is the 413.
Also added heal-on-ingress (healJsonObject) for typeOptions and view
config. A browser tab holding a damaged value posts it straight back, and
with storage now correct the server would faithfully persist the garbage and
nest it again on the next edit. Row cells needed nothing: sanitizeCells
already drops any key that is not a writable property id.
2. defaultValue was configured but never applied
Status: FIXED Found: while explaining the formula-on-new-row fix
The property editor lets you set a default for select, multiSelect, number,
text, url, email and checkbox ("Checked by default"), and saves it into
typeOptions.defaultValue. Nothing ever read it back. Zero references in
apps/server/src/ee/base; the client references it only in the property
configuration components. Table row creation posts { pageId } with no
cells at all, and Kanban seeds only its group-by column — so a configured
default silently did nothing.
This also explains the reported formula symptom. A formula inverting a
checkbox stayed blank on a new row because the row stored no checkbox key,
so prop("Checkbox") == false evaluated against a missing value rather than
against false. The recompute was firing correctly the whole time — row
creation already calls applyFormulas().
Fix, in two places:
base-row.service.tswithDefaults()seeds a new row's cells from each property'sdefaultValue. Explicitly supplied cells always win, so Kanban's group-by seed is preserved; formula columns are never seeded.base-formula.service.tsevaluateRow()applies the same defaults to its in-memory working copy, so rows predating a default (or predating this fix) still evaluate sensibly. Checkbox falls back tofalsewhen no default is configured — it has no unset state, and an absent cell renders unchecked.
Existing rows are deliberately not backfilled. Retroactively filling cells someone left blank is worse than leaving them; the evaluation-time defaults mean older rows still compute correctly without their stored data being rewritten.