Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package rqlite for openSUSE:Factory checked in at 2026-05-23 23:23:25 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rqlite (Old) and /work/SRC/openSUSE:Factory/.rqlite.new.2084 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rqlite" Sat May 23 23:23:25 2026 rev:48 rq:1354515 version:10.1.0 Changes: -------- --- /work/SRC/openSUSE:Factory/rqlite/rqlite.changes 2026-05-14 21:45:42.257376486 +0200 +++ /work/SRC/openSUSE:Factory/.rqlite.new.2084/rqlite.changes 2026-05-23 23:23:50.280042869 +0200 @@ -1,0 +2,10 @@ +Thu May 21 19:03:52 UTC 2026 - Andreas Stieger <[email protected]> + +- Update to version 10.1.0: + * Add Schema management page to Console app + * Display node TLS state in console's Cluster panel +- includes changes from 10.0.6: + * Limit number of redirects followed on cluster-join + * fix HTTP auth reporting + +------------------------------------------------------------------- @@ -40,0 +51,3 @@ +- includes fix for CVE-2026-33814: golang.org/x/net/http2: infinite + loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE + (boo#1265706) Old: ---- rqlite-10.0.5.tar.xz New: ---- rqlite-10.1.0.tar.xz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rqlite.spec ++++++ --- /var/tmp/diff_new_pack.oasQbQ/_old 2026-05-23 23:23:51.976112108 +0200 +++ /var/tmp/diff_new_pack.oasQbQ/_new 2026-05-23 23:23:51.976112108 +0200 @@ -17,7 +17,7 @@ Name: rqlite -Version: 10.0.5 +Version: 10.1.0 Release: 0 Summary: Distributed relational database built on SQLite License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.oasQbQ/_old 2026-05-23 23:23:52.108117496 +0200 +++ /var/tmp/diff_new_pack.oasQbQ/_new 2026-05-23 23:23:52.128118312 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/rqlite/rqlite.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v10.0.5</param> + <param name="revision">v10.1.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="changesgenerate">enable</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.oasQbQ/_old 2026-05-23 23:23:52.260123702 +0200 +++ /var/tmp/diff_new_pack.oasQbQ/_new 2026-05-23 23:23:52.272124192 +0200 @@ -1,7 +1,7 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rqlite/rqlite.git</param> - <param name="changesrevision">6aab1c97eddd21fde001aeec50dbf3605ef1069d</param> + <param name="changesrevision">b8d7a7dc5db9ce2c60ac37d2e0cbbaa2a2ae0811</param> </service> </servicedata> (No newline at EOF) ++++++ rqlite-10.0.5.tar.xz -> rqlite-10.1.0.tar.xz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/CHANGELOG.md new/rqlite-10.1.0/CHANGELOG.md --- old/rqlite-10.0.5/CHANGELOG.md 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/CHANGELOG.md 2026-05-20 08:23:17.000000000 +0200 @@ -1,3 +1,17 @@ +## v10.1.0 (May 20th 2026) +### New features +- [PR #2672](https://github.com/rqlite/rqlite/pull/2672), [PR #2673](https://github.com/rqlite/rqlite/pull/2673): Add Schema management page to Console app. + +### Implementation changes and bug fixes +- [PR #2671](https://github.com/rqlite/rqlite/pull/2671): Display node TLS state in console's Cluster panel. See [issue #2669](https://github.com/rqlite/rqlite/discussions/2669). + +## v10.0.6 (May 19th 2026) +### Implementation changes and bug fixes +- [PR #2664](https://github.com/rqlite/rqlite/pull/2664): Limit number of redirects followed on cluster-join. Fixes issue [#2616](https://github.com/rqlite/rqlite/issues/2616). Thanks @goingforstudying-ctrl +- [PR #2667](https://github.com/rqlite/rqlite/pull/2667): Refactor database `CheckpointManager` for clarity. +- [PR #2668](https://github.com/rqlite/rqlite/pull/2668): Move WAL-related types to `wal` package. +- [PR #2670](https://github.com/rqlite/rqlite/pull/2670): Correctly pass a `nil` Credential Store to the HTTP service, fixing HTTP auth reporting. See [issue #2669](https://github.com/rqlite/rqlite/discussions/2669). + ## v10.0.5 (May 12th 2026) ### Implementation changes and bug fixes - [PR #2658](https://github.com/rqlite/rqlite/pull/2658): Release images for _hard float_ and v6 ARM systems. See [discussion #2657](https://github.com/rqlite/rqlite/discussions/2657). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/CONTRIBUTING.md new/rqlite-10.1.0/CONTRIBUTING.md --- old/rqlite-10.0.5/CONTRIBUTING.md 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/CONTRIBUTING.md 2026-05-20 08:23:17.000000000 +0200 @@ -7,7 +7,7 @@ Many packages have their own `DESIGN.md` design document. You should review these before making changes. Doing so will help you understand the code and its construction. ## Issues are not assigned -Issues are never explicitly assigned to inviduals. If you wish to work on issue simply ask questions on the issue as needed, and generate a Pull Request with your proposed fix. +Issues are never explicitly assigned to inviduals. If you wish to work on issue simply ask questions on the issue as needed, and generate a Pull Request with your proposed fix. **Before coding any substantial change it's strongly recommended you discuss your proposal first**. ## Use of Coding Agents Coding Agents are fine to use, but any PR that appears to be "AI slop" or generated without any apparent thought by the actual programmer, may be closed without comment. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/cluster/client.go new/rqlite-10.1.0/cluster/client.go --- old/rqlite-10.0.5/cluster/client.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/cluster/client.go 2026-05-20 08:23:17.000000000 +0200 @@ -27,6 +27,7 @@ maxPoolCapacity = 64 defaultMaxRetries = 0 noRetries = 0 + maxRedirects = 10 protoBufferLengthSize = 8 ) @@ -513,7 +514,10 @@ if err := ctx.Err(); err != nil { return err } - for { + for i := 0; i < maxRedirects; i++ { + if err := ctx.Err(); err != nil { + return err + } conn, err := c.dial(nodeAddr) if err != nil { return err @@ -558,6 +562,7 @@ } return nil } + return errors.New("max redirects exceeded") } // BroadcastHWM performs a broadcast to all specified nodes. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/cluster/client_test.go new/rqlite-10.1.0/cluster/client_test.go --- old/rqlite-10.0.5/cluster/client_test.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/cluster/client_test.go 2026-05-20 08:23:17.000000000 +0200 @@ -340,6 +340,160 @@ } } +func Test_ClientJoinNode_MaxRedirectsExceeded(t *testing.T) { + // Simulate a cluster where every node always redirects to another node, + // creating an infinite redirect loop. The client should stop after + // maxRedirects and return an error. + redirects := make(chan struct{}, maxRedirects) + + srv := servicetest.NewService() + srv.Handler = func(conn net.Conn) { + redirects <- struct{}{} + + c := readCommand(conn) + if c == nil { + return + } + if c.Type != proto.Command_COMMAND_TYPE_JOIN { + t.Fatalf("unexpected command type: %d", c.Type) + } + + // Always respond with "not leader" and a leader address to redirect to. + p, err := pb.Marshal(&proto.CommandJoinResponse{ + Error: "not leader", + Leader: srv.Addr(), // redirect back to same server to simulate loop + }) + if err != nil { + conn.Close() + return + } + writeBytesWithLength(conn, p) + } + srv.Start() + defer srv.Close() + + c := NewClient(&simpleDialer{}, 0) + req := &command.JoinRequest{ + Address: "test-node-addr", + } + err := c.Join(context.Background(), req, srv.Addr(), nil, time.Second) + if err == nil { + t.Fatal("expected error when max redirects exceeded") + } + if !strings.Contains(err.Error(), "max redirects exceeded") { + t.Fatalf("expected 'max redirects exceeded' error, got: %s", err) + } + + close(redirects) + count := 0 + for range redirects { + count++ + } + if count != maxRedirects { + t.Fatalf("expected %d redirect attempts, got %d", maxRedirects, count) + } +} + +func Test_ClientJoinNode_ContextCanceledDuringRedirect(t *testing.T) { + // Simulate a redirect loop and cancel the context partway through. + // The context check happens at the top of each loop iteration, before + // dialing. Because the local test server responds instantly, we need + // to cancel the context before calling Join to guarantee the + // cancellation is observed before the loop exhausts maxRedirects. + srv := servicetest.NewService() + srv.Handler = func(conn net.Conn) { + c := readCommand(conn) + if c == nil { + return + } + if c.Type != proto.Command_COMMAND_TYPE_JOIN { + t.Fatalf("unexpected command type: %d", c.Type) + } + + p, err := pb.Marshal(&proto.CommandJoinResponse{ + Error: "not leader", + Leader: srv.Addr(), + }) + if err != nil { + conn.Close() + return + } + writeBytesWithLength(conn, p) + } + srv.Start() + defer srv.Close() + + c := NewClient(&simpleDialer{}, 0) + req := &command.JoinRequest{ + Address: "test-node-addr", + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately so the first iteration sees it + + err := c.Join(ctx, req, srv.Addr(), nil, time.Second) + if err == nil { + t.Fatal("expected error when context is canceled") + } + if !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("expected context canceled error, got: %s", err) + } +} + +func Test_ClientJoinNode_SuccessAfterRedirect(t *testing.T) { + // First response redirects, second response succeeds. + attempts := make(chan struct{}, 2) + + srv := servicetest.NewService() + srv.Handler = func(conn net.Conn) { + attempts <- struct{}{} + + c := readCommand(conn) + if c == nil { + return + } + if c.Type != proto.Command_COMMAND_TYPE_JOIN { + t.Fatalf("unexpected command type: %d", c.Type) + } + + var p []byte + var err error + if len(attempts) == 1 { + p, err = pb.Marshal(&proto.CommandJoinResponse{ + Error: "not leader", + Leader: srv.Addr(), + }) + } else { + p, err = pb.Marshal(&proto.CommandJoinResponse{}) + } + if err != nil { + conn.Close() + return + } + writeBytesWithLength(conn, p) + } + srv.Start() + defer srv.Close() + + c := NewClient(&simpleDialer{}, 0) + req := &command.JoinRequest{ + Address: "test-node-addr", + } + err := c.Join(context.Background(), req, srv.Addr(), nil, time.Second) + if err != nil { + t.Fatalf("expected success after redirect, got: %s", err) + } + + close(attempts) + count := 0 + for range attempts { + count++ + } + if count != 2 { + t.Fatalf("expected 2 attempts, got %d", count) + } +} + func Test_ClientRetry_SuccessFirstAttempt(t *testing.T) { srv := servicetest.NewService() srv.Handler = func(conn net.Conn) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/cmd/rqlited/main.go new/rqlite-10.1.0/cmd/rqlited/main.go --- old/rqlite-10.0.5/cmd/rqlited/main.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/cmd/rqlited/main.go 2026-05-20 08:23:17.000000000 +0200 @@ -437,7 +437,11 @@ func startHTTPService(cfg *Config, str *store.Store, cltr *cluster.Client, credStr *auth.CredentialsStore, pxy *proxy.Proxy) (*httpd.Service, error) { // Create HTTP server and load authentication information. - s := httpd.New(cfg.HTTPAddr, str, cltr, pxy, credStr) + var cs httpd.CredentialStore + if credStr != nil { + cs = credStr + } + s := httpd.New(cfg.HTTPAddr, str, cltr, pxy, cs) s.CACertFile = cfg.HTTPx509CACert s.CertFile = cfg.HTTPx509Cert diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/checkpoint_manager.go new/rqlite-10.1.0/db/checkpoint_manager.go --- old/rqlite-10.0.5/db/checkpoint_manager.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/db/checkpoint_manager.go 2026-05-20 08:23:17.000000000 +0200 @@ -1,7 +1,6 @@ package db import ( - "encoding/binary" "expvar" "fmt" "io" @@ -61,12 +60,10 @@ dbPath string walPath string - salt *[2]uint32 - - // nextFrameIdx is the index of the next frame in the WAL file to read as - // part of a checkpoint attempt. This value is 0-based in the sense that - // if the first frame is to be read, then this value is 0, not 1. - nextFrameIdx int64 + // resetWatch carries forward state from a partial-checkpoint (all pages + // moved but WAL not truncated) so the next attempt can detect whether + // SQLite reset the WAL in the meantime and decide where to resume. + resetWatch *WALResetWatch logger *log.Logger } @@ -74,10 +71,11 @@ // NewCheckpointManager returns a new CheckpointManager for the given database. func NewCheckpointManager(db *DB) (*CheckpointManager, error) { return &CheckpointManager{ - db: db, - dbPath: db.Path(), - walPath: db.WALPath(), - logger: log.New(log.Writer(), "[db-checkpoint] ", log.LstdFlags), + db: db, + dbPath: db.Path(), + resetWatch: &WALResetWatch{}, + walPath: db.WALPath(), + logger: log.New(log.Writer(), "[db-checkpoint] ", log.LstdFlags), }, nil } @@ -116,8 +114,7 @@ stats.Get(preCompactWALSize).(*expvar.Int).Set(walSzPre) if walSzPre == 0 { - cm.nextFrameIdx = 0 - cm.salt = nil + cm.resetWatch.Disarm() return &CheckpointManagerMeta{}, 0, nil } @@ -131,8 +128,7 @@ if !meta.Success() { return mmeta, 0, fmt.Errorf("checkpoint did not complete within %s", timeout) } - cm.nextFrameIdx = 0 - cm.salt = nil + cm.resetWatch.Disarm() return mmeta, 0, nil } @@ -144,31 +140,17 @@ } defer walFD.Close() - walReset := false - if cm.nextFrameIdx > 0 { - // The manager is telling us to start reading from other than the start. This - // means that the all frames were moved in the last checkpoint attempt, but the - // truncate itself failed. Before we start reading the WAL file from the given - // frame index, we need to check if the WAL was reset. If it was reset then - // SQLite started writing new frames from the beginning of the file, not appending - // at index nextFrameIdx. - // - // We can perform this check by comparing the salt values. If the salt values - // do not match, then we know that the WAL file has been reset, and we need to - // reset our state to start reading from the beginning of the WAL file again. - salt, err := readSaltAt(walFD) - if err != nil { - return nil, 0, fmt.Errorf("read WAL salt: %w", err) - } - if cm.salt == nil || *salt != *cm.salt { - cm.nextFrameIdx = 0 - cm.salt = salt - walReset = true - } + // Record the salt in the WAL header before any changes take place. If we + // were watching for a WAL reset, the watch tells us where to resume and + // whether the WAL was in fact reset since it was armed. + preChkSalt, err := wal.ReadSaltAt(walFD) + if err != nil { + return nil, 0, fmt.Errorf("read WAL salt: %w", err) } + startFrameIdx, walReset := cm.resetWatch.Check(preChkSalt) compactStartTime := time.Now() - scanner, err := wal.NewCompactingFrameScanner(walFD, cm.nextFrameIdx, false) + scanner, err := wal.NewCompactingFrameScanner(walFD, startFrameIdx, false) if err != nil { return nil, 0, fmt.Errorf("create compacting frame scanner: %w", err) } @@ -184,14 +166,11 @@ stats.Get(compactedWALSize).(*expvar.Int).Set(n) ///////////////////////////////////////////////////////////////////////////////// - // Now, attempt to perform a TRUNCATE checkpoint of the database. - - // Grab salt state before checkpoint is attempted. - cm.salt, err = readSaltAt(walFD) - if err != nil { - return nil, 0, fmt.Errorf("read WAL salt: %w", err) + // Now, attempt to perform a TRUNCATE checkpoint of the database. Close the WAL + // file handle explicitly to avoid any chance of intefering with SQLite. + if err := walFD.Close(); err != nil { + return nil, 0, fmt.Errorf("create WAL writer: %w", err) } - meta, err := cm.db.CheckpointWithTimeout(CheckpointTruncate, timeout) if err != nil { return nil, 0, fmt.Errorf("checkpoint: %w", err) @@ -205,8 +184,7 @@ if rc == 0 { // WAL was reset. Next write will start at the beginning of the WAL file. stats.Add(numCheckpointWALTruncated, 1) - cm.nextFrameIdx = 0 - cm.salt = nil + cm.resetWatch.Disarm() return mmeta, n, nil } if pnCkpt < pnLog { @@ -221,14 +199,13 @@ // file not truncated. We can use the WAL data, but it requires special // handling. // - // This needs to be handled carefully because we do not know where the next - // WAL frame will be written. That is only revealed when the next write takes - // place. It might be written to the end of WAL file, or SQLite might reset - // the WAL, which would cause the next WAL frame to be written at the beginning - // of the file. The only way to tell will be to check the salt values on the - // next checkpoint attempt. + // We do not know where the next WAL frame will be written. That is only + // revealed when the next write takes place: SQLite might append at the end + // of the WAL file, or it might reset the WAL and write from the start. The + // only way to tell on the next checkpoint attempt is to compare salt + // values, so arm the watch with the salt and resume frame we will need. stats.Add(numCheckpointPartial, 1) - cm.nextFrameIdx = int64(pnCkpt) + cm.resetWatch.Arm(preChkSalt, int64(pnCkpt)) return mmeta, 0, nil } stats.Add(numCheckpointInvariantErrors, 1) @@ -239,15 +216,3 @@ func (cm *CheckpointManager) Close() error { return nil } - -// readSaltAt reads the salt values from the WAL header at the given ReaderAt. -func readSaltAt(r io.ReaderAt) (*[2]uint32, error) { - buf := make([]byte, 8) - if _, err := r.ReadAt(buf, 16); err != nil { - return nil, err - } - return &[2]uint32{ - binary.BigEndian.Uint32(buf[0:]), - binary.BigEndian.Uint32(buf[4:]), - }, nil -} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/checkpoint_manager_test.go new/rqlite-10.1.0/db/checkpoint_manager_test.go --- old/rqlite-10.0.5/db/checkpoint_manager_test.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/db/checkpoint_manager_test.go 2026-05-20 08:23:17.000000000 +0200 @@ -513,11 +513,11 @@ if meta.WALReset { t.Fatal("first checkpoint: WALReset must be false on the very first attempt") } - if cm.nextFrameIdx == 0 { - t.Fatalf("first checkpoint: expected nextFrameIdx > 0 (pnCkpt == pnLog path), got 0 (meta=%s)", meta) + if !cm.resetWatch.armed { + t.Fatal("first checkpoint: expected resetWatch to be armed (pnCkpt == pnLog path)") } - if cm.salt == nil { - t.Fatal("first checkpoint: expected salt to be saved") + if cm.resetWatch.resumeFrameIdx == 0 { + t.Fatalf("first checkpoint: expected resumeFrameIdx > 0 (pnCkpt == pnLog path), got 0 (meta=%s)", meta) } // Release the reader. The previous checkpoint moved all frames to the diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/db.go new/rqlite-10.1.0/db/db.go --- old/rqlite-10.0.5/db/db.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/db/db.go 2026-05-20 08:23:17.000000000 +0200 @@ -210,8 +210,15 @@ // CheckpointMeta contains metadata about a WAL checkpoint operation. type CheckpointMeta struct { - Code int + // Code is the return code for the operation. Zero if successful. + Code int + + // Pages is the total number of frames in the WAL. Pages int + + // Moved is the total number of checkpointed frames in the log file + // (including any that were already checkpointed before Checkpoint + // was called) Moved int } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/db_test.go new/rqlite-10.1.0/db/db_test.go --- old/rqlite-10.0.5/db/db_test.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/db/db_test.go 2026-05-20 08:23:17.000000000 +0200 @@ -94,7 +94,7 @@ } } -// Test_WALNotCheckpointedOnClose tests that when a database with an existing +// Test_WALNotChangedOnReopen tests that when a database with an existing // file is opened, that the files are not modified in anyway. func Test_WALNotChangedOnReopen(t *testing.T) { path := mustTempPath() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/wal/compacting_section_scanner_test.go new/rqlite-10.1.0/db/wal/compacting_section_scanner_test.go --- old/rqlite-10.0.5/db/wal/compacting_section_scanner_test.go 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/db/wal/compacting_section_scanner_test.go 2026-05-20 08:23:17.000000000 +0200 @@ -357,9 +357,11 @@ defer srcConn.Close() mustExec(srcConn, "PRAGMA wal_autocheckpoint=0") mustExec(srcConn, "CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)") + mustExec(srcConn, "CREATE TABLE bar (id INTEGER PRIMARY KEY, name TEXT)") // Insert rows to generate WAL frames. - for i := range 100 { + testCount := 1000 + for i := range testCount { mustExec(srcConn, fmt.Sprintf("INSERT INTO foo (name) VALUES ('row%d')", i)) } @@ -373,6 +375,7 @@ if err != nil { t.Fatal(err) } + t.Logf("source WAL is %d bytes in size", mustFileSize(srcWAL)) s, err := NewCompactingFrameScanner(bytes.NewReader(walBytes), 0, false) if err != nil { @@ -395,6 +398,7 @@ if _, err := w.WriteTo(destF); err != nil { t.Fatal(err) } + t.Logf("compacted WAL is %d bytes in size", mustFileSize(destWAL)) // Open the dest database and verify data is present. destDSN := fmt.Sprintf("file:%s", destDB) @@ -404,12 +408,19 @@ } defer destConn.Close() + // Check that inserted record counts are correct for both tables. var count int if err := destConn.QueryRow("SELECT COUNT(*) FROM foo").Scan(&count); err != nil { t.Fatal(err) } - if count != 100 { - t.Fatalf("expected 100 rows, got %d", count) + if count != testCount { + t.Fatalf("expected %d rows, got %d", testCount, count) + } + if err := destConn.QueryRow("SELECT COUNT(*) FROM bar").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("expected 0 rows, got %d", count) } } @@ -691,3 +702,11 @@ t.Fatalf("failed to iterate rows: %s", err) } } + +func mustFileSize(path string) int64 { + stat, err := os.Stat(path) + if err != nil { + panic("failed to stat file") + } + return stat.Size() +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/wal/salt.go new/rqlite-10.1.0/db/wal/salt.go --- old/rqlite-10.0.5/db/wal/salt.go 1970-01-01 01:00:00.000000000 +0100 +++ new/rqlite-10.1.0/db/wal/salt.go 2026-05-20 08:23:17.000000000 +0200 @@ -0,0 +1,32 @@ +package wal + +import ( + "encoding/binary" + "fmt" + "io" +) + +// Salt represents the two 32-bit salt values from a SQLite WAL header. +type Salt [2]uint32 + +// Equal reports whether s and other hold the same salt values. +func (s Salt) Equal(other Salt) bool { + return s == other +} + +// String returns a human-readable representation of s. +func (s Salt) String() string { + return fmt.Sprintf("Salt(%d,%d)", s[0], s[1]) +} + +// ReadSaltAt reads the salt values from the WAL header via the given ReaderAt. +func ReadSaltAt(r io.ReaderAt) (Salt, error) { + var buf [8]byte + if _, err := r.ReadAt(buf[:], 16); err != nil { + return Salt{}, err + } + return Salt{ + binary.BigEndian.Uint32(buf[0:4]), + binary.BigEndian.Uint32(buf[4:8]), + }, nil +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/db/wal_reset_watch.go new/rqlite-10.1.0/db/wal_reset_watch.go --- old/rqlite-10.0.5/db/wal_reset_watch.go 1970-01-01 01:00:00.000000000 +0100 +++ new/rqlite-10.1.0/db/wal_reset_watch.go 2026-05-20 08:23:17.000000000 +0200 @@ -0,0 +1,47 @@ +package db + +import "github.com/rqlite/rqlite/v10/db/wal" + +// WALResetWatch records state carried forward from a checkpoint attempt +// that moved all pages from the WAL to the database but failed to truncate +// the WAL itself. In that situation SQLite may, on the next write, either +// append to the existing WAL or reset it from the start — and the only way +// to tell which happened is to compare the WAL header's salt against the +// salt observed at the time of the partial-checkpoint. +// +// While armed the watch carries the salt to compare against and the frame +// index to resume reading from if the WAL has not been reset. The zero +// value is the unarmed state; all methods are safe on the zero value. +type WALResetWatch struct { + armed bool + salt wal.Salt + resumeFrameIdx int64 +} + +// Arm starts the watch, recording the salt observed and the frame index +// from which the next checkpoint should resume if the WAL has not been +// reset in the meantime. +func (w *WALResetWatch) Arm(s wal.Salt, resumeFrameIdx int64) { + *w = WALResetWatch{armed: true, salt: s, resumeFrameIdx: resumeFrameIdx} +} + +// Disarm clears the watch. +func (w *WALResetWatch) Disarm() { + *w = WALResetWatch{} +} + +// Check returns the frame index at which the next checkpoint should begin +// reading the WAL, and whether a WAL reset was detected since the watch +// was armed. When the watch is not armed it returns (0, false). On reset +// detection the watch is disarmed: the prior resume frame index refers to +// a WAL state that no longer exists. +func (w *WALResetWatch) Check(current wal.Salt) (frameIdx int64, walReset bool) { + if !w.armed { + return 0, false + } + if w.salt.Equal(current) { + return w.resumeFrameIdx, false + } + w.Disarm() + return 0, true +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/http/console/index.html new/rqlite-10.1.0/http/console/index.html --- old/rqlite-10.0.5/http/console/index.html 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/http/console/index.html 2026-05-20 08:23:17.000000000 +0200 @@ -12,6 +12,7 @@ <div class="header-content"> <h1 id="home-link" class="home-link"><svg class="logo" viewBox="0 0 46.214 44.45" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image width="46.214" height="44.45" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIMAAAB+CAYAAAAHkaKhAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4AQbASMgLA364gAABWlJREFUeNrt3c9PXFUYxvFnDszAtDNhbBkqqf0BNBVTEwuxEBe0YNKwMVZr4kLd+A+oMUZafyytsjO6swvXutESEwFN1IVWMSYuSlAjLYImFkwsggwzlLkuDKaYGJm599zzvuc+3w2ruRyYT957584ZABhjjDHGGGOMMcYYY4wxxhhjzGUpLQudvDwfXHx/Zsc/VK5jP1Yampyu+fnT7ThxOKfmd9yoYZEffv5TcObZcaxXNnf8mKamBWTuPuoUxMczy5i6thL0deRVgDAaIJx9bqImCABQLm+gcuUH5DfLztZ+o7SJ4Te/x9TcSsDThCMI2ydE2vmEKGQbMPHUneg7LHtCGJ8h/DMhpgVMiDfkT4iUzxC2TYjmNNLHjmLV8YT46Olu3HtI5kWlSQIEACivb2BDwIR4bGwVU9eDgBgcQbgVhPNTRgUY/qAqEoSRBOFhixC2gbhCEGIxbEEoW4Yg6mWnQBAmaRAIQigGVxAIQhgG1xBE3YcQAsIkGQJfZTjGIA0CQTjCIBUCQcSMQToEgogJgxYISQdhCIEgYsGgFUJSQRhCIAirGHyBkDQQkW+IHf9iPnh0ZBLVaoB0o5sbnAM 97Xjywe7Ij9vUnMaFy6v49uc15yD69qUi3yCT8m0inOptx6cXH7K2k6hys/pa/+j0SL0gikcOYWlXMdQaChlg4gGDqEEYQqitTKM599XIsdHjd+zy7pRhCIEgIsOQNAg+gwiFYfLLheCsYwgnHUCQCOKbxfAgQmF4d/JH63sW/28ifOYIgjQQl+YCt5PBZaccTgSJIES9mkgqBJ9AGEIgCJUYJEPwAYQhBIJQhUETBM0gDCEQhAoMmiFoBNFICPGAqNyson90euQXYqitkx5B+DeI0++sjCz9IXPEiztN5Fpb8PgT/fCxTKM5N3AkH/lxe/YCj3R6hiHX2oJyVyc2ghTYziG81AukjUcYCMEtBDEYCKH2jkcMQQSGXGsLKoTgHIJzDFsQKoRQM4SMhWfOEAIhOMWQLxKCNAhOMOSLLSh3EkLNEHrsQogdAyGEgNBg/3sZQiCEWDEQQu3dEzOEWDAQQn0QXo4ZgnUMhKAHglUMhKALgjUMhKAPghUMhKATQuQYCEEvhEgxEIJuCJFhIIQ6IOyRBSESDIRQJ4ReWRCAkLujd7e2oNyZjRxCEPgL4a5CgB6BEEJPhj/zBSsT4a2ZAFeX/SSxt0kmBGv3GcK2sAoMjVW9AzE+txlsVOWuT+zH6+Y9AyEdgmgMPoHQAEE8Bh9AaIGgAoNmEJogqMGgEYQ2CKow3ApiVjgIjRDUYdgCcb9gEFohqMQgGYRmCGoxSAShHUJoDOeH23HgtkziQbiGkE8D3XtMyimGrrZs6pNnuhMNQgKEwQMNKeeTIekg fIIQ2TVDEkG4hpCLGEKkF5BJAiEBwlDEEICI/3sdAMwuloKh17/Dwu+Vuo9R6DiIG/m2uh9/MAe8PZhCMRv9E7G4FsAlhN1pYGB/g5VtZVYOGhZEWAwAcHsWeKUPKDbDm2xNBKv3GSScMn4tAS9OAb+tE4JTDJJAnJ8CFkuE4BSDFBDXS8ALX+sFERcE6xikgFhUCiJOCLF gIAgdEGLDQBC1lXcAIVYMBLFzCIMOIMSOgSDkQnCCgSBkQnCGgSDkQXCKgSBkQXCOIekgJEEQgSGpIKRBEIMhaSAkQgAsvYUdptmlUnDmvTVMbxacraEtC7xq6e1vqRBEYgCA2eUgGBqrYmEVTkFcOPH31yRAEIvBRxDSIYjG4BMIDRDEYfABhBYIKjBoBqEJghoMWyAuXXP7scq2bAr37dv5GroKhn8ckzHGGGOMMcYYY4wxxhhj7D/6C6s6jhtV+7AcAAAAAElFTkSuQmCC"/></svg> rqlite</h1> <nav> + <button class="tab" data-tab="schema">Schema</button> <button class="tab" data-tab="query">Query</button> <button class="tab" data-tab="backup">Backup</button> </nav> @@ -26,6 +27,22 @@ <div class="query-actions"> <button id="execute-btn">Execute</button> <span class="hint">Ctrl+Enter to execute</span> + <label class="query-option query-option-right" title="Read consistency level for queries; ignored for writes"> + Level + <select id="query-level"> + <option value="weak">weak (default)</option> + <option value="linearizable">linearizable</option> + <option value="none">none</option> + <option value="strong">strong</option> + </select> + </label> + <label class="query-option query-freshness-field" title="Maximum staleness — Go duration (e.g. 5s, 1m). Only meaningful with level=none."> + Freshness + <input type="text" id="query-freshness" placeholder="e.g. 5s"> + </label> + <label class="query-option query-freshness-field" title="Reject the query if data freshness can't be verified within the window"> + <input type="checkbox" id="query-freshness-strict"> Strict + </label> </div> </div> <div id="query-history" class="query-history"></div> @@ -51,6 +68,13 @@ <pre id="raw-json" class="raw-json"></pre> </div> </section> + <section id="schema" class="tab-content"> + <div class="schema-controls"> + <button id="schema-refresh-btn">Refresh</button> + <span id="schema-last-updated" class="last-updated"></span> + </div> + <div id="schema-content"></div> + </section> <section id="backup" class="tab-content"> <div class="backup-panel"> <h3>Download Backup</h3> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/http/console/static/css/style.css new/rqlite-10.1.0/http/console/static/css/style.css --- old/rqlite-10.0.5/http/console/static/css/style.css 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/http/console/static/css/style.css 2026-05-20 08:23:17.000000000 +0200 @@ -258,8 +258,46 @@ .query-actions { display: flex; align-items: center; - gap: 1rem; + gap: 0.75rem; margin-top: 0.5rem; + flex-wrap: wrap; +} + +.query-option { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.query-option-right { + margin-left: auto; +} + +.query-option select, +.query-option input[type="text"] { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.25rem 0.375rem; + font-size: 0.8125rem; + font-family: inherit; +} + +.query-option input[type="text"] { + width: 5rem; +} + +.query-freshness-field { + display: none; +} + +.query-actions.show-freshness .query-freshness-field { + display: inline-flex; } #execute-btn { @@ -437,6 +475,214 @@ color: var(--accent); } +/* Schema Tab */ +.schema-controls { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.schema-loading, +.schema-empty { + color: var(--text-muted); + font-size: 0.875rem; + padding: 1rem 0; +} + +.schema-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.schema-actions button { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + border-radius: 3px; + cursor: pointer; +} + +.schema-actions button:hover { + background: var(--surface-alt); + color: var(--text); +} + +.schema-count-toggle { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin-left: 0.5rem; + font-size: 0.8125rem; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.schema-section .detail-section-header { + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; +} + +.schema-kind { + display: inline-block; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.0625rem 0.375rem; + margin-right: 0.375rem; + border-radius: 3px; + background: var(--surface-alt); + color: var(--text-secondary); + font-weight: 600; + vertical-align: middle; +} + +.schema-count { + color: var(--text-muted); + font-weight: normal; + font-size: 0.8125rem; +} + +.schema-rowcount-text { + text-decoration: underline dotted; + text-underline-offset: 2px; + cursor: help; +} + +.schema-section .detail-section-body { + padding: 0; +} + +.schema-columns { + border-radius: 0; + box-shadow: none; +} + + +.result-table th.schema-center, +.result-table td.schema-center { + text-align: center; +} + +.schema-check { + color: var(--success-text); + font-weight: 700; + font-size: 1.5rem; + line-height: 1; +} + +.schema-dash { + color: var(--text-muted); +} + +.schema-pk-badge { + font-size: 0.75rem; + margin-left: 0.25rem; +} + +.schema-pk-row td:first-child { + font-weight: 600; +} + +.schema-sql-block { + padding: 0.5rem 0.75rem 0.75rem; + border-top: 1px solid var(--border-light); + background: var(--surface); +} + +.schema-sql-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.schema-sql-toggle { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + border-radius: 3px; + cursor: pointer; +} + +.schema-sql-toggle:hover { + background: var(--surface-alt); + color: var(--text); +} + +.schema-drop-table { + background: transparent; + border: 1px solid var(--error); + color: var(--error-text); + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + border-radius: 3px; + cursor: pointer; + font-weight: 600; +} + +.schema-drop-table:hover { + background: var(--error); + color: #fff; +} + +.schema-drop-table:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.schema-sql-wrapper { + position: relative; + margin-top: 0.5rem; +} + +.schema-sql-wrapper.hidden { + display: none; +} + +.schema-sql { + margin: 0; + padding: 0.625rem 2.25rem 0.625rem 0.75rem; + background: var(--surface-alt); + border-radius: 3px; + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + +.schema-sql-copy { + position: absolute; + top: 0.375rem; + right: 0.375rem; + background: var(--copy-bg, transparent); + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; + font-size: 0.875rem; + line-height: 1; + padding: 0.25rem 0.375rem; + color: var(--text-secondary); + transition: background 0.15s, color 0.15s; +} + +.schema-sql-copy:hover { + background: var(--copy-bg-hover, var(--surface)); + color: var(--text); +} + +.schema-section > .detail-section-body > .schema-sql { + margin: 0; + border-radius: 0; +} + /* Status Tab */ .status-controls { display: flex; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.0.5/http/console/static/js/app.js new/rqlite-10.1.0/http/console/static/js/app.js --- old/rqlite-10.0.5/http/console/static/js/app.js 2026-05-12 18:52:08.000000000 +0200 +++ new/rqlite-10.1.0/http/console/static/js/app.js 2026-05-20 08:23:17.000000000 +0200 @@ -26,6 +26,47 @@ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'"); } + function formatTimeOfDay(date) { + var d = date || new Date(); + var hh = String(d.getHours()).padStart(2, "0"); + var mm = String(d.getMinutes()).padStart(2, "0"); + var ss = String(d.getSeconds()).padStart(2, "0"); + return hh + ":" + mm + ":" + ss; + } + + // copyToClipboard copies text to the clipboard, returning a Promise that + // resolves on success and rejects on failure. Uses the async Clipboard + // API when available (secure contexts only), otherwise falls back to a + // hidden textarea + document.execCommand("copy") for plain-HTTP serves. + function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + return new Promise(function (resolve, reject) { + var ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + var ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) { + ok = false; + } + document.body.removeChild(ta); + if (ok) { + resolve(); + } else { + reject(new Error("Copy not supported in this context")); + } + }); + } + // --- Dark Mode --- var THEME_KEY = "rqlite_theme"; @@ -88,6 +129,9 @@ tabContents.forEach(function (tc) { tc.classList.remove("active"); }); tab.classList.add("active"); document.getElementById(target).classList.add("active"); + if (target === "schema") { + loadSchema(); + } }); }); @@ -96,6 +140,30 @@ var sqlInput = document.getElementById("sql-input"); var executeBtn = document.getElementById("execute-btn"); var resultsDiv = document.getElementById("query-results"); + var queryLevel = document.getElementById("query-level"); + var queryFreshness = document.getElementById("query-freshness"); + var queryFreshnessStrict = document.getElementById("query-freshness-strict"); + var queryActions = document.querySelector("#query .query-actions"); + + var QUERY_LEVEL_KEY = "rqlite_query_level"; + var savedLevel = localStorage.getItem(QUERY_LEVEL_KEY); + if (savedLevel) { + queryLevel.value = savedLevel; + } + updateFreshnessVisibility(); + + queryLevel.addEventListener("change", function () { + localStorage.setItem(QUERY_LEVEL_KEY, queryLevel.value); + updateFreshnessVisibility(); + }); + + function updateFreshnessVisibility() { + if (queryLevel.value === "none") { + queryActions.classList.add("show-freshness"); + } else { + queryActions.classList.remove("show-freshness"); + } + } executeBtn.addEventListener("click", executeQuery); @@ -181,6 +249,24 @@ renderHistory(); + function buildRequestURL() { + var params = ["timings"]; + var level = queryLevel.value; + if (level && level !== "weak") { + params.push("level=" + encodeURIComponent(level)); + } + if (level === "none") { + var f = queryFreshness.value.trim(); + if (f) { + params.push("freshness=" + encodeURIComponent(f)); + } + if (queryFreshnessStrict.checked) { + params.push("freshness_strict"); + } + } + return "/db/request?" + params.join("&"); + } + // --- Last query results (for export) --- var lastQueryResults = null; @@ -193,7 +279,7 @@ saveToHistory(sql); - apiRequest("POST", "/db/request?timings", [sql]) + apiRequest("POST", buildRequestURL(), [sql]) .then(function (resp) { lastQueryResults = resp.data; renderResults(resp.data); @@ -312,10 +398,13 @@ text = JSON.stringify(rows, null, 2); } - navigator.clipboard.writeText(text).then(function () { - var orig = btn.textContent; + var orig = btn.textContent; + copyToClipboard(text).then(function () { btn.textContent = "Copied!"; setTimeout(function () { btn.textContent = orig; }, 1500); + }, function () { + btn.textContent = "Copy failed"; + setTimeout(function () { btn.textContent = orig; }, 1500); }); } @@ -386,9 +475,13 @@ }); copyJsonBtn.addEventListener("click", function () { - navigator.clipboard.writeText(rawJsonPre.textContent).then(function () { + copyToClipboard(rawJsonPre.textContent).then(function () { copyJsonBtn.textContent = "\u2714"; setTimeout(function () { copyJsonBtn.textContent = "\u2398"; }, 1500); + }, function () { + copyJsonBtn.title = "Copy not supported in this context"; + copyJsonBtn.textContent = "\u2718"; + setTimeout(function () { copyJsonBtn.textContent = "\u2398"; }, 1500); }); }); @@ -412,12 +505,7 @@ if (!rawJsonWrapper.classList.contains("hidden")) { rawJsonPre.textContent = JSON.stringify(lastStatusData, null, 2); } - // Update last-updated timestamp - var now = new Date(); - var hh = String(now.getHours()).padStart(2, "0"); - var mm = String(now.getMinutes()).padStart(2, "0"); - var ss = String(now.getSeconds()).padStart(2, "0"); - lastUpdatedSpan.textContent = "Updated " + hh + ":" + mm + ":" + ss; + lastUpdatedSpan.textContent = "Updated " + formatTimeOfDay(); }).catch(function (err) { statusCards.innerHTML = '<div class="status-error">' + escapeHTML(err.message) + '</div>'; }); @@ -464,6 +552,7 @@ var oss = data.os || {}; var rt = data.runtime || {}; var cluster = data.cluster || {}; + var mux = data.mux || {}; var snapStore = store.snapshot_store || {}; var sections = [ @@ -533,7 +622,7 @@ rows: [ ["Address", cluster.addr], ["API Address", cluster.api_addr], - ["HTTPS", cluster.https] + ["TLS", mux.tls] ] }, { @@ -753,4 +842,314 @@ backupBtn.disabled = false; }); }); + + // --- Schema Tab --- + + var schemaContent = document.getElementById("schema-content"); + var schemaRefreshBtn = document.getElementById("schema-refresh-btn"); + var schemaLastUpdated = document.getElementById("schema-last-updated"); + + var SCHEMA_TABLES_QUERY = "SELECT m.name AS table_name, m.sql AS table_sql, p.name AS column_name, p.type, p.\"notnull\" AS not_null, p.dflt_value, p.pk FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' ORDER BY m.name, p.cid"; + var SCHEMA_OBJECTS_QUERY = "SELECT name, type, tbl_name, sql FROM sqlite_master WHERE type IN ('index', 'trigger') AND sql IS NOT NULL ORDER BY type, tbl_name, name"; + var SCHEMA_COUNT_ROWS_KEY = "rqlite_schema_count_rows"; + + function shouldCountRows() { + return localStorage.getItem(SCHEMA_COUNT_ROWS_KEY) === "true"; + } + + schemaRefreshBtn.addEventListener("click", loadSchema); + + function slugify(s) { + return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + } + + function loadSchema() { + schemaRefreshBtn.disabled = true; + schemaContent.innerHTML = '<div class="schema-loading">Loading schema...</div>'; + + apiRequest("POST", "/db/query?associative", [SCHEMA_TABLES_QUERY, SCHEMA_OBJECTS_QUERY]) + .then(function (resp) { + var tableNames = extractTableNames(resp.data); + if (tableNames.length === 0 || !shouldCountRows()) { + renderSchema(resp.data, {}); + schemaLastUpdated.textContent = "Last updated: " + formatTimeOfDay(); + return; + } + var countQueries = tableNames.map(function (n) { + return "SELECT COUNT(*) FROM " + sqlQuoteIdent(n); + }); + return apiRequest("POST", "/db/query?level=none", countQueries) + .then(function (cresp) { + var counts = {}; + var results = (cresp.data && cresp.data.results) || []; + results.forEach(function (r, i) { + if (r && !r.error && r.values && r.values[0]) { + counts[tableNames[i]] = r.values[0][0]; + } + }); + renderSchema(resp.data, counts); + schemaLastUpdated.textContent = "Last updated: " + formatTimeOfDay(); + }, function () { + renderSchema(resp.data, {}); + schemaLastUpdated.textContent = "Last updated: " + formatTimeOfDay(); + }); + }) + .catch(function (err) { + schemaContent.innerHTML = '<div class="result-error">' + escapeHTML(err.message) + '</div>'; + }) + .finally(function () { + schemaRefreshBtn.disabled = false; + }); + } + + function extractTableNames(data) { + var names = []; + var seen = {}; + var rows = (data && data.results && data.results[0] && data.results[0].rows) || []; + rows.forEach(function (row) { + var n = row.table_name; + if (n && !seen[n]) { + seen[n] = true; + names.push(n); + } + }); + return names; + } + + function renderSchema(data, rowCounts) { + rowCounts = rowCounts || {}; + if (!data || !data.results || data.results.length === 0) { + schemaContent.innerHTML = '<div class="result-error">No results returned</div>'; + return; + } + var tableResult = data.results[0] || {}; + var objectResult = data.results[1] || {}; + if (tableResult.error) { + schemaContent.innerHTML = '<div class="result-error">' + escapeHTML(tableResult.error) + '</div>'; + return; + } + + // Group table columns by table name, preserving order. + var tables = {}; + var tableOrder = []; + (tableResult.rows || []).forEach(function (row) { + var name = row.table_name; + if (!tables[name]) { + tables[name] = { sql: row.table_sql, columns: [] }; + tableOrder.push(name); + } + tables[name].columns.push(row); + }); + + // Group indexes and triggers by type. + var indexes = []; + var triggers = []; + (objectResult.rows || []).forEach(function (row) { + if (row.type === "index") indexes.push(row); + else if (row.type === "trigger") triggers.push(row); + }); + + if (tableOrder.length === 0 && indexes.length === 0 && triggers.length === 0) { + schemaContent.innerHTML = '<div class="schema-empty">No tables, indexes, or triggers found.</div>'; + return; + } + + var html = ""; + + html += '<div class="schema-actions">'; + html += '<button type="button" class="schema-expand-all">Expand all</button>'; + html += '<button type="button" class="schema-collapse-all">Collapse all</button>'; + html += '<label class="schema-count-toggle" title="Run COUNT(*) on every table when loading the schema">'; + html += '<input type="checkbox" class="schema-count-rows"' + (shouldCountRows() ? ' checked' : '') + '> Count rows'; + html += '</label>'; + html += '</div>'; + + // Tables. + tableOrder.forEach(function (tableName) { + var t = tables[tableName]; + var anchor = "schema-table-" + slugify(tableName); + html += '<div class="detail-section schema-section open" id="' + escapeHTML(anchor) + '">'; + html += '<div class="detail-section-header">'; + html += '<span><span class="schema-kind">table</span> ' + escapeHTML(tableName); + var columnsStr = t.columns.length + ' column' + (t.columns.length === 1 ? '' : 's'); + html += ' <span class="schema-count">(' + escapeHTML(columnsStr); + if (Object.prototype.hasOwnProperty.call(rowCounts, tableName)) { + var rc = Number(rowCounts[tableName]); + var rowsStr = rc.toLocaleString() + ' row' + (rc === 1 ? '' : 's'); + html += ', <span class="schema-rowcount-text" title="Row count read with 'none' consistency — may be slightly stale">' + escapeHTML(rowsStr) + '</span>'; + } + html += ')</span></span>'; + html += '<span class="arrow">▶</span>'; + html += '</div>'; + html += '<div class="detail-section-body">'; + html += '<table class="result-table schema-columns"><thead><tr>'; + html += '<th>Column</th>'; + html += '<th>Type</th>'; + html += '<th class="schema-center">Not Null</th>'; + html += '<th>Default</th>'; + html += '</tr></thead><tbody>'; + t.columns.forEach(function (col) { + html += '<tr' + (col.pk ? ' class="schema-pk-row"' : '') + '>'; + html += '<td>' + escapeHTML(col.column_name); + if (col.pk) { + html += ' <span class="schema-pk-badge" title="Primary key">🔑</span>'; + } + html += '</td>'; + html += '<td>' + escapeHTML(col.type) + '</td>'; + html += '<td class="schema-center">' + (col.not_null + ? '<span class="schema-check" title="Value may not be NULL">✓</span>' + : '<span class="schema-dash" title="Value may be NULL">—</span>') + '</td>'; + if (col.dflt_value === null || col.dflt_value === undefined) { + html += '<td class="null-value">NULL</td>'; + } else { + html += '<td>' + escapeHTML(col.dflt_value) + '</td>'; + } + html += '</tr>'; + }); + html += '</tbody></table>'; + html += '<div class="schema-sql-block">'; + html += '<div class="schema-sql-actions">'; + if (t.sql) { + html += '<button class="schema-sql-toggle" type="button">Show CREATE TABLE</button>'; + } else { + html += '<span></span>'; + } + html += '<button class="schema-drop-table" type="button" data-table-name="' + escapeHTML(tableName) + '">Drop table</button>'; + html += '</div>'; + if (t.sql) { + html += '<div class="schema-sql-wrapper hidden">'; + html += '<button type="button" class="schema-sql-copy" title="Copy to clipboard">⎘</button>'; + html += '<pre class="schema-sql">' + escapeHTML(t.sql) + '</pre>'; + html += '</div>'; + } + html += '</div>'; + html += '</div></div>'; + }); + + // Indexes. + indexes.forEach(function (idx) { + var anchor = "schema-index-" + slugify(idx.name); + html += '<div class="detail-section schema-section" id="' + escapeHTML(anchor) + '">'; + html += '<div class="detail-section-header">'; + html += '<span><span class="schema-kind">index</span> ' + escapeHTML(idx.name); + html += ' <span class="schema-count">on ' + escapeHTML(idx.tbl_name) + '</span></span>'; + html += '<span class="arrow">▶</span>'; + html += '</div>'; + html += '<div class="detail-section-body">'; + html += '<pre class="schema-sql">' + escapeHTML(idx.sql) + '</pre>'; + html += '</div></div>'; + }); + + // Triggers. + triggers.forEach(function (trg) { + var anchor = "schema-trigger-" + slugify(trg.name); + html += '<div class="detail-section schema-section" id="' + escapeHTML(anchor) + '">'; + html += '<div class="detail-section-header">'; + html += '<span><span class="schema-kind">trigger</span> ' + escapeHTML(trg.name); + html += ' <span class="schema-count">on ' + escapeHTML(trg.tbl_name) + '</span></span>'; + html += '<span class="arrow">▶</span>'; + html += '</div>'; + html += '<div class="detail-section-body">'; + html += '<pre class="schema-sql">' + escapeHTML(trg.sql) + '</pre>'; + html += '</div></div>'; + }); + + schemaContent.innerHTML = html; + } + + function sqlQuoteIdent(name) { + return '"' + String(name).replace(/"/g, '""') + '"'; + } + + function dropTable(tableName, btn) { + btn.disabled = true; + var sql = "DROP TABLE " + sqlQuoteIdent(tableName); + apiRequest("POST", "/db/execute", [sql]) + .then(function (resp) { + var results = (resp.data && resp.data.results) || []; + var err = results[0] && results[0].error; + if (err) { + window.alert("Failed to drop table \"" + tableName + "\": " + err); + btn.disabled = false; + return; + } + loadSchema(); + }) + .catch(function (err) { + window.alert("Failed to drop table \"" + tableName + "\": " + err.message); + btn.disabled = false; + }); + } + + schemaContent.addEventListener("change", function (e) { + var t = e.target; + if (t && t.classList && t.classList.contains("schema-count-rows")) { + localStorage.setItem(SCHEMA_COUNT_ROWS_KEY, t.checked ? "true" : "false"); + loadSchema(); + } + }); + + schemaContent.addEventListener("click", function (e) { + var header = e.target.closest && e.target.closest(".detail-section-header"); + if (header && schemaContent.contains(header)) { + header.parentElement.classList.toggle("open"); + return; + } + + var btn = e.target; + if (!btn.classList) return; + + if (btn.classList.contains("schema-sql-toggle")) { + var block = btn.closest(".schema-sql-block"); + var wrapper = block ? block.querySelector(".schema-sql-wrapper") : null; + if (!wrapper) return; + if (wrapper.classList.contains("hidden")) { + wrapper.classList.remove("hidden"); + btn.textContent = "Hide CREATE TABLE"; + } else { + wrapper.classList.add("hidden"); + btn.textContent = "Show CREATE TABLE"; + } + return; + } + + if (btn.classList.contains("schema-sql-copy")) { + var wrap = btn.closest(".schema-sql-wrapper"); + var preEl = wrap ? wrap.querySelector(".schema-sql") : null; + if (!preEl) return; + copyToClipboard(preEl.textContent).then(function () { + btn.textContent = "✔"; + setTimeout(function () { btn.innerHTML = "⎘"; }, 1500); + }, function () { + btn.title = "Copy not supported in this context"; + btn.textContent = "✘"; + setTimeout(function () { btn.innerHTML = "⎘"; }, 1500); + }); + return; + } + + if (btn.classList.contains("schema-drop-table")) { + var tableName = btn.getAttribute("data-table-name"); + if (!tableName) return; + var ok = window.confirm("Drop table \"" + tableName + "\"?\n\nThis permanently deletes the table and all its data. This action cannot be undone."); + if (!ok) return; + dropTable(tableName, btn); + return; + } + + + if (btn.classList.contains("schema-expand-all")) { + schemaContent.querySelectorAll(".schema-section").forEach(function (s) { + s.classList.add("open"); + }); + return; + } + + if (btn.classList.contains("schema-collapse-all")) { + schemaContent.querySelectorAll(".schema-section").forEach(function (s) { + s.classList.remove("open"); + }); + return; + } + }); })(); ++++++ vendor.tar.xz ++++++
