A database manager for the terminal, written in Zig: what a graphical client does - browse, edit, alter, dump, import - on a text screen, and quicker, because everything is a key press. SQLite, PostgreSQL, MySQL/MariaDB, SQL Server, Redis, Kafka, S3, Azure Blob, RabbitMQ, MQTT, SFTP and Kubernetes, behind one interface - and a CSV file, opened as a table.
Krtek is Czech for a mole: a small thing that digs through what is underneath and comes back up with what it found.
Written for the terminals people actually use: under the kitty keyboard protocol (Ghostty, Kitty, WezTerm) a key press reports the unshifted key plus a modifier, so what a key produced is read from the text the terminal reports, not from the key code - otherwise every capital letter and every shifted symbol arrives wrong.
brew install zales/krtek/krtek # macOS and Linux
sudo apt install krtek # Debian, Ubuntu - after the two lines below
tar xzf krtek-*.tar.gz && ./krtek-*/krtek # anything elseDebian and Ubuntu have an APT repository, so apt upgrade keeps krtek up to date
like anything else:
sudo install -d /etc/apt/keyrings
sudo curl -fsSLo /etc/apt/keyrings/krtek.gpg https://zales.github.io/krtek/krtek-archive-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/krtek.gpg] https://zales.github.io/krtek ./" | sudo tee /etc/apt/sources.list.d/krtek.list
sudo apt update && sudo apt install krtekThe metadata is signed, so apt checks it the way it checks any other repository -
no trusted=yes anywhere. .github/workflows/apt.yml
signs Release, publishes the public key beside it, and verifies its own signature
before it goes out, because a repository whose signature does not check out breaks
apt update for everyone who already trusts the key.
That repository is GitHub Pages, built from the
.deb files attached to the releases rather than from a build, so it can be
regenerated at any time and it keeps the older versions: apt install krtek=0.4.0-1
works. Every release also has the
.tar.gz, the .deb and the Homebrew formula for macOS and Linux on both
architectures. The .deb installs the binary, the man page and the copyright, and
Depends on nothing at all, so it goes on any Debian or Ubuntu of any age.
It needs nothing installed. SQLite, libpq, the MariaDB connector, libssh2 and
OpenSSL are linked into the binary, and Redis, Kafka, S3, Azure Blob, RabbitMQ, MQTT,
the Kubernetes API and SQL Server's TDS are spoken directly - down to the WebSocket a shell in a
container needs, so no kubectl either. The Linux
builds are static against musl and run on any distribution - checked on Debian with nothing
installed at all; the macOS builds leave only Apple's own libraries dynamic. That
is -Dstatic.
Or from source, which needs nothing but Zig 0.17:
zig build -Doptimize=safe
./zig-out/bin/krtek # the list of saved connections
./zig-out/bin/krtek database.db
./zig-out/bin/krtek data.csv # a CSV or TSV file, as one table
./zig-out/bin/krtek postgres://user@host:5432/database
./zig-out/bin/krtek mysql://user@host:3306/database
./zig-out/bin/krtek mssql://user@host:1433/database
./zig-out/bin/krtek redis://host:6379/0
./zig-out/bin/krtek rediss://host:6380/0 # the same over TLS
./zig-out/bin/krtek kafka://host:9092
./zig-out/bin/krtek s3://bucket
./zig-out/bin/krtek s3+http://key:secret@localhost:9000/bucket
./zig-out/bin/krtek azure://account:key@container
./zig-out/bin/krtek rabbit://guest@host:15672/vhost
./zig-out/bin/krtek mqtt://user@host:1883 # everything a broker carries
./zig-out/bin/krtek mqtts://host/home/# # over TLS, and one branch of it
./zig-out/bin/krtek sftp://user@host/srv/data
./zig-out/bin/krtek k8s:// # the kubeconfig's current context
./zig-out/bin/krtek k8s://prod/payments # a context, and a namespace in itA SQLite file is opened through SQLite's own VFS: edits go straight to disk and there is nothing to save. A name with no file behind it is asked about before a database is made of it - SQLite makes whatever it is asked to open, and a letter wrong in a path used to be a new, empty database beside the real one. A CSV file is one table, and is written when a row or a column changes - see CSV files.
Connections are saved, and started with no argument the app opens the list of
them: enter connects, a adds, e edits, x removes - and asks first,
because a password kept in the keychain goes with it. The file is
~/.config/krtek/connections, one name<TAB>target line per connection, readable
and editable by hand.
Adding one asks which engine first, and the fields under it are that engine's:
a host and a database for PostgreSQL, a bucket and a region for S3, a vhost for
RabbitMQ, a file for SQLite. Nobody has to remember that a MinIO bucket is
s3+http://key@host:9000/bucket - the form writes the target out of what was
typed.
Editing takes a target apart again, and only when putting it back together
gives exactly the same string. Anything this does not model - a libpq keyword
string, an amqp:// url, a query with sslmode in it - stays the single field it
always was, under an engine called target, so editing a connection can never
quietly rewrite it.
Where a password is kept is chosen per connection, in the connection form:
| what happens | where it lives | |
|---|---|---|
ask |
the default: asked when the server asks, used once, forgotten | nowhere |
file |
the connection stops asking | password=… on its line, plain text, file mode 0600 |
keychain |
the connection stops asking; macOS guards it | the login keychain, service krtek, account = the target |
touchid |
a fingerprint instead of typing, every time | the same keychain item, read only after Touch ID says so |
The list says which is which, so it is never a surprise. Leave the field empty and the place set, and the password is asked for once and then kept there.
keychain is the better of the two on a Mac: nothing is on disk in the clear, and
macOS decides whether to hand it over. It will ask you the first time each build
of krtek reads an item, because a keychain item remembers which binary created
it and a rebuilt binary is a different one; "Always Allow" settles it for that
binary. If you refuse, the app falls back to asking for the password itself.
touchid is offered where the Mac has a reader with a finger enrolled. The
keychain does not enforce it, and it is worth being exact about that: an item
the keychain itself guards with a fingerprint has to live in the data protection
keychain, and that needs an entitlement only a binary signed with a real team
identity may carry - claimed by the ad-hoc signature these builds have, macOS
kills the process on the spot. That was tried first. So what happens is that
krtek asks for the fingerprint through LocalAuthentication and then reads the
item the way it always did: a lock on the door of this program, not on the safe.
Worth having anyway, because the alternative people actually reach for is
Always Allow, which hands the binary the password silently and forever. A
refusal is not a failure - it falls back to asking for the password, which is
what somebody whose finger will not read needs.
The fingerprint comes after the keychain has handed the password over, not before. That changes nothing about what is promised - this program will not use a saved password until somebody at the keyboard says so, and that holds whichever order they come in - but it means nobody is asked to touch anything before finding out there is nothing to unlock.
The two modes guard different things, and that is the whole of the choice.
A keychain item remembers which binary may read it, so the first read from a
build macOS has not seen brings up "krtek wants to use your confidential
information" - that is the dialog asking for your login password, and Always Allow settles it for that binary. Strong, and one dialog per build.
A touchid item is stored so the keychain hands it over without asking, and the
fingerprint is what stands in the way instead. That is deliberate: a fingerprint
that arrives only after a dialog asking for the login password is a fingerprint
replacing nothing, which is what it was after every rebuild. What it costs is
real - any program running as you can read those particular passwords out of
the keychain with no prompt at all - and what it buys is that the keychain's
question about which binary is replaced by one about whether the owner is at
the keyboard. keychain still answers the first question; pick by which one
matters to you.
Where an engine has its own password store - ~/.pgpass for libpq, ~/.my.cnf
for MySQL, PGPASSWORD in the environment - that is better still, and it keeps
working either way.
/ narrows the list, by name or by what the connection points at - thirty-odd
of them is more than anybody scrolls through, and half are told apart by their
host rather than by the name somebody gave them. The name is matched the fuzzy
way the sidebar and the palette are; the target is searched for the letters as
typed, because every target begins postgres:// and runs to forty characters,
and a fuzzy match against one says yes to nearly everything.
A connection can be marked read-only, with r in the list or the toggle in
the form. Nothing is written through one: no insert, no update, no delete, no
schema statement, no upload and no removal of a file - and a statement typed in
the editor is refused too, by the conservative test, so anything whose first word
is not a read is taken to write. It is not a claim about the server; the account
may be allowed to do all of it. It is about what this program will do with that
connection, which is the useful thing to be able to say when a production
database sits in the same list as half a dozen local ones and both are one
enter away.
The list says which connections are marked, so it is visible before connecting,
and the header says so while one is open. A context from a kubeconfig can be
marked as well: there is nowhere in a kubeconfig to keep the mark, so marking one
saves a connection of krtek's own with the same name and the same target -
which is why the connection form still refuses to edit a found connection while
this key does not. Renaming one leaves two answers to what a cluster is called;
marking one leaves one.
Nothing is only discoverable by reading the key map. ctrl+k opens a command
palette: type a few words of what you want - dro tab, expo, vacuum - and it
lists what matches with the letters that matched underlined, each with the key
that does it, so using it teaches the keys. The same fuzzy match filters the
object list, so ordit finds order_items. The line at the bottom of the screen
shows what is worth pressing in the pane you are actually in, and an empty table
says how to put something in it.
It fits the terminal it is in. It asks whether the background is light or
dark and colours itself accordingly, and follows a theme switch while
running; KRTEK_THEME=light or =dark settles it by hand. Copying goes through
OSC 52, so y then c, y, p or s puts the value, the row, the page as CSV
or the last statement in the system clipboard - over ssh and inside tmux too,
because there is no local clipboard involved. Where the terminal can draw images
(Kitty, Ghostty, WezTerm), an image BLOB is shown as the image; everywhere else,
and for any other BLOB, as hex with its printable characters beside it.
A statement can be given up on. Anything that takes more than a moment puts a
spinner and a running time at the bottom, and ctrl+c stops it: SQLite's virtual
machine is halted through its progress handler, PostgreSQL gets a cancel request
on its own socket, MySQL a KILL QUERY down a second connection while the first
waits through the connector's non-blocking calls. The connection stays usable, and
a batch stops at the statement that was interrupted. The same key ends the
program, and takes two presses to do it: one that arrives a moment after the
statement finished is a ctrl+c with nothing to stop, and it used to take every
tab with it.
So can a connection. One that takes more than a moment to open puts a panel
in the middle of the screen: what is being opened, the step it is on - looking a
name up, connecting to an address, the TLS handshake, logging in, reading what
is there - and for how long. esc or ctrl+c gives up and puts back whatever
was on screen before. Anything else typed meanwhile is kept and happens once the
connection is there, as it did when a connect simply blocked; giving up lets go
of it. The attempt is made on a thread of its own, because a name that does not
resolve and an address that does not answer are calls nothing can interrupt; one
that was given up on runs out unwatched and closes what it opened, if it opened
anything.
The picture above is a table: the objects in the database down the left, the rows beside them, the cell under the cursor picked out and shown whole at the bottom.
The SQL editor: keywords, strings, numbers and comments in colour, tab completing
a table or column name, ctrl+s running it.
Forms have one shape everywhere: labels right-aligned against their fields, the
field itself underlined for its whole width so an empty one is still visibly
somewhere to type, and the frame drawn around what is in it rather than around the
pane. What a value is - a column's type, its NOT NULL, its default - sits after
the field in grey, because that is a note about the value and not part of its name.
Where a row of fields is too narrow to carry its labels, each label moves inside its
own empty field and steps aside as soon as something is typed there.
A field is a line with a cursor in it, and so is the prompt along the bottom and
what is typed into the palette - one line editor, so a key means the same in all
three: the arrows, home and end or ctrl+a and ctrl+e, alt+b and alt+f
by a word, ctrl+w for the word before the cursor and ctrl+u for all of it. A
value longer than its field shows the part the cursor is in. tab goes to the
next field somebody types into and shift+tab to the one before; the arrows go
to everything, the NULL box beside each value of a row included.
Every grey in the interface is text somebody has to read, so each carries at least 4.5:1 against what it is drawn on. The three levels are text, dim and faint, and faint is the floor rather than the vanishing point: the footer hints, the headings of the key map and the explanation under the connection list are all written in it.
ctrl+k is the command palette. Type a few letters of what you want; the letters
that matched are underlined, and the key that does it is on the right, so using it
teaches the key map.
It holds what this connection can do, and nothing else. It used to hold all
of it whatever was open, so Redis - which has one table holding every key, and no
SQL - was offered "write and run SQL", "create a view" and "add a foreign key".
The keys themselves refused, which only meant the list was describing things that
were not there. The engine's own capabilities decide it now, and where an engine
calls something by another name the palette does too: the editor is write and
run SQL on a database and write and run a command on Redis, Kafka or a
cluster, because there it takes that engine's own commands. # says database
on Redis, vhost on RabbitMQ, namespace on a cluster and directory over
SFTP.
With no argument it opens the saved connections - here one of each engine, with
keychain marking the one whose password macOS keeps.
The structure of a table: columns, indexes, foreign keys and the CREATE
statement the engine reports.
Every engine that speaks SQL is the same screen. Here it is a SQL Server, whose
protocol this program writes out itself - schemas down the left, money and
decimal kept as digits rather than turned into floats, and text that is text
whatever the database's own codepage happens to hold.
A cluster is a database too: a resource kind is a table and a namespace is a schema.
enter opens the object - what each container is doing, what the last one died of,
and the events - with what can be done to it along the bottom.
Those are not photographs of a terminal. The pty harness reproduces what the app
drew, colours and all, and tests/shot.py writes that grid out as
an SVG - so ./tests/shots.sh regenerates every one of them, and they cannot
quietly drift away from what the program does. The three that need a server -
the SQL Server one and the two cluster ones - are taken by the suites that
already bring one up, with SHOTS=1.
krtek people.csv # or .tsvA file whose name ends in .csv or .tsv is read into a SQLite database in
memory, as one table named after the file, with its first line as the column
names. From there on it is a table like any other: it sorts, filters, takes the
row form and the alter form, and answers SQL - select mesto, avg(plat) from people group by 1 on a file that has never seen a database.
What separates the fields is read off the first line: a comma, a semicolon,
a tab or a bar, whichever there is most of. A .tsv is tabs whatever is in it.
A semicolon is what a spreadsheet writes wherever the comma is the decimal mark,
so a comma in a number is read as one too - 100,50 is a number in a file
separated by semicolons, sorts as one and is written back with its comma.
A file nobody changed is a file nobody wrote. That is the rule both ends are held to:
- A column is a number only when every value in it writes back as the same
bytes.
1,-7and3.14are numbers.007,+7and1e5are text, and stay what the file said. A column of prices -1.50,10.00, the same count of digits every time - is aDECIMAL(15,2): a number to sort and add up, and the type is what remembers to write2back as2.00. - Nothing between two separators is no value, and
""is an empty text - the way PostgreSQL'sCOPYreads them, and the only way a delimited file has of telling the two apart. - The file is written when the table no longer says what the file does, and not otherwise: looking at it, a statement that was rolled back, and an update that changed nothing leave it untouched. A transaction is written when it commits.
- It is written the way it was read: its separator, its line endings, its byte order mark, whether its last line ends, and its header as it stood. The one thing not kept is quoting nobody needed - a field is quoted when it has to be.
- It is written beside itself and renamed into place, with its permissions, so a write that fails half way leaves the file as it was; a link is followed rather than replaced; a file marked read-only is refused rather than gone round; and a file that changed on disk since it was read is not written over - the statement says so, and opening it again shows what is there now.
A row longer than the header gets columns of its own (column_3), a shorter one
gets gaps, and a header with no name or the same name twice gets names that can
be told apart - and goes back as it came unless the column is renamed. So does a
column called rowid: left with that name it would take it from the row's own
number, which is what a row is edited by. A file that is a SQLite database is
opened as one, whatever it is called.
A CSV file holds rows and nothing else, so c, gN, Y and D - another
table, another name, a copy, no table - are refused, and so are indexes, views,
triggers and keys: each would live in memory and be gone when the file is closed.
The types are worked out again every time the file is opened, so a NOT NULL or
a default set in the alter form lasts as long as the session. gb says what was
found: the separator, the decimal mark, the line ending.
Redis is not relational and the driver does not pretend otherwise. It is fitted
to the interface rather than the other way round: one table called data whose
columns are key, type, ttl and value, rows found with SCAN, and the
numbered databases as schemas, so # moves between them. A value is shown as
its type allows - a string as it is, a list or set as its elements, a hash as
field=value - and editing a cell writes SET, editing the ttl writes EXPIRE
or PERSIST, deleting a row writes DEL. Filtering the key with W becomes the
MATCH pattern of the scan, % and _ translated to * and ?.
There is no DDL: c, a, I, K, gV and T answer with the reason instead of
writing SQL that could not work, and D is FLUSHDB. Searching every table is
refused, because there is only one. The SQL editor is a Redis console - KEYS user:*, HGETALL cart:7, INFO memory, TTL greeting - and that is where
anything this mapping does not cover belongs.
The protocol is spoken directly: RESP is a handful of prefixes, so there is no client library, no dependency and no licence to think about.
TLS is the second s, which is how redis-cli spells it and what a hosted
Redis expects:
krtek rediss://cache.example:6380/0 # asks for the password if there is one
krtek "rediss://cache.example:6380?insecure=1" # a certificate of its own makingIt goes through the OpenSSL that is already linked in. The certificate is
checked against the authorities this machine trusts and against the name in the
target, and ?insecure=1 is for a server whose certificate nobody signed. The
port is 6379 unless the target says otherwise: there is no other one TLS is by
custom found on. The form has a TLS toggle, and database information (gb)
says what the connection is: TLSv1.3, or none.
There is no client certificate to offer, so a server that insists on one -
which is how Redis comes once TLS is on, until tls-auth-clients no - refuses,
and the refusal is quoted: tlsv13 alert certificate required. And a target
that says redis:// to a port that only takes TLS is told to try the other.
A server that will not answer CONFIG or INFO opens like any other. A
hosted Redis has the first renamed away, and the second may not be among what
its user is allowed to run. Both are asked on connecting, the answer was an
error, and the error was read as what had been asked for - which ended the
program before the first screen. Such a server is now taken to have sixteen
databases, which is what Redis has unless it is told otherwise, and its version
is shown as ?. The same goes for any answer that is not in the shape of what
was asked: it is read as nothing, not as something it is not.
A connection that is lost is made again. A server that restarts, or an idle
timeout somewhere on the way, used to end the session without saying so: r
answered reloaded over a table of no rows, and went on answering it. The
connection is now looked at before each request, and where the server has hung
up it is dialled again - through TLS if the target said so, with the password,
into the database that was open - before the request is sent, so nothing is in
doubt. One that goes between a question and its answer is made again too and
the question asked once more, unless asking twice is not the same as asking
once: a line typed in the console and a rename are said to have been cut off
instead, and the next thing asked has a connection again. A server that cannot
be reached is said on the status line - the connection to redis at cache.example:6379 was lost, and it cannot be reached again - the table says
could not be read and not that it is empty, and r tries again.
A topic is a table whose columns are partition, offset, timestamp, key,
value and headers, and the mapping earns its keep: an offset is a page number,
so 1-50 of 12043 is exact and costs one ListOffsets, and (partition, offset)
addresses a record precisely. Filtering on the partition or the offset is pushed
into the fetch itself; a filter on the key or the value is applied as records
arrive, because a log has no index to do it with. Sorting descending reads the tail
of the log, which is the thing you usually want.
What it will not do is pretend a record can be changed. The log is append-only, so
an edit is refused and says why, and deleting one row is refused too - Kafka throws
away a prefix of a partition, which is TRUNCATE, not a row. Inserting works:
that is a Produce, and the partition is chosen with the same murmur2 hash Kafka's
own clients use, so a key written from here lands where it would have landed from
anywhere else.
A topic can be watched rather than asked about. R follows the open one:
every couple of seconds - :follow 0.5 or :follow 10 for another interval - the
records are read again and the view stays on the newest limit of them, counted
from the end of the log rather than from a page boundary, so a record that arrives
appears under the cursor with everything before it still on the screen instead of
alone at the top of a fresh page. Turning a page says the watching is over, which
is what turning a page means. It costs one ListOffsets and one Fetch a tick,
and nothing was added to the driver to do it: the end of a topic is a page number
like any other.
The structure view shows the partitions with their leader, replicas, in-sync
replicas and offset range, and the definition is what DescribeConfigs says, with
the values inherited from the broker commented out so the ones actually set on the
topic stand out.
The editor is a Kafka console:
TOPICS every topic, with its partitions and records
BROKERS the cluster
GROUPS the consumer groups
OFFSETS orders the earliest and latest offset of each partition
DESCRIBE orders the configuration
CREATE orders 3 1 a topic, with partitions and replication factor
DROP orders
TRUNCATE orders throw away every record, keep the topic
PRODUCE orders key some value
No client library. The protocol is spoken directly, and every API version it uses is the last one before that API became flexible - so there are no compact strings and no tagged fields anywhere, one encoding instead of two, and brokers from 2.1 to 4.x all answer it. Records are read from the leader of their partition, which metadata names, so a real cluster works and not only one broker on a laptop.
Compressed batches are unpacked: gzip and zstd come out of Zig's standard library, and snappy and lz4 are written out in the driver, because a great many topics are one of those and a reader that cannot open them is not much of a reader. A codec it does not know says so rather than handing back rubbish.
TLS and SASL are there for a cluster that is not on a private network:
krtek "kafka+ssl://alice@broker:9093" # asks for the password
krtek "kafka+ssl://bob@broker:9093?mechanism=SCRAM-SHA-256"
krtek "kafka://alice@broker:9092?password=secret" # SASL, no encryption
krtek "kafka+ssl://alice@broker:9093?insecure=1" # a certificate of its own makingTLS goes through the OpenSSL that is already linked in, and the broker's
certificate is verified against the machine's own trust store unless insecure=1
says not to bother. SASL is PLAIN, SCRAM-SHA-256 and SCRAM-SHA-512, and SCRAM
is computed here rather than by a library - including the server's own signature,
which is the half of SCRAM that proves the broker knew the password too. A user
with no password makes krtek ask for one, and it can be kept wherever the other
connections keep theirs, keychain included.
A bucket is a table and an object is a row, with key, size, modified,
etag and storage for columns. The mapping is closer than Kafka's: keys come
back sorted, a key addresses a row exactly, and filtering with W on
key LIKE 'a/b%' becomes the prefix of the listing - which is the only filter
S3 has, so anything else is refused with the reason rather than answered by
downloading the bucket and pretending. Renaming a key is a copy and then a
delete, in that order, because S3 has no rename and a failure has to leave the
original where it was.
Paging is by continuation token, which only goes forwards. S3 has no OFFSET: page five is reached by asking for the four before it and keeping the token each one ends with. Those tokens are kept, so paging forward costs one request a page and going back costs nothing. Counting is a walk, so it is done for a bucket small enough to walk and reported as unknown for one that is not - an unknown number of rows beats a wrong one.
The body of an object is not in the grid: a listing that fetched every object to
draw a screen would download the bucket. The editor is an S3 console instead,
and GET brings one object back as a value - so an image is shown as an image and
anything else as hex, by the same code that does it for a BLOB.
BUCKETS every bucket this key can see
LS [bucket] [prefix] one page of keys
GET key the object itself, shown as a value
HEAD key what the server says about it
PUT key "some text"
DEL key
URL key 3600 a signed link anybody can open, for an hour
It is not only Amazon. MinIO, Ceph, Garage and R2 speak the same protocol and
differ in how a bucket is addressed; a target that names a host gets path-style
addressing, Amazon gets the bucket in the hostname, and ?path=1 settles it by
hand. A bucket in another region answers with the region it is in, and that is
followed once rather than shown as a 301 nobody can read. Garage has one region,
named in its own configuration, and refuses a request signed for any other by
saying which it wanted: that is followed the same way, so nobody has to know to
write ?region=garage.
krtek s3://photos # AWS, keys from the environment or ~/.aws
krtek "s3://photos?region=eu-central-1&profile=work"
krtek "s3+http://minioadmin:minioadmin@localhost:9000/photos" # MinIO
krtek "s3://key:secret@ceph.example:8443/data?insecure=1" # a certificate of its own makingCredentials are looked for where the AWS tools look, in the order they look:
the target, then AWS_ACCESS_KEY_ID and friends, then ~/.aws/credentials and
~/.aws/config for the profile in use. So a machine already set up for the aws
command needs nothing said here, and a key that would otherwise sit in the
connections file in the clear stays where it was. The info view says which of the
four answered.
A target that names an access key and no secret - s3://AKIA…@photos - is asked
for the secret the way every other engine is asked for a password, and it can be
kept wherever the other connections keep theirs, keychain included. A target that
names a key is never quietly completed from the environment: somebody who wrote a
key down means that key.
No SDK, and the signature is written out. SigV4 is a hash of a canonical form of the request signed with a key derived from the secret, the date, the region and the service - about a hundred lines, and almost every bug in it is a canonicalisation bug rather than a cryptographic one. So the canonical request and the string to sign are functions of their own, checked against Amazon's own worked examples byte for byte, including a presigned URL: see src/db/s3/sigv4.zig.
The same shape as S3, because the service has the same shape: a container is a
table, a blob is a row, with name, size, modified, etag, type and
tier for columns. Names come back sorted, a WHERE name LIKE 'a/b%' is the
listing's own prefix, and paging is by marker, which only goes forwards - so the
markers are kept and going back a page costs nothing. The console is CONTAINERS,
LS, GET, HEAD, PUT, DEL.
Three ways to write a target, because there are three things people have to hand:
krtek "azure://mystorage:key@photos" # the account's own endpoint
krtek "azure+http://devstoreaccount1:key@127.0.0.1:10000/photos" # Azurite, or a proxy
krtek "DefaultEndpointsProtocol=https;AccountName=…;AccountKey=…;EndpointSuffix=core.windows.net"That last one is what the portal shows and what Azurite prints on startup, so it
is taken as a target rather than made into homework; ;Container=photos names
one, and without it every container the key can see is a table. The key can also
be left out of the target and typed as the password, which is where the keychain
takes it, or found in AZURE_STORAGE_KEY and AZURE_STORAGE_CONNECTION_STRING.
A shared access signature works instead of a key, and then nothing is signed at
all: the token is the whole of it.
Shared Key is written out, and it is wrong in the same places S3's signature
is - none of them cryptographic. The account key is base64 and has to be
decoded before it is used, the account appears twice in the resource line when
it is in the path (which is what the emulator does), and a body of nothing is
signed as an empty slot rather than as a zero while the Content-Length: 0
header still has to be sent. Every one of those is a 403 with no explanation, and
every one of them has a test - against signatures computed outside this program,
and against the emulator itself.
Google Cloud Storage is not a driver here: it has an S3-compatible XML API,
so s3://key:secret@storage.googleapis.com/bucket with HMAC keys is the shape
that should work. It is untested - there was no account to try it against - so
that is a suggestion rather than a claim.
A broker holds nothing to list, and this driver does not pretend it does.
There is no "show me the topics" in MQTT: a topic exists while somebody
publishes to it, and the only way to learn of one is to be subscribed when a
message for it goes by. So that is what krtek does. It subscribes - to
everything, unless the target names a filter - and the tables are what has
arrived since:
| table | what it is | i |
edit | x |
|---|---|---|---|---|
topics |
every topic heard from, with the last thing said on it | publishes | publishes the row again, with the change | clears the retained message |
messages |
each message in the order it came, the newest last | publishes | refused: sent is sent | refused |
subscriptions |
the filters this connection listens to | subscribes | changes the quality of service | unsubscribes |
$SYS |
what the broker says about itself, where it does | - | - | - |
The retained messages arrive first, so a moment after connecting topics is
what the broker holds: the state of a house, the configuration of a fleet. R
on messages follows the log as it grows.
What follows the host is the filter, because a broker has no database to
name. Nothing is everything, with the broker's own $SYS beside it; a path is
that and nothing else, which is how a broker with a great deal going through it
is looked at a branch at a time:
krtek mqtt://broker # everything, and $SYS
krtek mqtt://ada@broker/home/# # one branch; asks for the password
krtek mqtts://broker:8883/home/+/temperature
krtek "mqtt://broker?client=bench-1&keepalive=15"Something has to keep listening, which is the one way this driver is unlike the others. A database answers when asked; a broker sends when it has something, wants some of it acknowledged, and takes a client that has been silent too long for gone. So the connection belongs to a thread of its own, which reads what arrives, acknowledges it and says it is still there twice in every keep-alive, while the interface is answered out of memory. Nothing is missed because nobody pressed a key. A connection that is lost says so in the header, what was heard stays on the screen, and the next thing asked for connects again and subscribes to what it was subscribed to.
What is kept is bounded: the last fifty thousand messages, or sixty-four
megabytes of them. The oldest go first, and gb says how many have.
The filter row speaks MQTT. A condition on topic with a + or a # in it
matches the way a subscription does, and so does anything typed into the raw
field: home/+/temperature there is those topics, and a plain word is looked
for in the topic and the payload.
It is MQTT 3.1.1, written out in src/db/mqtt/wire.zig with no library:
every broker speaks it, and what 5 adds is properties this would only skip. It
subscribes at quality of service 2 so that a message is shown with the quality
it was published with; a broker that grants less says so in subscriptions.
The editor is a console - one command a line, and the payload is the rest of its line as it stands, braces and quotes and all:
PUBLISH home/lamp {"state": "on", "brightness": 80}
PUBLISH -r -q 1 home/thermostat 22 retained, at quality 1
RETAIN home/mode night the same as PUBLISH -r
CLEAR home/thermostat clear the retained message
SUBSCRIBE sensors/# 1
UNSUBSCRIBE sensors/#
TOPICS home/+/temperature what has been heard, narrowed
MESSAGES thermostat the last thousand that mention it
STATUS the connection, and the counts
FORGET empty what is kept here
A dump is those lines - the subscriptions and the last message of every topic,
retained where it was - so a broker's state goes into a file and back into
another broker. Running one publishes every line of it: what was retained is
held again, and what was only heard goes by again, which for a topic a device
acts on is an order given twice - a file to read before it is run. The log and
$SYS are left out of it. A payload that a line cannot carry, one with a line
break in it or bytes that are not text, is written as PUBLISH -b and base64.
Two things a broker does not say, and so neither does this. A message passed on
as it happens does not carry the retained flag, whoever published it and how, so
retained is what the broker held when the subscription was made and what this
connection has given it to hold since. And times are when a message arrived
here, in UTC like Kafka's: MQTT 3.1.1 puts no timestamp on a message.
Reading a queue is destructive, so this driver does not read queues. AMQP has
no peek: basic.get takes the message off, and putting it back with
nack requeue=true changes the order and marks it redelivered. A queue is not a
table - it is a line of people waiting, and looking at somebody in a queue means
pulling them out of it. A client that browsed messages the way it browses rows
would quietly reorder a production queue every time the screen was drawn, and no
amount of care in the client can fix that, because the protocol has no other
move.
So what is a table here is the topology, which can be read as often as
anybody likes: queues, exchanges, bindings, consumers, connections,
channels and nodes, each of them a list endpoint of the management API on
port 15672. A vhost is a schema, so # moves between them. Those endpoints page,
count and sort on the server, which is why 1-50 of 812 is exact and costs one
request, and why sorting by the number of messages is the broker's work rather
than this program's - filtering is the broker's too, so W on the name becomes
its own name= filter.
Messages are still reachable, in the editor, which is a RabbitMQ console - and what each of the two ways does is on the screen rather than in a footnote:
QUEUES [name] the queues, filtered by what a name contains
EXCHANGES, BINDINGS, CONSUMERS the rest of the topology
CONNECTIONS, CHANNELS, NODES what the broker is doing
VHOSTS, OVERVIEW, DEFINITIONS the broker itself, and the whole vhost as it exports it
PEEK orders 10 messages, put back afterwards - the order changes and
they come back marked redelivered
DRAIN orders 10 messages, kept off the queue for good
PUBLISH events order.new "hello" send one; - as the exchange is the default one
PURGE orders throw away everything in it
DECLARE QUEUE orders quorum and DECLARE EXCHANGE events topic
BIND events orders order.# and UNBIND events orders order.%23
DELETE QUEUE orders and CLOSE, which hangs up on a client
Nothing in that list happens while browsing. Declaring, binding and deleting also
work from the grid - i on queues declares one, x removes it - and an edit
is refused, because a queue is declared and not altered. A dump of a vhost is
those commands, so what comes out goes back in: that is the topology, not the
messages, which is the only honest thing a dump of a broker can be.
krtek rabbit://guest@localhost:15672/ # asks for the password
krtek "rabbit://admin:secret@broker:15672/production"
krtek rabbits://admin@broker/production # HTTPS, port 15671
krtek amqp://guest@broker:5672/%2F # the url you have to handThat last one is taken and its port is not: 5672 speaks AMQP, and connecting to
it with HTTP would hang rather than say anything useful, so the management port
is used instead and the info view says so. The default vhost is /, which has to
travel as %2F in every path the driver builds - the single most common way to
get a 404 out of the management API, and the reason there is a test for a queue
with a space in its name.
The first engine here that is a filesystem rather than a store of keys, and
it shows in three places. A directory is a real place, so it is what a schema is
and # walks the tree. A rename is a real rename, not a copy and a delete. And a
listing arrives whole - SFTP has no pagination - so the sorting, the counting and
the paging are this program's and are exact, and name LIKE '%trip%' works,
which is the one thing S3 and Azure have to refuse.
A directory is a table and a file is a row, with name, size, kind,
modified, mode and owner for columns. Editing the name renames; editing the
mode chmods, in either 644 or rw-r--r--; adding a row makes an empty file, or
a directory when kind says so. The editor is a small shell:
LS [path] CD path PWD
GET path PUT path text
RM path MKDIR path MV from to
CHMOD 644 path STAT path
The transport is libssh2, and that is a deliberate exception. Everything else here is written out - Redis, Kafka, HTTP, two request signatures - and SSH is where that stops. A mistake in Kafka's framing is a wrong row on a screen; a mistake in a key exchange is a vulnerability, and a hand-rolled one would serve nobody. libssh2 is small, BSD licensed, and links against the OpenSSL already in the binary, so the single static file it all ships as is still a single static file.
What that costs is written down rather than hidden: the session is blocking, so
ctrl+c does not interrupt a transfer halfway. Nothing hangs forever instead -
every call has a timeout on it.
The host key is checked against ~/.ssh/known_hosts, like any other ssh
client. A host nobody has met is refused with its fingerprint rather than trusted
quietly, and a key that has changed is refused loudly. ?insecure=1 is the way
to say otherwise, and it has to be said.
krtek sftp://user@host/srv/data # the agent, then ~/.ssh/id_*, then a password
krtek "sftp://user@host?key=~/.ssh/id_backup"
krtek "sftp://user@host:2222/srv?insecure=1" # a host that is not in known_hosts yetAuthentication goes the way ssh goes: the key named in the target, then the
agent, then ~/.ssh/id_ed25519, id_ecdsa and id_rsa, and a password last -
asked for the way every other engine's is, and kept wherever that connection
keeps its password.
A resource kind is a table and a namespace is a schema. # switches namespace
the way it switches schema on PostgreSQL, the object list is pods, deployments,
services, nodes and fourteen others, and a row is one object with the columns
kubectl would have printed - including the four that have to be worked out rather
than read: how old a thing is, how many of a pod's containers are ready, how many
times they have restarted, and how many of a deployment's replicas are up.
krtek k8s:// # the kubeconfig's current context
krtek k8s://prod # the context called prod
krtek k8s://prod/payments # that context, opened on that namespace
krtek "k8s://?kubeconfig=/tmp/other" # a file other than $KUBECONFIG or ~/.kube/configA target names a context and at most a namespace, and nothing else. Everything about how to reach a cluster is already in the kubeconfig, and a second place for the same fact to be wrong is worse than a longer command line.
Which is why nothing has to be typed at all. Started with no argument, krtek
offers every context of the kubeconfig in the list of connections, marked
kubeconfig and sitting after the ones that were saved. They are not saved and
never written to the connection file: the kubeconfig is where a cluster is
described, so x says to remove the context there and e says that a is how to
make one of your own. Adding a cluster to a kubeconfig is enough to reach it from
here.
The kubeconfig is read the way kubectl reads it, which meant three things this
program did not have. A YAML reader, written for the shape a kubeconfig actually
is - block mappings, sequences and plain scalars - and refusing by name and line
number anything it does not know, because an anchor read as a plain scalar puts
&ca where a certificate belongs. A TLS layer that takes a certificate authority
and a client certificate as PEM in memory, trusting the cluster's own authority
instead of the machine's, the way kubectl does. And a runner for exec
credential plugins, since that is how nearly every cloud cluster authenticates.
The plugin runner does not go through a shell - the args: list goes to execve
as an array, so an argument with a quote in it is one argument - allocates nothing
between fork and exec, because this program has a thread reading the terminal
and the child of a fork has only the one that called it, and gives up after half a
minute, because a plugin that opens a browser and waits would otherwise hold the
interface forever.
The status column is kubectl's, not status.phase. A pod stuck in a crash loop is
phase Running with a container waiting on CrashLoopBackOff, and a pod that has
finished is phase Succeeded where kubectl says Completed - so a column that
showed the phase would call the one broken pod in a namespace healthy, which is
the single thing anybody scans a pod list for.
A pod's containers are a square each. What kubectl writes as 1/2 is drawn as
▪▫: filled for a container that is ready and hollow for one that is not, so
the row says how many are up to somebody who cannot tell the colours apart, and
the colour says which kind of not up - green for ready, amber for one that is
being started or is up and not ready yet, red for one that died or is waiting to
be started again after dying, grey for one that finished as it was meant to. A
crash loop is red for all but the instant its container runs, although that
container is waiting nearly all the time: what it is waiting for is to be
started again. The value is still 1/2 - that is what the column is filtered on
and put in order by, what E writes to a file and what is copied - and the
squares are only how the grid draws it. Which is the engine's to say and the
interface's to draw: a driver hands a mark for each thing a cell counts, and the
grid knows nothing about what a container is.
A pod's cpu and memory are what it is using, not what it asked for. A
request is written once in a manifest and says nothing about the pod that is
eating a node today, which is the one somebody opens a list of pods to find - so
o on either column puts them in order of how much, 5m before 10m and
900.0Ki before 1.2Mi. The figures are metrics-server's, added up over the
pod's containers and rounded the way kubectl top rounds them. That is an add-on
and a second request, and neither is allowed to stop a list of pods from opening:
on a cluster without one, for an account that may not ask it, and for a pod that
is not running, the two cells are empty rather than zero, because nobody measured
and that is not the same as idle. TOP pods is the same question asked on
purpose, and says why it cannot be answered. What a pod asked for and what it is
limited to are on the screen enter opens, under what it is using, where the
three numbers can be read against each other.
What controls a pod - ReplicaSet, StatefulSet, Job - has a column too, after
the status rather than before it: every pod of a deployment says the same word
there, and that is not worth the status falling off the edge of a narrow window.
enter on a row opens a screen about it. Everything the grid had room for
and everything it did not: what each container is doing, what the last one died of
and with what exit code, the labels, and the events - which is where a failed image
pull or a failed mount is written down and nowhere else. Along the bottom is what
can be done to that one thing:
l logs s shell t terminal y as JSON x delete this pod esc back
Those are the engine's, not the interface's. A driver says what can be done to a
row and gives the line of its own console that does it, so the interface draws a
key and a word and runs what it was given - it neither knows nor needs to know
what a log or a shell is. A deployment offers R restart and no shell, because a
deployment has no container to be in. Where an engine has nothing to add, enter
still opens the row in a form, which is what it has always done: a database row is
already all of itself, and opening one means editing it.
None of that is discovered by trying it. What an engine will not do is a
capability with the reason in it, so the footer offers only the keys that work
here - x and no i or e on a cluster, i and no e or x on a Kafka topic -
and a key that cannot work says why the moment it is pressed rather than after a
form has been filled in.
Reading, deleting and scaling, and not editing. An object is a document with a
controller acting on it; writing one back from a grid of flattened cells is a way
to lose a field nobody displayed, and e says so instead of trying. What is
there instead is exact: x deletes an object and asks first, because nothing takes
that back, and the editor is a console for the cluster:
APPLY and the manifest under it, documents and all
GET pods the same as choosing pods on the left
WHY api-7c9d4 what it is doing, what it died of, what was said
LOGS api-7c9d4 500 the last lines of a pod's log
EXEC api-7c9d4 open a shell there; what you type goes to it
EXIT and close it again
DESCRIBE pod api-7c9d4 one object, whole, as the cluster holds it
SCALE deployments api 5 replicas
RESTART deployments api a rolling restart, the annotation kubectl uses
USE kube-system another namespace
NAMESPACES / CONTEXTS / VERSION
WHY is the one that answers a question rather than a request. A restart count
of seven does not say whether the container ran out of memory or exited 1, and
that is in the state before the one it is in now - the only record of a container
that no longer exists. So it reads the phase, what each container is doing, what
the last one died of with its exit code, and what the cluster has said about the
pod lately, which is where a failed image pull or a failed mount is written down
and nowhere else.
s on that screen is the same as EXEC below, aimed at the pod already under
the cursor.
APPLY takes a manifest written under it, several documents and all, and asks
before it runs because a manifest can make and overwrite anything in it. Each
document goes to the cluster as it stands: Kubernetes accepts YAML for a
server-side apply and does the merging itself, which is both less code here and
better behaved than a read, a change and a write from this end - two people
applying different fields of one object do not overwrite each other, and the
server keeps track of which of them owns what. kubectl shows krtek as the field
manager afterwards.
So what is read here is only enough to know where to send it: the apiVersion,
the kind and the name. Everything else is the server's to understand, including
every part of YAML this program's own reader does not - a manifest is not a
kubeconfig and has no reason to be limited to what one needs. A kind krtek knows
goes to the path it knows; anything else - a custom resource - is pluralised the
way Kubernetes pluralises, and a 404 says so rather than pretending the kind does
not exist.
What comes back is what happened to each document, created or configured, one
to a row.
EXEC opens one shell and keeps it. What is typed goes to that shell and what
it says comes back as rows, so the session is the point: cd /var/log and then
ls mean what they say, which is the whole difference between a shell and a way
of running one command. The editor stays open and empties instead of closing,
sitting at the foot of the panel with the output above it - the shape every
terminal has - and its title says which container is listening. EXIT closes it.
A shell that never ends has no way of saying a command has ended, so it is asked
to print a marker and the exit status after each one, with the marker random per
session in case a command's own output contains it. A non-zero status is said in
the output as [exit 1], in brackets so it reads as this program's voice rather
than the command's.
EXEC -t is the other thing, for when it is wanted: the whole terminal handed to
the container, which is what top and vi need and what a grid cannot give them.
The key loop stops - its reader would otherwise eat every keystroke meant for the
container - the alternate screen is left so what was on it comes back, and the
window size goes down channel 4 whenever it changes.
Underneath both is src/db/ws.zig, because Kubernetes offers SPDY, which is retired, and WebSocket, which every version since 1.29 speaks. It is RFC 6455 for a client: a handshake that is four headers and an answer to check, and a frame header of two to fourteen bytes. The streams are multiplexed by a channel number in front of every message, so the protocol on top of that is a first byte.
One thing is worth writing down, because it cost an afternoon. On macOS a
descriptor opened from /dev/tty cannot be waited on: poll calls it invalid and
select never calls it ready, while read on that same descriptor returns what
was typed - so a terminal handover built on one sees no keystroke, ever. The
descriptor the shell handed over works properly, and is the one used.
R follows a log the way it follows a table. Anything the engine says is worth
running again can be followed, so LOGS api-7c9d4 and then R is a tail, and the
newest line stays under the cursor. Anything else is refused rather than repeated:
a console with PRODUCE and SCALE in it has statements that must happen exactly
as often as they were typed, and INSERT … RETURNING produces rows without being
a question.
A list is fetched whole and paged here, because Kubernetes pages with a
continue token that walks forwards only and cannot answer "the rows from 400",
which is what a grid with page numbers asks. Filtering and sorting are done here
for the same reason, over the text the grid shows - 2/3 is not a number and
neither is 4d2h.
The object list does not count. Answering "how many pods, how many secrets,
how many events" for eighteen kinds would be eighteen list requests before the
first frame, so ? is what the sidebar says until something has been read; every
select leaves its count behind and the sidebar picks it up from there. A count
that would cost a fetch of every secret in the namespace is not a count worth
having.
else`, so a new engine is one union member and a struct with the same method names - anything missing is a compile error that names itself. No vtables.
pub const Db = union(enum) {
sqlite: *sqlite.Db,
postgres: *postgres.Db,
pub fn columns(self: Db, arena: std.mem.Allocator, table: Table) Error![]Column {
switch (self) {
inline else => |driver| return driver.columns(arena, table),
}
}
};The rule the drivers keep: everything engine specific stays behind src/db/.
The interface knows about objects, columns, indexes, keys and row identity; it
does not know what a PRAGMA or a pg_catalog is. What differs is declared as
capabilities - PostgreSQL has schemas and alters in place, SQLite has a rowid
and has to rebuild a table - and the interface asks for those instead of
guessing.
Zig fetches libvaxis itself, from build.zig.zon.
./fetch-sqlite.sh # download the SQLite amalgamation into vendor/
zig build # zig-out/bin/krtek (needs Zig 0.17 and libpq)
zig build run -- x.db
zig build test # unit tests
tests/screen.py x.db '{down}{enter}' 'oo' # drive it headlessly, see below
zig build dbcheck -- mysql://… # talk to a server, without the interface
zig build kccheck # check the macOS keychain, by handBoth client libraries are keg-only on Homebrew; -Dlibpq=/prefix and
-Dmariadb=/prefix point the build at them if they live somewhere other than
/opt/homebrew/opt/libpq and /opt/homebrew/opt/mariadb-connector-c, and
-Dopenssl= does the same for OpenSSL. The MariaDB connector speaks to MySQL as
well, and its licence lets a program that is not GPL link it - Oracle's own client
library does not.
-Dstatic links libpq, the connector and OpenSSL into the binary. On macOS that
leaves the system's own libraries dynamic - Kerberos, LDAP, curl, zlib - and on
Linux nothing at all, because musl has a static libc.
A static Linux build cannot be done with a distribution's packages:
Debian ships a libpq.a without the pgcommon and pgport archives it needs,
and Alpine, which has those, ships no archive for the MariaDB connector. So
tests/linux-static.sh builds the connector from source
and is meant to run in an Alpine container - docker run --rm -v "$PWD:/src" -w /src alpine:3.22 …, which is what CI does.
The connector is built from source for a Mac as well, though Homebrew has an
archive of it. The connector keeps every way of logging in but the oldest as a
plugin it loads at run time from a directory - Homebrew's own, on the machine
that built the binary - so a release with that archive inside could not log in to
a MySQL 8, which asks for caching_sha2_password unless it is told otherwise.
tests/connector.sh builds an archive with
caching_sha2_password, sha256_password and MariaDB's ed25519 inside it, and
is what both platforms link: ./tests/connector.sh, then zig build -Dstatic -Dmariadb=/tmp/mariadb-static with /tmp/mariadb-static/lib/pkgconfig in
PKG_CONFIG_PATH.
What a static link takes is not one -l per library, and not the same list twice,
so pkg-config --static is asked and its answer translated: both libraries in one
call, because they share zlib and OpenSSL and asking separately puts zlib in
twice, which dyld refuses to load. Where brew's libpq.pc names libpgcommon, the
_shlib variant beside it is used instead - the plain archive is built differently
from the libpq that references it, and linking it leaves pg_encoding_to_char
undefined. Each archive is then handed to the linker as a file rather than as
-lname: that keeps it out of its own search, which is where an archive quietly
becomes a shared library and takes the whole binary with it.
Packaging is two short scripts, both of which a release runs and CI rehearses.
packaging/deb.sh wraps the built binary as a .deb - binary,
man page, copyright, changelog, Depends on nothing - and needs dpkg-deb, which
is why it runs on the machine that has it rather than in the Alpine container that
built the binary. packaging/formula.sh writes the Homebrew
formula from the checksums the archives were actually packaged with, so it cannot
name a checksum that does not exist; the release attaches it and, given a
TAP_TOKEN secret that may push to zales/homebrew-krtek, commits it there as
Formula/krtek.rb, and the release attaches it. The tap fetches it from there with
a workflow of its own rather than being pushed to from here: that way it writes to
itself with the token it already has, and no personal access token has to live in
either repository as a secret. It is what brew install zales/krtek/krtek reads.
homebrew-core is a different matter: it wants a formula that builds from source and
would have to be worth its while.
The APT repository is .github/workflows/apt.yml, which
takes the .deb files off the releases - all of them, so nothing is lost - runs
dpkg-scanpackages over the lot, writes the Release file, signs it if there is a
key - the APT_GPG_PRIVATE_KEY secret - and pushes the result to the gh-pages
branch together with the landing page
in docs/index.html. It runs when a release is published, and by
hand from the Actions tab whenever the repository needs rebuilding.
The copyright file spells out what is linked in, because a static binary carries
other people's code: libpq under the PostgreSQL licence, the MariaDB connector
under the LGPL, OpenSSL under Apache 2.0, SQLite in the public domain. The LGPL
asks that its object code can be replaced, and it can - zig build -Dstatic -Dmariadb=<prefix> from this source is the whole procedure, which is what the
copyright file says.
Everything is reachable from the key map, which ? prints in full.
The keys are vi's, wherever vi has one for the thing. hjkl and w b move,
gg and G are the ends, ctrl+d and ctrl+u go half a screen and ctrl+f
and ctrl+b a whole one, H M L are the top, middle and bottom of the
screen and zt zz zb put the row under the cursor there; / looks for text
and n and N go to the next place it is and the one before; y yanks, m and
a letter leaves a mark and ' and the letter goes back to it, :12 is a row and
:$ the last one. Eleven letters meant something else here before they meant
that, and each of those things is on g and the letter it used to be: gv the
whole value, gm the messages, gb the database information, gL the
relations, gw the visible columns, gy cloning a row, gM importing, gV
creating a view, gN renaming a table, gn and gp the next page of rows and
the one before. Press g and the footer lists them.
A screen is what the window shows, and a page is what the engine hands over.
The grid holds two hundred rows at a time - :limit says how many - and the
screen keys move through those by what is on screen, going on to the next two
hundred where they run out, the way j does. gn and gp turn that page
outright. They were the same keys once, so on a table of a hundred and twenty
rows pgdn did nothing at all.
/ looks in the pane it is pressed in. In the list it narrows the names, and
enter opens the first of what is left. In the rows it finds text - as it is
typed, whatever its case, accents included - and underlines every cell that has
it. It looks in the rows in hand, not in the table: W filters the table and F
searches all of them.
Nothing is deleted, and nothing is quit, on one key. x on a row waits for
x again, the way dd is two keys. Rows marked with space - or a run of them,
V at one end and V at the other - are asked about with their count; a mark is
drawn on its row and stays on that row whatever page is on screen, because it is
kept as what addresses the row and not as where the row was. q leaves what is
in front - the key map, a value, a screen that is not the grid - then the tab,
and the program after the last of them. :qa is all of it at once.
Tabs. A connection is a tab, and there can be several: ctrl+t opens an empty
one, t on a saved connection opens it in one, :tabnew <target> opens anything
by name. ] [ or gt gT move between them, alt+1 to alt+9 go to one by
number, a click does the same, and alt+w or :tabclose closes the one in front.
Each keeps its own table, cursor, filter, marks, editor and what it was
following. A tab is called what its connection is called rather than what table
is open in it, because two tabs are usually two databases and the same table is
in both.
Getting in. A list of saved connections with the engine and target of each, added and edited in a form; a password prompt that echoes nothing when the server wants one.
Browsing. Object list with row counts and a filter (PostgreSQL's estimate is
replaced with an exact count), a data grid with paging,
sorting, horizontal scrolling, a detail box for the whole value, and a structure
view with columns, indexes, foreign keys and the CREATE statement - which
scrolls, as the other screens that are read rather than worked in do. A table
wider than the window says which of its columns are on screen, with an arrow on
each side there are more, and keeps the first of them in place while the rest
move under it. The mouse does what it would be expected to: a click puts the
cursor on a cell or opens a table, a click on a column's name sorts by it, and
the wheel scrolls whatever it is over. R follows
a table: it reads it again every couple of seconds and keeps the view on the last
limit rows, so an append arrives on the screen by itself. Column
visibility and a filter of up to three conditions plus a raw WHERE. Database
info - pragmas and an integrity check on SQLite, server settings and size on
PostgreSQL - and a list of every relation.
Rows. A form to insert, edit or clone a row - typing a value clears its NULL box - plus quick in-place editing of a single cell, row marking, and deletion of everything marked.
gv opens the value on its own, and scrolls where there is more of it than
fits - arrows, pgup/pgdn, home/end, the wheel - and y there copies it.
It counts lines as drawn rather than
as stored, because a line longer than the box wraps, and a scroll that counted
the stored ones would jump over the wrapped half of one.
A cell is one line in the grid and the whole value everywhere else. A value with newlines in it would tear the grid apart, so the grid gets a flattened copy
- but
gv, the clipboard and a CSV export get what the engine actually returned. A RedisINFOis one cell of eighty lines, and it used to arrive as one unbroken paragraph in the value view and as eighty lines' worth of spaces in an export, in a format whose quotes exist to carry newlines. The second copy is kept only for the cells that needed flattening.
Schema. Create a table, alter one, add an index or a foreign key, create a
view or a trigger, rename, copy, empty or drop. # switches schema on PostgreSQL
and database on MySQL, which is the same thing there. The type list in a form is
the engine's own: varchar(255) on MySQL, timestamptz on PostgreSQL,
nvarchar(max) on SQL Server.
How an alter happens is the engine's business. PostgreSQL alters in place, one
ALTER TABLE per difference; MySQL does too, with CHANGE COLUMN, which renames
and retypes in one go. SQL Server alters in place as well, but a rename is not a
statement there at all - it is sp_rename, a stored procedure - and a default is
a constraint of its own rather than part of the column, so changing a column's
type leaves its default where it was. SQLite can only add, rename and drop a column, so
anything else - a type, a default, a primary key, a foreign key - is done by
rebuilding the table: a new table, the
rows copied over, the old name put back, the foreign keys carried across and the
indexes regenerated from their metadata with the column renames applied, so
renaming a column does not break them.
Data. A SQL editor - several lines, keywords, strings, numbers and comments in
colour, tab completing table and column names from a list under the cursor,
ctrl+p walking the history - that runs several statements and reports each
one on its own, search across every text column of every table, export as an SQL
dump (whole database or one table, structure and/or data) or CSV/TSV, and import
of an SQL script or a CSV file. Commands: :export, :dump, :limit, :text,
:open, :check, :analyze, :vacuum, :follow, :w, :e, :set, the
:tab ones and :q - which leaves what is in front, then the tab, and the
program only when there is nothing else left to leave - and :qa, which leaves
all of it. The arrows after : bring back what was typed there before.
The editor has vi's two modes. It opens in insert mode, where a key is the
character on it; esc is normal mode, where dd, cw, yy, p, o and the
rest are what they are in vi, u takes a change back and ctrl+r puts it back,
and enter runs the statement. esc once more puts the editor away - and what
was in it is there again the next time it opens, because esc twice is how
anybody makes sure of being in normal mode and that cannot be what loses a
statement. Completion knows what a statement calls its tables: after
from orders o, o. and tab lists the columns of orders, with a schema in
front of the table or without, and after join the tables a foreign key leads
to come first.
A statement and what came of it are on the screen together. One that the
engine will not take leaves the editor open, with all of what the engine said
under the statement - PostgreSQL's caret under the word it stopped at included.
One that reads stays too, as a strip above the rows it brought back with the keys
handed to the grid: s puts the typing back in it, so changing a word and
running it again is s, the word and ctrl+s. esc puts the strip away, and
opening a table does. A statement that writes is not kept that way - what s
and a few letters run again should be something that can be run again.
A batch reports each statement separately, and one that leaves a transaction
open is rolled back. A generated schema change
stops at the first error, so its own COMMIT can never make half a rebuild
permanent.
| File | Role |
|---|---|
src/db/db.zig |
the interface, the shared quoting and the statement splitter |
src/db/sqlite.zig |
the SQLite driver: pragmas and the table rebuild |
src/db/sheet.zig |
a CSV file opened through that driver: reading it in, writing it back |
src/db/csv.zig |
reading and writing delimited files |
src/db/postgres.zig |
the PostgreSQL driver over libpq, single-row mode |
src/db/mysql.zig |
the MySQL and MariaDB driver over the MariaDB connector |
src/db/mssql.zig |
the SQL Server driver: T-SQL, sys.* and the schema statements |
src/db/tds.zig |
TDS itself - packets, the handshake inside them, the token stream |
src/db/redis.zig |
the Redis driver: RESP straight over a socket, no library |
src/sqlite.zig |
the SQLite C declarations |
src/tui/term.zig |
the terminal: a thin adapter over libvaxis |
src/tui/app.zig |
state, the loaded page, and everything that runs SQL |
src/tui/forms.zig |
the forms: what each asks, and what is done with the answers |
src/tui/connection_form.zig |
the form a connection is added in, which builds itself again for each engine |
src/tui/dialing.zig |
opening a connection on a thread of its own, with a panel that can be given up on |
src/tui/file_actions.zig |
what the two file panes do: copying, removing, renaming, and asking first |
src/tui/editor.zig |
the SQL editor and the tokenizer that colours it |
src/tui/fuzzy.zig |
the fuzzy match shared by the palette and the filter |
src/tui/form.zig |
the form widget every dialog is built from |
src/tui/draw.zig |
rendering |
src/tui/input.zig |
the key map and the command palette |
src/tui/bench.zig |
the program with no terminal under it, for the unit tests |
src/tui/connections.zig |
the saved connections and where each keeps its password |
src/db/kafka.zig |
Kafka: the protocol, the compression codecs, TLS and SASL |
src/db/ask.zig |
what the interface asks for, and the SQL it renders to |
src/tui/biometry.zig |
Touch ID, through the Objective-C runtime by hand |
src/tui/keychain.zig |
the macOS keychain, through Security.framework |
vendor/sqlite3.c |
the unmodified SQLite amalgamation, compiled by Zig's clang |
src/db/kafka/ |
the Kafka protocol, the codecs, the target and SCRAM - the parts with no connection in them |
src/db/net.zig |
a socket with TLS on it, shared by the drivers that speak their own protocol |
src/db/http.zig |
HTTP/1.1 over that socket: keep-alive, chunked, and a ceiling |
src/db/s3.zig |
S3: buckets as tables, listing by continuation token |
src/db/s3/ |
the signature and the target - again, no connection in either of them |
src/db/xml.zig |
just enough XML for what an object store answers |
src/db/azure.zig |
Azure Blob: the same shape, a different signature |
src/db/azure/ |
Shared Key, and the three ways to write a target |
src/db/rabbit.zig |
RabbitMQ: the topology as tables, over the management API |
src/db/mqtt.zig |
MQTT: the connection, the thread that listens, and what was heard as tables |
src/db/mqtt/wire.zig |
the MQTT 3.1.1 packets, and how a filter matches a topic |
src/db/rabbit/ |
which endpoint each table is, where its columns live in the JSON, and the target |
src/db/ssh.zig |
the little of libssh2 this needs, and the connecting and authenticating |
src/db/sftp.zig |
SFTP: a directory as a table, with real renames |
src/db/k8s.zig |
Kubernetes: resource kinds as tables, namespaces as schemas |
src/db/k8s/ |
the kubeconfig, its YAML, the credential plugin, the target and which kinds are tables |
src/db/ws.zig |
RFC 6455 for a client: the handshake, the framing, and the masking a client must do |
packaging/ |
the .deb and the Homebrew formula |
docs/index.html |
the landing page, which is also the APT repository |
docs/krtek.1 |
the man page, installed by both of them |
The libraries are SQLite (compiled in), libpq (linked) and libvaxis for the terminal, which brings true colour, grapheme aware widths, the kitty keyboard protocol, bracketed paste and the mouse. Every frame is drawn in full and vaxis writes out only the cells that changed; a resize arrives as an event, so there is no polling and no signal handler.
zig build test runs the unit tests, and most of what a key does is among them.
Everything drawn goes into the cells libvaxis keeps, and those need no terminal:
src/tui/bench.zig is the loop main runs - a key, then a
frame - over a screen that is only cells, on a SQLite file in a directory of its
own. A test types a script, reads the screen back as text and asks the database
what became of it, as fast as the code runs:
var bench = try Bench.open(BOOKS);
defer bench.close();
try bench.keys("j{enter}jx");
try bench.says("1 row(s) deleted");
try bench.expectAsked("SELECT title FROM books ORDER BY id", "RUR Žert Saturnin");The keys, the forms and the drawing are tested that way, beside the code they test. One of them draws every screen at eight window sizes and looks for anything past the edge, which is what found the structure of a table falling over on a window twenty-four columns wide. What that cannot say is whether a terminal would agree - how wide it draws a character, what it sends for a key - and that is what the rest of this section is for.
A terminal app cannot be checked by a human on every change, so tests/screen.py runs the binary in a pseudo terminal, feeds it keys and renders the escape sequences it emits back into a character grid:
tests/screen.py x.db '{down}{enter}' 'oo' 'gn' # open a table, sort, page
tests/screen.py x.db 'c' 'notes' '{ctrl-s}' # create a table
tests/screen.py x.db 'i' '{tab}hello' '{ctrl-s}' # insert a row
tests/screen.py x.db '?' '{keep}' # leave the screen as it is
tests/screen.py '' '{keep}' # the connection list
tests/screen.py x.db '{ctrl-k}' 'dro tab' '{keep}' # the command palette
tests/screen.py x.db 's' 'select 1' '{ctrl-s}' # write SQL and run it
tests/kitty.py x.db 'S' 'Cr' # keys as Ghostty sends them
tests/kitty.py x.db '{shift}{f13}{kpdown}' # keys that are not text
tests/screen.py x.db '{tab}' 'yc' | grep CLIPBOARD # what a copy key sent
SCREEN_RAW=/tmp/raw.bin tests/screen.py x.db '{keep}' # keep the escapes too
SCREEN_SLOW=3 tests/k8s.sh # wait three times as longEvery wait in the harness is a guess at how long the app takes to answer a key.
On a machine that is busy with something else the guess is short - the screen is
read while the answer is still on its way, and a test fails that has nothing
wrong with it - and SCREEN_SLOW is how to say so.
tests/kitty.py drives the same binary but sends keys the way a
terminal with the kitty keyboard protocol does - shift+s as CSI 115:83;2;83u
rather than as the byte S, and {shift}, {f13} or {kpdown} as the private
use codepoints that protocol gives to keys which are not text. A plain pty cannot
express either difference, and both are where keyboard bugs live.
The parsers that read from a socket are fuzzed, because several of them - the snappy and lz4 unpackers, the walker over record batches, the HTTP reader and the listing S3 answers with - are written out in this repository by hand, and they read lengths off the wire and then believe them:
zig build fuzz # a few seconds, from a fixed seed
zig build fuzz -- 1000000 7 # a million inputs, from another
zig build fuzz -- replay gzip 1f8b… # one input, once, for a debug build to look atEvery iteration gets a fixed budget of memory and nothing else, so a parser that
would allocate whatever its input asked for reports OutOfMemory instead of taking
the machine with it. That is not hypothetical: it is what the first run did, and
five of the six things it has found were real -
the details are in the driver. CI runs 150 000 inputs from a
fixed seed on every push, so whatever it found once it finds again.
zig build test --fuzz is what this would otherwise be. With Zig 0.17.0 it
compiles, but it instruments the vendored SQLite too, which its runtime will not
start with, and a crash it finds still leaves zig build exiting 0 - so it could
not fail CI anyway.
The same harness records the colours, so the screenshots in this file are written out of it: tests/shot.py turns a captured screen into an SVG and tests/shots.sh builds a small demo database and takes all of them, in a configuration of its own so no screenshot shows anybody's real connections:
zig build && ./tests/shots.shThe same harness draws every screen at every size worth caring about:
zig build && ./tests/sizes.shWhat that checks is not that a screen looks good at forty columns - a grid of five columns cannot, and truncating is the honest answer - but that the drawing is still coherent. Chiefly that two panels are not drawn one column inside each other, which is what put a second border down each side of the connection form on any window under about seventy-five columns, and that the cursor is on screen wherever it is - a list of thirty connections used to stop drawing where the room ran out, so everything past the fold was unreachable. Counting frames that open against ones that close was tried and is wrong: a panel drawn over another covers its top and not its bottom on purpose.
There is no minimum size and there is deliberately not one. Nothing breaks as the window shrinks - the grid truncates all the way down to ten columns by three - so a floor would take away a window that works rather than prevent one that does not. What the small sizes needed instead was for the list of objects to shrink with the window rather than stay twenty-six columns wide until it vanished, and for the key map to stop putting two columns where one fits.
A server is needed for the drivers that talk to one, and tests/kafka.sh brings its own: it starts a Kafka in KRaft mode with four listeners - plain, SASL, SASL over TLS, and an internal one for Kafka's own tools - writes a topic in every compression codec with those tools, creates a PLAIN user and a SCRAM user, and then checks the driver against all of it, refusals included. The records it reads are ones the Java client wrote, which is the point.
zig build && ./tests/kafka.shtests/s3.sh does the same for S3, against Garage rather than Amazon on purpose: Garage wants path-style addressing and has a region of its own that nobody told the driver about, which is where a driver written only against AWS falls over. The signature is the same either way - if Garage accepts it Amazon does, and the unit tests already check it against Amazon's own worked examples. Thirteen objects over four-object pages, so the continuation tokens have to cover everything exactly once, and every failure - wrong secret, missing bucket, no credentials at all - has to say what is wrong rather than a number.
zig build && ./tests/s3.shtests/azure.sh does it for Azure Blob against Azurite, Microsoft's own emulator - which is the better test for the same reason MinIO is: it keeps the account in the path, which is what a driver written only against Azure gets wrong. The account and key in that script are not secrets; they are the same on every machine that has ever run the emulator, which is what makes it reproducible. It seeds itself with a signature of its own making, so the seeding does not depend on the thing being tested.
zig build && ./tests/azure.shtests/rabbit.sh brings up a broker with its management plugin, declares a topology with a queue whose name has a space in it - which is where an unescaped vhost or name shows up as a 404 - and checks the listings, the counts and every way in that can fail: a wrong password, a vhost that is not there, and the AMQP port, which is the mistake everybody makes once. It browses no messages, because browsing messages is what this driver refuses to do.
What it does to messages it does through the console, on purpose, and counts
them with rabbitmqctl afterwards: PEEK has to leave five where there were
five, and DRAIN three. Then the grid - a queue declared with i and removed
with x, an edit refused in the driver's own words - a connection marked
read-only that does neither, and a dump of one vhost replayed into another. That
last one found three faults at once: a name written bare, so the queue with a
space in its name came back as a queue called dead; the exchange with no name
declared as one called direct; and the broker's own amq. exchanges declared
too, one of which it refuses.
zig build && ./tests/rabbit.shtests/mqtt.sh is the same against Mosquitto, with
mosquitto_pub and mosquitto_sub from the same image as the other side of
every exchange: what was retained before krtek connected has to be in topics
when it does, what krtek publishes has to reach a subscriber that is not
krtek, and what it clears has to be gone for the next client. A broker with a
password file is connected to with the wrong password, with none and with no
user, and one with TLS under a certificate nobody signed - refused, unless the
target says not to look. Most of the driver needs no broker to be tested - the unit tests bring
their own, forty lines of one on the other end of a socket pair - so what the
suite adds is a real one, and a restart of it under an open connection.
zig build && ./tests/mqtt.shtests/redis.sh is there for TLS, which no unit test can
bring: one Redis with a port in the clear and a port that speaks nothing else,
under a certificate signed by an authority made on the spot and issued to
localhost. That gives a certificate all three of its answers - refused when
nobody knows who signed it, accepted when the authority is trusted and the name
is the one on it, refused again for the same server under another name. Then
what has to survive the encryption: a value of 300 000 bytes compared with what
went in, three thousand keys counted and paged, a password, an answer that
arrives after several read timeouts, and a value changed in the grid and read
back by redis-cli in the clear. A second server wants a certificate from the
client, which there is none to give, and has to be quoted saying so. A third
has CONFIG renamed away, as a hosted one does, and then INFO taken from its
user, and has to open all the same. And then
what a session has to survive: every client thrown out with the server still
up and a password on it, the server restarted under an open connection, and the
server stopped for good, which has to be said and not called reloaded.
zig build && ./tests/redis.shtests/postgres.sh and tests/mysql.sh came
last and should have come first: everything exotic here was being checked against
a real server while the two engines most people open were checked by hand. Each
brings up its own and looks for what that engine does differently - PostgreSQL
reads through the catalogs, has schemas that are not databases, streams a result
a row at a time and cancels a statement down a second connection; MySQL calls a
database a schema, runs in ANSI_QUOTES so that "name" is a name, and alters
with CHANGE COLUMN. The MySQL one earned its keep on the first run: a
decimal(12,2) was going through a float on the way to the screen, so 2499.50
arrived as 2499.5.
The PostgreSQL one also runs the unit test that wants a server: every schema
statement the driver writes, run as written, in a schema of its own. A statement
compared only with a string the same file wrote says nothing about whether a
server would take it - a trigger was a syntax error for as long as that was the
only check - so it is handed one with zig build test -Dagainst=KRTEK_POSTGRES=postgres://…. An option and not a variable in the
shell: a test run is kept and handed back while the binary is the same, whatever
is in the environment, so the variable alone got yesterday's answer, with every
test that wants a server skipped. Both suites make a trigger through the form
and then check that it fires.
zig build && ./tests/postgres.sh
zig build && ./tests/mysql.shtests/csv.sh opens a CSV file the way a spreadsheet in half of Europe writes one - semicolons, CRLF, a comma in the numbers, a line break inside a quoted value - changes one cell through the interface, and compares the file with what it should now be, byte for byte:
zig build && ./tests/csv.shtests/keys.sh is about what a key costs when it does the wrong
thing, and reads the answer out of the file rather than off the screen: a row
marked on one page and x pressed on the next deletes the row that was marked,
x on its own deletes nothing, q in the key map closes the key map, the screen
keys move on a table shorter than a page, a value is edited where the cursor is,
and a file that is not there is not made by being asked about. Each of those was
the other way round once.
zig build && ./tests/keys.shtests/connecting.sh is about the server that does not
answer, so it brings none up: a listener that takes a connection and says nothing
is that server on any machine, for as long as the test wants it. It checks that
the wait is said on the screen - with the step it is stuck on, and without the
password - that esc gets the program back with whatever was open still open,
that a key typed during the wait is not lost, and that an attempt somebody gave
up on neither holds up the next one nor writes on its panel.
zig build && ./tests/connecting.shtests/saved.sh is about the file the list of connections is kept in, when it cannot be written. A connection saved from the form, one removed and one marked read-only each have to say that they did not reach the file - and still be saying it after the connection the form goes on to open has written its own line over the first - while opening one, which only moves it to the front of a list somebody may keep read-only on purpose, says nothing:
zig build && ./tests/saved.shtests/mssql.sh is worth more than the rest of these, because
nothing in that driver is somebody else's code: the packet framing, the
handshake, the login, the token stream and the types are all written here, and
only a server can say they are right. Two of its checks are the unit tests that
skip on a laptop and run when one is named - one reads back a row of every type
worth having, the other runs every schema statement the driver knows how to
write, as written. The rest drive the interface: a transaction that is really a
transaction, an accent that survives being typed into a cell, and a statement
that will not finish being stopped with the connection still working
afterwards. The one that found three faults in a single run exports the table
and replays the file with plain sqlcmd rather than through krtek, so the file
has to stand on its own.
The image is amd64 and nothing else. On Apple Silicon it runs under whatever Docker emulates with, and under QEMU the server dies of a segmentation fault before it listens - which reads like a broken image and is a setting: Docker Desktop has to use the Apple Virtualization framework with Rosetta. The suite says so when that is what happened.
zig build && ./tests/mssql.sh
SHOTS=1 ./tests/mssql.sh # and the screenshot that needs a servertests/sftp.sh starts an ssh server, makes a key for it, and
checks both ways in - a password and that key - because both are how people
connect. It also checks the part a client is tempted to skip: an unknown host is
refused with its fingerprint, and only insecure=1 lets it through.
zig build && ./tests/sftp.shtests/k8s.sh brings up a k3s in a container, because that is a
whole cluster in one image and it hands out a kubeconfig with a certificate
authority of its own and a client certificate - the pair a driver has to get right
and the pair no unit test can prove. It checks all three ways in: that client
certificate, a service account's bearer token, and an exec credential plugin,
since the last of those is how nearly every cloud cluster authenticates. Then it
compares the pod list against kubectl name for name and state for state,
including the two states that are not the phase - a crash-looping pod and a
finished one - and checks that SCALE and RESTART reach the cluster and that
x asks before it deletes.
zig build && ./tests/k8s.sh
SHOTS=1 ./tests/k8s.sh # and the two screenshots that need a clusterThat is how everything described here was verified: against a real SQLite file
with sqlite3 reading the result back, and against a PostgreSQL 17 container
with psql doing the same.
-
No undo. The file is edited in place, like any other database client, so
:dumpbefore a risky change. -
A CSV file is held in memory and written whole. Fine for the files people open by hand, slow for one of a gigabyte: every change writes all of it, into a new file that takes the old one's place - with its permissions, and without its hard links or its extended attributes. It has to be UTF-8, or at least not UTF-16, and its first line has to be the header. Numbers are shown the way every other engine's are, with a point, and typed with one, whatever mark the file writes them with:
150,5typed into a column of numbers is a text, and goes into the file as one. -
A rebuild cannot recover what the pragmas do not report:
CHECKconstraints, generated columns and collations are lost when a table is altered. The form says so. -
A trigger is replayed as its own text, so renaming a column a trigger mentions makes the alter fail - safely, with a rollback and the failing statement in the report. Drop the trigger, alter, recreate it.
-
Editing a row needs a way to identify it: SQLite's
rowid, or a primary key or unique index over NOT NULL columns. A view, a PostgreSQL table without a key, and anything joined are read-only. PostgreSQL'sctidis deliberately not used as a key, because it moves on UPDATE. -
The editor has no selection. It is meant for writing a statement, not for editing prose: there is no visual mode, a count in front of a command is not read, and
.does not repeat one. In the gridVmarks a run of rows: once where it starts and once where it ends. -
Completion reads a statement without parsing it. It finds the tables named after
FROM,JOIN,UPDATEandINTOand what each is called, which is enough to turno.into the columns oforders. It does not follow a CTE or a subquery to the columns that come out of it. -
A long statement blocks the interface while it runs, apart from the spinner and
ctrl+c: the engine is called synchronously, and keys that arrive while it is running are dropped rather than queued. -
MySQL runs in
ANSI_QUOTES,NO_BACKSLASH_ESCAPES, so that one shared quoting is right for all three engines. A statement you write yourself is affected:"a"is an identifier, not a string. -
Users and privileges, and the process list are not there on either engine.
-
A dump does not include triggers, because it is written from the interface, which reports tables, views and indexes.
-
Redis reads a page of keys in two exchanges, not four hundred. A key's type, its age and what it holds are four commands, and asked one key at a time a screen of a hundred took four hundred round trips - fine on a socket in the same machine, half a minute on a link with twenty-five milliseconds of latency. They go out together and the answers come back in order, which is what Redis promises about a pipeline. Giving up is asked about before an exchange is sent and while its answer is not coming, and a connection given up on in the middle of one is let go of: it holds answers nobody is going to read, and the next thing asked used to read them as its own.
-
Redis is mapped, not modelled. The interface asks for rows in SQL, so the driver recognises the four shapes this app itself writes - SELECT, UPDATE, INSERT, DELETE over
data- and passes everything else to Redis as a command. It is not a SQL parser and does not try to be. If another engine like this appears, the interface should grow a non-SQL path instead of each such driver growing a recogniser. -
PostgreSQL specifics not covered:
COPY,EXPLAIN ANALYZE, sequences as objects of their own, materialized view refresh, and switching database without reconnecting (:opentakes a whole target). -
S3 has no upload and no delete of many at once.
PUT key textwrites what is typed, which is meant for a small object; there is no multipart upload, so a large one belongs in a tool that streams, and an object larger than 64 MB is refused rather than held in memory. Making and dropping a bucket is not there either: both are decisions about where data lives and what it costs, and a key press is the wrong way to make them. Nor is there versioning, tagging, or an ACL - a listing shows what a listing gives. -
RabbitMQ's messages are not rows. Reading a queue takes messages off it, so the grid never touches them:
PEEKandDRAINin the console do, and say which of the two happened. There is no AMQP in this program at all - no consuming, no acking somebody else's delivery, no shovel or federation management - and a broker without the management plugin cannot be opened, because that plugin is the whole protocol here. -
Azure Blob has no link and no upload either. A link needs a shared access signature, which is a signature of its own and is not written yet; a big blob needs blocks, which
PUTdoes not do. Snapshots, versions, leases, the change feed and anything Data Lake adds are not there: a listing shows what a listing gives. -
SFTP transfers cannot be given up on. libssh2 is used in blocking mode, so
ctrl+cdoes not interrupt one halfway; every call has a timeout instead, so nothing hangs forever. There is no recursive copy, no resume, noscp, and no ssh beyond the SFTP subsystem - and no FTP or WebDAV, which are filesystems of the same shape and would fit the same driver.
MIT - see LICENSE.
What is linked into the binary keeps its own: SQLite (vendor/sqlite3.c) is
public domain, libpq is under the PostgreSQL licence, the MariaDB Connector/C
under the LGPL 2.1 and OpenSSL under Apache 2.0. The LGPL asks that its object
code can be replaced, and it can: zig build -Dstatic -Dmariadb=<prefix> from
this source is the whole procedure. The copyright file in the .deb says all
of this too.