mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +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.
837 lines
29 KiB
Go
837 lines
29 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// IDEA-1486 + IDEA-1488 regression tests.
|
|
//
|
|
// These tests guard the paired ship of the JSONB NOT NULL hardening on
|
|
// items.fields / items.tags / views.config and the handler-layer shape
|
|
// validation that closes the contract loop on the writer side.
|
|
//
|
|
// Most tests are dialect-agnostic and run on whichever driver
|
|
// testStore() picks (SQLite by default, Postgres when
|
|
// PAD_TEST_POSTGRES_URL is set). The SQLite-specific schema
|
|
// introspection assertions (FTS triggers, indexes) are gated on the
|
|
// SQLite path because the equivalent invariants live in different
|
|
// system catalogs on Postgres.
|
|
|
|
// TestItemsViewsJSONB_UpdateItemCoercesEmptyStringFields exercises the
|
|
// IDEA-1486 floor at the items.go:1442 UPDATE path. After the NOT NULL
|
|
// migration ships, an UpdateItem with Fields="" would 500 on Postgres
|
|
// JSONB type-validation and silently store invalid JSON on SQLite.
|
|
// The store-layer coercion normalizes "" → "{}" / "[]" before the
|
|
// UPDATE reaches the database.
|
|
func TestItemsViewsJSONB_UpdateItemCoercesEmptyStringFields(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "JSONBCoerce")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
item := createTestItem(t, s, ws.ID, col.ID, "an item", "")
|
|
|
|
emptyFields := ""
|
|
emptyTags := ""
|
|
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
Fields: &emptyFields,
|
|
Tags: &emptyTags,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem with empty fields/tags: %v", err)
|
|
}
|
|
if updated.Fields != "{}" {
|
|
t.Errorf("Fields after empty-string update: want %q, got %q", "{}", updated.Fields)
|
|
}
|
|
if updated.Tags != "[]" {
|
|
t.Errorf("Tags after empty-string update: want %q, got %q", "[]", updated.Tags)
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_UpdateViewCoercesEmptyStringConfig exercises the
|
|
// IDEA-1486 floor at views.go:152.
|
|
func TestItemsViewsJSONB_UpdateViewCoercesEmptyStringConfig(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "ViewCoerce")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
view, err := s.CreateView(ws.ID, models.ViewCreate{
|
|
CollectionID: &col.ID,
|
|
Name: "All",
|
|
ViewType: "list",
|
|
Config: `{"layout":"list"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateView: %v", err)
|
|
}
|
|
|
|
emptyConfig := ""
|
|
updated, err := s.UpdateView(view.ID, models.ViewUpdate{
|
|
Config: &emptyConfig,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateView with empty config: %v", err)
|
|
}
|
|
if updated.Config != "{}" {
|
|
t.Errorf("Config after empty-string update: want %q, got %q", "{}", updated.Config)
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_ImportWorkspaceCoercesEmptyAndMalformed exercises
|
|
// the import-boundary normalization: empty-string and malformed JSON on
|
|
// items.fields / items.tags / collections.settings are coerced to the
|
|
// shape default. Mirrors the IDEA-1488 log-and-coerce policy: legacy
|
|
// bundles don't fail-stop on one bad row.
|
|
func TestItemsViewsJSONB_ImportWorkspaceCoercesEmptyAndMalformed(t *testing.T) {
|
|
s := testStore(t)
|
|
owner, err := s.CreateUser(models.UserCreate{
|
|
Email: "owner-import@example.com",
|
|
Name: "Owner",
|
|
Password: "passw0rd!",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
export := &models.WorkspaceExport{
|
|
Version: 1,
|
|
ExportedAt: "2026-05-16T00:00:00Z",
|
|
Workspace: models.WorkspaceExportMeta{
|
|
Name: "Imported",
|
|
Slug: "imported",
|
|
},
|
|
Collections: []models.CollectionExport{
|
|
{
|
|
ID: "old-coll-1",
|
|
Name: "Tasks",
|
|
Slug: "tasks",
|
|
Prefix: "TASK",
|
|
Schema: `{"fields":[]}`,
|
|
Settings: "", // empty-string sentinel → "{}"
|
|
CreatedAt: "2026-05-16T00:00:00Z",
|
|
UpdatedAt: "2026-05-16T00:00:00Z",
|
|
},
|
|
{
|
|
ID: "old-coll-2",
|
|
Name: "Ideas",
|
|
Slug: "ideas",
|
|
Prefix: "IDEA",
|
|
Schema: `{"fields":[]}`,
|
|
Settings: `not json at all`, // malformed → log-and-coerce
|
|
CreatedAt: "2026-05-16T00:00:00Z",
|
|
UpdatedAt: "2026-05-16T00:00:00Z",
|
|
},
|
|
},
|
|
Items: []models.ItemExport{
|
|
{
|
|
ID: "old-item-1",
|
|
CollectionID: "old-coll-1",
|
|
Title: "Item with empty fields",
|
|
Slug: "item-empty",
|
|
Content: "",
|
|
Fields: "", // empty → "{}"
|
|
Tags: "", // empty → "[]"
|
|
CreatedAt: "2026-05-16T00:00:00Z",
|
|
UpdatedAt: "2026-05-16T00:00:00Z",
|
|
},
|
|
{
|
|
ID: "old-item-2",
|
|
CollectionID: "old-coll-1",
|
|
Title: "Item with malformed json",
|
|
Slug: "item-bad",
|
|
Content: "",
|
|
Fields: `{not valid`, // malformed → log-and-coerce → "{}"
|
|
Tags: `also not json`, // malformed → log-and-coerce → "[]"
|
|
CreatedAt: "2026-05-16T00:00:00Z",
|
|
UpdatedAt: "2026-05-16T00:00:00Z",
|
|
},
|
|
{
|
|
ID: "old-item-3",
|
|
CollectionID: "old-coll-1",
|
|
Title: "Item with valid fields",
|
|
Slug: "item-good",
|
|
Content: "",
|
|
Fields: `{"status":"open"}`,
|
|
Tags: `["urgent"]`,
|
|
CreatedAt: "2026-05-16T00:00:00Z",
|
|
UpdatedAt: "2026-05-16T00:00:00Z",
|
|
},
|
|
{
|
|
// IDEA-1486 R1 codex P2: imported fields=null and
|
|
// tags=null must coerce to defaults. Without the
|
|
// non-nil check in coerceJSONForImport,
|
|
// json.Unmarshal("null", &m) returns err=nil with m
|
|
// staying nil, and the raw "null" string would land
|
|
// in the column — JSONB null on Postgres or text
|
|
// "null" on SQLite, both of which break downstream
|
|
// readers expecting an object/array.
|
|
ID: "old-item-null",
|
|
CollectionID: "old-coll-1",
|
|
Title: "Item with json null fields and tags",
|
|
Slug: "item-null",
|
|
Content: "",
|
|
Fields: `null`,
|
|
Tags: `null`,
|
|
CreatedAt: "2026-05-16T00:00:00Z",
|
|
UpdatedAt: "2026-05-16T00:00:00Z",
|
|
},
|
|
},
|
|
}
|
|
|
|
ws, err := s.ImportWorkspace(export, "Imported Coerce", owner.ID)
|
|
if err != nil {
|
|
t.Fatalf("ImportWorkspace: %v", err)
|
|
}
|
|
|
|
// All three items must be present, with coerced/preserved JSON.
|
|
cases := []struct {
|
|
title string
|
|
wantFields string
|
|
wantTags string
|
|
}{
|
|
{"Item with empty fields", "{}", "[]"},
|
|
{"Item with malformed json", "{}", "[]"},
|
|
{"Item with valid fields", `{"status":"open"}`, `["urgent"]`},
|
|
{"Item with json null fields and tags", "{}", "[]"},
|
|
}
|
|
for _, tc := range cases {
|
|
var gotFields, gotTags string
|
|
err := s.db.QueryRow(s.q(
|
|
"SELECT fields, tags FROM items WHERE workspace_id = ? AND title = ?"),
|
|
ws.ID, tc.title,
|
|
).Scan(&gotFields, &gotTags)
|
|
if err != nil {
|
|
t.Fatalf("query item %q: %v", tc.title, err)
|
|
}
|
|
// Postgres returns JSONB normalized form; normalize string compare
|
|
// via a tolerance for whitespace via raw equality fallback.
|
|
if !jsonEqualString(gotFields, tc.wantFields) {
|
|
t.Errorf("item %q: fields want %q, got %q", tc.title, tc.wantFields, gotFields)
|
|
}
|
|
if !jsonEqualString(gotTags, tc.wantTags) {
|
|
t.Errorf("item %q: tags want %q, got %q", tc.title, tc.wantTags, gotTags)
|
|
}
|
|
}
|
|
|
|
// Both collections should have settings = "{}" after coercion.
|
|
rows, err := s.db.Query(s.q(
|
|
"SELECT name, settings FROM collections WHERE workspace_id = ? ORDER BY name"),
|
|
ws.ID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("query collections: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var name, settings string
|
|
if err := rows.Scan(&name, &settings); err != nil {
|
|
t.Fatalf("scan collection: %v", err)
|
|
}
|
|
if name == "Tasks" || name == "Ideas" {
|
|
if !jsonEqualString(settings, "{}") {
|
|
t.Errorf("collection %q settings want %q, got %q", name, "{}", settings)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// jsonEqualString compares two JSON strings tolerantly of insignificant
|
|
// whitespace differences that emerge from JSONB ↔ TEXT round-trips.
|
|
func jsonEqualString(a, b string) bool {
|
|
return strings.Join(strings.Fields(a), "") == strings.Join(strings.Fields(b), "")
|
|
}
|
|
|
|
// TestItemsViewsJSONB_SQLiteRebuildPreservesIndexesAndTriggers is the
|
|
// SQLite-only schema-introspection regression test for migration 056.
|
|
// It asserts that after the items rebuild, all 7 indexes and 3 FTS
|
|
// triggers exist, and that the items_fts virtual table still answers
|
|
// queries against post-rebuild rowids. Catches regressions where the
|
|
// rebuild forgets to recreate an index or the FTS triggers don't
|
|
// re-attach to the renamed items table.
|
|
func TestItemsViewsJSONB_SQLiteRebuildPreservesIndexesAndTriggers(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
t.Skip("SQLite-specific schema-introspection test")
|
|
}
|
|
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "RebuildShape")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Seed an item so we can verify FTS still works post-rebuild.
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Auth quickref",
|
|
"OAuth2 authentication flow")
|
|
if item == nil {
|
|
t.Fatal("createTestItem returned nil")
|
|
}
|
|
|
|
// All 8 indexes from 005/017/053/054 must be present on items.
|
|
// idx_items_invocation_slug_per_collection was added to migration
|
|
// 056 in IDEA-1486 R1 codex P1.1 after the original draft forgot
|
|
// to recreate it; this test grew to 8 entries alongside that fix.
|
|
wantIndexes := map[string]bool{
|
|
"idx_items_collection": false,
|
|
"idx_items_workspace": false,
|
|
"idx_items_parent": false,
|
|
"idx_items_updated": false,
|
|
"idx_items_assigned_user": false,
|
|
"idx_items_agent_role": false,
|
|
"idx_items_workspace_seq": false,
|
|
"idx_items_invocation_slug_per_collection": false,
|
|
}
|
|
rows, err := s.db.Query(
|
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='items'")
|
|
if err != nil {
|
|
t.Fatalf("query sqlite_master for indexes: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var n string
|
|
if err := rows.Scan(&n); err != nil {
|
|
t.Fatalf("scan index name: %v", err)
|
|
}
|
|
if _, ok := wantIndexes[n]; ok {
|
|
wantIndexes[n] = true
|
|
}
|
|
}
|
|
for name, seen := range wantIndexes {
|
|
if !seen {
|
|
t.Errorf("expected index %q on items, not found in sqlite_master", name)
|
|
}
|
|
}
|
|
|
|
// All 3 FTS triggers must be present on items.
|
|
wantTriggers := map[string]bool{
|
|
"items_fts_insert": false,
|
|
"items_fts_update": false,
|
|
"items_fts_delete": false,
|
|
}
|
|
trows, err := s.db.Query(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='items'")
|
|
if err != nil {
|
|
t.Fatalf("query sqlite_master for triggers: %v", err)
|
|
}
|
|
defer trows.Close()
|
|
for trows.Next() {
|
|
var n string
|
|
if err := trows.Scan(&n); err != nil {
|
|
t.Fatalf("scan trigger name: %v", err)
|
|
}
|
|
if _, ok := wantTriggers[n]; ok {
|
|
wantTriggers[n] = true
|
|
}
|
|
}
|
|
for name, seen := range wantTriggers {
|
|
if !seen {
|
|
t.Errorf("expected trigger %q on items, not found in sqlite_master", name)
|
|
}
|
|
}
|
|
|
|
// FTS still answers queries against the post-rebuild table.
|
|
results, err := s.SearchItems(ws.ID, "authentication")
|
|
if err != nil {
|
|
t.Fatalf("SearchItems: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
var titles []string
|
|
for _, r := range results {
|
|
titles = append(titles, r.Item.Title)
|
|
}
|
|
sort.Strings(titles)
|
|
t.Errorf("expected 1 FTS result for 'authentication', got %d (%v)", len(results), titles)
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_SQLiteRebuildRejectsNullFieldsAfterMigration
|
|
// guards the post-migration NOT NULL constraint on items.fields and
|
|
// items.tags: any attempt to write a literal NULL must fail at the
|
|
// schema level on SQLite (driven by the table-rebuild from migration
|
|
// 056). This is the load-bearing invariant the IDEA-1486 floor adds.
|
|
func TestItemsViewsJSONB_SQLiteRebuildRejectsNullFieldsAfterMigration(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
t.Skip("SQLite-specific test; Postgres counterpart uses ALTER COLUMN SET NOT NULL")
|
|
}
|
|
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "NullReject")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Direct SQL INSERT bypassing the store helpers, attempting NULL on
|
|
// the post-migration-hardened columns.
|
|
for _, col2 := range []string{"fields", "tags"} {
|
|
stmt := "INSERT INTO items (id, workspace_id, collection_id, title, slug, " + col2 +
|
|
", created_at, updated_at) VALUES ('null-id-" + col2 + "', ?, ?, ?, ?, NULL, ?, ?)"
|
|
_, err := s.db.Exec(stmt, ws.ID, col.ID, "null "+col2, "null-slug-"+col2, "2026-05-16T00:00:00Z", "2026-05-16T00:00:00Z")
|
|
if err == nil {
|
|
t.Errorf("expected NOT NULL constraint violation inserting NULL %s, got success", col2)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_SQLiteRebuildRejectsNullConfigAfterMigration is
|
|
// the views.config counterpart to the items NULL-reject test.
|
|
func TestItemsViewsJSONB_SQLiteRebuildRejectsNullConfigAfterMigration(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
t.Skip("SQLite-specific test")
|
|
}
|
|
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "ViewNullReject")
|
|
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO views (id, workspace_id, name, slug, view_type, config, created_at, updated_at) "+
|
|
"VALUES ('null-view', ?, 'V', 'v', 'list', NULL, '2026-05-16T00:00:00Z', '2026-05-16T00:00:00Z')",
|
|
ws.ID,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected NOT NULL constraint violation inserting NULL views.config, got success")
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_PostgresRejectsNullFieldsAfterMigration is the
|
|
// Postgres counterpart — runs only when PAD_TEST_POSTGRES_URL is set.
|
|
// Verifies the ALTER COLUMN ... SET NOT NULL from pgmigrations/035 + 036
|
|
// actually rejects NULL writes.
|
|
func TestItemsViewsJSONB_PostgresRejectsNullFieldsAfterMigration(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set")
|
|
}
|
|
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "PgNullReject")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Attempt to write NULL into each hardened column.
|
|
for _, c := range []string{"fields", "tags"} {
|
|
stmt := "INSERT INTO items (id, workspace_id, collection_id, title, slug, " + c +
|
|
", created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)"
|
|
_, err := s.db.Exec(stmt,
|
|
"null-id-"+c, ws.ID, col.ID, "null "+c, "null-slug-"+c,
|
|
"2026-05-16T00:00:00Z", "2026-05-16T00:00:00Z")
|
|
if err == nil {
|
|
t.Errorf("expected NOT NULL constraint violation inserting NULL items.%s, got success", c)
|
|
}
|
|
}
|
|
|
|
// And on views.config.
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO views (id, workspace_id, name, slug, view_type, config, created_at, updated_at) "+
|
|
"VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)",
|
|
"null-view", ws.ID, "V", "v-null", "list",
|
|
"2026-05-16T00:00:00Z", "2026-05-16T00:00:00Z")
|
|
if err == nil {
|
|
t.Error("expected NOT NULL constraint violation inserting NULL views.config, got success")
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_SQLiteRebuildBackfillsNullToDefault exercises the
|
|
// migration backfill path: a pre-existing NULL row in items.fields /
|
|
// items.tags / views.config gets coerced to the shape default during
|
|
// the rebuild. The migration runs on the test store before any test
|
|
// rows are inserted, so this test seeds a "would-be-pre-migration" row
|
|
// via direct SQL, then runs the migration text again on top — verifying
|
|
// the backfill is idempotent.
|
|
//
|
|
// The IDEA-1485 atomic-tx runner re-records the migration if asked to
|
|
// re-apply, so we apply the migration text directly via
|
|
// applySQLiteMigration. Migrations are designed to be idempotent under
|
|
// repeated application (DROP TABLE IF EXISTS items_new; backfill UPDATEs
|
|
// are WHERE x IS NULL; index/trigger DROPs use IF EXISTS).
|
|
func TestItemsViewsJSONB_SQLiteRebuildIsIdempotent(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
t.Skip("SQLite-specific idempotency test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
s, err := New(filepath.Join(dir, "idempotent.db"))
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
mig056, err := migrationsFS.ReadFile("migrations/056_items_jsonb_not_null.sql")
|
|
if err != nil {
|
|
t.Fatalf("read 056: %v", err)
|
|
}
|
|
mig057, err := migrationsFS.ReadFile("migrations/057_views_config_not_null.sql")
|
|
if err != nil {
|
|
t.Fatalf("read 057: %v", err)
|
|
}
|
|
|
|
// Re-apply each migration; both should succeed without error.
|
|
// Use a unique synthetic name so the schema_migrations bookkeeping
|
|
// row doesn't collide with the already-applied one.
|
|
if err := applySQLiteMigration(s.db, "test_056_repeat.sql", string(mig056)); err != nil {
|
|
t.Fatalf("re-apply 056: %v", err)
|
|
}
|
|
if err := applySQLiteMigration(s.db, "test_057_repeat.sql", string(mig057)); err != nil {
|
|
t.Fatalf("re-apply 057: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_ItemLinksRoundTripAfterRebuild verifies that the
|
|
// items rebuild preserves the inbound FK from item_links.source_id /
|
|
// target_id → items.id. Since we preserve every id value in the
|
|
// INSERT…SELECT, the FK metadata re-resolves to the renamed table by
|
|
// name and existing links remain valid.
|
|
func TestItemsViewsJSONB_ItemLinksRoundTripAfterRebuild(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "LinkRoundTrip")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
source := createTestItem(t, s, ws.ID, col.ID, "Source", "")
|
|
target := createTestItem(t, s, ws.ID, col.ID, "Target", "")
|
|
|
|
_, err := s.CreateItemLink(ws.ID, models.ItemLinkCreate{
|
|
TargetID: target.ID,
|
|
LinkType: "blocks",
|
|
}, source.ID)
|
|
if err != nil {
|
|
t.Fatalf("CreateItemLink: %v", err)
|
|
}
|
|
|
|
links, err := s.GetItemLinks(source.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItemLinks: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link from source, got %d", len(links))
|
|
}
|
|
if links[0].TargetID != target.ID {
|
|
t.Errorf("link target = %q, want %q", links[0].TargetID, target.ID)
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_SQLiteBackfillRepairsMalformedShapes is the
|
|
// codex R2 P1 regression test. It applies migrations 001-055 against
|
|
// a fresh DB (stopping BEFORE the 056/057 hardening), seeds rows that
|
|
// violate the post-migration shape contract in every observable way
|
|
// (empty string, JSON null, wrong-shape JSON, non-JSON garbage), then
|
|
// applies 056 + 057 and asserts every malformed row is repaired to
|
|
// the default.
|
|
//
|
|
// The CREATE UNIQUE INDEX idx_items_invocation_slug_per_collection
|
|
// in 056 calls json_extract on every fields value — a single row
|
|
// with malformed JSON would error mid-migration. The "garbage"
|
|
// seeding below is the actual ship-breaker scenario; without the
|
|
// widened WHERE clause, the migration aborts.
|
|
func TestItemsViewsJSONB_SQLiteBackfillRepairsMalformedShapes(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
t.Skip("SQLite-specific (json_valid / json_type)")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "backfill_shape.db")
|
|
dsn := dbPath + "?_pragma=busy_timeout(30000)&_pragma=foreign_keys(on)&_txlock=immediate"
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
|
t.Fatalf("WAL: %v", err)
|
|
}
|
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL
|
|
)`); err != nil {
|
|
t.Fatalf("schema_migrations: %v", err)
|
|
}
|
|
|
|
// Apply migrations up to BUT NOT INCLUDING 054 (which creates the
|
|
// partial UNIQUE index on json_extract(fields, '$.invocation_slug')),
|
|
// 055, 056, 057. We then seed pre-054 malformed rows, then apply
|
|
// 056 + 057. The codex R2 P1 ship-breaker scenario is precisely:
|
|
// rows with malformed items.fields exist, then 056 recreates the
|
|
// partial UNIQUE index over those rows, and CREATE INDEX errors
|
|
// mid-migration when json_extract evaluates on the bad rows.
|
|
//
|
|
// We skip 054 in the pre-seed pass because 054's own CREATE INDEX
|
|
// would also error on malformed rows; 056 is the migration under
|
|
// test, and its widened backfill WHERE clause is the fix. After
|
|
// 056 repairs and rebuilds, the post-56 partial index sees only
|
|
// well-shaped rows.
|
|
names, err := readMigrationNames(migrationsFS, "migrations")
|
|
if err != nil {
|
|
t.Fatalf("read migrations: %v", err)
|
|
}
|
|
postSeed := map[string]bool{
|
|
"054_playbooks_invocation_fields.sql": true,
|
|
"055_collections_settings_not_null.sql": true,
|
|
"056_items_jsonb_not_null.sql": true,
|
|
"057_views_config_not_null.sql": true,
|
|
}
|
|
for _, name := range names {
|
|
if postSeed[name] {
|
|
continue
|
|
}
|
|
data, err := migrationsFS.ReadFile("migrations/" + name)
|
|
if err != nil {
|
|
t.Fatalf("read migration %s: %v", name, err)
|
|
}
|
|
if err := applySQLiteMigration(db, name, string(data)); err != nil {
|
|
t.Fatalf("apply %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// Seed prerequisites — a workspace + collection so item FKs resolve.
|
|
const wsID = "ws-bf"
|
|
const collID = "coll-bf"
|
|
const ts = "2026-05-16T00:00:00Z"
|
|
if _, err := db.Exec(
|
|
`INSERT INTO workspaces (id, name, slug, settings, created_at, updated_at)
|
|
VALUES (?, ?, ?, '{}', ?, ?)`,
|
|
wsID, "BFShape", "bfshape", ts, ts,
|
|
); err != nil {
|
|
t.Fatalf("seed workspace: %v", err)
|
|
}
|
|
if _, err := db.Exec(
|
|
`INSERT INTO collections (id, workspace_id, name, slug, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
collID, wsID, "Tasks", "tasks-bf", ts, ts,
|
|
); err != nil {
|
|
t.Fatalf("seed collection: %v", err)
|
|
}
|
|
|
|
// Seed items with every observable shape pathology. Pre-056 the
|
|
// items.fields column is nullable + no shape check, so every one
|
|
// of these INSERTs succeeds.
|
|
itemSeeds := []struct {
|
|
id string
|
|
slug string
|
|
fields any // string or nil for SQL NULL
|
|
tags any
|
|
}{
|
|
{"i-null", "i-null", nil, nil}, // SQL NULL on both
|
|
{"i-empty", "i-empty", "", ""}, // empty-string sentinel
|
|
{"i-jsnull", "i-jsnull", "null", "null"}, // JSON null literal
|
|
{"i-wrongshape", "i-wrong", "[]", "{}"}, // wrong shape on each
|
|
{"i-garbage", "i-garbage", "not json", "garb"}, // non-JSON text
|
|
{"i-valid", "i-valid", `{"k":"v"}`, `["a"]`}, // already valid — must be preserved
|
|
{"i-slug", "i-slug", `{"invocation_slug":"x"}`, `[]`}, // exercises the partial UNIQUE index
|
|
}
|
|
for _, sd := range itemSeeds {
|
|
if _, err := db.Exec(
|
|
`INSERT INTO items (id, workspace_id, collection_id, title, slug, fields, tags, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
sd.id, wsID, collID, sd.id, sd.slug, sd.fields, sd.tags, ts, ts,
|
|
); err != nil {
|
|
t.Fatalf("seed item %s: %v", sd.id, err)
|
|
}
|
|
}
|
|
|
|
// Seed views with shape pathologies on config.
|
|
viewSeeds := []struct {
|
|
id string
|
|
slug string
|
|
config any
|
|
}{
|
|
{"v-null", "v-null", nil},
|
|
{"v-empty", "v-empty", ""},
|
|
{"v-jsnull", "v-jsnull", "null"},
|
|
{"v-arr", "v-arr", "[]"},
|
|
{"v-garbage", "v-garb", "{garbage"},
|
|
{"v-valid", "v-valid", `{"layout":"list"}`},
|
|
}
|
|
for _, sd := range viewSeeds {
|
|
if _, err := db.Exec(
|
|
`INSERT INTO views (id, workspace_id, name, slug, view_type, config, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'list', ?, ?, ?)`,
|
|
sd.id, wsID, sd.id, sd.slug, sd.config, ts, ts,
|
|
); err != nil {
|
|
t.Fatalf("seed view %s: %v", sd.id, err)
|
|
}
|
|
}
|
|
|
|
// Now apply 055, 056, 057. 054 is deliberately skipped because
|
|
// its own CREATE INDEX on json_extract(fields, '$.invocation_slug')
|
|
// would error on the malformed seed rows — and in production, 054
|
|
// already shipped without that error, which means no production
|
|
// row carried malformed fields at 054-run-time. The codex R2 P1
|
|
// concern is forward-looking: if malformed rows appeared between
|
|
// 054 and 056 (or any other source), 056's REBUILD recreates the
|
|
// same partial UNIQUE index and would re-hit the json_extract
|
|
// error. The widened backfill WHERE clause in 056 repairs those
|
|
// rows BEFORE the rebuild, so the migration succeeds end-to-end.
|
|
postSeedOrder := []string{
|
|
"055_collections_settings_not_null.sql",
|
|
"056_items_jsonb_not_null.sql",
|
|
"057_views_config_not_null.sql",
|
|
}
|
|
for _, name := range postSeedOrder {
|
|
data, err := migrationsFS.ReadFile("migrations/" + name)
|
|
if err != nil {
|
|
t.Fatalf("read migration %s: %v", name, err)
|
|
}
|
|
if err := applySQLiteMigration(db, name, string(data)); err != nil {
|
|
t.Fatalf("apply %s (codex R2 P1 ship-breaker): %v", name, err)
|
|
}
|
|
}
|
|
|
|
// Assert every malformed item.fields was repaired to "{}", every
|
|
// malformed item.tags to "[]", and the valid rows survived.
|
|
type wantRow struct{ fields, tags string }
|
|
itemWant := map[string]wantRow{
|
|
"i-null": {`{}`, `[]`},
|
|
"i-empty": {`{}`, `[]`},
|
|
"i-jsnull": {`{}`, `[]`},
|
|
"i-wrongshape": {`{}`, `[]`},
|
|
"i-garbage": {`{}`, `[]`},
|
|
"i-valid": {`{"k":"v"}`, `["a"]`},
|
|
"i-slug": {`{"invocation_slug":"x"}`, `[]`},
|
|
}
|
|
for id, want := range itemWant {
|
|
var f, ta string
|
|
err := db.QueryRow(`SELECT fields, tags FROM items WHERE id = ?`, id).Scan(&f, &ta)
|
|
if err != nil {
|
|
t.Fatalf("query item %s: %v", id, err)
|
|
}
|
|
if f != want.fields {
|
|
t.Errorf("item %s fields: want %q, got %q", id, want.fields, f)
|
|
}
|
|
if ta != want.tags {
|
|
t.Errorf("item %s tags: want %q, got %q", id, want.tags, ta)
|
|
}
|
|
}
|
|
|
|
viewWant := map[string]string{
|
|
"v-null": `{}`,
|
|
"v-empty": `{}`,
|
|
"v-jsnull": `{}`,
|
|
"v-arr": `{}`,
|
|
"v-garbage": `{}`,
|
|
"v-valid": `{"layout":"list"}`,
|
|
}
|
|
for id, want := range viewWant {
|
|
var c string
|
|
err := db.QueryRow(`SELECT config FROM views WHERE id = ?`, id).Scan(&c)
|
|
if err != nil {
|
|
t.Fatalf("query view %s: %v", id, err)
|
|
}
|
|
if c != want {
|
|
t.Errorf("view %s config: want %q, got %q", id, want, c)
|
|
}
|
|
}
|
|
|
|
// The partial UNIQUE index must exist after the rebuild AND the
|
|
// uniqueness constraint must actually fire on duplicate
|
|
// invocation_slug — proof that the index works on post-rebuild rows
|
|
// AND that the migration succeeded end-to-end (which it could not
|
|
// have if json_extract had errored mid-CREATE INDEX).
|
|
var idxName string
|
|
err = db.QueryRow(
|
|
`SELECT name FROM sqlite_master
|
|
WHERE type='index' AND tbl_name='items'
|
|
AND name='idx_items_invocation_slug_per_collection'`,
|
|
).Scan(&idxName)
|
|
if err != nil {
|
|
t.Fatalf("invocation_slug index missing after migration: %v", err)
|
|
}
|
|
if _, err := db.Exec(
|
|
`INSERT INTO items (id, workspace_id, collection_id, title, slug, fields, tags, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"i-slug-dup", wsID, collID, "dup", "i-slug-dup",
|
|
`{"invocation_slug":"x"}`, `[]`, ts, ts,
|
|
); err == nil {
|
|
t.Error("expected UNIQUE constraint violation on duplicate invocation_slug, got success")
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_PostgresBackfillRepairsMalformedShapes is the
|
|
// Postgres counterpart. JSONB columns reject invalid JSON at write
|
|
// time, so the only pre-existing pathologies are SQL NULL and JSONB-
|
|
// valid-but-wrong-shape (e.g. JSONB null, an array where an object is
|
|
// required, a primitive). Codex R2 P1: the original NULL-only WHERE
|
|
// would have left wrong-shape rows in place.
|
|
func TestItemsViewsJSONB_PostgresBackfillRepairsMalformedShapes(t *testing.T) {
|
|
pgURL := os.Getenv("PAD_TEST_POSTGRES_URL")
|
|
if pgURL == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set")
|
|
}
|
|
_ = pgURL // testStore picks up the env
|
|
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "PgBackfill")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Direct INSERT with wrong-shape JSONB. Bypasses store helpers and
|
|
// writes JSONB null / array / primitive into the columns. JSONB
|
|
// null satisfies NOT NULL (SQL NULL ≠ JSONB null) but fails
|
|
// jsonb_typeof = 'object'.
|
|
const ts = "2026-05-16T00:00:00Z"
|
|
mustInsert := func(t *testing.T, id, fields, tags string) {
|
|
t.Helper()
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO items (id, workspace_id, collection_id, title, slug, fields, tags, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9)`,
|
|
id, ws.ID, col.ID, id, id, fields, tags, ts, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed item %s: %v", id, err)
|
|
}
|
|
}
|
|
mustInsert(t, "pg-jsnull", `null`, `null`)
|
|
mustInsert(t, "pg-wrongshape", `[]`, `{}`)
|
|
mustInsert(t, "pg-prim", `42`, `"a string"`)
|
|
mustInsert(t, "pg-valid", `{"k":"v"}`, `["tag"]`)
|
|
|
|
// Re-run the backfill UPDATE clauses from pgmigrations/035 (the
|
|
// migration itself already ran during testStore init; this
|
|
// exercises the UPDATE shape against rows seeded above).
|
|
if _, err := s.db.Exec(
|
|
"UPDATE items SET fields = '{}'::jsonb WHERE fields IS NULL OR jsonb_typeof(fields) != 'object'"); err != nil {
|
|
t.Fatalf("re-run fields backfill: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(
|
|
"UPDATE items SET tags = '[]'::jsonb WHERE tags IS NULL OR jsonb_typeof(tags) != 'array'"); err != nil {
|
|
t.Fatalf("re-run tags backfill: %v", err)
|
|
}
|
|
|
|
want := map[string]struct{ fields, tags string }{
|
|
"pg-jsnull": {`{}`, `[]`},
|
|
"pg-wrongshape": {`{}`, `[]`},
|
|
"pg-prim": {`{}`, `[]`},
|
|
"pg-valid": {`{"k": "v"}`, `["tag"]`}, // Postgres adds whitespace
|
|
}
|
|
for id, exp := range want {
|
|
var f, ta string
|
|
err := s.db.QueryRow(`SELECT fields::text, tags::text FROM items WHERE id = $1`, id).Scan(&f, &ta)
|
|
if err != nil {
|
|
t.Fatalf("query %s: %v", id, err)
|
|
}
|
|
if !jsonEqualString(f, exp.fields) {
|
|
t.Errorf("item %s fields want %q got %q", id, exp.fields, f)
|
|
}
|
|
if !jsonEqualString(ta, exp.tags) {
|
|
t.Errorf("item %s tags want %q got %q", id, exp.tags, ta)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestItemsViewsJSONB_ItemsFTSVirtualTableExists guards that the
|
|
// items_fts virtual table itself survives the items rebuild. (Per
|
|
// design decision D2, the virtual table is NOT dropped — only the
|
|
// three triggers are.)
|
|
func TestItemsViewsJSONB_ItemsFTSVirtualTableExists(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
t.Skip("SQLite-specific (items_fts is a SQLite FTS5 virtual table)")
|
|
}
|
|
|
|
s := testStore(t)
|
|
var name string
|
|
err := s.db.QueryRow(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
|
|
).Scan(&name)
|
|
if err == sql.ErrNoRows {
|
|
t.Fatal("items_fts virtual table missing after migration")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("query items_fts: %v", err)
|
|
}
|
|
}
|