2 Issues
Ulysia edited this page 2026-10-06 16:54:33 +02:00
This file contains ambiguous Unicode characters

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.md at commit f31ec6a. 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

  1. 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.
  2. Updating a select column 413s. /api/bases/properties/update returns FST_ERR_CTP_BODY_TOO_LARGE, and the request body contains typeOptions: {"0":"{", "1":"\"", "2":"0", …} — a JSON string that has been spread into an object keyed by character index.
  3. Select options aren't created (multiSelect create appears to work, but its payload is built fresh client-side and has not survived a reload).
  4. 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.
  5. 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 typeOptions comes 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?.includeTime on a string → undefined → toggle renders off (1)
  • {...opts} on a string → character-indexed object → enormous body (2)
  • typeOptions.choices on a string → undefined → no options (3)
  • opts?.source on 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:

  • Bind does parameters[i] = options.serializers[type](x) (connection.js:960), where type is the parameter type the SERVER resolved — not inferType's client-side guess.
  • typeHandlers keys serializers by every from OID as well as to (types.js:206), and types.json.from = [114, 3802], so serializers[3802] is JSON.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:

  1. base-row.service.ts withDefaults() seeds a new row's cells from each property's defaultValue. Explicitly supplied cells always win, so Kanban's group-by seed is preserved; formula columns are never seeded.
  2. base-formula.service.ts evaluateRow() 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 to false when 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.