mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
ec71903be7
* feat(store): NOT NULL hardening on items.fields/tags + views.config (IDEA-1486) Paired ship of IDEA-1486 (sibling-table JSONB NOT NULL hardening) and IDEA-1488 (handler-layer shape validation for ViewUpdate/CollectionUpdate). Generalizes the IDEA-1484 / collections.settings precedent (PR #562) to the remaining nullable JSON columns and closes the shape-validation gap that NOT NULL alone doesn't cover. Schema layer (IDEA-1486 floor): - migrations/056_items_jsonb_not_null.sql: rebuild items with fields TEXT NOT NULL DEFAULT '{}' and tags TEXT NOT NULL DEFAULT '[]', preserving all 7 indexes, recreating the 3 items_fts triggers, and rebuilding the FTS5 index. Foreign-keys-off / on bookends are lifted outside the IDEA-1485 atomic-tx wrapper. - migrations/057_views_config_not_null.sql: rebuild views with config TEXT NOT NULL DEFAULT '{}'. - pgmigrations/035 + 036: SET NOT NULL + SET DEFAULT on the three JSONB columns. Split per-table to mirror the SQLite per-table file granularity. Store layer (IDEA-1486 floor): - items.go UpdateItem and views.go UpdateView normalize "" -> "{}" / "[]" before writing. Same boundary pattern as CreateItem and the IDEA-1484 precedent at collections.go:248. - export.go ImportWorkspace coerces empty-string AND malformed JSON at import time on items.fields, items.tags, and collections.settings. Malformed input is coerce-and-log via slog.Warn (length only, never raw value) so legacy bundles don't fail-stop on one bad row. - remapFieldIDs early-returns "{}" on empty input so the second-pass UPDATE can't write "" verbatim. - Migrated the existing fmt.Printf at export.go:329 to slog.Warn for consistency. Handler layer (IDEA-1488 ceiling): - ViewCreate / ViewUpdate UnmarshalJSON via flexJSONToString with new ErrInvalidConfigType sentinel. - CollectionCreate / CollectionUpdate UnmarshalJSON with new ErrInvalidSettingsType sentinel. - handlers_views.go and handlers_collections.go surface both sentinels as 400 with the domain-level message (mirrors the BUG-1144 precedent at handlers_items.go:641). Tests: - internal/store/items_views_jsonb_test.go: store-coercion + import coercion + log-and-coerce-on-malformed + SQLite schema introspection (7 indexes + 3 FTS triggers + items_fts virtual table survival) + Postgres NOT NULL enforcement + migration re-apply idempotency + item_links round-trip after rebuild. - internal/server/handlers_views_collections_jsonb_test.go: PATCH/POST flexible-shape coverage for views.config and collections.settings, including domain-level 400 message assertions that the response does not leak Go unmarshal internals. Refs: IDEA-1486, IDEA-1488, IDEA-1484 (precedent), IDEA-1485 (substrate). * fix(store,models): codex R1 follow-ups for IDEA-1486 / IDEA-1488 Three concrete defects surfaced by codex R1 against the initial paired ship. All three close holes that defeated parts of the original contract. P1.1: migration 056 missed the playbook invocation_slug unique index. - migrations/056_items_jsonb_not_null.sql: recreate the partial UNIQUE index idx_items_invocation_slug_per_collection from migration 054 verbatim after the other 7 indexes. Without it, the application-layer pre-check in handlers_items.go:checkUniqueFields would be a TOCTOU race with no DB-level guard — the original index that 054 explicitly added as the actual uniqueness backstop would be silently dropped during the items rebuild. - items_views_jsonb_test.go: the schema-introspection test now asserts 8 indexes, not 7. Verified via `grep -rn "ON items(" migrations/` that no other items-touching indexes were missed. P1.2: flexJSONToString didn't validate inner content of JSON-encoded strings. Pre-fix, `{"config": "[]"}` / `{"settings": "not json"}` / `{"fields": "[]"}` / `{"tags": "{}"}` slipped past the shape validators because the `case '"'` branch unmarshalled the envelope and returned the inner string verbatim — bypassing the whole point of IDEA-1488. - models/item.go: after unmarshalling the JSON-encoded string, validate that the trimmed inner content's first byte matches expectedStart ('{' / '[') AND parses as JSON. Empty inner strings still pass through to the store-layer empty-string coercion (IDEA-1486 floor), so legacy "" → default normalization is preserved. - The pre-existing ItemUpdate fields/tags path inherits the same tightening because it routes through this helper — covered by new test file handlers_items_jsonb_inner_shape_test.go. - Parallel handler tests for views.config and collections.settings added to handlers_views_collections_jsonb_test.go. P2: coerceJSONForImport accepted JSON null as well-formed. - store/export.go: json.Unmarshal("null", &m) returns err=nil with m staying nil; the prior code returned the raw "null" string verbatim, which lands as JSONB null on Postgres (satisfies NOT NULL since SQL NULL ≠ JSONB null) or text "null" on SQLite. The non-nil check on the unmarshalled value routes JSON null to the existing log-and-coerce path with the rest of the malformed shapes. - items_views_jsonb_test.go: extended import test with an item carrying fields=null / tags=null; expects both coerced to "{}" / "[]" and the structured slog.Warn emitted. Verified: make test (SQLite) and the full ./... suite against the existing port-5445 Postgres container both pass cleanly. Refs: IDEA-1486, IDEA-1488, codex R1 review. * fix(store,server): codex R2 follow-ups for IDEA-1486 / IDEA-1488 Two defects surfaced by codex R2. P1 is a real ship-breaker; P2 closes a parity gap that R1 missed. P1: migration backfill normalized only SQL NULL, not malformed/wrong- shape JSON. The four new migrations originally wrote `WHERE x IS NULL`. Rows with fields = '' / 'null' / '[]' / 'not json' all survived the filter, then violated the post-migration NOT NULL+shape contract. Concrete ship- breaker on SQLite: 056 recreates the partial UNIQUE index on json_extract(fields, '$.invocation_slug') from migration 054, and json_extract errors on rows whose fields fails json_valid — a single bad row breaks CREATE INDEX mid-migration. Toggle-verified: with the NULL-only WHERE, the new SQLite test fails at exactly that CREATE INDEX with "SQL logic error: malformed JSON (1)". Widened the backfill clauses: - migrations/056: UPDATE items WHERE fields IS NULL OR json_valid(fields)=0 OR json_type(fields)!='object' (same trio for tags with 'array'). - migrations/057: same trio for views.config. - pgmigrations/035: WHERE fields IS NULL OR jsonb_typeof(fields)!='object'. JSONB rejects invalid JSON on write so the json_valid leg isn't needed on Postgres; only the shape check matters. - pgmigrations/036: same shape check on views.config. Regression tests in internal/store/items_views_jsonb_test.go: - TestItemsViewsJSONB_SQLiteBackfillRepairsMalformedShapes: applies migrations through 053 (skipping 054 which would itself error on malformed rows), seeds every observable shape pathology — SQL NULL, empty string, JSON null literal, wrong-shape JSON, non-JSON garbage — then applies 055/056/057. Asserts every malformed row is repaired AND the partial UNIQUE index actually fires on duplicate invocation_slug post-rebuild (proving the CREATE INDEX path executed end-to-end). - TestItemsViewsJSONB_PostgresBackfillRepairsMalformedShapes: parallel Postgres coverage; seeds JSONB null / array / primitive via direct ::jsonb cast and asserts the widened WHERE clause repairs each. P2: handleCreateItem didn't unwrap ErrInvalidFieldsType/ErrInvalidTagsType. R1's flexJSONToString tightening propagated the sentinels through every UnmarshalJSON path, but handleCreateItem (POST /items) still returned 'invalid JSON: <wrapped>' from decodeJSON. PATCH and the view/collection POST/PATCH handlers already unwrapped — POST was the outlier. - internal/server/handlers_items.go: mirror the PATCH-side errors.Is handling at the POST path. Brief, three-line diff. - handlers_items_jsonb_inner_shape_test.go: new TestCreateItem_ JSONEncodedStringInnerShapeValidated covers POST with fields=`[]`, fields=42, tags=`{}`, tags={"x":1}, plus a valid positive control. Asserts no "invalid JSON:" wrapper and presence of the sentinel message verbatim. Backfill-pattern audit (codex R2's grep prompt): only 055 / pg-034 (collections.settings, already shipped) exhibits the same NULL-only WHERE gap. Per the brief: NOT touched — retroactive repair belongs to a separate IDEA. Other NULL-only backfills (043/pg-023's oauth_providers, 044/pg-024's expires_at) handle their respective shapes correctly or aren't JSON columns. Verified: make test (SQLite) clean. Full ./... suite against the existing port-5445 Postgres container clean (one unrelated flake in internal/collab passed on rerun). Refs: IDEA-1486, IDEA-1488, codex R2 review.
165 lines
8.0 KiB
SQL
165 lines
8.0 KiB
SQL
-- IDEA-1486: harden items.fields and items.tags to NOT NULL DEFAULT.
|
|
-- See also IDEA-1484 / PR #562 (collections.settings precedent at
|
|
-- 055_collections_settings_not_null.sql).
|
|
--
|
|
-- SQLite does not support ALTER COLUMN ... SET NOT NULL, so the table is
|
|
-- rebuilt via the standard SQLite recipe. All inbound FKs to items(id)
|
|
-- (item_links.source_id/target_id, item_versions.item_id, comments.item_id,
|
|
-- item_stars.item_id, grants.item_id, item_yjs_updates.item_id, and the
|
|
-- items.parent_id self-reference) point at the primary key; since we
|
|
-- preserve every `id` value during the copy, those references remain
|
|
-- valid after the rename.
|
|
--
|
|
-- foreign_keys is toggled OFF for the duration to avoid the constraint
|
|
-- checker tripping on the transient DROP TABLE. The IDEA-1485 migration
|
|
-- runner (store.go:applySQLiteMigration) lifts these PRAGMA bookends out
|
|
-- of the wrapping transaction so they actually take effect on SQLite.
|
|
--
|
|
-- This migration also drops + recreates the three items_fts triggers
|
|
-- (auto-dropped with the items table) and issues a `'rebuild'` on the
|
|
-- FTS5 virtual table. The items_fts virtual table itself stays in place:
|
|
-- its `content='items'` link rebinds to the renamed items table by name,
|
|
-- and `'rebuild'` repopulates the internal index against the post-
|
|
-- rebuild rowids. Trigger bodies are copied verbatim from migration 005.
|
|
|
|
PRAGMA foreign_keys = OFF;
|
|
|
|
-- Backfill: repair any row whose fields / tags violates the post-
|
|
-- migration shape contract (NOT NULL + object-or-array JSON). The
|
|
-- WHERE clauses widen the NULL-only filter to cover three pre-
|
|
-- existing failure modes:
|
|
--
|
|
-- 1. fields IS NULL — the original IDEA-1486 case.
|
|
-- 2. json_valid(fields) = 0 — empty-string, "not json", or
|
|
-- any other non-JSON text the
|
|
-- NOT-NULL-DEFAULT never blocked.
|
|
-- 3. json_type(fields) != 'object' — JSON-but-wrong-shape (e.g.
|
|
-- 'null', '[]', '"a string"',
|
|
-- a number).
|
|
--
|
|
-- Same trio for tags with 'array' as the expected json_type. Per
|
|
-- codex R2 P1: the original NULL-only filter would have left every
|
|
-- malformed row in place. The partial UNIQUE index on
|
|
-- json_extract(fields, '$.invocation_slug') (recreated below) errors
|
|
-- on rows whose fields fails json_valid, so a single bad row would
|
|
-- break the CREATE INDEX mid-migration. Repair has to happen before
|
|
-- the rebuild so both the INSERT…SELECT and the post-rename CREATE
|
|
-- INDEX see only well-shaped rows.
|
|
UPDATE items
|
|
SET fields = '{}'
|
|
WHERE fields IS NULL
|
|
OR json_valid(fields) = 0
|
|
OR json_type(fields) != 'object';
|
|
|
|
UPDATE items
|
|
SET tags = '[]'
|
|
WHERE tags IS NULL
|
|
OR json_valid(tags) = 0
|
|
OR json_type(tags) != 'array';
|
|
|
|
DROP TABLE IF EXISTS items_new;
|
|
|
|
CREATE TABLE items_new (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
collection_id TEXT NOT NULL REFERENCES collections(id),
|
|
title TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
content TEXT DEFAULT '',
|
|
fields TEXT NOT NULL DEFAULT '{}',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
pinned INTEGER DEFAULT 0,
|
|
sort_order INTEGER DEFAULT 0,
|
|
parent_id TEXT REFERENCES items(id),
|
|
created_by TEXT DEFAULT 'user',
|
|
last_modified_by TEXT DEFAULT 'user',
|
|
source TEXT DEFAULT 'web',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
item_number INTEGER,
|
|
created_by_user_id TEXT REFERENCES users(id),
|
|
last_modified_by_user_id TEXT REFERENCES users(id),
|
|
assigned_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
agent_role_id TEXT REFERENCES agent_roles(id) ON DELETE SET NULL,
|
|
role_sort_order INTEGER NOT NULL DEFAULT 0,
|
|
content_flushed_at TEXT,
|
|
content_flushed_op_log_id INTEGER,
|
|
seq INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(workspace_id, slug)
|
|
);
|
|
|
|
INSERT INTO items_new (
|
|
id, workspace_id, collection_id, title, slug, content, fields, tags,
|
|
pinned, sort_order, parent_id, created_by, last_modified_by, source,
|
|
created_at, updated_at, deleted_at, item_number,
|
|
created_by_user_id, last_modified_by_user_id,
|
|
assigned_user_id, agent_role_id, role_sort_order,
|
|
content_flushed_at, content_flushed_op_log_id, seq
|
|
)
|
|
SELECT
|
|
id, workspace_id, collection_id, title, slug, content,
|
|
COALESCE(fields, '{}'),
|
|
COALESCE(tags, '[]'),
|
|
pinned, sort_order, parent_id, created_by, last_modified_by, source,
|
|
created_at, updated_at, deleted_at, item_number,
|
|
created_by_user_id, last_modified_by_user_id,
|
|
assigned_user_id, agent_role_id, role_sort_order,
|
|
content_flushed_at, content_flushed_op_log_id, seq
|
|
FROM items;
|
|
|
|
DROP TABLE items;
|
|
ALTER TABLE items_new RENAME TO items;
|
|
|
|
-- Recreate all 8 indexes (originally from 005, 017, 053, 054). Every
|
|
-- index is attached to the dropped items table and does NOT survive
|
|
-- DROP TABLE items; each must be re-issued explicitly.
|
|
CREATE INDEX IF NOT EXISTS idx_items_collection ON items(collection_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_workspace ON items(workspace_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_updated ON items(updated_at) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_assigned_user ON items(assigned_user_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_agent_role ON items(agent_role_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_workspace_seq ON items(workspace_id, seq DESC);
|
|
|
|
-- Playbook invocation_slug uniqueness guard (from migration 054). The
|
|
-- body matches 054 verbatim. This is the DB-level TOCTOU protection
|
|
-- that the application-layer checkUniqueFields in handlers_items.go
|
|
-- relies on — dropping it during the rebuild and forgetting to
|
|
-- recreate it would silently lose the uniqueness invariant.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_invocation_slug_per_collection
|
|
ON items(collection_id, json_extract(fields, '$.invocation_slug'))
|
|
WHERE json_extract(fields, '$.invocation_slug') IS NOT NULL
|
|
AND json_extract(fields, '$.invocation_slug') != ''
|
|
AND deleted_at IS NULL;
|
|
|
|
-- Recreate items_fts triggers (auto-dropped with the items table).
|
|
-- Bodies match 005_collections.sql:96-107 exactly. The IF EXISTS guards
|
|
-- mirror 046_restore_documents_fts_triggers.sql as an idempotency belt;
|
|
-- they are harmless no-ops on the success path because DROP TABLE items
|
|
-- already removed them.
|
|
DROP TRIGGER IF EXISTS items_fts_insert;
|
|
DROP TRIGGER IF EXISTS items_fts_update;
|
|
DROP TRIGGER IF EXISTS items_fts_delete;
|
|
|
|
CREATE TRIGGER items_fts_insert AFTER INSERT ON items BEGIN
|
|
INSERT INTO items_fts(rowid, title, content, tags) VALUES (NEW.rowid, NEW.title, NEW.content, NEW.tags);
|
|
END;
|
|
|
|
CREATE TRIGGER items_fts_update AFTER UPDATE ON items BEGIN
|
|
INSERT INTO items_fts(items_fts, rowid, title, content, tags) VALUES('delete', OLD.rowid, OLD.title, OLD.content, OLD.tags);
|
|
INSERT INTO items_fts(rowid, title, content, tags) VALUES (NEW.rowid, NEW.title, NEW.content, NEW.tags);
|
|
END;
|
|
|
|
CREATE TRIGGER items_fts_delete AFTER DELETE ON items BEGIN
|
|
INSERT INTO items_fts(items_fts, rowid, title, content, tags) VALUES('delete', OLD.rowid, OLD.title, OLD.content, OLD.tags);
|
|
END;
|
|
|
|
-- Rebuild the FTS5 internal index from the post-rebuild items table.
|
|
-- The contentless-content link binds by table name, so items_fts re-
|
|
-- attaches to the renamed items table automatically; 'rebuild' clears
|
|
-- and repopulates the internal index against the current rowids.
|
|
INSERT INTO items_fts(items_fts) VALUES ('rebuild');
|
|
|
|
PRAGMA foreign_keys = ON;
|