Parity matrix
What each Propus engine does compared with the system it replaces, measured rather than claimed. A row is "yes" only when a test in this repository exercises it; gaps are listed by name. Updated with every release that changes an engine.
CoreStore vs PostgreSQL 18
Proof: tests/diff/run.sh runs 36,000 random queries and 22 scenarios (views,
triggers, functions, windows, savepoints…) on Propus and a real postgres:18
container and compares the results; tests/diff/extensions.sh does the same
for vectors against pgvector/pgvector:pg18 and geometry against
postgis/postgis:18-3.6. engines/propus-core/tests covers errors, row-level
security, recursion limits and index plans; tests/replication/run.sh runs a
primary and replicas in separate containers.
| Area | PostgreSQL 18 | CoreStore | Notes |
|---|---|---|---|
| Queries | full | yes | joins, subqueries, WITH (recursive), set operations, CASE, JSON operators |
| Window functions | yes | yes | ranking, offsets, values, every aggregate over ROWS/RANGE/GROUPS frames, named windows (no frame EXCLUDE) |
| Aggregates | yes | yes | FILTER, ORDER BY inside, WITHIN GROUP (percentile_cont/disc, mode), string_agg, array_agg, json(b)_agg/object_agg |
| Views | yes | yes | run as the caller (security_invoker), so row-level security applies; materialized views with REFRESH |
| Indexes | B-tree, expression, partial, INCLUDE | yes | the planner uses an expression index when the query repeats the expression, a partial one when the query's WHERE implies its predicate |
| Functions | SQL, PL/pgSQL, others | SQL, PL/pgSQL | other languages (plpython…) are refused; recursion stops at a stack budget with SQLSTATE 54001 |
| Triggers | row, statement, INSTEAD OF, event | yes, not event | BEFORE/AFTER/INSTEAD OF, WHEN, UPDATE OF, TRUNCATE; event triggers are not provided |
| Savepoints | yes | yes | also inside PL/pgSQL EXCEPTION blocks |
| Extensions | CREATE EXTENSION, hundreds available | built in | vector, postgis, plpgsql, uuid-ossp accepted as already there; others (pgcrypto, pg_trgm…) refused with 0A000 |
| pgvector | vector, distances, HNSW, IVFFlat | yes | HNSW recall ≥ 0.95 tested against exact search; ivfflat is built as HNSW; halfvec and sparsevec are not provided |
| PostGIS | geometry, geography, ~1,000 functions, GiST | the common ~60 | ST_Transform knows 4326 and 3857 only; rasters and topology are not provided |
| Replication | streaming replicas, synchronous commit, promotion | yes | primary and read replicas with a sealed replication secret; failover is a replica's own choice (--failover-after), not a quorum: use one replica with it, or promote by hand |
| Row-level security | permissive and restrictive policies | permissive | on by default for every table |
| Not provided | DISTINCT ON, LISTEN/NOTIFY (the change feed does this), restrictive policies, statement_timeout, logical replication slots, foreign data wrappers, table inheritance and partitioning |
FlashCache vs Redis 7.4
Proof: tests/cache/run.sh runs the same 866 commands against FlashCache and
a real redis:7-alpine (7.4) container and compares every reply
(tests/cache/diff.mjs); tests/cache/features.mjs covers what a one-shot
comparison cannot (blocking, transactions, pub/sub, notifications, RESP3,
clients, monitor, tenants); the runner also checks journal replay after a
SIGKILL and snapshot restore. Unit tests cover the data structures (HyperLogLog
error bound, geohash against Redis's published values, Lua patterns).
| Area | Redis 7.4 | FlashCache | Notes |
|---|---|---|---|
| Protocol | RESP2, RESP3, inline commands, pipelining | yes | HELLO 2/3; RESP3 maps, sets, doubles, booleans, nulls, verbatim, pushes |
| Strings | SET options (NX/XX/GET/EX/PX/EXAT/PXAT/KEEPTTL), GETEX, GETDEL, MSET(NX), INCR family, INCRBYFLOAT, APPEND, SETRANGE/GETRANGE, STRLEN, LCS | yes | |
| Bitmaps | SETBIT, GETBIT, BITCOUNT (BYTE/BIT), BITPOS, BITOP (AND/OR/XOR/NOT), BITFIELD(_RO) with OVERFLOW | yes | BITOP DIFF/DIFF1/ANDOR/ONE from Redis 8 too |
| Hashes | all commands, HRANDFIELD, HSCAN, field expiry (HEXPIRE family, 7.4) | yes | HGETDEL/HGETEX/HSETEX from Redis 8 too |
| Lists | all commands, LPOS, LMOVE, LMPOP, blocking BLPOP/BRPOP/BLMOVE/BLMPOP/BRPOPLPUSH | yes | blocked clients wake on the write, not by polling |
| Sets | all commands, SINTERCARD, SMISMEMBER, SSCAN | yes | |
| Sorted sets | ZADD options, ZRANGE (BYSCORE/BYLEX/REV/LIMIT), union/inter/diff (+STORE, WEIGHTS, AGGREGATE), ZINTERCARD, ZRANGESTORE, ZMPOP/BZMPOP, ZRANDMEMBER, ZSCAN | yes | sets accepted as inputs to ZUNION and friends, as in Redis |
| Streams | XADD (ids, *, ms-*, NOMKSTREAM, MAXLEN/MINID), XRANGE/XREVRANGE, XREAD (BLOCK, $, +), consumer groups (XGROUP, XREADGROUP, NOACK, XACK, XPENDING, XCLAIM, XAUTOCLAIM, XINFO, XSETID), XDEL, XTRIM | yes | entries-read/lag as Redis 7 computes them |
| HyperLogLog | PFADD, PFCOUNT (multi-key), PFMERGE; GET/SET of the HYLL string | yes | same hash, registers and estimator as Redis, so counts are identical; GET returns Redis's dense bytes and the PF commands read Redis's dense and sparse bytes, so HyperLogLogs move between Redis and FlashCache with GET and SET (tested both ways at 7 to 40,000 elements) |
| Geo | GEOADD (NX/XX/CH), GEODIST, GEOHASH, GEOPOS, GEORADIUS(BYMEMBER)(_RO), GEOSEARCH, GEOSEARCHSTORE | yes | search is a scan over the sorted set; fine for thousands of points, not yet indexed for millions |
| Keys | DEL/UNLINK, EXISTS, TYPE, EXPIRE family with NX/XX/GT/LT, (P)EXPIRETIME, PERSIST, RENAME(NX), COPY, MOVE, DUMP/RESTORE, KEYS, SCAN (MATCH/COUNT/TYPE), RANDOMKEY, TOUCH, OBJECT, SORT/SORT_RO, WAIT, WAITAOF | yes | DUMP payloads are FlashCache's own (not Redis RDB bytes); COPY … DB and MOVE cross databases |
| Transactions | MULTI/EXEC/DISCARD, WATCH/UNWATCH, EXECABORT, SELECT inside MULTI | yes | |
| Scripting | EVAL/EVALSHA(_RO), SCRIPT LOAD/EXISTS/FLUSH, FUNCTION LOAD/LIST/DELETE/FLUSH/DUMP/RESTORE/STATS, FCALL(_RO), redis.call/pcall/sha1hex/status_reply/error_reply/setresp/log, KEYS/ARGV, cjson, bit, SELECT in scripts | yes | Lua 5.4 semantics on Piccolo (pure Rust); cmsgpack and struct not provided; SCRIPT KILL/FUNCTION KILL answer NOTBUSY because a script is stopped by the server at lua-time-limit (default 5 s) and its writes until then are kept — Redis instead keeps running and lets other clients kill it only if it has not written |
| Pub/sub | PUBLISH, SUBSCRIBE, PSUBSCRIBE, UNSUBSCRIBE, PUNSUBSCRIBE, PUBSUB CHANNELS/NUMSUB/NUMPAT; shard variants | yes | SPUBLISH/SSUBSCRIBE behave as the plain ones (no cluster) |
| Keyspace notifications | notify-keyspace-events flags, __keyspace@<db>__/__keyevent@<db>__ | yes | the channel names the key's own database |
| Memory bound | maxmemory, all eight maxmemory-policy values, maxmemory-samples, LFU (lfu-log-factor, lfu-decay-time), OOM errors under noeviction | yes | sizes are estimates, as Redis's are |
| Persistence | RDB snapshots + AOF | yes, differently | a KeelStore snapshot every 30 s (and on SAVE/BGSAVE/shutdown) plus a journal with appendfsync always/everysec/no; a restart loses at most the fsync interval; non-deterministic commands (scripts, SPOP, XREADGROUP…) journal their effects, as Redis 7 does |
| Config | CONFIG GET/SET/RESETSTAT/REWRITE | yes | settings persist by themselves (Redis needs CONFIG REWRITE); server-level settings (bind, port, requirepass, dir…) belong to the Propus server |
| Introspection | INFO (all sections), DBSIZE, TIME, LASTSAVE, COMMAND (COUNT/LIST/INFO/DOCS/GETKEYS), MEMORY USAGE/STATS/DOCTOR, SLOWLOG, LATENCY, DEBUG OBJECT/SLEEP, ROLE, LOLWUT | yes | COMMAND DOCS carries summaries and groups, not the full argument tables; LATENCY histograms come from command statistics |
| Clients | CLIENT ID/SETNAME/GETNAME/SETINFO/INFO/LIST/KILL (filters)/NO-EVICT/NO-TOUCH/REPLY, MONITOR | yes | CLIENT PAUSE, CLIENT TRACKING (client-side caching) and CLIENT UNBLOCK are not provided |
| Databases | SELECT 0–15, MOVE, COPY … DB, SWAPDB, FLUSHDB/FLUSHALL, INFO keyspace | yes | every stored key carries its database, so databases never overlap (KEYS, SCAN, DBSIZE, FLUSHDB see one); pub/sub spans databases, as in Redis |
| Access control | ACL users with command and key rules | different | identities are Propus keys and tokens: a tenant's token is confined to its own key namespace (every key, channel, script and FLUSH), non-admin callers cannot run @dangerous/@admin commands; ACL WHOAMI/CAT/LIST/USERS/GETUSER answer, ACL SETUSER points at the Propus API |
| Multi-tenancy | none | more | one cache per database, tenant namespaces inside it, the same credential as the SQL database |
| HTTP | none | more | GET/PUT/DELETE /v1/cache/{key}, POST /v1/cache/command |
| Cluster | hash slots, MOVED/ASK, CLUSTER commands | not yet | a standalone's answers (CLUSTER → "cluster support disabled"); horizontal scale is ScaleMesh's job |
| Replication | REPLICAOF, PSYNC, WAIT | not yet | SQL databases have read replicas; a cache lives on the primary only; REPLICAOF/SYNC are refused |
| Modules | MODULE LOAD, RedisJSON, RediSearch… | no | the data types above are built in; JSON documents live in CoreStore (JSONB), search in TrackSense |
| Client-side caching | CLIENT TRACKING, RESP3 invalidation pushes | no | planned with PulseStream-driven invalidation |
Known differences a program could notice: HSCAN/HGETALL return fields in
sorted order (Redis: insertion/hash order); error messages from Lua carry no
user_script:line: prefix; OBJECT HELP and other HELP texts are shorter;
COMMAND lists Redis 8 commands FlashCache also speaks.
PulseStream vs Kafka 3.9
Proof: tests/stream/run.sh runs kafkajs (admin, idempotent and gzip
producers, consumer groups shared by two members, transactions with offsets
and read_committed, resets, DeleteRecords, group and topic deletion) against
PulseStream and against a real apache/kafka:3.9.0 container and compares the
two transcripts line by line; confluent-kafka (librdkafka) produces, consumes
in a group, commits and reads watermarks; the HTTP suite covers partitions,
keys, headers, compaction, retention settings, dead letters, server-assigned
groups and WebSocket push. Unit tests cover murmur2 against Kafka's vectors,
RecordBatch v2 with CRC-32C and gzip, idempotence, transactions, retention,
compaction and the migration of first-release streams.
| Area | Kafka 3.9 | PulseStream | Notes |
|---|---|---|---|
| Wire protocol | binary protocol, versioned APIs | yes | Produce 0–7, Fetch 0–11, ListOffsets 0–5, Metadata 0–8, OffsetCommit 0–7, OffsetFetch 0–5, FindCoordinator 0–2, JoinGroup 0–5, Heartbeat 0–3, LeaveGroup 0–3, SyncGroup 0–3, DescribeGroups 0–4, ListGroups 0–2, SaslHandshake 0–1, ApiVersions 0–3, CreateTopics 0–4, DeleteTopics 0–3, DeleteRecords 0–1, InitProducerId 0–1, AddPartitionsToTxn 0–1, AddOffsetsToTxn 0–1, EndTxn 0–1, TxnOffsetCommit 0–2, DescribeConfigs 0–2, AlterConfigs 0–1, SaslAuthenticate 0–1, CreatePartitions 0–1, DeleteGroups 0–1, IncrementalAlterConfigs 0, OffsetDelete 0; clients negotiate down to these |
| Records | RecordBatch v2: keys, values, headers, timestamps, producer id/epoch/sequence, control markers | yes | CRC-32C checked; batches arrive uncompressed, gzip, snappy (raw or xerial-framed), lz4 or zstd (librdkafka produces each in the tests); fetches return uncompressed batches |
| Topics and partitions | create/delete/describe, add partitions, per-topic configs | yes | up to 1024 partitions; retention.ms, retention.bytes, cleanup.policy, max.message.bytes, delete.retention.ms, message.timestamp.type take effect, other settings are kept and shown; auto-creation on first use (1 partition) |
| Partitioning | murmur2 of the key, sticky round-robin otherwise | yes | the same hash as Kafka's default partitioner, so keys land where a Kafka client expects |
| Retention | time and size based deletion | yes | enforced every minute and on maintain |
| Compaction | last record per key, tombstones, delete.retention.ms | yes | a compaction runs when half the records are new or after five minutes; POST /streams/{t}/compact runs it now; DeleteRecords is refused on compacted topics, as Kafka does |
| Consumer groups | join/sync/heartbeat/leave, generations, rebalance and session timeouts, leader-side assignment, static membership ids, DescribeGroups/ListGroups/DeleteGroups | yes | the classic protocol; group.initial.rebalance.delay.ms 500 ms; the server also assigns (range) for HTTP members |
| Offsets | OffsetCommit/OffsetFetch/OffsetDelete, ListOffsets earliest/latest/by timestamp/max timestamp | yes | |
| Idempotent producers | producer ids, epochs, sequence checks, duplicate batches answered with their original offset | yes | |
| Transactions | InitProducerId with a transactional id (fencing), AddPartitionsToTxn, AddOffsetsToTxn, TxnOffsetCommit, EndTxn commit/abort markers, last stable offset, aborted-transaction lists, read_committed fetches, transaction timeouts | yes | |
| Fetch | long polling (max_wait_ms, min_bytes), max_bytes budgets, isolation levels | yes | fetch sessions are accepted but not kept (every fetch is full) |
| Auth | SASL/PLAIN, SCRAM, OAUTHBEARER, mTLS, ACLs | PLAIN | the database's connection secret is the password (any username); each database is its own broker-sized world; SCRAM and ACL rules are not provided |
| Admin | configs, partitions, records, groups | yes | DescribeConfigs for topics and the broker; AlterConfigs and IncrementalAlterConfigs for topics |
| HTTP and WebSocket | none (REST Proxy is separate) | more | JSON produce/read/groups/commit/lag, server-assigned groups, live push |
| Dead letters | none (client libraries) | more | nack counts attempts; at the limit the record goes to <topic>.dlq with the reason and the group moves on |
| Change feed | none (Debezium is separate) | more | every committed CoreStore change is readable and pushed (/changes); ObjectCore events are a keyed objects topic |
| Encryption at rest | none | more | the stream file is sealed with the database's key when the database is encrypted |
| Replication, multi-broker, KRaft, MirrorMaker | yes | not yet | streams live on the primary; SQL read replicas do not carry them |
| Quotas, throttling | yes | no | |
| Kafka Streams, Connect, Schema Registry | separate products | no | |
| Compression on the wire | gzip, snappy, lz4, zstd | yes, in | producers may use any codec; PulseStream stores records uncompressed and fetches return them so (topic-level compression.type is accepted but not applied) |
Known differences a program could notice: fetchTopicOffsets right after
createTopics sees the new topic at once (Kafka may take a moment to elect a
leader); the group coordinator forgets live membership on restart (committed
offsets stay), so consumers rejoin; COMMAND DOCS-style completeness is not a
goal here — the protocol table above is what is spoken.
AutoGraph vs Prisma, Drizzle, SQLAlchemy, Entity Framework
Proof: tests/e2e/run.sh runs the same scenario in each SDK (TypeScript,
Python, Java, C#) against a live server: tables defined in code, sync and a
no-op second sync, inserts with defaults filled in, operator and OR/NOT
conditions, enum and column CHECKs refused with SQLSTATE 23514, composite
unique refused with 23505, eager loading of one, many and many-through
relations (nested in TypeScript and Python), inner and left joins nested under
the joined table, group by with count/sum/max/avg, keyset paging, increment,
onUpdateNow stamping, row-level security through a scoped client, cascade
deletes, versioned migrations generated, applied once and rolled back,
(TypeScript, Python) the live schema introspected and generated back as code,
nested writes (create, connect, connectOrCreate, set, disconnect, update,
delete, upsert on one, many and many-through; rolled back on failure; RLS
through as()), middleware (order, changed args and results, short-circuit,
soft delete, kept by as()) and renames (detected, refused with drop until
decided, data kept, in a migration's up and down; the CLI's --rename / --no-rename in TypeScript).
| Area | Prisma / Drizzle / SQLAlchemy / EF | AutoGraph | Notes |
|---|---|---|---|
| Schema in code | yes | yes | TS, Python, Java, C#; column types incl. NUMERIC(p,s), VARCHAR(n), UUID, JSONB; primary, unique, not null, defaults, foreign keys with ON DELETE/UPDATE, enums (as CHECK), column and table CHECKs, single and composite indexes and uniques |
| Row-level security in the schema | no (SQL by hand) | more | policies and grants are part of the table definition and sync |
| Search in the schema | no | more | search: { columns, tenantColumn } keeps a TrackSense index |
| Push / sync | prisma db push, drizzle-kit push | yes | additive by default; { drop: true } also drops columns and tables no longer defined |
| Versioned migrations | prisma migrate, drizzle-kit generate, Alembic, EF migrations | yes | generated from the difference, written as SQL files with up and down, applied once each in its own transaction (_propus_migrations), rolled back |
Introspection (db pull) | yes | yes | TS and Python return table definitions; the schema keeps each column's declared SQL type |
| Generated types | yes | yes | db.generate() / propus-generate emits TypeScript, Python, Java or C# row types and table definitions; the CLI wraps generate() and is not separately tested |
| Typed rows | yes | TS, C# records via generation | TypeScript infers row and insert types from the definitions; Python/Java/C# return dictionaries/maps at runtime, typed through generated code |
| Queries | full | yes | where with eq/ne/gt/gte/lt/lte/in/notIn/like/ilike/startsWith/endsWith/contains/between/isNull/matches, AND/OR/NOT; columns, distinct, order, limit/offset, keyset paging, pluck, count, exists |
| Relations | include / with / joinedload / Include | yes | one, many, many-through; filtered, ordered, limited and nested eager loading; one query per relation level (no N+1) |
| Joins | yes | yes | inner and left, rows nested under the joined table's name or alias |
| Aggregates | yes | yes | count (distinct), sum, avg, min, max, group by, having |
| Writes | yes | yes | insert, insert many, upsert (ON CONFLICT), update, increment, delete, delete all, RETURNING everywhere |
| Transactions | yes | yes | interactive transactions with the same query API |
| Raw SQL | yes | yes | query(sql, params) with $n parameters |
| Nested writes (create with children) | Prisma | yes | on insert create / connect / connectOrCreate for one (parent first, key filled in), many and many-through (join rows); on update also set, disconnect, update, delete, upsert; one transaction as the client's identity, so row-level security applies to every row; any failure rolls back; tested in all four SDKs incl. RLS through as() |
| Middleware / extensions / soft delete | Prisma, EF | yes | db.use((params, next) => …) around every operation (writes and the select terminals), onion order, args and results changeable, short-circuit, kept by as() / database() / transactions; soft delete tested in all four SDKs; no $extends-style typed extensions |
| Lazy loading, change tracking, unit of work | SQLAlchemy, EF | no | AutoGraph is query-first, like Drizzle |
| Column renames in migrations | Prisma prompts | yes | plan().renames lists column (same SQL type) and table (same columns) candidates; decisions emit RENAME COLUMN / RENAME TO (reversed in down), data kept; with drop, an undecided candidate refuses; propus-generate --migration takes --rename / --no-rename (tested) or asks on a terminal (checked by hand) |
| Other databases | Postgres, MySQL, SQLite… | Propus only | the Postgres wire protocol also lets Prisma, Drizzle and node-postgres talk to Propus directly (tested with Drizzle) |
Migration Engine
What moves into Propus, and how. Proof: tests/migrate/run.sh starts
throwaway Postgres 18, MySQL 8.4, Redis 7.4, Kafka 3.9 and SeaweedFS (S3) containers and
runs engines/propus-migrate/tests (each source end to end, plus a crash in
the middle of the copy and a resume) and tests/migrate/api.sh (the same
through the server's HTTP API, with a server restart while following).
| Source | Lands in | Copy | Follows changes | Fence at cutover | Verify | Rollback |
|---|---|---|---|---|---|---|
| PostgreSQL 10+ | tables | keyset batches, resumable; types, defaults, NOT NULL, unique, checks, enums, foreign keys, indexes; identity/serial continue after the copied ids | logical decoding (test_decoding, a slot recorded before the copy, advanced only after applying) | default_transaction_read_only on the database, other sessions ended | row count and SHA-256 per row, merged by key | Propus's changes since the cutover written back in one transaction |
| MySQL 5.7+ / MariaDB | tables | as above; ENUM, JSON, binary as \x hex, BIT, TINYINT(1) as BOOLEAN | row binary log (needs binlog_format=ROW, binlog_row_image=FULL) | SET GLOBAL read_only | as above | as above (REPLACE / DELETE) |
| SQLite | tables | as above | no (copy with the application stopped) | operator | as above | as above |
| Redis / Valkey 6+ (standalone; a cluster is read by slot but not yet tested against a real one) | the database's FlashCache, db by db | every type with exact expiries (PEXPIRETIME), hash fields' expiries, streams with groups, consumers and pending entries; HyperLogLogs counted the same | keyspace notifications (turned on for the job, set back after); compare-and-repair after a restart, a dropped connection, and at the cutover | CLIENT PAUSE … WRITE | every key compared on both sides | lifts the pause; the source made equal to FlashCache |
| Kafka 2.4+ (and anything speaking its protocol), plain, TLS, SASL PLAIN or SCRAM-SHA-256/512 | the database's PulseStream | every topic (or those named) with its partition count and its own settings; every partition from its earliest offset with offsets, keys, values, headers and timestamps kept; committed data only (aborted transactions stay gaps, as consumers see them); any compression; consumer groups' committed offsets | reading each partition as a consumer does; groups' offsets every 5 s; new topics and partitions picked up | none possible: cutover needs the producers stopped ("fence": false); a fenced cutover is refused | every record's digest per partition, partition ends, every group's offsets | records Propus received after the cutover produced back in order (Kafka gives them the same offsets when nothing else wrote), groups' offsets committed there |
| S3-compatible (AWS, R2, MinIO, SeaweedFS…) | the database's object store | objects with content type and user metadata; MD5 checked against single-part ETags; multipart ETags kept; in a versioned bucket each object's history, oldest first, as many versions as the store keeps; a bucket readable by anyone (policy status, an AllUsers grant, or a policy letting everyone GET) becomes public | listing again every 30 s | none possible: cutover needs the application stopped ("fence": false); a fenced cutover is refused | ETag per object, SHA-256 read again for multipart objects, each versioned object's history | the source buckets made equal to Propus (current objects) |
Every job is saved after each batch under <data>/migrations, with its source
URL sealed by the server's key; a server restart resumes it. Start one with
propus migrate --from <source URL> --to <database URL> or
POST /v1/db/<db>/migrations.
Connections to sources use TLS as each one's own clients do: Postgres takes
libpq's sslmode (prefer by default; verify-ca/verify-full check the
certificate, both also checking the host name) and sslrootcert; MySQL takes
ssl-mode (PREFERRED by default, VERIFY_CA, VERIFY_IDENTITY) and
ssl-ca; Redis rediss://; S3 HTTPS unless secure=false. The test servers
run with a throwaway CA, and MySQL refuses unencrypted connections there, so
every MySQL path (copy, binary log, rollback) is shown to run over TLS. Gaps, by name:
Postgres views, triggers, functions and row-level security policies are
reported, not copied; MySQL triggers and procedures likewise; a Redis FLUSHDB while following sends no per-key
notifications and is caught by the cutover's repair, not at once; module types
(RedisJSON…) are listed and skipped; S3 lifecycle rules, tags, per-object ACLs
and the details of bucket policies are not copied, and an object whose latest
version is a delete marker is not copied (Propus keeps versions only under a
live object); whether a bucket is public is tested against AWS's documented
responses only, because the test server (SeaweedFS) keeps no ACLs or policies; Redis rollback replaces the source's
content rather than replaying a change list (writes that reach the source after
the cutover are overwritten).