From fd3bd823fbc26e7345b64b624faa80d1111782ae Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:33:32 +0000 Subject: [PATCH] fix: gracefully handle non-existent tables in getStore and add bug-hunting integration tests This commit implements the testing plan from docs/PLAN.md by adding `bug_integration_test.go` to test IndexedDB adapter behaviors including multiple initialization, numeric/text PKs, cursor concurrency, missing records, and missing tables. Additionally, it resolves a panic uncovered by the "Table Not Found" test. When executing a transaction on an uninitialized or missing object store, IndexedDB throws a JS exception which `syscall/js` translates into a Go panic. This commit adds a deferred `recover()` block inside `getStore` to cleanly catch the panic and return a standard Go `error`. Co-authored-by: cdvelop <44058491+cdvelop@users.noreply.github.com> --- tests/bug_integration_test.go | 153 ++++++++++++++++++++++++++++++++++ tx.go | 11 ++- 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/bug_integration_test.go diff --git a/tests/bug_integration_test.go b/tests/bug_integration_test.go new file mode 100644 index 0000000..987aef7 --- /dev/null +++ b/tests/bug_integration_test.go @@ -0,0 +1,153 @@ +//go:build wasm + +package tests_test + +import ( + "fmt" + "testing" + "github.com/tinywasm/indexdb" + "github.com/tinywasm/orm" +) + +type SimpleUser struct { + ID string + Email string +} + +func (m *SimpleUser) TableName() string { return "simple_users" } +func (m *SimpleUser) Schema() []orm.Field { + return []orm.Field{ + {Name: "ID", Type: orm.TypeText, Constraints: orm.ConstraintPK}, + {Name: "Email", Type: orm.TypeText, Constraints: orm.ConstraintUnique}, + } +} +func (m *SimpleUser) Values() []any { return []any{m.ID, m.Email} } +func (m *SimpleUser) Pointers() []any { return []any{&m.ID, &m.Email} } + +type SimpleSession struct { + ID string + UserID string +} + +func (m *SimpleSession) TableName() string { return "simple_sessions" } +func (m *SimpleSession) Schema() []orm.Field { + return []orm.Field{ + {Name: "ID", Type: orm.TypeText, Constraints: orm.ConstraintPK}, + {Name: "UserID", Type: orm.TypeText}, + } +} +func (m *SimpleSession) Values() []any { return []any{m.ID, m.UserID} } +func (m *SimpleSession) Pointers() []any { return []any{&m.ID, &m.UserID} } + +type NumericPK struct { + ID int64 + Value string +} + +func (m *NumericPK) TableName() string { return "numeric_pks" } +func (m *NumericPK) Schema() []orm.Field { + return []orm.Field{ + {Name: "ID", Type: orm.TypeInt64, Constraints: orm.ConstraintPK}, + {Name: "Value", Type: orm.TypeText}, + } +} +func (m *NumericPK) Values() []any { return []any{m.ID, m.Value} } +func (m *NumericPK) Pointers() []any { return []any{&m.ID, &m.Value} } + +type NonExistent struct { + ID string +} + +// Implement methods for NonExistent just to satisfy orm.Model interface in case adapter panics earlier +func (m *NonExistent) TableName() string { return "non_existent_table" } +func (m *NonExistent) Schema() []orm.Field { + return []orm.Field{{Name: "ID", Type: orm.TypeText, Constraints: orm.ConstraintPK}} +} +func (m *NonExistent) Values() []any { return []any{m.ID} } +func (m *NonExistent) Pointers() []any { return []any{&m.ID} } + +func TestBugScenario(t *testing.T) { + logger := func(args ...any) { t.Log(args...) } + dbName := "bug_integration_test_db" + + // 1. & 2. Multiple Initialization & Wait for Success + db1 := indexdb.InitDB(dbName, nil, logger, &SimpleUser{}, &SimpleSession{}, &NumericPK{}) + if db1 == nil { + t.Fatal("First InitDB returned nil") + } + + db2 := indexdb.InitDB(dbName, nil, logger, &SimpleUser{}, &SimpleSession{}, &NumericPK{}) + if db2 == nil { + t.Fatal("Second InitDB returned nil") + } + + // 3. TEXT Primary Key + user := SimpleUser{ID: "user_1", Email: "test@example.com"} + err := db1.Create(&user) + if err != nil { + t.Fatalf("Failed to create user with text PK: %v", err) + } + + var readUser SimpleUser + err = db1.Query(&readUser).Where("ID").Eq("user_1").ReadOne() + if err != nil { + t.Fatalf("Failed to ReadOne user with text PK: %v", err) + } + if readUser.Email != "test@example.com" { + t.Fatalf("Expected email test@example.com, got %s", readUser.Email) + } + + // 4. NUMERIC Primary Key + numEntry := NumericPK{ID: 42, Value: "Answer"} + err = db1.Create(&numEntry) + if err != nil { + t.Fatalf("Failed to create entry with numeric PK: %v", err) + } + + var readNum NumericPK + err = db1.Query(&readNum).Where("ID").Eq(int64(42)).ReadOne() + if err != nil { + t.Fatalf("Failed to ReadOne entry with numeric PK: %v", err) + } + if readNum.Value != "Answer" { + t.Fatalf("Expected value Answer, got %s", readNum.Value) + } + + // 5. Table Not Found + err = db1.Create(&NonExistent{ID: "ghost"}) + if err == nil { + t.Fatal("Expected error when creating on a non-existent table, got nil") + } else if err.Error() == "" { + t.Fatal("Expected a non-empty error message") + } + + // 6. Cursor Concurrency + for i := 0; i < 10; i++ { + u := SimpleUser{ID: fmt.Sprintf("concur_%d", i), Email: fmt.Sprintf("c%d@test.com", i)} + err := db1.Create(&u) + if err != nil { + t.Fatalf("Failed to create concurrent user %d: %v", i, err) + } + } + + // Read them back quickly + for i := 0; i < 10; i++ { + var ru SimpleUser + err := db1.Query(&ru).Where("ID").Eq(fmt.Sprintf("concur_%d", i)).ReadOne() + if err != nil { + t.Fatalf("Failed to read back concurrent user %d: %v", i, err) + } + if ru.Email != fmt.Sprintf("c%d@test.com", i) { + t.Fatalf("Mismatch on concurrent read %d", i) + } + } + + // 7. Empty Result Behavior + var missingUser SimpleUser + err = db1.Query(&missingUser).Where("ID").Eq("not_found_user").ReadOne() + if err == nil { + t.Fatal("Expected error when reading non-existent record, got nil") + } else if err.Error() != "record not found" { + t.Fatalf("Expected 'record not found' error, got: %v", err) + } +} diff --git a/tx.go b/tx.go index 824501f..c719816 100644 --- a/tx.go +++ b/tx.go @@ -103,11 +103,18 @@ func ProcessCursorRequest(req js.Value, onNext func(cursor js.Value) bool) error // Transaction helper to start a transaction and get the object store. // mode should be "readonly" or "readwrite". -func (d *IndexDBAdapter) getStore(tableName string, mode string) (js.Value, error) { +func (d *IndexDBAdapter) getStore(tableName string, mode string) (store js.Value, err error) { if !d.db.Truthy() { return js.Value{}, Err("Database not initialized") } + defer func() { + if r := recover(); r != nil { + err = Errf("Failed to access table %s: %v", tableName, r) + store = js.Value{} + } + }() + // Create transaction // Note: We are creating a new transaction for each operation here as per the Adapter pattern (stateless execution). // In a more complex ORM usage, we might want to reuse transactions, but orm.Adapter Execute is usually atomic per query. @@ -127,7 +134,7 @@ func (d *IndexDBAdapter) getStore(tableName string, mode string) (js.Value, erro return js.Value{}, Errf("Failed to create transaction for table %s", tableName) } - store := tx.Call("objectStore", tableName) + store = tx.Call("objectStore", tableName) if !store.Truthy() { return js.Value{}, Errf("Failed to get object store for table %s", tableName) }