# sluice — full documentation (plain text) Generated from the same sources as https://sluicesync.com/docs/ — regenerate with `node build.mjs`. Curated index: https://sluicesync.com/llms.txt =============================================================== # Documentation (https://sluicesync.com/docs/) Migrate and continuously sync MySQL and Postgres, and import SQLite / Cloudflare D1 — correctness-first, loud failure by default. sluice is an open-source tool for moving and keeping databases in sync between MySQL and Postgres, in all four directions. SQLite files (and a wrangler d1 export .sql dump), live Cloudflare D1 databases, CSV / TSV / NDJSON files, and mydumper / pscale dump directories also import into Postgres or MySQL, and SQLite is itself a migrate target — 14 engines are registered today (run sluice engines to list them). It is built around three surfaces you can use independently or end to end: - Migrate — a one-shot schema + data copy, with deferred indexes/constraints for fast bulk load and per-table resume. - Sync — change-data-capture streaming with a snapshot → CDC handoff and resumable checkpoints. - Operate — run as a long-lived service behind /readyz and /metrics, or as one-shot jobs. ## Start here - Getting started — install, connect, and run your first migration and sync. - Command reference — every command, its key flags, and worked examples. - Configuration — connection strings, environment variables, the YAML config file, and global flags. New here? The fastest path is Getting started → run a --dry-run migration against a copy of your data → then read the migrate and sync start references. =============================================================== # Getting started (https://sluicesync.com/docs/getting-started/) Install sluice, point it at a source and target, and run your first migration and continuous sync. ## Install sluice is a single static binary with no daemon and no SaaS dependency. Install it with your platform's package manager: - macOS / Linux (Homebrew): brew install sluicesync/tap/sluice - Windows (Scoop): scoop bucket add sluicesync https://github.com/sluicesync/scoop-bucket then scoop install sluice - Windows (WinGet): winget install sluicesync.sluice (once accepted into winget-pkgs) - Debian / Ubuntu: download the .deb from the latest release, then sudo dpkg -i sluice_*_linux_amd64.deb - RHEL / Fedora: download the .rpm, then sudo rpm -i sluice_*_linux_amd64.rpm - Go: go install sluicesync.dev/sluice/cmd/sluice@latest - Container (multi-arch, distroless): docker pull ghcr.io/sluicesync/sluice:latest Self-contained binaries (Linux / macOS / Windows × amd64 / arm64) and .deb / .rpm / .apk packages are attached to every release. Verify the install: sluice --version sluice engines # list the database engines built into this binary ## Prerequisites - A source and a target database you can reach over the network. - Engines available out of the box (14 — run sluice engines to confirm): mysql, mariadb, the planetscale and self-hosted vitess MySQL flavors, postgres, sqlite and d1 (migrate sources; sqlite is also a target), the trigger-CDC engines postgres-trigger, sqlite-trigger, d1-trigger, and the flat-file migrate sources csv, tsv, ndjson, and mydumper. - For continuous sync from Postgres, the source normally needs logical replication (a replication slot). Managed Postgres that blocks slots (e.g. Heroku) can use the slot-less trigger engine instead. - SQLite and Cloudflare D1 are migrate sources (a local file, a .sql dump, or a live D1 over the HTTP query API) into Postgres or MySQL; SQLite is also a target. Their base engines are migrate-only — for continuous sync use the trigger-CDC variants sqlite-trigger / d1-trigger. ## Connecting to your databases Source and target are passed as DSNs (connection strings). The driver is named separately with --source-driver / --target-driver. Engine · DSN format · mysql · user:pass@tcp(host:3306)/dbname · mariadb · Same shape as mysql (user:pass@tcp(host:3306)/dbname) — use the mariadb driver for a MariaDB server; sluice fingerprints the server and steers you if the driver and server family are mismatched. · postgres · postgres://user:pass@host:5432/dbname?sslmode=require · sqlite · A file path (./app.db) or a wrangler d1 export .sql dump (auto-detected). Also a target driver. · d1 · d1:/// (or d1:// + CLOUDFLARE_ACCOUNT_ID); API token via CLOUDFLARE_API_TOKEN. · A note on sslmode. The sslmode=require in these placeholder DSNs encrypts the connection but does not verify the server's certificate — a safe default that works against any TLS target regardless of its CA. Prefer sslmode=verify-full (encrypt and verify the CA chain + hostname, which defeats man-in-the-middle) whenever the target's certificate is trusted by your system store or you can pin its CA with sslrootcert. Managed providers with a public CA make this free — e.g. PlanetScale Postgres ships a Let's Encrypt certificate, so sluice connects with verify-full out of the box. sluice (pgx) passes sslmode and sslrootcert straight through to the driver and never downgrades TLS on its own. DSNs often contain credentials, so you can supply them via environment variables instead of flags: export SLUICE_SOURCE='root:rootpw@tcp(localhost:3306)/app' export SLUICE_TARGET='postgres://postgres:pgpw@localhost:5432/app?sslmode=disable' See Configuration for the full set of environment variables and the optional YAML config file. ## Your first migration A one-shot migration translates the source schema, creates the target tables, bulk-copies rows, then builds indexes and constraints. Always do a dry run first — it reads the source schema and prints the plan without touching the target: sluice migrate \ --source-driver mysql --source 'root:rootpw@tcp(localhost:3306)/app' \ --target-driver postgres --target 'postgres://postgres:pgpw@localhost:5432/app?sslmode=disable' \ --dry-run When the plan looks right, drop --dry-run to apply it. If a migration is interrupted, re-run with --resume — state is checkpointed per table on the target, so it picks up where it left off: sluice migrate --source-driver mysql --source ... --target-driver postgres --target ... --resume Cold-start safety. sluice refuses to bulk-copy into a non-empty target by default (an INSERT into a populated table would collide on the primary key). Use --resume to continue a prior run, or read the migrate reference for the recovery flags. ## Import a SQLite file or Cloudflare D1 SQLite and Cloudflare D1 are migrate sources into Postgres or MySQL. Point --source-driver sqlite at a local .db file — or at a wrangler d1 export .sql dump, which is auto-detected — and migrate as usual: # SQLite file (or a wrangler d1 export .sql dump) → Postgres sluice migrate \ --source-driver sqlite --source ./app.db \ --target-driver postgres --target 'postgres://postgres:pgpw@localhost:5432/app?sslmode=disable' To read a live Cloudflare D1, use --source-driver d1 with a d1:// DSN; the API token is read from CLOUDFLARE_API_TOKEN (env-only, never a flag). The reader projects each column through typeof() + CAST(… AS TEXT) / hex() so integers above 253 and BLOBs round-trip exactly (no JavaScript 52-bit rounding), and the reads don't take D1 offline: # Live Cloudflare D1 → Postgres export CLOUDFLARE_API_TOKEN=... sluice migrate \ --source-driver d1 --source 'd1:///' \ --target-driver postgres --target 'postgres://...?sslmode=disable' SQLite is also a migrate target (--target-driver sqlite) — emit a .db from any source (decimals are stored byte-exact as TEXT), e.g. to then run wrangler d1 import. D1 itself is not a target; emit a SQLite .db and import it with wrangler. Declared dates are an explicit choice. SQLite has no native temporal storage, so columns declared DATE / DATETIME are decoded per --sqlite-date-encoding (iso default, or unixepoch / unixmillis / julian) — a value whose storage class doesn't match is refused loudly, never a silently-wrong date. For continuous (not one-shot) sync from SQLite / D1, use the trigger-CDC engines sqlite-trigger / d1-trigger. ## Your first continuous sync Continuous sync captures a consistent snapshot, bulk-copies it, then streams ongoing changes. Streams are identified by a --stream-id so they can resume after a restart: sluice sync start \ --source-driver mysql --source 'root:rootpw@tcp(localhost:3306)/app' \ --target-driver postgres --target 'postgres://postgres:pgpw@localhost:5432/app?sslmode=disable' \ --stream-id app-prod From another shell, check freshness or status, and stop the stream cleanly when you're done: sluice sync status --stream-id app-prod --target-driver postgres --target ... sluice sync health --stream-id app-prod --target-driver postgres --target ... # cron-friendly exit code sluice sync stop --stream-id app-prod --target-driver postgres --target ... # drains in-flight changes, then exits ## Verify the copy After a migration or once a stream has caught up, compare source and target: sluice verify \ --source-driver mysql --source ... \ --target-driver postgres --target ... verify compares row counts by default and can escalate to per-row hashing — see the verify reference. ## Set up backups sluice takes logical backups — a full snapshot that roots a chain, plus incrementals appended onto it — to a local directory or any S3/GCS/Azure object store. It's the same binary; no separate backup daemon. Take a full backup first; on Postgres, add --chain-slot so the full provisions the replication slot that anchors the chain (incrementals then chain with zero gap, no manual slot setup): # full snapshot to a local directory (chain root) sluice backup full --source-driver postgres --source ... \ --output-dir /var/backups/app --chain-slot # append an incremental onto the chain sluice backup incremental --source-driver postgres --source ... \ --output-dir /var/backups/app Backups are compressed with zstd by default (--compression none|gzip|zstd). To rest the chain encrypted, add the encryption flags — a passphrase (read from an env var or file, not the command line) or a cloud KMS key (--kms-key-arn / --gcp-kms-key-resource / --azure-key-vault-id); see the backup reference. For object storage, swap --output-dir for --target (s3://, gs://, azblob://, file:///). S3-compatible providers — Cloudflare R2, Backblaze B2, MinIO, Wasabi, Tigris — take three extra knobs: --backup-endpoint (the provider's endpoint), --backup-region, and --backup-path-style (bucket-in-path addressing, which most non-AWS providers require): # full backup to Cloudflare R2 (an S3-compatible store) sluice backup full --source-driver postgres --source ... \ --target s3://my-bucket/app-chain \ --backup-endpoint https://.r2.cloudflarestorage.com \ --backup-region auto \ --backup-path-style \ --chain-slot Credentials follow the cloud SDK's normal resolution (e.g. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY for any S3-compatible endpoint). To run continuously instead of one incremental at a time, use sluice backup stream run (rolling incrementals) — and replay a chain into a live target with the broker tutorial. ## Trigger-based CDC (no replication slot / Bucardo-style) When the source is a managed Postgres that blocks logical replication slots — Heroku Postgres, RDS without the right grants, Supabase / Crunchy starter tiers — sluice can capture changes with plpgsql triggers instead. Per-table triggers write into a sluice_change_log capture table; the postgres-trigger engine tails the log. The lifecycle is explicit — setup → run → teardown — so the source-side DDL is visible at the CLI, not silently applied on first sync. 1. Install the capture triggers (--tables is required; on a tier that denies event-trigger creation, add --allow-polled-fingerprint to opt into the polled schema-fingerprint fallback): sluice trigger setup \ --dsn 'postgres://user:pass@host:5432/app?sslmode=require' \ --tables=orders,customers \ --allow-polled-fingerprint 2. Stream with the trigger engine — the source driver is postgres-trigger; everything else is an ordinary sync start: sluice sync start \ --source-driver postgres-trigger \ --source 'postgres://user:pass@host:5432/app?sslmode=require' \ --target-driver postgres --target 'postgres://...target...' \ --stream-id app 3. Tear down cleanly when the stream is finished — this drops every per-table trigger and (by default) the sluice_change_log table, leaving the source with zero residue (the full set of objects setup installs is listed under Objects sluice creates). Pass --keep-data to retain the change-log for forensics, or --yes to skip the confirmation prompt: sluice trigger teardown \ --dsn 'postgres://user:pass@host:5432/app?sslmode=require' --yes The slot-based PG CDC reader refuses loudly when the source role lacks the REPLICATION attribute rather than silently degrading to polling — the trigger engine is the deliberate slot-less path. See the trigger reference. ## Next steps - Command reference — the full flag set for every command. - Continuous sync from a backup chain — replay a chain into a target as a long-running broker (decoupled transport). - cutover — prime target sequences before switching traffic, so the first post-cutover INSERT can't collide. - Configuration — YAML config, env vars, type/expression overrides, and PII redaction. =============================================================== # Command reference (https://sluicesync.com/docs/commands/) Every sluice command, its purpose, the flags that matter most, and worked examples. The general shape is sluice [flags]. Every command accepts the global flags (--config, --log-level, …). Run sluice --help for the complete flag list — the tables below cover the flags you'll reach for most. Parallelism flags mean different things per command. The same flag name maps to a different axis depending on the verb — read this row before tuning. Flag · What it controls · --table-parallelism · Tables processed concurrently. On migrate = tables copied at once; on backup = tables read at once (the read-side analog of pg_dump -j); on restore = tables bulk-applied at once (pg_restore -j). On sync start it governs the PG-source cold-start sweep only. · --bulk-parallelism · Within-table concurrency (a single table's chunks at once). On migrate / restore it multiplies with --table-parallelism, the product bounded by the target connection budget. · --apply-concurrency · CDC apply lane count (PK-hash, exactly-once). Used by sync start, sync from-backup, and the incremental-replay leg of restore. · --copy-fanout-degree · VStream/CDC cold-start write fan-out (PlanetScale-MySQL target) on sync start. · On sync start, --table-parallelism / --bulk-parallelism are PG-source-only — they're inert on MySQL / VStream sources. For a MySQL or Vitess/PlanetScale source's cold-copy concurrency, use the source-DSN knobs copy_table_parallelism (native MySQL) / vstream_copy_table_parallelism (VStream) for read concurrency, and --copy-fanout-degree for write fan-out. ## engines ### sluice engines List the database engines built into this binary and their bulk-load / CDC capabilities. sluice engines 14 engines are registered today: mysql (binlog CDC), planetscale and self-hosted vitess (both VStream CDC), mariadb (a MySQL-family flavor — bulk migrate source and target, backup/restore/verify, and continuous CDC sync since v0.99.271: sluice parses MariaDB's domain-based GTIDs and resumes off them; native uuid/inet columns are the one CDC-refused shape, steered to bulk migrate), postgres (logical-replication CDC), sqlite and d1 (migrate sources — sqlite is also a target — no CDC), the trigger-CDC engines postgres-trigger (slot-less Postgres), sqlite-trigger (local SQLite file), and d1-trigger (live Cloudflare D1), and the flat-file migrate sources csv, tsv, ndjson (ADR-0163) and mydumper (a mydumper / pscale database dump directory, ADR-0161). The vitess flavor shares the PlanetScale engine code with a self-hosted-vtgate capability set, and warm-resumes since v0.99.44. Engine · Role · Notes · mysql · CDC source · migrate source & target · Vanilla MySQL: binlog (row-based) CDC and bulk LOAD DATA cold-copy. DSN user:pass@tcp(host:3306)/db. · planetscale · CDC source · migrate source & target · PlanetScale MySQL flavor: VStream (gRPC) CDC and batched-insert cold-copy — Vitess blocks LOAD DATA, so use this, not mysql, against a *.psdb.cloud host. Auto-discovers the keyspace shard layout. · vitess · CDC source · migrate source & target · Self-hosted Vitess/vtgate: shares the planetscale engine code (VStream CDC) with a self-hosted-vtgate capability set; warm-resumes since v0.99.44. · mariadb · CDC source · migrate source & target · MySQL-family flavor: binlog/domain-GTID CDC (parses MariaDB domain GTIDs like 0-100-38 and resumes off them, v0.99.271/ADR-0170), bulk migrate source & target, and backup/restore/verify. Native uuid/inet6/inet4 columns decode faithfully through CDC as of v0.99.272/ADR-0171; SHOW BINLOG STATUS / SHOW BINARY LOG STATUS / SHOW MASTER STATUS fallback covers 10.11→13.1. · postgres · CDC source · migrate source & target · Logical-replication (replication-slot) CDC and COPY cold-copy. Roles, extensions, and slot lifecycle are surfaced explicitly, never silently auto-handled. · sqlite · migrate source (file or .sql dump) and target · Pure-Go modernc.org/sqlite, no CGO. Imports a binary .db or an auto-detected wrangler d1 export .sql dump into Postgres / MySQL; as a target emits a .db (decimals byte-exact as TEXT). Migrate only (no CDC). · d1 · migrate source (live, lossless) · Reads a live Cloudflare D1 over its HTTP query API (token via CLOUDFLARE_API_TOKEN); per-column typeof() + CAST(… AS TEXT) / hex() projection makes integers above 253 and BLOBs round-trip exactly, and reads don't take D1 offline (ADR-0132). · postgres-trigger · CDC source · Slot-less Postgres trigger-CDC: per-table AFTER triggers + a change-log watermark, for managed Postgres where a logical-replication slot isn't available. · sqlite-trigger · CDC source · Trigger-based continuous sync from a local SQLite file: per-table AFTER triggers + a sluice_change_log watermark for exactly-once resume (ADR-0135). · d1-trigger · CDC source · The same trigger-CDC design over a live D1's HTTP query API (ADR-0136). · csv / tsv · migrate source (file) · RFC 4180 delimited files (ADR-0163), staged into a temp SQLite database with every value byte-exact TEXT (007.1500 stays 007.1500; integers above 253 stay exact) and read through the validated SQLite surface; --infer-types auto-engages to recover rich target types. File conventions are declared, never sniffed — see the flat-file note below. tsv is the same engine with the delimiter fixed to TAB. Migrate only (no CDC). · ndjson · migrate source (file) · One JSON object per line (ADR-0163). Numbers land as their raw source text — never through a float64 — so integers above 253 and arbitrary-precision decimals stay byte-exact; nested objects/arrays land as raw JSON text; a duplicate key within an object or a top-level JSON array refuses loudly (the message carries the jq -c '.[]' conversion). Migrate only (no CDC). · mydumper · migrate source (directory) · A mydumper or pscale database dump directory (ADR-0161): metadata + per-table -schema.sql + extended-INSERT data chunks (plain, gzip, or zstd). Values decode through the live MySQL engine's own decoder — byte-identical to a live read; single-precision FLOAT display-rounding baked into the dump file itself is WARNed per table. Migrate only (no CDC). · Flat-file imports: conventions are declared, never sniffed (ADR-0163). RFC 4180 encodes neither NULL nor header presence — both are producer conventions, and guessing either is the #1 CSV silent-loss class (a wrong header guess eats a data row or turns data into column names; a wrong NULL guess silently turns empty strings into NULLs or vice versa). So the csv/tsv drivers require the conventions on the command line: Flag · Purpose · --csv-header / --csv-no-header · One is required. Declare whether the first record carries the column names (--csv-no-header names columns col1..colN in file order). Opening a csv/tsv source without either is refused loudly (SLUICE-E-CSV-HEADER-UNDECLARED). Mutually exclusive. · --csv-null · The UNQUOTED field text that means SQL NULL: --csv-null='' (the PostgreSQL COPY CSV convention — an unquoted empty field is NULL), --csv-null='\N', or --csv-null=NULL. A QUOTED field is always data ("NULL" is the four-character string; "" is the empty string). Without this flag a file containing an unquoted empty field is refused loudly (SLUICE-E-CSV-NULL-AMBIGUOUS) — a file with no empty unquoted fields needs no flag. · --csv-delimiter · csv driver only: the field delimiter — a single ASCII character, or \t/tab for TAB (default ,). The tsv driver is fixed to TAB. · Plain mysqldump / pg_dump .sql dumps and pg_dump -Fc archives are deliberately not parsed by any driver — they refuse with SLUICE-E-SOURCE-FOREIGN-DUMP and the message carries the exact scratch-server-replay recipe; a recognisable format handed to the wrong driver (a mydumper directory to csv, a .tsv through the comma lexer, a gzip'd or UTF-16 file) refuses with SLUICE-E-SOURCE-WRONG-DRIVER naming the right driver or preparation step. WAN-fast MySQL CDC apply (ADR-0139/0140). Against a MySQL / PlanetScale-MySQL target, consecutive same-shape INSERTs fold into one multi-row INSERT … ON DUPLICATE KEY UPDATE, UPDATEs apply as that same keyed upsert, and DELETEs coalesce into one DELETE … WHERE pk IN (…) — turning N round trips into one so high-latency / cross-region apply keeps up. A rate-limited INFO line (rows_per_stmt) reports the coalescing ratio so you can see whether it's helping. Rich types over continuous CDC. Continuous sync now carries the types that earlier only cold-started: PostgreSQL arrays (int4[], text[], numeric[], …, multi-dimensional preserved), MySQL ENUM and SET, MySQL→PG and PG→PG ENUM, and PostGIS geometry (every subtype/dimension, SRID preserved) — all over the CDC apply path, in both source directions (v0.99.50–v0.99.60). PostGIS geography, arrays of geometry (geometry[]), and arrays of enum (enum[]) remain loudly refused over CDC — no silent loss. ## migrate ### sluice migrate Run a one-time schema + data migration: translate the schema, create tables, bulk-copy rows, then build indexes and constraints. Flag · Purpose · --source-driver / --source · Source engine name and DSN (or SLUICE_SOURCE). · --target-driver / --target · Target engine name and DSN (or SLUICE_TARGET). · --dry-run, -n · Print the plan; don't touch the target. · --include-table / --exclude-table · Glob-aware table filters (mutually exclusive). Scope the bulk copy — including the PlanetScale (VStream) snapshot — not just the write path. · --include-database / --exclude-database / --all-databases · Multi-database fan-out (ADR-0074, MySQL source): migrate several source databases in one run, each to a same-named target namespace. Glob-aware; system databases (information_schema, mysql, …) are always excluded. When any database-scope flag is set the source DSN's database is optional (it's a server connection). · --include-schema / --exclude-schema / --all-schemas · Multi-schema fan-out (ADR-0075, Postgres source): the PG-source synonyms of the -database family. System schemas (pg_catalog, information_schema, …) are always excluded. MySQL source uses the -database spelling, PG source uses -schema; supplying both spellings in one invocation is a hard error. · --map-database / --map-schema · SRC=DST — rename a namespace on the way (ADR-0142, repeatable). Without it a fan-out lands each source namespace in a same-named target; this routes SRC to a differently-named DST (snapshot and CDC). --map-database for a MySQL source, --map-schema for a Postgres source (same rule as the fan-out spellings). The rename is engine-side only — source-keyed --redact / --type-override still match on the original name. · --allow-degraded-fks · PG-target only: tolerate a dirty FK source — when ADD CONSTRAINT FOREIGN KEY fails on orphan rows (SQLSTATE 23503), retry as NOT VALID and surface the degraded constraint at the end (run VALIDATE CONSTRAINT after fixing the orphans). Default off (loud failure on a dirty source). MySQL has no per-constraint NOT VALID and refuses loudly if this is set against a MySQL target. · --resume, -r · Resume a failed migration from per-table checkpoints on the target. · --bulk-parallelism · Parallel reader/writer pairs per large table (0 = auto, 1 = off). Since v0.99.64 (ADR-0096) within-table chunking covers single non-integer PKs (UUID/string/binary/decimal/temporal) and all-orderable composite PKs via sampled-keyset chunking — not just single-integer PKs. Tables with no usable PK (or a non-orderable PK column like JSON/array/geometry) still take the single-reader path. · --bulk-parallel-min-rows · Row-count threshold below which a table is copied with a single reader/writer pair regardless of --bulk-parallelism. 0 = auto (base 80000, dialled down on many-table schemas). · --table-parallelism · Tables copied concurrently (0 = auto: 4, 1 = off). Multiplies with --bulk-parallelism; the product is bounded by the target connection budget. · --max-target-connections · Connection budget on the target the parallelism product must fit inside. · --index-build-parallelism · Postgres-only: deferred indexes built concurrently after the bulk copy. · --type-override · TABLE.COLUMN=TYPE — force a target column type (repeatable). · --redact · Redact a PII column, e.g. users.email=hash:sha256 (repeatable). · --where · TABLE= — copy only the rows of TABLE matching a native source-SQL boolean predicate (repeatable, source-keyed; ADR-0173). Pushed into the source read (SELECT … WHERE ()), evaluated on the source. Use for per-tenant/region carve-outs or GDPR-scoped extracts. Filtering a parent table orphans its children (SLUICE-E-WHERE-FK-ORPHAN) — filter consistently or pass --allow-degraded-fks. A key naming no table refuses (SLUICE-E-WHERE-UNKNOWN-TABLE). Run verify with the SAME --where. See Split rows by region. On sync, the same flag drives continuous filtered replication (and sync adds --where-strict-collation to force byte-exact string matching). · --allow-degraded-fks · Postgres target: when a filtered copy orphans a foreign key (SQLSTATE 23503), attach it NOT VALID instead of refusing — the FK still rejects new orphaning writes; run VALIDATE CONSTRAINT after reconciling. Refused on a MySQL target (no per-constraint NOT VALID). · --infer-types · SQLite / D1 source only (ADR-0144): opt-in, data-validated promotion of conservatively-typed columns to native target types — INTEGER→boolean, ISO-8601 TEXT→timestamptz/timestamp, JSON TEXT→jsonb, UUID TEXT→uuid — but only after an exhaustive aggregate over the actual data confirms every value qualifies; otherwise the column keeps its safe type. Mixed-offset / sub-µs temporal columns and non-UUID *_id values stay text, never silently coerced. An explicit --type-override always wins. Off by default. Against a live D1, auto-engages --stage-local (below). · --stage-local / --no-stage-local · Cloudflare D1 source only (ADR-0145, v0.99.167): first replicate the live D1 into a byte-faithful local SQLite file (verbatim DDL + exact storage classes, integers above 253 included — lossless, unlike wrangler d1 export), then migrate from that file. Sidesteps D1's HTTP query limits (the per-query CPU ceiling and the GLOB pattern-complexity limit that block --infer-types on a live D1). Auto-engaged by --infer-types against a D1 source; set --stage-local explicitly to stage without inference, or --no-stage-local to force the direct path. The staged file is created in the system temp dir (override with the global --stage-dir, v0.99.259) and removed when the migrate finishes. Mutually exclusive. · --include-orm-tables / --skip-orm-tables · ORM bookkeeping tables (Rails schema_migrations, Prisma _prisma_migrations, Drizzle __drizzle_*, Laravel migrations, Flyway, Goose, …) carry the source engine's migration state, which is meaningless on a different target engine. On a cross-engine migrate they're skipped by default, each skip announced by name; --include-orm-tables copies them anyway. A same-engine run keeps them (the history is still valid) unless you pass --skip-orm-tables. The two flags are mutually exclusive. · --target-schema · Postgres-only: land tables under a named schema namespace. · --inject-shard-column · NAME=VALUE — ADR-0048 Shape A: inject a sluice-managed discriminator column on a consolidated target so per-shard rows from a multi-shard Vitess source land disjoint via a composite PK. Each per-shard run passes a distinct VALUE. · --allow-cross-shard-merge · Opt out of the cross-shard-collision preflight (Bug 152). Off by default the guard is active: a multi-shard Vitess/PlanetScale source without --inject-shard-column refuses to merge into a single PK/UNIQUE target. Pass this only when the key is globally unique across shards. · --reset-target-data · Destructive recovery: drop source-schema tables on the target, then cold-start. Prompts (type reset) unless --yes. Mutually exclusive with --resume. · --source-tls-ca / --target-tls-ca · Path to a PEM CA certificate for CA-pinned verify-ca TLS to a MySQL source / target (ADR-0158): trust this CA, verify the server certificate chains to it, skip the hostname check — the strongest mode that works against MySQL's SAN-less auto-generated certs. On the source it covers both the data connection and the binlog/CDC stream. Refused if the DSN already sets tls=; refused on non-MySQL endpoints (Postgres uses sslrootcert=/path/ca.pem in the DSN instead). Also accepted by sync start, verify, backup, and restore (target side). · --planetscale-org · On migrate this arms the automatic deploy-request index-build fallback on a planetscale target (ADR-0148) — on restore and sync start the same flag serves both this fallback and the telemetry opt-in, each arming on its own token pair (v0.99.259). When a deferred post-copy ADD INDEX hits PlanetScale's statement-time wall (errno 3024) or the safe-migrations direct-DDL block (errno 1105), sluice builds it via a dev branch + deploy request instead. Requires safe migrations ON (sluice never toggles it) plus a service token (PLANETSCALE_SERVICE_TOKEN_ID / PLANETSCALE_SERVICE_TOKEN env). Control-plane only, distinct from the data-plane --target DSN; ignored on non-planetscale targets; off when unset (the migrate is unchanged). · --planetscale-database / --planetscale-branch · Database name (defaults to the --target DSN's database) and production branch (default main) for the ADR-0148 index-build fallback. Only consulted when --planetscale-org is set. · --csv-null / --csv-header / --csv-no-header / --csv-delimiter · Flat-file source declarations (ADR-0163) for --source-driver csv|tsv — see the flat-file note under engines. Refused (never silently ignored) when the source driver is not a flat-file engine. · Filtered dry run, then apply: sluice migrate --source-driver mysql --source ... --target-driver postgres --target ... \ --include-table 'app_*' --exclude-table 'app_audit' --dry-run Redact PII as it copies: sluice migrate --source-driver mysql --source ... --target-driver postgres --target ... \ --redact users.email=hash:sha256 \ --redact users.ssn=mask:ssn Import a SQLite file / .sql dump, or a live Cloudflare D1: point --source-driver at sqlite (a .db file or an auto-detected wrangler d1 export .sql dump) or d1 (a d1:// DSN; token via CLOUDFLARE_API_TOKEN). Big integers above 253 round-trip exactly; declared DATE / DATETIME columns are decoded per --sqlite-date-encoding. SQLite is also a target (--target-driver sqlite). sluice migrate --source-driver sqlite --source ./app.db \ --target-driver postgres --target 'postgres://...?sslmode=disable' sluice migrate --source-driver d1 --source 'd1:///' \ --target-driver mysql --target 'user:pass@tcp(host:3306)/app' No-PRIMARY-KEY tables (v0.99.13). A source table with no declared PRIMARY KEY but a NOT-NULL UNIQUE key now migrates and syncs MySQL→Postgres without a manual schema change — sluice promotes the unique key for an idempotent copy (this already worked MySQL→MySQL). A table with no PK and no NOT-NULL unique key is still refused loudly. ## sync start ### sluice sync start Start (or resume) a continuous-sync stream: consistent snapshot → bulk copy → ongoing CDC. Identified by --stream-id for clean restart. Flag · Purpose · --stream-id · Stream identifier; the key its position is persisted under on the target. · --slot-name · Postgres replication-slot suffix (default sluice_slot); set per-instance to run several streams off one source. · --apply-batch-size · CDC changes per target tx, or auto. Default auto (v0.99.44, ADR-0089): the AIMD latency controller adapts the batch size within [1, ceiling] to a p95 target for >10× throughput over single-row apply. Ceilings: 1000 mysql/postgres, 100 planetscale. Pass =1 for the conservative one-change-per-tx behavior. Tables with no usable identity key (no PK, no unique index) are never batched — each such change commits alone. · --no-auto-tune · Disable the AIMD controller. --apply-batch-size=N then becomes a strictly static row cap (floor stays 1) instead of an adaptive ceiling. For workloads where you've hand-tuned the batch size and want no auto-adaptation. · --apply-concurrency · CDC apply lane count W (ADR-0104/0105/0106; engine-general — MySQL and Postgres). The merged change stream is fanned across W in-order lanes by primary-key hash (same key → same lane → applied in source order, so dependent INSERT→UPDATE→DELETE never reorder), each lane committing concurrently on its own connection with its own AIMD batch controller. 0 (default, unset) = auto:N — the new fast-by-default adaptive concurrent path: Postgres min(4, slot-budget), MySQL/PlanetScale a fixed 4. 1 = explicit serial opt-out (byte-identical to the pre-fast-by-default behaviour). W>1 honored verbatim. Exactly-once for keyed tables (the position advances only to a boundary durable across all lanes). An in-lane PlanetScale tx-killer (MySQL) or serialization/deadlock (Postgres) is recovered in-lane — split-and-retried idempotently, no stream restart. · --schema-changes · forward (default, ADR-0091) auto-applies unambiguous source DDL — ADD/DROP/ALTER COLUMN, CREATE/DROP INDEX, ADD/DROP/MODIFY CHECK — on the target so the sync stays online through schema evolution (shape support; whether a given shape actually arrives depends on the source engine's CDC surface — see the per-source matrix in the schema-changes guide). refuse restores the conservative pre-v0.92 behavior: any source DDL surfaces loudly with the drained-model recovery hint. RENAME COLUMN and a computed/volatile DEFAULT on ADD COLUMN always refuse loudly. See the warn box below. · --copy-fanout-degree · VStream/CDC snapshot cold-start (PlanetScale-MySQL target) only, ADR-0097: WRITE-side fan-out — the incoming snapshot row stream is PK-hash-partitioned out to N concurrent batched-INSERT writers, each on its own connection, to beat the single round-trip-bound INSERT connection vtgate forces. 0 = auto: 4; 1 = serial. Bounded by the target connection budget. · --no-auto-resnapshot · Opt out of the automatic re-snapshot when a resume hits a purged/invalid source position (v0.99.51, ADR-0093). By default a resume from a position older than the source's retained binlogs — routine on PlanetScale's retention window — auto-recovers with a fresh cold-start re-snapshot; with this flag set, sluice instead fails loudly with the recovery commands named, so a full re-snapshot of very large tables is a deliberate choice. · --inject-shard-column · NAME=VALUE — ADR-0048 Shape A discriminator column for consolidating a multi-shard Vitess source onto one target (per-shard streams pass distinct VALUEs). See the migrate row. · --allow-cross-shard-merge · Opt out of the cross-shard-collision preflight (Bug 152) — see the migrate row. Off by default the guard is active. · --metrics-listen · Bind a Prometheus /metrics + /readyz endpoint, e.g. :9090. Exports sluice_build_info, a Go-runtime block, and — when PlanetScale telemetry is configured (below) — the sluice_target_* CPU/mem/storage/lag gauge family. See /metrics export. · --position-from-manifest · URL of a backup chain (s3://, gs://, azblob://, file:///) whose terminal manifest's EndPosition becomes this stream's resume position — resume CDC from a restored chain's tail without re-bulking. Bypasses the persisted sluice_cdc_state position. PG soft preflight warnings fire here; --strict-preflight promotes them to refusals. (Mutually exclusive with --restart-from-scratch / --reset-target-data.) · --planetscale-org · PlanetScale org slug, consumed by both optional PlanetScale integrations — each arms on its own token pair (v0.99.259): (1) target-health telemetry (CPU/mem/storage/lag) read from the PlanetScale metrics endpoint (ADR-0107), feeding proactive apply back-off and the sluice_target_* gauges — opt-in and all-or-nothing with the metrics-token pair (org + a partial metrics pair is a loud refusal); (2) the ADR-0148 deploy-request index-build fallback for the cold-start index phase on a planetscale target — opportunistic, WARN-at-most, arming on the service-token pair (a fallback-only arming never trips the telemetry refusal). A control-plane credential, distinct from the data-plane --target DSN. Unlike migrate's flag it has no ambient PLANETSCALE_ORG env binding here — arming needs the explicit flag (the tokens still come from env). Off when unset (default sync unchanged). · --planetscale-metrics-token-id / --planetscale-metrics-token · PlanetScale service-token (granted read_metrics_endpoints) ID + secret for --planetscale-org telemetry. Set via the env vars PLANETSCALE_METRICS_TOKEN_ID / PLANETSCALE_METRICS_TOKEN — never on the command line; masked in all logging. · --planetscale-metrics-db / --planetscale-metrics-branch · Database (defaults to the --target DSN's database) and branch (default main) the telemetry series is filtered to. Only consulted when --planetscale-org is set. · --planetscale-database / --planetscale-branch / --planetscale-service-token-id / --planetscale-service-token / --planetscale-deploy-timeout · ADR-0148 index-build fallback inputs — same set and defaults as migrate's: database defaults to the --target DSN's, branch to main, deploy deadline 1h; the service token (branch + deploy-request scopes) via env PLANETSCALE_SERVICE_TOKEN_ID / PLANETSCALE_SERVICE_TOKEN. Before v0.99.259 the cold-start's walled PlanetScale index build always ended at the SLUICE-E-INDEX-* hint; armed, sluice builds it via a deploy request. An unarmed run is byte-identical. · --suppress-target-metrics-history · Disable persisting polled target-health metrics to the sluice_target_metrics_history table (7-day retention, pruned). History is on by default when telemetry is configured; it lets sluice diagnose show the recent CPU/mem/storage/lag trend without scripting the metrics API. Advisory + failure-isolated — never affects the sync. · --notify-webhook / --notify-slack · Threshold-alert sinks (also accepted by metrics-watch): a generic webhook (JSON POST) and/or a Slack incoming-webhook. Set the URLs via the env vars SLUICE_NOTIFY_WEBHOOK / SLUICE_NOTIFY_SLACK. Advisory + failure-isolated (a dead sink is logged-and-swallowed). The sinks themselves are ungated — pair one with a threshold below; only the util / control-plane-lag / growth thresholds additionally need --planetscale-org telemetry. · --notify-sync-lag-seconds · Alert when sluice's own apply lag (sluice_sync_lag_seconds) is at or above N seconds. Ungated — works on MySQL and Postgres alike, needing only a sink; no PlanetScale telemetry. 0 disables. · --notify-storage-util / --notify-cpu-util / --notify-mem-util · Alert when the target's storage / CPU / memory utilisation (a fraction 0–1, used/capacity) is at or above the threshold. Edge-triggered + cooldown'd. 0 disables a rule. Requires --planetscale-org telemetry. · --notify-lag-seconds / --notify-storage-growth-per-min · Alert when the target's control-plane replica lag (seconds) is at or above the value, or when storage utilisation is climbing at or above this fraction-of-capacity per minute (a pre-grow early warning, e.g. 0.02 = +2%/min). 0 disables. Requires --planetscale-org telemetry. · --notify-cooldown · Minimum interval between re-fires of a still-breached alert (default 15m) — a sustained breach reminds at most once per interval, not every poll. · --notify-schema-drift · On by default (ADR-0157; inert unless a --notify-* sink is configured): fire a critical notification when a source schema change stalls the sync — a DDL sluice cannot auto-forward (e.g. RENAME COLUMN on MySQL). The alert carries the drift detail + recovery steps. Ungated from PlanetScale telemetry — works on every engine pair. Pass --notify-schema-drift=false to disable while keeping metrics alerts. Advisory + failure-isolated: a delivery problem never affects the (already stalled) sync. · --notify-slot-health · On by default (ADR-0059; inert unless a sink is configured; fires only for Postgres logical-replication sources — the structured slog WARNs fire regardless): notify when the source replication slot crosses a health threshold — WAL retention pressure at 70% (warning) / 85% (critical) of max_slot_wal_keep_size, 30m slot inactivity (warning), wal_status unreserved (critical — invalidation at the next checkpoint), and the terminal events (wal_status lost, slot dropped mid-stream) each page critical exactly once and latch. A sustained slot-health probe outage (5 consecutive failures) pages a warning — the net never goes silently blind. Pass --notify-slot-health=false to keep the slog WARNs only. Advisory + failure-isolated. · --source-tls-ca / --target-tls-ca · CA-pinned verify-ca TLS to a MySQL source / target (ADR-0158) — PEM CA path; covers the data connection and the binlog/CDC stream on the source. See the migrate row for the full semantics. · --apply-retry-attempts · Max consecutive retriable apply failures absorbed before exiting (ADR-0038, default 8 — tuned for managed-Vitess tx-killer transients). 1 = no retry. The counter resets whenever the persisted CDC position advances. · --apply-retry-backoff-base / --apply-retry-backoff-cap · Exponential backoff between retriable apply failures: base 100ms (doubling), capped at 30s. Only consulted when --apply-retry-attempts > 1. · --apply-exec-timeout · Per-statement deadline on every apply-path ExecContext (default 60s). Closes the silent-stall mode where a half-closed target connection blocks the apply goroutine inside the driver; on expiry the batch is retried on a fresh connection. 0 disables (unbounded). · --source-heartbeat-interval · Write a heartbeat row on the source every interval so the slot/binlog can't be evicted past the consumer against an idle source. · --dry-run, -n · Show cold-start vs warm-resume and the planned actions without starting. · --schema-already-applied · Skip all cold-start DDL (you promise the target catalog matches). For Atlas/Liquibase-managed or PlanetScale Safe-Migrations targets. · --include-table / --exclude-table · Glob-aware table filters (mutually exclusive). Scope the cold-start snapshot and its resume — including the PlanetScale (VStream) snapshot, so an excluded table in a large keyspace is never streamed (v0.99.12–v0.99.13), not just the write path. · --where · TABLE= — continuous filtered replication: replicate only the rows of TABLE matching a native source-SQL boolean predicate (repeatable, source-keyed; ADR-0173/0174). Unlike migrate's one-shot --where, this scopes both the cold-start copy and the ongoing CDC tail — a change that moves a row into scope replays as an INSERT, one that moves it out replays as a DELETE, so the target stays a faithful filtered replica instead of accumulating orphans. Same rules as migrate's: filtering a parent orphans its children (SLUICE-E-WHERE-FK-ORPHAN — filter consistently or pass --allow-degraded-fks), a key naming no table refuses (SLUICE-E-WHERE-UNKNOWN-TABLE). A string predicate is classified under the column's real collation so a row-move matches the source's own = (case/accent folding, and PAD-SPACE trailing-space semantics on legacy collations); on a PlanetScale/Vitess source a PAD-SPACE-collation predicate is filtered client-side so trailing-space rows aren't dropped (v0.99.283). · --where-strict-collation · sync-only. Refuse any --where string predicate whose collation can't be reproduced byte-exact, instead of folding under the column's collation. By default a string --where compares the way the source's = does — a case/accent-insensitive column folds, which is the faithful default; this flag opts out, allowing only byte-exact (_bin / binary) collation columns and refusing loudly (SLUICE-E-WHERE-CDC-UNSUPPORTED-PREDICATE) on a case/accent-insensitive one. Use when the filter's string equality must be exactly memcmp — e.g. a downstream that must never receive case/accent-folded matches. · --force-cold-start · Skip the pre-flight check that refuses to bulk-copy into a populated target. Use with caution — an INSERT into a non-empty table can collide on the primary key. Still warm-resumes from a persisted position (it only skips the check); ignored on the warm-resume path. · --reset-target-data · Destructive recovery: delete the CDC-state row, DROP every source-schema table on the target, then run a fresh cold-start. For a wedged-state recovery (e.g. slot-missing fall-through). Prompts (type reset) unless --yes. See ADR-0023. · --restart-from-scratch · Force a fresh cold-start re-copy from the beginning, ignoring any persisted resume position (incl. a mid-COPY cursor) — without dropping the target (the idempotent copy absorbs the overlap). For a bad checkpoint. Differs from --force-cold-start (keeps the position) and --reset-target-data (drops tables). (v0.99.10) · Source DDL auto-applies by default (v0.99.45, ADR-0091). A running stream now forwards unambiguous source schema changes onto the target automatically — including a destructive DROP COLUMN, which drops the column (and its data) on the target. This keeps the sync online through routine schema evolution, but it means a source DDL change propagates without operator review. To gate DDL through a separate change-management process, start the stream with --schema-changes=refuse — any source DDL then surfaces loudly instead of applying. (The older --forward-schema-add-column flag is deprecated: it warns and still forwards, subsumed by the new default.) Mid-stream reshard is followed automatically (v0.99.62, ADR-0094). A PlanetScale/Vitess source reshard (shard split/merge, MoveTables) used to halt the sync as a loud terminal error. The Streamer now reopens onto the new shard layout from the journal-stamped GTIDs and continues with no gap and no re-snapshot. (Not yet auto-followed when --inject-shard-column is engaged — that interplay keeps the prior loud-terminal behavior.) Multi-table Vitess keyspaces cold-copy in one command (v0.99.63, ADR-0095). A full Vitess/PlanetScale keyspace now cold-copies in a single sync start at bounded memory — the engine auto-shards the VStream COPY by table internally, so there's no per-table --include-table workaround. On by default for a fresh multi-table cold-start; opt out with vstream_copy_single_stream=true in the source DSN (see Source-DSN tuning parameters). The apply path is adaptive-concurrent by default (v0.99.100+, ADR-0106). With --apply-concurrency unset, CDC apply fans out across an auto-chosen number of PK-hash lanes (Postgres min(4, slot-budget); MySQL/PlanetScale 4) — exactly-once for keyed tables, with per-lane AIMD and in-lane tx-killer/deadlock recovery. To force the old strictly-serial apply, pass --apply-concurrency 1. Resilient on managed / PlanetScale targets (no flags needed). sluice automatically rides PlanetScale storage-grow and primary-reparent serving transitions without operator intervention — across cold-copy writes, cold-copy source reads, the coordinated grow-gate, restore reconciliation, and (new in v0.99.118) the post-copy DDL phase (index / constraint / view build). Transient errors during a transition are bounded-retried and loud only on genuine exhaustion. Run as a service with metrics + idle-source heartbeat: sluice sync start --source-driver postgres --source ... --target-driver mysql --target ... \ --stream-id reporting \ --metrics-listen :9090 \ --source-heartbeat-interval 30s With PlanetScale target-health telemetry + a storage alert (tokens via env, control-plane credential distinct from --target): export PLANETSCALE_METRICS_TOKEN_ID=... # the read_metrics_endpoints service token export PLANETSCALE_METRICS_TOKEN=... export SLUICE_NOTIFY_SLACK=https://hooks.slack.com/services/... sluice sync start --source-driver mysql --source ... --target-driver planetscale --target ... \ --stream-id app-prod \ --planetscale-org acme --planetscale-metrics-db app \ --notify-storage-util 0.85 --notify-slack "$SLUICE_NOTIFY_SLACK" ## sync status / stop / health ### sluice sync status · stop · health Inspect, gracefully stop, and health-check a running stream. All take --stream-id plus the target connection. - sync status — show the stream's persisted position and phase. - sync stop — request the stream to drain in-flight changes and exit cleanly. By default it just files the stop request and returns; pass --wait / -w to block until the running streamer drains and clears its stop signal (with --timeout, default 5m; on timeout the CLI exits non-zero and the stop request remains in place). Use --wait to coordinate ALTER windows or scripted teardowns. - sync health — probe freshness against thresholds and return a cron-friendly exit code (non-zero when stale). sluice sync stop --stream-id app-prod --target-driver postgres --target ... --wait --timeout 10m sluice sync health --stream-id app-prod --target-driver postgres --target ... \ --max-stale-seconds 300 # exit non-zero if the last apply was more than 5 minutes ago sync health's freshness check is --max-stale-seconds N (target-side wall-clock seconds since the last apply; 0 = informational only). When you also pass --source-driver + --source the probe reads the source position too and, on a PG→PG pair, exposes --max-lag-bytes N (source LSN bytes ahead of target; MySQL GTID sets aren't byte-distance comparable). Both exit 1 when breached — cron-friendly. ## sync run / sync tui ### sluice sync run --config syncs.yaml Supervise many syncs from one process (ADR-0122): each sync is failure-isolated with bounded-backoff restart, and a bad neighbor never takes the fleet down. Flag · Purpose · --config, -c · Required (the global flag). Path to a syncs.yaml fleet config — a syncs: list of per-sync specs (each a curated subset of the sync start knobs) plus an optional fleet-wide restart: policy. Load-time validation refuses a duplicate stream-id, a colliding Postgres slot name, or an unknown/misspelled key (a typo'd knob is a loud failure, never a silent drop). · --dashboard-listen · Serve a read-only fleet dashboard — a self-contained HTML page plus a stable GET /api/fleet JSON API — on ADDR (e.g. :9300). Empty = off. It exposes only what sync status --all does (stream-ids, states, errors — no DSNs, no row data) and has no authentication: bind to localhost or a trusted network. A bind failure is loud-fatal (the fleet won't start without the dashboard you asked for). · --dry-run, -n · Validate the fleet config (required fields, stream-id + slot-name uniqueness, retry bounds) and print the resolved plan — start nothing. · The process blocks until every sync exits; Ctrl-C / SIGTERM stops them all cleanly. Live reload without a restart: edit syncs.yaml and send the process SIGHUP — sluice re-reads and re-validates the file, then reconciles the live fleet (starts added syncs, drains removed ones, restarts changed ones, leaves unchanged ones untouched). A reload that fails to parse or validate is refused loudly and the running fleet keeps going on the old config. SIGHUP is POSIX-only; on Windows, restart the process to change the fleet. The full walkthrough is in Operate a sync fleet. # validate + print the plan, start nothing sluice sync run --config syncs.yaml --dry-run # run the fleet with a read-only dashboard API on :9300 sluice sync run --config syncs.yaml --dashboard-listen :9300 # reload the running fleet after editing syncs.yaml (POSIX) kill -HUP "$(pgrep -f 'sluice sync run')" ### sluice sync tui --connect ADDR A full-screen terminal dashboard for a running fleet (ADR-0125) — it polls a 'sync run --dashboard-listen' server's /api/fleet endpoint, so it works locally or over an SSH tunnel without disturbing the fleet process. Flag · Purpose · --connect · Required. host:port or URL of a running sync run --dashboard-listen server — :9300, localhost:9300, http://host:9300, or a full …/api/fleet URL. The TUI polls its /api/fleet endpoint. · --refresh · How often to poll /api/fleet for a fresh fleet view (default 2s). · The TUI keeps the last-known fleet on screen with an "unreachable" banner if a poll fails, instead of blanking. # terminal 1: run the fleet with the dashboard API exposed sluice sync run --config syncs.yaml --dashboard-listen :9300 # terminal 2 (local or over an SSH tunnel): live terminal view sluice sync tui --connect :9300 --refresh 2s ## schema add-table ### sluice schema add-table Bring a new source table into an active stream's scope without a destructive --reset-target-data cycle. Drain the stream first via 'sluice sync stop --wait'. Flag · Purpose ·
(argument) · Unqualified name of the new source table; its schema/database is inferred from --source. · --stream-id · Required — must match the active stream's id (run sluice sync status to confirm). · --type-override / --expr-override · Per-column overrides for the new table (repeatable). · --target-schema · Postgres-only: must match the active stream's --target-schema, or be omitted to inherit the recorded value. · --no-drain · Phase 2 live add: run against an actively-streaming sync without first running sync stop --wait. PG-only in this release; MySQL sources still require the drained workflow. · --dry-run, -n / --yes, -y · Print the plan without modifying anything / skip the typed-confirmation prompt. · # drain first, add the table, then resume sluice sync stop --stream-id app-prod --target-driver postgres --target ... --wait sluice schema add-table new_events \ --source-driver mysql --source ... --target-driver postgres --target ... \ --stream-id app-prod sluice sync start --stream-id app-prod --source-driver mysql --source ... --target-driver postgres --target ... ## sync from-backup ### sluice sync from-backup run · stop Replay a backup chain into a target as a long-running broker — polls a chain root (S3/GCS/Azure/local) for new incrementals and applies them. No direct source↔target connectivity required. Flag · Purpose · --backup-target / --backup-dir · The chain location: a URL (s3://, gs://, azblob://, file:///) or a local directory. Mutually exclusive. · --backup-endpoint / --backup-region / --backup-path-style · S3-compatible-provider knobs (R2 / B2 / MinIO / Wasabi / Tigris); only meaningful when --backup-target is an s3:// URL. · --target-driver / --target · Target engine name and DSN (or SLUICE_TARGET). · --stream-id · Required. The key the broker's chain-state position is persisted under on the target — needed for clean restart resume. · --apply-concurrency · Key-hash concurrent-apply lane count W for incremental replay (the same machinery sync start uses). 0 (default) = auto:4; 1 = serial; W>1 honored. Matters for high-latency / cross-region targets — without it a large incremental replays through a single RTT-bound stream. Exactly-once preserved. · --reset-target-data · Cold-start recovery: drop target tables, run a chain restore (full + every incremental), then transition to live polling. Prompts (type reset) unless --yes. Mutually exclusive with --at-chain-id. · --at-chain-id · Operator-asserted resume: treat the target as currently at chain ID (e.g. after a manual sluice restore), write a fresh state row, and tail forward. Mutually exclusive with --reset-target-data. · --poll-interval · Cadence each broker tick runs at (default 30s); new incrementals are applied within ~one interval of their source-side commit. · --apply-batch-size · CDC changes per target transaction during replay (default 100). Idempotent applier semantics keep replay-on-crash safe. · --max-buffer-bytes · Soft cap on per-batch buffered memory in the CDC applier. Default 67108864 (64 MiB). · The full walkthrough — producing the chain, cold-start vs warm-resume, stopping — is in the backup-chain sync guide. sluice sync from-backup run \ --backup-target s3://my-bucket/app-chain \ --target-driver postgres --target ... \ --stream-id app-broker --apply-concurrency 4 --poll-interval 30s sluice sync from-backup stop --backup-target s3://my-bucket/app-chain ## cutover ### sluice cutover Two-phase sequence priming at cutover: re-read source sequence / AUTO_INCREMENT state and apply it to the target with a safety margin, so the first post-cutover INSERT can't collide on the primary key. sluice cutover --config sluice.yaml --cutover-sequence-margin 1000 Run after the snapshot has caught up and just before switching application traffic to the target. ## backup ### sluice backup Take and verify logical backups — full snapshots and incremental chains, optionally encrypted, to local FS or object storage. Subcommand · Purpose · backup full · Take a full snapshot (chain root). · backup incremental · Append an incremental onto the existing chain. · backup stream run / stop · Run as a long-lived process appending incrementals at a rolling cadence; stop drains the in-flight rollover and exits cleanly. · backup verify · Re-checksum every chunk in a chain and report mismatches. · backup prune / compact · Retention: drop the oldest segments, or merge consecutive segments whose gaps fall within --merge-window. Compact splits a merge group at a rotation-boundary coverage gap instead of refusing the run (v0.99.41) — chains stopped while the source was idle stay compactable. · backup keygen · Generate an Ed25519 signing keypair for --sign-key / --verify-key (ADR-0154 Phase 2): the private key (PKCS#8 PEM, written 0600) signs backups, the public key (SPKI PEM, distributable freely) verifies them. --out-dir DIR writes sluice-sign-key.pem + sluice-verify-key.pem, or name the paths with --priv + --pub (mutually exclusive with --out-dir); --force overwrites — by default keygen refuses to clobber an existing private key (losing/replacing it strands the signing of any chain it already signed). · backup export-as-parquet · One-shot, read-only transcode of a backup's row chunks into Parquet for analytics — own section below. · Flag · Purpose · --output-dir / --target · Destination: a local directory, or a URL (s3://, gs://, azblob://, file:///). Mutually exclusive. · --chain-slot · Postgres-only, on backup full: provision the persistent replication slot (named by --slot-name) as the snapshot anchor and ensure the publication, so backup incremental chains with zero gap and no manual slot setup. (v0.99.35) · --table-parallelism · Tables read concurrently during the backup sweep (the read-side analog of pg_dump -j); 0 = auto (4). Postgres pins every parallel reader to one shareable exported snapshot; vanilla MySQL coordinates N readers under a brief FTWRL window (v0.99.43, ADR-0088) — both match the serial sweep's cross-table consistency. MySQL falls back to a serial single reader (a loud INFO names why) without RELOAD. (v0.99.39 / v0.99.43) · --include-table / --exclude-table · Glob-aware table filters; scope the backup snapshot itself — including the PlanetScale (VStream) snapshot — so an excluded table in a large keyspace is never streamed (v0.99.13), not just what's written. · --compression · Per-segment chunk codec: none | gzip | zstd. Default zstd (55–85% faster restore — the DR-critical axis; ~1–5% larger than gzip). none leaves chunks as human-readable .jsonl on a local-FS target. Recorded in lineage.json and read back from there on restore (never inferred from bytes). · --encrypt · Enable client-side envelope encryption. Requires exactly one key source (below). The chain rests encrypted; restore / verify / the broker read the same flag to unwrap. · --encryption-passphrase-env / --encryption-passphrase-file · Passphrase mode: read the passphrase from an environment variable or a file (preferred over --encryption-passphrase, which lands in shell history). The chain root records the Argon2id params so incrementals and restores re-derive the KEK — operators only remember the passphrase. · --kms-key-arn / --gcp-kms-key-resource / --azure-key-vault-id · KMS mode: wrap the CEK through AWS KMS, GCP Cloud KMS, or Azure Key Vault respectively — the root key never leaves the cloud KMS. Mutually exclusive with each other and with the passphrase flags. KMS and passphrase modes can't be mixed within one chain. · --sign · Sign the backup manifest + lineage catalog with a detached HMAC-SHA-256 keyed off the chain KEK (ADR-0154 Phase 1). Requires --encrypt with a passphrase (HMAC-off-KEK signs only encrypted chains); extending an already-signed chain signs automatically. Mutually exclusive with --sign-key. · --sign-key · Sign with an Ed25519 private key (PKCS#8 PEM — generate a pair with sluice backup keygen), or via a cloud KMS signing key given as kms:/// (aws / gcp / azure — the private key stays in the HSM). Selects the asymmetric scheme over the --sign HMAC default; works on both plaintext and encrypted backups. Accepts a file path, env:VAR, or kms://...; never logged. · --verify-key · Read side (restore / backup verify / the broker / export-as-parquet): the public key that verifies an asymmetrically-signed chain — an SPKI PEM file (the offline DR path) or kms://... to fetch the trusted key online. Required for such a chain — the KEK does NOT verify an asymmetric signature, and the recorded manifest key reference is never trusted; verification anchors on the key you name. Absent it, the chain WARNs present-but-unverified and proceeds (DR-safe) unless --require-signature. · --require-signature · Strict-always signature policy on restore/verify: a signed chain that cannot be verified (no matching key supplied) is refused rather than warned. An INVALID signature is always refused regardless of this flag. Leave off for the DR-safe default (never fail a restore for a signature it cannot check). · sluice backup full --source-driver postgres --source ... --target s3://my-bucket/app-chain --chain-slot sluice backup incremental --source-driver postgres --source ... --target s3://my-bucket/app-chain # signed chain: generate an Ed25519 pair once, sign on write, verify on read sluice backup keygen --out-dir ~/.sluice/keys sluice backup full --source-driver postgres --source ... --target s3://my-bucket/app-chain \ --sign-key ~/.sluice/keys/sluice-sign-key.pem sluice restore --from s3://my-bucket/app-chain --target-driver postgres --target ... \ --verify-key ~/.sluice/keys/sluice-verify-key.pem --require-signature Full backups are engine-neutral; incremental chains need a CDC source. backup full works against any registered source — including sqlite (a local file). backup incremental appends changes since the chain root, so it needs a CDC-capable source: Postgres / MySQL natively, or the trigger-CDC engines for SQLite / D1 (sqlite-trigger / d1-trigger). A base sqlite source is migrate-only (no CDC), so it can root a full backup but not extend an incremental chain. Values that used to break backups (v0.99.40). IEEE-special floats (NaN, ±Infinity) now ride the chunk codec exactly — one such row no longer makes a table un-backupable, and restores are bit-identical to pg_dump. ## backup export-as-parquet ### sluice backup export-as-parquet One-shot, read-only transcode of an existing backup's row chunks into one zstd-compressed Parquet file per table plus a parquet_index.json export manifest — the analytics exit surface over the chain sluice already captured (ADR-0164). The export represents one snapshot — the latest full by default, or the full named by --backup-id. Incremental change-windows after that full are not folded in (a loud WARN names the count); operators who need point-in-time state restore the chain and re-export. Exit-only: sluice never reads its Parquet output back — sluice restore keeps the JSON-Lines path. The Parquet files themselves are written plaintext even from an encrypted chain — the analytics destination's encryption posture is a separate operator choice. Flag · Purpose · --from-dir / --from · The backup to export: a local directory (the same one --output-dir wrote to), or a URL (s3://, gs://, azblob://, file:///). One is required; mutually exclusive. · --output-dir / --output · Destination for the Parquet files + parquet_index.json: a local directory (created if absent) or a URL (s3://bucket/prefix, gs://, azblob://, file:///). One is required; mutually exclusive. · --backup-endpoint / --backup-region / --backup-path-style · S3-compatible-provider overrides (endpoint, region, path-style addressing) — apply to both --from and --output when they are s3:// URLs. · --include-table / --exclude-table · Glob-aware table filters (comma-separated, repeatable; mutually exclusive) — export a subset of the snapshot's tables. · --backup-id · Export the segment full snapshot with this BackupID instead of the latest one (chain-to-a-point at snapshot granularity; find ids in the chain's manifests or lineage.json). Incremental ids are refused — their change-windows are not exportable. · --force-overwrite · Replace a prior export at the destination. By default the command refuses when parquet_index.json is already present. · --encrypt + key flags / --verify-key / --require-signature · Read-side encryption + signature flags, mirroring restore: an encrypted chain needs --encrypt + the chain's passphrase / KMS reference; a signed chain is verified (strictly, with --require-signature) before any chunk is decoded. · sluice backup export-as-parquet --from s3://my-bucket/app-chain \ --output-dir ./warehouse-drop \ --exclude-table 'audit_*' Never a silent narrow: a column type or value with no faithful Parquet representation — a multi-dimensional array, a TIME outside a calendar day (MySQL TIME reaches ±838h), a PG NUMERIC NaN/Infinity, a sub-microsecond timestamp — is refused loudly with SLUICE-E-EXPORT-UNREPRESENTABLE (exclude the table and export the rest, or query that table's JSON-Lines chunks directly — DuckDB reads them natively). The documented string downgrades (unbounded NUMERIC, TIMETZ) carry the exact value text and are WARNs, not refusals. ## restore ### sluice restore Restore a logical backup chain (full + every incremental up to the tail) into a target database. Flag · Purpose · --from-dir / --from · Backup location: a local directory, or a URL (s3://, gs://, azblob://, file:///). Mutually exclusive. · --target-driver / --target · Target engine name and DSN. Accepts any registered engine — a backup taken from one engine can be restored into another (e.g. a MySQL chain into a Postgres target). · --table-parallelism · Tables bulk-applied concurrently (the write-side analog of pg_restore -j); 0 = auto (4), works on both engines; incremental change replay stays ordered. (v0.99.39) · --bulk-parallelism · Within-table chunk parallelism — a single table's chunks applied concurrently (ADR-0112). 0 = auto: min(8, NumCPU); 1 = serial. Engages only for tables with ≥2 chunks; multiplies with --table-parallelism (table × chunk), with the product bounded by the target connection budget. Applies to chain restores too. · --apply-concurrency · Key-hash concurrent-apply lane count for the incremental-replay leg of a chain restore (ADR-0104/0105). The full-restore row load is the bulk COPY (governed by the two parallelism flags above); a chain's incremental change-replay would otherwise run through a single serial stream and stall RTT-bound on a high-latency / cross-region target. 0 (default) = auto:4; 1 = serial; W>1 honored. Exactly-once preserved. No effect on a single-full restore. · --target-schema · Postgres-only: land restored tables under a named schema namespace. · --encrypt + key flags / --verify-key / --require-signature · Read-side chain unwrapping and signature verification — the same flags the backup write side takes: an encrypted chain needs --encrypt + the chain's passphrase / KMS reference (a mismatched or missing key mode is refused at preflight with SLUICE-E-BACKUP-ENCRYPTION-MISMATCH); an asymmetrically-signed chain needs --verify-key, strict with --require-signature. · --planetscale-org · PlanetScale org slug, consumed by both optional PlanetScale integrations — each arms on its own token pair (v0.99.259): (1) target-health telemetry (ADR-0107/0115) clamping the AUTO restore-parallelism product by live headroom — all-or-nothing with the metrics-token pair (--planetscale-metrics-token-id / --planetscale-metrics-token, env-set); (2) the ADR-0148 deploy-request index-build fallback for restore's deferred index phase on a planetscale target — opportunistic, WARN-at-most, arming on the service-token pair (a fallback-only arming never trips the telemetry refusal). Control-plane only, distinct from the data-plane --target DSN; no ambient PLANETSCALE_ORG env binding on this command. Off when unset. · --planetscale-database / --planetscale-branch / --planetscale-service-token-id / --planetscale-service-token / --planetscale-deploy-timeout · ADR-0148 index-build fallback inputs — same set and defaults as migrate's (database from the --target DSN, branch main, deadline 1h; service token via env). Before v0.99.259 a restore's walled PlanetScale index build always ended at the SLUICE-E-INDEX-* hint even with credentials available. On timeout the deploy keeps running in PlanetScale and re-running the restore re-probes and rebuilds only what is still missing. · --target-tls-ca · CA-pinned verify-ca TLS to a MySQL target (ADR-0158) — see the migrate row. · sluice restore --from s3://my-bucket/app-chain \ --target-driver postgres --target ... Pair with sync start --position-from-manifest URL — point it at the chain URL whose terminal manifest's EndPosition becomes the stream's resume position, so CDC picks up from the chain's tail without re-bulking. (PG soft preflight warnings — wal_keep_size sufficiency, Patroni-managed source — fire here; --strict-preflight promotes them to refusals.) Drive both restore parallelism axes (tables × within-table chunks, product bounded by the target budget): sluice restore --from s3://my-bucket/app-chain \ --target-driver postgres --target ... \ --table-parallelism 4 --bulk-parallelism 4 Cross-engine restore (a MySQL backup into a Postgres target): --target-driver accepts any registered engine — the backup's source engine and the restore target need not match. sluice restore --from s3://my-bucket/mysql-chain \ --target-driver postgres --target 'postgres://user:pass@host:5432/app' ## backfill ### sluice backfill Backfill or transform a column in place — a same-database, keyset-chunked, resumable, online-safe UPDATE. The 'migrate' step of the expand-contract pattern (ADR-0159). Backfill is single-endpoint — it runs INSIDE one database (no source/target pair), walking the table's primary key in bounded batches and issuing one UPDATE per batch, so no statement ever locks (or hits the statement-time wall of a managed provider on) more than --batch-size rows. The cursor persists in the same database's sluice_migrate_state control tables, so a killed run resumes where it left off — the crash-replay window is at most one chunk, and the replayed chunk is a no-op under a self-describing --where guard. The --set expressions and the --where predicate are native SQL for the --driver engine, emitted verbatim (same-database, so there is no cross-dialect translation to do). Flag · Purpose · --driver · Required. Engine name for the database (mysql, mariadb, planetscale, vitess, or postgres — the engines that implement the in-place backfill surface). SQLite/D1 refuse with SLUICE-E-BACKFILL-UNSUPPORTED-ENGINE (a single-file/edge database doesn't need the online-safety machinery — run the UPDATE directly). · --dsn · Required. Database DSN. Backfill is same-database: it reads and updates this one endpoint. · --table · Required. Table to backfill. It must have a usable orderable primary key to cursor on — a keyless table (or a JSON/array/geometry PK) refuses with SLUICE-E-BACKFILL-NO-PRIMARY-KEY; there is no flag to force an unbounded whole-table UPDATE. · --set · Assignment 'COL = EXPR' applied to every matched row (repeatable; required except with --verify-only). Split at the FIRST =, so expressions may themselves contain =. A --set column that doesn't exist on the table refuses up front with SLUICE-E-BACKFILL-UNKNOWN-COLUMN (the message lists the table's actual columns). · --where · Native-SQL predicate scoping which rows are backfilled. Make it self-describing (e.g. new_col IS NULL) so re-runs and crash-resume skip already-done rows. · --batch-size · Rows per bounded UPDATE batch (keyset-chunked walk of the primary key). 0 (default) uses sluice's bulk-copy default. · --dry-run · Print the generated per-chunk UPDATE statement and an affected-row estimate, then exit without writing anything. · --restart · Discard the stored resume cursor for this exact spec (--set/--where) and start over from the beginning of the table. Refused while another run of the same spec looks live (SLUICE-E-BACKFILL-CONCURRENT-RUN) — it would clear the state row out from under the live walker. · --verify · After the run completes, count rows still matching --where: 0 prints the safe-to-contract signal; >0 fails with SLUICE-E-BACKFILL-INCOMPLETE (re-run to catch up, then verify again). Requires --where. · --verify-only · Skip the walk and just run the --where remaining-count gate (no UPDATEs, no control-table writes) with the same 0 / >0 exit contract — the scriptable post-migration check. Requires --where; --set is optional. · # expand step done (new column exists); backfill it online, then gate the contract step sluice backfill --driver planetscale --dsn 'user:pass@tcp(host)/app' --table users \ --set "full_name = CONCAT(first_name, ' ', last_name)" \ --where 'full_name IS NULL' \ --verify Resume vs restart. A killed run resumes from the persisted cursor automatically on re-run; a spec whose stored state is already complete needs --restart to walk again (the SLUICE-E-BACKFILL-INCOMPLETE catch-up loop). A cursor persisted by an older sluice whose JSON store mangled binary or >253 integer PK values is refused loudly (SLUICE-E-BACKFILL-CORRUPT-CURSOR) rather than silently skipping PK ranges — re-run with --restart; a self-describing guard makes the re-walk touch only the rows the interrupted run never reached. A second invocation of the same spec while the first is still walking — its state-row heartbeat fresher than 5 minutes, typically an overlapping cron — is refused with SLUICE-E-BACKFILL-CONCURRENT-RUN (v0.99.260): two concurrent walks would interleave cursor writes and break the at-most-one-chunk replay bound. Heartbeat-only, no lease — a kill -9'd run keeps the spec refused for at most one window. ## expand-contract ### sluice expand-contract Drive the full expand → migrate → contract schema-change pattern on a PlanetScale database: deploy-request the ADD COLUMN, run the online backfill, verify, and (only with --yes) deploy-request the DROP COLUMN (ADR-0162). This command mutates a production branch. It is PlanetScale-specific by design: it needs the control-plane service token on top of the data-plane DSN, and the production branch must have safe migrations enabled — deploy requests are the mechanism the expand and contract legs ship through. sluice never flips that toggle for you: with safe migrations off it refuses with SLUICE-E-PS-SAFE-MIGRATIONS-DISABLED. The legs: expand creates a sluice dev branch, applies --expand-ddl, and deploys it via a deploy request (sluice's control tables ride inside the same deploy); migrate runs the backfill against the production data with your --set/--where; verify re-counts the --where guard; contract — a destructive DROP COLUMN — runs only after a clean verify and --yes. Without --yes (or without --contract-ddl) the run stops after verify and prints the exact resume command, so the destructive leg is always an explicit second decision. Flag · Purpose · --org · Required (or env PLANETSCALE_ORG). PlanetScale organization slug. · --database · Required. PlanetScale database name. · --branch · Production branch the pattern targets (deploy requests merge into it; the backfill runs against its data). Default main. · --service-token-id / --service-token · PlanetScale service token (branch + deploy-request scopes). Set via the env vars PLANETSCALE_SERVICE_TOKEN_ID / PLANETSCALE_SERVICE_TOKEN (the pscale CLI convention) — never on the command line; never logged. Required except under --dry-run, which makes no control-plane call. · --dsn · Required. Data-plane MySQL DSN for the production branch — the migrate (backfill) leg runs inside it. The engine is fixed to planetscale (no --driver to mis-set). · --table · Required. Table the pattern operates on. · --expand-ddl · Verbatim ADD COLUMN DDL for the expand leg (e.g. ALTER TABLE t ADD COLUMN full_name VARCHAR(255)), applied on a dev branch and shipped via a deploy request. Required unless --resume-from skips the leg. · --contract-ddl · Verbatim DROP COLUMN DDL for the contract leg. Optional: without it the run stops after verify with resume instructions. Runs only after a clean verify AND --yes. · --set / --where · The backfill assignment(s) (repeatable; native SQL, emitted verbatim) and the self-describing guard (e.g. new_col IS NULL). --where is required: it scopes the backfill AND is the verify gate that authorizes the contract step. · --batch-size · Rows per bounded backfill UPDATE. 0 (default) uses sluice's bulk-copy default. · --yes, -y · Confirm the contract leg (a destructive DROP COLUMN deploy request). Without it the run stops after verify and prints the exact resume command. · --dry-run · Print the full plan — branches, deploy requests, the rendered backfill statement, the gates — without a single control-plane call and without writing anything. · --keep-branches · Keep the sluice dev branches instead of deleting them at the end (debugging aid). · --resume-from · Leg to continue from after an interrupted run: expand (default, full pattern), migrate (the ADD COLUMN already deployed), contract (the backfill already completed; still re-verifies — --set is optional here). · --poll-interval · Deploy-request / branch state polling cadence. Default 10s. · --deploy-timeout · Per-deploy-request deadline. Default 1h — large tables deploy via VReplication: real wall-clock, but async and unbounded by errno 3024. A deploy request that outwaits the deadline still un-deployed keeps the dev branch (deleting it would close the still-open deploy request you were just told to approve); the timeout message names the kept branch and the post-close delete recipe (v0.99.260). · export PLANETSCALE_SERVICE_TOKEN_ID=... export PLANETSCALE_SERVICE_TOKEN=... sluice expand-contract --org acme --database app \ --dsn 'user:pass@tcp(aws.connect.psdb.cloud)/app?tls=true' --table users \ --expand-ddl 'ALTER TABLE users ADD COLUMN full_name VARCHAR(255)' \ --set "full_name = CONCAT(first_name, ' ', last_name)" \ --where 'full_name IS NULL' \ --contract-ddl 'ALTER TABLE users DROP COLUMN first_name, DROP COLUMN last_name' \ --yes The SLUICE-E-PS-* refusal family names each failure precisely. SLUICE-E-PS-SAFE-MIGRATIONS-DISABLED (exit 3): safe migrations is off on the branch — enable it in the PlanetScale UI or via pscale branch safe-migrations enable; sluice never auto-enables a production-branch behavior change. SLUICE-E-PS-DEPLOY-REQUEST-FAILED: a deploy request errored, was closed, computed an empty or stranger-touching diff, or outran --deploy-timeout — the message carries the DR number, state, and URL, and a timed-out expand continues with --resume-from migrate. SLUICE-E-PS-BRANCH-STALE-BASE: a fresh PlanetScale dev branch's schema can lag production (observed live: a branch created 14 minutes after a deploy still lacked the deployed column), and a deploy request from a stale base would silently revert newer production schema — sluice gates every dev branch on freshness, self-heals once via an on-demand backup + branch re-create, and raises this only if still stale. Two more pre-deploy gates ship since ADR-0167 (v0.99.258): the deploy request's computed diff is refused if it touches any object the leg never intended (the stale-base phantom-revert signature), and after a review/deploy wait longer than ~2 minutes production's schema is re-verified against the provisioning baseline — refusing SLUICE-E-PS-BRANCH-STALE-BASE if it moved mid-wait. Re-running the pattern on a reused database: when the current run's expand leg actually deployed a schema change, the backfill walk restarts even if a prior cycle left a completed marker for the identical --set/--where spec — the self-describing guard scopes the re-walk to unfinished rows; --resume-from migrate still honors mid-walk cursors and completed markers, and standalone sluice backfill is unchanged (v0.99.258). ## deploy-ddl ### sluice deploy-ddl Ship ONE verbatim DDL statement to a PlanetScale production branch safely, as one command: dev branch (with the stale-base freshness gate), apply the DDL, deploy request, deploy, finalize, cleanup (ADR-0165). This command mutates a production branch (through PlanetScale's governed deploy-request channel). It replaces five hand-driven pscale commands plus a hazard the operator can't see — a fresh PlanetScale dev branch can silently propose reverting recent production schema (the SLUICE-E-PS-BRANCH-STALE-BASE gate above catches it). It requires safe migrations ON the branch (the deploy-request prerequisite; without safe migrations, direct DDL works and this command is unnecessary). There is no data-plane DSN: the DDL runs on the dev branch via a just-minted branch password. The named consumer is the one-time control-table bootstrap on a safe-migrations branch — control-tables ddl prints the statements to ship. Flag · Purpose · --org · Required (or env PLANETSCALE_ORG). PlanetScale organization slug. · --database · Required. PlanetScale database name. · --branch · Production branch the deploy request merges into (must have safe migrations enabled). Default main. · --service-token-id / --service-token · PlanetScale service token (branch + deploy-request scopes), via env PLANETSCALE_SERVICE_TOKEN_ID / PLANETSCALE_SERVICE_TOKEN; never logged. Required except under --dry-run. · --ddl · Required. The single verbatim DDL statement to ship (e.g. CREATE TABLE ... or ALTER TABLE ...), applied on a dev branch exactly as written and deployed via a deploy request. · --dry-run · Print the plan — branch name, the DDL, the deploy-request flow — without a single control-plane call and without writing anything. · --keep-branches · Keep the sluice dev branch instead of deleting it at the end (debugging aid). · --poll-interval · Deploy-request / branch state polling cadence. Default 10s. · --deploy-timeout · Deploy-request deadline. Default 1h (large tables deploy via VReplication — async, unbounded by errno 3024). On a timeout with the deploy request still un-deployed the dev branch is kept — deleting it would close the still-open deploy request; the message names the kept branch and the cleanup recipe (v0.99.260). The same ADR-0167 post-wait freshness recheck as expand-contract guards a >2-minute review wait (operator-authored DDL skips only the diff-scope assertion). · # bootstrap sluice's control tables on a safe-migrations branch: sluice control-tables ddl # prints the exact CREATE statements sluice deploy-ddl --org acme --database app \ --ddl 'CREATE TABLE IF NOT EXISTS sluice_migrate_state (...)' # one statement per run ## control-tables ddl ### sluice control-tables ddl Print the exact CREATE statements for sluice's own control tables (migrate-state + cdc-state), single-sourced from the engine's definitions — for bootstrapping a target that refuses direct DDL (ADR-0165). Read-only, needs no credentials and no org/database — output is pure SQL plus -- comment lines, so it pastes or pipes into any governed channel: deploy-ddl (one statement per run), the PlanetScale UI, or a reviewed migration file. On a PlanetScale branch with safe migrations enabled, direct DDL is refused (Error 1105, surfaced as SLUICE-E-PS-DIRECT-DDL-BLOCKED) — sluice's own ensure paths are detect-first, so pre-creating the control tables this way lets migrate / sync / backfill run against the branch without ever needing a direct CREATE. Flag · Purpose · --engine · Engine whose control-table dialect to print. Default planetscale (the bootstrap consumer — safe migrations blocks direct DDL); mysql / vitess print the same dialect. Engines that don't publish their control-table DDL are refused by name. · sluice control-tables ddl # planetscale dialect (default) sluice control-tables ddl --engine mysql # same dialect, spelled for vanilla MySQL ## trigger setup / teardown ### sluice trigger setup Install a trigger-CDC engine's source-side state — slot-less continuous CDC for managed Postgres that blocks logical replication, a local SQLite file, or a live Cloudflare D1. Flag · Purpose · --source-driver · Trigger-CDC engine to install: postgres-trigger (default), sqlite-trigger (a local SQLite file — --dsn is the file path), or d1-trigger (a live Cloudflare D1 over the HTTP query API — --dsn is the d1:// form, token via CLOUDFLARE_API_TOKEN). · --dsn · Source DSN to install the trigger state into. A PG DSN for postgres-trigger, a SQLite file path for sqlite-trigger, or the d1:// form for d1-trigger. · --tables · Required, comma-separated (repeatable): the tables to install per-table row + truncate triggers on. Empty-list discovery is a follow-up — the command errors if it's unset. · --schema · PG schema the change-log + capture function + per-table triggers live in (postgres-trigger only). Defaults to the DSN's schema query parameter (typically public). · --allow-polled-fingerprint · Permit the non-superuser polled schema-fingerprint path when event triggers aren't grantable (e.g. Heroku). Default off: the engine refuses loudly so the weaker DDL-detection mode is acknowledged explicitly. · --capture-payload · full (default) / changed / minimal — how much of each row the trigger records. · --dry-run, -n · Print the DDL the command would apply and exit; no source-side state is modified. · sluice trigger setup --dsn 'postgres://user:pass@host:5432/app' \ --tables=orders,customers --allow-polled-fingerprint # then stream with the trigger engine: sluice sync start --source-driver postgres-trigger --source ... --target-driver mysql --target ... --stream-id app ### sluice trigger teardown Remove every trace of the trigger engine from the source Postgres database — the counterpart to trigger setup. Run it once the stream is finished to leave the source clean. Flag · Purpose · --dsn · Source Postgres DSN to clean up. · --tables · Tables whose per-table triggers to drop. Empty (default) discovers every table with a sluice-installed trigger in the active schema. · --schema · PG schema; defaults to the DSN's schema query parameter. · --keep-data · Retain sluice_change_log (and the meta table) for forensics. Default drops them — the engine's promise is to remove every trace. · --dry-run, -n / --yes, -y · Print the DDL and exit / skip the destructive-action confirmation prompt. · sluice trigger teardown --dsn 'postgres://user:pass@host:5432/app' --yes ### sluice trigger prune Reap durably-applied rows from a trigger-CDC source's sluice_change_log while a sync is live — the capture path never removes consumed rows, so the change-log grows unbounded for the life of a continuous sync (ADR-0137). Flag · Purpose · --source-driver / --source · The trigger-CDC source whose change-log to prune: postgres-trigger (default), sqlite-trigger, or d1-trigger, and the DSN where sluice_change_log lives (a PG DSN, a SQLite file path, or the d1:// form; token via CLOUDFLARE_API_TOKEN). · --target-driver / --target · The target engine + DSN the sync applies to — where the durably-applied CDC position lives. prune reads the target's persisted frontier as the only safe lower bound and refuses loudly if it can't read one (it never prunes blind). · --stream-id · Required — the same --stream-id the sync uses. Its durable position bounds the prune; prune cross-checks the recorded source fingerprint to refuse a --source/--stream-id mis-pairing. · --keep · Safety margin: keep the most-recent N change-log ids below the durable frontier unpruned (default 1000). Belt-and-suspenders — the frontier itself is already durably applied, so even 0 is safe. · --vacuum · After pruning, VACUUM to reclaim file space — sqlite-trigger / d1-trigger only (Postgres relies on autovacuum). Off by default; VACUUM rewrites the whole database. · --schema · PG source schema holding sluice_change_log (postgres-trigger only); defaults to the DSN's schema parameter. · --dry-run, -n · Compute and print the prune bound without deleting anything. · The correctness crux: a change-log row is pruned only if its id is at or below the watermark the applier has persisted to the target. The exactly-once contract advances that watermark only on durable apply, so the target's persisted position is the durably-applied frontier — pruning on the source's MAX(id), the read cursor, or a TTL would delete not-yet-applied rows and cause silent permanent loss on the next warm-resume. Run it periodically against a live trigger-CDC sync (especially d1-trigger, where change-log growth and per-write billing both matter): # preview the bound, delete nothing sluice trigger prune --source-driver sqlite-trigger --source ./app.db \ --target-driver postgres --target 'postgres://user:pass@host:5432/app' \ --stream-id app --dry-run # reap durably-applied rows, keeping a 1000-id margin, then reclaim space sluice trigger prune --source-driver sqlite-trigger --source ./app.db \ --target-driver postgres --target 'postgres://user:pass@host:5432/app' \ --stream-id app --keep 1000 --vacuum ## schema preview / diff ### sluice schema preview · diff Inspect translation without moving data: print the target DDL sluice would emit, or diff a live target against what sluice would produce. sluice schema preview --source-driver mysql --source ... --target-driver postgres sluice schema diff --source-driver mysql --source ... --target-driver postgres --target ... ## verify ### sluice verify Compare data integrity between source and target — row counts by default, escalating to sampled or full per-row hashing. Flag · Purpose · --depth · How thorough: count (default — per-table row-count comparison) or sample (counts + per-table sampled-row content hashes; ~99% confidence on a 5%+ corruption rate). A full per-row hash mode is planned, not yet shipped. · --sample-rows-per-table / --sample-seed · Sampling size and a deterministic seed. · --strict-hash · Require byte-identical per-row hashes. · --format / --output · Report format and output destination (for CI gating). · sluice verify --source-driver mysql --source ... --target-driver postgres --target ... --depth count sluice verify --source-driver mysql --source ... --target-driver postgres --target ... --depth sample ## matview refresh ### sluice matview refresh Refresh PostgreSQL materialized views on the target (PG-only). Handy as a scheduled job after a sync catches up. sluice matview refresh --target-driver postgres --target ... \ --matview daily_totals --target-schema reporting --matview takes bare matview names (comma-separated, repeatable) that match pg_matviews.matviewname case-sensitively; the schema is named separately with --target-schema (default public). Omit --matview to refresh every matview in the schema. Add --concurrently to emit REFRESH MATERIALIZED VIEW CONCURRENTLY (requires a unique index on the matview; readers stay live). ## slot list / drop ### sluice slot list · drop Manage source-side Postgres replication slots — list sluice-created slots, or drop an orphaned one left by an interrupted stream. sluice slot list --source-driver postgres --source ... sluice slot drop --source-driver postgres --source ... --slot-name sluice_slot ## diagnose ### sluice diagnose Assemble an operator bundle (source/target capability + role state, debug-zip shape) to attach when filing an issue. sluice diagnose --source-driver mysql --source ... --target-driver postgres --target ... --out ./sluice-diagnose.zip Supply the five PlanetScale telemetry flags — --planetscale-org, --planetscale-metrics-token-id / --planetscale-metrics-token (env), --planetscale-metrics-db (defaults to the --target DSN's database), --planetscale-metrics-branch (default main) — to add a target-health metrics snapshot (CPU/mem/storage/lag) to the bundle. Control-plane credential, distinct from --target. See sync start for the same flag semantics. ## metrics-watch ### sluice metrics-watch Standalone PlanetScale control-plane metrics daemon — poll a database's CPU/mem/storage/lag on an interval and fire threshold alerts, with no migration or sync attached. Opens NO connection to the database itself; reads only the PlanetScale metrics API. Flag · Purpose · --engine · Required: mysql | postgres | planetscale | vitess — picks the PlanetScale metric vocabulary for the watched database. No DB connection is opened. · --planetscale-org · Required. Org slug whose metrics endpoint the watch reads. Control-plane only. · --planetscale-metrics-token-id / --planetscale-metrics-token · Service-token (read_metrics_endpoints) ID + secret. Set via the env vars PLANETSCALE_METRICS_TOKEN_ID / PLANETSCALE_METRICS_TOKEN — never on the command line. · --planetscale-metrics-db · Required — the database to watch (there is no --target DSN to derive it from). · --planetscale-metrics-branch · Branch to filter the series to (default main). · --interval · Poll / print cadence (default 60s — the PlanetScale metrics granularity). · --once · Poll a single sample, print / evaluate it, and exit (the one-shot mode for scripts). · --quiet · Suppress the per-poll live line; emit only threshold alerts (the alert-only-daemon shape). · --metrics-listen · Also serve a Prometheus /metrics endpoint re-exporting the watched database's CPU/mem/storage/lag as the sluice_target_* gauge family — turning the daemon into a standalone PlanetScale-metrics exporter. Ignored with --once. · --notify-* · The full alerter set — --notify-webhook / --notify-slack sinks (env SLUICE_NOTIFY_WEBHOOK / SLUICE_NOTIFY_SLACK) and the --notify-storage-util / --notify-cpu-util / --notify-mem-util / --notify-lag-seconds / --notify-storage-growth-per-min thresholds + --notify-cooldown — identical semantics to sync start. · Run as an alert-only daemon (tokens via env; fire on 85% storage): export PLANETSCALE_METRICS_TOKEN_ID=... export PLANETSCALE_METRICS_TOKEN=... sluice metrics-watch --engine planetscale --planetscale-org acme --planetscale-metrics-db app \ --notify-storage-util 0.85 --notify-slack "$SLACK_URL" --quiet =============================================================== # Error codes & exit codes (https://sluicesync.com/docs/error-codes/) The stable SLUICE-E-* error codes and the process exit-code contract — a greppable branching surface for scripts, log pipelines, and agents driving the CLI. sluice's error messages have always named the remedy in prose — "pass --zero-date=null", "use --resume". Prose is a poor branching surface for scripts, log pipelines, and AI agents driving the CLI, so every error class that carries an operator hint also carries a stable error code: a frozen SLUICE-E-- identifier machines can match exactly. The human-facing message is unchanged; the code and a concise remedy ride along as metadata. A SLUICE-E-* code in sluice's output is stable and greppable — once shipped, the string is frozen (renaming or removing one is a breaking change), and it maps deterministically to an exit code (2 for a config error, 3 for a named refusal). The registry in internal/sluicecode is the single source of truth, and a unit test enforces that it matches this table in both directions. Codes are minted only for errors that already carry an operator hint — it is deliberately not a catalogue of every possible error. Where the metadata surfaces: under the global --log-format json flag a terminal coded error emits one ERROR record with code, hint, and err attributes (text-format logging shows the same record in slog's text shape); the exit code lets a caller distinguish "sluice refused and named the remedy — retrying won't help" from a generic runtime failure without parsing anything. ## Exit codes sluice historically exited 0 on success and 1 on everything else. The taxonomy below keeps those two meanings stable and carves two classes out of the generic-failure bucket, so nothing that checks != 0 changes behaviour. Exit code · Meaning · 0 · Success. For verify, diff, and sync-health: success and clean. · 1 · Generic runtime failure. For verify/diff/sync-health this is those commands' long-standing per-command meaning: the check ran and found a mismatch / drift / stale stream. · 2 · Config error: the --config file could not be loaded or parsed. (The read-side commands verify/diff/sync-health/metrics-watch have always used 2 more broadly for "the check could not run at all". For verify this includes a run that completed but could not verify one or more tables — a per-table count/sample error, or a source table missing on the target: an unverified table is not a pass, so those runs exit 2 rather than a misleading 0. Tables deliberately excluded via --include-table/--exclude-table or config filters stay exit-neutral.) · 3 · Named refusal: sluice declined to proceed (or to silently alter a value) and named the remedy — the refusal-class codes below. Retrying without acting on the hint fails identically. · 80 · Usage error: kong (the CLI parser) exits 80 on unknown flags/commands and missing required arguments, before any sluice code runs. sluice adopts this rather than remapping it. · Backward compatibility. Scripts and unit files that check exit != 0 (including a systemd Restart=on-failure) are unaffected — every failure class is still non-zero. Scripts that check exit == 1 specifically should be updated: config errors and named refusals that previously exited 1 now exit 2 and 3. ## Error codes The class drives the exit code: a terminal refusal exits 3, a terminal runtime code exits 1 like any other failure — the code is in the log record either way. Code · Class · Meaning · Remedy · SLUICE-E-CONNECT-REFUSED · runtime · The database host/port is unreachable from this machine. · Verify the DSN host/port and network reachability. · SLUICE-E-CONNECT-AUTH-FAILED · runtime · The database rejected the DSN credentials. · Verify the DSN username and password. · SLUICE-E-CONNECT-DATABASE-MISSING · runtime · The DSN names a database that does not exist on the server. · Verify the DSN database name. · SLUICE-E-BULKCOPY-TARGET-TABLE-MISSING · runtime · Bulk-copy hit a missing target table — schema-apply failed or wrote into a different schema. · Check the schema-apply phase's output and the target schema/database the DSN points at. · SLUICE-E-BULKCOPY-TABLE-FAILED · runtime · A table failed mid-bulk-copy; earlier tables have data but not their declared secondary indexes yet (the indexes phase runs after all tables finish copying). · Fix the offending table and continue with --resume, or skip it with --exclude-table=. · SLUICE-E-SCHEMA-PERMISSION-DENIED · runtime · The target role lacks CREATE on the schema. · GRANT the privilege or use a different role. · SLUICE-E-INDEX-STATEMENT-TIME-LIMIT · runtime · A post-copy index build hit PlanetScale's statement-time limit (MySQL errno 3024); the data is already copied. This surfaces only when the automatic deploy-request fallback (ADR-0148) is unarmed or unavailable — armed (planetscale target + --planetscale-org + service token + safe migrations ON), sluice builds the index via a deploy request instead. The arming flags exist on migrate, restore, and sync start (the three modes that run the deferred index build) — not yet on the fleet sync run specs or the sync from-backup broker. · --resume finishes just the indexes with no re-copy (grow the PlanetScale cluster first for a faster build), start fresh with --upfront-indexes, or arm the deploy-request fallback (--planetscale-org + PLANETSCALE_SERVICE_TOKEN_ID/_TOKEN, safe migrations ON). · SLUICE-E-INDEX-DIRECT-DDL-DISABLED · runtime · PlanetScale safe-migrations is enabled on the target branch and blocked a direct ADD INDEX at the deferred index-build phase (errno 1105) — in practice a --resume/restore continuing an earlier copy, or a branch whose safe migrations was enabled mid-run. This surfaces only when the automatic deploy-request index-build fallback (ADR-0148) is unarmed — armed with --planetscale-org + a service token, sluice builds the indexes through a deploy request; the arming flags exist on migrate, restore, and sync start, not yet on the fleet sync run specs or the sync from-backup broker. Note the fallback covers ONLY the index phase: a fresh migrate into a safe-migrations branch refuses earlier, at user-table creation, with SLUICE-E-PS-DIRECT-DDL-BLOCKED — unless the schema was pre-created via deploy requests, in which case migrate's pre-create gate skips the matching tables (ADR-0166) and the deploy-request path carries a fresh migrate end to end. · Give the command (migrate, restore, or sync start) a PlanetScale service token (--planetscale-org + PLANETSCALE_SERVICE_TOKEN_ID/_TOKEN env) so the deploy-request fallback engages, or disable safe-migrations on the branch for the migration (the standalone sluice expand-contract and sluice deploy-ddl commands ship individual schema changes via deploy requests too). · SLUICE-E-CDC-REPLICATION-PERMISSION · runtime · The connecting role lacks the REPLICATION attribute. · ALTER ROLE x REPLICATION; see Prepare a Postgres source. · SLUICE-E-CDC-POOLER-ENDPOINT · runtime · The replication-slot creation command was rejected by the source as a SQL syntax error (SQLSTATE 42601 on/near CREATE_REPLICATION_SLOT) — the signature of a connection pooler (Supabase Supavisor, pgbouncer) in front of the database: the pooler strips the replication=database startup parameter, so the replication-protocol command reaches a normal backend as plain SQL. Logical replication cannot run through a pooler. · Point --source at the DIRECT database endpoint (Supabase: the db..supabase.co host, not *.pooler.supabase.com; note the direct endpoint is IPv6-only on Supabase — from an IPv4-only network the IPv4 add-on is required). See managed-services. · SLUICE-E-CDC-ROW-IMAGE-PARTIAL · refusal · The MySQL source streams partial binlog row images — binlog_row_image is MINIMAL or NOBLOB, or binlog_row_value_options is PARTIAL_JSON (all read at CDC start: sync cold-start, warm resume, and backup incremental; the binlog_row_value_options read is tolerant since the variable only exists on MySQL ≥ 8.0.3). Under a partial image the UPDATE before-image omits non-key columns and the after-image omits unchanged columns, so sluice's CDC would silently lose every UPDATE (stream green, counts equal, only content diverges). Azure Database for MySQL Flexible Server ships MINIMAL as its platform default; DO/RDS/GCP ship FULL. sluice refuses at CDC start instead. · Set full row images: SET GLOBAL binlog_row_image=FULL and/or SET GLOBAL binlog_row_value_options='' (dynamic, no restart; applies to sessions opened after the change). On Azure: az mysql flexible-server parameter set --name binlog_row_image --value FULL. Segments already written under the partial mode stay partial — start the sync fresh after the flip. See managed-services. · SLUICE-E-CDC-STANDBY-SOURCE · refusal · The CDC source is a read-only hot standby / read replica (pg_is_in_recovery() = true) — e.g. a Supabase read replica (db.-rr-…supabase.co) or any streaming-replication standby. CDC has to manage the sluice publication on the source, and CREATE/ALTER PUBLICATION cannot run on a standby (pre-fix this surfaced as a raw SQLSTATE 25006 "read-only transaction" error). PG 16+ standbys can host logical slots, but slot creation blocks on the primary's next running-xacts record and managed platforms gate the nudge, so the primary is the supported CDC source. · Point --source at the PRIMARY endpoint (Supabase: db..supabase.co, not the -rr- replica host). A standby remains a fine source for bulk sluice migrate. See managed-services. · SLUICE-E-CDC-MARIADB-UNSUPPORTED · refusal (retained, no longer emitted) · Historical refusal from before MariaDB CDC shipped. As of v0.99.271 (ADR-0170) sluice supports continuous CDC from a MariaDB source: it parses MariaDB's domain-based GTID positions (e.g. 0-100-38) and resumes off them, so this refusal is no longer raised — the code is kept in the registry for back-compat only. Native uuid/inet columns were briefly refused too (v0.99.271) and now decode faithfully as of v0.99.272 (ADR-0171); see the row below. · None needed — MariaDB CDC works, including native uuid/inet columns as of v0.99.272. · SLUICE-E-CDC-MARIADB-NATIVE-TYPE-UNSUPPORTED · refusal (retained, no longer emitted) · Historical refusal from before MariaDB native uuid / inet6 / inet4 columns decoded through CDC. v0.99.271 (ADR-0170) raised it because the binlog carries these types' raw storage bytes (16 for uuid/inet6, 4 for inet4) — not the text bulk migrate sees — and a decoder written for MySQL VARCHAR would have stringified the raw bytes into a wrong value that a MySQL-family CHAR(36)/VARCHAR(45) target would silently accept (Postgres rejected it loudly, 22P02). As of v0.99.272 (ADR-0171) the faithful binlog decode shipped and this refusal is no longer raised — the correct byte layout is canonical big-endian UUID (no reorder), length-prefixed with trailing 0x00 stripped (width taken from the declared data_type), and BSD inet_ntop6 text; verified byte-exact on live 11.4/10.11. The code is kept in the registry for back-compat only. See the field note. · None needed — MariaDB native uuid/inet now stream through CDC faithfully. (Bulk sluice migrate was always unaffected.) · SLUICE-E-CONNECT-IPV6-ONLY · runtime · The DSN host failed to resolve from this machine but carries an AAAA (IPv6) record — the host is IPv6-only and this network appears IPv4-only. Seen on Supabase free-tier direct endpoints, where IPv4 is a paid add-on. · For bulk migrate, use the provider's pooler endpoint (it has an A record); for CDC, the direct endpoint is required — a pooler cannot proxy replication — so enable the provider's IPv4 add-on or run sluice from an IPv6-capable network. See managed-services. · SLUICE-E-COLDSTART-TARGET-NOT-EMPTY · refusal · Cold-start refused: a target table already contains data (usually a previous run died mid-copy). · Sync: re-run with --reset-target-data --yes. Migrate: use --resume. Either mode: --force-cold-start to copy into the populated table anyway (collides on PRIMARY KEY in most cases). · SLUICE-E-TARGET-TABLE-SHAPE-MISMATCH · refusal · migrate refused before any data moved: a target table with the same name already exists but its column shape — names (order-insensitive), types, nullability — differs from what the migration would create. migrate's table creation is CREATE TABLE IF NOT EXISTS, so before this gate a conflicting pre-existing table was silently tolerated and only failed mid-copy. A pre-existing table whose column shape MATCHES is skipped with an INFO instead (indexes/constraints/defaults are deliberately outside the compare — a pre-created table legitimately carries them; later phases create any missing ones idempotently). The message names the table and the first differing columns, expected vs actual. · Drop or rename the conflicting target table, exclude it with --exclude-table, or alter its shape to match sluice schema preview's output; --reset-target-data --yes drops every in-scope target table first. On --resume the gate does not run (the prior attempt's own tables re-create idempotently). · SLUICE-E-SCHEMA-EXTENSION-NOT-ENABLED · refusal · A column's type is owned by a PostgreSQL extension the operator has not opted into. · Pass --enable-pg-extension ; see Type mapping. · SLUICE-E-VALUE-ZERO-DATE · refusal · A MySQL zero/partial date (0000-00-00 …) has no valid calendar value the target can hold. · Pass --zero-date=null or --zero-date=epoch to carry it; see migrating-legacy-mysql. · SLUICE-E-VALUE-NUL-BYTE · refusal · A string value carries a NUL byte (0x00), which PostgreSQL text types cannot store. · Clean the source data, or map the column to bytea with --type-override COL=bytea. · SLUICE-E-VALUE-UNREPRESENTABLE · refusal · A value no target column type can represent — e.g. a NaN/±Infinity float from a PostgreSQL double precision source into a MySQL FLOAT/DOUBLE (MySQL has no non-finite floats), or an infinity/pre-Gregorian (BC) Postgres timestamp into a fixed-width target. Refused before the driver sees it so it fails loudly instead of corrupting the value or retry-looping on the server's misleading error. · Filter or transform the source value (e.g. NULLIF / CASE on the source query). · SLUICE-E-EXPR-BACKSLASH-LITERAL · refusal · A SQLite expression's string literal contains a backslash (or a double-quoted token), which MySQL would silently reinterpret under its default sql_mode. · Rewrite the expression on the SQLite source, or re-create it on the MySQL target post-migration. · SLUICE-E-CONFIRMATION-REQUIRED · refusal · A destructive command was run without --yes. sluice is non-interactive and never prompts, so it refuses loudly instead of blocking on a prompt (slot drop is the current caller). · Re-run with --yes (or -y) to confirm the destructive operation. · SLUICE-E-DRIVER-HOST-MISMATCH · refusal · The chosen driver cannot drive the DSN's host — today: the vanilla mysql driver pointed at a PlanetScale endpoint (*.connect.psdb.cloud), whose binlog CDC and LOAD DATA cold-copy Vitess blocks. Caught up front, before any connection. · Pass --source-driver planetscale / --target-driver planetscale for the PlanetScale endpoint. · SLUICE-E-INDEX-MISSING · refusal · The post-copy verification found the target is missing one or more secondary indexes the migration was expected to build (named as table.index in the message) — a loud-failure safety net against a silent index-build no-op. sluice refuses to report a successful migration with an incomplete schema. · Re-run with --resume to rebuild the missing indexes; if it recurs, the target rejected the index DDL — check the target's DDL/online-migration policy and the logs for the underlying error. · SLUICE-E-VSTREAM-FLOAT-LOSSY · refusal · backup full on a PlanetScale/Vitess (VStream) source with --strict-float, when a single-precision FLOAT column cannot be re-read exactly: the table is keyless / float-PK-only (no primary key to target the exact re-read — refused upfront), larger than --float-reread-max-rows (too large for the bounded-memory exact re-read), or the exact re-read returned rows but not one streamed row's primary key matched (a systemic PK-rendering divergence). vttablet's rowstreamer renders FLOAT at mysqld's 6-significant-digit display precision, and --strict-float demands exact-or-fail. · Add a primary key (or exclude the table), raise --float-reread-max-rows if it's a size cap and you have the headroom, or drop --strict-float (the default archives exact where it can and rounded — with a WARN — elsewhere). A target-side --type-override to DOUBLE does NOT help — the source value is already rounded on the wire. · SLUICE-E-BACKUP-SIGNATURE-INVALID · refusal · A signed (FormatVersion 6, ADR-0154) backup manifest's detached signature failed verification — the manifest was tampered, rolled back to an older version, its change-list was truncated, the wrong key was supplied, or the signature scheme/algorithm was relabeled. Applies to all three signing schemes: HMAC-off-KEK (--sign, Phase 1), Ed25519 (--sign-key /--verify-key , Phase 2), and KMS (--sign-key kms://.../--verify-key, Phase 3). sluice refuses to restore/verify it before any data lands. · Restore from an untampered copy of the backup; if the whole store is suspect, the signature caught exactly the substitution it exists to catch. Verify the key matches the chain — the chain's --encrypt passphrase for an HMAC-signed chain, the correct --verify-key (a public-key PEM for Ed25519 / KMS, or kms://... to fetch the trusted key) for an asymmetrically-signed chain (the KEK does NOT verify an asymmetric signature). The recorded manifest key reference is never trusted — verification anchors on the key you supply. · SLUICE-E-BACKUP-SIGNATURE-MISSING · refusal · A signed (FormatVersion 6) backup manifest asserts a detached signature but none is present (or the lineage catalog's signature is absent), OR --require-signature was set and the chain could not be verified with the supplied key material — the tamper signal for a dropped signature, a signed-chain manifest replaced without re-signing, or a signed chain restored without the matching verify key under strict policy. · Restore from a copy whose .sig objects are intact; supply the matching verify key (--encrypt passphrase for HMAC-off-KEK, --verify-key for Ed25519 / KMS); a maintenance run (compact/prune) that could not re-sign must be re-run with the chain's signing key material (--encrypt passphrase, --sign-key , or --sign-key kms://...). · SLUICE-E-BACKUP-SIGNATURE-UNSUPPORTED · refusal · A signed backup manifest records a signature scheme FAMILY (e.g. a future post-quantum scheme) or a kms/ whose algorithm this build of sluice does not know how to verify, or a canonicalization version newer than this build supports — the signature was written by a NEWER sluice. This is a forward-incompatibility, NOT a tamper signal: sluice fails closed (it will not restore/verify what it cannot check) but does not claim the backup is compromised. Distinct from -INVALID precisely so a version/scheme gap is never mistaken for an attack. · Upgrade sluice to a build that supports the backup's signature scheme/canonicalization, then re-run the restore/verify. The backup is not necessarily tampered — an older binary simply cannot verify a newer signature format. · SLUICE-E-BACKUP-MANIFEST-INVALID · refusal · A backup manifest — or the chain of manifests — fails an internal-consistency check, refused before any data lands. Three shapes: (1) the recorded BackupID does not match its content, recomputed at restore/broker time from the fields the id deterministically covers (created_at / source_engine / kind / EndPosition); (2) the recorded SchemaHash does not match the manifest's own schema, recomputed at chain-restore time; (3) a mixed-mode lineage, where a segment's full and one of its incrementals disagree on encryption (one encrypted, one plaintext) — a mis-stitched or tampered chain. All three catch bit-rot, truncated rewrites, or a lazy tamper that edited a covered field without recomputing its fingerprint. They are corruption backstops, not tamper-proofing: a fully-coherent edit that also recomputes the fingerprints and fixes the parent-link chain is the signed-only boundary. · Restore from an untampered copy of the backup. To close the coherent-edit residual and catch this at backup verify time, sign the chain (--sign / --sign-key + --require-signature). Genuine corruption from a bad upload produces the same refusal; re-fetch/re-stream the manifest. · SLUICE-E-BACKUP-INCOMPLETE · refusal · A restore, broker replay, or export-as-parquet decoded/applied a DIFFERENT number of changes/rows than the manifest records — the signing-independent backstop against silent truncation or edit of an unsigned backup (ADR-0154 R2 residual). Three shapes: (1) after replaying an incremental's change chunks the last applied change did not REACH the manifest's EndPosition (a store adversary dropped the tail change-chunk entries; survivors keep their ordinals so every GCM AAD still validates, but the intact EndPosition would overstate the data and poison a resumed CDC stream); (2) a table's/chunk's decoded row total disagrees with the manifest's recorded positive RowCount (the layer-2 count check, on restore and on Parquet export alike); (3) a full's table or chunk records 0 rows yet decodes rows (a zeroed RowCount that would otherwise disable the layer-2 backstop). · Restore/export from an untampered copy of the backup. To catch this class at backup verify time (before a restore) and to close the residual coherent-edit boundary, sign the chain (--sign / --sign-key) so the manifest signature covers the change-chunk ordinal + count + positions — an unsigned encrypted incremental is protected against tail-truncation only by this replay-time backstop. Genuine truncation from a bad upload produces the same refusal; re-fetch/re-stream the incremental. · SLUICE-E-BACKUP-CHUNK-AUTH-FAILED · refusal · An encrypted backup chunk failed its AES-GCM authenticated-decryption check during restore (or broker replay): the ciphertext or its bound additional-authenticated-data (AAD) does not match what was sealed — a tampered/corrupt chunk, or a spliced/reordered store where a chunk was moved between positions or between two same-column-set tables. This is the loud, coded TWIN of SLUICE-E-BACKUP-SIGNATURE-INVALID for a backup that is ENCRYPTED but NOT SIGNED: without a signature, sluice catches the swap/tamper at decrypt rather than at verify, and refuses before any row lands. By the time a chunk decrypts the chain key has already unwrapped (its wrap is itself authenticated), so this is never a wrong-passphrase error — a wrong key is caught earlier at the key unwrap. · Restore from an untampered copy of the backup; if the whole store is suspect, the AAD binding caught exactly the chunk substitution/splice it exists to catch. If you also want tamper caught at backup verify time (before a restore), sign the chain (--sign / --sign-key) — an unsigned encrypted backup is protected only at decrypt. Genuine bit-rot (not tampering) produces the same refusal; re-fetch the chunk object or restore from a healthy replica. · SLUICE-E-BACKUP-CHUNK-CORRUPT · refusal · A backup chunk's STORED bytes do not hash to the SHA-256 recorded for it in the manifest, checked by rehashing the bytes during restore, broker replay, or backup verify. This is the byte-level integrity check that runs BEFORE decryption, so it fires on plaintext and encrypted chunks alike — the integrity TWIN of SLUICE-E-BACKUP-CHUNK-AUTH-FAILED (which is the AES-GCM authenticated-decryption check). It catches at-rest corruption / bit-rot and any tamper that altered a chunk's stored bytes (for an encrypted chunk a byte flip is caught here first, before the GCM tag). sluice refuses before any row lands. · Restore from an untampered / healthy copy of the backup, or re-fetch the chunk object from the store (a bad upload or storage bit-rot produces the same refusal as a tamper). If you want tamper caught at backup verify time and covered by a manifest signature, sign the chain (--sign / --sign-key + --require-signature). · SLUICE-E-BACKUP-CHAIN-CONFLICT · refusal · Another writer advanced this backup chain's lineage while this operation (a backup full/backup incremental finalize, a backup stream rollover, a rotation COMMIT, backup compact, backup prune, or backup verify --rebuild-catalog) was in flight. Every chain writer read-modify-writes the shared lineage.json catalog; two writers interleaving it (a duplicate cron entry running backup incremental twice, a backup racing a compact/prune, an operator double-start) can silently corrupt the chain's structure. The catalog write is a compare-and-swap on the chain's write-generation, so the losing writer refuses loudly and writes NO catalog change; the named marker records the other writer's host/pid/claim-time. Detection requires a store with conditional-write support (local --output-dir, S3, GCS, Azure; an S3-compatible endpoint that lacks conditional PUTs degrades to the old unguarded behavior with a WARN). · Check for a duplicate cron/scheduler entry or a concurrent backup/compact/prune/stream against the same chain; let the other writer finish, then re-run — the refused operation left the catalog untouched (a refused backup's own manifest is durable and is re-cataloged by the next writer or stream resume). If conflicts persist, inspect the newest lineage.gen/ marker to identify the competing host/pid. · SLUICE-E-BACKUP-ENCRYPTION-MISMATCH · refusal · The supplied encryption configuration does not match the chain's recorded encryption metadata, refused at preflight before any chunk is read or written. Two shapes, on every chain-reading surface (restore, chain restore, sync from-backup, backup export-as-parquet, backup verify's decrypt preflight, the catalog-rebuild codec probe) AND on the chain-EXTENDING writers (backup incremental, the sync --backup-to streaming rollover — both align against the parent chain's recorded shape before writing a single chunk): (1) the chain records ChainEncryption (it is encrypted) but no --encrypt + key material was supplied — the message names the chain's algorithm, kek_mode, and kek_ref so you know exactly what to bring; (2) key material WAS supplied but its KEK mode (passphrase vs a KMS provider) differs from the chain's recorded kek_mode — the wrong KIND of key, caught before an unwrap attempt could produce a confusing decrypt failure. (The inverse mismatch — a key supplied against a chain that CLAIMS plaintext — is the downgrade-tamper signal and stays SLUICE-E-BACKUP-CHUNK-AUTH-FAILED.) · Pass --encrypt with the key material the chain was written under: --encryption-passphrase{,-env,-file} for kek_mode=passphrase-argon2id, --kms-key-arn for aws-kms, --gcp-kms-key-resource for gcp-kms, --azure-key-vault-id for azure-kms — the refusal message names the recorded kek_mode/kek_ref. A wrong passphrase/key of the RIGHT mode fails later at the CEK unwrap instead ("wrong passphrase / KMS key?"). · SLUICE-E-BACKFILL-NO-PRIMARY-KEY · refusal · sluice backfill refused the table: it has no primary key, or a primary-key column is non-orderable (JSON/array/geometry) — so the keyset walk that bounds each UPDATE to --batch-size rows has nothing safe to cursor on. An unbounded whole-table UPDATE is exactly the statement-time-wall / long-lock shape the command exists to avoid, so sluice refuses rather than degrade to it. · Add a primary key to the table (or fix the non-orderable key), then re-run. There is no flag to force an unbounded backfill — for a small table where a single UPDATE is fine, run it directly in your SQL client. · SLUICE-E-BACKFILL-UNSUPPORTED-ENGINE · refusal · The --driver engine does not implement the in-place backfill surface. Backfill ships for MySQL (including the planetscale / vitess flavors, which ride the same bounded-UPDATE path) and Postgres; SQLite/D1 have no backfill executor. · Pass --driver mysql, --driver planetscale, --driver vitess, or --driver postgres. For SQLite/D1, run the transform directly — a single-file/edge database doesn't need the online-safety machinery. · SLUICE-E-BACKFILL-UNKNOWN-COLUMN · refusal · A --set clause names a column that does not exist on the target table, caught against the read schema before any UPDATE runs (the message lists the table's actual columns). Refusing up front beats a per-chunk SQL error — or worse, a typo silently creating the impression the backfill ran. · Fix the --set column spelling (the left side of the first =); expressions on the right side are passed to the engine verbatim and are validated by the database itself. · SLUICE-E-BACKFILL-INCOMPLETE · runtime · sluice backfill --verify (or --verify-only) counted rows still matching the --where guard AFTER the walk completed — the check ran truthfully and found unfinished work, the online-backfill catch-up signal: rows inserted behind the walk's cursor during the run, or written since a completed run. The walk's own work is intact and persisted (the migration state stays complete); the gate is saying the table is not yet safe for the contract step. · Re-run the backfill to pick up the stragglers (a spec whose stored state is complete needs --restart to walk again), then verify again. On a quiesced database, a nonzero count after a clean walk means the --where guard does not actually self-describe doneness — fix the predicate (e.g. new_col IS NULL) so an already-backfilled row no longer matches. · SLUICE-E-BACKFILL-CORRUPT-CURSOR · refusal · sluice backfill refused to resume: the persisted cursor for this spec was written by an older sluice whose JSON state store mangled non-string-safe PK values — binary cursor bytes had invalid-UTF-8 sequences replaced with U+FFFD, and integer cursors above 253 drifted through float64 — so its stored value provably no longer names the row the walk stopped at. Resuming from it would silently skip (or replay far beyond the one-chunk bound) arbitrary PK ranges, and a later contract step would destroy the never-backfilled rows; refusing loudly is the only safe answer. Current releases store cursors in a lossless tagged envelope, so fresh runs never produce this. · Re-run the same spec with --restart to walk the table from the beginning. With a self-describing --where guard (e.g. new_col IS NULL) the re-walk updates only the rows the interrupted run never reached — already-done rows are untouched. · SLUICE-E-BACKFILL-CONCURRENT-RUN · refusal · sluice backfill refused to start: the spec's state row is still in the walking phase AND its heartbeat (the row's updated_at, touched on every committed chunk) is fresher than the 5-minute freshness window — another run of the same spec looks live, typically an overlapping cron invocation. Two concurrent walks of one spec interleave cursor writes, breaking the at-most-one-chunk replay bound into arbitrary skipped or replayed PK ranges, so sluice refuses before touching anything (including a --restart, which would clear the state row out from under the live walker). Heartbeat-only, no lease: a single chunk UPDATE outliving the window can slip past the guard, and a kill -9'd run keeps the spec refused for at most one window. · Wait for the running backfill to finish (or for its heartbeat to go stale — the window passes with no committed chunk), then re-run; the second invocation resumes from the persisted cursor. If no other run exists (e.g. the previous one was killed seconds ago), simply wait out the window. Deep client↔database clock skew can also trip the guard — it only ever over-refuses, never lets a concurrent walk through. · SLUICE-E-PS-SAFE-MIGRATIONS-DISABLED · refusal · sluice expand-contract (or sluice deploy-ddl) refused: the PlanetScale production branch does not have safe migrations enabled, and deploy requests — the mechanism these commands ship schema changes through — cannot be created into such a branch. sluice never enables the toggle for you: it is a behavior change on your production branch (direct DDL becomes blocked from then on, and the enable/disable propagation lag makes toggling around a run unsafe). (migrate's ADR-0148 index-build fallback never raises this code — with safe migrations off it simply stands down and the direct index error surfaces with its usual hint.) · Enable the branch's "Safe migrations" setting in the PlanetScale UI (or pscale branch safe-migrations enable --org ), understanding that every future schema change on the branch must then ship via a deploy request; then re-run. · SLUICE-E-PS-DEPLOY-REQUEST-FAILED · runtime · A PlanetScale deploy request driven by sluice (sluice expand-contract, sluice deploy-ddl, or migrate's ADR-0148 index-build fallback) entered a failure state (error, complete_error, cancelled, …), was closed without deploying, computed an empty diff (no_changes — that leg's DDL is likely already deployed from an earlier run), computed a diff touching an object the leg never intended (the ADR-0167 pre-deploy blast-radius check: a stranger table in the diff means the dev branch's base was stale or the branch was edited outside sluice — deploying would ship those changes; deploy-ddl carries no intended set, so this check applies to expand-contract and the index fallback), or did not become deployable / complete before --deploy-timeout. The message names the leg, the deploy-request number, the state it saw, and the deploy request's URL. · Inspect the deploy request at the URL in the message. A timeout on a large table usually means the VReplication deploy is still running — watch it finish, then continue with --resume-from migrate (after expand) or re-run the contract leg. A no_changes diff means that leg's DDL is already deployed: delete the leftover dev branch and resume past the leg. If your organization requires deploy-request approval, approve it and re-run — on that specific timeout sluice KEEPS the dev branch (deleting it would close the still-open deploy request); delete the branch yourself once the request closes, as the message spells out. For migrate's index-build fallback, recovery is always --resume: the index phase re-probes the target and rebuilds only what is still missing, so an index that deployed after the timeout is simply detected and skipped. · SLUICE-E-PS-BRANCH-STALE-BASE · runtime · A newly created PlanetScale dev branch's schema can lag the production branch it was created from (observed live: a branch created 14 minutes after a deploy still lacked the deployed column — the lag is intermittent and its timing undocumented). A deploy request from such a branch would silently revert the missing changes (on the contract leg, that would drop the freshly backfilled expand column). sluice expand-contract, sluice deploy-ddl, and migrate's ADR-0148 index-build fallback compare every dev branch's schema against production before applying any DDL, self-heal a stale base once (delete the branch → take an on-demand backup → recreate), and raise this error only when the branch is still stale after the rebase, or the rebase backup itself failed / outran --deploy-timeout. The same code also fires from the ADR-0167 post-wait freshness recheck: when a deploy request sat in its deployable/review wait for more than ~2 minutes and production's schema CHANGED in that window, the request's diff was computed against the old schema, so deploying it could silently revert the newer change — sluice refuses right before the deploy call instead. · If the rebase backup was still running, let it finish in PlanetScale and re-run. Otherwise compare pscale branch schema against the production branch to see what differs, take a fresh manual backup of production (pscale backup create), and re-run. For the post-wait recheck shape, simply re-run — the command re-provisions the dev branch from current production and recomputes the deploy request. · SLUICE-E-PS-DIRECT-DDL-BLOCKED · refusal · The PlanetScale branch has safe migrations enabled, which refuses every direct DDL statement (Error 1105 "direct DDL is disabled") — and sluice needed one. Two cases, each named in the message: (a) creating (or column-migrating) one of sluice's own control tables — sluice_migrate_state, sluice_cdc_state, and siblings — where the ensure paths are detect-first, so this fires only when the DDL is genuinely needed and the message echoes the exact refused statement; (b) a user-table CREATE during migrate's or sync cold-start's schema-apply phase — a fresh migrate into a safe-migrations branch always refuses here, before any data moves (the ADR-0148 index-build fallback engages later, at the index phase, and cannot help with table creation). · Case (a): bootstrap the control tables through the governed channel — sluice control-tables ddl prints the exact CREATE statements, sluice deploy-ddl --org --database --ddl '' ships each one via a deploy request; then re-run. Case (b): disable safe migrations on the branch for the migration window and re-enable it after, or pre-create the schema via deploy requests (sluice schema preview prints the target DDL, sluice deploy-ddl ships each statement) — a re-run of sluice migrate then skips the pre-created tables whose column shape matches (ADR-0166), and a sync stream skips schema-apply with sluice sync start --schema-already-applied. · SLUICE-E-SOURCE-FOREIGN-DUMP · refusal · The --source file is a plain mysqldump / pg_dump .sql dump or a pg_dump custom-format (PGDMP) archive — full-dialect / private formats sluice deliberately does not parse (the IR-first tenet). Detected by content signature at open, on any file-reading source driver (sqlite, mydumper, csv/tsv/ndjson), before any data moves. · Restore the dump to a scratch server with its native tool and migrate live — the error message carries the exact three-command recipe (docker run a scratch MySQL/PostgreSQL, mysql/psql/pg_restore the dump into it, sluice migrate from the scratch server). A mydumper/pscale database dump DIRECTORY needs no scratch server: --source-driver mydumper. · SLUICE-E-SOURCE-WRONG-DRIVER · refusal · The --source is a recognisable input this source driver does not read: a mydumper directory handed to csv/sqlite, a CSV/TSV/NDJSON file handed to mydumper/sqlite, a binary SQLite .db handed to csv, a gzip/zstd-compressed file, a UTF-16 text file, a single-array JSON document handed to ndjson, or a .tsv/.csv extension contradicting the chosen delimited driver. Refused loudly instead of mis-parsing (a tab file through the comma lexer would silently stage one wide column). · The message names the right --source-driver or the preparation step (decompress, transcode to UTF-8, jq -c '.[]' for a JSON array). For a correctly-delimited file whose extension merely lies, declare intent with --source-driver csv --csv-delimiter=.... · SLUICE-E-CSV-NULL-AMBIGUOUS · refusal · A csv/tsv source contains an UNQUOTED empty field and no NULL representation was declared. RFC 4180 has no NULL — NULL-vs-empty-string is pure producer convention and the #1 silent-loss class for CSV ingest — so sluice refuses to guess, naming the record and column. A QUOTED empty field ("") is unambiguously the empty string and never triggers this. · Declare the file's convention: --csv-null='' (an unquoted empty field means NULL — the PostgreSQL COPY CSV convention), or --csv-null='\N' / --csv-null=NULL (that literal means NULL; empty fields are then empty strings). A quoted field is always data regardless. · SLUICE-E-CSV-HEADER-UNDECLARED · refusal · A csv/tsv source was opened without --csv-header or --csv-no-header. Header presence is never sniffed: guessing wrong either silently eats the first data row as column names or turns the header into a data row. · Pass --csv-header (the first record carries the column names) or --csv-no-header (columns are named col1..colN in file order). · SLUICE-E-EXPORT-UNREPRESENTABLE · refusal · sluice backup export-as-parquet refused a column type or value that has no faithful Parquet representation: a multi-dimensional array value (the column type declares no dimensionality, so the derived LIST schema cannot hold nested lists), a TIME value outside a calendar day (MySQL TIME durations reach ±838h; PG allows 24:00:00), a PG NUMERIC NaN/Infinity (Parquet DECIMAL has no non-finite form), a decimal with more digits than its declared precision/scale, or a timestamp with sub-microsecond precision. The export never silently narrows, rounds, or wraps a value — the documented string downgrades (unbounded NUMERIC, TIMETZ) carry the exact value text and are WARNs, not this refusal. · Exclude the affected table (--exclude-table) and export the rest, or query that table's JSON-Lines chunks directly (DuckDB reads them natively — see the DuckDB cookbook). · SLUICE-E-WHERE-CDC-UNSUPPORTED-PREDICATE · refusal · sluice sync with a --where TABLE= filter (ADR-0173 Phase 2) refused at sync-start: unlike migrate (which pushes the predicate down to the source read), a continuous filtered sync must evaluate the predicate CLIENT-SIDE per CDC change — there is no source-side stream filter — so it accepts only a restricted, faithfully-evaluable grammar (a column compared to a literal with = != <> < <= > >=, col IN (…), IS [NOT] NULL, combined with AND/OR/NOT and parentheses). A case/accent-insensitive string equality on a UTF-8 collation is supported (v0.99.282+) — sluice evaluates it under the column's own collation, including trailing-space (PAD) semantics, so it matches the source exactly. What still refuses UP FRONT: a function call, a subquery, arithmetic, LIKE, an unknown column, a string ordering comparison (< <= > >=, collation-dependent), a string comparison on a collation sluice can't reproduce faithfully (a non-UTF-8 charset such as latin1, or a Postgres non-deterministic ICU collation), an equality on a FLOAT/DOUBLE column (the client compares exactly while the source coerces to a 64-bit double; float ordering is allowed — except a single-precision FLOAT ordering term on a PAD-SPACE-forced PlanetScale/Vitess table, which refuses because the cold-start copy carries single-precision FLOAT display-rounded), a timezone-aware temporal comparison, or unrecognized syntax — refused rather than evaluated in a way that could silently leak out-of-scope rows or drop in-scope ones. (A PAD-SPACE-collation string filter on a PlanetScale/Vitess source — where the server-side filter is NO-PAD — is NOT refused as of v0.99.283: that table streams unfiltered server-side and is filtered client-side with the PAD-faithful comparator.) migrate --where is unaffected (it never evaluates client-side). · The common cases — equality / IN / IS NULL on a column, including case/accent-insensitive string matches (country IN ('US','CA')) — are supported directly. For a genuinely unreproducible one (a non-UTF-8 charset column, a non-deterministic PG collation, a float equality), either normalize on the source (e.g. a generated lower-cased column) and filter on that, or use sluice migrate --where (source-evaluated, full source-SQL) if you only need the initial subset. · SLUICE-E-WHERE-CDC-BEFORE-IMAGE · refusal · sluice sync with a --where filter (ADR-0173 Phase 2) refused at sync-start because the source is not configured to deliver full row BEFORE-images, which the filter's row-move evaluation requires: an UPDATE that changes a row so it newly matches (move-IN) or no longer matches (move-OUT) the predicate must be translated to a target INSERT or DELETE, and deciding that needs the predicate evaluated on BOTH the before- and after-image. On MySQL this is binlog_row_image=FULL (already required for all sluice CDC — a partial image raises SLUICE-E-CDC-ROW-IMAGE-PARTIAL); on Postgres each filtered table needs REPLICA IDENTITY FULL (the default REPLICA IDENTITY DEFAULT carries only the primary key in the before-image, so a predicate on a non-key column could not be evaluated on the old row). The message names the table and the exact remedy. · Postgres: ALTER TABLE
REPLICA IDENTITY FULL on each filtered table, then restart the sync. MySQL: SET GLOBAL binlog_row_image=FULL (see SLUICE-E-CDC-ROW-IMAGE-PARTIAL). · SLUICE-E-WHERE-FK-ORPHAN · refusal · sluice migrate with a --where TABLE= row filter (ADR-0173) refused at the constraints phase: the filter excluded rows of a PARENT table, orphaning rows on a child table that references it, so the deferred ADD CONSTRAINT FOREIGN KEY failed with SQLSTATE 23503 on the target. The message names the child table, the FK constraint, and the referenced parent (flagging which side carries a --where). This fires only when --allow-degraded-fks is NOT set — with that flag the FK is instead attached NOT VALID (a PG target) and no refusal is raised. · Filter consistently so the referenced parent's rows are also copied (a child --where must not out-scope its parent), or pass --allow-degraded-fks (PG target) to attach the FK as NOT VALID and validate later after reconciling the orphans. Referential-aware auto-inclusion of parent rows is a future enhancement (ADR-0173). · SLUICE-E-WHERE-UNKNOWN-TABLE · refusal · sluice migrate / verify with a --where TABLE= filter refused at start because TABLE names no table in the source schema — a typo (--where user=… missing the s) or a case-fold mismatch. The migrate/verify readers match the filter by exact table name, so an unmatched key would silently disable the filter and copy/count the whole table — a scope-escape that verify, riding the same lookup, would then confirm as a false PASS. Refusing beats a silent whole-table copy (the continuous-sync leg already refuses the same class). Matching is case-insensitive; two keys that fold to the same table are refused. Added v0.99.279. · Correct the --where table name (matching is case-insensitive) or remove the entry, and pass the same --where to migrate and verify. · =============================================================== # Type mapping (https://sluicesync.com/docs/type-mapping/) What your MySQL TINYINT(1) / ENUM / DECIMAL / JSON / temporal types become on Postgres (and vice versa), and on SQLite / D1 — the cross-engine translation policies. sluice never translates one dialect straight to another. Every column type maps source-dialect → typed IR → target-dialect: source-specific knowledge lives in readers, target-specific knowledge in writers, and the IR is the only shared contract. That's why the four-direction matrix needs four readers and four writers, not twelve pairwise tables. This page is the operator-facing summary of those policies; the canonical, always-current source is docs/type-mapping.md and the runtime value contract in docs/value-types.md. ## Core vs extension types The IR type system is a two-tier hierarchy, and the tier decides what happens on an engine that lacks a type: - Core types — integers, decimal, float, boolean, char/varchar/text, binary/blob, date/time/datetime/timestamp, JSON — are the types every relational engine has in some form. Every engine reads and writes them; they are the lingua franca. - Extension types — ENUM, SET, UUID, arrays, PostGIS geometry, and the Postgres network types (inet/cidr/macaddr) — are types only some engines support natively. Each engine declares which it handles; an engine that lacks one either applies a documented degradation (e.g. Postgres array → MySQL JSON) or refuses loudly. Postgres extension types (hstore, citext, pgvector, PostGIS) are opt-in via --enable-pg-extension EXT and refuse loudly at schema-read if the flag is absent (SLUICE-E-SCHEMA-EXTENSION-NOT-ENABLED) — never silently dropped. Adding a new engine never amends the core; it declares which extension types it supports and provides the reader/writer code. The orchestrator never asks "are you MySQL?" — it asks "do you support arrays?" ## MySQL ↔ Postgres The most-travelled direction. Notable rows below; the full table is in the canonical doc. MariaDB reads and writes through this same MySQL mapping. As a MySQL-family flavor it uses the mysql rows here in both directions; the divergences are all on the catalog side, not the type table — MariaDB's native uuid / inet6 / inet4 types, its per-table (not per-schema) CHECK-constraint names, a geometry SRID it stores but won't echo in SHOW CREATE, and a different COLUMN_DEFAULT dialect. The MariaDB field notes cover each. MySQL · Postgres · Notes · TINYINT(1) · boolean · The MySQL boolean convention. A value outside {0,1} collapses to true; sluice WARNs loudly once per column and names the row. Override with --type-override col=smallint to keep the integer (smallint is the safe floor — a tinyint override could round-trip back to a boolean). · TINYINT / SMALLINT / MEDIUMINT / INT / BIGINT · smallint / smallint / integer / integer / bigint · MEDIUMINT widens to integer on PG (no 3-byte int). Signed ranks map straight across. · … UNSIGNED · widens one rank · tinyint→smallint, smallint→integer, mediumint/int→bigint. bigint unsigned → bigint (uniform): PG has no unsigned 64-bit, so values in (2^63-1, 2^64-1] aren't representable — but this is the only mapping that keeps an AUTO_INCREMENT PK and its FK children type-consistent (the default Rails/Laravel/Django schema). Surfaced by a loud range-narrowing notice at schema preview / migrate preflight; override to numeric to keep the full range (then the column can't be an identity key). · DECIMAL(p,s) / NUMERIC · numeric(p,s) · Carried as a string end-to-end; precision is lossless. A bare Postgres numeric (no p/s) is arbitrary-precision — PG→PG round-trips it bare; PG→MySQL widens to DECIMAL(65,30) (MySQL's max) with a loud widening notice. · FLOAT / DOUBLE · real / double precision · Finite values ride through exactly. NaN/±Inf are Postgres-only, so PG→MySQL refuses them loudly with SLUICE-E-VALUE-UNREPRESENTABLE (MySQL has no non-finite floats) rather than coerce them; MySQL→PG never produces one. · CHAR(n) / VARCHAR(n) / TINY..LONGTEXT · char(n) / varchar(n) / text · A PG varchar(N) above MySQL's representable cap down-maps to the smallest MySQL TEXT-family type, with a loud advisory. Charset/collation are carried same-engine, dropped-with-WARN cross-engine (collation names aren't portable). · DATE / TIME(p) / DATETIME(p) / TIMESTAMP(p) · date / time(p) / timestamp(p) / timestamptz(p) · MySQL TIMESTAMP always stores UTC → PG timestamptz. A bare PG time/timestamp (no precision) round-trips bare PG→PG but materializes (6) on a MySQL target. A PG timetz → MySQL drops the zone (MySQL has no tz-aware time). Zero/partial MySQL dates (0000-00-00) are refused unless --zero-date=null|epoch (SLUICE-E-VALUE-ZERO-DATE). · ENUM('a','b') · enum type (default) or text + CHECK · Default emits a PG CREATE TYPE … AS ENUM; per-column override for text + a CHECK constraint. A PG enum → MySQL becomes a column-level ENUM(...) (no shared type; each column gets its own). · SET('a','b') · text[] + CHECK · Membership preserved via a CHECK; override to a comma-delimited text. · JSON · jsonb (default) / json · MySQL JSON and PG jsonb both validate + normalise; PG json (no b) preserves whitespace/key order. Carried as raw bytes. · (no MySQL type) · uuid · PG uuid → MySQL CHAR(36) / BINARY(16). · JSON (degraded) · T[] (array) · MySQL has no array type: a PG array → MySQL JSON (empty {}→[], NULL element→JSON null, nested preserved). Override array_strategy: concat for simple scalar arrays. Multi-dimensional arrays are pinned per element family — see the field note on the pgx codec that silently flattened numeric[][]. · VARCHAR(45/30) · inet / cidr / macaddr · PG network types have no MySQL native form: inet/cidr→VARCHAR(45), macaddr→VARCHAR(30) (auto-shaped since v0.7.0; overridable). · spatial types · geometry (PostGIS) · Requires PostGIS on the target via --enable-pg-extension; carried as WKB. Every subtype/SRID preserved. · ## SQLite & Cloudflare D1 SQLite (and D1, which is SQLite over HTTP) is the one engine whose value storage isn't pinned by its column declaration — a column has a type affinity, and each stored value carries its own storage class. sluice resolves an IR type from the declared type in a load-bearing order: declared temporal / bool spellings win first, affinity second. SQLite declared / affinity · IR → typical target · Notes · DATE / DATETIME·TIMESTAMP / TIME · date / timestamp (no tz) / time · Declared spelling overrides affinity (they'd otherwise read as NUMERIC decimals). The value encoding is an operator choice — --sqlite-date-encoding (iso default / unixepoch / unixmillis / julian); a storage-class mismatch is refused loudly, naming the row. · BOOL / BOOLEAN · boolean · Decodes 0/1 and truthy text; anything else is refused. · INTEGER affinity · bigint · SQLite integers are 64-bit signed. Integers above 253 round-trip exactly via the (typeof, text/hex) projection (the lossless live-D1 reader path). · TEXT affinity · text · Unbounded — declared VARCHAR(n) lengths aren't enforced by SQLite, so no misleading bound is carried. · REAL affinity · double precision · 8-byte IEEE-754. · NUMERIC affinity · unconstrained numeric · Arbitrary precision. · As a migrate target, SQLite emits the declared type its reader reads back to the same IR type. The one load-bearing wrinkle: an ir.Decimal is stored with TEXT affinity (the exact decimal string), not NUMERIC — NUMERIC affinity would coerce 19.99 to the binary float 19.989999999999998 and silently corrupt money (Bug 162); it reads back as text (a documented downgrade). Anything SQLite has no faithful storage for — geometry, inet/cidr/macaddr, bit, interval, array, domain — is refused loudly at emit time, never coerced to a silently-wrong column. D1 is not a write target: emit a SQLite .db (--target-driver sqlite) and wrangler d1 import it. ## Per-column overrides The default policies cover the common case; override per column in YAML (mappings:) or on the CLI. Overrides are typed against the IR, not dialect syntax: - --type-override TABLE.COLUMN=TYPE — force a target column type (repeatable). The override rewrites the IR type the reader decodes with, so e.g. =smallint on a TINYINT(1) reads the cell as an integer end-to-end. - --enable-pg-extension EXT — opt into a Postgres extension type (hstore, citext, vector, PostGIS) so its columns pass through instead of refusing. - YAML mappings: entries also carry enum_strategy, array_strategy, on_zero_date, and per-column target_type options. Run sluice schema preview first to see the exact target DDL sluice would emit, including every widening/narrowing advisory and any untranslatable-expression refusal — before touching the target. =============================================================== # Configuration (https://sluicesync.com/docs/configuration/) Connection strings, environment variables, the YAML config file, and the global flags every command shares. ## Connection strings Every data-moving command takes a source and target driver + DSN: Engine · Driver name · DSN format · MySQL · mysql · user:pass@tcp(host:3306)/dbname · MariaDB · mariadb · MySQL DSN shape (user:pass@tcp(host:3306)/dbname) against a MariaDB server — a MySQL-family flavor with binlog / domain-GTID CDC (v0.99.271). Use this driver, not mysql; sluice fingerprints the server and steers you on a mismatch. · Postgres · postgres · postgres://user:pass@host:5432/dbname?sslmode=require · PlanetScale · planetscale · MySQL DSN against the PlanetScale host (TLS required). · Vitess (self-hosted) · vitess · MySQL DSN against vtgate — the self-hosted Vitess flavor (VStream CDC; warm-resume since v0.99.44). · SQLite · sqlite · A file path (./app.db) or a wrangler d1 export .sql dump (auto-detected). Migrate source and target (no CDC). · Cloudflare D1 · d1 · d1:/// (or d1:// + CLOUDFLARE_ACCOUNT_ID); token via the env var CLOUDFLARE_API_TOKEN (never a flag). Migrate source. · Postgres (slot-less) · postgres-trigger · Same as postgres; pairs with trigger setup. · SQLite / D1 (CDC) · sqlite-trigger / d1-trigger · Trigger-based continuous CDC over a SQLite file / live D1; pair with trigger setup --source-driver. · ## Environment variables Keep credentials out of your shell history by passing DSNs via the environment: Variable · Equivalent flag · SLUICE_SOURCE · --source · SLUICE_TARGET · --target · ## YAML config file For anything beyond a handful of flags, pass a YAML file with --config / -c. CLI flags take precedence over config values. Common keys: # sluice.yaml include_tables: ["app_*"] exclude_tables: ["app_audit"] # force target column types (CLI: --type-override) mappings: - column: products.attrs type: jsonb binary: true # replace generated-column bodies verbatim (CLI: --expr-override) expression_mappings: - column: orders.total_cents expression: "(price_cents * qty)" # PII redaction (CLI: --redact) redactions: - rule: users.email=hash:sha256 - rule: users.ssn=mask:ssn # dictionaries referenced by tokenize:dict / randomize:dict strategies dictionaries: first_names: values: ["Alex", "Sam", "Jordan"] Then run, for example: sluice migrate -c sluice.yaml --source-driver mysql --source ... --target-driver postgres --target ... ## Global flags These apply to every command: Flag · Default · Purpose · --config, -c · — · Path to a YAML config file. · --log-level, -l · info · Verbosity: debug / info / warn / error. · --log-format · text · text or json — one JSON object per line, for Loki / Datadog / CloudWatch ingestion of a long-running sync. (v0.99.31) · --pprof-listen · off · Bind net/http/pprof at an address to diagnose stalls (e.g. :6060). · --mysql-sql-mode · strict · Override sluice's forced strict sql_mode. Pass '' (empty) to migrate legacy MySQL data with zero-dates. · --zero-date · error · How to carry MySQL zero / partial dates (0000-00-00, YYYY-00-DD, YYYY-MM-00): error refuses loudly naming the column; null carries them as NULL (itself refused on a NOT NULL column); epoch substitutes 1970-01-01. A silent-loss-class control — the default is the safe one. · --sqlite-date-encoding · iso · How a SQLite / D1 source decodes columns declared date/time (SQLite has no native temporal storage): iso reads ISO-8601 TEXT; unixepoch / unixmillis read INTEGER/REAL unix seconds/milliseconds; julian reads a REAL/INTEGER Julian day. A value whose storage class doesn't match is refused loudly naming the row — never a silently-wrong date (use --type-override =text to carry an outlier raw). Per-source override: ?sqlite_date_encoding=… on the source DSN. · --stage-dir · system temp · Directory for sluice's large scratch files: the csv/tsv/ndjson staged SQLite copy (roughly the source file's size), the D1 --stage-local replica, and the backup export-as-parquet per-table scratch. Override on hosts whose /tmp is a small tmpfs — the ADR-0145 hazard class. The directory must already exist (a missing path is refused loudly). Env: SLUICE_STAGE_DIR. (The sqlite .sql-dump materialize path does not honor it yet.) (v0.99.259) · --max-memory · off · Soft ceiling on the Go heap (e.g. 2GiB, 512MiB), applied via SetMemoryLimit at startup to bound RSS. Unlike --max-buffer-bytes (raw buffered bytes only), this bounds the whole heap. Honors the GOMEMLIMIT env var when unset. (v0.99.10) · --version, -V · — · Print version and exit. · Migrating legacy MySQL data? sluice forces a strict sql_mode on every MySQL connection to close the silent-clamp / silent-zero-date class. Data that was only accepted under a relaxed mode (pre-5.7 zero-dates, silently-truncated values) will refuse loudly — pass --mysql-sql-mode='' to fall through to the server default. Zero / partial dates specifically are governed by --zero-date (default error): use --zero-date=null to carry them as NULL or --zero-date=epoch to substitute 1970-01-01 rather than refusing. ## Source-DSN tuning parameters A handful of throughput / observability knobs are passed as query parameters on the source DSN rather than as CLI flags — they are engine-specific and parsed inside the engine, so they're stripped before reaching the database session. Append them to the source connection string (e.g. ...&vstream_copy_table_parallelism=4). Parameter · Applies to · Purpose · copy_table_parallelism=N · native MySQL source · ADR-0101/0102 (v0.99.70–71): cold-copy N tables concurrently under one FTWRL window, each a consistent-snapshot reader. Composes with --copy-fanout-degree for W×D total write concurrency. Absent / 0 / 1 = the serial single-snapshot path. Falls back to serial (loud WARN) without the RELOAD privilege. · vstream_copy_table_parallelism=K · VStream (PlanetScale / Vitess) source · ADR-0099/0100 (v0.99.67/69): open K concurrent COPY streams over a disjoint table partition for the cold-copy. Absent / 0 / 1 = serial. Not auto-clamped into the connection-budget preflight — the operator must keep K × D ≤ --max-target-connections (sluice WARNs naming the contract). · vstream_copy_single_stream=true · VStream source · ADR-0095 (v0.99.63): opt out of the auto-shard VStream COPY and restore the legacy single interleaved stream (and its ADR-0071 memory-refusal floor). Auto-shard is on by default for a fresh cold-start of more than one table. · vstream_idle_warn_timeout=DUR · VStream source · v0.99.43: tune the idle-stall WARN that fires when the source is alive (heartbeats flowing) but sending no change events — the throttled-or-idle signal. Default 30s; 0 disables the WARN only (the hard liveness/progress guards are unaffected). · ## Prometheus /metrics export Pass --metrics-listen ADDR to sync start (or metrics-watch) to bind a Prometheus-format /metrics endpoint (plus /readyz on sync start) for the life of the process. Beyond the stream's apply/throughput counters it exports: - sluice_build_info{version,commit,go_version} — a constant-1 gauge carrying the build metadata. - A Go-runtime block — sluice_go_goroutines, sluice_go_gomaxprocs, heap (sluice_go_memstats_heap_*), and GC stats. - The sluice_target_* gauge family — target CPU / memory / storage utilisation and replica lag — when PlanetScale telemetry is configured (--planetscale-org + the metrics-token flags). Without telemetry these gauges are simply absent. ## What sluice creates in your databases To make migrations resumable and continuous sync durable, sluice writes a small, predictable set of sluice_-prefixed bookkeeping objects — state tables on the target, and a replication slot / publication / triggers on the source. They're excluded from schema diff and verify, so they never look like drift. For the full inventory — what each object is, when it appears, and how to remove it — see Objects sluice creates. =============================================================== # Objects sluice creates in your databases (https://sluicesync.com/docs/database-objects/) The full inventory of sluice's bookkeeping tables, slots, publications, and triggers — what each is for, when it appears, and how to remove it. To make migrations resumable and continuous sync durable, sluice creates a small, predictable set of bookkeeping objects in your source and target databases. Every one is prefixed sluice_ so you can always find them, and the schema readers exclude them from schema diff and verify (ADR-0029) so they never register as drift or count against a row comparison. Nothing here is hidden — this page is the complete list of what sluice writes, which command writes it, why, and how to clean it up. Where they live. On Postgres targets the bookkeeping tables are created in the target DSN's schema parameter (default public) — they follow --target-schema, they are not hardcoded to public. On MySQL targets they live in the connection's default database. The source-side object names (sluice_slot, sluice_pub, sluice_heartbeat) are defaults and all overridable. Every object below is created idempotently (IF NOT EXISTS / CREATE OR REPLACE), so a re-run never errors on an existing one. ## Target database — bookkeeping tables These hold the state that makes migrate --resume and sync start warm-resume work. They persist between runs by design (that's the durable resume frontier); the only built-in way to drop them is the destructive --reset-target-data recovery path, which clears the relevant state and the tables sluice manages. Object · Created by · When & why · Cleaned up by · sluice_cdc_state · sync start · At CDC stream open. One row per --stream-id: the durable CDC source position, slot name, source-DSN fingerprint, and stop flag — the warm-resume frontier. · --reset-target-data (clears the row); otherwise persists. · sluice_migrate_state · migrate · At bulk-copy start. One header row per --migration-id for resumable bulk migration (ADR-0082). · --reset-target-data; otherwise persists. · sluice_migrate_table_progress · migrate · At bulk-copy start. One row per table — per-table progress / keyset checkpoint so --resume picks up mid-copy (ADR-0082). · --reset-target-data; otherwise persists. · sluice_cdc_schema_history · sync start · At CDC stream open; rows written only at a real DDL/schema-delta boundary. Position-anchored schema versions so each event decodes in the schema in effect at its position — resume-after-DDL without a re-snapshot (ADR-0049). Grows with DDL count (tiny). · Compacted on demand below the retention floor by backup prune; --reset-target-data. · sluice_target_metrics_history · sync start (telemetry only) · Only when PlanetScale telemetry is configured (--planetscale-org). A bounded rolling history of polled target-health snapshots (CPU/mem/storage/lag) so diagnose can show the recent trend (ADR-0107). Advisory — never affects the sync. · Rows auto-pruned to a rolling window; table via --reset-target-data. Disable with --suppress-target-metrics-history. · sluice_shard_consolidation_lease · sync start (consolidation only) · Only when consolidating a multi-shard Vitess/PlanetScale source onto one target with cross-shard DDL coordination (ADR-0054). One row per consolidated table records which shard-stream owns applying a coordinated DDL. · Lease rows GC-swept automatically; table via --reset-target-data. · --reset-target-data is destructive: it clears the relevant state row(s) and drops every source-schema table sluice manages on that target, then cold-starts. Other tables on the target are untouched. See the migrate reference and ADR-0023. ## Source database — Postgres logical CDC The native postgres CDC engine reads the WAL through a logical replication slot. It creates two persistent server objects plus two optional/transient ones. Full operational detail — failover, slot invalidation, sizing — is in the Postgres source-prep guide. Object · Kind · When & why · Cleaned up by · sluice_slot · replication slot · Created lazily on the first CDC connect (cold-start). Pins WAL and holds the resume LSN (confirmed_flush_lsn). pgoutput plugin; failover-aware on PG 17+. · Never auto-dropped — explicit sluice slot drop . (Auto-dropped only if cold-start setup itself fails.) · sluice_pub · publication · Ensured on demand when missing, by migrate and sync start. Defines the table set pgoutput streams — scoped FOR TABLE … by default (ADR-0021), FOR ALL TABLES for multi-schema CDC. · No dedicated command — manual DROP PUBLICATION (a DROP SCHEMA won't remove it). sluice rescopes/recreates it itself. · sluice_heartbeat · table · Opt-in via --source-heartbeat-interval (default off). A periodic INSERT generates WAL so the consumer position keeps advancing on an idle source — preventing slot-invalidation / binlog-purge silent loss. Also created on a MySQL source under the same flag. · Rows auto-pruned (--source-heartbeat-prune-window, default 1h); the table itself is left in place — drop manually. · sluice_backup_anchor_ · temporary slot · Created by backup at snapshot start to pin a consistent export point for the run. · Transient — the server auto-drops it when the session closes (even on crash). Legacy leaked anchors are auto-swept on the next backup. · MySQL source: native MySQL CDC reads the binlog and creates nothing on the source except the opt-in sluice_heartbeat table above — there is no slot or publication concept. ## Source database — trigger-based CDC The slot-less trigger engines capture changes with database triggers instead of a log stream. trigger setup installs every object below; trigger teardown removes all of them (pass --keep-data to retain the change-log for forensics), and trigger prune reaps applied change-log rows. They live in the source schema (--schema, default public on Postgres). ### Postgres trigger engine (postgres-trigger, ADR-0066) Object · Kind · Why · sluice_change_log + sluice_change_log_meta · tables (+ indexes) · Append-only captured-change log (txid, op, PK + before/after JSONB) and a singleton schema-version pin. · sluice_capture_change(), sluice_capture_truncate_fn(), sluice_capture_ddl() · functions · Row-capture (payload mode set by --capture-payload), TRUNCATE companion, and the DDL event-trigger handler. · sluice_capture, sluice_capture_truncate (per table); sluice_capture_ddl_trg · triggers · One combined AFTER INSERT/UPDATE/DELETE trigger and a TRUNCATE trigger per table, plus one cluster DDL event trigger. · ### SQLite / Cloudflare-D1 trigger engines (sqlite-trigger / d1-trigger, ADR-0135/0136) Object · Kind · Why · sluice_change_log + sluice_change_log_meta · tables · Captured-change log with a monotonic id watermark, and a schema-version pin. · sluice_change_log_columns · table · Captured-column fingerprint — since SQLite/D1 have no DDL triggers, a source ALTER is caught here and sync start refuses loudly rather than dropping a new column silently. · sluice_capture_
_ · triggers · Three per table (SQLite has no combined-event trigger form), each writing into the change-log. · The two families differ in trigger naming: postgres-trigger uses one combined trigger literally named sluice_capture per table, whereas sqlite-trigger/d1-trigger use three separate sluice_capture_
_ triggers. Both are fully removed by trigger teardown. ## Cleanup quick reference Command · Removes · sluice slot drop · The PG source replication slot (the one object sluice never drops on its own). · sluice trigger teardown · Every trigger-engine object on the source; --keep-data retains the change-log. · sluice trigger prune / backup prune · Old change-log rows / below-floor sluice_cdc_schema_history rows (the tables stay). · sluice sync start --reset-target-data · The target bookkeeping state + every source-schema table sluice manages on the target (destructive recovery). · manual · sluice_pub (DROP PUBLICATION), and the sluice_heartbeat table once heartbeats are no longer needed. · =============================================================== # Migrate MySQL → Postgres (https://sluicesync.com/docs/migrate-mysql-to-postgres/) The flagship first migration: connect, preview the plan, copy the data, and verify it landed. A one-shot migrate translates the source schema, creates the target tables, bulk-copies the rows, then builds indexes and constraints — in that order, so the bulk load runs against constraint-free tables and finishes fast. This guide walks MySQL → Postgres end to end, but the same shape works in all four directions (just swap the --source-driver / --target-driver pair). Reach for migrate when you can take a short write-freeze on the source; if you need a zero-downtime cutover, run the continuous-sync flow instead — but even then a clean migrate is the fastest way to learn how sluice translates your schema. Before a production cutover, freeze writes on the source (or accept that rows written during the copy won't be captured — migrate is a point-in-time copy, not a stream). To keep writes flowing throughout, use continuous sync. ## 1. Point sluice at both databases Source and target are each a driver name plus a DSN. Because DSNs carry credentials, pass them through the environment to keep them out of your shell history: export SLUICE_SOURCE='root:rootpw@tcp(localhost:3306)/app' export SLUICE_TARGET='postgres://postgres:pgpw@localhost:5432/app?sslmode=require' sluice engines # confirm 'mysql' and 'postgres' are registered The MySQL DSN is user:pass@tcp(host:3306)/dbname; the Postgres DSN is a postgres:// URL. See Configuration for every engine's DSN format. ## 2. Dry-run the plan first --dry-run (-n) reads the source schema and prints exactly what sluice would do — tables, row estimates, the translated types — without touching the target. Always do this first: sluice migrate \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --dry-run For the actual target DDL sluice will emit — column by column, with cross-engine translation notes — use schema preview. That's where you'll catch a type you want to steer with --type-override before any data moves. ## 3. Run the migration When the plan looks right, drop --dry-run: sluice migrate \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" sluice copies each table, then builds secondary indexes and constraints in deferred phases. On large schemas it copies several tables at once and splits big tables into parallel chunks automatically — see --table-parallelism / --bulk-parallelism in the migrate reference if you want to tune the connection budget. Cold-start safety. sluice refuses to bulk-copy into a non-empty target by default — an INSERT into a populated table would collide on the primary key. That refusal is the safety net, not an error to suppress: it means the target already has data. Start from an empty target, or see the recovery flags below. ## 4. If it's interrupted, resume Migration state is checkpointed per table on the target. If a run dies partway (network blip, OOM, Ctrl-C), re-run the identical command with --resume (-r) and it continues from the last committed checkpoint rather than starting over: sluice migrate \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --resume To deliberately start clean over an already-populated target, --reset-target-data drops the source-schema tables on the target and re-copies (it prompts for a typed reset confirmation unless you add --yes). It's mutually exclusive with --resume. ## 5. Verify the copy Once the migration finishes, confirm source and target agree. verify compares per-table row counts by default and returns a non-zero exit code on any mismatch (CI-friendly): sluice verify \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --depth count For content checking — not just counts — escalate to --depth sample (per-table sampled-row content hashes; ~99% confidence on a 5%+ corruption rate). See the validate guide for the depth ladder. ## Migrating legacy MySQL data? sluice forces a strict sql_mode on every MySQL connection to close the silent-clamp / silent-zero-date class of corruption. Data that was only storable under a relaxed mode — pre-5.7 zero-dates (0000-00-00), silently-truncated values — will refuse loudly rather than land subtly wrong. That's deliberate. Two knobs let you decide how to carry it: - --zero-date=null carries zero/partial dates as NULL (refused on a NOT NULL column), or --zero-date=epoch substitutes 1970-01-01. - --mysql-sql-mode='' (explicit empty) falls all the way through to the server's default sql_mode for the broadest legacy tolerance. Both are global flags — see Configuration for the full discussion. =============================================================== # Preview & validate before you migrate (https://sluicesync.com/docs/preview-and-validate/) See the exact target DDL, steer the type translation, and confirm the copy — without guessing. sluice is correctness-first: it would rather refuse loudly than land data subtly wrong. The flip side is that you can see everything it intends to do before it does it. This guide covers the three read-only inspection commands — schema preview, schema diff, and verify — plus --type-override, the one knob that steers translation when you disagree with a default. Reach for this guide whenever a migration touches types you care about (money, JSON, UUIDs, SQLite's untyped columns) or before any production cutover. ## 1. Preview the target DDL schema preview reads the source schema, runs the full cross-engine translation, and prints the exact DDL the target engine would emit — with advisory notes on anything non-obvious. It never connects to the target to write; the target DSN is only used to construct the right dialect's writer: sluice schema preview \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" Scope it with --include-table / --exclude-table (glob-aware), write it to a file with -o ddl.sql, or get machine-readable output with --format json. This is where you eyeball how each MySQL type maps to Postgres before a single row moves. ## 2. Steer a type with --type-override When you disagree with a default mapping — say you want a MySQL JSON column to land as Postgres jsonb rather than json, or a free-form column forced to text — override it per column. The format is TABLE.COLUMN=TYPE, repeatable, and it's accepted by preview, migrate, and sync start alike, so you can preview the override and then migrate with the identical flag: sluice schema preview \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --type-override products.attrs=text \ --type-override events.payload=jsonb For target-type options that need more than a type name — e.g. jsonb with binary: true — use the YAML mappings: block instead of the CLI flag (the CLI form takes a bare type). See Configuration → YAML config. ## SQLite & D1: declared types vs stored affinity SQLite (and Cloudflare D1) don't store strict types — a column has a declared type but values live under SQLite's loose affinity rules. sluice infers the target type from the declared type, but two cases need an explicit decision because guessing would risk a silently-wrong value: - Dates & times. A column declared DATE / DATETIME / TIMESTAMP / TIME could hold ISO text, unix seconds/millis, or a Julian day. You name the encoding with --sqlite-date-encoding (iso default, or unixepoch / unixmillis / julian). A value whose storage class doesn't match is refused loudly, naming the row — never coerced to a wrong date. - Outliers. If one column genuinely holds raw text you don't want interpreted, carry it as-is with --type-override =text. Preview an import the same way you'd preview any source — point --source-driver at sqlite or d1 — to see the resolved target types before committing. Full detail is in the SQLite/D1 guide. ## 3. Diff a live target against the source When the target already exists — a previous migration, an Atlas/Liquibase-managed schema, a hand-built warehouse — schema diff compares what's actually on the target against what sluice would produce from the source, and reports the drift with copy-paste DDL suggestions. It exits non-zero on any difference, so it gates cleanly in CI: sluice schema diff \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" ## 4. Verify the data landed After a migration (or once a sync has caught up), verify compares the data itself. It has a depth ladder — start cheap, escalate only if you need stronger guarantees: Depth · What it does · --depth count (default) · Per-table row-count comparison. Fast, catches whole-table and bulk-row loss. · --depth sample · Counts plus per-table sampled-row content hashes — ~99% confidence of catching a 5%+ corruption rate at the default 100 rows/table. Raise --sample-rows-per-table for rarer anomalies; --strict-hash switches the row hash from MD5 to SHA-256. · # fast count check, CI-gated (non-zero exit on mismatch) sluice verify --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" --depth count # content sampling with a larger sample sluice verify --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --depth sample --sample-rows-per-table 500 Both modes accept --format json and -o FILE for piping into a CI gate or alertmanager. A full per-row hash mode is planned but not yet shipped. =============================================================== # Zero-downtime migration with continuous sync (https://sluicesync.com/docs/zero-downtime-cutover/) Cold-start the data, let CDC catch up while the app keeps writing, then cut over in a brief, controlled window. A one-shot migrate is a point-in-time copy: rows written after it starts are missed. sync start closes that gap — it takes a consistent snapshot, bulk-copies it, then streams ongoing changes (change-data-capture) so the target tracks the source live. That lets you keep the application running on the source the whole time and flip traffic over in a short, controlled window. This is the core "sync, not just migrate" workflow; reach for it whenever downtime isn't acceptable. Source prerequisites. CDC reads the source's native change stream. Postgres needs logical replication (a replication slot + REPLICATION role); MySQL needs the binlog (ROW format). On a managed Postgres that blocks slots (Heroku, some RDS tiers), use the slot-less trigger engine instead — sluice refuses loudly rather than silently degrading to polling. ## 1. Start the stream A stream is identified by --stream-id so it can resume after a restart. The first launch cold-starts (snapshot → bulk copy), then transitions seamlessly into live CDC and keeps running until you stop it: sluice sync start \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --stream-id app-prod Restarting with the same --stream-id warm-resumes from the persisted position — it does not re-run the snapshot. To run it as a long-lived service with a health endpoint and an idle-source heartbeat (so the slot/binlog can't be evicted past the consumer during quiet periods), add --metrics-listen :9090 and --source-heartbeat-interval 30s; see running as a service and the sync start reference. ## 2. Watch it catch up From another shell, check the stream's position and freshness. sync health returns a cron-friendly exit code so you can script "are we caught up yet?": sluice sync status --stream-id app-prod --target-driver postgres --target "$SLUICE_TARGET" # exit non-zero if the last apply was more than 5s ago sluice sync health --stream-id app-prod --target-driver postgres --target "$SLUICE_TARGET" \ --max-stale-seconds 5 Once sync health reports fresh under a tight threshold, the target is tracking the source within seconds — you're ready to cut over. (On a PG→PG pair, also pass --source-driver/--source to expose --max-lag-bytes for byte-distance lag.) ## 3. Quiesce and drain At your chosen cutover moment, stop writes to the source application (the brief window), then drain the last in-flight changes. sync stop --wait blocks until the streamer has applied everything queued and exited cleanly: sluice sync stop --stream-id app-prod \ --target-driver postgres --target "$SLUICE_TARGET" \ --wait --timeout 10m On timeout the CLI exits non-zero and the stop request stays in place — so a scripted cutover fails safe rather than proceeding on a half-drained target. ## 4. Prime sequences (cutover) CDC replicates row changes, not catalog-level sequence positions. So after the drain, the target's SERIAL / AUTO_INCREMENT counters can lag behind the IDs that already exist in its rows — and the first post-cutover INSERT would collide on the primary key. cutover closes that gap: it re-reads the source sequence state and applies it to the target with a safety margin: sluice cutover \ --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --sequence-margin 1000 cutover is idempotent and fails safe: a re-run within the margin reports every table as noop, and if the target's sequence is already ahead of the source by more than the margin it refuses (exit code 2) rather than risk a collision — the signal that something already wrote to the target. Run it after the drain and before pointing application traffic at the target. ## 5. Verify, then flip traffic Confirm the data agrees, then point your application at the target: sluice verify --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" --depth count The full sequence: start the stream → wait for fresh → freeze source writes → sync stop --wait → cutover → verify → repoint the app. Only the last three steps fall inside the write-freeze window, so downtime is measured in seconds-to-minutes, not the length of the copy. ## 6. Rolling back a cutover The safety net for a cutover is a reverse sync. Until you're confident in the new database, keep the old primary intact — do not drop or repurpose it. If something goes wrong after you flip traffic and you must fail back, you point the application at the old database again — but any writes the new database took while it was live need to travel back the other direction. You can't naively cold-start a reverse sync (new → old) to carry them: the old database still holds all its original rows, so it isn't empty, and a fresh cold-start refuses loudly rather than bulk-copy into a populated target — SLUICE-E-COLDSTART-TARGET-NOT-EMPTY. That refusal is the guard working as designed; you don't want a full re-copy, you want just the delta. Two ways to be ready for it: - Run the reverse stream from the start (recommended for true reversibility). Right after the forward cutover, start a second stream in the opposite direction with its own --stream-id, so the old database keeps tracking the new one continuously. Failing back is then just a traffic flip plus a cutover on the old side to re-prime its sequences — no re-copy, no refusal. - Reconcile the delta manually. If you didn't keep a reverse stream running, resync the window of new-database writes back to the old database the other direction and verify before re-flipping traffic — rather than forcing a cold-start into the non-empty old database. Why the old primary must survive the window. The reverse path only exists while the old database is still there and consistent. Dropping it immediately after cutover throws away your rollback option; keep it until the new database has proven itself, then decommission. Schema changes during a long-running sync. By default a stream forwards unambiguous source DDL (ADD/DROP/ALTER COLUMN, CREATE/DROP INDEX, …) onto the target automatically so it stays online through schema evolution — including a destructive DROP COLUMN. To gate DDL through a separate change process, start with --schema-changes=refuse. See the warning box in the sync start reference. =============================================================== # Staged (wave) migration — a few tables at a time (https://sluicesync.com/docs/staged-wave-migration/) Move your biggest or most self-contained tables first, cut them over, then bring the rest across in later waves. Not every migration wants to be one event. On a large database it is often safer to move the biggest or most self-contained tables first, cut the application over for just those, let them bake in production for a while, then bring the rest across in later waves. Each wave is small enough to reason about, and a problem in wave 3 doesn't implicate the tables that have been running fine since wave 1. sluice supports this today. This guide covers the two mechanisms, which one to use on which source engine (they are not interchangeable), how foreign keys constrain your wave ordering, and the one thing sluice deliberately does not do: replicate writes back from the target to the source. ## Two mechanisms · One growing stream · Several independent streams · Shape · a single --stream-id whose table scope expands · one --stream-id per wave, running side by side · Add a wave with · sluice schema add-table · another sluice sync start · Cut over · all tables together, at the end · per wave, independently · Postgres source · supported · unsafe today — see the caveat below · MySQL / PlanetScale / Vitess source · supported · supported · The distinction that matters is whether you need to cut waves over independently. If the point of staging is to de-risk the copy — everything lands eventually, in one cutover, but you'd rather not snapshot 4 TB in a single run — use one growing stream. If the point is to de-risk the cutover — wave 1 serving production traffic from the target while wave 2 is still copying — you need independent streams, and today that means a MySQL-family source. ## Mechanism A — one growing stream Start a stream scoped to your first wave, then extend its scope as you go. The stream keeps one CDC position, one slot, one control-state row. sluice sync start \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --include-table 'orders,order_items' \ --stream-id appdb Later, bring users into the same stream. On a Postgres source this works without draining the running stream: sluice schema add-table users \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --stream-id appdb --no-drain schema add-table creates the table on the target, bulk-copies its rows from a consistent snapshot, extends the source publication, and hands the table to the running CDC stream — the same gapless snapshot to CDC boundary a cold start gets. It prompts for typed confirmation (the table name) unless you pass --yes. --no-drain is Postgres-only in this release. On a MySQL-family source, use the drained workflow: sync stop --wait, then schema add-table, then sync start again. Re-running sync start with the same --stream-id warm-resumes from the persisted position — it does not re-snapshot. One table per invocation; repeat it per table or script the loop. On a Postgres source this is the mechanism that grows scope safely — it extends the publication additively, so it can't disturb tables already in scope. ## Mechanism B — several independent streams Each wave gets its own --stream-id, its own CDC position, and — on engines with a replication-slot concept — its own --slot-name. The waves are fully independent: you cut wave 1 over and stop its stream while wave 2 is still snapshotting. # Wave 1 — cut over first, weeks before the rest. sluice sync start \ --source-driver mysql --source "$SRC" \ --target-driver postgres --target "$DST" \ --include-table 'orders,order_items' \ --stream-id wave1 # Wave 2 — started later, runs alongside wave 1. sluice sync start \ --source-driver mysql --source "$SRC" \ --target-driver postgres --target "$DST" \ --include-table 'users,sessions' \ --stream-id wave2 --include-table is comma-separated, repeatable, and glob-aware (audit_*), and it scopes both legs — the cold-start snapshot and the live CDC apply. CDC state is keyed per stream, so waves never contend for position. On a MySQL-family source each stream opens its own binlog / VStream reader and there is no shared server-side object to collide over. On Postgres, give each stream its own --slot-name — without it every stream lands on the default sluice_slot and they collide. sluice prepends sluice_ if your name doesn't already start with it, so every slot stays findable: sluice sync start ... --stream-id wave1 --slot-name wave1 # creates slot "sluice_wave1" Postgres sources: don't run concurrent streams with different table scopes. The replication slot is per-stream, but the publication is not — every Postgres-source stream shares a single publication (sluice_pub) and scopes it to its own table list at cold start, replacing the whole set. Cold-starting a second wave against the same source therefore drops the first wave's tables out of the publication: its slot stays healthy and keeps advancing, but it silently replicates nothing from that moment on. Until this is addressed, on a Postgres source use Mechanism A, or run waves strictly sequentially — fully stopping one wave's stream before cold-starting the next. MySQL, PlanetScale, and Vitess sources are unaffected (they have no publication). The fix is designed in ADR-0175. ## Foreign keys decide your wave ordering This constrains wave composition more than table size does. A wave-1 table with a foreign key pointing at a wave-2 table cannot have that constraint created — the referenced table isn't on the target yet. Two ways through: - Order waves along the FK dependency edges. Parents before children. This is the clean answer when the graph allows it, and it is worth drawing the graph before you pick waves. - Use --skip-foreign-keys when it doesn't. Cyclic dependencies, or a wave you can't reorder, need the escape hatch. It creates no FK constraints on the target and — importantly — synthesizes a backing index on each skipped FK's referencing columns, so the join performance the FK's index was providing doesn't quietly regress while you wait for the later wave. Every skipped constraint is named in the run's output; add them back after the final wave lands. This is a different situation from row-level filtering, which orphans children of rows that were filtered out and refuses loudly. See Copy a subset of tables if you're staging by rows rather than by tables. ## Cutting a wave over Per wave, the sequence is the standard cutover scoped to that wave's tables: # 1. Prime the target's identity columns past the source's high-water mark. sluice cutover \ --source-driver mysql --source "$SRC" \ --target-driver postgres --target "$DST" \ --include-table 'orders,order_items' \ --sequence-margin=1000 # 2. Flip application traffic for these tables. (Your step, not sluice's.) # 3. Drain and stop this wave's stream. sluice sync stop \ --target-driver postgres --target "$DST" \ --stream-id wave1 --wait # 4. Prove it landed. sluice verify \ --source-driver mysql --source "$SRC" \ --target-driver postgres --target "$DST" \ --include-table 'orders,order_items' --depth=sample cutover takes --include-table too, so a wave's sequences get primed without touching tables that are still source-authoritative. Skipping it is the classic staged-migration failure: application writes to the target start allocating IDs that collide with rows CDC is about to deliver. Use --wait on sync stop. Without it the stop is asynchronous and late in-flight changes may not have landed. ## Split-brain is yours to prevent sluice doesn't own the traffic switch — the cutover moment is application-specific. The consequence is that sluice cannot tell whether a wave's tables are still being written on the source. If writes keep landing on the source after you've cut a wave over, CDC faithfully replicates them on top of the application's writes to the target. Per row, last writer wins, and nothing surfaces as an error — this is the one place in a staged migration where you can lose data quietly. The stream is doing exactly what you asked; the problem is upstream of it. The only real protection is on the source side, at the moment of cutover: revoke INSERT/UPDATE/DELETE on the wave's tables from the application role, install a rejecting trigger, or take the source write path out of the application entirely for those tables. "We updated the config and believe nothing writes there any more" is not protection — make the source refuse. ## What sluice does not do: write-back to the source Some managed-import products keep a reverse stream running after cutover, so writes landing on the new database replicate back to the old one and you can fail back if the new database can't take the load. sluice has no equivalent today, and it isn't a small gap. The blocker is that sluice's CDC apply path carries no origin marker — nothing says "this change is one I applied, don't re-emit it." A reverse sync start running alongside a forward one is therefore a replication loop. There is a narrower version the wave design makes tractable, worth understanding even though it is not a supported feature: once a wave is cut over and its forward stream is stopped, nothing is streaming those tables source to target any more, so a reverse stream scoped to exactly that wave's tables is table-disjoint from every live forward stream and isn't a loop. What still stands in the way: the source's identity columns would need priming past the target's; a cross-engine reverse needs a reverse-direction schema that round-trips, which the forward migration doesn't guarantee; and it has never been tested. It is a plausible composition of shipped primitives, not a validated path. If you need genuine fail-back today, keep the source authoritative until you're confident: cut a wave's reads over first, leave writes on the source, and move writes only once the target has proven itself under real read load. That captures most of the risk reduction without a reverse stream. ## Choosing waves: a checklist - Draw the FK graph. It constrains ordering more than size does. Parents before children; note the cycles that will need --skip-foreign-keys. - Prefer self-contained clusters. A wave whose tables reference only each other cuts over cleanly and can be verified in isolation. - Put the scariest table in wave 1, not last. The point of staging is to learn early, on the table most likely to surprise you, while the blast radius is smallest and rollback is still just "keep using the source." - Confirm your source engine supports the mechanism you want. Independent per-wave cutover needs Mechanism B, which needs a MySQL-family source today. - Decide the write fence per wave before you start, not at the cutover window. - Budget the stream count. Each concurrent wave is a full CDC reader (and on Postgres, a slot). A handful is fine; dozens is not a design, it's a load test. ## Next steps - Zero-downtime migration — the single-wave cutover flow this guide generalizes. - Copy a subset of tables — scoping with --include-table in depth, including cross-engine namespace mapping. - Schema changes during a sync — schema add-table in the broader schema-evolution context. - Migrate many databases or schemas — staging by namespace instead of by table. - Verify & reconcile — proving each wave landed before you start the next. =============================================================== # Import SQLite or Cloudflare D1 (https://sluicesync.com/docs/import-sqlite-d1/) Move a SQLite file, a wrangler D1 export, or a live Cloudflare D1 into Postgres or MySQL — losslessly. sluice imports SQLite and Cloudflare D1 into Postgres or MySQL through the same migrate pipeline as everything else — parallel copy, cross-engine type translation, deferred indexes, --dry-run, verify — with no pgloader or external tool. Reach for this when you're graduating a SQLite/D1 app onto a server database, or pulling D1 data into an analytics warehouse. There are three source shapes; pick by what you have. ## A local SQLite file Point --source-driver sqlite at the file (a bare path, a file: URI, or a sqlite:// URL — opened read-only) and migrate as usual: sluice migrate \ --source-driver sqlite --source ./app.db \ --target-driver postgres --target 'postgres://user:pass@host:5432/db?sslmode=require' Large tables parallel-copy automatically (PK-range / keyset chunks, tuned by --bulk-parallelism), the same as any other source. A .db file is read byte-exact — sluice reads the int64 straight from the file. ## A wrangler D1 export (.sql dump) wrangler d1 export emits a .sql text dump. sluice ingests one directly — it sniffs the file's header, materializes the dump in-process (no sqlite3 CLI), and auto-skips D1's internal _cf_* tables. So the import is two commands: wrangler d1 export --remote --output dump.sql sluice migrate --source-driver sqlite --source dump.sql \ --target-driver postgres --target '' The export rounds big integers. Both of D1's default extraction paths — the wrangler d1 export dump and the bare query API — silently lose integers larger than 253 (≈ 9 ×1015): D1 serializes them through a JavaScript double before sluice ever sees them. For Snowflake-style IDs (Discord/Twitter 64-bit IDs), nanosecond timestamps, or large counters, use the live query-API reader below — it's the only lossless path. For a D1 database without integers that large (the common case), the export path is exact and simple. ## A live Cloudflare D1 (lossless) --source-driver d1 reads a live D1 over its HTTP query API and is the lossless import. It projects every column through typeof() + CAST(… AS TEXT) / hex(), so integers above 253 round-trip exactly, INTEGER is distinguished from REAL, and BLOBs decode from hex. Reads don't take D1 offline. (Why the text projection instead of the obvious JSON number? See the field note on 253 as a database boundary — wrangler d1 export rounds big integers through float64 before any database sees them.) The API token is read from the environment only — never a flag, never logged: export CLOUDFLARE_API_TOKEN=... # required export CLOUDFLARE_ACCOUNT_ID=... # optional if the account is in the DSN sluice migrate \ --source-driver d1 --source 'd1:///' \ --target-driver postgres --target '' DSN forms are d1:/// or the short d1:// (account from CLOUDFLARE_ACCOUNT_ID). A missing token, account, or database id is refused loudly at startup, before any request. ## Dates, times, and booleans SQLite and D1 have no native temporal or boolean storage. sluice maps a column whose declared type names one (DATE / DATETIME / TIMESTAMP / TIME, BOOL / BOOLEAN) to the right target type, and you tell it how the values are encoded with --sqlite-date-encoding: Encoding · Temporal values stored as · iso (default) · ISO-8601 TEXT, e.g. '2024-01-02 03:04:05' · unixepoch · INTEGER unix seconds · unixmillis · INTEGER/REAL unix milliseconds · julian · REAL/INTEGER Julian day · A value whose storage class doesn't match the chosen encoding — or ISO text matching no layout, or a non-truthy boolean — is refused loudly, naming the row, never a silently-wrong date. Carry a genuine outlier raw with --type-override =text. Preview the resolved types first with schema preview. Column DEFAULT expressions are carried too: a SQLite datetime('now') / CURRENT_TIMESTAMP default becomes the target's CURRENT_TIMESTAMP (date('now')→CURRENT_DATE, time('now')→CURRENT_TIME). A non-portable default expression is dropped with a loud WARN rather than emitted as an expression the target can't evaluate — the column keeps its type, just without the default. ## Richer target types with --infer-types Because SQLite/D1 storage is dynamically typed, the safe default maps a source conservatively and losslessly — INTEGER→bigint, TEXT→text. That never fails and never loses data, but a clean dataset often wants native Postgres types. --infer-types (opt-in, SQLite/D1 source only) promotes INTEGER→boolean, ISO-8601 TEXT→timestamptz/timestamp, JSON TEXT→jsonb, and UUID TEXT→uuid — but only after validating the actual data: sluice migrate \ --source-driver d1 --source 'd1:///' \ --target-driver postgres --target '' \ --infer-types Candidates are picked by name hint (is_*/*_flag; *_at/created/updated; *_json/metadata/payload; *_id/*_uuid) and then each is gated by one aggregate pushed down to the source — a boolean column promotes only if no value is outside (0,1), a UUID column only if every value matches an anchored hex-UUID GLOB, and so on. A *_id holding cus_abc123 fails UUID validation and stays text — the exact case that's a total-data-loss failure under name-only type guessing. Temporal handling is tz-aware (timestamptz only when every value carries an offset, else naive timestamp); a mixed offset/naive column or a sub-microsecond fraction is kept text rather than risk a silent UTC-shift or rounding. A structured report names every promotion (with the validated row count) and every column kept safe. An explicit --type-override always wins. On a live D1, inference stages locally first (automatic). Cloudflare D1's query API rejects the rich-type validation patterns (its GLOB complexity limit), so against --source-driver d1 sluice first replicates the database into a byte-faithful local SQLite file and validates there — engaged automatically when you pass --infer-types (v0.99.167). The staged copy is lossless (exact storage classes, integers above 253 included — unlike wrangler d1 export), so inference sees the original types and decides identically. Pass --stage-local to stage even without inference (a faster local bulk read), or --no-stage-local to force the direct path. A plain D1 migrate without --infer-types streams directly as before. (Not needed for a local SQLite file — it has no such limit.) The war story behind this — a UUID GLOB that passed every local test and died on live D1 with code 7500 — is the field note Cloudflare D1 is not your local SQLite. ## ORM bookkeeping tables An app's ORM keeps its migration state in a bookkeeping table — Rails schema_migrations, Prisma _prisma_migrations, Drizzle __drizzle_migrations, Laravel migrations, Flyway, Goose, and more. That state describes the source engine's schema history and is meaningless — sometimes actively misleading — on a different target engine. On a cross-engine migrate (e.g. D1→Postgres) sluice skips these by default, announcing each skip by name so nothing vanishes silently. Copy them anyway with --include-orm-tables; on a same-engine run they're kept by default (the history is still valid) unless you pass --skip-orm-tables. Recognition is by distinctive name plus a column-shape guard for the generic names (migrations, schema_migrations), so an app table that merely shares a name isn't skipped by accident. ## SQLite as a target SQLite is also a migrate target (--target-driver sqlite) — emit a .db from any source (decimals are stored byte-exact as TEXT affinity, not lossy REAL — see the field note SQLite's DECIMAL is a suggestion for why), e.g. to then run wrangler d1 import. D1 itself is not a write target; produce a SQLite .db and import it with wrangler. sluice migrate \ --source-driver postgres --source '' \ --target-driver sqlite --target ./out.db ## Continuous sync (trigger-CDC) The base sqlite and d1 engines are migrate-only — SQLite has no logical change stream (its WAL is a physical page-log). For continuous sync, sluice captures changes with triggers, via the sqlite-trigger (local file) and d1-trigger (live D1) engines. The lifecycle is explicit — setup → sync → teardown: # 1. install per-table capture triggers + the change-log (each table needs a PRIMARY KEY) sluice trigger setup --source-driver sqlite-trigger --dsn ./app.db --tables=users,orders # 2. cold-start snapshot, then stream changes continuously sluice sync start --source-driver sqlite-trigger --source ./app.db \ --target-driver postgres --target 'postgres://user:pass@host:5432/db?sslmode=require' # 3. remove every trigger + the change-log when done (--keep-data to retain it) sluice trigger teardown --source-driver sqlite-trigger --dsn ./app.db --yes Big integers and BLOBs round-trip exactly through capture and CDC (the trigger encodes each column as a (typeof, text/hex) pair). Enable PRAGMA journal_mode=WAL on a local source so the poller never blocks the app's writes. Because SQLite has no DDL triggers, a source ALTER TABLE isn't auto-captured — re-run trigger setup after a schema change; sync start refuses loudly on schema drift rather than silently dropping a new column. The live d1-trigger path is identical over the HTTP query API (the token is a D1:Edit token); mind D1's per-write billing and the change-log growth — run sluice trigger prune periodically. Full detail: the SQLite/D1 operator doc. =============================================================== # Migrate many databases or schemas at once (https://sluicesync.com/docs/multi-database/) Fan a whole MySQL server or a multi-schema Postgres source out to same-named target namespaces in one run. By default migrate and sync start move the one database (MySQL) or schema (Postgres) named in the source DSN. The multi-namespace flags move all of a server's databases, or all of a Postgres source's schemas, in a single run — snapshot and CDC both — fanning each source namespace out to a same-named target namespace. Reach for this with a multi-tenant MySQL server (one database per tenant), a Postgres database holding several application schemas, or any "migrate the whole server" job. The unifying idea is that a MySQL database is the rough equivalent of a Postgres schema. So there's one internal routing with two spellings: use the --*-database form on a MySQL source and the --*-schema form on a Postgres source. They're synonyms — mixing both spellings in one invocation is a loud error. Flag · Meaning · --all-databases / --all-schemas · Every non-system namespace on the source. · --include-database / --include-schema · Only these (comma-separated, repeatable; glob patterns allowed, e.g. app_*). · --exclude-database / --exclude-schema · Every non-system namespace except these. · Within a form, include / exclude / all are mutually exclusive. System namespaces are always excluded (information_schema, performance_schema, mysql, sys on MySQL; pg_catalog, information_schema, pg_toast, pg_temp* on Postgres). When any namespace-scope flag is set, the source DSN's database/schema is optional — sluice connects to the server (or, on PG, to the database) rather than a single namespace. ## Postgres source: every schema in one run A Postgres database holding sales, billing, inventory → one Postgres target, each schema recreated (auto-created if absent) under its own name: sluice migrate \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require' \ --target-driver postgres --target 'postgres://user:pw@dst/appdb?sslmode=require' \ --all-schemas Continuous sync is identical — just sync start with a --stream-id. Scope with globs, or take everything except a couple: # only the app_* schemas (plus public) sluice migrate ... --include-schema 'app_*,public' # everything except the staging schemas sluice migrate ... --exclude-schema 'scratch,tmp_load' ## MySQL server: every database → Postgres in one run A MySQL server hosting one database per tenant/service → a single Postgres target, each MySQL database recreated as a same-named PG schema (auto-created). Note the source DSN has no database after the / — with --all-databases it's a server connection: sluice migrate \ --source-driver mysql --source 'root:pw@tcp(src:3306)/' \ --target-driver postgres --target 'postgres://user:pw@dst/warehouse?sslmode=require' \ --all-databases MySQL shop / crm / analytics land as PG schemas shop / crm / analytics under warehouse. When the target is also MySQL, each source database is recreated via CREATE DATABASE IF NOT EXISTS — same names, no manual pre-creation. ## Fan-IN: many sources → one target namespace The reverse shape — several independent source databases (e.g. per-microservice MySQL databases) consolidated into one Postgres analytics schema. This isn't a --all-* fan-out; it's N separate runs, each pinned to the same target namespace with --target-schema (Postgres-target-only; it prefixes every emitted object and auto-creates the schema): # service A → warehouse.analytics sluice migrate --source-driver mysql --source 'root:pw@tcp(svc-a:3306)/orders' \ --target-driver postgres --target 'postgres://user:pw@dst/warehouse?sslmode=require' \ --target-schema analytics # service B → the SAME warehouse.analytics (run separately) sluice migrate --source-driver mysql --source 'root:pw@tcp(svc-b:3306)/users' \ --target-driver postgres --target 'postgres://user:pw@dst/warehouse?sslmode=require' \ --target-schema analytics To avoid table-name collisions across services landing in one schema, pair --target-schema with --inject-shard-column NAME=VALUE, which adds a per-source discriminator and a composite PK. See the migrate reference. ## Rename a namespace on the way By default every source namespace lands in a same-named target. To route one to a differently-named target — consolidating legacy_app into app, or namespacing each tenant under a prefix — pass --map-database SRC=DST (MySQL source) or --map-schema SRC=DST (Postgres source), repeatable, for both snapshot and CDC: # MySQL databases shop / crm → PG schemas storefront / sales (analytics keeps its name) sluice migrate \ --source-driver mysql --source 'root:pw@tcp(src:3306)/' \ --target-driver postgres --target 'postgres://user:pw@dst/warehouse?sslmode=require' \ --all-databases \ --map-database shop=storefront \ --map-database crm=sales The spelling rule matches the fan-out flags — --map-database on a MySQL source, --map-schema on a Postgres source; mixing both in one run is a loud error. The rename is purely a target-side routing: source-keyed --redact and --type-override still match on the original source name, so a remap never quietly disables a redaction rule. ## The documented edges - Cross-database / cross-schema foreign keys are refused loudly. A fan-out validates that FK referents are inside the selected set; an out-of-scope FK fails loudly at the deferred FK pass (after the copy), never silently dropped. - Separate Postgres databases are one run each. A PG connection is scoped to a single database, so --all-schemas covers every schema within the connected database; moving N separate PG databases is N runs (one --source DSN each). - PlanetScale-MySQL is a single keyspace and isn't a multi-namespace target — fanning several source databases into one PS-MySQL branch would collapse and collide. PlanetScale-Postgres behaves like regular Postgres and takes --all-schemas fine. - Default routing is same-name; rename with --map-database / --map-schema. Each source namespace lands in a target namespace of the same name unless you remap it (see below). For the fan-IN shape use --target-schema. =============================================================== # Copy a subset of tables (cross-engine, with continuous sync) (https://sluicesync.com/docs/copy-table-subset/) Copy one-to-several tables from an existing Postgres database to a PlanetScale MySQL/Vitess (or plain MySQL) target and keep just those continuously in sync. You don't have to move a whole database. --include-table / --exclude-table scope a migrate or a sync start to just the tables you choose — for both the bulk copy and the CDC stream — so you can copy one-to-several tables from an existing Postgres database into a PlanetScale MySQL/Vitess (or plain MySQL) target and keep only those continuously in sync. This guide covers selecting the tables, how Postgres schemas map onto MySQL databases/keyspaces (the part people get surprised by), the PlanetScale keyspace prerequisite, and foreign-key handling for Vitess targets. ## Select the tables Two mutually-exclusive flags scope any run. Use one or the other, never both: - --include-table t1,t2 — copy only these (comma-separated, repeatable, and glob-aware, e.g. app_*). - --exclude-table t1,t2 — copy everything except these (same syntax). The scope is honored end to end: it filters the bulk copy, the VStream / logical-replication cold-start snapshot, and the live CDC apply. An excluded table in a large source is never even read — not merely "not written" — so scoping down a big source is cheap, not just tidy. sluice migrate \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require&schema=app' \ --target-driver mysql --target 'root:pw@tcp(dst:3306)/app' \ --include-table users,orders ## How Postgres schemas map to MySQL This is the crux, and it surprises people: a Postgres schema and a MySQL database are the same namespace tier. So when the target is MySQL, a PG schema maps to a MySQL database — not to a table prefix, and never flattened silently into one place. ### Default — one schema in, one database out By default the source DSN's ?schema=app (or public) names the single namespace copied; every other schema on the source is ignored — there is no flattening. On a plain MySQL target the target database must already exist: sluice does not create it, and a missing one fails loudly rather than guessing: # target database 'app' must already exist on plain MySQL, or: # Error 1049 (42000): Unknown database 'app' sluice migrate \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require&schema=app' \ --target-driver mysql --target 'root:pw@tcp(dst:3306)/app' \ --include-table users,orders ### Copy more exactly — each schema to its own database If you want to bring several schemas across faithfully, fan them out: --all-schemas (every non-system schema) or --include-schema app,reporting (glob-aware). Each PG schema becomes an auto-created same-named MySQL database, and same-named tables in different schemas stay separate — app.users and reporting.users are two distinct target tables in two distinct databases, never merged. Use a target DSN with a trailing / and no database, so the run connects to the server rather than one database: sluice migrate \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require' \ --target-driver mysql --target 'root:pw@tcp(dst:3306)/' \ --include-schema app,reporting This is the "copy more of the database, exactly" answer: multiple target databases, one per schema. On PlanetScale each of those target databases is a keyspace — see the keyspace note below, which changes the pre-creation rule. ### Flattening many schemas into one database is refused Merging two source schemas into a single target database is deliberately refused, because it would collide same-named tables and silently lose one. --map-schema app=x --map-schema reporting=x errors: many-to-one is refused; sluice never merges two source namespaces into one target --map-schema old=new is a 1:1 rename only — e.g. --map-schema app=app_prod routes one schema to one differently-named database. It is not a merge tool. ### --include-table under fan-out is per-schema When you combine table scoping with a fan-out, the table filter applies per schema, not globally. So --all-schemas --include-table users copies both app.users and reporting.users — the name is matched inside each schema independently. Gotcha: a fanned-out schema with no matching table fails the whole run. If any selected schema has no table matching --include-table (a stray empty public is the classic case), the run ends in a loud non-zero error even though every other schema copied fine. Pair --all-schemas --include-table … with --exclude-schema public (or list exactly the schemas you mean with --include-schema) so no empty namespace is in scope. ### Summary Scenario · Target namespaces · Auto-create target DB? · Same-named tables · Single schema (default) · The one schema in the DSN → one database · No — database must pre-exist on plain MySQL · n/a (one namespace) · Fan-out (--all-schemas / --include-schema) · Each schema → its own same-named database · Yes on plain MySQL (keyspace must pre-exist on PlanetScale) · Stay separate — never merged · Flatten (--map-schema a=x --map-schema b=x) · Refused · — · — · --include-table under fan-out · Filter applied per-schema · Per the fan-out row above · One copy per schema that has the table · ## PlanetScale / Vitess keyspaces On a PlanetScale/Vitess target, the DSN's database is the keyspace, and sluice does NOT auto-create it. Unlike plain MySQL — where a fan-out target database is created for you — a PlanetScale/Vitess keyspace must be pre-provisioned. pscale database create app gives you the default keyspace (named after the database); create more with pscale keyspace create … --wait. A missing keyspace fails loudly before any data moves: Error 1105 (HY000): VT05003: unknown database 'app' in vschema So an --all-schemas fan-out to PlanetScale requires every target keyspace to exist first — create them all before the run. Use --target-driver planetscale and a DSN of the form …/?tls=true (the PlanetScale MySQL DSN uses ?tls=true, not the Postgres sslmode=…). ## Keep only the subset in sync The same table scope carries onto sync start: it cold-copies only the included tables, then tails CDC for only those. An insert into an excluded table is never created or streamed on the target — the excluded table is outside the stream entirely (live-confirmed): sluice sync start --stream-id sub \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require&schema=app' \ --target-driver planetscale --target 'USER:PASS@tcp(aws.connect.psdb.cloud:3306)/?tls=true' \ --include-table users Watch it, gate cutover on freshness, then drain and stop: sluice sync status --stream-id sub \ --target-driver planetscale --target "$SLUICE_TARGET" sluice sync health --stream-id sub \ --target-driver planetscale --target "$SLUICE_TARGET" --max-stale-seconds 30 sluice sync stop --stream-id sub \ --target-driver planetscale --target "$SLUICE_TARGET" --wait Two operational callouts. First, sync stop requires --target-driver and --target — it reads the stream's state from the target, so it errors with a "missing flags" message without them. Second, a stopped Postgres stream leaves its replication slot behind on the source; drop it before starting a fresh stream (SELECT pg_drop_replication_slot('sluice_slot');) or sluice refuses loudly with "replication slot already exists; drop it before starting". The Postgres source also needs wal_level=logical — see Prepare a Postgres source. ## Foreign keys on a Vitess target See the full guide: Foreign keys on a Vitess / PlanetScale target — the two strategies (skip-and-index vs enable-FK-support) and how to choose. The short version: If your subset carries foreign keys and you're targeting PlanetScale/Vitess — where cross-shard FKs don't work and FK support is opt-in per database — --skip-foreign-keys (v0.99.198+) skips creating the FK constraints on the target while keeping each FK's referencing columns indexed. It synthesizes a backing index only when an existing target index doesn't already cover those columns as a left-prefix, so you transition an FK-bearing source without stripping the FKs from it first, and joins stay fast. Add it to the migrate or sync start command: sluice migrate \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require&schema=app' \ --target-driver planetscale --target 'USER:PASS@tcp(aws.connect.psdb.cloud:3306)/?tls=true' \ --include-table users,orders \ --skip-foreign-keys It is mutually exclusive with --allow-degraded-fks (opposite intents — one skips FK creation, the other creates FKs and tolerates dirty source rows), and it is never silent: each skipped FK is reported on its own log line (the table, the referencing columns, and the synthesized or already-covering index) plus a summary count. Alternatively, enable FK support on the PlanetScale database instead of skipping — turn on "Allow foreign key constraints" in the target database's Settings → General tab (unsharded databases only) so sluice's FK DDL is accepted; see the region-move guide's foreign-key note. ## Next steps - Migrate many databases or schemas — the full fan-out story across every schema or database at once. - PlanetScale Postgres, Move PlanetScale regions, and PlanetScale & Vitess — the target-side setup for each PlanetScale flavor. - Verify & reconcile — confirm only the tables you scoped landed, with matching --include-table. - Command reference — every flag named here, with defaults. =============================================================== # Foreign keys on a Vitess / PlanetScale target (https://sluicesync.com/docs/foreign-keys-vitess/) Migrating or syncing a foreign-key-bearing source into Vitess or PlanetScale MySQL — the two strategies (skip-and-index, or enable FK support) and how to choose. When the target is Vitess — or PlanetScale MySQL, which is managed Vitess — foreign keys need a decision that a plain MySQL or Postgres target doesn't force on you. This guide covers why, the two strategies sluice supports, and which one to pick. It applies to both the one-shot migrate and the sync start cold-start. ## Why Vitess is different Vitess treats foreign keys specially, for two reasons: - Cross-shard FKs don't work. A sharded keyspace can't enforce a constraint whose parent and child rows live on different shards — there's no shard-spanning transaction to check referential integrity against. - On PlanetScale, FK support is opt-in per database, and only on unsharded databases. By default PlanetScale rejects FOREIGN KEY DDL outright (Vitess answers with VT10001); you turn support on per database, and even then only when the database is unsharded. So migrating an FK-bearing source — a Postgres or MySQL database that has foreign keys — into Vitess/PlanetScale needs a call: skip the FKs, or turn FK support on. sluice supports both, and never silently drops a constraint — whichever path you take is explicit and logged. ## Strategy 1 — skip the constraints, keep the columns indexed --skip-foreign-keys (v0.99.198+, on migrate and sync start) skips creating the FK constraints on the target, but ensures each skipped FK's referencing column tuple is still indexed. It synthesizes a plain backing index only when no existing target index already covers those columns as a left-prefix — never a redundant one. Why the index matters. On a MySQL/Vitess target, MySQL auto-creates an FK's backing index only when the FK itself is created. A naive skip would therefore leave the referencing column unindexed and slow every join through it. sluice keeps the column indexed so joins stay fast — you get the transition without the performance cliff. This lets an existing FK-bearing database transition without stripping the FKs from the source first. It's the right choice for a sharded target (where FKs can't be enforced anyway) or any target where FKs are managed out-of-band. sluice migrate \ --source-driver postgres --source 'postgres://user:pw@src/appdb?sslmode=require&schema=app' \ --target-driver planetscale --target 'USER:PASS@tcp(aws.connect.psdb.cloud:3306)/?tls=true' \ --skip-foreign-keys The same flag is available on sync start, where it applies to the cold-start schema-apply — steady-state CDC apply never creates FKs, so nothing else changes. It is mutually exclusive with --allow-degraded-fks (opposite intents — one skips FK creation, the other creates FKs and tolerates dirty source rows) and sluice refuses loudly if both are set. And it is never silent: each skipped FK is logged on its own line — the table, the referencing columns, and the synthesized or already-covering index — plus a summary count at the end of the run. ## Strategy 2 — enable FK support on an unsharded PlanetScale database If the target is an unsharded PlanetScale database and you want the foreign keys, turn on "Allow foreign key constraints" in the database's Settings → General tab in the PlanetScale UI. This is a toggle, not a pscale flag — the operator sets it after creating the database, and it applies to unsharded databases only. Once it's on, no special sluice flag is needed: sluice's normal foreign-key DDL is accepted and the constraints are created as usual (leave --skip-foreign-keys off). Enable it before you migrate, with no open deploy requests. See the region-move guide's foreign-key note for the full PlanetScale-side caveats (cyclic CASCADE FKs are unsupported; deploy requests don't validate pre-existing rows). ## Which to use Target · Foreign keys? · Strategy · Sharded · Can't be enforced cross-shard · Skip — --skip-foreign-keys (columns stay indexed) · Unsharded · Wanted · Enable FK support in Settings → General, then migrate normally · Unsharded · Not wanted / managed elsewhere · Skip — --skip-foreign-keys · The two strategies are not combined. --skip-foreign-keys means no FK DDL at all — it doesn't emit constraints for an FK-enabled database to accept. Enable FK support or skip; pick one per target. ## Next steps - Copy a subset of tables — scope a migrate or sync to just the tables you choose; FK handling in context. - PlanetScale & Vitess — the full target-side setup for a Vitess/PlanetScale-MySQL destination. - Move PlanetScale regions — the FK-enablement note lives in its "Before you start" section. - PlanetScale Postgres — the other PlanetScale flavor, where FKs behave like normal Postgres. - Command reference — --skip-foreign-keys, --allow-degraded-fks, and every other flag, with defaults. =============================================================== # Continuous sync from a backup chain (the broker) (https://sluicesync.com/docs/from-backup-sync/) Replay a backup chain into a target as a long-running broker — no direct source↔target connectivity required. The broker (sluice sync from-backup run) replicates by reading a backup chain instead of connecting to the source's CDC stream directly. One sluice process produces the chain from the source; another tails it and applies the changes to a target. The backup store — S3 / GCS / Azure Blob / local FS — is the message log between them. Reach for this when the source and target can't (or shouldn't) talk directly: an air-gapped target, cross-region DR where the chain already crosses the boundary, or fanning one chain out to several targets. The broker trades latency and throughput for the decoupled-transport property. If your source and target can reach each other directly, sync start is lower-latency and higher-throughput — use it instead. The broker is for moderate volumes with decoupled transport. ## 1. Produce the chain On the source side, take a full backup to root the chain, then keep it fed with incrementals. On Postgres add --chain-slot to the full so it provisions the replication slot that anchors the chain (incrementals then chain with zero gap): sluice backup full --source-driver postgres --source 'postgres://...source...' \ --target s3://my-bucket/app-chain \ --backup-endpoint https://.r2.cloudflarestorage.com \ --backup-region auto --backup-path-style \ --chain-slot Then feed it. Either run periodic incrementals from a scheduler: sluice backup incremental --source-driver postgres --source 'postgres://...source...' \ --target s3://my-bucket/app-chain \ --backup-endpoint https://.r2.cloudflarestorage.com \ --backup-region auto --backup-path-style …or run a continuous producer that commits rolling incrementals on a cadence (a long-lived process — run it under systemd / k8s). --rollover-window sets how often it commits an incremental; --retain-rotate-at-chain-length rotates into a fresh segment to keep segments compact for pruning: sluice backup stream run --source-driver postgres --source 'postgres://...source...' \ --target s3://my-bucket/app-chain \ --backup-endpoint https://.r2.cloudflarestorage.com \ --backup-region auto --backup-path-style \ --rollover-window 10s \ --retain-rotate-at-chain-length 20 ## 2. Replay it into the target On the consumer side, point the broker at the same chain. It reads the chain's catalog every --poll-interval, applies any incrementals newer than its persisted position in chain order, and persists progress in the target's sluice_cdc_state. The --stream-id is required so it can resume cleanly after a restart: sluice sync from-backup run \ --backup-target s3://my-bucket/app-chain \ --backup-endpoint https://.r2.cloudflarestorage.com \ --backup-region auto --backup-path-style \ --target-driver postgres --target 'postgres://...target...' \ --stream-id app-broker \ --apply-concurrency 4 \ --poll-interval 10s --apply-concurrency matters for cross-region targets. Each incremental's merged change stream is fanned across W in-order PK-hash lanes (same key → same lane → applied in source order), each committing concurrently on its own connection. Without it, a large incremental replayed into a high-latency target applies through a single RTT-bound stream and the broker falls behind. 0 (default) = auto:4; 1 = explicit serial; W>1 honored. Exactly-once is preserved — every change in an incremental carries the same chain position, so the lanes persist the identical resume position the serial path would. ## 3. Cold-start vs warm-resume On its first launch against a chain, the broker has no sluice_cdc_state row for the chosen --stream-id, so it doesn't know where in the chain to begin and refuses loudly. There are two ways past that, mutually exclusive: - --reset-target-data — drop the target's tables, run a chain restore (full + every incremental up to the tail), then transition to live polling. The full from-the-chain rebuild; suitable when the target is empty or you want a clean rebuild. Prompts (type reset) unless --yes. - --at-chain-id — operator-asserted resume: tell the broker the target is already at chain ID (e.g. you just ran a manual sluice restore to bring the target up to a known checkpoint). It writes a fresh state row and tails forward from there — no re-bulking. The common case is the post-restore cold-start: bulk-copy the chain once with sluice restore, then launch the broker with --at-chain-id set to that restore's tail manifest. Pass the flag only on the first launch; every subsequent restart warm-resumes from sluice_cdc_state automatically and needs neither flag: # first launch after a fresh restore sluice sync from-backup run --backup-target s3://my-bucket/app-chain \ --target-driver postgres --target 'postgres://...target...' \ --stream-id app-broker --apply-concurrency 4 --poll-interval 10s \ --at-chain-id 9b12b8ccdc3e7fa9725825ab032e6d6d41d3db09 # every restart after that — warm-resume, no recovery flag sluice sync from-backup run --backup-target s3://my-bucket/app-chain \ --target-driver postgres --target 'postgres://...target...' \ --stream-id app-broker --apply-concurrency 4 --poll-interval 10s ## 4. Stopping cleanly Stop the broker by writing a stop signal to the chain destination — the running process observes it on its next tick and exits cleanly. Because the signal lives in the store, you can stop a broker from a different host without process access (both sides agree on the chain, not on the host): sluice sync from-backup stop --backup-target s3://my-bucket/app-chain The broker follows segment-rotation seams automatically and is restart-resilient on both sides — its idempotent applier absorbs any overlap on resume. Two consumers must use distinct --stream-ids for distinct targets, or they'll race on position writes. To rest the chain encrypted, the broker accepts the same encryption flags as the rest of the backup family — see the backup reference. =============================================================== # Drive sluice from an AI agent (https://sluicesync.com/docs/agent-skills/) sluice ships task-scoped agent skills — plain-markdown playbooks that let Claude Code, Cursor, or any skill-aware assistant run a migration, verify a sync, or operate a backup chain on your behalf, inside the same safety gate. sluice ships a set of agent skills: task-scoped operator playbooks that let an AI coding agent — Claude Code, Cursor, or anything that follows the open agent-skills convention — drive the sluice CLI for one concrete job. Each skill is a plain SKILL.md file: no plugins, nothing agent-specific, versioned in the source repo alongside the CLI it drives. They live under skills/ in the repository. ## Why sluice ships them sluice already exposes a machine-readable surface built for assistants: an AGENTS.md command taxonomy, an llms.txt docs index, per-command --format json envelopes, stable SLUICE-E-* error codes, and a documented exit taxonomy. A skill sits on top of that surface. It does not re-document the CLI — it references those canonical sources and encodes the decision tree for a single task: what to run, how to read the result back, what to report, and where a human must approve before anything changes. One skill, one task, one go/no-go. ## The catalog Nine skills ship today, in two tiers. ### Tier 1 — the core loop Skill · Use it to · Writes? · migrate-preflight · Assess a migrate or sync before running it → a go/no-go with the risks named. · read-only · fidelity-verify · Confirm a completed migrate / sync / restore is faithful → a fidelity report. · read-only · sluice-error-triage · Turn a SLUICE-E-* code + exit code into a root cause and a recovery path. · read-only · backup-chain-operator · Plan and operate an encrypted backup chain (full → incremental → compact → prune → restore-test). · gated · ### Tier 2 — operational + engine-specific Skill · Use it to · Writes? · cdc-sync-operator · Stand up and operate continuous sync (cold-start → CDC → cutover). · gated · planetscale-migration · Migrate or sync against PlanetScale / Vitess (VStream, reparent, ownership, metrics-watch). · gated · fleet-operator · Operate a sync run fleet — many syncs in one process. · gated · redaction-setup · Configure and verify PII redaction during migrate / sync. · gated · sqlite-d1-import · Import SQLite / Cloudflare D1 (--stage-local, --infer-types, big-int / CPU gotchas). · gated · ## The safety gate Every skill honors sluice's command taxonomy — the same gate a careful human operator uses: - Read-only commands (--dry-run, verify, schema preview / diff, sync health / status, backup verify, engines) run freely. - State-changing commands (migrate, sync start / run, backup *, restore, cutover) run only as part of an approved task. - Destructive flags (--reset-target-data, --force-cold-start, --yes, backup prune / compact without --dry-run) are never passed without explicit human approval for that specific invocation. Every skill also follows sluice's own discipline: verify by reading state back, never trust an exit code alone, and treat status:"refused" / exit 3 as a decision point — surface error.hint and wait, don't retry the command unchanged. ## Getting started - Install the CLI. You need the sluice binary — brew install sluicesync/tap/sluice, go install sluicesync.dev/sluice/cmd/sluice@latest, or the ghcr.io/sluicesync/sluice container (see Getting started). - Install the skills. Run the setup script from a checkout of the repo — it detects the agents present and installs each SKILL.md into the right place: ./skills/install.sh For Claude Code that is ~/.claude/skills//SKILL.md (personal, all projects) or .claude/skills//SKILL.md (checked into a project); Cursor and others have equivalents. Because the skills are just markdown, you can also copy the directories by hand. - Describe the task in natural language. The matching skill's trigger loads it automatically — "migrate this Postgres DB to PlanetScale" pulls in migrate-preflight; "why did this restore fail?" pulls in sluice-error-triage — or invoke one explicitly (/migrate-preflight). - Review the go/no-go. The skill drives the CLI on your behalf and returns a go/no-go, a report, or a gated action — and stops at the safety gate for your approval before anything writes. ## Learn more - skills/ in the repository — every SKILL.md, the catalog, and install.sh. - llms.txt and llms-full.txt — the AI-assistant docs index the skills point at. - AGENTS.md — the command taxonomy, standard workflow, JSON-envelope shape, and env-first credentials that the safety gate is built on. =============================================================== # Supported directions (https://sluicesync.com/docs/supported-directions/) Every source → target pair sluice can move, for one-shot migrate and for continuous sync — and which pairs each surface does not cover. sluice moves data between database engines through two surfaces: migrate (a one-shot schema + data copy) and sync (continuous change-data-capture). A "direction" is just a source engine → target engine pair. Which pairs are supported differs between the two surfaces, because migrate and sync have different engine roles — a few engines can be read continuously but not written to, and a couple can only ever be a source. The authoritative, always-current list for the binary in your hand is sluice engines; this page is the operator-facing summary of what those roles add up to. sluice engines # lists every engine built into this binary and its role (migrate / CDC, source / target) ## Migrate — one-shot copy Migrate reads a source once and writes a fresh copy into a target. Every migrate source copies to every migrate target — the cell is never "unsupported", only "faster" on the same-engine diagonal. Cross-engine pairs flow through the typed IR; same-engine pairs take an optimized path but the same fidelity. Source ↓ / Target → · MySQL · MariaDB · PlanetScale / Vitess · Postgres · SQLite · MySQL · ✓ a · ✓ · ✓ b · ✓ · ✓ · MariaDB · ✓ · ✓ a · ✓ b · ✓ · ✓ · PlanetScale / Vitess · ✓ · ✓ · ✓ b · ✓ · ✓ · Postgres · ✓ · ✓ · ✓ b · ✓ c · ✓ · SQLite (file / .sql dump) · ✓ · ✓ · ✓ b · ✓ · ✓ · Cloudflare D1 (live) · ✓ · ✓ · ✓ b · ✓ · ✓ · CSV / TSV / NDJSON (file, csv/tsv/ndjson) · ✓ · ✓ · ✓ b · ✓ · ✓ · mydumper / pscale dump (directory, mydumper) · ✓ · ✓ · ✓ b · ✓ · ✓ · a same-engine MySQL or MariaDB uses the native LOAD DATA LOCAL INFILE loader (a MariaDB target falls back to batched multi-row INSERT per table on local_infile=OFF servers and geometry-bearing tables). b a PlanetScale / Vitess target blocks LOAD DATA LOCAL, so cold-copy falls back to batched multi-row INSERT (use the planetscale / vitess engine name, not mysql, against a Vitess-backed host). c same-engine Postgres byte-pipes the native COPY stream — the raw-copy fast lane — when there's no transform to apply. See How sluice copies your data for which internal path each cell takes. MariaDB is a MySQL-family flavor. Use the mariadb engine name (not mysql) against a MariaDB server — sluice fingerprints the server and steers you if they're mismatched. Its migrate cells match the mysql column/row; the one place it diverges from vanilla MySQL is on continuous sync (domain-GTID binlog, below) and a handful of catalog conventions covered in the MariaDB field notes. Targets are MySQL, MariaDB, PlanetScale / Vitess, Postgres, or SQLite. Cloudflare D1 is a migrate source only (read live over its HTTP API), never a migrate target. The flat-file engines — csv, tsv, ndjson (each staged byte-exact into a temp SQLite database; file conventions declared with the --csv-* flags, never sniffed — ADR-0163) and mydumper (a mydumper / pscale database dump directory, ADR-0161) — are migrate sources only. Plain mysqldump / pg_dump .sql dumps are deliberately not parsed (loud refusal with a scratch-server recipe). The trigger-CDC engines (postgres-trigger, sqlite-trigger, d1-trigger) are sync-only and don't appear here. ## Sync — continuous change-data-capture Sync does an initial snapshot, then streams every subsequent change from the source until you cut over. It has more sources than migrate — including three trigger-based engines for platforms that can't hand out a native replication feed — but fewer targets: changes are only ever applied to a MySQL-family (MySQL, MariaDB, PlanetScale / Vitess) or Postgres target. Source ↓ / Target → · MySQL · MariaDB · PlanetScale / Vitess · Postgres · MySQL — binlog · ✓ · ✓ · ✓ · ✓ · MariaDB — binlog / domain-GTID d · ✓ · ✓ · ✓ · ✓ · PlanetScale / Vitess — VStream · ✓ · ✓ · ✓ · ✓ · Postgres — replication slot · ✓ · ✓ · ✓ · ✓ · Postgres — slot-less (postgres-trigger) · ✓ · ✓ · ✓ · ✓ · SQLite — sqlite-trigger · ✓ · ✓ · ✓ · ✓ · Cloudflare D1 — d1-trigger · ✓ · ✓ · ✓ · ✓ · d A MariaDB source streams its native binlog, parsing MariaDB's domain-based GTIDs (e.g. 0-100-38) and resuming off them — a distinct format from MySQL's GTIDs, and one MariaDB won't let you pre-check for reachability (the stream itself is the only honest signal; see the field note). Native uuid/inet6/inet4 columns decode faithfully through CDC (v0.99.272). SQLite and D1 are sync sources, not sync targets. A continuous stream from SQLite or D1 runs through the sqlite-trigger / d1-trigger engines (the plain sqlite / d1 engines have no CDC); a stream never lands into SQLite or D1. For managed Postgres that can't grant a replication slot (Heroku, some RDS/Supabase tiers), postgres-trigger is the slot-less source path. ## The four MySQL ↔ Postgres directions The combination sluice was built for — the fully bidirectional MySQL ↔ Postgres matrix — is every cell where both sides are one of those two engines, and all four work in both migrate and sync: - MySQL → Postgres and Postgres → MySQL — cross-engine, through the IR (type translation, value-fidelity checks, PII redaction, overrides). - MySQL → MySQL and Postgres → Postgres — same-engine, nothing to translate; the native loader / raw-COPY fast lane applies on migrate. PlanetScale, self-hosted Vitess, and MariaDB are all MySQL-dialect flavors, so anywhere this page says "MySQL" as a direction, they slot into the same cell — you just pick the matching engine name. PlanetScale / Vitess use VStream and batched-insert instead of binlog and LOAD DATA; MariaDB uses the vanilla-MySQL binlog and loader path, differing only in its domain-GTID stream format and a few catalog conventions (the MariaDB field notes cover them). ## Next steps - How sluice copies your data — the internal path (IR vs raw fast lane) each direction takes, and why the fast lane never trades correctness for speed. - Command reference: engines — the per-engine role table (bulk-load / CDC capabilities, DSN shapes) this page summarizes. - Getting started — connect a source and target and run your first migrate. - Import SQLite or Cloudflare D1 — the SQLite / D1 source specifics end to end. =============================================================== # How sluice copies your data (https://sluicesync.com/docs/how-sluice-copies/) Same-engine vs cross-engine: which internal path a copy takes, and why the fast path never trades correctness for speed. Every migration and sync moves rows through one of two internal paths. Which one runs is automatic — you don't pick it — but the distinction explains sluice's performance profile and, more importantly, why it never trades correctness for speed. ## The IR path — the default, and the only path for cross-engine Everything cross-engine — MySQL → Postgres, Postgres → MySQL, SQLite → anything — flows through sluice's internal representation (IR): a typed, dialect-neutral model of your schema and values. The source reader decodes each row into IR; the target writer encodes IR into the target's wire format. The IR is where every cross-engine capability lives: type translation (MySQL TINYINT(1) ↔ PG BOOLEAN; PG UUID / INET / ARRAY ↔ their MySQL equivalents), PII redaction, --type-override / --expr-override, and the value-fidelity checks that refuse loudly rather than silently coerce. That generality is the point of sluice — but it has a cost: every value is decoded and re-encoded, even when source and target are the same engine and nothing needs to change. ## Postgres → Postgres — the fast lane that skips the round trip When both sides are Postgres and there is no transformation to apply, the bytes the source emits are exactly the bytes the target wants, so the decode → IR → re-encode round trip buys nothing. sluice detects this and byte-pipes the server's native COPY stream straight from source to target (COPY (SELECT …) TO STDOUT → COPY … FROM STDIN), never materializing an IR row. This is the same tactic pgcopydb uses, and it closes most of the per-stream throughput gap against it. It composes with sluice's parallel copy — each table, and each primary-key-range chunk of a large table, byte-pipes independently. ## MySQL → MySQL — no translation to do, plus a native loader A same-engine MySQL copy still flows through the IR (there is no raw byte-pipe for MySQL today), but with source and target identical there is nothing to translate — every type round-trips exactly — and sluice writes through MySQL's native bulk loader (LOAD DATA LOCAL INFILE) on the parallel copy path, the fastest ingest MySQL offers. (PlanetScale blocks LOAD DATA LOCAL, so a PlanetScale target falls back to batched multi-row INSERT.) ## SQLite and Cloudflare D1 — always the IR path, with a lossless read projection A SQLite file, a wrangler d1 export .sql dump, or a live Cloudflare D1 database imports into Postgres or MySQL (and SQLite is itself a migrate target). These always take the IR path — there is no raw byte-pipe for SQLite or D1, and there doesn't need to be: the copy is cross-engine, so every value is being translated anyway. The engineering that matters here is on the read side, because SQLite's dynamic typing and D1's HTTP-only access each make "read the value exactly" the hard part. - Dynamic types, read losslessly. SQLite stores a storage class per value, not a declared column type. sluice reads each column through a (typeof(col), CAST(col AS TEXT) / hex(col)) projection, so an integer above 253 keeps every bit and a BLOB round-trips byte-for-byte instead of going through a lossy float or UTF-8 coercion. A decimal written into a SQLite target lands byte-exact as TEXT. - Declared temporal types are an explicit decode, never a guess. SQLite has no native date/time storage, so a column declared DATE / DATETIME is decoded per --sqlite-date-encoding (iso default, or unixepoch / unixmillis / julian). A stored value whose storage class doesn't match the chosen encoding is refused loudly — never silently turned into a wrong date. - Live D1 stays online. The d1 engine reads over D1's HTTP query API (token via CLOUDFLARE_API_TOKEN) using the same lossless projection; the read doesn't take the database offline (ADR-0132). This is the one-shot migrate copy path. Continuous sync from SQLite or D1 is a separate, trigger-based mechanism — the sqlite-trigger / d1-trigger CDC engines — not this cold-copy lane. See Import SQLite or Cloudflare D1 for the full walkthrough, and Supported directions for which SQLite/D1 pairs are one-shot vs continuous. ## The fast lane is not "more accurate" — it is the same fidelity, less work Worth stating plainly: the Postgres byte-pipe is not a more exact copy than the IR path. Both are exact. It is faster precisely because it only runs when there is provably nothing to change — so it can move bytes instead of re-deriving them. ## The safety gate — why speed never costs you correctness The Postgres byte-pipe is guarded by one auditable check that proves there is no transform to skip. The moment any of these is present, sluice falls back to the IR path automatically — per table, without you configuring anything: - --redact (PII redaction) - --type-override or --expr-override - shard-column injection (--inject-shard-column) - an OID / wire-format-sensitive type — extension types like pgvector / hstore, bit, or PostGIS geometry — whose per-type codec must run Add a redaction rule to a Postgres → Postgres migrate and the raw lane silently steps aside for exactly the tables that need it; drop the rule and it re-engages. The fast lane is opportunistic and conservative by construction. Scope today. The Postgres byte-pipe runs on the cold-copy phase of migrate (not the sync cold-start or a resume yet). Format is text by default (safe across Postgres major versions); binary is opt-in on matched server majors. # same-engine PG->PG migrate; the raw COPY lane engages automatically sluice migrate \ --source-driver postgres --source 'postgres://user:pass@src:5432/app?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@dst:5432/app?sslmode=require' \ --raw-copy-format auto # text (default) | binary | auto (binary when majors match) ## Next steps - migrate reference — the parallelism flags (--table-parallelism, --bulk-parallelism) and --raw-copy-format. - Redact PII — the transforms that route a copy onto the IR path. - Preview & validate — see the plan before you run it. =============================================================== # Verify & reconcile after a migration (https://sluicesync.com/docs/verify-reconcile/) Confirm every row landed and no structural drift crept in — then know exactly what to do when it didn't. Preview & validate is the pre-migration companion to this guide: it shows the DDL and steers translation before a row moves. This guide is the post-migration half — after migrate finishes, or after a sync has caught up, you want proof that the data actually landed and that the target's shape still matches what sluice would produce. Two read-only commands give you that proof: verify compares the rows, schema diff compares the structure. Both exit non-zero on a discrepancy, so either one drops straight into a cron job or a CI gate. ## 1. Verify the rows landed verify compares the data itself, on a depth ladder — start cheap, escalate only when you need a stronger guarantee. It never writes to the target. Depth · What it does · --depth count (default) · Per-table row-count comparison. Fast, works across engines, and catches whole-table loss and bulk-row loss. · --depth sample · Counts plus per-table sampled-row content hashes — ~99% confidence of catching a 5%+ corruption rate at the default 100 rows/table. Same-engine only (see below). · For a cross-engine migration (MySQL → Postgres and the like), count is the mode you run. Server-side row hashing renders values in each engine's own text format, so a cross-engine sample would report false mismatches; sluice refuses --depth=sample loudly when the source and target engines differ rather than hand you a misleading result. Sample mode is for same-engine checks (MySQL → MySQL, Postgres → Postgres). # cross-engine: row-count parity, the everyday post-migrate check sluice verify --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --depth count # same-engine: escalate to sampled content hashing sluice verify --source-driver postgres --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --depth sample --sample-rows-per-table 500 Tune sample mode with --sample-rows-per-table (raise it for tables with rare anomalies), --sample-seed (deterministic — the same seed picks the same rows on both sides; change it to reshuffle), and --strict-hash (SHA-256 instead of MD5, for an extra confidence margin or a compliance posture that requires it). Scope any run with --include-table / --exclude-table (glob-aware, mutually exclusive). Full per-row hashing is planned, not yet shipped. Today the ladder stops at sample; count plus a well-sized same-engine sample is the strongest check available. ## 2. JSON output and cron-friendly exit codes Both depths accept --format json and -o FILE, so verify pipes cleanly into a CI gate or an alertmanager pipeline. The JSON carries per-table deltas — source vs target counts, sampled-row count, and the mismatched primary keys when sample mode finds drift — so you get the offending rows, not just a red/green. sluice verify --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --depth count --format json -o verify.json The exit code is the contract for automation: Exit · Meaning · 0 · Clean — every checked table matched. · 1 · Mismatch — at least one table differs (a count delta, or a sampled-row hash mismatch). · 2 · Operational error — couldn't connect, unsupported engine, bad flags. Distinct from 1 so a gate never conflates "the data differs" with "the check couldn't run". · Redacted migrations. If you migrated with --redact, the target values differ from the source by design — so a same-engine --depth=sample run will report those rows as content mismatches. Verify redacted migrations with --depth=count (row parity is still meaningful), or scope the sample to unredacted tables with --include-table. ## 3. Confirm no structural drift Row parity doesn't prove the shape is right — an index that failed to build, a column that came back with the wrong type, a constraint that never applied. schema diff reads the live target and compares it against what sluice would produce from the source, then prints the delta with copy-paste DDL suggestions to reconcile it. It's read-only — there is no --apply flag by design (ADR-0029); the DDL is for you to review and run. Like verify, it exits non-zero on any difference. sluice schema diff --source-driver mysql --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" Its exit codes mirror verify: 0 clean, 1 drift detected (the gate fails; a one-line summary goes to stderr, the full diff to stdout or -o FILE), 2 operational error. Trim the noise when part of the target is managed out-of-band: --ignore-charset-collation suppresses MySQL charset/collation diffs, --ignore-extras hides tables/columns/indexes that exist only on the target, and --skip-views drops view comparison entirely. --format json is available for CI. If you steered any types at migrate time with --type-override, pass the identical flags here so the diff compares against the schema you actually intended. ## 4. What to do on a mismatch A non-zero exit tells you where — the table, and (in sample mode) the mismatched primary keys. From there: - Structural drift (schema diff flagged it). Read the suggested DDL. If it's a missing index or constraint on an otherwise-correct table, applying the suggestion is often enough. If a column type is wrong, fix it with a --type-override and re-migrate that table rather than hand-patching. - A count shortfall on a fresh migration. Re-run migrate. A plain re-run is idempotent for tables that copied cleanly and fills the gap. If the target table is in a partially-written state you want to discard, migrate --reset-target-data is the destructive recovery: it deletes the migrate-state row, drops every source-schema table on the target, and runs a fresh cold-start (it prompts for confirmation — type reset, or pass --yes in automation). See ADR-0023. - A drift that appears on a running sync. The equivalent recovery is sync start --reset-target-data — drop the target, restore, then transition back to live polling. Don't reach for it on a transient lag; let the sync catch up and re-verify first. - The mismatch is on the source side. If verify reports the target has more rows, or the counts drift on every re-run, suspect the source: rows written after the copy started, a source table still taking writes without a sync, or a filter (--include-table) that differs between the migrate and the verify. Re-verify with the same table scope you migrated. ## 5. Symptom → first look Symptom · First look · verify --depth=count exits 1, target has fewer rows · Re-run migrate; if partially written, migrate --reset-target-data. · Target has more rows than source · Source took writes after the copy — reconcile the window, or move to continuous sync before cutover. · --depth=sample exits 1 on a redacted migration · Expected — redacted target content differs. Use --depth=count. · verify exits 2 (not 1) · Operational, not data: connectivity, engine name, or flags. Check the DSNs and --*-driver values. · schema diff exits 1, missing index/constraint · Apply the suggested DDL from the diff output. · schema diff flags a wrong column type · Re-migrate the table with a --type-override; don't hand-patch. · --depth=sample refused for a cross-engine pair · By design — use --depth=count across engines. · ## Next steps - Preview & validate before you migrate — the pre-migration half: DDL preview and type steering. - Command reference: verify and schema preview / diff — every flag in one place. - Zero-downtime migration (continuous sync) — when the source keeps taking writes, verify after the sync catches up. =============================================================== # Schema changes during a live sync (https://sluicesync.com/docs/schema-changes/) How sluice keeps a running sync online while the source schema evolves — what forwards automatically, what refuses loudly, and how to recover. A source schema rarely stands still. Columns get added, types get widened, indexes come and go while a continuous sync is running. sluice does not manage those migrations for you — tools like Atlas, sqitch, Flyway, and liquibase do that — but it does keep the stream online through them. By default it forwards the operator's own committed DDL onto the target, so a routine ALTER TABLE no longer wedges the sync. This page covers what forwards automatically, the narrow set of changes that still refuse loudly, and the drained-migrate recovery when one does. ## The control: --schema-changes A single tristate flag on sync start (and per-sync in a sync run fleet spec) governs the behavior, introduced in ADR-0091: Mode · Behavior · --schema-changes=forward (default) · Apply every unambiguous source schema change on the target automatically, logging each applied DDL at INFO. The sync stays online through routine schema evolution. · --schema-changes=refuse · The conservative pre-v0.92 behavior: any source DDL surfaces loudly with a structured drift diff and the drained-model recovery hint. For operators who gate DDL through a separate change-management process. · This is a behavior change on upgrade. A stream that previously refused on source DDL now forwards it. Set --schema-changes=refuse to keep the old drained-model default. Note also that --schema-changes is a no-op under Shape A (--inject-shard-column): the multi-shard boundary router already forwards every shape via its lease. The older --forward-schema-add-column boolean is deprecated — forwarding is on by default and covers every shape, so the flag is subsumed; setting it logs a deprecation warning and forwards. ## What forwards, by source engine Under forward, the intercept can emit any shape's DDL, but a change only reaches the target if the source's CDC stream actually carries its detail on the wire. Postgres logical replication (pgoutput) carries less than MySQL's information_schema re-read, so the honest matrix differs by source engine. This is the ground-truth table from ADR-0091 §1d — do not assume a shape forwards without checking it: Shape · MySQL source · Postgres source · ADD COLUMN · forwards · forwards · DROP COLUMN · forwards · forwards · ALTER COLUMN TYPE (same- or cross-engine) · forwards · forwards · ALTER NULLABILITY · forwards · refuses1 · Column REORDER · no-op2 · no-op2 · CREATE / DROP INDEX · refuses3 · never signaled on the wire — cannot forward; mirror manually1 · ADD / DROP / MODIFY CHECK · refuses3 · never signaled on the wire — cannot forward; mirror manually1 · RENAME COLUMN · refuses (§rename) · forwards via attnum4 · RENAME TABLE / multi-shape combo · refuses · refuses · 1 pgoutput's relation message carries only column name + type + the replica-identity key flag — no nullability flag, no secondary-index or CHECK metadata. The wire never signals these on a Postgres source, so they produce no boundary to forward. A resulting incompatibility surfaces as a loud apply error on the next affected row, not silent corruption. 2 sluice decodes rows by column name, never by position, so a pure reorder needs no DDL — it is a safe no-op. 3 MySQL's CDC projection reads only {schema, name, columns, primary key} on a DDL boundary; it does not project secondary indexes or CHECK constraints. Forwarding them would need a new catalog projection (perf-only for indexes; cross-engine expression-translation-hazardous for checks), so both are deferred. 4 A Postgres RENAME is proven via the stable pg_attribute.attnum — see RENAME COLUMN. Every forwarded DDL is logged at INFO as it lands, so the applied change is visible in the sync's log stream. Cross-engine type ALTERs are retargeted through the same translation path a cold-start CREATE TABLE uses; a widening ALTER forwards cleanly, while a narrowing or incompatible one is rejected by the target engine and surfaces as a loud, retryable refuse (position not advanced). ## What always refuses, even under forward Two shapes never auto-apply, because forwarding the wrong guess would silently lose data: ### RENAME COLUMN A column rename and a DROP x + ADD y of the same type are indistinguishable from the replication stream alone — both present as exactly one dropped column and one added column. Guessing RENAME when the truth is drop+add keeps stale data under the new name; guessing drop+add when the truth is RENAME drops the column's data on the target. The only safe disambiguation is a stable column identity that survives a rename: - Postgres has one — pg_attribute.attnum is stable across a rename. The PG CDC reader carries it as the column's stable id; the intercept forwards a rename only when the before and after columns share the same non-zero attnum (proven rename, data preserved) and refuses otherwise. Because the proof is definitive, a bug here can only ever refuse safely, never mis-forward. - MySQL has no equivalent — ORDINAL_POSITION changes on reorder and there is no creation id, so a MySQL-source rename is fundamentally unprovable from catalog state. It refuses, permanently. Drain and rename on both ends explicitly. ### ADD COLUMN with a computed / volatile DEFAULT An ADD COLUMN whose DEFAULT is a non-deterministic function is refused, because evaluating it in the target's session diverges from the per-row values the source already inserted (ADR-0058 §2a). The refused functions include NOW() / CURRENT_TIMESTAMP / clock_timestamp(), nextval(), gen_random_uuid(), random(), and MySQL's UUID() / RAND() — matched schema-qualified or bare, and detected even when wrapped (e.g. COALESCE(NULL, NOW())). A constant DEFAULT forwards normally. If the probe of a column's default can't be read at all, sluice refuses on uncertainty rather than risk a wrong value. Multi-shape combos (more than one structural change in a single boundary) also refuse — the IR delta can't be unambiguously ordered — as does a target DDL apply that fails on lock contention, permissions, or an unrecognized type. Every one of these leaves the CDC position un-advanced, so a retry replays the boundary once you've reconciled by hand. ## The refusal message When a change refuses, the error is deliberately greppable and names the specific offending object plus the operator action. It carries three parts: the classify error (which shape / how many changes), a structured drift diff that names the exact columns / indexes / constraints that differ, and a recovery hint. The hint spells out the drained model: - Run sluice sync stop --wait to drain in-flight changes. - Apply the schema change on the target (manually, or via sluice schema migrate). - Resume with sluice sync start --resume. - It also notes that --schema-changes=refuse keeps the drained model as the default for any subsequent source DDL. ## Operator runbook: recovering a refused change When a change refuses — or when you run --schema-changes=refuse deliberately — the recovery is the drained-schema-migrate sequence. Stop the stream with --wait so the CLI blocks until the streamer confirms a graceful drain (the in-flight batch is committed and the CDC position is persisted past the last applied event), apply the DDL to whichever side needs it, then resume from the persisted position: # 1. Drain and stop — --wait blocks until the drain is confirmed sluice sync stop --wait \ --stream-id app-prod \ --target-driver postgres --target 'postgres://...target...' # 2. Apply the schema change on source and/or target as appropriate psql "$SOURCE_DSN" -c 'ALTER TABLE accounts RENAME COLUMN label TO name;' psql "$TARGET_DSN" -c 'ALTER TABLE accounts RENAME COLUMN label TO name;' # 3. Resume from the persisted CDC position sluice sync start --resume \ --stream-id app-prod \ --source-driver mysql --source 'root:rootpw@tcp(localhost:3306)/app' \ --target-driver postgres --target 'postgres://...target...' The --resume flag picks up the persisted CDC position (source LSN / GTID set / VStream cursor), so pre-stop events apply cleanly and the first event after resume sees the new shape on both sides. Without --resume, sluice refuses to bulk-copy into a populated target. The order "stop → ALTER source → ALTER target → start" is robust regardless of which side commits the DDL first, as long as both sides carry the new shape before resume. Plan the target-side change first. sluice schema diff runs the source schema through sluice's translation pipeline and reports drift against the target's actual schema — apply the ALTER on the source, run the diff, and it surfaces the missing-on-target columns / type mismatches with suggested ALTER statements as a starting point. It does not know your data volume or lock duration, so review them before running. ## Next steps - sync start reference — the --schema-changes row and the full sync flag set. - Migrate MySQL to Postgres — the one-shot migration the drained model resumes onto. - schema diff / schema migrate — pre-flight drift and apply the target-side change. =============================================================== # Redact PII while you migrate & sync (https://sluicesync.com/docs/redact-pii/) Seed staging, dev, analytics, and vendor handoffs from production without letting personal data leave with the rows. You need a realistic copy of production in a place production data isn't allowed to go — a staging database, a developer laptop, an analytics warehouse, a vendor's environment. The schema, the row shapes, and the referential structure all have to survive; the emails, card numbers, and national IDs must not. sluice does this inline with --redact: PII is transformed between the source reader and the target writer, so the sensitive value never lands on the target and never touches the backup on disk. There is no separate scrubbing pass to forget to run. ## How --redact works Each rule names a column and a strategy: --redact '[schema.]table.column=STRATEGY[:options]' The flag is repeatable — pass it once per column. Rules are applied in the bulk-copy hot path and the CDC apply path alike; the strategy's output replaces the source value verbatim at the named column before it reaches the target. When no --redact is configured the pipeline short-circuits before any per-row work, so operators who don't use redaction pay nothing for the feature. sluice migrate \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --redact users.email=hash:sha256 Every rule also has a YAML form under a redactions: block in the config file (see Configuration). CLI and YAML mix: CLI rules are processed first, YAML appends, and a duplicate on the same schema.table.column is last-write-wins with a WARN. Keep the bulk in version-controlled YAML; reach for the flag for per-environment overrides (--redact users.email=null in staging). ## Where redaction applies The same rule set is honoured uniformly across every path that moves rows, so a column can't leak through a surface you forgot about: Command · Behaviour · sluice migrate · One-shot bulk copy — every row passes through the redactor. · sluice sync start · Both phases honour --redact: the cold-start snapshot copy and the live CDC apply stream. · sluice backup full / incremental · Backup chunks are PII-clean on disk; a later restore copies them through unchanged. · sluice schema preview · No data moves — it annotates the generated CREATE TABLE DDL with which columns are redacted (see below). · ## The strategy families sluice ships 26 strategies across five families. Pick the one that matches the column's shape. ### Constant & foundational Strategy · Behaviour · null · Replace with NULL. Refuses on NOT NULL columns — use static: there instead. · static: · Replace every value with one literal constant. · truncate: · Keep the first N runes (rune-counted; UTF-8 and emoji safe). · hash:sha256 · SHA-256 hex digest — deterministic, no key required. · hash:hmac-sha256 · Keyed HMAC-SHA256 hex digest — requires --keyset-source (see below). · ### Format-preserving masks Generic masks keep some characters and blank the rest (default mask char X): Strategy · Behaviour · mask:inner:,[,] · Keep first M1 + last M2 runes; mask the middle. mask:inner:4,4 on 4111111111111111 → 4111XXXXXXXX1111. · mask:outer:,[,] · Mask the first M1 + last M2; keep the middle. · Country- and format-specific presets validate the input shape and preserve just the non-identifying part: Preset · Behaviour · mask:ssn · US SSN — preserve last 4 (XXX-XX-NNNN). · mask:pan / mask:pan-relaxed · Card PAN — preserve first 6 + last 4. mask:pan requires a valid Luhn checksum; mask:pan-relaxed skips the check. · mask:email · First char of the local part + masked middle + full @domain. · mask:ca-sin · Canadian SIN — preserve last 3 (Luhn-validated). · mask:uk-nin · UK National Insurance number — keep prefix letters + suffix, mask the digits. · mask:iban · IBAN — preserve country code, check digits, 2 BBAN, and last 4. · mask:uuid · UUID — preserve hyphens + first 4 + last 4 hex. See the caveat below. · mask:uuid on a native uuid column. The masked output contains X characters that aren't valid hex, so a target column typed uuid (Postgres) refuses at preflight — before any data moves — unless you also map that column to text with --type-override=table.col=text. ### Realistic synthetic values (randomize) The randomize:* generators produce fresh, valid-shape fake values — ideal when staging needs data that looks real. Output is replay-stable per source row: the same source primary key always regenerates the same target value across CDC resume, cold-start re-apply, and backup → restore (ADR-0039). Strategy · Output · randomize:int:, · Integer in [min, max] inclusive. · randomize:email · rand-local@rand-domain.test (IETF-reserved TLD). · randomize:us-phone · NANP-valid XXX-XXX-XXXX. · randomize:uuid · RFC 4122 UUIDv4 (passes strict UUID column validation). · randomize:ssn · US SSN avoiding reserved ranges. · randomize:pan[:] · Luhn-valid card PAN; optional visa / mastercard / amex. · randomize:ca-sin · Luhn-valid Canadian SIN. · randomize:uk-nin · UK NIN matching the HMRC prefix alphabet. · randomize:iban[:] · IBAN with mod-97 check digits; optional DE / GB / FR. · Every randomize:* rule needs a primary key on the source table — the replay seed is derived from the row's PK. The pipeline refuses loudly at startup if a randomize:* rule targets a heap (no-PK) table; add a PK on the source, or pick a non-random strategy. ### Dictionary strategies Dictionary strategies map source values into a named lookup table declared in YAML (ADR-0040): Strategy · Keyed by · Use case · randomize:dict: · Source PK (replay-stable) · Per-row random pick with controlled cardinality. · tokenize:dict: · Source value (HMAC) · Stable per-value surrogates — the same input value maps to the same dict entry in every table and column. · The distinction is the point: randomize:dict can send two rows with the same value but different PKs to different entries, whereas tokenize:dict guarantees every occurrence of a value (anywhere) maps to the same surrogate — so analytics joins on the redacted column stay coherent. Dictionaries must be declared in YAML; a CLI reference to an undeclared dict name refuses at parse time. ## Determinism Redaction output is deterministic, which is what makes it safe to re-run — CDC resume and backup → restore reproduce identical surrogates on the same data. There are four contracts: Semantics · Strategies · Guarantee · Stateless · null, static:, truncate:, hash:sha256, all mask:* · Same input → same output on any sluice run, anywhere. · Keyed · hash:hmac-sha256 · Same input + same keyset key → same output. · PK-keyed replay-stable · randomize:* (incl. randomize:dict) · Same source row (table + column + PK) → same output across re-runs. · Input-keyed cross-stream · tokenize:dict · Same input value + same key → same output across tables, columns, and streams. · To correlate a redacted column across tables (a join key), use tokenize:dict or hash:hmac-sha256; the other strategies don't carry cross-table consistency on the same source value. ## The operator keyset (--keyset-source) The two keyed strategies — hash:hmac-sha256 and tokenize:dict — resolve their HMAC secret from an operator-controlled keyset (ADR-0041). Any rule using either strategy requires --keyset-source; sluice refuses loudly at preflight otherwise. The keyset is a small YAML document holding one or more named keys, each with generations so old surrogates keep resolving after a rotation. It resolves from three sources: # keyset YAML on disk --keyset-source=file:/etc/sluice/keyset.yaml # keyset YAML in an env var (container / secret-manager friendly) --keyset-source=env:SLUICE_KEYSET # sluice-managed sluice_keysets table on a DSN — shared across streams --keyset-source=db:postgres://user:pw@host:5432/keysetdb A rule names which key it uses via the trailing : segment (or a YAML key: field); omit it to use the keyset's declared default or its sole entry. Two rules that name the same key produce cross-consistent surrogates: --redact users.email=hash:hmac-sha256:customer_pii --redact users.first_name=tokenize:dict:first_names:customer_pii The db: form is the cross-stream stability primitive: two streams pointing at the same keyset DSN turn alice@example.com into the same surrogate on staging-1 and staging-2. For two independent installs to agree (cross-org exchange), install the same file: keyset at both ends. No hot-reload. The keyset is snapshotted once at process startup; a rotation takes effect only on the next restart. After a rotation, new rows get surrogates under the new active generation while existing target rows keep theirs — a clean rotation means re-running the migration under the new key. The security model is stable hashing, not secrecy: protect the key bytes with your storage layer — sluice does not encrypt them at rest. ## Preview redaction before you run sluice schema preview annotates the generated DDL so you can eyeball which columns are covered before moving a single row. The annotation is comment-only — the CREATE TABLE itself is unchanged, so the output stays drop-in usable: sluice schema preview \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --redact users.email=hash:sha256 \ --redact users.ssn=mask:ssn CREATE TABLE users ( id SERIAL PRIMARY KEY, email TEXT NOT NULL, -- REDACTED via hash:sha256 ssn TEXT, -- REDACTED via mask:ssn ... ); ## The audit log Every command that moves rows emits exactly one INFO line at startup recording the configured redaction surface — the scope, the column count, and the distinct strategy names: sluice: redaction configured scope=migrate columns=5 strategies=[hash:sha256 mask:pan randomize:email tokenize:dict:first_names truncate:4] Per-column rules are deliberately not logged — the mapping itself is sensitive (--redact billing.credit_card=truncate:4 reveals which column holds card numbers), and per-row surrogates are never logged. When a keyset loads, a second line records its source scheme and per-key generations, with any DSN credentials redacted. ## Worked examples ### Mask and hash for an analytics copy sluice migrate \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --redact users.email=hash:sha256 \ --redact users.phone=mask:inner:3,4 \ --redact users.ssn=randomize:ssn ### Realistic synthetic data for a live staging sync Redaction is honoured on the CDC stream too, so staging stays continuously fresh and continuously scrubbed. YAML config plus a stream id: # sluice.yaml redactions: - table: users.email strategy: randomize form: email - table: users.phone strategy: randomize form: us-phone - table: customers.pan strategy: randomize form: pan brand: visa sluice sync start -c sluice.yaml \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --stream-id staging-refresh ### Cross-table stable surrogates for a vendor handoff Use tokenize:dict with one shared key so a customer's name is the same token in every table — the vendor can still join, but never sees the real value: # sluice.yaml dictionaries: first_names: entries: [Alpha, Bravo, Charlie, Delta, Echo, Foxtrot] redactions: - table: users.first_name strategy: tokenize dict: first_names key: customer_pii - table: orders.customer_first_name strategy: tokenize dict: first_names key: customer_pii sluice migrate -c sluice.yaml \ --source-driver postgres --source "$SRC" \ --target-driver postgres --target "$DST" \ --keyset-source=file:/etc/sluice/keyset.yaml ## What redaction is not - Not a PII discovery scanner. sluice redacts the columns you name; it does not crawl the schema to find which columns hold personal data. Identifying them is your (or your compliance team's) job. - Not encryption at rest. Redaction transforms values in flight so the sensitive original never reaches the target or the backup. Protecting the keyset secret and the target storage itself is your storage layer's responsibility — sluice does not encrypt the key bytes at rest. ## Next steps - Configuration — the YAML redactions:, dictionaries:, and keyset blocks in full. - Command reference — the flag set for migrate, sync, backup, and schema preview. - Getting started — install sluice and run your first migration and sync. =============================================================== # Prepare a Postgres source (https://sluicesync.com/docs/postgres-source-prep/) What a Postgres source needs before it can feed a continuous sync — the required GUCs, the REPLICATION role attribute, replication-slot lifecycle, and the slot-less path for managed Postgres. A one-shot migrate from Postgres needs only SELECT and works anywhere, including locked-down managed tiers. Continuous sync is different: sluice's default Postgres CDC engine reads changes through a logical replication slot, which needs a handful of cluster settings and a role privilege. This guide is the practical checklist — set these before sync start, and if your host forbids them, jump to the slot-less trigger path at the end. ## Required GUCs Logical replication is gated by a small set of server parameters. Check them as a superuser on the source: SHOW wal_level; -- must be 'logical' SHOW max_replication_slots; -- >= 2 x replicas SHOW max_wal_senders; -- >= 2 x replicas, and >= max_replication_slots SHOW max_slot_wal_keep_size; -- '> 4GB' recommended; '-1' = unlimited (risky) - wal_level = logical — required. Changing it needs a cluster restart; it cannot be set live. - max_replication_slots and max_wal_senders — sized for your replica count; both need a restart to change. - max_slot_wal_keep_size — strongly recommended > 4GB (live-reloadable). The default -1 means "retain WAL until the disk fills," which is its own bad day; a bounded cap lets a slot recover from a short consumer outage without one stuck slot filling the disk. - For PG 17+ HA, also enable sync_replication_slots = on and hot_standby_feedback = on — see slot survival under failover. If wal_level is not logical, sluice's CDC reader fails the precondition check at startup — before it touches any slot — with a clear message rather than a mid-stream surprise: postgres: cdc: wal_level is "replica"; must be 'logical' for logical replication (set wal_level=logical in postgresql.conf and restart) Logical WAL costs more. Flipping wal_level from replica to logical raises the WAL byte-rate — roughly 1.2x–1.6x on a typical OLTP workload, more on wide TEXT/JSONB rows under REPLICA IDENTITY FULL. That multiplier also applies to WAL a lagging slot retains, so budget max_slot_wal_keep_size (and your backup/replica bandwidth) accordingly. Measure your own workload at logical before depending on it in production. ## The REPLICATION role attribute The role sluice connects as must be a superuser or carry the REPLICATION attribute — creating a logical slot requires it: ALTER ROLE sluice_user WITH REPLICATION; sluice does not silently degrade to polling when this is missing. A preflight probe (reading the world-readable pg_roles.rolsuper OR rolreplication) runs before the CDC reader opens, and refuses loudly — naming the role and every recovery path — rather than letting slot creation fail opaquely mid-cold-start with a raw ERROR: permission denied to create replication slot (SQLSTATE 42501): the source connecting role "app_user" is not a superuser and lacks the REPLICATION attribute. Slot-based Postgres CDC (--source-driver=postgres) creates a logical replication slot at cold start ... Recovery: (a) grant the attribute: ALTER ROLE app_user REPLICATION; (b) re-run with a superuser or replication-enabled role; (c) on managed Postgres that forbids the REPLICATION attribute (Heroku Postgres Essential, Render Basic, Supabase free), use --source-driver=postgres-trigger There is deliberately no --allow-missing-replication escape hatch: the role genuinely cannot create a slot, so the honest choices are to grant the attribute, swap roles, or use the slot-less engine. This refusal fires only on the slot-based CDC path — a pure bulk migrate is unaffected. ## The replication slot sluice creates one logical slot per stream, named sluice_slot by default. Override it with --slot-name; sluice prepends sluice_ if your value doesn't already start with it (so --slot-name shard_a creates sluice_shard_a). The convention lets you find every sluice-owned slot with WHERE slot_name LIKE 'sluice\_%'. Give concurrent sluice instances against the same source distinct slot names — without them they collide on the default. List and drop slots from the CLI without dropping to psql: # List every slot on the source (columns mirror pg_replication_slots) sluice slot list --source-driver postgres --source 'postgres://user:pass@host:5432/app' # Drop a named slot (prompts for confirmation; --yes skips it, # --force drops an active slot, --if-exists treats a missing slot as success) sluice slot drop sluice_slot --source-driver postgres --source 'postgres://user:pass@host:5432/app' When you start a stream and setup fails partway (publication permissions, START_REPLICATION rejection, cancellation), the freshly-created slot is auto-dropped before the error returns — so failed cold-start attempts don't leave sluice_slot-named slots behind. Auto-cleanup deliberately skips a slot that pre-existed the call (it may carry someone else's progress) and a slot whose pump already emitted positioned changes (that's user data); for those, sluice slot drop is the explicit path. ## Slot survival under failover This is the part that bites people. A logical slot is a primary-local object by default — when the primary fails over, the slot does not move to the new primary, and a slot left behind is silently lost: no error, no warning, your CDC stream just begins missing changes. Confirm one slot-preservation mechanism is actually configured before betting production on it: - PlanetScale Postgres (Patroni): add the slot name to the "Logical slot name" field under Cluster configuration → Parameters → Failover (comma-delimited for multiple consumers). Slots not listed there are lost on failover. - Self-hosted Patroni: declare it under slots: as a permanent logical slot (type logical, plugin pgoutput). - PG 17+ native sync: sync_replication_slots = on plus hot_standby_feedback = on. - Vanilla Postgres without HA: nothing to do — there's no failover — but still monitor slot health. The idle-slot trap. Even with all three mechanisms configured, a slot that hasn't advanced during the slot-sync window can still be lost on failover: the standby's copy stays at an old LSN, and promotion leaves it pointing at recycled WAL (wal_status='lost' on resume). The durable fix is to keep the slot advancing — run sync start continuously (its CDC reader sends a standby-status keepalive every 10s), and on quiet sources inject lightweight WAL. sluice has this built in. ### Keeping an idle slot alive Set --source-heartbeat-interval and sluice INSERTs a row into a source-owned table (default sluice_heartbeat) on each interval; the write generates WAL, advancing the consumer position against an idle source and preventing slot eviction (ADR-0061 / F17): sluice sync start \ --source-driver postgres --source 'postgres://user:pass@host:5432/app' \ --target-driver mysql --target 'user:pass@tcp(host:3306)/app' \ --stream-id app \ --source-heartbeat-interval 30s It is opt-in (0, off, by default) because the INSERT is a behaviour change on the source that regulated systems must enable explicitly. The heartbeat table is auto-created and periodically pruned (--source-heartbeat-prune-window, default 1h); on a role without CREATE TABLE the streamer WARNs once and continues without it. Rename the table with --source-heartbeat-table-name, or silence the warning with --no-source-heartbeat. ## Slot health and telemetry A logical slot moves through these states, visible in pg_replication_slots.wal_status: wal_status · Meaning · reserved · Healthy — all required WAL is on disk. · extended · Healthy but the consumer is behind; the slot holds more WAL than max_wal_size. · unreserved · Required WAL has left pg_wal but is still recoverable. · lost · Required WAL is gone. The slot exists but cannot be used — silent-loss-class for CDC. · When sluice sees a slot in unreserved or lost state it refuses to start replication and points at the recovery path — sluice slot drop on the source, then restart with an empty position to force a fresh snapshot, and raise max_slot_wal_keep_size to prevent recurrence. After dropping the slot, get past the cold-start refusal on the (partially-streamed) target with sync start --reset-target-data --yes (clears sluice's state and drops the source-schema tables it manages, then re-snapshots; see ADR-0023). For proactive monitoring, sluice surfaces PG 14+ per-slot decode-spill counters (large transactions spilling the ReorderBuffer to disk — sustained spill is what can fill pg_replslot/ and invalidate a slot) in two places: # sync health prints them when the source is PG 14+ and the slot has decoded sluice sync health --source-driver postgres --source ... \ --target-driver postgres --target ... --stream-id app ... spill_txns: 17 spill_bytes: 5242880 # Prometheus /metrics (when --metrics-listen is set on sync start) sluice_pg_slot_spill_txns_total{stream_id="app",slot="sluice_slot"} 17 sluice_pg_slot_spill_bytes_total{stream_id="app",slot="sluice_slot"} 5242880 Both counters are cumulative since slot creation, so alert on the rate (rate(sluice_pg_slot_spill_bytes_total[5m])). sluice deliberately omits the lines — rather than printing 0 — when it can't tell (PG < 14, the slot hasn't decoded yet, or a non-Postgres source), so "no signal" is never mistaken for "no spill." If they climb, raise logical_decoding_work_mem on the source (live-reloadable) and split oversized application transactions. ## Managed / locked-down Postgres: the slot-less trigger engine When the host forbids logical replication — Heroku Postgres, RDS without the right grants, Supabase / Crunchy starter tiers — you cannot get a replication slot at all. sluice's answer is the postgres-trigger engine: per-table plpgsql triggers write every change into a capture table (sluice_change_log) and the engine tails it — Bucardo-style CDC with no slot and no REPLICATION attribute (ADR-0066). The lifecycle is explicit — setup → run → teardown — so the source-side DDL is visible at the CLI, never silently applied on first sync. 1. Install the capture triggers. --tables is required. On a tier that also denies event-trigger creation (needed for automatic DDL detection), add --allow-polled-fingerprint to opt into the weaker polled schema-fingerprint fallback — the command refuses loudly without it so you acknowledge the trade-off: sluice trigger setup \ --source-driver postgres-trigger \ --dsn 'postgres://user:pass@host:5432/app' \ --tables orders,customers,line_items \ --allow-polled-fingerprint 2. Stream with the trigger engine. The source driver is postgres-trigger; everything else is an ordinary sync start: sluice sync start \ --source-driver postgres-trigger --source 'postgres://user:pass@host:5432/app' \ --target-driver postgres --target 'postgres://user:pass@target:5432/app?sslmode=require' \ --stream-id app 3. Tear down cleanly when the stream is finished — this drops every per-table trigger and (by default) the sluice_change_log table, leaving zero residue. Pass --keep-data to retain the change-log for forensics, or --yes to skip the confirmation prompt: sluice trigger teardown \ --source-driver postgres-trigger \ --dsn 'postgres://user:pass@host:5432/app' --yes The connecting role needs CREATE on the target schema, TRIGGER on each replicated table, and INSERT on sluice_change_log — a much smaller ask than REPLICATION. Tune how much of each row the capture writes with --capture-payload (full / changed / minimal), and reap durably-applied change-log rows while the sync runs with sluice trigger prune. The full command surface is in the trigger reference, and the trigger-CDC walkthrough lives in Getting started. ## Next steps - sync start — every flag for the continuous-sync command, including --metrics-listen and the notify thresholds. - trigger setup / teardown — the slot-less engine's full reference. - Getting started: trigger-based CDC — a worked slot-less walkthrough. =============================================================== # Sync from managed Postgres without a replication slot (https://sluicesync.com/docs/managed-postgres-slotless/) Heroku Postgres, RDS without grants, Supabase / Crunchy starter tiers — managed Postgres that forbids logical replication still streams via sluice's trigger-based postgres-trigger engine. No slot, no REPLICATION attribute. sluice's default Postgres CDC engine reads the write-ahead log through a logical replication slot — which needs the connecting role to be a superuser or carry the REPLICATION attribute. Plenty of managed tiers forbid exactly that. For those, sluice ships a deliberate slot-less path: the postgres-trigger engine captures changes with per-table triggers instead of a slot. This guide covers when you need it, the explicit setup → run → teardown lifecycle, and the flagship Heroku Postgres → PlanetScale move. ## When you need slot-less CDC A one-shot migrate from Postgres needs only SELECT and runs anywhere, including the most locked-down tiers. Continuous sync is where the slot requirement bites: creating a logical replication slot requires the REPLICATION role attribute, and these managed tiers don't grant it: - Heroku Postgres — no rolreplication, no CREATE_REPLICATION_SLOT, no event-trigger creation. The canonical case. - AWS RDS without the right grants — logical replication is off unless the parameter group and role grants are set up for it. - Supabase / Crunchy Bridge starter tiers — the starter roles don't carry the attribute. - PlanetScale Postgres custom pscale_api_* roles — these API roles lack REPLICATION; slot-based CDC into PS-PG needs the Default postgres role. (Full detail in the PlanetScale Postgres guide.) sluice does not silently degrade to polling when the slot path is unavailable. The slot-based reader runs a preflight probe before it opens — reading the world-readable pg_roles.rolsuper OR rolreplication — and refuses loudly, naming the role and pointing straight at this engine, rather than letting slot creation fail opaquely mid-cold-start with a raw ERROR: permission denied to create replication slot: the source connecting role "app_user" is not a superuser and lacks the REPLICATION attribute. Slot-based Postgres CDC (--source-driver=postgres) creates a logical replication slot at cold start ... Recovery: (a) grant the attribute: ALTER ROLE app_user REPLICATION; (b) re-run with a superuser or replication-enabled role; (c) on managed Postgres that forbids the REPLICATION attribute (Heroku Postgres Essential, Render Basic, Supabase free), use --source-driver=postgres-trigger There is deliberately no --allow-missing-replication escape hatch: the role genuinely cannot create a slot, so the honest choices are to grant the attribute, swap roles, or take this slot-less path. The postgres-trigger engine installs per-table plpgsql AFTER triggers that write every change into a capture table (sluice_change_log); the engine tails that log — Bucardo-style CDC with no slot and no REPLICATION attribute (ADR-0066). The lifecycle is explicit, so the source-side DDL is visible at the CLI, never silently applied on first sync. ## 1. Install the capture triggers sluice trigger setup installs the change-log table, the capture function, and the per-table triggers. --tables is required — name every table you want captured: sluice trigger setup \ --source-driver postgres-trigger \ --dsn 'postgres://user:pass@host:5432/app' \ --tables orders,customers,line_items \ --allow-polled-fingerprint On a tier that also denies event-trigger creation (Heroku is one) automatic DDL detection can't use an event trigger, so add --allow-polled-fingerprint to opt into the weaker polled schema-fingerprint fallback. The command refuses loudly without it, so you explicitly acknowledge the trade-off rather than silently getting the degraded DDL-detection mode. The connecting role needs CREATE on the target schema, TRIGGER on each replicated table, and INSERT on sluice_change_log — a much smaller ask than REPLICATION. Preview the exact DDL without touching the source with --dry-run; the full set of objects it installs is listed under Objects sluice creates. Tune how much of each changed row the capture writes with --capture-payload (full, the default, keeps the full before- and after-image; changed trims the after-image to PK + changed columns; minimal reduces the apply to a last-write-wins PK match — safe for one-way CDC with no concurrent target writers, and it reaches toward roughly 2× source-write overhead instead of more). ## 2. Stream with the trigger engine The source driver is postgres-trigger; everything else is an ordinary sync start — cold-copy first, then CDC tailed off the trigger log, with the same value fidelity, warm-resume, and encryption as any sluice sync: sluice sync start \ --source-driver postgres-trigger --source 'postgres://user:pass@host:5432/app' \ --target-driver postgres --target 'postgres://user:pass@target:5432/app?sslmode=require' \ --stream-id app Cross-engine directions. A postgres-trigger source can stream to a Postgres target and to a MySQL / PlanetScale-MySQL target — PG ↔ MySQL is sluice's supported cross-engine direction, and the trigger engine counts as a Postgres source for that purpose. Set --target-driver mysql (or planetscale) and the target DSN accordingly. The same PG-native shapes that have no clean MySQL form — PostGIS geometry, pg_trgm operator-class indexes, EXCLUDE constraints — refuse loudly before any data moves, exactly as they do for the vanilla postgres source. The source change-log grows for the life of a continuous sync; reap durably-applied rows while the sync runs with sluice trigger prune (it reads the target's durably-applied frontier as the only safe lower bound and refuses to prune blind). See the trigger reference for its flags. ## 3. Tear down cleanly When the stream is finished, sluice trigger teardown drops every per-table trigger and (by default) the sluice_change_log table, leaving zero residue on the source: sluice trigger teardown \ --source-driver postgres-trigger \ --dsn 'postgres://user:pass@host:5432/app' --yes --yes skips the destructive-action confirmation prompt (for scripted/CI use). Pass --keep-data to retain the change-log table for forensics instead of dropping it. Teardown is idempotent — re-running against a partially-uninstalled source proceeds cleanly via DROP ... IF EXISTS. ## Heroku Postgres → PlanetScale Heroku Postgres forbids replication slots outright, so it's the canonical postgres-trigger scenario. The three commands above work standalone against a Heroku source — read the DATABASE_URL fresh at each invocation (Heroku rotates it under failover) and append ?sslmode=require (Heroku rejects non-TLS connections): sluice trigger setup \ --source-driver postgres-trigger \ --dsn "$(heroku config:get DATABASE_URL --app myapp)?sslmode=require" \ --tables users,orders,items \ --allow-polled-fingerprint sluice sync start \ --source-driver postgres-trigger \ --source "$(heroku config:get DATABASE_URL --app myapp)?sslmode=require" \ --target-driver postgres \ --target 'postgres://...your-target...?sslmode=require' \ --stream-id heroku-myapp For a hands-off, dashboard-driven move there's a packaged wrapper: sluice-heroku-migrator — a fork of PlanetScale's heroku-migrator with the replication engine swapped from Bucardo to sluice's postgres-trigger engine. Because sluice is a lightweight Go binary rather than an embedded PostgreSQL daemon, it deploys on a Standard-1x/2x dyno regardless of database size. It packages the same setup → sync → cutover flow this guide runs by hand — with TCP keepalives tuned for cloud NAT and psql-based status/cutover — behind a four-phase dashboard (Setup, Data Sync, Traffic Switch, Complete). You deploy it as a Heroku container app, set the HEROKU_URL, PLANETSCALE_URL, and PASSWORD config vars, and drive the phases from its dashboard. Prerequisites it enforces: every table has a primary key, the required extensions exist on the PlanetScale side, schema migrations are paused during the move, and the target has 1.5–2× the Heroku data size provisioned. The wrapper only automates the manual flow above — nothing it does isn't reproducible with the three sluice commands directly. The --tables-first, explicit-lifecycle shape is what makes the trigger engine safe to run on someone else's managed database: nothing is installed on the source until you name it, and teardown removes every trace. This is a deliberate operability contrast with trigger tools that install capture state implicitly and leave residue behind. ## Next steps - Prepare a Postgres source — the slot-based path's required GUCs, the REPLICATION attribute, and slot lifecycle (the engine this guide is the alternative to). - Getting started: trigger-based CDC — a worked slot-less walkthrough. - trigger setup / teardown / prune — the slot-less engine's full command reference. - Verify & reconcile — confirm the target matches the source after the copy, identical to any sluice sync. =============================================================== # Migrating from Neon with sluice (https://sluicesync.com/docs/migrate-from-neon/) Direct vs -pooler endpoints (and why CDC needs the direct one), the irreversible enable_logical_replication project setting, TLS interop, and the live-validated migrate + continuous-sync recipe. Neon is managed Postgres, so sluice drives it with the vanilla postgres engine — no flavor, no special driver. It has been live-validated as a migration and CDC source (Neon → PlanetScale Postgres, 2026-07-15): fidelity was byte-identical on md5 ground truth including the hard value families — NaN inside numeric[], ±Infinity, denormal floats, and 2-D arrays with NULL elements — and the snapshot → CDC handoff and post-handoff convergence were clean. What is Neon-specific is the endpoint model and how logical replication gets enabled. This guide covers both. ## Direct vs pooler endpoints — pick by workload Every Neon branch has two hostnames for the same database: Endpoint · Hostname shape · What it is · Direct · ep--..aws.neon.tech · A real Postgres backend connection. · Pooled · same, with a -pooler suffix on the first label · pgbouncer in transaction mode. · - CDC requires the direct endpoint. A pooler cannot proxy replication-protocol commands — it strips the replication=database startup parameter, so the slot-creation command reaches a normal backend as plain SQL and is rejected as a syntax error. sluice recognizes that exact signature: sync start against the pooled host fails at slot creation with the coded SLUICE-E-CDC-POOLER-ENDPOINT refusal, which names the remedy (point --source at the direct host). - Bulk migrate through the pooler works — a full snapshot-pinned parallel migrate passed through it at validation scale — but sluice's parallel copy pins server connections inside long-lived snapshot transactions, which risks pool exhaustion mid-copy at higher parallelism or scale, with a confusing failure when it hits. sluice emits a preflight WARN when the source host matches the -pooler pattern. Prefer the direct endpoint for both modes. ## Enabling logical replication (the project setting, not postgresql.conf) Neon defaults to wal_level=replica. sluice's CDC preflight checks this before touching any slot and refuses loudly — the message points at the provider matrix in Prepare a Postgres source. The fix on Neon is not a GUC edit: it's the project setting enable_logical_replication (console: Settings → Logical replication; also settable via the project-update API). Two things to know before you flip it, both validated live: - The toggle is irreversible. Once enabled on a project, it cannot be turned back off. - It takes effect in seconds, with no visible downtime. No restart window to plan. Bulk migrate does not need it — only continuous sync (a replication slot) does. ## The validated recipe One-shot copy (dry-run first, then drop the flag): sluice migrate \ --source-driver postgres --source 'postgres://user:pass@ep-my-branch-123456.us-east-2.aws.neon.tech/neondb?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --dry-run Continuous sync — direct endpoint, after enabling logical replication: sluice sync start \ --source-driver postgres --source 'postgres://user:pass@ep-my-branch-123456.us-east-2.aws.neon.tech/neondb?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id neon-app Connection notes from the validation run: - TLS: Neon DSNs work with sslmode=require, and sslmode=verify-full also works with the standard system roots — prefer it (see the sslmode note). - Region co-location matters. The validation runs were cross-provider; co-locating the sluice process (or the target) with the Neon region measurably reduces snapshot wall-clock. - Autosuspend / cold-start is unprobed. The validation project stayed active throughout, so scale-to-zero resume latency under a sluice snapshot has not been characterized. If you run against an autosuspending endpoint and see slow first-connection behaviour, that's the place to look. ## The wal_proposer_slot you'll see in slot listings Every Neon endpoint carries an always-present physical replication slot named wal_proposer_slot — it's part of Neon's safekeeper architecture, not a leaked consumer. sluice's slot-health monitoring correctly ignores it. If you enumerate slots with your own tooling (or sluice slot list), expect to see it and leave it alone; only sluice_-prefixed logical slots belong to sluice. ## What sluice checks for you - SLUICE-E-CDC-POOLER-ENDPOINT — sync start against the -pooler host fails at slot creation with a coded refusal explaining that a pooler cannot proxy replication, naming the direct-endpoint remedy. - Pooler-host preflight WARN — migrate and sync start warn up front when the source hostname matches the -pooler label pattern, before any pool-exhaustion surprise. - wal_level preflight refusal — CDC against a project without logical replication enabled refuses at startup (before touching any slot), pointing at Neon's enable_logical_replication setting via the provider matrix. - SLUICE-E-CDC-REPLICATION-PERMISSION — if the connecting role lacks the REPLICATION attribute, the preflight refuses with the exact ALTER ROLE remedy rather than failing opaquely mid-cold-start. - Slot-health monitoring that ignores wal_proposer_slot — Neon's internal slot is never flagged as a leaked consumer. ## Next steps - Prepare a Postgres source — the full slot lifecycle, retention, and failover story (provider matrix included). - Verify & reconcile — confirm the target matches the source after the copy. - Field note: replication slots don't die with your process — why an abandoned slot pins WAL on the source. - Field note: the idle-slot failover trap — slot survival on HA-managed Postgres. =============================================================== # Migrating from Supabase with sluice (https://sluicesync.com/docs/migrate-from-supabase/) Session vs transaction Supavisor pooler modes, the IPv6-only free-tier direct endpoint and what it means for CDC, float display vs float identity, and the platform schemas sluice already scopes out. Supabase is managed Postgres, so sluice drives it with the vanilla postgres engine. It has been live-validated as a bulk-migration source (2026-07-15) with bit-exact fidelity through both Supavisor pooler modes, and as a continuous-CDC source (2026-07-16) over the direct endpoint. Unlike Neon, wal_level=logical is on out of the box — the thing that gates CDC on Supabase is not a setting but network reachability of the direct endpoint. That's the first thing to understand. ## The IPv6-only direct endpoint (the thing that bites first) Supabase free-tier direct endpoints (db..supabase.co) have only an AAAA record — IPv4 connectivity to the direct endpoint is a paid add-on. From an IPv4-only machine the connection fails in about a second with the platform resolver's cryptic no-data error. sluice detects this class: on a resolve failure it probes for an AAAA record and, when the host is IPv6-only, extends the error with the remedy — the coded SLUICE-E-CONNECT-IPV6-ONLY. - Bulk migrate: use the pooler endpoint (aws--.pooler.supabase.com — it has an A record). Validated bit-exact. - CDC: the direct endpoint is required — a pooler cannot proxy the replication protocol — so from an IPv4-only network you need Supabase's IPv4 add-on or an IPv6-capable network. sync start through Supavisor fails at slot creation with the coded SLUICE-E-CDC-POOLER-ENDPOINT refusal explaining exactly this. ## Session vs transaction pooler modes Supavisor exposes two ports on the pooler hostname, and they behave differently under sluice's parallel copy: Mode · Port · Behaviour under sluice · Session · :5432 · Bulk migrate works, including parallel copy. Validated bit-exact. · Transaction · :6543 · Server connections rotate per transaction, which trips pgx's statement cache (SQLSTATE 42P05, “prepared statement already exists”). sluice WARNs and falls back to the single-reader copy path — correct, but parallel copy is unavailable in this mode. · Prefer session mode (or the direct endpoint) for large copies. sluice's pooler-host preflight WARN fires for both modes — the hostname matches the pooler.supabase.com pattern either way: # Bulk migrate through the session pooler (IPv4-friendly) sluice migrate \ --source-driver postgres --source 'postgres://postgres.abcdefghijkl:pass@aws-0-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --dry-run # CDC needs the DIRECT endpoint (IPv6-only on the free tier) sluice sync start \ --source-driver postgres --source 'postgres://postgres:pass@db.abcdefghijkl.supabase.co:5432/postgres?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id supabase-app CDC readiness. wal_level=logical is Supabase's default — nothing to enable (contrast Neon's project toggle). CDC is validated end-to-end against the direct endpoint (2026-07-16): cold-start snapshot → logical-slot CDC → INSERT/UPDATE/DELETE convergence, a clean stop, and exactly-once warm-resume — with no slot leaked on the managed instance. The only prerequisite is reaching the direct endpoint: the free-tier default is IPv6-only, so from an IPv4-only host enable Supabase's IPv4 add-on (it swaps the endpoint's AAAA record for an A record while enabled, and reverts about ten seconds after you disable it — so don't disable it under a running stream from an IPv4-only host). A read replica is not a CDC source — sluice refuses a Supabase -rr- standby with SLUICE-E-CDC-STANDBY-SOURCE and steers you to the primary; see Migrating from a Supabase read replica for that recipe plus the CDC-preflight facts (slot budget, WAL-retention, PITR). See Prepare a Postgres source for the slot checklist. ## Float display is not float identity Supabase servers default extra_float_digits=0, so text-level float comparisons against a Supabase source mislead: a value can print rounded while the stored bits are exact. sluice's copy was proven bit-exact via float8send ground truth. If you diff sluice's output with external tooling that compares text, pin the session first — or better, compare the send-function bytes: SET extra_float_digits = 1; -- make text output round-trip-exact SELECT md5(string_agg(float8send(col)::text, ',' ORDER BY id)) FROM t; -- or compare bits directly A “mismatch” that disappears under extra_float_digits=1 was never a data difference. ## Platform schemas Supabase ships its platform schemas (auth, storage, realtime, …) alongside public. sluice's default public scoping ignores them — there is nothing to exclude manually, and your migrated target gets your tables, not the platform's. ## What sluice checks for you - SLUICE-E-CONNECT-IPV6-ONLY — a resolve failure against an IPv6-only host (the free-tier direct endpoint, from an IPv4-only network) is diagnosed with an AAAA probe and refused with the remedy: pooler endpoint for bulk migrate, IPv4 add-on or IPv6 network for CDC. - SLUICE-E-CDC-POOLER-ENDPOINT — slot creation through Supavisor is recognized by its SQLSTATE 42601 signature and refused with the direct-endpoint remedy, instead of surfacing as a bare syntax error. - Pooler-host preflight WARN — both Supavisor ports match the pooler.supabase.com pattern and warn before the copy starts. - Transaction-mode fallback WARN — the SQLSTATE 42P05 statement-cache signature on :6543 triggers a WARN and a correct single-reader fallback rather than a failed copy; the WARN is your signal to switch to :5432 for parallelism. ## Next steps - Migrating from a Supabase read replica — the bulk-from-replica recipe, the CDC-standby refusal, and the corrected CDC-preflight facts. - Prepare a Postgres source — the CDC checklist once the direct endpoint is reachable. - Managed Postgres (slot-less) — the trigger-CDC path for tiers whose roles can't create slots. - Verify & reconcile — sluice's own verification, which compares values (not display text). =============================================================== # Migrating from a Supabase read replica with sluice (https://sluicesync.com/docs/migrate-from-supabase-read-replica/) A Supabase read replica (an -rr- endpoint) is a fine bulk-migrate source that offloads the copy's read load from your primary — PG 16+ standby parallel snapshots engage unreduced — but CDC is refused: a replica can't host the sluice publication, so continuous sync must point at the primary. Plus the corrected CDC-preflight facts and how to verify safely against a lagging replica. A Supabase read replica is managed Postgres running as a streaming-replication standby, so sluice drives it with the vanilla postgres engine. Live-probed 2026-07-17 (PG 17 replica, same region as its primary): it works as a bulk sluice migrate source with the full consistency story, and it is refused as a CDC source with a coded steer to the primary. This guide covers both, plus verifying safely against a replica. For the primary-endpoint essentials (IPv6-only direct host, Supavisor pooler modes, TLS, float display), start with the main Supabase guide. ## Bulk migrate: a first-class source A read replica is a full bulk-migration source, and the reason to use one is load: it offloads the snapshot's read cost from the production primary. The consistency story is not reduced. pg_export_snapshot() is legal on a PG ≥ 16 standby, so sluice's parallel, snapshot-pinned copy engages unreduced on the replica — one shared snapshot across every table and chunk reader, byte-exact (float8send-proven), evaluated at the replica's replay position. sluice migrate \ --source-driver postgres \ --source 'postgres://postgres.abcdefghijkl-rr-us-east-1-xyz:pass@aws-0-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --dry-run ## Reaching the replica: DSN shapes A replica has its own hostnames, and the routing differs by whether you use the direct endpoint or the pooler: Path · How the replica is addressed · Direct · db.-rr--.supabase.co:5432 — its own hostname, IPv6-only exactly like the primary. The IPv4 add-on covers replicas too (one PATCH gives both endpoints A records), but Supabase bills IPv4 per database, so it costs 2× while a replica exists. · Pooler · Same host/port as the primary — routing is by username: postgres. reaches the primary, postgres.-rr-- reaches the replica (session mode :5432 for sluice). · The password is identical to the primary (the role catalog replicates physically). Sanity-check which end you're on with SELECT pg_is_in_recovery() — t means you're on the replica. ## CDC: point at the primary, never the replica CDC has to manage the sluice publication on the source, and CREATE/ALTER PUBLICATION cannot run on a standby. sluice refuses up front with the coded SLUICE-E-CDC-STANDBY-SOURCE steer (before the fix this surfaced as a raw SQLSTATE 25006 “read-only transaction” at publication ensure). PG 16+ standbys can technically host logical slots, but creation blocks on the primary's next running-xacts record and Supabase denies the pg_log_standby_snapshot() nudge that would unblock it — so the primary is the supported CDC source. Point sync start (and backup CDC chains) at db..supabase.co, not the -rr- host. ## The CDC preflight facts (on the primary) Once you're on the primary, three preconditions are worth knowing precisely — the first two are commonly mis-stated: - Slots and senders default to 10 on a fresh Micro project (max_replication_slots=10, max_wal_senders=10) — plenty for one stream, but a shared budget if Realtime or ETL also consume the project. An earlier probe recorded 5/5; Supabase raised the platform default, so treat the exact number as observed in-band, not fixed. - max_slot_wal_keep_size scales with the COMPUTE tier, not PITR — 512 MB on Micro, 2048 MB on Small+ (field-validated 2026-07-17, PG 17.6). This is the real WAL runway a detached or lagging stream has, not a paper setting: in a paired probe a detached logical slot on Small survived 1377 MB of retained WAL still wal_status='reserved' (~2.7× the Micro bound), while the identical slot on Micro was invalidated once retained WAL crossed ~512 MB — wal_status='unreserved' at 551 MB, then wal_status='lost' (invalidation_reason='wal_removed') after the next checkpoint, forcing a re-snapshot. The lever for a wider window is a compute-tier bump (Small → 2 GB, ~$15/mo), not the PITR add-on (~$100/mo) — PITR only reaches 2 GB transitively because it requires Small compute. sluice's slot-health probe pages at 70/85% of whatever the live bound is; keep detach windows short. - PITR is CDC-benign. Unlike Cloud SQL's binlog toggle (which destroys positions), enabling Supabase PITR leaves wal_level, max_wal_senders, max_replication_slots, and the logical-slot LSN untouched, adds no platform slot, and never rewinds a slot's position — it only extends archived-WAL retention. A sluice CDC stream behaves identically with PITR on or off. ## Verifying against a replica: gate on replay lag sluice verify against a lagging replica compares the target with the replica's past — it can false-flag rows the copy correctly took from the primary moments earlier, or false-clean a stale target. Prefer verifying against the endpoint you copied from (self-consistent), and treat the primary as the authoritative sign-off target. If you must verify against a replica, first confirm pg_stat_replication.replay_lag on the primary is ≈ 0 (or that receive-LSN == replay-LSN on the replica). Two operator traps: - now() - pg_last_xact_replay_timestamp() on the replica reads minutes of “lag” on an idle primary — it timestamps the last replayed transaction, not the true lag. Check replay_lag on the primary instead. - A long copy from a replica under primary write load can be cancelled by WAL-replay recovery conflicts once it outlives max_standby_streaming_delay. sluice fails loudly; re-run, or copy from the primary. ## What sluice checks for you - SLUICE-E-CDC-STANDBY-SOURCE — a CDC source that is a read-only standby (pg_is_in_recovery() = true) is refused up front with the primary-endpoint remedy, instead of surfacing later as a raw read-only-transaction error at publication ensure. - Unreduced parallel snapshot on the standby — because pg_export_snapshot() works on a PG 16+ standby, sluice does not silently downgrade to a single-reader copy; the shared-snapshot parallel copy engages, byte-exact. - Slot-health paging on the live bound — the probe reads the actual max_slot_wal_keep_size and pages at 70/85% of it, so the alert reflects your compute tier rather than a hardcoded assumption. ## Next steps - Migrating from Supabase — the primary-endpoint guide: IPv6-only direct host, Supavisor pooler modes, TLS, and float display vs identity. - Verify & reconcile — the verification sluice runs, and why it compares values, not display text. - Prepare a Postgres source — the slot lifecycle and retention story for the CDC leg on the primary. =============================================================== # Migrating from DigitalOcean MySQL with sluice (https://sluicesync.com/docs/migrate-from-digitalocean-mysql/) The ~13–16-minute binlog purge window on defaults (and the config-API knob that fixes it), the private CA via --source-tls-ca, sql_mode differences, and the resnapshot-livelock hazard sluice warns about. DigitalOcean Managed MySQL works with sluice's vanilla mysql engine — cold copy and the CDC handoff were validated live (2026-07-15, MySQL 8.4). One platform behaviour is important enough to headline, because it determines whether a continuous sync can survive at all on default settings. ## The ~13–16-minute binlog window on defaults On DO Managed MySQL defaults, an out-of-band platform reaper purges every binlog file roughly 13–16 minutes after creation — while @@binlog_expire_logs_seconds reads 259200 (3 days) and the DO config API shows no retention field until you first set one. The server variable does not reflect the platform's actual purge behaviour, and no SQL-level check can see the real window — which is why sluice's preflight signal is the DSN host pattern (*.db.ondigitalocean.com): sync and backup runs against that pattern emit a loud WARN naming the window and the remedy. Why it matters, concretely: - A CDC position older than the window is unrecoverable — the resume fails with “binlog purged” (ErrPositionInvalid), and the only path forward is a fresh snapshot. - A cold copy that takes longer than the window can livelock auto-resnapshot: each retry re-copies, exceeds the window again, and loses its position again. If your data size puts the copy anywhere near 13 minutes, set the retention knob before sync start. The fix (confirmed working) is DO's database config API — there is no SQL knob and no UI field until it's set once: # Seconds; accepted range 600-86400. 86400 (24 h) is the right value for migrations. curl -X PATCH "https://api.digitalocean.com/v2/databases//config" \ -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"config": {"binlog_retention_period": 86400}}' It takes effect immediately, no restart, and pre-existing binlogs stop being purged. One open question the validation could not settle: whether an attached binlog-dump connection holds the purger back on DO (on AWS RDS it provably does not). Until answered, treat the config-API knob as required for any DO CDC use rather than relying on a live stream to protect itself. Deciding deliberately about re-snapshots. By default a purged position auto-recovers with a fresh cold-start re-snapshot. If a full re-copy is expensive and you'd rather decide by hand, sync start --no-auto-resnapshot fails loudly with the recovery commands instead of re-copying — useful while you're still sizing the retention window. ## Connecting: the cluster's private CA DO clusters use a private CA, so neither system roots nor a bare ?tls=true can verify the server certificate. Fetch the cluster CA from the API and hand it to sluice with --source-tls-ca — CA-pinned verify-ca TLS (trust this CA, verify the chain, skip the hostname check that MySQL's SAN-less certificates can't satisfy): # The API returns the cluster CA base64-encoded curl -s "https://api.digitalocean.com/v2/databases//ca" \ -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" | jq -r '.ca.certificate' | base64 -d > do-ca.pem sluice migrate \ --source-driver mysql --source 'doadmin:pass@tcp(db-mysql-nyc3-12345.b.db.ondigitalocean.com:25060)/defaultdb' \ --source-tls-ca do-ca.pem \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --dry-run --source-tls-ca covers both the data connection and the binlog/CDC stream, and refuses if the DSN already sets tls= (one TLS decision, not two). The same flag exists on sync start, verify, and backup. The doadmin user has the replication grants sluice's binlog CDC needs — no extra GRANTs on defaults. ## sql_mode differences worth knowing - Default sql_mode includes ANSI — double-quoted strings are identifiers on this server. sluice's own SQL is unaffected, but anything you run manually against the source with "double quotes" behaves differently than on a stock MySQL. - sql_require_primary_key=true by default — keyless tables cannot be created on a DO target, and restoring keyless-table dumps there fails until the setting is relaxed. (Keyless tables are also the ones that can't take sluice's exact-UPDATE repairs — give them primary keys and everyone wins.) ## What sluice checks for you - The retention advisory WARN — sync and backup runs against a *.db.ondigitalocean.com host warn about the ~13–16-minute default purge window and name the config-API remedy. (The host pattern is the only reliable signal — the server variable can't be trusted on this platform, so the WARN is unconditional.) - Loud position-invalid recovery — a resume from a purged position surfaces as an explicit “persisted position is no longer valid” WARN and a fresh cold start, never a silent gap; --no-auto-resnapshot converts that into a hard stop with named recovery commands. - --source-tls-ca refusals — the flag refuses to combine with a DSN-level tls= setting, and refuses on non-MySQL engines (Postgres uses sslrootcert= in the DSN) instead of silently ignoring a security flag. - SLUICE-E-DRIVER-HOST-MISMATCH class checks — driver/DSN sanity is verified up front, before any connection. ## Next steps - Zero-downtime migration — the full sync → verify → cutover flow once CDC is stable. - Migrating from AWS RDS MySQL — the same purge-window class with a tighter window and a SQL-visible remedy. - Field note: MySQL's own certificate can't pass verify-full — why --source-tls-ca is verify-ca, not verify-full. - Field note: the transaction that lands in neither the snapshot nor the binlog — what the snapshot-position capture is protecting against. =============================================================== # Migrating from AWS RDS MySQL with sluice (https://sluicesync.com/docs/migrate-from-rds-mysql/) The detect-first binlog-retention advisory (what the WARN means and the one-line SQL remedy), 8.0-family parameter groups, the platform-blocked FTWRL, and the regional truststore bundle. AWS RDS for MySQL works with sluice's vanilla mysql engine — cold copy and the CDC handoff were validated live (2026-07-16, MySQL 8.4.9 on a db.t4g.micro). Aurora MySQL shares the endpoint suffix and the retention procedure below. Like DigitalOcean, the headline is binlog retention — but on RDS the truth is SQL-visible, so sluice can check it for you instead of warning blind. ## Binlogs purge in ~5–11 minutes on defaults With no retention configured, RDS purges each binlog file on a ~5-minute sweep once automated backups have uploaded it — observed lifetime ~5–11 minutes per file — while @@binlog_expire_logs_seconds reads 30 days. The server variable does not govern the RDS purger (same class as DigitalOcean), but unlike DO the real setting is visible in SQL: CALL mysql.rds_show_configuration → binlog retention hours, default NULL (“as soon as possible”). A CDC position older than the window is unrecoverable, and a cold copy longer than it can livelock auto-resnapshot — and RDS's window is tighter than DO's ~13–16 minutes: plan for the ~5-minute floor, not the ceiling. Before sync start or backup, run this on the source (master user, plain SQL, effective immediately, no restart): CALL mysql.rds_set_configuration('binlog retention hours', 24); -- range 1..168 (7 days max) CALL mysql.rds_show_configuration; -- verify: binlog retention hours = 24 The details that matter, all live-proven: - An attached, caught-up stream does NOT hold the purger back. Files sluice had already read were purged on schedule while the stream ran; a caught-up stream survives only because it sits on the active file. Any lag or disconnection beyond the window is fatal at defaults — set the knob first, don't rely on staying attached. - With the knob set, long gaps warm-resume cleanly. A stream that stayed detached for 35 minutes resumed and replayed its full backlog exactly; the same gap on defaults forced a cold start. - The 168-hour cap is real — a paused stream beyond 7 days is impossible on RDS MySQL, period. - Retained binlogs count against allocated storage; on tiny instances set the value back to NULL (or lower) after cutover. - Automated backups must be ON (retention ≥ 1 day) or RDS disables binary logging entirely — no binlogs, no CDC. What the WARN means. sluice's advisory here is detect-first: on sync/backup runs against an *.rds.amazonaws.com host it queries the retention setting and WARNs only when it is NULL or under 24 hours — a correctly configured source stays silent. If you see the WARN, the stream is running on borrowed time (~5–11 minutes of it); run the mysql.rds_set_configuration call above and the next run is quiet. ## Version + parameter-group gotchas - MySQL 8.4's default parameter group is CDC-ready (binlog_format=ROW is the engine default and the group leaves it unset). - MySQL 8.0's family default is binlog_format=MIXED — an RDS MySQL 8.0 source needs a custom parameter group with binlog_format=ROW. The parameter is dynamic: no reboot, but only new connections see it, so reconnect after the change. - gtid_mode=OFF_PERMISSIVE by default; sluice's file/position CDC works as-is. ## The FTWRL platform block (why serial cold copy is expected) RDS blocks FLUSH TABLES WITH READ LOCK at the platform level even though the master user holds RELOAD — the statement returns 1045 Access denied regardless of grants. Two sluice behaviours follow, both by design and both WARNed: - The N-way concurrent cold copy falls back to serial (the concurrent path needs the read lock for a consistent multi-connection snapshot). - The snapshot position is captured without a write freeze — a concurrent commit during the capture instant could land in neither the copy nor the CDC tail. No grant fixes this — it's the platform, not your permissions (sluice's WARN text names the RDS reality on RDS hosts). If exactness of the handoff position matters, quiesce writers during the snapshot; on an idle or low-write source, accept the WARN. ## TLS: the public regional bundle RDS defaults allow plaintext (require_secure_transport=OFF), and a bare ?tls=true fails — the RDS CA is not in system roots. The working recipe is --source-tls-ca with the public regional bundle (one well-known URL per region, no API call — contrast DO's authenticated CA endpoint): curl -sO https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem sluice sync start \ --source-driver mysql --source 'admin:pass@tcp(mydb.abc123.us-east-1.rds.amazonaws.com:3306)/app' \ --source-tls-ca us-east-1-bundle.pem \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id rds-app The master user has the replication grants CDC needs out of the box (REPLICATION SLAVE, REPLICATION CLIENT) — nothing to GRANT on defaults. ## What sluice checks for you - The detect-first retention advisory — on *.rds.amazonaws.com hosts, sluice queries mysql.rds_configuration and WARNs when binlog retention hours is NULL (naming the ~5–11-minute purge reality and the exact remedy call) or configured under 24 h (a milder WARN naming the window); a value ≥ 24 h stays silent. If the query itself fails, sluice falls back to an unconditional DO-style WARN rather than staying quiet. - FTWRL fallback WARNs — the serial-copy fallback and the no-freeze snapshot capture are both announced, never silent. - Loud position-invalid recovery — a resume from a purged position is an explicit WARN plus a fresh cold start (or a hard stop under --no-auto-resnapshot), never a silent gap. - Unencrypted-binlog-stream WARN — a plaintext DSN gets a warning that the CDC stream is unencrypted; --source-tls-ca resolves it. ## Next steps - Migrating from AWS RDS Postgres — the Postgres sibling: parameter groups, role membership, force_ssl. - Migrating from Google Cloud SQL MySQL — the managed MySQL where the retention variable tells the truth and defaults are already CDC-safe. - Zero-downtime migration — sync → verify → cutover once retention is configured. - Field note: the transaction that lands in neither the snapshot nor the binlog — the exact hazard the no-freeze WARN describes. - Field note: MySQL's own certificate can't pass verify-full — why the CA-pinned mode skips hostname verification. =============================================================== # Migrating from AWS RDS Postgres with sluice (https://sluicesync.com/docs/migrate-from-rds-postgres/) The rds.logical_replication parameter-group flip, the rds_replication role-membership model (nothing to grant for the master user), force_ssl and the trust bundle, and the trigger-engine fallback. AWS RDS for PostgreSQL works with sluice's vanilla postgres engine — live-validated 2026-07-16 (PostgreSQL 16.14) as a bulk-migration source with byte-identical md5 ground truth, including NaN-in-numeric[], ±Infinity, denormal floats, and 2-D arrays with NULL elements. Trigger-CDC (postgres-trigger) was validated end-to-end in the same run. Slot-based CDC was blocked in that run by a sluice-side false refusal — the preflight didn't yet understand RDS's role-membership model while the platform itself was proven slot-capable — and the preflight has since been taught that model, so slot CDC is expected to work with the master user. Aurora Postgres uses the same role model and parameter names. ## Enabling logical replication (parameter group + reboot) Not postgresql.conf and not a console toggle: attach a custom parameter group with rds.logical_replication = 1. The parameter is static, so a reboot is required (~2 minutes); the GUC wal_level itself is read-only on RDS. Two gotchas the validation surfaced: - Retention 0 means wal_level=minimal, not replica. RDS couples wal_level to automated backups: with backup-retention-period 0 — the cheapest possible instance shape — the baseline is minimal, one notch below the replica most docs assume. A cost-minimized instance is two steps from CDC-ready, not one — but the remedy is the same either way: the parameter flip forces logical regardless of retention. Don't detour via “enable backups.” - After the flip, RDS provisions the slot budget automatically: max_replication_slots=20, max_wal_senders=35. Nothing to size by hand. aws rds create-db-parameter-group --db-parameter-group-name sluice-pg16-logical \ --db-parameter-group-family postgres16 --description 'logical replication for sluice CDC' aws rds modify-db-parameter-group --db-parameter-group-name sluice-pg16-logical \ --parameters 'ParameterName=rds.logical_replication,ParameterValue=1,ApplyMethod=pending-reboot' aws rds modify-db-instance --db-instance-identifier mydb --db-parameter-group-name sluice-pg16-logical aws rds reboot-db-instance --db-instance-identifier mydb # static parameter: the reboot is unavoidable ## Roles: membership, not attributes The RDS master user is not a superuser and never carries the REPLICATION attribute (rolreplication=f) — and ALTER ROLE ... REPLICATION is not available on RDS at all. What actually gates slot creation is membership in the rds_replication role, which the master user has from creation. sluice's replication-capability preflight recognizes that membership (it probes rolsuper OR rolreplication or rds_replication membership when that role exists), so: - Master user: nothing to grant. It has everything sluice needs on defaults — including CREATE EVENT TRIGGER (via rds_superuser) for sluice trigger setup. - Custom roles: GRANT rds_replication TO ; is the RDS equivalent of the REPLICATION attribute. ## TLS: force_ssl and the trust bundle rds.force_ssl=1 is the platform default on PG 15+ engines — plaintext connections are refused at pg_hba (no pg_hba.conf entry ... no encryption). sslmode=require works out of the box; verify-full works with the AWS trust bundle passed in the DSN (Postgres endpoints take sslrootcert= in the DSN — the --source-tls-ca flag is for MySQL-family endpoints and refuses on Postgres rather than being silently ignored): curl -sO https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem sluice sync start \ --source-driver postgres \ --source 'postgres://master:pass@mydb.abc123.us-east-1.rds.amazonaws.com:5432/app?sslmode=verify-full&sslrootcert=global-bundle.pem' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id rds-app ## RDS Proxy: connect to the instance endpoint RDS Proxy is untested with sluice and expected CDC-incompatible — it is a transaction-mode pooler, and the replication protocol cannot traverse a pooler (the same class as the Neon and Supabase pooler findings). Point sluice at the instance endpoint (*...rds.amazonaws.com), not a proxy endpoint (*.proxy-*.rds.amazonaws.com). ## The trigger-engine fallback When a replication slot isn't an option — a custom role you can't grant rds_replication to, an organization that forbids the parameter-group reboot, or a need to start CDC before the reboot window — the slot-less postgres-trigger engine runs the same continuous sync off per-table capture triggers, no slot and no replication privilege required. It was validated end-to-end against RDS in the same run (the master user can create the event trigger sluice's DDL detection prefers, via rds_superuser): sluice trigger setup \ --source-driver postgres-trigger \ --dsn 'postgres://master:pass@mydb.abc123.us-east-1.rds.amazonaws.com:5432/app?sslmode=require' \ --tables orders,customers,line_items sluice sync start \ --source-driver postgres-trigger --source 'postgres://master:pass@mydb...rds.amazonaws.com:5432/app?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id rds-trigger-app The full lifecycle (setup → run → teardown, payload tuning, pruning) is in Managed Postgres (slot-less). ## What sluice checks for you - The membership-aware replication preflight — before opening the CDC reader, sluice verifies the role can create a slot via rolsuper OR rolreplication or rds_replication membership (checked only where that role exists, so stock Postgres is unaffected). A genuinely incapable role refuses with SLUICE-E-CDC-REPLICATION-PERMISSION and the RDS-appropriate remedy, instead of failing opaquely at slot creation. - The wal_level preflight refusal — CDC against replica (or retention-0's minimal) refuses at startup, pointing at the provider matrix with the RDS parameter-group remedy. - The Aurora HA advisory — a source host matching *.cluster*.rds.amazonaws.com (Aurora cluster endpoints) gets the managed-HA WARN about the idle-slot failover trap, with the heartbeat mitigation. - Slot-health monitoring — wal_status transitions (unreserved → critical, lost → terminal, exactly-once) and PG 14+ decode-spill counters, the same as any Postgres source. - Event-trigger capability by attempt, not by role list — sluice trigger setup probes whether the role can actually create an event trigger rather than checking a predefined-role name, so RDS's rds_superuser-patched capability is recognized. ## Next steps - Prepare a Postgres source — slot lifecycle, retention pressure, and the failover checklist. - Managed Postgres (slot-less) — the trigger engine's full lifecycle reference. - Migrating from AWS RDS MySQL — the MySQL sibling: binlog retention, FTWRL, the regional bundle. - Field note: the idle-slot failover trap — the hazard behind the Aurora HA advisory. =============================================================== # Migrating from Google Cloud SQL Postgres with sluice (https://sluicesync.com/docs/migrate-from-cloudsql-postgres/) The cloudsql.logical_decoding flag (restart included, about a minute), the self-service ALTER ROLE ... REPLICATION grant, default-plaintext TLS and the per-instance CA, and the validated migrate + CDC recipe. Google Cloud SQL for PostgreSQL works with sluice's vanilla postgres engine — live-validated 2026-07-16 (PostgreSQL 16.14) as a bulk-migration and slot-based CDC source: byte-identical md5 ground truth on the bulk copy (including NaN-in-numeric[], ±Infinity, denormal floats, and 2-D arrays with NULL elements), an exact snapshot → CDC handoff with live-write convergence, and a clean stop with zero orphaned slots. The pleasant surprise, coming from the RDS guide: Cloud SQL has no platform-specific role model to learn. Two self-service steps take a fresh instance to CDC-ready, and both of sluice's preflight refusals name remedies that work verbatim here. ## Enabling logical replication (one flag, about a minute) A fresh Cloud SQL instance sits at wal_level=replica regardless of backup settings — there is no RDS-style “retention 0 means minimal” trap, so you are always exactly one flag from CDC-ready. wal_level itself is not directly settable; the knob is the database flag cloudsql.logical_decoding: # The patch performs the required restart INLINE — about a minute end-to-end (validated live). gcloud sql instances patch my-instance --database-flags=cloudsql.logical_decoding=on # Careful: --database-flags REPLACES the entire flag set. On an instance with # existing flags, include them all in the same patch: # --database-flags=cloudsql.logical_decoding=on,max_connections=200 Notes from the validation run: - The restart is automatic and fast. No separate reboot step to schedule — wal_level=logical was queryable about 50 seconds after the patch started (contrast RDS: custom parameter group + explicit reboot, ~3 minutes). - The slot budget is already provisioned: max_replication_slots=10 / max_wal_senders=10 before the flip, unchanged by it. Plenty for sluice's one slot; only heavy multi-consumer setups need explicit raises. - The --database-flags replace-everything behaviour is the one real gotcha — a patch that names only the new flag silently clears every other flag on the instance. ## Roles: ALTER ROLE ... REPLICATION actually works here The default postgres user is not a superuser (rolsuper=f), but it is a member of cloudsqlsuperuser — and Cloud SQL patches the standard “must be superuser to alter replication users” check for its members. So the fix is one standard-Postgres statement, run as yourself: ALTER ROLE postgres WITH REPLICATION; -- succeeds as the (non-superuser) master user That statement is exactly the first remedy in sluice's SLUICE-E-CDC-REPLICATION-PERMISSION refusal — Cloud SQL is the first managed provider in this validation series where it applies verbatim. One honest caveat: the refusal's provider-specific examples currently name platforms where the attribute is not grantable (RDS's role membership, Heroku's support ticket), and don't yet name Cloud SQL as the “just run it” case — don't let those examples talk you out of trying the ALTER ROLE. - Membership confers nothing. The platform roles cloudsqlreplica / cloudsqllogical carry rolreplication themselves, but the REPLICATION attribute is not inherited via membership in stock Postgres, and Cloud SQL does not patch that — a role granted IN ROLE cloudsqlreplica still cannot create a slot (validated live). Those roles are platform-internal; the attribute on your role is the mechanism, unlike RDS's rds_replication membership model. - Expect the refusals in role → flag order. On a stock instance, sync start meets the replication-permission refusal first; after the grant, the wal_level refusal (whose provider matrix names the Cloud SQL flag). Doing both steps up front skips the second round-trip. ## TLS: plaintext is accepted by default Cloud SQL's default sslMode is ALLOW_UNENCRYPTED_AND_ENCRYPTED — a public-IP DSN without sslmode=require sends credentials and data in the clear, and nothing refuses (contrast RDS Postgres, where force_ssl is the default). Set sslmode=require or stronger on every Cloud SQL DSN yourself, or flip the instance to --ssl-mode=ENCRYPTED_ONLY server-side. Mode · On a default Cloud SQL instance · sslmode=disable · Accepted. Nothing server-side refuses plaintext unless the instance was created/patched to ENCRYPTED_ONLY. · sslmode=require · Works (TLS 1.3). What the validation ran on throughout — the sensible floor. · sslmode=verify-ca · Works with the per-instance CA (recipe below). The ceiling on default instances. · sslmode=verify-full · Fails on default instances: the server certificate names .sql.goog DNS names — never the public IP — and the default (per-instance-CA) mode publishes no matching dnsName. Instances created with --server-ca-mode=GOOGLE_MANAGED_CAS_CA get a resolvable name that should allow it (unvalidated). · The per-instance CA is an authenticated API fetch (like DigitalOcean's, unlike RDS's public bundle URL); Postgres endpoints take it as sslrootcert= in the DSN (the --source-tls-ca flag is for MySQL-family endpoints and refuses on Postgres rather than being silently ignored): gcloud sql instances describe my-instance --format='value(serverCaCert.cert)' > cloudsql-ca.pem sluice migrate \ --source-driver postgres \ --source 'postgres://postgres:pass@34.148.x.y:5432/app?sslmode=verify-ca&sslrootcert=cloudsql-ca.pem' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --dry-run ## The Auth Proxy is a tunnel, not a pooler The Cloud SQL Auth Proxy is untested with sluice, but it is categorically different from the pgbouncer/Supavisor/RDS-Proxy class that breaks CDC: it's a per-connection encrypted TCP tunnel with IAM auth, not a transaction pooler, so the replication protocol should traverse it. Treat that as unvalidated — the validated path is the direct instance IP with an authorized-network entry, which needs no proxy at all. Two things to know if you route through it anyway: sluice's pooler-host preflight WARN cannot see the hop (the DSN host becomes localhost, so a quiet preflight there is not a verdict), and Cloud SQL's separate Managed Connection Pooling feature is a pooler and belongs in the CDC-incompatible class (also untested). ## The validated recipe Public IP + an authorized-network entry for the migration host is enough — no proxy, no VPC peering. One-shot copy first: sluice migrate \ --source-driver postgres --source 'postgres://postgres:pass@34.148.x.y:5432/app?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --dry-run Continuous sync, after the flag flip and the role grant: sluice sync start \ --source-driver postgres --source 'postgres://postgres:pass@34.148.x.y:5432/app?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id cloudsql-app ## Decommissioning: drop the slot A cleanly stopped sluice stream leaves its replication slot in place — that's what makes the stream resumable. When you're done for good, drop it: an abandoned slot retains WAL and will eventually fill the instance disk (a 10 GB starter disk has little slack). sluice slot list --source-driver postgres --source 'postgres://...' sluice slot drop sluice_slot --yes --source-driver postgres --source 'postgres://...' An empty slot catalog is the Cloud SQL baseline — there are no Neon-style platform-internal slots to leave alone; anything listed is a consumer someone created. ## What sluice checks for you - The wal_level preflight refusal — CDC against a replica-level instance refuses at startup, before touching any slot, pointing at the provider matrix whose Cloud SQL row (cloudsql.logical_decoding = on, restart included) was validated correct by this run. - SLUICE-E-CDC-REPLICATION-PERMISSION — a role without the REPLICATION attribute refuses with the exact ALTER ROLE remedy, and on Cloud SQL that remedy genuinely works as the master user (see the caveat above about the refusal's provider examples). - Slot-health monitoring — wal_status transitions and decode-spill counters, the same as any Postgres source; the validation stream ran WARN-free throughout. - SLUICE-E-CONFIRMATION-REQUIRED — slot drop refuses without --yes, so a decommissioning script can't destroy a resumable position by accident. ## Next steps - Prepare a Postgres source — the full slot lifecycle, retention, and failover story (provider matrix included). - Migrating from AWS RDS Postgres — the AWS sibling: everything Cloud SQL does differently (role membership, parameter-group reboot, force_ssl). - Verify & reconcile — confirm the target matches the source after the copy. - Field note: replication slots don't die with your process — why the decommissioning step matters. =============================================================== # Migrating from Google Cloud SQL MySQL with sluice (https://sluicesync.com/docs/migrate-from-cloudsql-mysql/) The first managed MySQL whose binlog retention is honest and safe by default (a 1-day floor), the --retained-transaction-log-days decoy, the PITR toggle that invalidates every position, and the per-instance-CA TLS recipe. Google Cloud SQL for MySQL works with sluice's vanilla mysql engine — cold copy, the CDC handoff, and a 35-minute-detached warm resume were all validated live (2026-07-16, MySQL 8.0.45). It's the third managed MySQL in this guide set, and the first with genuinely good news: the binlog-retention hazard that headlines the DigitalOcean and RDS guides is, here, both truthful and safe by default. What earns the headline instead is a Cloud-SQL-specific hazard of its own: the PITR toggle. ## Retention: honest and safe by default (a 1-day floor) On Cloud SQL, @@binlog_expire_logs_seconds reads 86400 (1 day) — and unlike DO and RDS, the variable is the real governing knob. No out-of-band reaper purges ahead of it: in ~80 minutes of rotation-forced observation, zero purges occurred, with files surviving 48+ minutes past rotation (on DO defaults every one of those files dies at ~13–16 minutes of age; on RDS at ~5–11). The platform also refuses to set the flag below 86400 (allowed values: 0 = never expire, or 86400–4294967295 seconds), so the CDC window can't be misconfigured short. The live-proven consequence: a sluice stream detached for 35 minutes on pure defaults — the exact gap length demonstrated fatal on RDS defaults — warm-resumed cleanly with exact backlog replay. Defaults are CDC-safe for any cold-copy-plus-reattach gap under ~24 hours; most migrations need no retention knob at all. · DigitalOcean · AWS RDS · Cloud SQL · Default effective window · ~13–16 min (out-of-band reaper) · ~5–11 min (post-backup-upload purge) · 1 day (variable-governed) · Does @@binlog_expire_logs_seconds tell the truth? · No (reads 3 days) · No (reads 30 days) · Yes · The real knob · config API, 600–86400 s · mysql.rds_set_configuration, cap 168 h · database flag, floor 86400 s, no practical cap · Defaults CDC-safe? · No · No · Yes (gaps < 24 h) · When a planned pause will exceed a day, stretch the window with the database flag — applied live, no restart, ~20 seconds (validated with a sync stream attached and uninterrupted): gcloud sql instances patch my-instance --database-flags=binlog_expire_logs_seconds=604800 # Careful: --database-flags REPLACES the entire flag set — include any existing flags. Unlike RDS there is no 7-day cap — multi-week windows are possible. Two soft edges: on-disk binlogs count against storage (watch it if auto-grow is off), and Google documents early purging under disk pressure — don't treat the window as contractual on a nearly-full disk. ## The decoy knob: --retained-transaction-log-days Cloud SQL has a second, better-advertised retention setting, and it is not the one that matters. --retained-transaction-log-days (transactionLogRetentionDays) governs the PITR log copies uploaded to Cloud Storage — invisible to the replication protocol, useless to a CDC client, and capped at 7 days on the Enterprise edition anyway. The two are provably decoupled: patching it 7→1→7 completed in ~3 seconds with no restart and left @@binlog_expire_logs_seconds untouched. It looks like RDS's binlog retention hours; the database flag above is the actual CDC knob. Also worth knowing: SET GLOBAL binlog_expire_logs_seconds and PURGE BINARY LOGS are both denied from SQL (root lacks SUPER) — every retention change goes through gcloud — but the reading is SQL-visible and honest, which is more than DO or RDS offer. ## The PITR toggle destroys positions (both directions) For Cloud SQL MySQL, --enable-bin-log is the PITR switch, and binary logging is coupled to automated backups (disabling backups with binlog on is refused with an HTTP 400; creating with --enable-bin-log silently implies backups on). The hazard is the toggle itself, live-probed in both directions: - --no-enable-bin-log restarts the instance (an ~10-minute operation), sets log_bin=0, and destroys every existing binlog. A live sluice stream rode the restart's connection refusals through its retry loop, reconnected, and then failed loudly and correctly: ERROR 1236 (HY000): Binary log is not open. - Re-enabling restarts the instance again and resets binlog numbering to mysql-bin.000001 — so every position persisted on either side of the round-trip is permanently invalid, even though binlogs exist again. sluice recovers from this the loud way, validated live across the full toggle round-trip: the restart's warm resume detects the invalid position — “persisted position is no longer valid; falling through to cold start … binlog file ‘mysql-bin.000012’ is no longer available on the source (purged)” — WARNs, and auto-resnapshots (fresh copy, re-attach at the new numbering, counts exact after). If a full re-copy is expensive and you'd rather decide by hand, sync start --no-auto-resnapshot converts that into a hard stop with named recovery commands. Database flags survive the toggle; positions do not. One honest caveat. Today the recovery WARN describes the position as purged — accurate, but it doesn't yet name the PITR toggle as the likely Cloud SQL cause (there's no hostname to detect the platform by; a fingerprint via @@version ending in -google is the planned refinement, not shipped at the time of writing). If you see position-invalid recovery on Cloud SQL and retention was at defaults, check the instance's operation log for a binlog toggle before suspecting the window. ## FTWRL works — frozen snapshots, no WARNs Cloud SQL's root user holds effective RELOAD/FLUSH_TABLES and the platform honors them — FLUSH TABLES WITH READ LOCK succeeds. sluice's consistent multi-table cold copy therefore runs concurrent, with a frozen snapshot position, and none of the fallback WARNs from the RDS guide apply: no serial-copy fallback, no no-freeze capture. The snapshot-position gap is closed here the way it's meant to be. ## Connecting: the per-instance CA Defaults accept plaintext (sslMode: ALLOW_UNENCRYPTED_AND_ENCRYPTED) — a plain DSN works and gets sluice's unencrypted-binlog-stream WARN. A bare ?tls=true fails (x509: certificate signed by unknown authority): each instance has its own private CA (CN=Google Cloud SQL Server CA), the same class as DigitalOcean's — and unlike RDS there is no public bundle URL; the fetch is an authenticated API call: gcloud sql instances describe my-instance --format='value(serverCaCert.cert)' > cloudsql-ca.pem sluice sync start \ --source-driver mysql --source 'root:pass@tcp(34.148.x.y:3306)/app' \ --source-tls-ca cloudsql-ca.pem \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id cloudsql-app --source-tls-ca covers both the SQL connections and the binlog/CDC stream — validated end-to-end on Cloud SQL. The server certificate's CN is project:instance, not the IP; sluice's CA-pinned verify-ca mode handles that without a hostname-verification failure (see why MySQL certificates can't pass verify-full). ## Defaults that are already right - binlog_format=ROW, binlog_row_image=FULL, gtid_mode=ON out of the box — even on 8.0 (contrast RDS 8.0's MIXED and its custom-parameter-group dance). sluice's file/position CDC works as-is; positions carry the server_uuid. - Replication grants present on root (REPLICATION SLAVE, REPLICATION CLIENT) — nothing to GRANT. - sql_require_primary_key=OFF — keyless tables land fine as targets, unlike DO. - Stock-strict sql_mode — no DO-style ANSI surprise; double-quoted strings are strings. ## What sluice checks for you - Mostly: nothing fires — correctly. Cloud SQL connects by bare IP (or the Auth Proxy at localhost), so the DO/RDS host-pattern retention advisories have nothing to match — and nothing to say: at defaults the window is a day, not minutes, so a quiet preflight is the right result on this platform, not a blind spot. - Loud position-invalid recovery — a mid-stream binlog loss is a loud 1236 failure, and a resume from an invalidated position (the PITR toggle's signature) is an explicit “persisted position is no longer valid” WARN plus a fresh cold start — validated live on Cloud SQL across the toggle round-trip; --no-auto-resnapshot makes it a hard stop with named recovery commands instead. - Unencrypted-binlog-stream WARN — a plaintext DSN gets a warning that the CDC stream is unencrypted; --source-tls-ca resolves it (and refuses to combine with a DSN-level tls=, or to apply to non-MySQL engines). - The FTWRL WARNs are absent by design — on this platform their absence means the frozen-snapshot concurrent copy actually ran. ## Next steps - Migrating from AWS RDS MySQL — the sibling where defaults purge in minutes and the remedy is one SQL call. - Migrating from DigitalOcean MySQL — the other purge-window platform, where no SQL-visible truth exists at all. - Zero-downtime migration — the full sync → verify → cutover flow. - Field note: the transaction that lands in neither the snapshot nor the binlog — the seam FTWRL closes, and on Cloud SQL actually can. =============================================================== # Migrating from Azure Database for MySQL (Flexible Server) with sluice (https://sluicesync.com/docs/migrate-from-azure-mysql/) The one required knob: binlog_row_image defaults to MINIMAL, under which binlog CDC silently loses UPDATEs (Bug 193) — set it FULL first. In exchange, Azure has the safest binlog retention of any managed MySQL probed (an honest 0 = never-expire default, no reaper), zero-setup public-CA TLS, and an -azure version fingerprint. Azure Database for MySQL (Flexible Server) works with sluice's vanilla mysql engine — cold copy, the CDC handoff, and a 35-minute-detached warm resume were all validated live (2026-07-17, Standard_B1ms, MySQL 8.0.45). Retention is the safest of any managed MySQL in this guide set, but Azure carries a different, sharper trap that must be fixed before any sync: the default row-image setting. ## REQUIRED: set binlog_row_image=FULL before any sync Azure's platform default is binlog_row_image=MINIMAL — the only major managed-MySQL platform that defaults to it — and under MINIMAL, binlog CDC loses UPDATEs silently (Bug 193). INSERT and DELETE are unaffected and row counts stay equal, so only column content diverges — a 0.2% content drift that sails under a default-depth sample. Set it FULL before sync start: az mysql flexible-server parameter set --resource-group --server-name \ --name binlog_row_image --value FULL It's dynamic — applies in ~20 seconds with no restart. Verify with SELECT @@binlog_row_image;. sluice's CDC preflight also refuses a non-FULL row image at stream start with the coded SLUICE-E-CDC-ROW-IMAGE-PARTIAL — but set the knob regardless, and if a stream already ran under MINIMAL, re-verify with full-table sampling (--sample-rows-per-table sized to the table), not the default sample depth: a 0.2% divergence is exactly what the default sampling design point can miss. ## Retention: the safest defaults of any managed MySQL probed binlog_expire_logs_seconds defaults to 0 (no time-based expiry) — an honest never-expire — and, unlike DigitalOcean, Vultr, and RDS, no out-of-band reaper was observed: files survived 85+ minutes, multiple rotations, and an on-demand full backup without a single purge. A detached stream warm-resumes after long gaps on pure defaults (a 35-minute detach — fatal on RDS/DO defaults — replayed its backlog exactly). The concern inverts: binlogs accrue against your storage until you bound them. az mysql flexible-server parameter set --resource-group --server-name \ --name binlog_expire_logs_seconds --value 604800 # live, no restart Purge appears platform-scheduled and lazy — files can outlive the configured window by tens of minutes. Manual PURGE BINARY LOGS is denied (no SUPER/BINLOG_ADMIN). ## Connection + privilege notes - TLS is mandatory AND zero-setup — plaintext is refused (require_secure_transport=ON, the only probed managed-MySQL platform that refuses it), and a bare ?tls=true just works: the server chain validates against the public roots already in your system store. No CA download, no --source-tls-ca. - FTWRL works (RELOAD honored) — sluice's concurrent frozen-snapshot cold copy runs with no fallback WARNs. - Replication grants (REPLICATION SLAVE/REPLICATION CLIENT) are present on the admin user out of the box; binlog_format=ROW is read-only at the platform (no MIXED trap); gtid_mode=OFF by default (file/position CDC is fine); sql_require_primary_key=OFF; stock-strict sql_mode (no DigitalOcean-style ANSI surprise). - Host pattern *.mysql.database.azure.com; the in-band fingerprint is @@version ending in -azure. One-time subscription step: az provider register --namespace Microsoft.DBforMySQL must have completed before instance creation works. sluice sync start \ --source-driver mysql --source 'myadmin:pass@tcp(myserver.mysql.database.azure.com:3306)/app?tls=true' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id azure-app ## What sluice checks for you - SLUICE-E-CDC-ROW-IMAGE-PARTIAL — a source streaming partial binlog row images (binlog_row_image != FULL) is refused at CDC start, because that is exactly the silent-UPDATE-loss shape; the remedy is the az … parameter set above. - No retention advisory — correctly. Azure's defaults hold binlogs, so the host-pattern retention WARNs that fire on DigitalOcean and Vultr have nothing to warn about here; a quiet preflight is the right result, not a blind spot. - Loud position-invalid recovery — a resume from a purged position (only reachable if you bounded retention aggressively) is an explicit WARN plus a fresh cold start, or a hard stop under --no-auto-resnapshot. ## Next steps - Migrating from Azure Database for PostgreSQL — the Postgres sibling: self-grantable REPLICATION, and the best TLS story in the series. - Migrating from Google Cloud SQL MySQL — the other managed MySQL whose retention variable is honest. - Field note: the row image that drops your UPDATEs — why MINIMAL loses column content while counts stay equal. - Field note: the retention variable that tells five different truths — where Azure's never-expire default sits among the five. =============================================================== # Migrating from Azure Database for PostgreSQL (Flexible Server) with sluice (https://sluicesync.com/docs/migrate-from-azure-postgres/) The self-grantable REPLICATION attribute (ALTER ROLE ... WITH REPLICATION works as the admin user), the best TLS story in the series (verify-full with zero setup against public roots), and the wal_level flip that needs an explicit restart. The built-in PgBouncer is General-Purpose-tier-plus only. Azure Database for PostgreSQL (Flexible Server) works with sluice's vanilla postgres engine — live-validated 2026-07-17 (PG 16.14) as a migration and slot-based CDC source: byte-identical bulk migrate on md5 ground truth (NaN in numeric[], ±Infinity, denormal floats, 2-D arrays with NULL elements) and exact CDC convergence with a clean snapshot → CDC handoff. ## Enabling logical replication (three self-service steps) No ticket, all self-service — but mind the explicit restart in step 2: - Set wal_level=logical — Azure exposes the GUC directly (no provider-specific alias). It is static, so the command returns with the change pending: az postgres flexible-server parameter set --resource-group --server-name \ --name wal_level --value logical - Restart explicitly — the parameter does NOT take effect until you restart (~1 minute; contrast Cloud SQL, whose patch restarts for you). Verify afterward with SHOW wal_level;: az postgres flexible-server restart --resource-group --name - Grant the connecting role the REPLICATION attribute — this works as the (non-superuser) admin user, because Azure patches the grant for azure_pg_admin members. It is exactly recovery path (a) in sluice's replication-capability refusal: ALTER ROLE WITH REPLICATION; The platform replication role is grant-restricted and irrelevant here — there is no RDS-style membership model; the attribute is the mechanism. Baseline before the flip: wal_level=replica regardless of backup settings (no RDS-style retention-0 ⇒ minimal trap), max_replication_slots=10 / max_wal_senders=10 (unchanged by the flip), and zero platform slots. ## TLS: the best story in the series TLS is mandatory (plaintext refused at pg_hba) and the certificate chain is public (Microsoft/DigiCert roots). So sslmode=verify-full works with no CA download and no sslrootcert — use it on every Azure DSN; it is both the strictest and the zero-config mode (better than the per-instance-CA fetch that DigitalOcean, Cloud SQL, and Vultr require). If a client stack with its own bundled CA fails verification, it's missing an OS trust store, not an Azure quirk. sluice sync start \ --source-driver postgres \ --source 'postgres://myadmin:pass@myserver.postgres.database.azure.com:5432/app?sslmode=verify-full' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id azure-pg-app ## The built-in PgBouncer (General Purpose tier and up) Azure's built-in PgBouncer requires the General Purpose tier or higher (port 6432 on the same hostname when enabled; it cannot be enabled at all on Burstable) and is expected to strip replication like the Supavisor class — untested. Connect sluice to port 5432. ## Provisioning friction Microsoft.DBforPostgreSQL provider registration is a one-time subscription step, and region availability differs per subscription even from the MySQL flexible service — a region that provisions MySQL can refuse PG with “The location is restricted.” Plan a region fallback. ## Decommissioning A cleanly stopped sluice stream leaves its (resumable) replication slot in place; when you're done for good, drop it — an abandoned slot retains WAL and will eventually fill the instance disk: sluice slot drop --yes ## What sluice checks for you - SLUICE-E-CDC-REPLICATION-PERMISSION — if the connecting role lacks the REPLICATION attribute, the preflight refuses with the exact ALTER ROLE … WITH REPLICATION remedy (recovery path (a)), which on Azure the admin user can run itself. - wal_level preflight refusal — CDC before the parameter flip + restart refuses at startup rather than failing opaquely mid-cold-start. - Slot-lifecycle honesty — sluice slot list / slot drop surface and manage the replication slot explicitly, so a crashed stream's leftover slot is visible rather than a silent WAL leak. ## Next steps - Migrating from Azure Database for MySQL — the MySQL sibling and its required row-image knob. - Migrating from Google Cloud SQL Postgres — the other managed PG where ALTER ROLE … WITH REPLICATION is self-service. - Prepare a Postgres source — the full slot lifecycle, retention, and failover story. - Field note: replication slots don't die with your process — why an abandoned slot pins WAL. =============================================================== # Migrating from Vultr Managed MySQL with sluice (https://sluicesync.com/docs/migrate-from-vultr-mysql/) The binlog-retention hazard is the headline: an out-of-band reaper purges every binlog file ~10–16 minutes after creation while the variable reads 3 days — and uniquely among managed MySQL, Vultr exposes no retention knob at all. That makes CDC migrate-and-cutover-shaped: keep any pause well under 10 minutes. Same Aiven-derived platform as DigitalOcean, without DigitalOcean's fix. Vultr Managed Databases for MySQL works with sluice's vanilla mysql engine — cold copy and the CDC handoff were validated live (2026-07-17, throwaway hobbyist single-node, MySQL 8.4.8). Vultr's DBaaS is the same Aiven-derived platform as DigitalOcean's, and it shares DO's headline hazard — without DO's escape hatch. ## The binlog window, with no retention knob An out-of-band platform reaper purges every binlog file ~10–16 minutes after creation — while @@binlog_expire_logs_seconds reads 259200 (3 days), the identical value DigitalOcean shows. The variable reports a window the platform does not enforce (same platform lineage as DO), but where DO's config API accepts a binlog_retention_period, Vultr exposes no retention control at all: the advanced-options API rejects the option by name, the database-update API ignores it, and SET GLOBAL / SET PERSIST / PURGE BINARY LOGS are denied to vultradmin. There is nothing to configure — the ~10-minute floor is permanent. sluice emits a loud WARN at sync/backup start on the *.vultrdb.com host pattern, the only reliable signal (@@version_comment is a bare “Source distribution”). What that means in practice: - A CDC position older than ~10 minutes is unrecoverable (ErrPositionInvalid, auto-resnapshot). - An attached, caught-up stream is safe only while it stays caught up — files behind a live stream purge on schedule; the active file alone is immune (live-demonstrated). - A cold copy or restart gap longer than ~10 minutes can livelock auto-resnapshot with no remedy: each retry re-copies, exceeds the window again, and loses its position again. Treat Vultr MySQL as a migrate-and-cut-over source. Keep the sync stream attached and caught up from snapshot to cutover, and keep any planned pause well under 10 minutes. For long-running or pausable replication, this platform's defaults cannot support it — and there is no knob to change that. ## Connection + schema gotchas (the DigitalOcean list, almost verbatim) - Host pattern *.vultrdb.com, on a nonstandard high port. Plaintext is accepted (require_secure_transport=OFF) but the unencrypted-binlog WARN applies. A bare ?tls=true fails — each cluster has a private CA (Aiven “Project CA”) embedded in the create/GET API response's ca_certificate field. Save it and pass --source-tls-ca (no separate CA-endpoint call, unlike DO): # ca_certificate comes back inline in the database create/get API response — save it, then: sluice sync start \ --source-driver mysql --source 'vultradmin:pass@tcp(vultr-prod-xxx.vultrdb.com:16751)/defaultdb' \ --source-tls-ca vultr-ca.pem \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id vultr-app - vultradmin has the replication grants CDC needs, plus RELOAD — so FTWRL works and sluice runs the concurrent frozen-snapshot cold copy with no fallback WARNs. - Default sql_mode includes ANSI — double-quoted strings are identifiers. Manual SQL against the source behaves differently than on stock MySQL. - sql_require_primary_key=ON — keyless tables cannot be created on a Vultr target. - local_infile=OFF (Vultr-as-target takes the batched-INSERT fallback); max_binlog_size is lowered to 64 MB; MySQL 8.4 is the only offered version. ## What sluice checks for you - The unconditional retention WARN — sync and backup runs against a *.vultrdb.com host warn about the ~10-minute unconfigurable purge window. The wording is stronger than DigitalOcean's, because DO's message can point at a knob and Vultr's cannot. - Loud position-invalid recovery — a resume from a purged position surfaces as an explicit WARN and a fresh cold start; --no-auto-resnapshot converts that into a hard stop with named recovery commands. - --source-tls-ca refusals — the flag refuses to combine with a DSN-level tls= setting and refuses on non-MySQL engines, rather than silently ignoring a security flag. ## Next steps - Migrating from Vultr Managed PostgreSQL — the Postgres sibling, which ships CDC-ready with zero preparation. - Migrating from DigitalOcean MySQL — the same Aiven reaper class, but with a config-API retention knob. - Field note: the retention variable that tells five different truths — where Vultr's no-knob case sits among the five. - Zero-downtime migration — the sync → verify → cutover flow, kept inside the 10-minute window. =============================================================== # Migrating from Vultr Managed PostgreSQL with sluice (https://sluicesync.com/docs/migrate-from-vultr-postgres/) The only provider validated so far that ships CDC-ready with zero preparation: wal_level=logical and a REPLICATION-bearing admin role out of the box. Plus the pghoard_local platform-internal slot you must not drop, and Vultr's managed PgBouncer — the live counter-example where CDC actually traverses the pooler. Vultr Managed Databases for PostgreSQL works with sluice's vanilla postgres engine — live-validated 2026-07-16 (PG 17.10) as a migration and slot-based CDC source: byte-identical bulk migrate on md5 ground truth (NaN in numeric[], ±Infinity, denormal floats, 2-D arrays with NULL elements) and exact CDC convergence with a clean snapshot → CDC handoff. ## Enabling logical replication: nothing to do Vultr (an Aiven-lineage platform) ships CDC-ready — the only provider validated so far where that is true. wal_level=logical is set out of the box, and the master user (vultradmin) carries the REPLICATION attribute from first boot, so sync start works with zero preparation. max_replication_slots / max_wal_senders default to 20/20 and are raisable to 64 via the database's advanced options. For a custom role, ALTER ROLE WITH REPLICATION works as vultradmin (no superuser needed — the platform patches the grant, like Cloud SQL and Azure). sluice sync start \ --source-driver postgres \ --source 'postgres://vultradmin:pass@vultr-prod-xxx.vultrdb.com:16751/defaultdb?sslmode=require' \ --target-driver postgres --target 'postgres://user:pass@target-host:5432/app?sslmode=require' \ --stream-id vultr-pg-app ## The pghoard_local slot is platform-internal Every Vultr PG instance carries an always-active physical replication slot named pghoard_local (Aiven's pghoard backup daemon). sluice knows it: sluice slot list shows it labeled platform-internal, sluice slot drop refuses it without --force, and the slot-health probe (scoped to sluice's own slot) never flags it. Leave it alone — never drop it, and don't count it as a leaked consumer. It's the Aiven-lineage sibling of Neon's wal_proposer_slot. ## TLS Plaintext is refused server-side; sslmode=require works out of the box, and sslmode=verify-full works with the project CA, which is delivered inline in the database create/get API response (ca_certificate field) — save it and pass it via ?sslrootcert= on the DSN. System roots do not verify (private per-project CA). ## The connection pooler: CDC actually traverses it Vultr's managed PgBouncer pools listen on the primary hostname at port + 1 with dbname= (the API does not expose this — it's the platform convention). Unlike the Neon / Supavisor / RDS-Proxy pooler class, replication connections pass through (modern PgBouncer ≥ 1.24 forwards them 1:1 to the server): both bulk migrate (parallel, snapshot-pinned, no statement-cache trip) and slot-based CDC — slot creation, streaming, warm resume, clean stop — were validated end-to-end through a transaction-mode pool. This is the live counter-example that makes the “a pooler always strips replication” claim provider-dependent. Prefer the direct port anyway: a pool sized N permanently holds N of the plan's small connection budget (22 on the cheapest plan). Note that sluice's pooler-host WARN does not fire here — the pool hostname equals the primary's, only the port differs — which on Vultr is harmless, but it also means “no WARN” is not evidence of “not a pooler” on this provider. ## Decommissioning A cleanly stopped sluice stream leaves its (resumable) replication slot in place; when done for good, sluice slot drop --yes — an abandoned slot retains WAL against the instance disk. (pghoard_local stays; see above.) ## What sluice checks for you - Slot-health monitoring that ignores pghoard_local — Vultr's platform slot is never flagged as a leaked consumer, and slot drop refuses it without --force. - SLUICE-E-CDC-REPLICATION-PERMISSION — present as a safety net, though on Vultr the default vultradmin already carries the attribute, so it stays quiet on defaults. - Parallel copy through the pool with no statement-cache trip — the transaction-mode pool didn't trip pgx's statement cache in the validation run, so parallel copy engaged; a pool-exhaustion risk at high parallelism is the reason to prefer the direct port. ## Next steps - Migrating from Vultr Managed MySQL — the MySQL sibling, whose binlog retention is the opposite story (no knob, ~10-minute floor). - Migrating from Neon — another managed PG with an always-present platform slot to leave alone. - Prepare a Postgres source — the full slot lifecycle, retention, and failover story. - Field note: replication slots don't die with your process — why an abandoned slot pins WAL on the source. =============================================================== # Migrating from MariaDB with sluice (https://sluicesync.com/docs/migrate-from-mariadb/) MariaDB is a first-class MySQL-family source engine — use the mariadb driver, not mysql. Bulk migrate to vanilla MySQL or PlanetScale round-trips cleanly (validated live); the divergences are all on the catalog and CDC side — a different COLUMN_DEFAULT dialect, per-table CHECK-constraint names, a geometry SRID it stores but won't echo, native uuid/inet types, domain-based GTIDs for continuous sync, and MariaDB 11.4's new default collation that remaps on a MySQL target. MariaDB works with sluice's mariadb engine — a MySQL-family flavor that shares vanilla MySQL's reader, decoder, type mapping, and binlog/loader path. A one-time sluice migrate from MariaDB into a MySQL-family target was validated live (2026-07-17): MariaDB 11.4 → vanilla MySQL 8.4 and MariaDB 11.4 → PlanetScale (MySQL), both completing all seven tables with a clean verify --depth count and byte-identical values on the representative rows. Everything that makes MariaDB not vanilla MySQL is on the catalog and CDC side — this guide is that list. Use the mariadb driver name, not mysql. sluice fingerprints the server and steers you if the two are mismatched. The mariadb flavor exists precisely so the catalog-reading and CDC divergences below are handled correctly; pointing the vanilla mysql reader at a MariaDB server would misread its defaults and constraint catalog. Its migrate behavior matches the mysql engine cell-for-cell — see Supported directions. ## Source DSN + driver MariaDB uses the same Go MySQL DSN grammar as vanilla MySQL — user:pass@tcp(host:3306)/db, with ?tls=true (or --source-tls-ca ) for an encrypted connection. Pick the target driver by where you're landing the data: mysql for self-hosted or managed vanilla MySQL, planetscale for PlanetScale MySQL. A dry run confirms the plan and the connection before anything writes: # MariaDB → vanilla MySQL sluice migrate \ --source-driver mariadb --source 'app:pass@tcp(mariadb-host:3306)/app' \ --target-driver mysql --target 'root:pass@tcp(mysql-host:3306)/app' \ --dry-run # MariaDB → PlanetScale (MySQL) sluice migrate \ --source-driver mariadb --source 'app:pass@tcp(mariadb-host:3306)/app' \ --target-driver planetscale --target 'USER:PASS@tcp(aws.connect.psdb.cloud:3306)/mydb?tls=true' Everything below is target-agnostic unless a heading says otherwise — the vanilla-MySQL and PlanetScale runs produced identical target schemas and values. ## MariaDB 11.4's default collation remaps (the most visible WARN) MariaDB 11.4 changed its default collation to utf8mb4_uca1400_ai_ci (UCA 14.0.0). That collation does not exist on a MySQL 8 server family, so on a MySQL or PlanetScale target sluice maps each affected string column to the closest equivalent — utf8mb4_0900_ai_ci — and emits a per-column WARN naming the remap. This fires on essentially every VARCHAR/TEXT column of an 11.4 source, so expect a run of them; it is the single most common MariaDB-specific line in the log: WARN mysql: column data is preserved; some source collations do not exist on this target's server family, so the closest equivalent is used (edge-case sort/comparison order may differ — UCA version and PAD semantics) table=customers columns="email (utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci)" Column data is preserved — the WARN is about sort/comparison semantics, not bytes. UCA 14.0.0 and UCA 9.0.0 order a handful of scripts differently and have different PAD semantics, so a rarely-material edge case is index/ORDER-BY ordering of exotic strings. If exact collation ordering matters to your application, review the affected columns; for the common case the closest-equivalent mapping is correct and lossless. Why this fires on nearly every column, and when it actually matters: MariaDB 11.4's default collation doesn't exist on MySQL 8. ## JSON columns and the CHECK-constraint fan-out MariaDB has no distinct JSON storage type: a JSON column is stored as LONGTEXT plus an auto-generated CHECK (json_valid()) constraint named after the column. MariaDB's constraint names are unique per table, not per schema — so two tables that each have a meta JSON column both carry a CHECK named meta. A catalog join that is provably 1:1 on MySQL 8 fans out to a cartesian product on MariaDB, which historically could emit a duplicate CHECK and fail CREATE TABLE. The validation exercised exactly this shape — two tables (orders, customers) each with a meta JSON column, both source CHECKs named meta — and it migrated cleanly: on the MySQL-family target each JSON column lands as a native json type (the json_valid check is subsumed by the native type), no duplicate CHECK is emitted, and there is no fan-out. The mechanics of why the fix can't be symmetric (MySQL 8's CHECK_CONSTRAINTS has no TABLE_NAME column to join on) are in the field note: the join that's 1:1 on MySQL 8 and fans out on MariaDB. ## Geometry: the SRID it stores but won't show you Declare POINT REF_SYSTEM_ID=4326 and MariaDB stores the SRID — but SHOW CREATE TABLE echoes the column as a bare point DEFAULT NULL, and unlike MySQL 8 there is no srs_id catalog column. The declared value lives only in the OGC-standard information_schema.GEOMETRY_COLUMNS view, which is where sluice reads it. In the validation the target places.geom came back as point /*!80003 SRID 4326 */ with SRS_ID = 4326 — the SRID is carried, not silently reset to 0. Background: MariaDB accepts a geometry SRID it won't show you. Verify geometry with ST_Latitude/ST_Longitude, not a raw ST_AsText diff. MariaDB stores an SRID-4326 point in long-lat order and its ST_AsText prints long-lat; MySQL 8 honors the EPSG:4326 lat-long axis order and prints lat-long by default. So a point that reads POINT(-122.4194 37.7749) on the MariaDB source reads POINT(37.7749 -122.4194) on the MySQL target — the coordinates look swapped, but the geographic location is identical: ST_Latitude/ST_Longitude on the target match the source exactly, and ST_AsText(geom, 'axis-order=long-lat') reproduces the source string. This is an engine axis-order convention difference, not data loss — but a naive text comparison will flag a false mismatch. The full mechanism (sluice moves the WKB byte-faithful; only the engines' output rendering defaults differ): MariaDB and MySQL 8 disagree on which coordinate comes first. ## Native uuid / inet types MariaDB has native uuid, inet6, and inet4 column types. Under a bulk migrate they round-trip cleanly — the driver hands them back as formatted text, so uuid lands as char(36) and inet6/inet4 as varchar(45) on the target, with byte-identical values (verified in the run: every UUID and IP string matched source to target). “It migrated fine” is a statement about the bulk path only — under continuous CDC these same columns travel as their raw storage bytes, a different code path. sluice decodes them faithfully through CDC as of v0.99.272 (canonical big-endian UUID, length-prefixed with trailing 0x00 stripped, BSD inet_ntop6 text); the history of why this is a separate hazard from migrate is in the type that migrates clean and corrupts under CDC. ## The COLUMN_DEFAULT dialect MariaDB's information_schema reports column defaults in a different dialect than MySQL 8: string defaults keep their quotes, a defaultless nullable column's default is the literal word NULL, and DEFAULT CURRENT_TIMESTAMP reads as current_timestamp() with an empty extra. A reader written to MySQL conventions would silently corrupt every default; the mariadb flavor reads them correctly (in the run, DEFAULT CURRENT_TIMESTAMP, DEFAULT 'unnamed', and defaultless-nullable columns all reproduced on the target). The same dialect note also covers why SYSTEM VERSIONED tables and SEQUENCEs hide from the BASE TABLE filter: MariaDB reports its defaults in a different dialect. ## Landing on PlanetScale Nothing about the MariaDB source changes when the target is PlanetScale — the target-side behavior is the standard PlanetScale copy path, which differs from vanilla MySQL in a few ways worth expecting: - Batched / interpolated INSERT, not LOAD DATA — PlanetScale (a Vitess flavor) uses client-side parameter interpolation for the copy; sluice logs the write path at start. - Connection-budget capping — copy parallelism is auto-capped to the tier's connection budget (in the run, effective copy budget of 2 against a PS-10), and sluice notes that a PlanetScale target is tier-CPU-bound, not connection-bound (ADR-0116): a larger tier or Metal is the real throughput lever, not more parallelism. - Foreign keys — if your MariaDB schema has FK constraints, enable “Allow foreign key constraints” on the PlanetScale database before migrating (the test schema had none, so no toggle was needed). See Foreign keys on Vitess. - A plain migrate applies DDL directly — no deploy-request / safe-migrations interaction on the branch you point at. ## Verifying the migration sluice treats mariadb and mysql/planetscale as distinct engines, so cross-engine content-hash verification (--depth sample) is not yet available for MariaDB → MySQL-family — it refuses loudly and steers you to count mode rather than half-checking: sluice verify \ --source-driver mariadb --source 'app:pass@tcp(mariadb-host:3306)/app' \ --target-driver mysql --target 'root:pass@tcp(mysql-host:3306)/app' \ --depth count # → 7 table(s) checked, 7 clean, 0 mismatched, 0 could not be verified --depth count is the cross-engine verification path and passed clean in both runs. For content-level confirmation of the MariaDB-specific columns, spot-check them directly on the target — the geometry note above is the one place a naive comparison misleads. ## Continuous sync: domain-based GTIDs For a zero-downtime cutover (snapshot → CDC → cutover) rather than a one-time copy, a MariaDB source streams its native binlog, but MariaDB's GTIDs are domain-based (e.g. 0-100-38) — a distinct format from MySQL's GTIDs, and sluice parses and resumes off them (since v0.99.271). Two MariaDB CDC realities to plan around, both covered in MariaDB has no BEGIN, and won't tell you if your position survived: a MariaDB transaction opens with a MariadbGTIDEvent and no BEGIN event, and you cannot pre-check whether a stored position is still reachable — @@gtid_binlog_state is unchanged across PURGE BINARY LOGS, so a dead position looks live and the stream throwing error 1236 is the only honest signal. The native uuid/inet columns decode faithfully through this path (v0.99.272). ## What sluice checks for you - Engine fingerprint steering — pointing the mysql driver at a MariaDB server (or vice-versa) is caught and steered, so the catalog-reading divergences are always handled by the right flavor. - The collation-remap WARN — every string column whose source collation has no target-family equivalent is announced with the exact from → to mapping, never silently changed. - No CHECK fan-out — MariaDB's per-table CHECK-constraint names are read per-table, so same-named auto-CHECKs on JSON columns across tables don't collide into a duplicate-constraint CREATE TABLE failure. - Geometry SRID carried from GEOMETRY_COLUMNS — the SRID that SHOW CREATE omits is read from the OGC catalog view, so it isn't silently reset to 0. - Faithful native-type CDC decode — uuid/inet6/inet4 decode from their raw binlog bytes rather than being stringified into a wrong value a MySQL-family target would silently accept. - Cross-engine sample refusal — verify --depth sample refuses across the MariaDB/MySQL engine boundary instead of returning a misleading partial result; --depth count is the supported path. ## Next steps - Zero-downtime migration — the full snapshot → CDC → verify → cutover flow, once the domain-GTID stream is running. - Self-hosted MySQL → PlanetScale — the PlanetScale target path in depth (the MariaDB source slots into the same recipe). - MariaDB field notes — the five catalog/CDC divergences, each with the ground truth behind it. - Verify & reconcile — count-mode verification and what to do when it flags a delta. =============================================================== # PlanetScale & Vitess (https://sluicesync.com/docs/planetscale-vitess/) Migrate and continuously sync from PlanetScale-MySQL or any Vitess deployment through the VStream gRPC feed. PlanetScale (and self-hosted Vitess) don't expose MySQL's binary log directly — row changes come through Vitess's VStream gRPC API instead. sluice speaks that protocol through a MySQL-engine flavor: the same reader, decoder, and pipeline you use for vanilla MySQL, with a Vitess-shaped CDC transport and a capability set that reflects the platform's constraints. This guide covers selecting the flavor, tuning the cold-start copy, warm-resume across a purged position, and reading the throttler/lag signals that are unique to a Vitess-fronted source. ## Selecting the flavor Two driver names register the VStream-backed flavor; pick by deployment shape: Driver · Use for · planetscale · PlanetScale's hosted MySQL. TLS by default; auth is HTTP Basic where the username/password are your service-token name and value; the default shard convention is -. · vitess · A Vitess cluster you run yourself (etcd + vtctld + vtgate + vttablets). Shares PlanetScale's VStream engine code; point it at your vtgate. · sluice sync start \ --source-driver planetscale --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" What auto-detection does — and doesn't. A *.connect.psdb.cloud / *.private-connect.psdb.cloud host is recognised automatically so sluice excludes Vitess's _vt_* shadow tables — even when you connect with the plain mysql driver. Choosing the transport is still explicit, though: the mysql driver against a PlanetScale host gives you binlog CDC, not VStream. Pass --source-driver planetscale to get the VStream feed. Non-PlanetScale Vitess (custom domains) needs a manual --exclude-table='_vt_*'. ## Source preconditions Key constraints inherited from the Vitess platform (sluice already accounts for these — they're context, not steps): - No direct binlog access — CDC goes through VStream gRPC (the flavor declares CDCVStream, which the streamer's capability check accepts). - No LOAD DATA INFILE; the cold-copy uses batched inserts. - Sharded keyspaces are supported on both the standalone-CDC and snapshot→CDC paths. vtgate fans the COPY phase out per shard, then the same stream tails CDC across all shards. A VStream source password needs only read access. If a PlanetScale branch is the target, the password's role must allow DDL — sluice creates the destination tables plus its control tables (sluice_cdc_state, sluice_cdc_schema_history, …) on cold-start, and a reader/writer/readwriter role is denied DDL on a production branch. Mint the target password with pscale password create --role admin. If the target branch has Safe Migrations enabled, pre-create the tables and pass --schema-already-applied. ## Sharded keyspaces All optional; ride on the standard MySQL DSN as extra ?key=value parameters: DSN param · Purpose · vstream_shards · Comma-separated shard list (default -; e.g. vstream_shards=-80,80-). vttestserver dev clusters typically use 0. · vstream_auto_discover_shards=true · Discover the layout at Open time via SHOW VITESS_SHARDS LIKE '/%'. Mutually exclusive with vstream_shards; recommended when the layout isn't known statically. · vstream_endpoint · Override the vtgate gRPC endpoint. Default :443, matching PlanetScale's convention. · vstream_transport · tls (default) or plaintext (localhost vttestserver / dev only). · vstream_auth · basic (default) or none (vanilla Vitess with no VStream auth). · A mid-stream reshard surfaces as a typed ShardLayoutChangedError; the continuous-sync streamer's outer loop reopens the reader on the new layout automatically. ## VStream cold-start throughput The snapshot copy is bounded differently from a native-MySQL copy because vtgate forces a single cross-region-RTT-bound INSERT connection (it blocks LOAD DATA). Two axes widen it: Flag · Axis · --copy-fanout-degree · Write fan-out (ADR-0097, PlanetScale-MySQL target): PK-hash-partition the incoming snapshot row stream out to N concurrent batched-INSERT writers, each on its own connection. 0 = auto (4); 1 = serial. Bounded by the target connection budget. · --vstream-copy-table-parallelism · Read axis (ADR-0099, Vitess/PlanetScale source): the number of concurrent single-table COPY streams the auto-shard cold-copy runs. 0 = fall back to the DSN vstream_copy_table_parallelism param, then the engine default (1 = serial). An explicit flag wins over the DSN param. · The generic --table-parallelism / --bulk-parallelism cold-start knobs are inert on a VStream source (setting one emits a one-time WARN). Use the two flags above instead. --copy-table-parallelism is for self-managed non-Vitess MySQL, not PlanetScale. ## Warm-resume & auto-resnapshot On restart, sluice resumes from the persisted VGTID position. PlanetScale's binlog-retention window is finite, so a resume from a position older than the source's retained binlogs is routine — and by default (ADR-0093, parity with the self-hosted binlog path) sluice auto-recovers with a fresh cold-start re-snapshot rather than failing. On an idempotent VStream source the upsert copy absorbs the overlap and the target is not dropped. When a full re-snapshot is expensive (very large tables) and you'd rather decide deliberately, pass --no-auto-resnapshot. sluice then fails loudly with an actionable error naming the recovery commands (--restart-from-scratch / --reset-target-data) instead of re-copying. It gates both the pre-flight fall-through and the reactive VStream recovery. ## The throttler & lag reality Some VStream delays you act on; some you wait out. The measured findings reset a few intuitions: - The #1 real-world stall is a co-tenant VReplication migration on the same keyspace (an OnlineDDL on a large table), not your own write rate — its copy moves the shared shard-lag metric that gates every app. A write-heavy primary alone rarely trips the default 5s lag throttler on a healthy cluster. - Upsizing the cluster or vtgate does not clear a replica-lag throttle. The lever is source-side: reduce load, and avoid huge single transactions during bulk-copy and cutover. ### The mid-stream throttle signature When a throttle engages mid-stream, vtgate strips the in-band throttled flag from the events sluice sees, so the symptom is: heartbeats still flowing, zero change events, and sluice_lag_seconds climbing while sluice_seconds_since_last_event stays low (< 6s). No gRPC error arrives, so the stream stays connected and catches up when the throttle clears. sluice surfaces the symptom as a rate-limited WARN — "alive (heartbeats flowing) but NO change events for Ns" — once per quiet spell. Out-of-band, check SHOW VITESS_THROTTLED_APPS on the primary. The soft window is tunable per-DSN with vstream_idle_warn_timeout (a Go duration; 0 disables the WARN only, not the hard liveness guards). Corrected finding — a genuinely idle source does NOT fire this WARN on real PlanetScale. vtgate emits periodic idle VGTIDs that re-arm sluice's soft-idle timer, so the WARN is specific to a throttle or a large-transaction stall — not routine quiet. (Older guidance said an idle source produces the same WARN; on a real PlanetScale endpoint it does not.) If you see the WARN, treat it as a throttle/large-tx signal and check the throttled-apps list. A tablet failover / planned reparent terminates the stream; the streamer's outer loop reconnects from the persisted position — a single brief seconds_since_last_event spike is almost always transient. See the in-repo VStream troubleshooting runbook for the full cause catalogue. ## Storage auto-grow & primary reparent A non-Metal PlanetScale instance crossing a storage boundary briefly disrupts in-flight writes while the volume grows and a new primary is promoted. sluice rides these windows automatically — no flags required — across cold-copy write, source read, and the post-copy index/constraint phase. You'll see WARN lines naming the transient (Vitess 1105 "not serving" / read-only) and the retry; they're expected and self-clearing. A genuine, non-transient failure still surfaces loudly and promptly. ## Target-health telemetry (optional) sluice can consume PlanetScale's control-plane metrics (target CPU, memory, storage, replication lag) to back off apply pressure proactively and to fire operator alerts. This reads the PlanetScale metrics API, not the database — it uses a service token that is distinct from the data-plane --target DSN. The opt-in is all-or-nothing: an org without a complete token pair is a loud refusal. export PLANETSCALE_METRICS_TOKEN_ID=... # granted read_metrics_endpoints export PLANETSCALE_METRICS_TOKEN=... sluice sync start \ --source-driver planetscale --source "$SLUICE_SOURCE" \ --target-driver postgres --target "$SLUICE_TARGET" \ --planetscale-org acme \ --planetscale-metrics-db app \ --notify-storage-util 0.85 --notify-cpu-util 0.90 \ --notify-slack "$SLACK_WEBHOOK" When telemetry is on, sluice's /metrics export gains the sluice_target_* gauge family (CPU/mem/storage/lag), and the live signals clamp the startup apply-lane count and damp the AIMD high-water under pressure. The token id and secret should always come from the environment, never the command line. ### Watching a database without a sync To watch a PlanetScale database's health for dashboards or alert-only operation — with no sync attached and no database connection opened — use metrics-watch. It polls only the control-plane endpoint, fires the same --notify-* alerts, and with --metrics-listen ADDR becomes a standalone PlanetScale-metrics Prometheus exporter: sluice metrics-watch \ --engine planetscale --planetscale-org acme --planetscale-metrics-db app \ --notify-storage-util 0.85 --notify-slack "$SLACK_WEBHOOK" --quiet It supports --once (single sample, for scripts) and --interval (default 60s, the PlanetScale metrics granularity). ## PlanetScale-Postgres as a target PlanetScale-Postgres (PS-PG) is not Vitess-fronted for sluice's purposes — the vanilla postgres engine handles it cleanly, and its endpoints (*.pg.psdb.cloud) don't carry _vt_* shadow tables. One operational note: the tables sluice creates are owned by whichever role connects, and PlanetScale's non-superuser API role (pscale_api_*) will own them if you connect as it. If you want the tables owned by the Default postgres role, connect as that role. For CDC into PS-PG, ensure wal_level=logical and the connecting role has the REPLICATION attribute. ## Next steps - Operate a sync fleet — dashboards, alerting, and lag observability across many streams. - sync start reference — every flag named here, with defaults. - Zero-downtime migration — the snapshot→CDC cutover flow this guide's flags feed. =============================================================== # Move a PlanetScale database between regions (https://sluicesync.com/docs/planetscale-region-move/) PlanetScale has no native region move — create the database in the new region and let sluice copy it across, with zero downtime or in one shot. A PlanetScale database is pinned to its region at creation, and there is no native, in-place region move. The path is the same in spirit for every setup — create a new database in the target region and let sluice copy the data across (both ends are MySQL→MySQL, so no cross-engine type translation is involved) — but the exact shape depends on how your data is laid out. This guide covers three cases: Case 1 — a single unsharded database (the common one), Case 2 — several unsharded databases, and Case 3 — a sharded keyspace. Read Before you start and Provision the target first — they apply to all three — then jump to the case that matches your setup. ## Before you start & gotchas - Foreign keys — enable them on the target first. PlanetScale does not accept FOREIGN KEY DDL by default (Vitess rejects it with VT10001). If your schema uses foreign keys, turn on "Allow foreign key constraints" in the target database's Settings → General tab before you migrate — with no open deploy requests — so sluice's foreign-key DDL is accepted and the constraints are preserved (how to enable them). It is supported on unsharded databases only, cyclic foreign keys with CASCADE are not supported, and deploy requests do not validate the referential integrity of pre-existing rows. If you would rather not carry the foreign keys at all, dropping them also works — sluice emits each column's covering index as a separate statement, so those are kept. For the skip-vs-enable decision in full — including --skip-foreign-keys, which keeps the columns indexed for you — see Foreign keys on a Vitess / PlanetScale target. - Sharding. A normal unsharded PlanetScale database (the default) needs no special flag on v0.99.190+; --allow-cross-shard-merge applies only to a genuinely sharded source keyspace — see the note under Provision the target. - One run per keyspace. A PlanetScale database is a single keyspace, and each sync start / migrate moves one source keyspace to one target. To move several databases, run one per database (each with its own --stream-id and target) or supervise them with a sync fleet config — no single run spans multiple source keyspaces. - The sync stop --wait drain message. On VStream teardown, sync stop --wait may print a "did not complete drain within …" timeout even though the stream did drain and exit cleanly. Verify the process actually exited rather than treating that message alone as a failure. - Throughput is target-tier-CPU-bound. The bulk copy is limited by the target cluster's CPU; scale the tier for a faster copy. ## Provision the target & connect Create the destination database in the new region, sized to match (or exceed) the source. A PS-10 branch takes roughly 7–8 minutes to reach READY: pscale database create app-sa --region aws-sa-east-1 --cluster-size PS-10 Both the source and the target reach PlanetScale through the same global connect host, aws.connect.psdb.cloud:3306 — PlanetScale routes to the right region by credential, not by hostname. The connection strings are standard go-sql-driver MySQL DSNs, and ?tls=true is required on both: # source (CDC read) — export as SLUICE_SOURCE USERNAME:PASSWORD@tcp(aws.connect.psdb.cloud:3306)/app-us?tls=true # target (write) — export as SLUICE_TARGET USERNAME:PASSWORD@tcp(aws.connect.psdb.cloud:3306)/app-sa?tls=true USERNAME is the generated username field returned by pscale password create
.=text, which carries the column as free text so the real bytes migrate. sluice can't recover the original label — nobody can — but it refuses to let the loss be a surprise. ## The transferable lesson Character-set correctness is not uniform across a database's own surfaces. A column declared utf8mb4 stores 4-byte characters perfectly in its rows while the same server silently downgrades them in identifiers and enum labels in the data dictionary. When you copy schema, you are copying metadata that may have passed through a lossier path than the data did — verify label and identifier bytes with hex(), not by eye, and treat a corrupted catalog value as unrecoverable rather than assuming a re-read with the right charset will heal it. ## Primary sources - MySQL ENUM type and its limits — MySQL 8.0 Reference Manual: the ENUM type. - MySQL character-set support and utf8mb4 — the utf8mb4 character set. - sluice type mapping and overrides — type mapping (the --type-override escape). =============================================================== # The 20-second guillotine: Vitess's transaction killer meets a 96 ms WAN (https://sluicesync.com/field-notes/vitess-tx-killer-wan/) Continuous CDC into PlanetScale MySQL over the internet stalled at effectively zero throughput. The failure geometry: with no statement pipelining an N-row apply costs N round-trips; at 96 ms RTT a 1,000-row batch takes ~100 seconds; Vitess kills any transaction at 20 seconds; the adaptive batch controller shrinks the batch — and converges to a stall. Observed — trigger-CDC continuous apply into PlanetScale MySQL (Vitess) at ~96 ms RTT, and into PlanetScale Postgres. Internally Bug 168 (Postgres apply path, fixed v0.99.153) and Bug 169 (MySQL/Vitess apply path, insert path fixed v0.99.155; update/delete tail tracked under ADR-0138). ## What happened A continuous CDC sync into a PlanetScale MySQL target over the public internet stalled. With the default apply config, every apply transaction took far longer than Vitess's hard 20-second transaction timeout and was killed: Error 1105: ... tx killer rollback ... exceeded timeout: 20s The adaptive batch controller reacted to the failures and multiplicatively shrank the batch — 1000 → 500 → 250 → 125 → 62 — but the p95 stayed around 22 seconds, batches kept getting killed, and durable progress was roughly nil (over ~210 seconds the target advanced about 50 net rows; the durable resume position never left last_id=0). A self-tuning system had converged to a stall. ## Why (the mechanism) The apply path issued its statements one round-trip at a time — no statement pipelining, no multi-row coalescing — so an N-row apply transaction costs about N network round-trips. At a 96 ms RTT, a 1,000-row batch is 1000 × ~2×RTT ≈ ~100 s, well past the 20-second killer. So the two knobs fight each other with no winning setting: every batch big enough to be efficient overruns the killer, and every batch small enough to commit crawls at roughly lanes / RTT — with 4 lanes over 96 ms, on the order of 20–30 changes/s. The controller can only pick between "killed" and "crawling." The clean proof that the bottleneck was per-row round-trips and not batch/transaction count came from the Postgres side of the same test: pinning a large static batch (--no-auto-tune --apply-batch-size 1000) barely moved throughput — about 63 changes/s, essentially unchanged from the auto-tuned collapse. If batch count were the cost, a 1,000-row static batch would have jumped; it didn't, because the cost is 1,000 serial round-trips either way. Routing the identical workload through a pipelined applier (a batch costs ~1–2 RTT instead of N) took Postgres from ~63/s to ~5,000 changes/s. Latency × protocol shape beats every knob. ## The repro On a high-latency link (add ~80–100 ms with tc netem if you don't have a real WAN), run continuous CDC into a Vitess/PlanetScale MySQL target under a sustained write workload and watch the durable apply position: # generate a backlog, then apply over the WAN with the default config: # the 20 s tx-killer fires, AIMD collapses the batch toward 1, # durable progress ~0 (last_id stays near 0). # cap the batch low enough to commit inside 20 s to confirm the RTT floor: sluice sync start --no-auto-tune --apply-batch-size 80 ... # no tx-kills now — but only ~20-30 changes/s, two orders of # magnitude below the ~2,600/s the source generates. It diverges. The diagnostic knob is the static-batch test: if pinning a large static batch doesn't raise throughput, your bottleneck is per-row round-trips, and no batch-size setting will save you. ## What sluice does about it The real fix is to remove the per-row round-trips. On the Postgres apply path, sluice routes the batch through a statement-pipelined applier so a batch of N changes costs ~1–2 RTT — measured ~5,000 changes/s over the WAN where the round-trip-bound path managed ~63/s. On the MySQL/Vitess path, the insert-heavy case is handled by multi-row INSERT coalescing: re-validated on real PlanetScale MySQL at ~101 ms RTT, an insert-only 200,003-change backlog drained at ~4,000 changes/s with the default config and the 20-second killer never firing — versus the prior default-config stall (roughly 100–200×). The update/delete-heavy MySQL path is still round-trip-bound and is tracked as MySQL apply-parity work under ADR-0138; until it lands, migrate/cold-copy (a streaming COPY/bulk-load protocol, bandwidth-bound not RTT-bound) is the safe cross-region primitive. ## The transferable lesson Over a WAN, the shape of your protocol dominates every tuning knob. If your applier isn't pipelined or multi-row-coalesced, an N-row batch costs N round-trips, and no adaptive batch controller can find a setting that is both efficient and within a managed database's transaction timeout — it will converge to a stall, which is worse than an honest error because it looks like the system is trying. And managed-database transaction killers (Vitess's 20 s, others' equivalents) turn "slow" into "wedged": a batch that would merely have been slow on a self-hosted server gets rolled back entirely. Measure round-trip cost directly — the static-batch test — before you trust batch size as a lever. ## Primary sources - Vitess transaction timeout / tx-killer — Vitess transactions reference (the --queryserver-config-transaction-timeout behavior). - sluice PlanetScale & Vitess guidance — PlanetScale & Vitess and PlanetScale Postgres. - How sluice's CDC apply works — How sluice copies your data. =============================================================== # Cloudflare D1 is not your local SQLite (https://sluicesync.com/field-notes/d1-not-local-sqlite/) Our type-inference validated candidate columns with SQLite GLOB patterns — a UUID check is a 356-character char-class pattern — and passed every test we had, including a multi-GB head-to-head. Then it hit live D1 and died instantly: code 7500, LIKE or GLOB pattern too complex, on a 1,750-row table with pristine data. Observed — live Cloudflare D1 source (--source-driver d1) with --infer-types. Addressed by ADR-0145 (migrate --stage-local), shipped v0.99.167. ## What happened sluice's --infer-types feature validates candidate columns with SQLite GLOB patterns — the UUID-conformance check is a ~356-character character-class pattern (32 repetitions of [0-9a-fA-F]), the ISO-datetime check ~79 characters. It passed every test we had, including a multi-GB head-to-head. Then it ran against a live D1 database and died instantly: HTTP 400 code 7500: "LIKE or GLOB pattern too complex" Not on a huge table — on a 1,750-row table with pristine data, on the first *_at / *_uuid candidate column. The failure was size-independent, and no local test could ever have produced it. ## Why (the mechanism) D1's SQLite build ships a low SQLITE_MAX_LIKE_PATTERN_LENGTH, well below the ~356-character UUID pattern. Every stock local SQLite — and modernc.org/sqlite, the pure-Go build sluice uses locally — accepts the default cap of 50,000, so the long pattern compiles fine everywhere except on the real service. The dialect is identical; the limit is a hidden configuration surface you can't see from the SQL. So the whole failure class was invisible until the query ran on D1 itself: "SQLite-compatible" local testing told us nothing about it. One layer deeper sat a second cliff: even where the pattern is accepted (boolean/JSON checks), an unbounded full-column validation scan over a multi-GB table trips D1's per-query CPU ceiling and aborts with HTTP 429 / code 7429. Two independent hidden limits, both absent from every local engine. ## The repro Run a long-enough GLOB against a live D1 database — the row count and data quality are irrelevant: -- against live Cloudflare D1 (e.g. via wrangler d1 execute): -- a ~356-char character-class pattern like the UUID-conformance check SELECT count(*) FROM customers WHERE org_uuid GLOB '[0-9a-fA-F][0-9a-fA-F]... (32x) ...'; -- D1: HTTP 400 code 7500 "LIKE or GLOB pattern too complex" -- the identical query on any local SQLite (incl. modernc) with the -- default SQLITE_MAX_LIKE_PATTERN_LENGTH = 50000 runs fine. ## What sluice does about it The fix stops fighting the caps one query shape at a time. migrate --stage-local (D1 source only) first replicates the live D1 database into a byte-faithful local SQLite file, then runs the entire migrate — schema read, --infer-types validation, and bulk copy — against that local file via the existing sqlite engine, where neither the pattern-complexity limit nor the CPU ceiling exists. Staging closes the whole class of D1 HTTP-query limits (the GLOB cap, the CPU ceiling, ad-hoc COUNT/MAX 429s) in one move, and because the staged file carries D1's original conservative SQLite types, inference makes identical decisions. It auto-engages when --infer-types is set against a D1 source (the direct path is structurally broken there) unless you pass --no-stage-local. Crucially the staging is lossless, unlike wrangler d1 export, which rounds integers above 253 through a JavaScript double (see 253 is a database boundary now). A prototyped rowid-windowed "chunked validation" alternative was parked: it addresses only the CPU ceiling, not the GLOB-complexity limit — the same long patterns still abort at code 7500 before any CPU budget is reached. ## The transferable lesson When you target a hosted build of an embedded engine, the SQL dialect is the same but the limits are a config surface you can't see and can't test against locally: pattern-length caps, per-query CPU/time ceilings, statement-size and result-size bounds. "SQLite-compatible" (or "Postgres-wire-compatible") tells you about syntax, not about the operational envelope. Validate against the real service early, and when the hosted limits are a moving target, the robust move is often to get the data onto an unconstrained local copy and do the heavy work there rather than negotiating with each cap individually. ## Primary sources - sluice import guide — Import SQLite or Cloudflare D1 (the --stage-local path). - Cloudflare D1 limits — D1 platform limits. - SQLite's SQLITE_MAX_LIKE_PATTERN_LENGTH — SQLite implementation limits. =============================================================== # SQLite's DECIMAL is a suggestion: 19.99, stored as 19.989999999999998 (https://sluicesync.com/field-notes/sqlite-decimal-affinity/) SQLite doesn't have column types; it has affinities. Declare DECIMAL(10,2) and you get NUMERIC affinity, which stores any non-integer as a float64 — so 19.99 lands as 19.989999999999998 on disk. Not a rounding bug: an engine storage property. Observed — SQLite as a migrate target (writer) and as a source (reader). Internally Bug 162 (CRITICAL silent corruption, target side, fixed v0.99.147) and Bug 163 (loud COPY abort, source side, fixed v0.99.150). ## What happened Migrating an ordinary money column into a SQLite target, a decimal 19.99 landed on disk as 19.989999999999998 — exit 0, no warning. The produced .db is the whole deliverable of that path (the documented flow is X → SQLite → Cloudflare D1 via wrangler d1 import), so the corrupted value is exactly what the next consumer reads. This wasn't a sluice rounding bug; it was SQLite storing the value as a binary float because of how its type system works. ## Why (the mechanism) SQLite doesn't have column types — it has affinities. A column declared DECIMAL(10,2) (or NUMERIC) carries NUMERIC affinity, and SQLite coerces any non-integer inserted value to REAL — a float64 — on store. The first guard against this checked the wrong predicate: it refused values "beyond ~15 significant digits," on the theory that precision loss is about digit count. But float64 inexactness is not about significant digits; it is about dyadic representability — whether the value is a finite base-2 fraction. 19.99 = 1999/100 has a denominator that isn't a power of two, so it is not exactly representable in float64 despite having only four significant digits, and it slipped straight past the >15-digit guard. Essentially every real-world money value (19.99, 5.10, 0.10) is non-dyadic, so essentially every money value was silently floated. Integer-valued decimals (100.00 → INTEGER 100) and the rare dyadic value stored exactly, which is why spot checks missed it. The reader direction has its own trap. SQLite renders a stored REAL back with Go's strconv.FormatFloat(v, 'g', -1, 64), and the 'g' verb flips to exponent notation at magnitude ≥ 106 (or < 10-4). So a perfectly ordinary 1000000.00 renders as "1e+06" — and pgx's binary numeric (OID 1700) COPY encoder cannot find an encode plan for an exponent-notation string, so the migration aborts: ERROR: unable to encode "1e-10" into binary format for numeric (OID 1700): cannot find encode plan (SQLSTATE 57014) An entirely ordinary $1,000,000.00 in a SQLite DECIMAL column was enough to abort a SQLite → Postgres migrate. ## The repro -- WRITER side: what a "DECIMAL" SQLite target actually stores CREATE TABLE m (id INTEGER PRIMARY KEY, price DECIMAL(10,2)); INSERT INTO m VALUES (1, 19.99), (2, 5.10), (3, 100.00); SELECT id, typeof(price), price FROM m; -- 1 | real | 19.989999999999998 <- non-dyadic, silently floated -- 2 | real | 5.0999999999999996 -- 3 | integer | 100 <- integer-valued: exact -- READER side: 'g'-verb exponent rendering aborts a binary numeric COPY INSERT INTO m VALUES (4, 1000000.00); -- typeof -> real -- Go's FormatFloat(..., 'g', ...) renders 1e+06 -> pgx numeric COPY: SQLSTATE 57014 ## What sluice does about it Two fixes for one engine property. The writer now stores decimal/numeric columns as TEXT affinity by default, so the value is preserved byte-exact (19.99 stays '19.99') and sluice's own reader decodes TEXT → decimal cleanly — this also keeps the value wrangler d1 import-safe. The reader now renders floats with the 'f' verb instead of 'g', so 1000000 renders as plain digits that pgx's numeric encoder accepts. The change of predicate is the real lesson: the guard moved from "significant-digit count" to "not exactly representable in float64." ## The transferable lesson If you produce .db files for anyone — or consume them — a declared column type in SQLite tells you almost nothing; check typeof() on the actual stored value. And when you reason about float precision, the predicate is dyadic representability, not significant-digit count: 19.99 at four digits is lossy while some 17-digit values are exact, because base-2 can only finitely represent fractions whose denominator is a power of two. A guard written against "big numbers" will wave through every ordinary price. ## Primary sources - SQLite type affinity — datatypes in SQLite (§3, type affinity). - sluice type mapping for SQLite / D1 — type mapping. - Go strconv.FormatFloat — the 'f' vs 'g' verbs (exponent switch-over). - The dyadic-rational boundary — IEEE-754 double-precision format. =============================================================== # One long-lived reader, 75 GB of WAL (https://sluicesync.com/field-notes/sqlite-wal-checkpoint-starvation/) A continuous-CDC run against a 20 GB SQLite source watched the -wal file grow from zero to 75 GB in 52 minutes — while the change-log table it tracked stayed bounded at a few thousand rows. In WAL mode, a checkpoint can only reclaim frames older than the oldest live reader's snapshot. Observed — continuous sqlite-trigger CDC against a WAL-mode SQLite source, ~52-minute endurance run. Internally Bug 167 (found v0.99.151, fixed v0.99.152). ## What happened A continuous-CDC endurance run against a 20 GB SQLite source watched the -wal sidecar file grow from zero to 75 GB in 52 minutes — roughly 1.4 GB/min, linear, with no plateau — while the change-log table the sync tracked stayed bounded at a few thousand rows the whole time (a periodic prune kept its row count in check). Exactly-once was never in doubt; the harm was pure disk-fill, and it capped how long a continuous sync against a SQLite source could run. The process RSS crept up in lockstep, ~0.9 MB/min. ## Why (the mechanism) In WAL mode, a checkpoint can only copy-and-reclaim frames older than the oldest live reader's snapshot. A reader holding a snapshot pins every WAL frame at or after its read-mark, so the checkpoint can restart the WAL but never truncate the file. sluice's poll loop kept a live read on the source between polls, so the WAL accumulated every superseded version of the same heavily-churned change-log B-tree pages — each insert-then-prune-delete rewrites the same pages, and every old version stayed pinned. An explicit PRAGMA wal_checkpoint(TRUNCATE) with the sync still running could not reclaim the 75 GB. Ground truth was theatrical: the instant the process was killed and its read snapshot released, the last-connection-close checkpoint truncated the 75 GB WAL to zero, and the whole thing collapsed to about 0.6 GB of genuinely new pages in the main file. So ~74 GB of it was superseded frames the reader's snapshot had pinned. The precise culprit turned out to be subtle: it wasn't a single explicit long transaction but the poller's database/sql connection pool retaining an idle connection whose stale WAL read-mark pinned the checkpoint. (The RSS creep tracked the WAL, not the Go heap — it was modernc's OS-level mmap of the ever-growing -wal, a secondary effect that bounding the WAL also bounds.) ## The repro # multi-GB WAL-mode SQLite source; start a continuous CDC sync to any target; # drive a sustained insert/update/delete workload while pruning the change-log # so its ROW count stays bounded; sample the WAL each minute: stat --format='%s' big.db-wal # climbs ~GB/min, no plateau, # even though the change-log row count is flat # stop the sync -> the last connection closes -> the WAL truncates to ~0. # That truncation-on-close is the proof the running reader pinned it. ## What sluice does about it The fix is protocol hygiene, not tuning, and it has two parts (local-SQLite path only — the d1-trigger source polls over HTTP with no local pager and is unaffected). First, the poller's read connection is no longer retained idle (SetMaxIdleConns(0)), so its WAL read-mark is released after each poll and a checkpoint can reset the WAL — this alone held the WAL flat at ~8 MB in a focused repro where the default idle pool grew it to 158 MB in 12 seconds. Second, the poll loop issues PRAGMA wal_checkpoint(TRUNCATE) on a 30-second cadence (busy-tolerant: a BUSY result just retries next cadence), so the WAL stays bounded even when the operator's own application has disabled wal_autocheckpoint. The checkpoint runs in the poll goroutine between polls, never racing the read, and never touches the watermark or the exactly-once path. ## The transferable lesson A reader's snapshot pins the log — this is the same principle that makes an idle Postgres replication slot fill a disk (slots don't die with your process) and long transactions bloat any MVCC engine's dead-tuple space. SQLite just shows it to you as a single file you can stat. If you hold a long-lived read against a churning table, you are silently retaining every superseded version of the pages you touch; release the snapshot periodically (short-lived read transactions, and mind your connection pool's idle connections — a pooled idle connection holds a read-mark just as a live query does) so the log can be reclaimed. And watch for the second-order effect: a growing WAL that gets mmap'd can look like a memory leak while your heap stays perfectly flat. ## Primary sources - SQLite Write-Ahead Logging — the WAL design (checkpointing and the reader-snapshot constraint). - PRAGMA wal_checkpoint and wal_autocheckpoint — SQLite pragmas. - sluice trigger-based CDC — How sluice copies your data. =============================================================== # {}: two characters, two types, one silent corruption (https://sluicesync.com/field-notes/empty-object-vs-array/) In Postgres, {} is an empty array literal. In JSON, it's an empty object. Funnel both through one value-preparation path and []byte("{}") is genuinely ambiguous — and for nine releases our MySQL writer resolved it the wrong way. Observed — MySQL writer, empty JSON object on bulk copy. Internally Bug 47 (fixed v0.29.1; reproduced identically on every binary from v0.20.0 through v0.29.0). ## What happened A MySQL source value attrs = '{}' (an empty JSON object, JSON_TYPE = OBJECT) round-tripped through sluice and landed on a MySQL target as attrs = '[]' — an empty array, JSON_TYPE = ARRAY. Every other JSON shape was perfect: populated objects, empty arrays, populated arrays, JSON null, JSON scalars. Only the empty object flipped type, and it did so silently, on every release for nine versions. ## Why (the mechanism) The two literals collide on their bytes. In Postgres, {} is the empty array literal; in JSON, {} is the empty object. When a migration pipeline funnels both worlds through one value-preparation path, []byte("{}") arriving at the encoder is genuinely ambiguous — the bytes alone cannot say whether they mean "empty PG array, which for a MySQL JSON column should become []" or "empty MySQL JSON object, which should stay {}." The MySQL writer guessed array, so empty objects became []. The instructive part is the first fix attempt, which was rolled back within a day. Simply preserving {} as an object broke the opposite case — a Postgres empty array overridden onto a MySQL JSON column should land as [] — because no local heuristic can disambiguate two bytes that carry two legitimate meanings. The writer didn't need a cleverer guess; it needed information it didn't have: the source column's type, threaded down to the encoder. ## The repro -- MySQL source: six canonical JSON shapes INSERT INTO t (id, attrs) VALUES (1, '{"role":"admin"}'), -- populated object: preserved (2, '{}'), -- empty object: CORRUPTED -> [] (3, '[]'), -- empty array: preserved (4, '[1,2,3]'), -- populated array: preserved (5, 'null'), -- JSON null: preserved (6, '"hello"'); -- scalar: preserved -- migrate MySQL -> MySQL, then on the target: SELECT id, JSON_TYPE(attrs) FROM t WHERE id = 2; -- before the fix: ARRAY (source was OBJECT) ## What sluice does about it The fix threads the missing context through the IR: ir.Column gained an optional SourceColumnType field that the translation layer populates, and the MySQL writer consults it to disambiguate — source type is an array → [], otherwise → {}. The disambiguation is column-scoped, proven by a single-row test with two columns: an empty text[] overridden to a MySQL JSON column lands as [], while an empty JSON object in the sibling column lands as {} — same row, opposite resolutions, because each carries its own source type. ## The transferable lesson Value translation is only sound when type information travels with the value, all the way to the last encoder. The moment two distinct source types can serialize to identical bytes — {} the empty array and {} the empty object, or an empty string versus SQL NULL, or 0 the number versus 0 the boolean — a downstream stage that sees only the bytes cannot recover the intent, and any local heuristic it applies will be right for one meaning and wrong for the other. Don't make the encoder guess; carry the type. ## Primary sources - Postgres array input syntax ({} as the empty array) — arrays. - MySQL JSON type and JSON_TYPE() — the JSON data type. - sluice type mapping (how source type is carried across the translate boundary) — type mapping. =============================================================== # The zero value is a loaded gun (https://sluicesync.com/field-notes/zero-value-config-trap/) Twice in this project a config field that “defaults on” silently defaulted off (or worse) for every caller that didn't go through the CLI — because in Go, every construction site that doesn't set a field gets the zero value. Both had real database consequences. Observed — two config-defaulting bugs with database consequences. The first (a CDC resnapshot path) is the v0.99.51 trap behind ADR-0093; the second is Bug 180, an un-extendable encrypted backup chain (fixed v0.99.185). ## What happened Twice, a config field meant to "default on" silently defaulted off — or to an unreachable value — for every caller that didn't construct it through the CLI. Both looked correct in a unit test and both had a real database consequence: one a CDC resnapshot path, the other an encrypted backup chain you couldn't extend. ## Why (the mechanism) In Go, every struct construction site that doesn't set a field gets that field's zero value — false for a bool, "" for a string. The CLI is one construction site; every test, every internal broker/chain path, and every future caller is another, and they all get the zero value unless they explicitly set the field. A field named for its on-behavior silently inverts to off for all of them. - Round one — a boolean defaulting the wrong way. AutoResnapshotOnInvalidPosition was intended to default true. But every test and internal construction that didn't set it got false and took the suppressed branch. The race-detector integration job surfaced it as a nil-deref panic on that branch — an intended-on safety behavior was off everywhere except the CLI. - Round two — a default that made a feature unreachable, and it shipped. The backup encrypt-mode feature "omit --encrypt-mode to inherit the chain's mode" keyed the inherit branch on an empty string. But kong, the CLI parser, fills the flag's declared default ("per-chain") whenever the operator omits it — so no CLI invocation could ever produce the empty string the inherit branch needed. The branch was dead from the parser's side. The unit test passed "" directly and went green, sailing right past the layer that made it unreachable. The operator-visible result: extending or resuming a per-chunk-encrypted backup chain via the natural "omit the flag" invocation was refused. ## The repro type Streamer struct { // intended to default ON — but every caller that doesn't set it // gets the zero value (false) and silently takes the OFF branch: AutoResnapshotOnInvalidPosition bool } s := Streamer{} // a test, a broker path, a future caller... // s.AutoResnapshotOnInvalidPosition == false -> suppressed branch // The kong variant: a direct-call test cannot see a default the parser injects. // flag omitted on the CLI -> kong fills "per-chain" -> inherit branch (keyed // on "") is unreachable; but a unit test that passes "" directly goes green. ## What sluice does about it Two rules fell out, both now project doctrine. First: name a boolean config for its opt-out (SuppressX, NoX), never EnableX-defaulting-true-by-intent, so the zero value is the safe, common behavior and no construction site can silently invert it. Second: pin any omitted-flag semantics through the real argument parser, not a direct call — a unit test that hands the function a value the parser would never produce (an empty string kong fills with a default) proves nothing about the actual CLI path. Bug 180's fix is verified end-to-end: omitting --encrypt-mode now resolves to "", flows to the orchestrator, and correctly inherits the chain's mode, so an incremental into a per-chunk chain succeeds and restores byte-exact. ## The transferable lesson In any language with zero-value initialization, the default that matters is the one an unset field takes, not the one your primary constructor writes — and your CLI is only one of many constructors. Make the zero value the safe, common case. And when a behavior is gated on a specific config value, especially an omitted or empty one, test it through the real parser: a framework default (kong, argparse, a builder's fallback) can quietly make a branch unreachable while a direct-call unit test that supplies the value by hand stays green. The green test is testing a code path no user can reach. ## Primary sources - Go zero values — the language spec on zero values. - kong, the CLI parser sluice uses (default injection) — github.com/alecthomas/kong. - sluice encrypted-backup chains and modes — Take encrypted backups and Sync from a backup chain. =============================================================== # MySQL won't match a JSON column by bind parameter (https://sluicesync.com/field-notes/mysql-json-where-cast/) WHERE json_col = ? matches zero rows in MySQL whether you bind the value as a string or as bytes — the server won't cast the parameter to JSON for the comparison. On a CDC UPDATE, replay-idempotency tolerance turns that zero-row match into silent divergence. Observed — MySQL → MySQL logical replication (CDC apply) touching a JSON column. Internally the applier value-shaping fix, ADR-0013 (v0.2.2). ## What happened A MySQL-to-MySQL CDC UPDATE on a table with a JSON column silently applied nothing: zero rows affected, stream position advanced, exit 0, no error. The same applier in the other direction — Postgres to MySQL — failed loudly on the identical column, crashing with Cannot create a JSON value from a string with CHARACTER SET 'binary'. One applier, one JSON column, two directions, opposite symptoms — and only the loud one was safe. ## Why (the mechanism) The applier bound the row's values straight into parameterised SQL. Two MySQL-isms bit, in sequence: - The bytes. go-sql-driver/mysql tags a []byte parameter with the _binary introducer, and MySQL rejects that for a JSON column (… CHARACTER SET 'binary') — the loud PG→MySQL crash. The bulk-copy path had already learned to convert JSON []byte to a string; the CDC path hadn't inherited the fix. - The comparison. The deeper one, and the silent one: MySQL's = does not implicitly cast a ? bind parameter to JSON — bind the value as a string or as []byte, either way WHERE doc = ? compares a JSON column against a non-JSON parameter and matches nothing. The UPDATE found no row to change. What made the second one invisible is a property of every correct CDC applier: it must tolerate zero rows affected, because logical-replication resume re-applies events idempotently (a re-applied UPDATE legitimately matches zero rows the second time — that tolerance is what makes replay safe). So the applier could not tell "already applied" from "never matched," logged the zero-row result as normal, advanced the position, and diverged the target with no signal. ## The repro The comparison, in isolation — no replication needed: CREATE TABLE ledger (id BIGINT PRIMARY KEY, doc JSON); INSERT INTO ledger VALUES (1, '{"k":"v"}'); -- a JSON column compared against a (string/bytes) parameter: SELECT * FROM ledger WHERE doc = '{"k":"v"}'; -- 0 rows -- the same comparison, parameter cast to JSON first: SELECT * FROM ledger WHERE doc = CAST('{"k":"v"}' AS JSON); -- 1 row -- so a CDC applier binding: UPDATE ledger SET ... WHERE doc = ? -- matches nothing, reports 0 rows affected, and (idempotency -- tolerance) advances the stream position anyway. ## What sluice does about it The CDC applier now routes every bound value through the same per-type prepareValue shaping the bulk-copy path uses (JSON []byte → string, and the rest), driven by a lazily-populated per-table column-type cache. For the comparison itself, a placeholderFor(type) helper emits CAST(? AS JSON) instead of a bare ? for JSON-typed columns, so the equality is JSON-to-JSON and matches. The Postgres applier needs no cast equivalent — pgx inspects per-column type metadata natively. And a Debug line now fires whenever an UPDATE or DELETE matches zero rows: resume idempotency still depends on tolerating that case, but the silence now leaves a footprint. ## The transferable lesson A parameterised = against a typed column is not type-agnostic: MySQL will not coerce a bind parameter into JSON, so a comparison that reads correctly matches nothing at runtime. And when the consumer is a CDC applier, its idempotency tolerance — the very thing that makes replay safe — is exactly what hides a never-matched predicate. This is the MySQL sibling of a Postgres story we hit on the same class: REPLICA IDENTITY FULL silently ate our UPDATEs, where a jsonb value failed an equality round-trip. Same shape, two engines, two root causes: when equality quietly stops matching, replay-tolerance swallows the loss. ## Primary sources - MySQL JSON comparison & the need to cast — The JSON Data Type and CAST(… AS JSON). - The _binary charset introducer behavior — character set introducers. - Why a CDC applier tolerates zero-row applies — How sluice copies your data. =============================================================== # One LOAD DATA can't load a BLOB and a JSON column at once (https://sluicesync.com/field-notes/mysql-load-data-charset/) A BLOB column needs CHARACTER SET binary or the server rejects its first non-ASCII byte; a JSON column rejects its input under CHARACTER SET binary. The two requirements point opposite ways, and there is no statement-level clause that satisfies both. Observed — MySQL bulk load via LOAD DATA LOCAL INFILE into a table with both a binary and a JSON column. Internally the LOAD DATA row writer, ADR-0026. ## What happened The fast bulk-load path — LOAD DATA LOCAL INFILE, typically 5–10× faster than parameter-bound multi-row INSERTs because the server parses one statement and one stream — hit a table that carried both a BLOB/VARBINARY column and a JSON column. There is no single CHARACTER SET clause on the statement that loads both. Pick either one and the other column's rows are rejected. ## Why (the mechanism) LOAD DATA validates every input byte against a charset, and that charset is a statement-level setting — one CHARACTER SET clause for all columns. The two column types want opposite things from it: - Without CHARACTER SET binary, the server validates input against the connection charset (utf8mb4) and rejects the first non-ASCII byte in a BLOB/VARBINARY column with Error 1300: Invalid utf8mb4 character string. Any binary column is silently broken. - With CHARACTER SET binary, the server flips: a JSON column rejects its input with Cannot create a JSON value from a string with CHARACTER SET 'binary', because JSON requires a Unicode-tagged input stream. The requirements are mutually exclusive at the statement level: binary columns demand the raw-bytes charset, JSON columns demand a Unicode one, and you get to name exactly one. ## The repro CREATE TABLE mixed (id INT PRIMARY KEY, blob_col BLOB, json_col JSON); -- utf8mb4 (default): the BLOB's first non-ASCII byte → -- ERROR 1300 (HY000): Invalid utf8mb4 character string LOAD DATA LOCAL INFILE 'Reader::x' INTO TABLE mixed CHARACTER SET utf8mb4 (id, blob_col, json_col); -- CHARACTER SET binary: the JSON column → -- ERROR 3144 (22032): Cannot create a JSON value from a string -- with CHARACTER SET 'binary' LOAD DATA LOCAL INFILE 'Reader::x' INTO TABLE mixed CHARACTER SET binary (id, blob_col, json_col); ## What sluice does about it Load every field into a user variable under CHARACTER SET binary, then re-tag per column in a SET clause. Binary, numeric, and temporal columns take their variable verbatim (raw bytes, exactly what they want); JSON, TEXT, VARCHAR, and SET columns get CONVERT(@cN USING utf8mb4) — the bytes are unchanged, only the charset tag is corrected: LOAD DATA LOCAL INFILE 'Reader::x' INTO TABLE mixed CHARACTER SET binary (@c0, @c1, @c2) SET id = @c0, blob_col = @c1, -- raw bytes, verbatim json_col = CONVERT(@c2 USING utf8mb4); -- re-tagged to Unicode The per-column re-tag is a named, tested wart (columnSetExpr): adding a new type that needs re-tagging is a one-line switch case. (Geometry stays on the batched-INSERT path and forgoes the LOAD DATA speedup; the fallback names the offending column in a WARN so the cause is diagnosable from one log line.) ## The transferable lesson CHARACTER SET on LOAD DATA is a single, statement-wide, all-columns setting for what is really a per-column problem — and MySQL's own type system contains two columns that demand incompatible answers. The escape hatch is a general one: when a bulk statement must apply different byte-interpretation rules to different columns, funnel every field through a user variable and do the per-column work in a SET clause, where each column gets its own expression. It is the row-writer analogue of never trusting a single global setting to be right for every member of a heterogeneous set. ## Primary sources - MySQL LOAD DATA, its CHARACTER SET clause, and the user-variable + SET form — LOAD DATA statement. - Why JSON rejects a binary charset — The JSON Data Type. - How sluice bulk-loads a MySQL target — How sluice copies your data. =============================================================== # Every HA knob on, and the slot still vanished at failover (https://sluicesync.com/field-notes/postgres-idle-slot-failover/) Patroni slot-sync on, sync_replication_slots on, hot_standby_feedback on — and a logical slot that hadn't advanced during the sync window was still lost on promotion. “HA-replicated” means the slot's LSN is copied on a timer, not that the slot can't be lost. Observed — Postgres HA (Patroni / PG 17 native slot sync) source, failover during a quiet-source window. Operator-confirmed in production. See Prepare a Postgres source. ## What happened Every mechanism for surviving a failover was configured: Patroni slots: (the permanent logical slot), sync_replication_slots = on, and hot_standby_feedback = on. A failover promoted the standby, and the CDC stream came back with wal_status = 'lost' — the slot was present but invalid, pointing at WAL that no longer existed. Nothing in Postgres's logs named the dropped slot at failover time; it surfaced only when the consumer reconnected. ## Why (the mechanism) Slot sync — Patroni's, and PG 17's native equivalent gated by logical_slot_sync_timeout (default 300s) — is a primary→standby pull on a timer. The standby periodically copies the slot's LSN from the primary. The copy is therefore only ever as fresh as the last time the primary's slot advanced. If the primary's slot has not moved for the duration of the sync window — because the source is quiet, the consumer is paused, or the consumer's host is down — the standby's replica copy stays pinned at an old LSN. Promote that standby, and the new primary's slot points at WAL that has already been recycled: wal_status = 'lost' on the next resume. The counter-intuitive part: the fragile case is the idle slot, not the busy one. A slot that isn't advancing can't be synced fresh, so “no traffic” — which feels safe — is exactly the condition that lets a failover strand it. ## The diagnostic A failover is hard to stage on demand, but the precondition is observable: watch whether the slot's confirmed_flush_lsn advances on your workload. SELECT slot_name, wal_status, active, confirmed_flush_lsn FROM pg_replication_slots WHERE slot_type = 'logical'; -- On a quiet source, sample confirmed_flush_lsn over time. If it does -- NOT advance for hours, the standby's synced copy is frozen at that -- LSN — and a failover during that window will surface wal_status='lost' -- on resume. Advancement rate is the pre-production check. ## What sluice does about it Keep the slot advancing, two ways, and fall through cleanly if it's lost anyway: - Keep the consumer active. sluice's PG CDC reader sends pg_send_standby_status_update every 10 seconds whether or not events are flowing, so the slot reads as active from the primary's perspective and the standby's sync keeps pace. The operational rule: run sync start continuously, not as a one-shot during low-traffic windows. - Make a quiet source advance on purpose. For genuinely idle databases, inject WAL activity with SELECT pg_logical_emit_message(false, 'sluice-heartbeat', '') on a timer — it writes to WAL without modifying any user data (sluice's reader sees and discards it), guaranteeing the slot moves even if the active consumer briefly disconnects. - Backstop. If the slot is lost regardless, sync start --resume detects it, drops it, and falls through to a fresh cold-start rather than silently stalling. ## The transferable lesson “HA-replicated” for a logical replication slot means the slot's LSN is copied to the standby on a timer — not that the slot cannot be lost. Because the sync copies a position, a slot that doesn't advance can't be synced fresh, which inverts the usual intuition: the idle slot is the fragile one, not the busy one. On a quiet source, don't rely on the slot's position drifting forward on its own — make it advance, with an active consumer or an explicit WAL heartbeat. This is a specific instance of a broader Postgres-slot truth we hit from the other side too: a slot's lifetime is the server's to manage, not your process's. ## Primary sources - PG 17 logical replication slot synchronization & logical_slot_sync_timeout — logical replication failover. - pg_logical_emit_message (a WAL write with no user-data change) — logical decoding message functions. - Patroni permanent slots & slot failover — Patroni dynamic configuration. - sluice's Postgres source prep and the idle-slot mitigations — Prepare a Postgres source. =============================================================== # A Postgres LSN means nothing without its timeline (https://sluicesync.com/field-notes/postgres-lsn-timeline-scoped/) A logical-replication LSN is only comparable within a (system_id, timeline) tuple. Resume after a PITR or a promotion and the same slot name and same stored LSN point into a different WAL reference frame — the source streams from it happily, and events are silently skipped or replayed. Observed — Postgres logical-replication (slot-based) source, resume after a source-side PITR / standby promotion / base-backup clone. Internally ADR-0051 (a severity-A finding from a Postgres-internals audit). ## What happened A CDC stream resumed against a source that had been point-in-time-restored, picked up from its persisted (slot, lsn) position, and silently diverged. No error, no gap in the logs. The slot still existed by name; the stored LSN was still a valid-looking number; the source streamed WAL from it without complaint. But the rows that landed were not the rows that should have. ## Why (the mechanism) A Postgres LSN is not a global coordinate. It is only meaningful within a (system_id, timeline) tuple: the system_id identifies a specific cluster, the timeline identifies a specific branch of its WAL history. LSN values from one timeline are simply not comparable to LSN values from another. Three ordinary operational events change that reference frame out from under a stored position: - a standby promotion increments the timeline (same system_id, new timeline); - a PITR can produce a new timeline within the same cluster, or a fresh cluster from a base backup (new system_id); - pointing the tool at a different instance that happens to share the DSN host:port shape (a clone) — new system_id entirely. The replication protocol hands you the identity on a plate — IDENTIFY_SYSTEM returns (systemid, timeline, xlogpos, dbname) before START_REPLICATION — but it is easy to call it only on cold-start to read xlogpos and discard the rest. Do that, and on resume you send the old LSN into the new timeline's WAL and the server obliges. The divergence is silent because nothing on either side is looking at the mismatch. The sharp contrast is MySQL: a GTID set from a different server_uuid simply fails GTID_SUBSET against the new source's executed set, so the same class refuses itself for free. Postgres's raw LSN carries no such self-identifying provenance — you have to pin it yourself. ## The repro -- capture the identity the LSN belongs to, before you trust the LSN: IDENTIFY_SYSTEM; -- systemid | timeline | xlogpos | dbname -- 7382... | 1 | 0/1A2B3C4 | app -- promote a standby (timeline -> 2), or PITR, then reconnect and: IDENTIFY_SYSTEM; -- systemid | timeline | xlogpos | dbname -- 7382... | 2 | 0/95F00A0 | app -- same slot name, same stored LSN 0/1A2B3C4 — but timeline 2's -- WAL frame. Streaming from it is silently wrong. ## What sluice does about it sluice pins (SystemID, Timeline) from IDENTIFY_SYSTEM onto the persisted position token and re-issues IDENTIFY_SYSTEM on every reconnect — before the slot-existence check, so a diverged source surfaces "source identity has changed" rather than a misleading "slot missing." On divergence it names both the old and new (systemid, timeline) so an operator can confirm the change matches their intended PITR/promotion, and refuses by wrapping the same position-invalid sentinel that routes a missing slot to a loud cold-start fall-through. There is deliberately no --ignore-source-identity-change flag: the old LSN is by definition meaningless against the new source, so "stay strict" is the only honest semantic. (Legacy tokens with no pin are accepted once, with an INFO line, then pinned going forward.) ## The transferable lesson If you persist a Postgres LSN, persist the (system_id, timeline) it belongs to alongside it, and compare on every reconnect. A stored replication position is a coordinate in a reference frame, not an absolute address — and the ordinary HA events you most want to survive (failover, restore) are exactly the ones that change the frame while leaving the slot name and the number looking valid. Unlike a GTID, a bare LSN won't catch its own staleness for you; that check is yours to write, and its absence is a silent-loss class. ## Primary sources - Postgres replication protocol — IDENTIFY_SYSTEM and START_REPLICATION (the identity tuple returned before streaming). - Timelines and how promotion/PITR create them — WAL timelines. - sluice's Postgres source preparation — Prepare a Postgres source. =============================================================== # proto_version lets you parse streaming; only streaming='on' emits it (https://sluicesync.com/field-notes/pgoutput-streaming-abort/) Two pgoutput knobs are easy to conflate. The receiver flag equips you to parse streamed transactions; a separate publisher flag makes the server actually send them. The gap between them hides a silent-loss shape: a dropped StreamAbort leaves already-committed chunks on the target. Observed — Postgres logical replication via pgoutput, a defensive audit of the streaming-protocol dispatch. Internally ADR-0055 (finding F1 of a Postgres-internals audit). ## What happened A protocol audit found a default: branch in the WAL dispatcher that silently skipped StreamAbortMessageV2. Harmless in the tool's current configuration — but one config change away from durable, undetectable divergence. The interesting part is why it was latent, which is a pair of pgoutput knobs that look like one. ## Why (the mechanism) pgoutput negotiates streaming through two independent capabilities on START_REPLICATION: - proto_version ≥ 2 equips the receiver to parse the streaming frames — StreamStart / StreamStop / StreamCommit / StreamAbortV2 — for transactions that exceed logical_decoding_work_mem (default 64 MB) at the source. - streaming = 'on' (PG 14+) or 'parallel' (PG 16+) makes the publisher actually emit those frames. Pass proto_version = 2 without it and an oversized transaction is buffered and spilled to disk server-side, then delivered as one ordinary begin / rows / commit unit after it fully decodes. Parsing capability and emission are separate switches. Now the trap: suppose streaming is enabled (a config drift, a future change) and a consumer maps each streamed chunk to its own target transaction — a reasonable "one boundary → one commit" design. Chunk 1 commits durably on the target. Chunk 2 commits. Chunk N commits. Then the source rolls the transaction back and emits StreamAbortMessageV2. Drop that message and the N chunks stay committed on the target while the source has no record of them. The target now carries rows the source rolled back. What makes it nasty is the shape of the loss. It is not a missing-rows gap that a row-count or checksum diff would catch — it is extra rows relative to the post-abort source, and nothing upstream is signalling their existence. ## The repro (the two knobs) -- receiver equipped to PARSE streaming, but publisher not asked to EMIT it: START_REPLICATION SLOT s LOGICAL 0/0 (proto_version '2', publication_names 'p'); -- a 200 MB transaction spills to pg_replslot// and arrives as ONE -- begin/rows/commit unit. No StreamStart ever appears. -- ask the publisher to emit it too: START_REPLICATION SLOT s LOGICAL 0/0 (proto_version '2', streaming 'on', publication_names 'p'); -- now the same txn arrives as StreamStart / rows / StreamStop chunks, -- and a source ROLLBACK arrives as StreamAbortV2 — which a consumer -- MUST act on, not skip. ## What sluice does about it sluice runs proto_version = 2 deliberately without streaming, so one source transaction maps to one target transaction (the alignment its batched apply depends on) and oversized transactions are the source's memory problem, not a target-consistency problem. The audit fix replaces the silent default: skip with an explicit StreamAbortMessageV2 arm that refuses loudly if a streamed abort is ever seen — so a future flip of the publisher flag can't quietly resurrect the extra-rows class. (The spill it trades for is now observable too: PG 14+ exposes spill_txns / spill_bytes in pg_stat_replication_slots.) ## The transferable lesson When a protocol negotiates a capability from both ends, "I can parse it" and "you will send it" are different switches, and the interesting failures live in the gap. Enumerate the messages a capability could deliver even if your current config never triggers them, and make the ones you don't handle refuse loudly rather than fall through a silent default: — because the config that starts triggering them is one flag away, and a protocol message you drop is a decision you made without knowing it. Watch especially for loss that shows up as extra committed state rather than a gap: checksums and row counts are built to find gaps. ## Primary sources - pgoutput protocol & the streaming option — logical streaming replication protocol and streaming subscription option. - Streaming-of-in-progress-transactions & the spill counters — pg_stat_replication_slots. - How sluice maps source transactions to target transactions — How sluice copies your data. =============================================================== # CREATE IF NOT EXISTS is not a lock (https://sluicesync.com/field-notes/create-if-not-exists-race/) CREATE TABLE / TYPE … IF NOT EXISTS does a catalog pre-check and then an insert, and those two steps aren't atomic against a concurrent creation of the same name. Race it and one side gets a unique_violation on pg_class — from the statement that reads like it can't fail. Observed — Postgres target, parallel schema build / parallel restore creating objects concurrently. Internally the catalog-race retry wrapper (control table, then the index-build path — live-caught during a parallel restore). ## What happened Two connections ran CREATE TABLE … IF NOT EXISTS for the same name at nearly the same instant, and one of them failed with ERROR: duplicate key value violates unique constraint "pg_class_relname_nsp_index" (SQLSTATE 23505). From the statement whose entire purpose is to be a safe no-op when the object already exists. ## Why (the mechanism) IF NOT EXISTS is not a lock and not atomic. It is a two-step operation: check the system catalog (pg_class for a relation, pg_type for a type) for the name, and if absent, insert the catalog row. Two sessions can both pass the "absent" check before either inserts, and then the second insert collides on the catalog's own unique index — pg_class_relname_nsp_index for a table/index, pg_type_typname_nsp_index for a type — surfacing as SQLSTATE 23505 unique_violation. The guard reads like idempotence; under concurrency it is a check-then-act race, and Postgres enforces name uniqueness at the catalog layer regardless of the friendly clause. ## The repro -- two psql sessions, interleaved: -- session A -- session B BEGIN; BEGIN; CREATE TABLE IF NOT EXISTS t (id int); CREATE TABLE IF NOT EXISTS t (id int); COMMIT; -- blocks on A, then: -- ERROR: duplicate key value violates -- unique constraint -- "pg_class_relname_nsp_index" (23505) ## What sluice does about it sluice retries the failing statement — but only on the narrow, provably-benign shape: a 23505 whose constraint is a catalog index (pg_class_relname_nsp_index / pg_type_typname_nsp_index), which means "someone else just created this exact object" and the correct outcome (the object exists) has been reached. A 23505 on a user table's primary key or unique constraint is a genuine data conflict and stays loud — never swallowed by the retry. The same wrapper covers both the control-table setup and the concurrent index-build path. ## The sharper cousin: CREATE TYPE has no IF NOT EXISTS at all The same "IF NOT EXISTS is not the safety you think" trap has an even sharper edge for Postgres ENUM types — because there the guard doesn't exist. CREATE TYPE ... AS ENUM has no IF NOT EXISTS form, so a cold-start that creates enum types and is then interrupted mid-copy re-enters its schema phase on resume and hard-fails with SQLSTATE 42710 "type already exists" — turning every restart into a crash-loop with zero progress. It's especially nasty because the resume path deliberately skips the populated-target preflight on the assumption that the schema phase is fully idempotent — true for the CREATE TABLE IF NOT EXISTS sitting right next to it, false for the enum. Postgres's idempotent equivalent is a DO block that swallows duplicate_object: DO $$ BEGIN CREATE TYPE status AS ENUM ('active','closed'); EXCEPTION WHEN duplicate_object THEN null; END $$; And enum types outlive the columns that use them — drop the column and the type is orphaned, not cleaned up — so a re-create after a partial drop collides on the same 42710. The generalization: on Postgres a first-class catalog object's "make it exist" step is neither atomic (the pg_class/pg_type race above) nor universally guarded (CREATE TYPE has no IF NOT EXISTS), so any resume-or-retry path that assumes "re-running CREATE is safe" has to earn that assumption per object type. ## The transferable lesson IF NOT EXISTS (and CREATE OR REPLACE, and most "make it exist" DDL) is a convenience, not a concurrency primitive — it removes the error when you ran it twice in sequence, not when two workers run it at once. If your tool issues DDL in parallel, treat a catalog 23505 as an expected, retryable outcome of the race, and scope the retry tightly to the catalog constraint so a real user-data uniqueness violation still fails loudly. The tell that you have this bug is a "can't happen" duplicate-key error on a statement you thought was idempotent. ## Primary sources - Postgres on the non-atomicity of IF NOT EXISTS — CREATE TABLE (the IF NOT EXISTS note) and the error-code appendix (23505 unique_violation). - The system catalogs that enforce name uniqueness — pg_class / pg_type. =============================================================== # The replication stream never tells you the column default (https://sluicesync.com/field-notes/cdc-carries-no-default/) Neither pgoutput nor the MySQL binlog carries a column's DEFAULT. Forward an ADD COLUMN … DEFAULT now() over CDC and the target re-evaluates the default on its own — so every row that shipped before the ALTER gets a different value than the source's backfill. Observed — cross-engine CDC schema-change forwarding, an ALTER TABLE … ADD COLUMN … DEFAULT on the source mid-stream. Internally ADR-0058 (online schema-change forwarding) + Bug 90 / Bug 91. ## What happened A source added a column with a default — ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now() — while CDC was tailing it. The DDL forwarded to the target and new rows looked fine. But every row that had already shipped to the target before the ALTER carried a different created_at than the same row on the source. Silent per-row divergence across the whole pre-existing table. ## Why (the mechanism) Two facts combine. First, the replication wire format does not carry a column's DEFAULT. pgoutput's RelationMessage describes each column's name, type OID, and flags — there is no attdefault slot. MySQL's TableMapEvent describes column types and metadata — there is no COLUMN_DEFAULT. A CDC schema-forwarder literally cannot see the default in the stream; it only sees the DDL text (or the relation shape). Second, a volatile default is evaluated at ALTER time, per row. When the source runs ADD COLUMN … DEFAULT now() (or random(), gen_random_uuid(), MySQL UUID() / RAND()), it backfills every existing row with the default evaluated then, on the source. If the target only replays the DDL, it re-evaluates the default independently — a different now(), different random values, different UUIDs — for its own copy of those rows. The two backfills disagree, row by row. A constant default (DEFAULT 0, DEFAULT 'active') is safe precisely because it evaluates identically on both sides; the failure dispatches on the default's volatility class, not on any one function. ## The repro -- source, with CDC tailing and rows already replicated to the target: ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now(); -- source backfills existing rows with the ALTER-time now(), e.g. -- 2026-05-25 10:00:00+00 for every pre-existing row. -- target, replaying only the DDL: ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now(); -- target backfills the SAME rows with ITS now(), e.g. -- 2026-05-25 10:00:07+00 — 7 seconds off, every row, silently. ## What sluice does about it sluice classifies the default's volatility when it forwards an ADD COLUMN. A constant/immutable default is safe to replay as-is. A volatile default (time, random, UUID, sequence nextval) cannot be reconstructed identically from the DDL alone, so sluice does not let the target re-evaluate it — it forwards the column and drives an explicit, source-authoritative backfill of the already-shipped rows (or refuses loudly for the shapes it doesn't forward), rather than trusting two independent evaluations to agree. Sequence defaults get their own volatility classification (a nextval is as non-reproducible as now()). ## The transferable lesson A replication stream carries data changes, not the schema's generative rules — the DEFAULT is metadata that lives in the catalog, and neither pgoutput nor the binlog puts it on the wire. So "replay the DDL on the target" is only correct when the default is a constant. The moment a default is volatile, the source's ALTER-time backfill and the target's replayed backfill are two independent evaluations of a non-deterministic expression, and they will not match. If you forward schema changes over CDC, classify default volatility explicitly and treat volatile defaults as data to be copied from the source, never as DDL to be re-run. ## Primary sources - pgoutput RelationMessage (no default field) — logical replication message formats. - Postgres function volatility categories — function volatility. - MySQL binlog TABLE_MAP_EVENT — Table_map_event. - How sluice handles source schema changes during a sync — Schema changes during a sync. =============================================================== # MySQL TIME is a duration, not a time of day (https://sluicesync.com/field-notes/mysql-time-is-a-duration/) A MySQL TIME column ranges -838:59:59 to 838:59:59 and models elapsed duration, not clock time. Postgres time is a time-of-day, 00:00 to 24:00 — so any negative or over-24-hour MySQL TIME has no home there. The faithful target is interval. Observed — MySQL → Postgres migration of a TIME column. Internally the TIME → ir.Interval type mapping. ## What happened A MySQL-to-Postgres migration mapped a TIME column to Postgres time by name — the obvious pairing — and rows carrying values like 500:30:00 (a stopwatch total) or -12:30:00 (a negative offset) had nowhere to land. The names match; the semantics do not. ## Why (the mechanism) MySQL's TIME is a signed duration, documented range -838:59:59 to 838:59:59 — roughly ±35 days. It is designed to hold elapsed time (a lap time, a total worked, a delta), which is why it goes negative and well past 24 hours. Postgres time is a time of day: 00:00:00 to 24:00:00, a point on the clock, with no notion of negative or "more than a day." They share a name and a HH:MM:SS spelling, and diverge completely at the edges. Any MySQL TIME outside [00:00, 24:00) — negative, or over 24 hours — simply cannot be represented as a Postgres time. The correct Postgres home for a duration is interval, which is signed and unbounded in exactly the way TIME needs. ## The repro -- MySQL: TIME holds durations, signed, well past 24h CREATE TABLE laps (id INT, elapsed TIME); INSERT INTO laps VALUES (1, '500:30:00'), (2, '-12:30:00'); -- both valid -- Postgres time is a clock reading — these have no representation: SELECT '500:30:00'::time; -- ERROR: date/time field value out of range SELECT '-12:30:00'::time; -- ERROR: invalid input syntax for type time -- the faithful target: SELECT '500:30:00'::interval; -- 500:30:00 SELECT '-12:30:00'::interval; -- -12:30:00 ## What sluice does about it sluice maps MySQL TIME to the IR's Interval type, which lands on Postgres interval — so the full signed, ±838-hour range round-trips instead of clipping or erroring at the time boundary. The name-based TIME → time pairing is exactly the trap the IR exists to avoid: translation is by semantics, resolved in one place, not by matching type spellings across engines. ## The transferable lesson Two databases can give a type the same name and the same surface syntax and mean different things by it — MySQL TIME is a duration type wearing a time-of-day costume. When you translate types across engines, map on the value's semantics and range, not its name: the question isn't "does Postgres have a time?" but "what does MySQL let this column hold, and what's the Postgres type that holds all of it?" The answer for TIME is interval, and you only learn that by looking at the range, not the label. ## Primary sources - MySQL TIME range and duration semantics — The TIME Type (−838:59:59 … 838:59:59). - Postgres time vs interval — date/time types. - sluice's type-mapping policy — Type mapping. =============================================================== # Postgres text can't hold a NUL byte (https://sluicesync.com/field-notes/postgres-text-no-nul-byte/) text, varchar, and char reject an embedded 0x00 with SQLSTATE 22021; MySQL char/text store it without complaint. A cross-engine copy hits it, and because it fires inside the COPY protocol the error lands far from the offending row. Observed — MySQL → Postgres copy of a text column containing an embedded NUL byte. Internally the Postgres prepareValue NUL guard. ## What happened A cross-engine copy of a perfectly ordinary VARCHAR column failed on the Postgres side with a cryptic invalid byte sequence for encoding "UTF8": 0x00 — and because it surfaced inside the bulk COPY stream, the error landed nowhere near the row that carried the byte. The source column held a string with an embedded 0x00 (a stray NUL from an upstream C string, a serialized blob mislabeled as text, a bad import), which MySQL had stored without objection. ## Why (the mechanism) Postgres text types — text, varchar, char — cannot store a 0x00 byte. The NUL is reserved as a string terminator in the server's internal C representation, so an embedded one is rejected as an invalid byte sequence (SQLSTATE 22021, character-not-in-repertoire). MySQL's CHAR/VARCHAR/TEXT have no such rule — they treat the NUL as an ordinary byte and store it. So the value is legal on one engine and illegal on the other, and a migration is exactly where the two meet. The diagnosis is made harder by COPY: the failure fires while streaming the bulk buffer, so the error message is detached from the individual offending row. ## The repro -- MySQL stores an embedded NUL happily: CREATE TABLE t (id INT, s VARCHAR(64)); INSERT INTO t VALUES (1, CONCAT('a', CHAR(0), 'b')); -- OK, 3 bytes -- Postgres rejects the same bytes in a text type: SELECT E'a\x00b'::text; -- ERROR: invalid byte sequence for encoding "UTF8": 0x00 (SQLSTATE 22021) -- bytea holds arbitrary bytes, NUL included: SELECT E'\x610062'::bytea; -- \x610062, no complaint ## What sluice does about it sluice refuses the value loudly with a coded error (SLUICE-E-VALUE-NUL-BYTE) that names the column and the constraint, rather than letting the opaque COPY-stream error surface far from the row — and rather than the tempting silent "fix" of stripping the NUL, which would quietly alter the data. The data-preserving path, when you genuinely need to carry those bytes, is to target bytea, which stores arbitrary binary including 0x00. Loud refusal with the remedy named beats a cryptic wire error or a silent mutation. ## The transferable lesson "It's a string column on both sides" is not the same as "the same bytes are legal on both sides." Postgres text is Unicode text with a hard rule — no 0x00 — that MySQL text does not share, so a value that lives happily in MySQL is a hard error in Postgres. When the two disagree about what bytes a type may hold, the honest options are to refuse loudly (naming the column and the fix) or to route the data to a type that can hold it (bytea); silently stripping the offending byte to make the insert succeed is data corruption wearing the disguise of a bug fix. ## Primary sources - Postgres on the NUL character in text — character types and SQLSTATE 22021 in the error-code appendix. - bytea for arbitrary bytes — binary data types. - sluice's value contract and coded refusals — Error & exit codes. =============================================================== # A whole transaction in one zstd binlog event (https://sluicesync.com/field-notes/binlog-transaction-compression/) MySQL 8.0.20+ can pack an entire transaction into a single compressed TRANSACTION_PAYLOAD_EVENT. A binlog reader without a handler for it applies nothing and freezes its position with no error — and the server zeroes the inner events' end_log_pos, so a naive resume restarts mid-payload and dies. Observed — MySQL → target CDC from a source with binlog_transaction_compression = ON. Internally the TRANSACTION_PAYLOAD_EVENT decode + resume-alignment fix. ## What happened CDC from a MySQL 8.0.20+ source that had binlog_transaction_compression enabled (common for WAN replication and disk savings) silently applied nothing for compressed transactions: rows never landed, the stream position froze, and there was no error. Turning the setting off "fixed" it — which is the tell that the reader was missing an event type, not hitting a bug. ## Why (the mechanism) With binlog_transaction_compression = ON, the server packs a whole transaction — its TABLE_MAP, its ROWS events, its XID — into a single zstd-compressed TRANSACTION_PAYLOAD_EVENT. A binlog consumer that doesn't recognize that event type simply skips it: zero rows applied, position advanced past it, no error raised. Everything the transaction did is inside a payload the reader walked past. There is a second, sharper trap in the resume path. Inside the payload, the server zeroes the end_log_pos of the inner events (they no longer have a meaningful standalone file offset — they live inside the outer event). A resumer that stamps its checkpoint from an inner event's header therefore records position 0, and on warm-resume restarts inside the payload — where it finds row events with no preceding table map and dies with "no corresponding table map event." The correct checkpoint is the outer TRANSACTION_PAYLOAD_EVENT's LogPos (the transaction boundary). GTID-mode streams dodge this half, because the GTIDEvent precedes the payload and carries the resumable coordinate. ## The repro -- source (MySQL 8.0.20+): SET GLOBAL binlog_transaction_compression = ON; INSERT INTO t VALUES (1), (2), (3); -- one compressed txn -- in the binlog, instead of TABLE_MAP + WRITE_ROWS + XID you now see: -- Transaction_payload (compression: ZSTD) -- └─ TABLE_MAP / WRITE_ROWS / XID (inner; end_log_pos = 0) -- a reader with no Transaction_payload handler applies 0 of the 3 rows, -- reports no error, and advances past it. ## What sluice does about it sluice decompresses the TRANSACTION_PAYLOAD_EVENT and dispatches its inner events as if they had arrived uncompressed, so a compressed source is transparent. For the resume half, it stamps its checkpoint from the outer payload event's LogPos (the transaction boundary), never an inner event's zeroed end_log_pos — so a warm-resume lands on a transaction boundary and never mid-payload. Both halves are pinned by regression tests, because the failure only appears with the setting on and a resume across a compressed transaction. ## The transferable lesson A binlog reader's completeness is defined by the source settings it has never seen, not the ones it was tested against. binlog_transaction_compression is off by default, so a reader can pass every local test and silently drop every transaction the moment a DBA turns it on for bandwidth. Two lessons ride together: handle (or loudly refuse) every binlog event type the source can emit, not just the common ones; and when a container event rewrites its children's coordinates — here, zeroing inner end_log_pos — make sure your resume checkpoint comes from the coordinate that's still valid (the outer boundary), or your recovery path breaks exactly when you need it. ## Primary sources - MySQL binlog transaction compression — binary log transaction compression and the Transaction_payload_event. - sluice's MySQL CDC and resume model — How sluice copies your data. =============================================================== # Your primary key is only unique per shard (https://sluicesync.com/field-notes/vitess-per-shard-primary-key/) vtgate merges every Vitess/PlanetScale shard into one logical stream, but per-shard id ranges mean the same primary-key value legitimately exists on several shards. Copy them into one target table with that key and the collisions silently overwrite — exit 0, rows short. Observed — sharded Vitess / PlanetScale keyspace consolidated into a single target table. Internally Bug 152 + ADR-0048 (--inject-shard-column). ## What happened Consolidating a sharded Vitess keyspace into one target table finished clean — exit 0 — with fewer rows on the target than the sum of the shards. No error, no duplicate-key complaint. Rows from different shards that shared a primary-key value had silently overwritten each other. ## Why (the mechanism) A sharded keyspace behind vtgate presents as one logical database, so it is natural to treat it as one source and copy it into one target table. But uniqueness in Vitess is per shard, not global: each shard runs its own MySQL with its own auto-increment range, and tenant-local or hash-partitioned ids mean primary-key value 42 can legitimately exist on shard -80 and again on shard 80-, as two entirely different rows. Merge those into a single target table whose primary key is that id, and the second insert of 42 collides with the first. If the copy uses an upsert/replace, the collisions silently overwrite; if it uses plain inserts, the target's own PK rejects them — either way the consolidated table is short, and unless you are diffing counts per shard it looks like a clean run. ## The repro -- vtgate presents one stream; the shards each own id 42: mysql> SHOW VITESS_SHARDS; -- customer/-80 -- customer/80- -- shard -80: (id=42, name='alice') shard 80-: (id=42, name='bob') -- consolidate into one target with id as PK: -- INSERT (42,'alice') -> ok -- INSERT (42,'bob') -> duplicate key / or REPLACE overwrites alice -- result: one row for id=42, one tenant silently lost. ## What sluice does about it sluice makes the shard identity part of the target key. With --inject-shard-column NAME=VALUE it adds a discriminator column carrying each source's shard identity and folds it into the target's primary/unique key, so (shard, id) is globally unique and no row is overwritten. The consolidation preflight discovers the shard set (via SHOW VITESS_SHARDS) and — critically — fails closed if it can't establish that the merged keys will be unique, rather than proceeding into a silent overwrite. (If your ids are already provably global — Vitess sequences, or UUIDs — you don't need the discriminator, but that has to be true, not assumed.) ## The transferable lesson "One connection endpoint" does not mean "one key space." A sharded database presented through a single proxy still enforces uniqueness at the shard, so any primary key that isn't provably global — anything backed by per-shard auto-increment or per-tenant numbering — collides the moment you consolidate. Before merging N sources into one table, prove the key is globally unique or make it so (add the shard discriminator to the key), and make the check fail closed — because the failure mode is silent overwrite, and a row-count that's merely "smaller than expected" is easy to rationalize away. ## Primary sources - Vitess sharding & per-shard uniqueness — Vitess sharding and Vitess sequences (the global-id escape hatch). - sluice multi-source consolidation — Migrate many databases or schemas. =============================================================== # ENUM is an ordinal and SET is a bitmask on the wire (https://sluicesync.com/field-notes/mysql-enum-set-binlog-encoding/) In a raw binlog row event a MySQL ENUM cell is its 1-based ordinal and a SET cell is a numeric bitmask; the member-name list lives only in the table definition, never in the event. Decode without the schema and SET('a','c') becomes "5". Snapshot and VStream hand you text, so it hides until raw CDC. Observed — MySQL raw-binlog CDC of ENUM / SET columns. Internally Bug 145 (ENUM ordinal) + Bug 148 (SET bitmask). ## What happened A MySQL CDC stream delivered an ENUM('small','medium','large') value as 2 and a SET('a','b','c') value as 5 instead of 'medium' and 'a,c'. The same columns had round-tripped perfectly during the bulk-copy snapshot — the divergence appeared only once the raw binlog took over. ## Why (the mechanism) MySQL stores ENUM and SET as integers and puts those integers, not the labels, on the binlog wire: - an ENUM cell in a RowsEvent is its 1-based ordinal ('medium' → 2); - a SET cell is a numeric bitmask (bit i → the i-th member; 'a','c' → 0b101 = 5), sized to the storage width. The mapping from those integers back to label strings lives only in the table definition — it is never in the row event. A CDC reader that doesn't join the event against the schema decodes the raw integer and emits "2" / "5". What hides the bug is that the two other ways of reading the same data both resolve the labels for you: a snapshot via database/sql returns the text, and Vitess VStream returns the text — so everything looks correct until you hit the raw binlog path, where the integers are all you get. (And a bit set beyond the declared members must be an error, not silently dropped.) ## The repro CREATE TABLE t (id INT, size ENUM('small','medium','large'), tags SET('a','b','c')); INSERT INTO t VALUES (1, 'medium', 'a,c'); -- a snapshot query resolves the labels: SELECT size, tags FROM t; -- 'medium', 'a,c' -- the raw binlog RowsEvent carries the integers: -- size = 2 (1-based ordinal of 'medium') -- tags = 5 (bitmask 0b101 = 'a' | 'c') -- decode without the ENUM/SET member list and you store "2" and "5". ## What sluice does about it sluice carries the ENUM/SET member lists from the table's schema into the binlog decoder, so an ordinal is resolved back to its label and a bitmask is expanded to the comma-joined member set — matching exactly what the snapshot and VStream paths produce, so the two halves of a cold-start-then-CDC migration agree. A bitmask bit or ordinal outside the declared members is refused loudly rather than dropped, because a value the schema can't explain is a signal, not a row to guess at. This is a companion to a different ENUM/SET trap — MySQL substituting ? for a 4-byte-UTF-8 label at CREATE TABLE — two independent ways these "simple" types are sneakier than they look. ## The transferable lesson ENUM and SET are integers in a trenchcoat: an ordinal and a bitmask on disk and on the binlog wire, with the crucial integer→label dictionary held only in the table definition. Any decoder that reads the raw replication stream must join it against the schema to recover meaning — and the danger is that the easy paths (query results, VStream) do that join for you, so the raw-binlog path is the one place the abstraction leaks, and it leaks silently as plausible-looking numbers. When a value's meaning lives in metadata separate from the value, make sure every read path has that metadata in hand. ## Primary sources - MySQL ENUM and SET storage (ordinal / bitmask) — The ENUM Type and The SET Type. - Binlog row images — Rows_event. - sluice's cross-engine value contract — Type mapping. =============================================================== # The cold-start that buffered a whole table into swap (https://sluicesync.com/field-notes/vstream-snapshot-oom/) A 13 GB PlanetScale table drove the process to ~41 GB of RAM and got OOM-killed with zero rows written — the VStream snapshot reader held the entire copy phase in memory before a single row reached the target. The buffer wasn't laziness; three engine behaviors forced it. Observed — PlanetScale / Vitess cold-start (VStream COPY snapshot) of one large table. Internally ADR-0071 (extends the ADR-0028 memory-bounded-streaming audit, which never reached this reader). ## What happened A cold-start snapshot of a ~13 GB, ~19M-row PlanetScale table walked the process's RSS up 28 → 38 → ~41 GB on a 32 GB host, into swap, until the OOM killer reaped it — and not one row had been written to the target the entire time. The most ordinary cold-start shape there is (one big table) was an unbounded-memory failure. ## Why (the mechanism) The VStream snapshot reader drained the entire COPY phase into an in-memory map[table][]Row before it returned the stream — it only completed after the global COPY_COMPLETED event, and only then did bulk-copy to the target begin. So peak memory was the whole snapshot, and target writes couldn't start until the buffer was full. The uncomfortable part is that the buffer wasn't sloppiness — three VStream behaviors force a receiver to buffer, and a naive "just stream it straight through" rewrite breaks all three: - Order decoupling. VStream emits COPY rows in its order; the orchestrator consumes table-by-table in its order. Something has to hold the rows whose turn hasn't come. - Multi-shard fan-in. One logical table's rows arrive interleaved from N shards; they're merged by unqualified table name, which means collecting across the whole stream. - Inline dedup. Vitess re-emits rows already behind its scan cursor during COPY (binlog catch-up); those duplicate PKs are dropped as events arrive, which needs the stream in hand. ## What sluice does about it Two changes, shipped together. First, a correctness floor: the buffer is now accounted in bytes and refuses loudly over a cap (naming the table and the --max-buffer-bytes guidance) instead of growing into swap — a silent OOM becomes a bounded, diagnosable error. Second, the real fix: stop draining to completion. After capturing field metadata and the initial position, the reader returns immediately and pumps the gRPC stream from a background goroutine under the byte cap, emitting each table's rows as they arrive. A slow target backpressures the channel, which backpressures the Recv, which backpressures Vitess — so memory stays constant and target writes start right away. All three forcing invariants are preserved inside the bounded pump: dedup stays inline, shard fan-in still merges by name, and the snapshot position still finalizes at COPY_COMPLETED. ## The transferable lesson When a snapshot or CDC wire protocol decouples the order it emits from the order you consume — and especially when it also fans in from multiple shards and expects you to dedup inline — it has quietly made you a buffering system, and the buffer is unbounded by default until one large table walks you into swap. The fix is not to remove the buffer (those invariants are real) but to bound it by bytes and backpressure the source: pump under a cap so a slow consumer slows the producer, and refuse loudly rather than silently at the ceiling. The tell for this bug is a process that sits at growing RSS with zero output — it isn't slow, it's buffering the world before it starts. ## Primary sources - Vitess VStream COPY / catch-up semantics — VStream. - gRPC flow control / backpressure — gRPC core concepts. - How sluice cold-starts a sync — Zero-downtime migration. =============================================================== # One JSON blob in one row is a quadratic write (https://sluicesync.com/field-notes/migrate-state-quadratic-blob/) Storing all per-table progress as a single growing JSON blob and re-upserting it on every checkpoint is O(n²) work. On Postgres the amplification lands somewhere specific: a new tuple version plus a re-TOAST of the whole value, every time, on one hot row — while the clone runs inside the lock your workers are waiting on. Observed — the resumable-migration state store under a high-frequency checkpoint loop across many tables. Internally ADR-0082 (a HIGH-rated P1 audit finding). ## What happened Sluice's resumable-migration progress lived as one JSON blob — the whole map[table]TableProgress — in a single database row, re-written on every checkpoint. At the 10,000-table scale the parallel copy pool targets, that one row was re-encoded and re-upserted ≥20,000 times per migration (two breadcrumbs per table, plus a resume cursor every 5,000 rows, plus a checkpoint per chunk), each write carrying the whole ~0.86 MB blob. It worked fine at ten tables and quietly became a performance wall at ten thousand. ## Why (the mechanism) There are two costs stacked on top of each other. The first is the obvious quadratic: the blob grows linearly with table count, and it's rewritten a number of times that also grows with table count, so total work is O(n²). The second is where it hurts on a real database — Postgres MVCC and TOAST: - An UPDATE in Postgres doesn't overwrite in place; it writes a new tuple version and marks the old one dead (to be reclaimed by vacuum later). Rewriting one row 20,000 times creates 20,000 dead versions of that row. - A ~0.86 MB value is far past the ~2 KB inline threshold, so it lives in TOAST (the out-of-line storage for oversized values). Each update re-TOASTs the whole value into fresh chunks. The measured write amplification was ~17 GB — for a progress log. - And the whole-map deep clone that precedes the encode ran inside the state mutex, so every checkpoint serialized against all the parallel copy workers — the O(n) clone was also a contention point. The row count you think you're bounded by (tables) is not the row count that bites (tuple versions of one hot row). ## What sluice does about it Split the single blob into a header row plus one row per table, and give the store an O(1) per-table write. A checkpoint now upserts a single small progress row instead of re-encoding the whole map, so total work drops to O(n) and the TOAST re-write is per-table, not per-everything. The concurrency win comes for free: because workers now write different rows, the state mutex stops serializing them. (An additive state_format column lets an in-flight legacy blob upgrade in place, once.) ## The transferable lesson "Just keep the state as one JSON column and upsert it" is an O(n²) amplifier the moment the state grows and the checkpoints are frequent — and on an MVCC database the cost is not merely re-serialization: every write is a new tuple version plus a re-TOAST of the entire oversized value, all concentrated on one hot row that also becomes a lock. Give any growing state map an O(1) per-key write surface (a row per key), and you fix the algorithmic cost, the storage amplification, and the write contention in one move. The same shape shows up in this project's backup manifest — a growing metadata object rewritten once per unit of progress is the pattern to watch for — and, per tick instead of per write, in a poller that re-derives its state from full history. ## Primary sources - Postgres MVCC (an UPDATE writes a new row version) — concurrency control intro and routine vacuuming. - TOAST (out-of-line storage for large values) — TOAST. - sluice's resumable migration model — Preview & validate. =============================================================== # Rewriting the whole manifest, once per chunk (https://sluicesync.com/field-notes/backup-manifest-quadratic/) Every backup checkpoint re-wrote the entire manifest.json, schema included. Since the manifest grows with table count, the total was quadratic — a measured ~78 hours of pure manifest rewriting at 100k tables. And the two obvious ways to fix it are the same quadratic in disguise. Observed — sluice backup full of a many-table database; per-chunk and per-table checkpoints. Internally ADR-0086. ## What happened Every per-chunk and per-table checkpoint during a backup re-marshaled the entire manifest — the full embedded schema along with it — and re-wrote the whole manifest.json. The manifest grows linearly with table count, and it was rewritten a number of times that also grows with table count, so the total checkpoint work was quadratic. A scale probe put a number on it: roughly 0.018·N + 2.77e-5·N² seconds over N tables — about 78 hours of pure manifest rewriting at 100,000 tables, ~322 days at a million. Every other part of the backup path is linear; this was the one super-linear wall. ## Why (the mechanism) It is the textbook O(grows with N) × (done N times) = O(N²), hiding in plain sight because it is invisible at small scale: a backup of a few dozen tables rewrites a small file a few dozen times and finishes instantly, so nothing in local testing flags it. The checkpoints themselves can't just be thinned out — the crash contract (a crash leaves at most tableParallelism tables to redo) and the content-addressed upload-skip both depend on durable per-event progress. The work is load-bearing; the full rewrite per event is the waste. ## What sluice does about it Split the in-progress manifest into a base written once (schema, anchor, encryption header, the pre-staged table entries — the heavy immutable parts) plus an append-only manifest.progress.jsonl sidecar — one compact JSON line per checkpoint, O(1) per event — folded back into a byte-identical self-contained manifest.json at success. That's the easy half. The instructive half is what they didn't do: - The sidecar append is a single O_APPEND write plus fsync — deliberately not the usual write-to-temp-then-rename. Append-then-rename re-copies the whole growing file on every call: the exact quadratic being removed, wearing the costume of a safe atomic write. - Object stores (S3/GCS/Azure) have no append primitive, and emulating one with read-modify-write re-copies the object every call — quadratic again. So the blob-store path keeps the legacy full-rewrite behavior, as a named, WARN-logged wart rather than a silent one. ## The transferable lesson A metadata object that grows with your work and is rewritten once per unit of progress is silently O(n²), and it will pass every test you run at small scale and only surface as days at 100k. The fix is append-only rather than rewrite — but the trap has a second floor: the two most natural ways to make an append "safe" (write-to-tmp-and-rename, or read-modify-write on a store with no native append) each re-copy the whole file per call and quietly reintroduce the exact quadratic you set out to kill. When you replace an O(n²) rewrite, verify the replacement is genuinely O(1) per step and not an O(n) copy in disguise — and where the substrate can't give you a true append (object storage), say so out loud instead of shipping the quadratic silently. The same "stop rewriting the whole growing thing per checkpoint" lesson bit this project's migration state store too, there through MVCC and TOAST — and it recurs per tick rather than per write in a poller that re-reads all of history. ## Primary sources - POSIX O_APPEND atomic appends — open(). - Why object stores aren't append-friendly — S3 objects are immutable (replace-whole-object). - sluice's backup format & checkpoints — Take encrypted backups. =============================================================== # Count your bytes, not your rows (https://sluicesync.com/field-notes/batch-by-bytes-not-rows/) A batch size tuned for narrow OLTP rows — 5,000 rows, under 10 MB — quietly pins hundreds of MB the moment the workload is MB-scale TEXT, BYTEA, JSON, or geometry. Row count is a proxy for memory, and it's only honest when rows are uniform. Observed — bulk-copy (batched INSERT) and CDC apply into a target, a batch size set by row count meeting wide columns. Internally ADR-0028 (memory-bounded streaming). ## What happened The batch accumulators were bounded by row count — --bulk-batch-size 5000 for bulk INSERT, --apply-batch-size for CDC. On the narrow OLTP rows most tests use, 5,000 rows is under 10 MB and everything is fine. On a table with MB-scale TEXT / BYTEA / JSON / geometry columns, the same 5,000-row batch pinned hundreds of MB of driver parameter buffer — and a 500-change CDC batch holding one open transaction's parameter slice did the same. Memory spiked exactly where the configured batch size promised it wouldn't. ## Why (the mechanism) Row count is a stand-in for memory that only holds when every row is about the same small size. A batch accumulator holds N rows' worth of shaped values and driver parameter buffers before it flushes — that's N × (row width), and row width varies across workloads by orders of magnitude. A schema with one wide column breaks the proxy: 5000 × 10 bytes is 50 KB, 5000 × 2 MB is 10 GB, same batch size. Notably, the COPY and LOAD DATA paths were immune — they stream row-by-row through driver-controlled wire buffers and never hold N rows at once — so only the two accumulators (batched INSERT, and the open-transaction CDC apply) had the problem. The bound was on the wrong axis. ## The repro (the arithmetic) -- same batch size, four orders of magnitude apart in memory: -- narrow: 5000 rows x ~10 B/row = ~50 KB in flight -- wide: 5000 rows x ~2 MB/row = ~10 GB in flight -- a batch size that is safe for your test data is a memory bomb for -- someone else's schema — a single BYTEA/JSON/geometry column does it. ## What sluice does about it Add a byte budget: --max-buffer-bytes (default 64 MiB) that flushes on whichever fires first, the row count or the accumulated bytes. A wide-row workload transparently uses a smaller batch; a narrow one keeps the full count. The streaming paths (COPY, LOAD DATA) need no change, because they were never accumulators. The precedent is worth stealing: PlanetScale's pscale dumper already flushes its bulk-INSERT batcher at ~1 MB of statement body, not a fixed row count, for exactly this reason. ## The transferable lesson Row count is a proxy for memory, and the proxy is only accurate when rows are uniform. The moment a schema has a wide column — a blob, a document, a geometry — "5,000 rows" can mean 50 KB or 10 GB, so a batch size that's safe for your data is a memory bomb for someone else's. Bound an accumulator by the resource you actually care about (bytes), and keep the count as a secondary cap. And know which of your paths are accumulators and which are streamers: the streaming ones were never at risk here, because they never hold the whole batch at once — the same reason a byte cap belongs on the two that do. (It's the smaller cousin of a snapshot reader that buffered a whole table into swap.) ## Primary sources - Postgres large-value storage (why a "row" can be megabytes) — TOAST. - The 1 MB statement-body flush precedent — pscale / mydumper batch sizing conventions. - How sluice copies data (streaming vs batched paths) — How sluice copies your data. =============================================================== # A poller that re-reads all of history every tick (https://sluicesync.com/field-notes/poll-cost-grows-with-history/) A backup broker rebuilt its entire lineage chain on every 30-second tick — one object-store GET per manifest, even when nothing had changed. On a week-old stream that's ~2,000 GETs a tick, forever, with a tick that could outlast its own interval. The cost was tied to the age of the stream, not the size of the change. Observed — the backup broker following a live backup chain on object storage to replay new increments into a target. Internally the broker chain-cache fix. ## What happened The broker follows a growing backup chain and, every 30 seconds, replays whatever is new into the target. To find "what's new," it rebuilt the entire lineage chain from the root on every tick — one object-store GET plus a JSON decode per manifest — even when nothing had changed since the last tick. On a week-old stream rolling over every 5 minutes, the chain is ~2,000 manifests, so an idle tick did ~2,000 GETs, and a single tick could take longer than the 30-second interval that was supposed to trigger the next one. ## Why (the mechanism) The walk was O(history) per tick — not quadratic, but a per-tick cost that grows linearly with total accumulated history and never levels off. A poller that re-derives its state from the full history on every interval has a cost bound to the age of the stream rather than the size of the change, so the steady-state (nothing happened) is also the expensive case, and it gets more expensive every day the stream runs. On object storage the sting is doubled: each chain-walk read is a billed GET with real network latency, so "re-read everything, find nothing changed" is both slow and a line item. ## What sluice does about it Cache the walked chain, keyed on a cheap change-token: the raw-byte identity of the two objects that are rewritten whenever the chain changes — lineage.json (rewritten on every structural change) and the tail manifest (rewritten in place per checkpoint). Read the token before the rebuild, so a racing writer can only ever make the cached key look older, never let a stale chain be served — the worst case is one unnecessary rebuild, never a wrong answer. An idle tick drops from ~2,000 GETs to exactly two. ## The transferable lesson A polling system that re-derives its state from full history on every tick has a per-tick cost that grows without bound as the history grows — invisible on day one, a self-inflicted slowdown (and a storage bill) by week two, precisely because the idle path is the expensive one. The fix isn't to poll less often; it's to make "nothing changed" cheap: cache on a small change-token, validate the token before the expensive rebuild, and order the reads so a concurrent writer can only invalidate conservatively. This is the same family as two other things that bit this project — rewriting the whole manifest per chunk and re-encoding the whole state blob per checkpoint — all three pay for the entire accumulated size on every small step, and all three pass every fresh, small-scale test and only bite with age or volume. ## Primary sources - Object-store request cost & latency (why per-tick GET counts matter) — S3 request pricing. - Change-token / conditional-read patterns — HTTP conditional requests (ETag/If-None-Match, the same idea applied to a cache key). - sluice's backup chain & broker — Sync from a backup chain. =============================================================== # One INSERT is three binlog events (or four) (https://sluicesync.com/field-notes/binlog-event-volume/) The binlog is a log of events, not row changes. A single-row INSERT lands as three (BEGIN / WRITE_ROWS / XID), plus a spurious empty BEGIN/COMMIT per new connection — so if you size a rollover bound by INSERT count, budget 4×. Postgres counts differently again. Observed — sizing sluice backup stream's --rollover-max-changes against expected INSERT counts on a MySQL source. See How sluice copies your data. ## What happened An operator set an incremental-backup rollover bound against the number of INSERTs they expected to drive, and the windows closed 3–4× earlier than that — rows they thought would land in the current incremental spilled into the next one. The count the tool bounds on and the count in the operator's head were measuring different things. ## Why (the mechanism) The MySQL binlog records events, not user-visible row changes, and a change is wrapped in transaction framing. A single autocommit one-row INSERT is three events: 1. BEGIN (QueryEvent) 2. WRITE_ROWS_EVENTv2 (the actual row) 3. XID (commit) A multi-row INSERT ... VALUES (r1),(r2),...,(rN) collapses the row events into one each, so it's 2 + N (BEGIN + N row events + XID) — a 1,000-row multi-row insert is ~1,002 events, not 3,000. On top of the per-transaction framing there's a per-connection tax: many client sessions emit an empty BEGIN/COMMIT pair before their first DML, because the driver issues a session-setup statement (SET autocommit, SET time_zone, …) inside an implicit transaction that gets logged but carries no rows. So naive INSERT-counting under-counts binlog events by 3–4×. Postgres is different again: pgoutput delivers one countable change per row and surfaces transaction boundaries as separate Begin/Commit messages the consumer doesn't count as changes — so a PG operator can size by INSERT count directly, no multiplier. ## What sluice does about it The rule of thumb, documented for the flag: on a MySQL source, budget at least 4× your expected INSERT count for --rollover-max-changes (the 3-event per-row shape plus headroom for the empty pair and other bookkeeping — heartbeats, rotate, format-description). Predictable bulk multi-row shapes can go tighter (the 2 + N collapse). On Postgres, no multiplier. ## The transferable lesson "Number of changes" is an engine-specific unit. MySQL's binlog is a log of events — row images plus the BEGIN/XID framing around every transaction, plus a per-connection empty pair — so it runs 3–4× ahead of the row count you're thinking of; Postgres's logical stream is closer to one-per-row. When you bound anything against a replication log (a rollover, a batch, an alert threshold), bound it against the log's own unit, and remember the same "count the changes" knob does not mean the same thing across engines. ## Primary sources - MySQL binlog event types — the replication event stream (QUERY_EVENT, WRITE_ROWS_EVENT, XID_EVENT). - Postgres pgoutput per-row messages — logical replication message formats. =============================================================== # parseTime governs the query protocol, not the binlog (https://sluicesync.com/field-notes/binlog-temporal-strings/) parseTime=true on the DSN makes the query driver return time.Time. But the replication stream is a different code path, and it hands temporal columns back as raw strings regardless. The first TIMESTAMP row killed the CDC pump — and the silent channel-close looked exactly like a network stall for two release cycles. Observed — MySQL → target CDC on any table with a TIMESTAMP / DATETIME / DATE column. Internally Bug 12. ## What happened A MySQL CDC stream against any table with a temporal column silently applied zero events — the channel just went quiet, exactly like a stalled network connection. The mis-diagnosis chased port-forwarding and connectivity for two release cycles before the real cause surfaced: the very first temporal row event was killing the pump. ## Why (the mechanism) The DSN carried parseTime=true, which tells go-sql-driver/mysql to return time.Time for temporal columns — but that setting governs the query protocol (the result path of a normal SELECT). The binlog replication stream is a separate code path, and it hands temporal values back as their raw string form ("2026-05-05 20:38:23") no matter what the DSN says. The row decoder accepted only a time.Time, so the first temporal row event raised cannot decode string as time.Time (parseTime=true should be set). And the way that error surfaced is what turned a loud bug into a silent one: the pump reported it via a deferred setErr (visible only through a later Err() call, never logged at the point of failure), then closed the events channel. Downstream, the applier just saw the channel close with zero events — a fatal decode error wearing the costume of an idle stream. ## The repro -- source: any table with a temporal column, CDC tailing it CREATE TABLE t (id INT PRIMARY KEY, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP); INSERT INTO t (id) VALUES (1); -- pre-fix: the binlog row event carries ts as the string "2026-05-05 20:38:23"; -- the decoder rejects it, the pump setErr()s and closes the channel, -- the applier sees 0 events. Looks identical to a dead connection. ## What sluice does about it The binlog decoder now parses MySQL's canonical temporal string forms — second-precision, microsecond-precision, and date-only — plus their byte-slice equivalents and the 0000-00-00 zero-value sentinel, rather than assuming a pre-parsed time.Time. (Pinned end-to-end against a real mysql:8.0: pre-fix dropped 100% of CDC events on a temporal table, post-fix all flow.) ## The transferable lesson A driver flag like parseTime tunes the query protocol, not the replication stream — they are different paths through the same driver, and the binlog hands you raw strings whatever the DSN claims. When a setting clearly "works" for your queries but CDC breaks on the exact type it should govern, suspect that the replication path never saw the flag. The companion lesson is about failure visibility: a fatal error routed only through a deferred Err() — not logged where it happens — is indistinguishable from a healthy-but-idle stream, and that ambiguity is what cost the two release cycles. Surface pump-fatal errors loudly, at the point of failure. (The binlog is not the query protocol in more ways than one — it also compresses whole transactions into a single event a query-path reader never sees.) ## Primary sources - go-sql-driver parseTime (a connection/query-path option) — go-sql-driver/mysql parameters. - MySQL binlog row images carry temporal values in their own encoding — Rows_event. - How sluice reads MySQL CDC — How sluice copies your data. =============================================================== # BIT crosses the wire as bytes, and the engines disagree on layout (https://sluicesync.com/field-notes/mysql-bit-wire-bytes/) MySQL hands BIT(N) back as ceil(N/8) right-justified big-endian bytes; Postgres surfaces bit as a '0'/'1' text string. Carry the raw bytes between them through one []byte path and you silently store the ASCII of the digits, not the bits. Observed — migrating a BIT column between MySQL and Postgres. Internally catalog Bug 75. ## What happened Carrying a BIT(N) value between engines through a single []byte IR path silently corrupted every value — it stored the ASCII bytes of the '0'/'1' digits and the writer then kept only the last one. A bit field that looked like a trivial "just move the bytes" column was the one that lost its data. ## Why (the mechanism) The two engines put BIT on the wire in completely different shapes, and a raw-bytes IR is ambiguous between them: - MySQL hands BIT(N) back as ceil(N/8) big-endian bytes, right-justified — the value's bits packed into the minimum number of bytes. BIT(14) = b'10110100110010' is two packed bytes. - Postgres's bit / bit varying text I/O surfaces the value as a '0'/'1' text string already — the same form as the literal B'1010'. So "the bytes" means packed bits on one side and ASCII digit characters on the other. A pipeline that grabbed the driver's bytes and re-decoded them as if they were packed bits took Postgres's "10110100110010" (fourteen ASCII characters) and interpreted it as raw bit-bytes — storing garbage, then truncating to the last byte. Same code path, opposite meaning, silent loss. ## What sluice does about it Carry a single canonical form: a '0'/'1' text bit-string, most-significant bit first, exactly the column's declared bit-length (the same form Postgres's text I/O and B'…' literals use). It's engine-neutral and exact for any width. MySQL's packed bytes convert in via BitBytesToString (unpack ceil(N/8) right-justified big-endian bytes into the N-character string); the MySQL writer binds the uint64 form, not the raw bytes, so the write side doesn't re-introduce the ambiguity either. The canonical string is the one representation that can't be misread between the packed and expanded layouts. ## The transferable lesson "It's a bit field, just move the bytes" hides that two databases disagree on what "the bytes" are: MySQL packs the bits into ceil(N/8) big-endian bytes, Postgres's text protocol hands you the ASCII '0'/'1' expansion. A raw-[]byte intermediate is ambiguous between the packed and expanded forms, and the ambiguity resolves as silent corruption. When two engines encode the same logical value differently on the wire, don't pass the wire bytes through — pick a canonical representation that is unambiguous at every width and convert at each engine boundary. ## Primary sources - MySQL BIT storage & retrieval — The BIT Type and bit-value literals. - Postgres bit text I/O — bit-string types. - sluice's cross-engine value contract — Type mapping. =============================================================== # BIGINT UNSIGNED overflows both bigint and int64 (https://sluicesync.com/field-notes/bigint-unsigned-uint64/) A MySQL BIGINT UNSIGNED reaches 2⁶⁴−1, past Postgres bigint's 2⁶³−1 — and past Go's int64, so above that boundary the driver hands the value back as a uint64 a []byte/string-only decoder can't route. The type mismatch is known; the driver-representation switch is the sharp edge. Observed — migrating a MySQL BIGINT UNSIGNED column holding values above 263−1 to Postgres. A value-fidelity finding from the test rig. ## What happened A MySQL BIGINT UNSIGNED column carrying values above 2^63-1 had no working migration path to Postgres — and, worse, the loud error's recommended recovery didn't function either. This never silently lost data (it failed loudly), but it blocked a common migration and then lied about how to unblock it. ## Why (the mechanism) Three boundaries stack up at the same value: - The target type. BIGINT UNSIGNED reaches 2^64-1 (18,446,744,073,709,551,615); Postgres bigint tops out at 2^63-1. So above the signed max it can't be a bigint at all — it needs numeric or text. - The driver representation (the sharp edge). Above int64's max, go-sql-driver/mysql stops returning a []byte/string and returns a uint64. The value decoder's decodeDecimal/decodeString only handled []byte/string, so even the explicit escape hatch --type-override COL=decimal|text failed with cannot decode uint64 as {Decimal|string}. - The broken remediation. The unsigned-bigint notice told operators to use --type-override TABLE.COL=numeric — but numeric wasn't a recognized override token (only decimal is), and bare decimal defaulted to numeric(10,0), far too few digits for a 20-digit value. The documented fix pointed at a flag that didn't parse and a type too small to hold the number. ## What sluice does about it Add uint64/int64 cases to the decimal and string decoders that carry the exact decimal text via strconv.FormatUint/FormatInt, so a BIGINT UNSIGNED migrates as an exact Postgres numeric or text value — no precision lost. And correct the remediation hint at every site it's surfaced (the notice, the schema-preview output, the doc comments) to the token that actually parses, with enough digits for the value. ## The transferable lesson An unsigned 64-bit integer is a triple boundary: it overflows the target's signed bigint, it overflows the language's int64, and at that second overflow the driver quietly changes the Go type it hands you (uint64, not the []byte/string your decoder was built for). A migration that maps the type but never sees the over-int64max representation fails on exactly the values that justified making the column unsigned. And the meta-lesson: a loud-failure remedy is code too. If the error names a flag token that doesn't exist or a type too narrow for the value, "we refuse loudly and tell you the fix" degrades into "we refuse loudly and mislead you" — so test your remediation strings through the real parser, the same way you'd test a feature. (This is one of a family of integer-boundary hazards where the number outgrows the pipe carrying it.) ## Primary sources - MySQL integer ranges — integer types (BIGINT UNSIGNED = 0 … 2⁶⁴−1). - Postgres numeric ranges — bigint vs numeric. - go-sql-driver returns uint64 above int64 max — go-sql-driver/mysql. =============================================================== # The transaction that lands in neither the snapshot nor the binlog (https://sluicesync.com/field-notes/snapshot-position-gap/) Capture the consistent snapshot and the binlog position as two separate statements, and a transaction committing between them falls into the gap: after the frozen read view, below the recorded offset. It's in neither the bulk copy nor the CDC tail. A global read lock across both closes the seam. Observed — MySQL cold-start: freezing a consistent snapshot for the bulk copy and recording the binlog position for the CDC handoff. Internally the FTWRL-freeze fix; caught by a concurrent-writes-during-cold-start test (2299/2300 rows under -race). ## What happened A MySQL cold-start — bulk-copy a consistent snapshot, then hand off to CDC from a recorded binlog position — intermittently lost a single row under concurrent writes. The row was in neither the snapshot copy nor the CDC tail, with no error. (It was first mis-diagnosed as a slow-apply flake and "fixed" by raising a catch-up ceiling; the row never arrived at any ceiling, because it was never in the stream at all.) ## Why (the mechanism) The snapshot view and the start position were captured as two separate statements: START TRANSACTION WITH CONSISTENT SNAPSHOT; -- freezes the read view (bulk copy) -- << any transaction committing HERE falls into the gap >> SHOW BINARY LOG STATUS; -- records the CDC start offset A transaction that commits in the window between those two statements lands in neither phase: it committed after the read view froze, so the snapshot bulk-copy doesn't see it; and its binlog offset is below the position recorded a moment later, so CDC starts after it and skips it. The row exists on the source and in the binlog, but above the snapshot's cut and below the stream's cut — a silent-loss boundary exactly one transaction wide. ## What sluice does about it Wrap the capture in FLUSH TABLES WITH READ LOCK ... UNLOCK TABLES — the mydumper/Debezium consistent-snapshot pattern — so the snapshot's read view and the recorded binlog position name the exact same logical cut, with no commit able to interleave between them. The open transaction keeps the snapshot alive after the lock is released, and writes that resume afterward are captured by CDC from the frozen position. FLUSH TABLES WITH READ LOCK needs the RELOAD privilege; without it, sluice warns and falls back to the prior lock-free capture rather than failing the run (a least-privilege single-DB user who never hits the window keeps working). ## The transferable lesson When a cold-start hands off from a bulk snapshot to a change stream, the snapshot's consistency point and the stream's start position must be the same instant — capture them as two statements and the window between them is a silent-loss gap for any transaction that commits there (above the snapshot, below the position). The remedy is to make the two reads name one logical cut, classically by holding a global read lock across both so nothing can commit in between. This is the canonical consistent-snapshot dance — mydumper and Debezium do exactly this — and it's canonical precisely because everyone who builds snapshot-to-CDC handoff rediscovers the same one-transaction-wide hole, usually as a single mysteriously-missing row that looks like anything but a boundary bug. ## Primary sources - MySQL consistent snapshots & FLUSH TABLES WITH READ LOCK — FLUSH and InnoDB consistent reads. - The pattern in the wild — Debezium MySQL connector snapshot / mydumper's --trx-consistency-only. - How sluice cold-starts and hands off to CDC — Zero-downtime migration. =============================================================== # A CDC position isn't a universal coordinate (https://sluicesync.com/field-notes/cdc-position-leads-or-trails/) A change-stream gives you a position token to resume from and to reason about order — but engines disagree, non-obviously, on whether that token sits before or after the rows it names. Postgres and MySQL put a schema/DDL position ahead of the rows it introduces; Vitess stamps its commit token after them. Any “did we reach the boundary?” check written against one engine silently false-negatives on the other. Observed — hardening logical-backup restore against a manifest-tamper silent-loss. The completeness guard asked “was this incremental's data fully applied?” by testing whether a schema-history snapshot was anchored exactly at the window's end position — sound on Postgres and MySQL-binlog, a false negative on Vitess/PlanetScale. ## What happened A backup incremental records an end_position — the change-stream coordinate its replay must reach — and a list of change chunks carrying the row events. To catch a store-level adversary who deletes the chunks (an unsigned backup), restore asserts the replay actually reaches end_position: either the last replayed change lands exactly there, or a schema-history snapshot is anchored exactly there. That second clause exists for a legitimate DDL-only window (a schema change, no row writes) — such a window advances end_position to the schema snapshot's own position, so a snapshot sitting at the boundary means “nothing was dropped, this window was always schema-only.” On Postgres and MySQL that reasoning holds. On Vitess it is a silent false negative: an emptied data window — chunks deleted, rows lost — whose final transaction happened to first-touch a table leaves that table's routine schema snapshot sitting exactly at end_position, so the guard reads “boundary reached” and waves the data loss through. ## Why (the mechanism) The three engines stamp their positions on different sides of the same rows: - Postgres logical replication — a RelationMessage (the schema/relation descriptor) carries its own WAL position, and that WALStart strictly precedes the LSNs of the rows it introduces. The schema anchor leads the rows. - MySQL binlog — a DDL Query event's log position precedes the row events that follow it. Again the schema/DDL position leads the rows. - Vitess / PlanetScale VStream — the resumable coordinate is the VGTID, and VStream emits it per transaction commit, after the rows the commit covers. The token trails its rows. So a table's first-touch schema snapshot and the row changes in the same transaction are handed to you carrying one and the same position. Because Postgres and MySQL put the schema anchor before the rows, a routine data-window snapshot is always anchored below the window's last row — so a snapshot found at the end position can only be a genuine DDL-only window, and the heuristic is exact. On Vitess the snapshot and the rows share a position, so “snapshot at the end position” no longer distinguishes “schema-only window” from “data window whose rows were deleted.” Same code, same manifest shape — a different answer purely because the engine writes the coordinate on the other side of the rows. ## What sluice does about it sluice records the source engine's ordering as a capability, CDCPositionCommitsAfterRows (declared for the VStream flavors), and stamps it on every incremental manifest at backup time. When restore or the live broker sees it set, it refuses to treat a schema anchor at the boundary as proof of applied data — on those engines only an actually-replayed change-chunk tail counts, so an emptied-data window is refused loudly instead of restored short. Postgres and MySQL-binlog, whose anchor strictly precedes its rows, keep trusting the anchor, so a legitimate schema-only window still restores. The trust decision is gated on a declared property of the engine, not hard-coded per engine name. ## The transferable lesson A CDC position is not a universal coordinate. Whether the commit/GTID/LSN token an engine hands you sits before or after the rows it's associated with is an engine-specific property of the wire protocol — Postgres and MySQL lead with the schema position, Vitess trails with the commit token — and any “did we reach the boundary?” or event-ordering assumption you write against one engine is silently unsound the moment you point it at another. If a completeness or ordering check compares a position to “where the rows are,” it has to know which side of the rows that engine stamps the position on; anything else passes its tests on the engine you wrote it against and false-negatives on the one you didn't. ## Primary sources - Postgres logical replication protocol — the Relation message and its position, ahead of the row messages it describes. - Vitess VStream / VGTID — the VStream API delivers a VGTID at each transaction boundary, after the row events it covers. - How sluice replays a backup chain and its recorded positions — Sync from a backup chain. - Companion note — An ALTER with no rows behind it is invisible to Postgres CDC (whether a schema change is even visible, vs. this note's which side of the rows the position sits on). =============================================================== # An ALTER with no rows behind it is invisible to Postgres CDC (https://sluicesync.com/field-notes/ddl-invisible-without-rows/) Change-data-capture tools quietly assume a schema change leaves a mark in the replication stream. It does on MySQL and it does not on Postgres, and the divergence is sharp: pgoutput never streams DDL at all — a schema change surfaces only as a RelationMessage, and only right before the first row that follows it. A pure ALTER with no writes behind it produces nothing. Observed — establishing ground truth across four DDL shapes on real Postgres and MySQL while closing a backup-completeness hole. On Postgres a pure ADD COLUMN window closed with an empty end position and no schema anchor; the identical window on MySQL anchored a schema snapshot at the DDL event's own position. ## What happened We were probing how a schema-only change window looks in each engine's change stream — specifically whether an ALTER TABLE … ADD COLUMN with no following INSERT/UPDATE/DELETE advances the stream position and leaves a schema marker. The intuition, carried over from MySQL, was that a DDL is an event like any other: it happens at some position, so a schema-only window should advance the position to that DDL and record a schema snapshot there. On MySQL that is what happens. On Postgres it is not: a pure ADD COLUMN with no row behind it produced nothing in the logical stream — no relation message, no position advance, no snapshot. The new column's shape only appeared once a row for that table finally flowed. The same window, the same intent, two engines, and one of them treats a bare schema change as a non-event. ## Why (the mechanism) The two engines carry DDL in fundamentally different ways: - Postgres — pgoutput never streams DDL. Logical decoding emits data changes and the schema descriptors needed to interpret them; it does not emit ALTER TABLE. A schema change surfaces only indirectly, as an updated RelationMessage — and pgoutput sends that lazily: right before the first row change for that relation after the schema changed. No row change, no RelationMessage. So an ALTER … ADD COLUMN that isn't followed by any DML to that table leaves the stream untouched; the altered shape is invisible until data moves behind it. - MySQL — the binlog logs DDL as a first-class statement. An ALTER TABLE is written to the binlog as a text Query event at its own position, immediately, whether or not any row ever follows. A reader sees the DDL the moment it happens. So a “DDL-only window” is not one shape across engines. On MySQL it has a concrete position and a visible statement. On Postgres, until a row flows, it has neither — the change is real in the catalog but absent from the stream. ## Where it bit us sluice's logical-backup restore has a completeness check: it refuses a backup incremental whose recorded change chunks were deleted (a store-level tamper) by asserting the replay reaches the window's recorded end position — either via the last replayed change, or via a schema-history snapshot anchored exactly there (the legitimate schema-only-window case). We were hardening that check against a forged anchor, and needed to know: what does a real schema-only window actually look like? The ground truth settled it. On Postgres a pure DDL-only window has no snapshot and an empty end position, so it never even reaches the anchor branch — it's applied from the recorded schema delta and skipped by the completeness guard entirely. A snapshot that does sit at the end position, on either engine, only arises when a real column-changing DDL produced a schema delta (which the diff always records) or when data followed the DDL and the snapshot is anchored before those rows. So the guard now trusts an anchor at the boundary only when the window also carries a schema delta — an emptied-data window's forged anchor, which has none, is refused. The engine-visibility divergence is exactly why the naive “a schema-only window advances the position to its snapshot” assumption — true on MySQL, false on Postgres — had to be replaced with something derived from what the stream actually produces. ## The transferable lesson A schema change is not guaranteed to leave a trace in a CDC stream. On the MySQL binlog it does — a DDL is a logged statement at a known position. On Postgres logical replication it does not: pgoutput carries no DDL, and the relation descriptor that reveals the new shape is emitted only ahead of the first row that uses it, so a bare ALTER with nothing behind it is invisible until data moves. Any completeness check, boundary test, schema-drift detector, or “did this DDL replicate yet?” probe that assumes the schema change left a mark is unsound the moment you point it at pgoutput — it will pass on the engine you wrote it against and quietly miss the change on the one you didn't. This is a sibling of a CDC position isn't a universal coordinate: that note is about which side of the rows the position sits on; this one is about whether the schema change is even visible without rows behind it. Both are the same warning — a change stream tells you less, and less portably, than it looks like it does. ## Primary sources - Postgres logical replication protocol — the Relation message, sent before the first change to a relation whose row descriptor the subscriber hasn't seen; pgoutput carries no DDL statements. - MySQL binlog — a DDL QUERY_EVENT records the statement text at its own log position. - Companion note — A CDC position isn't a universal coordinate. =============================================================== # MySQL's own certificate can't pass verify-full (https://sluicesync.com/field-notes/mysql-cert-no-san/) The moment you decide to do MySQL TLS properly — tls=true, encrypt and verify the server — the handshake fails against a stock MySQL. Not because anything is misconfigured: the certificate mysqld generated for itself on first boot carries no SubjectAltName, and modern Go won't fall back to the Common Name to check the hostname. The two facts are structurally incompatible, and neither one makes that obvious on its own. Observed — wiring an authenticated TLS source connection for a MySQL→Postgres sync against a server using its default auto-generated certificate. tls=skip-verify connected but authenticated nothing; tls=true refused the handshake outright with a certificate error, on a server that was working fine. ## What happened A MySQL server started with no explicit certificate configuration doesn't run plaintext — since 5.7 it generates a self-signed CA and server certificate on first start and enables TLS automatically. So the encryption is there for free. The trouble is verifying it. The Go MySQL driver's DSN offers a small set of TLS modes: tls=false (plaintext), tls=skip-verify (encrypt, verify nothing), and tls=true (encrypt and fully verify — the equivalent of Postgres's sslmode=verify-full). The obvious secure choice is tls=true. Against a stock MySQL it fails every time, with a hostname-verification error — even though you're connecting to exactly the server that minted the cert. That leaves an unhappy binary choice: a verify-full mode that can't work, or skip-verify, which encrypts the pipe but authenticates the peer not at all — enough to stop a passive eavesdropper, useless against an active man-in-the-middle who simply presents their own cert. There's no in-between in the driver's DSN grammar. ## Why (the mechanism) Two independent facts collide: - MySQL's auto-generated server cert has no SubjectAltName. The Auto_Generated_Server_Certificate MySQL builds for itself puts the hostname (such as it is) only in the certificate's Common Name — the old, pre-2000 place for it. There is no SAN extension at all. - Go stopped trusting the Common Name. Since Go 1.15, crypto/tls no longer falls back to a certificate's Common Name for hostname verification — a SAN is required. The CN is ignored for name-matching entirely. (This was a deliberate, security-motivated deprecation across the ecosystem, following the CA/Browser Forum; it isn't a Go quirk so much as where every modern TLS stack landed.) Put them together and verify-full is unreachable by construction: the client demands a SAN to match the hostname against, and the server's certificate simply doesn't have one. The failure isn't “your cert is wrong for this host” — it's “your cert has nothing to match any host.” No amount of setting the right hostname helps, because there's no SAN on either side of the comparison. The missing mode is the one that fits: verify the certificate chains to a CA you trust, and skip only the hostname check the SAN-less cert can't satisfy anyway. That's exactly what Postgres has had all along as sslmode=verify-ca — a real authentication (a cert not signed by your CA is refused) that stops short of binding to a hostname. MySQL's driver has no such named mode; you can only get it by descending into Go's RegisterTLSConfig and building a tls.Config with your CA in RootCAs, InsecureSkipVerify: true to bypass the built-in hostname check, and a custom VerifyPeerCertificate callback that verifies the chain against the CA with no DNS name — which, counterintuitively, still runs even under InsecureSkipVerify. Get the callback subtly wrong (verify against the system pool instead of your CA, or return nil on error) and you've built blind skip-verify wearing a verify-ca label — which is why the load-bearing test is the one that presents a cert signed by the wrong CA and insists the handshake fails. ## Where it bit us We were making a sync's MySQL source connection authenticated rather than merely encrypted. tls=true was the intended answer and it wouldn't connect; skip-verify connected but defeated the point. The realization was that against MySQL's default certificate there is no DSN mode that both authenticates the server and completes the handshake — the secure-and-working combination lives only in a hand-built TLS config. So sluice grew --source-tls-ca / --target-tls-ca: point them at the CA (MySQL writes one out next to the server cert), and they build the verify-ca config, register it with the driver, and rewrite the DSN's tls= to use it. The connection is then genuinely authenticated — a server not holding a cert signed by that CA fails the handshake — just not hostname-bound, which is the most a SAN-less cert can offer. ## The transferable lesson MySQL's default TLS posture quietly forces you into verify-ca, and the reason is a two-part gotcha that neither half reveals alone: the server's auto-generated certificate carries no SubjectAltName, and the modern TLS client won't consult the Common Name to make up for it. So tls=true — the mode anyone reaches for when they want to “do TLS properly” — can never validate a stock MySQL server, and the tooling often presents only that or blind skip-verify, making authenticated TLS look impossible when it's merely unnamed. If you're connecting to any MySQL (or MariaDB, or a managed service) that presents a private-CA or self-signed certificate, the mode you actually want is verify-ca: trust the CA, verify the chain, skip the hostname the cert can't satisfy. Postgres names it for you; with MySQL you may have to build it yourself — and the one thing you must not get wrong is that “skip the hostname” is not the same as “skip the CA.” ## Primary sources - MySQL Reference Manual — Creating SSL and RSA Certificates and Keys using MySQL: the server auto-generates a self-signed CA + server certificate at first startup when none is configured, identifying the server in the Common Name rather than a SubjectAltName. - Go 1.15 release notes — crypto/tls: the deprecated, CommonName-based fallback for hostname verification is disabled by default; certificates must carry a SubjectAltName. (The x509ignoreCN GODEBUG that briefly re-enabled it was removed in Go 1.17.) - PostgreSQL documentation — SSL Support: sslmode=verify-ca verifies the certificate chains to a trusted CA; only verify-full additionally checks that the certificate's name matches the host. =============================================================== # Object stores can now say "that changed since you read it" — the portability layer can't ask (https://sluicesync.com/field-notes/object-store-create-only-cas/) Every writer of a backup chain shared one read-modify-write JSON catalog with no arbitration — last Put wins, loser's update silently vanishes. The fix wants compare-and-swap on the object, and the major stores now have it — but the portable object-store surface exposes only create-if-absent. You can build a CAS out of create-only (since ground-truthed live on real S3, GCS, and Azure Blob — three stores that answer an occupied-key conditional write with three different status codes). You can also be honest about the millisecond window it can't close — and about the fact that the primitive that would close it exists upstream, one abstraction layer away. Observed — hardening sluice's backup-chain catalog (ADR-0160, shipped v0.99.246), then ground-truthed against real AWS S3 (us-east-1), Google Cloud Storage (us-east1), and Azure Blob (eastus), all 2026-07-16: conditional-PUT enforcement, exactly-one-winner concurrency, and sluice's own coded conflict refusal observed end to end through the production path on all three. The unguarded race was real but never field-observed; the design constraint — no portable conditional overwrite — turned out to be a property of the portability layer, not the stores, and that reversal is the note. ## What happened A sluice backup chain has one structural record: a lineage catalog, a JSON object listing every segment and its parentage. Every writer — full-backup finalize, cron'd incrementals, stream rollovers, compaction, prune — loaded it, mutated it in memory, and Put it back whole. Two concurrent writers (two cron incrementals, a backup racing a prune, an operator double-start) interleave, the last Put wins, and the loser's structural update silently vanishes: a lost catalog append at best, a mis-parented chain at worst. The data chunks themselves are write-once at distinct paths; the catalog read-modify-write was the one unguarded shared object. The textbook fix is conditional overwrite — Put-if-ETag-matches, a true CAS on the object. The stores themselves have it: AWS S3 gained conditional writes in late 2024 — If-None-Match creates and, with them, If-Match ETag overwrites — GCS has always had generation-match preconditions, Azure has If-Match. But sluice's cloud backends ride a portable abstraction (gocloud.dev/blob), and the portable surface exposes exactly one conditional primitive: create-if-absent (mapping to If-None-Match: * on S3 and Azure, a generation-0 precondition on GCS, O_EXCL on local files). No If-Match — the writer options simply have no field for it (verified in source: nothing in v0.46.0's blob/ handles it, and no plan is visible). For years that was the fleet's fault — S3 had no conditional writes at all, and the portable layer tracked the floor of the fleet. The floor has moved; the abstraction hasn't. ## Building CAS from create-only The catalog write becomes a compare-and-swap on a chain write-generation, arbitrated by create-only claim markers at the chain root (lineage.gen/g-): - Observe, then read. At load, list the markers and record the max claimed generation — before reading the catalog. The order is load-bearing: reversed, you can observe past a competitor's just-landed update and silently clobber it; observe-first turns that window into a spurious-but-safe conflict. - Claim, then Put. At write, create marker g-. Create-if-absent guarantees exactly one concurrent writer wins the slot; the loser's create fails and the write refuses loudly — coded, with the marker's forensic body (host, pid, timestamp) pointing at the other writer — having changed nothing. - GC a trailing window of old markers after success. The liveness property is the part worth stealing: an orphaned marker from a crashed writer is not a stale lock. The next writer's observation lists markers, not catalog content, so the orphan simply becomes the new base and the next generation is claimed after it. No TTLs, no leases, no clock trust, no manual unlock. The rejected alternative makes the contrast sharp — storing the generation counter inside the catalog would turn a crashed claim into a permanent conflict with the recorded counter, a bricked chain needing manual repair. Listing markers as the observation source is what buys lock-free liveness. ## Verified on the real thing The scheme was originally pinned against MinIO and derived for AWS from documentation; a 2026-07-16 probe against real S3 (us-east-1) closed that gap, both at the raw API and through sluice's production path. Raw layer: If-None-Match: * PUT returns 200 on a fresh key and 412 PreconditionFailed on an occupied one; two concurrent conditional PUTs on one fresh key produce exactly one 200 and one 412, final content the winner's. Through sluice's own BlobStore (gocloud's s3blob), eight concurrent claim attempts produced exactly one winner and seven coded losers, and the full interleaved-writer scenario ended in the coded SLUICE-E-BACKUP-CHAIN-CONFLICT refusal naming the other writer's marker, with the loser having written nothing. The library genuinely sends the header — s3blob.go sets IfNoneMatch = "*" when asked, and a 412 on a PUT is only possible if a precondition was sent and enforced server-side. Real create-only CAS, end to end, no silent-ignore. One nuance the probe surfaced without reproducing: under truly simultaneous in-flight conditional PUTs, S3 can hand the loser a 409 ConditionalRequestConflict instead of the 412. gocloud maps only the 412 to the failed-precondition error the guard's conflict branch keys on, so a 409 loser would fall through to the guard's capability-degrade path — an unguarded write plus a WARN whose “the store may not support conditional PUTs” wording would be wrong in that instant. Safe direction (the loser is unguarded, never corrupted-by-guard, and the store's own arbitration already refused it), but it's a mapped-wrong degrade rather than the coded refusal; sluice v0.99.263 closed it by treating the 409 as a conflict, not a capability signal — a 409 means another conditional request was in flight and nothing was written, so the PUT is retried once (a clean retry means this writer won after all), and any still-contended outcome routes to the coded chain-conflict refusal, never the degrade path. The transferable half: when you build on a conditional primitive, audit the error mapping too — a store can refuse your precondition with a status your library doesn't translate. ## Three clouds, three loser shapes S3 was the first cloud row verified; GCS and Azure closed the matrix on the same day, and the interesting part is that all three answer “that key already exists” with a different status code — yet a portable guard survives all three, because the portability layer absorbs the divergence. The occupied-key create-only PUT returns: - S3: 412 PreconditionFailed — the normal loser, with the rare 409 ConditionalRequestConflict as an in-flight artifact under truly simultaneous PUTs (the one the v0.99.263 retry closes). - GCS: 412 conditionNotMet, uniformly — no 409-analogue exists. Under an eight-way simultaneous race GCS produced only 412s; it serializes conditional writes on the object generation, so there is no in-flight-conflict code to catch. The v0.99.263 S3-409 classifier never engages here — correct scoping, not a coverage gap. - Azure: 409 BlobAlreadyExists, uniformly — and this is Azure's normal occupied-key answer, not an anomaly. On Azure the codes are cleanly split: 409 is the create-only (If-None-Match: *) loser and 412 is the overwrite (If-Match) loser, where S3 folds both onto 412. Azure was the row that could have gone wrong. If gocloud mapped only the 412-family codes, every Azure conflict would have landed on the degrade-WARN path and the guard would be useless there. It doesn't: gocloud's azureblob driver explicitly routes BlobAlreadyExists (alongside the ConditionNotMet family) to the same failed-precondition error, with an in-source comment noting that the documented code is a variation of ConditionNotMet but BlobAlreadyExists “has also been observed” — the upstream maintainers hit exactly this divergence and absorbed it. Through sluice's own BlobStore, all three stores produced exactly one winner and the coded SLUICE-E-BACKUP-CHAIN-CONFLICT refusal for every loser, zero degrade-shaped errors, at both two-way and eight-way concurrency. Each store also has a residual throttling loser shape (GCS 429 rateLimitExceeded, Azure 503 ServerBusy) that maps to the degrade path rather than the coded refusal — safe direction, structurally unlikely against one-shot per-generation claim markers, not reproduced even at eight-way. The lesson the matrix carries: portability across object stores is real, but it lives in the error mapping, and the mapping is where a store's idiosyncratic status code either gets absorbed or silently breaks your guard. ## The residual, stated honestly A create-only CAS narrows the race; it cannot close it. Claim and Put are two operations, and a competitor whose observation lands inside another writer's claim-to-Put window — normally milliseconds, a marker PUT followed by the catalog PUT of an already-marshaled body — reads the pre-Put catalog, claims the next generation, and both writers Put unconditionally: last-write-wins again, undetected. The guard shrinks the silently-vulnerable window from the whole seconds-wide read-modify-write span to that millisecond gap. (The probe confirmed the window is real on live S3: the final catalog Put is an unconditional 200-overwrite.) True closure needs conditional overwrite on the catalog object itself — and the probes confirmed that primitive now exists on all three stores: an If-Match / generation-match PUT returned success against the current ETag/generation and a failed-precondition against a stale one, exactly the lost-update refusal the catalog wants (S3's If-Match, GCS's ifGenerationMatch=, Azure's If-Match). Closure no longer waits on any provider; it waits on the portability layer growing conditional overwrite, or on a per-provider SDK write (aws-sdk-go-v2's PutObject takes it today, GCS's storage.Conditions{GenerationMatch: N}, Azure's If-Match). That residual is documented in the ADR and accepted for v1 — a guard whose limits you can state is worth more than one you believe is airtight. ## The transferable lesson If your coordination lives on an object store, inventory the conditional primitives you actually have — and check which layer is withholding them. Portably it may be create-if-absent and nothing else, even when every store underneath now offers conditional overwrite; the constraint that shapes your design can belong to the client library, not the service. Know the two patterns create-only buys you: exactly-one-winner arbitration (claim markers) and lock-free liveness (observe the markers, not the guarded object, so a crashed claimant is a base, not a blocker). Order matters twice (observe before read; claim before put), and the honest accounting matters most: create-only CAS is a race-narrower, the claim-to-Put gap is the price of building on the one primitive your abstraction exposes — and when the missing primitive appears one layer up, say so, because that turns “accepted residual” into “closable, per-provider, whenever it's promoted.” This is the same family as our database-side note that CREATE … IF NOT EXISTS is not a lock — existence checks arbitrate creation, never modification. ## Primary sources - gocloud.dev/blob — WriterOptions.IfNotExist and its per-provider mapping (S3/Azure If-None-Match: *, GCS generation-0, local O_EXCL); no If-Match field in v0.46.0 (verified in source). - AWS S3 — conditional requests: If-None-Match creates and If-Match ETag overwrites (GA November 2024); 409 ConditionalRequestConflict on overlapping in-flight conditional writes. GCS generation-match preconditions (ifGenerationMatch); Azure Blob If-None-Match: * (returning 409 BlobAlreadyExists on an occupied key) and If-Match. - Real-cloud ground-truth probes (2026-07-16) — S3 (us-east-1), GCS (us-east1), and Azure Blob (eastus): the per-store loser-code matrix (S3 412+rare-409, GCS uniform 412, Azure uniform 409 BlobAlreadyExists), exactly-one-winner concurrency at two- and eight-way, and wire-level confirmation that gocloud sends the precondition and maps each store's loser code to the failed-precondition error through sluice's own BlobStore path. - sluice ADR-0160 — the backup-chain concurrent-writer guard: the marker scheme, the rejected alternatives, and the claim-to-Put residual. =============================================================== # Postgres writes +00; your parser expects +00:00 — and every date ends in the shape of the shortest legal offset (https://sluicesync.com/field-notes/shortest-legal-offset/) ISO 8601 admits at least four spellings of a UTC offset, and Postgres COPY picks the shortest: 2026-07-15 08:09:10.123456+00. A layout list that stops at ±hh:mm refuses Postgres's own default text output. And the fix has its own trap — a bare date like 2026-07-02 ends in -02, exactly the naive two-digit offset shape. Observed — sluice's SQLite/D1 type-inference path meeting a real Postgres-COPY-produced timestamptz column (found by the flat-file integration corpus; fixed v0.99.250). The bug was a loud wrong-refusal — a promoted column aborting mid-copy — affecting v0.99.166 through v0.99.249; never silent loss, and completed runs were always correct. ## What happened An ISO 8601 UTC offset can be spelled Z, +00:00, +0000, or bare +00 — all conformant. Postgres COPY … CSV renders timestamptz in the shortest legal form: space-separated, colon-less, two-digit offset. A parser whose layout list covers -07:00 and -0700 but not -07 refuses Postgres's own default text output — arguably the single most common zoned-timestamp rendering a data tool will ever meet in a file. sluice hit both halves of the class at once, because it had two independent implementations of the same predicate. The type-inference validator (GLOB-based, deciding whether a TEXT column's values all conform to a timestamp shape and can be promoted) accepted the value; the decoder (time.Parse layout-based, executing that promotion at copy time) refused it. Validator promises, decoder reneges: a promoted column aborted loudly mid-copy with a raw decode error. Two implementations of “is this a zoned timestamp?” had drifted apart, and every value in the gap between them became a wrong refusal. ## The widening has its own trap The obvious fix — teach both sides the bare ±hh spelling — contains a false-positive generator. A two-digit offset is a two-character numeric suffix after a sign, and a plain DATE ends in one: 2026-07-02 terminates in -02, which is byte-identical to the naive ±hh shape. An unanchored acceptor reads an offset into every date whose day-of-month resembles one — inventing a time zone, and with it an instant shift, on values that were naive dates all along. The accepted spelling has to be anchored: bare ±hh counts as an offset only when it follows seconds or a fractional-seconds field. A date can't get there; a real COPY-rendered timestamptz always does. ## What sluice does about it The decoder gained the missing layouts (T-separated naive datetimes, space-separated zoned forms, ±hhmm, anchored ±hh); the validator gained the matching spellings with the anchoring; and — the part that fixes the class rather than the instance — both sides are now pinned cell-by-cell against the same separator × zone × fraction matrix, with a real Postgres-COPY-produced timestamptz column instant-checked end to end on both Postgres and MySQL targets. The bare-date rows in the validator matrix assert the value stays naive. When two components implement one predicate, either make them literally one function or pin them against one shared truth table; anything looser drifts. ## Reproducing it Any Postgres, one psql session: CREATE TABLE t (ts timestamptz); INSERT INTO t VALUES ('2026-07-15 08:09:10.123456+00'); \copy t TO 'out.csv' CSV $ cat out.csv 2026-07-15 08:09:10.123456+00 <- space separator, bare two-digit offset Feed out.csv to any parser whose zoned layouts stop at ±hh:mm/±hhmm and watch it refuse Postgres's own default output. The false-positive half is just as quick: hand an unanchored ±hh acceptor the bare date 2026-07-02 and it reads a UTC-2 offset out of the day-of-month — which is why the accepted spelling must be anchored after seconds or a fraction. ## The transferable lesson Two lessons. First, a validator and a decoder that answer the same question are one predicate wearing two implementations, and the gap between them is a bug class of its own — here it surfaced as a loud wrong-refusal, but the same drift in the permissive direction would promise conformance the executor silently mangles. Second, the shortest legal ISO offset is a substring of every date: any temporal grammar that accepts bare ±hh without anchoring it to the time portion will hallucinate time zones out of day-of-month digits. Postgres will send you the short spelling — its COPY output is the conformance test your parser actually has to pass. ## Primary sources - ISO 8601 — time zone designators: Z, ±hh:mm, ±hhmm, and ±hh are all conformant spellings. - PostgreSQL documentation — datetime output styles: the ISO style renders the offset without a colon when minutes are zero (the +00 shape COPY emits). - sluice v0.99.250 changelog — the validator/decoder temporal-matrix fix and the affected-version range. =============================================================== # Your replication "position" is an unbounded set — and a 64 KB column caps it near 1,000 servers (https://sluicesync.com/field-notes/position-is-an-unbounded-set/) A MySQL GTID set grows with every server UUID that has ever written to the topology; a Vitess VGTID is that set again per shard. Checkpoint "the position" into a MySQL TEXT column and you've stored an unbounded value in a 64 KB box — and on a server without strict mode, the overflow is a silently truncated position, discovered only at the next resume. Observed — a full inventory of sluice's own MySQL control tables (roadmap item 65a, fixed v0.99.249). Never observed overflowing in the field, and on sluice's connections it would have failed loudly (STRICT_TRANS_TABLES is pinned); the silent-truncation half of this note is standard non-strict MySQL semantics stated as reasoning, not a field observation. ## What happened A replication position looks like a scalar — a number, a name — and for a single-server binlog coordinate it nearly is. A GTID set is not. It is a union of per-server-UUID interval lists (uuid:1-5000,uuid2:1-300,…), and its size is a function of topology history: every failover, clone, or promotion can introduce a server UUID that lives in the set from then on. A Vitess VGTID multiplies that again — one GTID set per shard, so resharding scales the token with the shard count. Any CDC tool that persists “the position” therefore needs a column sized for an unbounded value. MySQL TEXT caps at 65,535 bytes — roughly 1,000 server-UUID entries at typical entry sizes, or one very heavily sharded VGTID. sluice's inventory found it had made this call correctly once and then re-made it wrong twice: the schema-history anchor column was LONGTEXT from day one, with a comment saying exactly why (“for GTID sets can be long”), while two sibling tables holding the same token had shipped as TEXT. Inconsistent sizing across sibling columns holding the same value is precisely how this class ships — the reasoning was done, recorded, and not propagated. ## The loud/silent split What happens at byte 65,536 depends on a server setting the checkpoint writer may not control. With STRICT_TRANS_TABLES (sluice pins it), the INSERT/UPDATE errors and the position write aborts loudly mid-stream — recoverable, if inconvenient. On a server with strict mode disabled — still common in legacy configs — the value is silently truncated at the column limit with a warning nobody reads. A truncated GTID set is not detectably wrong: it parses, it's a valid set, it just describes less history than it should. The corruption manifests at the next resume as a position that re-fetches (or skips) work, which is the worst possible place for corruption to land — far from the write that caused it, wearing the costume of a replication bug. ## The coda: the one-line fix that gets refused The remediation is a one-line ALTER … MODIFY … LONGTEXT — which, on a PlanetScale branch with safe migrations enabled, is itself refused with Error 1105, because safe migrations refuses direct DDL by statement class regardless of effect (a widen-in-place MODIFY included). So the shipped fix is detect-then-ALTER: probe information_schema for the column's current DATA_TYPE, issue the MODIFY only while it's still text (zero DDL on the already-wide path), and when a genuinely needed widen is blocked, refuse loudly carrying the exact ALTER for the operator to ship through a deploy request — never a silent skip. Fresh installs simply declare LONGTEXT. (Postgres is unaffected; its text is unbounded.) ## Reproducing it The arithmetic first: one GTID-set entry is a 36-character server UUID plus its interval list (uuid:1-500000 ≈ 45–60 bytes with separators), so a 65,535-byte TEXT column holds on the order of 1,000 entries — synthesize one past the cap and watch what your strict-mode posture does with it: mysql> CREATE TABLE ckpt (id INT PRIMARY KEY, pos TEXT); mysql> SET SESSION sql_mode = 'STRICT_TRANS_TABLES'; mysql> INSERT INTO ckpt VALUES (1, REPEAT('a', 70000)); ERROR 1406 (22001): Data too long for column 'pos' -- loud abort mysql> SET SESSION sql_mode = ''; mysql> INSERT INTO ckpt VALUES (2, REPEAT('a', 70000)); Query OK, 1 row affected, 1 warning -- silently truncated mysql> SELECT LENGTH(pos) FROM ckpt WHERE id = 2; -- 65535 The non-strict row is the field failure mode: a valid-parsing, shorter-than-written position discovered only at resume. The widen coda reproduces on any PlanetScale safe-migrations branch: ALTER TABLE ckpt MODIFY pos LONGTEXT is refused with Error 1105 even as a pure widen — which is why the fix must probe information_schema first and issue the ALTER only when the column is genuinely still text. ## The transferable lesson Position tokens carry more structure than they look like they do. We've written before about a Postgres LSN being a coordinate in a reference frame rather than an absolute address; this is the size-shaped sibling: a MySQL/Vitess position is a set that grows with topology history, and “how big can the position get?” has no engine-provided answer. Size every column that stores an engine-opaque token as unbounded, and when you get that reasoning right once, grep for the siblings — the second and third columns holding the same value are where the corrected mistake quietly survives. And know your strict-mode posture: it is the difference between this bug aborting your stream and relocating your resume point. ## Primary sources - MySQL Reference Manual — GTID format and storage (per-server-UUID interval lists), TEXT column size limits, and strict SQL mode vs silent truncation. - Vitess documentation — VGTID (per-shard GTID sets in a keyspace position). - sluice v0.99.249 changelog — the TEXT → LONGTEXT widen, the detect-then-ALTER shape, and the safe-migrations refusal path. =============================================================== # Two things the Parquet export directory doesn't tell you (https://sluicesync.com/field-notes/parquet-directory-doesnt-tell-you/) Two things a file-based export doesn't tell its readers — and what spec-compliant readers assume in the silence. GeoParquet defines an omitted crs as "this is lon/lat degrees" — so omission is an assertion, and an EPSG:3857 export without the stamp reads Web-Mercator meters as degrees, no error, wrong planet positions. And the standard read_parquet('dir/*.parquet') recipe treats the directory as the catalog — but a re-export doesn't unwrite old files, so a dropped table's stale .parquet keeps answering the glob as current data. The fix for the second grew a third act: the first orphan sweep deleted without an ownership proof, and a cleanup pass without one is a hazard of its own. Observed — the 2026-07-15 repo audit (MED-D0-4 and MED-D0-5) against sluice's backup export-as-parquet, then exhibited live as a before/after differential by the v0.99.258 regression cycle on a real geometry corpus (details below). Both were sluice's own writer bugs: the schema knew the SRID the whole time (the IR carries it) and the writer simply didn't say it; the orphan sweep was simply never written. Fixed in v0.99.258 — and the sweep's first cut then over-deleted (the 2026-07-16 confirming audit's HIGH-2, live-reproduced), corrected in v0.99.262; that's the third act below. The GeoParquet omitted-crs default is spec behavior — nothing to file upstream. ## First: the omission that means something GeoParquet's geo footer metadata has a crs field per geometry column, and the spec defines its absence: an omitted crs means OGC:CRS84 — longitude/latitude degrees. That makes omission an assertion, not a gap. sluice's export emitted only the encoding and geometry types; a geometry(Point, 3857) column — Web-Mercator, coordinates in meters — exported with no crs, and every spec-compliant reader dutifully interpreted millions-of-meters coordinates as degrees. GeoPandas and DuckDB-spatial don't error on that: the values are numerically plausible, the shapes render, and every position is wrong. The live differential (v0.99.258 cycle, a corpus of SRID 4326 + 3857 + 32633 columns): the v0.99.257 export's geo block carries no crs at all — the 3857 column reads back as degrees under the spec default. The v0.99.258 export embeds canonical PROJJSON for the bundled SRIDs (4326 as GeographicCRS/WGS 84, 3857 as ProjectedCRS/Pseudo-Mercator), and read-back verification confirmed the round trip: DuckDB-spatial types the columns GEOMETRY('EPSG:4326') / GEOMETRY('EPSG:3857'), and GeoPandas reconstructs both CRSs. The repair has its own subtlety, worth spelling out because it's the same trap one level down. For an SRID sluice can't render to PROJJSON (the corpus's 32633), the honest stamp is an explicit null — which the spec defines as “CRS undefined,” semantically different from omitting the key (“CRS is CRS84”) — plus a WARN and an index note naming the SRID and the downstream remedies (GeoPandas set_crs, DuckDB ST_Transform). The key is never omitted. When a spec assigns meaning to your silence, “I don't know” has to be said out loud. ## Second: the directory is the catalog The analytics cookbook pattern — SELECT … FROM read_parquet('exports/*.parquet') — makes the directory listing the table catalog. But sluice's re-export wrote the fresh files and an updated index, and deleted nothing. Drop a table (or exclude it) and re-export with --force-overwrite: the fresh index no longer lists it, but its old .parquet file still sits in the directory, still matches the glob, and still serves last week's rows as current data. No reader consults the index; the glob is the query surface. Live differential, same cycle: on v0.99.257 a dropped table's tbl2.parquet orphan survives a --force-overwrite re-export and keeps answering the glob. On v0.99.258 the re-export deletes every .parquet the fresh index does not claim, logging each by name (deleted a stale .parquet not claimed by this export's index), and the sibling files are untouched. ## Third: the fix that over-reached — cleanup needs an ownership proof Honesty requires the third act: the v0.99.258 sweep closed the orphan hole and opened a bigger one. Its scope was “every .parquet the fresh index does not claim” — but it listed the output root recursively, and it ran whenever --force-overwrite was set, ungated on a prior sluice export existing there at all. sluice only ever writes top-level .
.parquet names, so a nested .parquet is by construction someone else's — yet the 2026-07-16 confirming audit (HIGH-2) reproduced a first-ever forced export into a directory that also held foreign Hive-style datasets (other-tool/dt=…/part-0001.parquet, a Spark output tree) deleting all of them, irreversibly, named at INFO only after the fact. “Not claimed by my index” describes every Parquet file on earth; a delete keyed on the absence of a claim has no boundary. v0.99.262 draws the boundary the first cut skipped, and it's a positive proof, not a broader filter: the sweep touches only top-level names — the shapes sluice could have written — and only when the destination's prior parquet_index.json proves a sluice export owned the directory; everything unclaimed outside that boundary is WARN-named as unmanaged and never deleted. The index that no reader consults (act two's complaint) turns out to have a second job: it is the writer's own ownership sentinel, the artifact that licenses the delete. ## Reproducing it Any MySQL or Postgres with a geometry column (this is the regression-cycle corpus shape): CREATE TABLE geo (id INT PRIMARY KEY, p4326 POINT NOT NULL SRID 4326, p3857 POINT NOT NULL SRID 3857); -- insert a few rows, then: sluice backup full --source-driver=mysql --source '' --out ./store sluice backup export-as-parquet --from-dir ./store --out ./exports # inspect the geo metadata (duckdb): SELECT key, value FROM parquet_kv_metadata('exports/geo.parquet'); # <= v0.99.257: the geo block has NO "crs" key — 3857 meters read as degrees # >= v0.99.258: PROJJSON for 4326 + 3857; unbundled SRIDs get explicit "crs": null + a WARN # the orphan: drop a table, take a fresh backup, re-export sluice backup export-as-parquet --from-dir ./store2 --out ./exports --force-overwrite ls exports/*.parquet # <= v0.99.257: the dropped table's file is still there, still answers the glob # >= v0.99.258: "deleted a stale .parquet not claimed by this export's index" naming it # the third act: a foreign nested dataset in the same destination mkdir -p fresh-exports/other-tool/dt=2026-07-01 # no prior sluice export here cp elsewhere/part-0001.parquet fresh-exports/other-tool/dt=2026-07-01/ sluice backup export-as-parquet --from-dir ./store --out ./fresh-exports --force-overwrite # v0.99.258-261: the foreign nested .parquet is DELETED — on a first-ever export # >= v0.99.262: top-level-only + prior-index ownership gate; foreign files WARN-named as unmanaged, untouched For the misread itself: import geopandas; geopandas.read_parquet('exports/geo.parquet').crs — pre-fix that reports EPSG:4326-equivalent (the spec default) for a column whose numbers are meters. ## The transferable lesson Common thread: in a spec'd file format, whatever the reader is defined to assume in the absence of your metadata is part of your writer's contract — read the spec's defaults as obligations, and when you genuinely don't know a value, say so explicitly rather than omitting the key, because the two silences can mean different things. And when the directory is the catalog — every glob-based lake pattern — deleting is part of writing: an exporter that only adds files leaves every consumer reading the union of all its historical runs. But the license to delete extends exactly as far as ownership does, and ownership must be a positive proof (a sentinel you wrote, names only you emit), never the absence of a claim — a cleanup pass scoped by what it doesn't recognize will eventually meet a directory it shares. ## Primary sources - GeoParquet specification — the geo metadata's crs field: omitted means OGC:CRS84; explicit null means undefined; PROJJSON as the encoding. - sluice v0.99.258 changelog — the per-column CRS stamp (canonical PROJJSON for 4326/3857, explicit null + WARN for unbundled SRIDs) and the --force-overwrite orphan sweep; audit findings MED-D0-4/5. - sluice v0.99.262 changelog — the sweep's ownership boundary: top-level names only, gated on the prior parquet_index.json sentinel, unmanaged files WARN-named and never deleted; the 2026-07-16 confirming audit's HIGH-2 (foreign nested datasets deleted on a first-ever forced export, live-reproduced). - sluice-testing session report v0.99.258 (F6) — the live differential on the 4326/3857/32633 corpus, including the DuckDB-spatial and GeoPandas read-back. - Companion field note — The Parquet library nulled every false (the same surface's earlier silent class: the library nulled every false; here the spec degrades every omission). =============================================================== # The index that shares only a name (https://sluicesync.com/field-notes/index-that-shares-only-a-name/) Idempotent index builds are detect-then-skip: if an index with the intended name already exists on the target, skip the build. But a name is not a definition — and index names live in a tiny convention-driven namespace (idx_email, uq_name). The sharpest cell is uniqueness: when the source's UNIQUE index name-matches a plain INDEX on the target, the existing definition silently decides which duplicate writes the target accepts or refuses. Every step exits green. Observed — the 2026-07-15 repo audit (MED-D0-8) against sluice's MySQL index-build paths, both the direct build and the deploy-request fallback; then exhibited both-sides on real MySQL by the v0.99.260 regression cycle (the differential below). This was sluice's own skip logic trusting a name; the advisory WARN shipped in v0.99.260. It's the index-level twin of the table-shape gate that shipped two releases earlier (ADR-0166): “already exists” is not “already correct.” ## What happened Migration tools re-run. Resume flows, retries, and pre-provisioned targets all mean the index-build phase routinely finds indexes already present — so every tool's build is detect-then-skip: probe the target catalog for the intended name, skip if found. sluice's probe checked existence by name only. A pre-existing index that merely shares the intended name — different columns, different prefix lengths, or crucially different uniqueness — was silently accepted as “already built.” For most definition drift the consequence is query plans that don't match what the operator thinks they migrated. Uniqueness is the cell with teeth, and it cuts both directions: - Source has UNIQUE KEY uq_name(name), target has a plain KEY uq_name(name): the target now accepts duplicate names the source could never hold. The divergence surfaces later as data the application assumes impossible. - The inverse — plain on the source, UNIQUE pre-created on the target — means the target rejects inserts the source legally performed, as constraint violations during sync or a later write. Either way, the migration exits green: the copy is exact, the index build reports success (it skipped), and the difference is invisible until data exercises it. ## The live differential The v0.99.260 regression cycle ran it on real MySQL: source table with UNIQUE KEY uq_name(name), target pre-created from sluice's own captured DDL with the index demoted to a plain KEY uq_name(name). binary outcome (same rc=0, same exact data md5 both sides) --------- ----------------------------------------------------------- v0.99.259 silent skip — zero WARN lines; the target index stays non-unique with no signal anywhere v0.99.260 dedicated WARN: "an index with this name already exists with DIFFERENT UNIQUENESS — the build skips it, so the EXISTING definition decides which duplicate writes the target accepts or refuses" with index=uq_name existing_definition=(name) intended_definition="UNIQUE (name)" A different-column collision gets the general WARN naming both definitions; the same-name-same-definition control stays silent on both binaries (no WARN noise on the healthy path). Deliberately a WARN and not a refusal: a differing definition can be intentional operator tuning — a wider covering index, an adjusted prefix — which is exactly what detect-then-skip exists to respect. The advisory costs one information_schema.statistics read per table that had skips, and a probe failure degrades to DEBUG rather than failing a build the existence check already green-lit. ## The compare is a tour of MySQL catalog quirks Deriving “is the existing definition the one I would have built?” from information_schema.statistics turned out to be its own field trip, ground-truthed on real MySQL during the fix: - A SPATIAL index reports a SUB_PART of 32 nobody asked for — catalog noise, not part of any buildable definition; compare it literally and every spatial index false-drifts. - FULLTEXT/SPATIAL indexes silently shed UNIQUE and per-column prefixes at DDL-emit time (insist and you get Error 1089) — so the intended side of the compare has to drop them too, mirroring the emitter's rules rather than the raw schema. - DESC key parts don't appear as a direction flag: they hide in COLLATION='D'. - Functional key parts surface as NULL COLUMN_NAME and can only be matched positionally — MySQL normalizes the expression text, so a byte-compare false-flags spellings that are equal. Every one of those is a way an honest definition compare either misses drift or invents it. The namespace being conventional is what makes the class common; the catalog being quirky is what makes the fix nontrivial. ## Reproducing it Any MySQL, two minutes (this is the regression-cycle fixture): -- source CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(50), val INT, UNIQUE KEY uq_name (name), KEY idx_val (val)); -- pre-create the target table identically EXCEPT the index clause: -- UNIQUE KEY uq_name (name) -> KEY uq_name (name) sluice migrate --source-driver=mysql --source '' --target-driver=mysql --target '' # <= v0.99.259: rc=0, no signal; SHOW INDEX FROM t on the target: uq_name NON_UNIQUE=1 # >= v0.99.260: rc=0, same data, plus the DIFFERENT-UNIQUENESS WARN naming both definitions -- then demonstrate the consequence the WARN names: INSERT INTO t VALUES (100,'alice',1),(101,'alice',2); -- target accepts; source never could ## The transferable lesson This is the third member of a family: existence checks that answer a different question than the one you asked. CREATE … IF NOT EXISTS answers “did a create race me?” without atomicity; a platform's statement-class gate answers “is this statement allowed?” rather than “is this effect safe?”; and a name probe answers “does something called this exist?” when the build needs “does this definition exist?”. Whenever idempotency is implemented as detect-then-skip, the detection must compare what the skip is trusting — and for indexes specifically, uniqueness is a data-integrity property wearing a performance object's clothes: it decides what writes are legal, so drifting it silently is a correctness bug, not a tuning difference. “Already exists” is not “already correct.” ## Primary sources - sluice v0.99.260 changelog — the index definition-drift WARN (columns, prefix, direction, uniqueness; direct build and deploy-request fallback alike); the 2026-07-15 audit finding MED-D0-8. - sluice-testing session report v0.99.260 (F1) — the live both-sides differential and exact WARN wording on real MySQL. - MySQL documentation — information_schema.statistics (SUB_PART, COLLATION, NULL COLUMN_NAME for functional key parts) and Error 1089; the catalog quirks above were ground-truthed on real MySQL including prefix+DESC, functional, and spatial indexes. - sluice ADR-0166 — the table-shape gate (“already exists ≠ already correct” one level up); companion field note CREATE IF NOT EXISTS is not a lock (the family's atomicity member). =============================================================== # The round-trip test that cannot see symmetric bugs (https://sluicesync.com/field-notes/round-trip-cannot-see-symmetric-bugs/) Write false, the file says NULL, read it back as false — green. A writer bug whose read-back is symmetric is invisible to every round-trip test, and the general condition is worse than one bug: if your writer and every test pin read through the same library, the entire format boundary is self-consistent, and a symmetric regression ships files the rest of the world can't read while your suite stays green. The fix isn't another test; it's an outside reader. And the checker we built to be that reader promptly demonstrated the class inside its own harness. Observed — sluice's Parquet export boundary, flagged by the 2026-07-15 repo audit (MED-T3): the writer and every read-back pin were the same parquet-go v0.30.1, with the advertised consumer (DuckDB) appearing in no gate. The class it guards against is prospective — an independent DuckDB probe run during the audit verified today's compatibility exact — but the shape had already fired once for real: the published parquet-zero-value-null note, where the library nulled every Go zero value and the round-trip read it back as the zero value, green. The external-reader gate shipped in v0.99.259. ## What happened sluice's backup export-as-parquet had, by the audit's count, a model test suite at the value level: every type family × shape pinned, the zero-value wart pinned from both sides, refusals coded. And all of it — writer and every reader in every pin — went through parquet-go v0.30.1. That's self-consistency, not correctness. A future library upgrade that broke the format symmetrically — a logical-type annotation dropped on write and ignored on read, an encoding both directions misinterpret identically — would keep every pin green while shipping files DuckDB and Spark can't decode. One concrete stake makes it vivid: if the UINT_64 logical-type annotation stopped surviving, uint64 max (18446744073709551615) reads back as -1 in any reader that honors the physical int64 without the annotation. sluice's own round trip would never notice; both sides would agree on the annotation's absence. ## The structural fix: something else does the reading v0.99.259 added a CI gate that is deliberately not another parquet-go test: a deterministic family × shape matrix — uint64 max, −0.0 (signbit), NaN/±Inf, denormals, all three DECIMAL physical tiers, microsecond temporals, JSON, empty-vs-NULL, array element families, row-group placement, footer metadata including the GeoParquet CRS — is generated through the real export codec and read back with real DuckDB, comparing values exactly. At gate-landing time: 15/15 checks under DuckDB v1.5.4, including DuckDB-spatial auto-decoding the GeoParquet column to POINT (1 2). One deliberate choice inverts normal CI hygiene: the workflow runs duckdb/duckdb:latest, unpinned. A pinned reader would freeze the gate at today's ecosystem; unpinned, the ecosystem's reader drifts into the gate instead of past it — if a future DuckDB stops accepting something sluice writes, the gate goes red, which is exactly the news it exists to deliver. (The cost — an upstream DuckDB regression can redden sluice's CI — is why it's a non-required workflow.) ## The recursive kicker The checker compares DuckDB's output against expected values stored in a JSON file. Its first cut decoded that file with encoding/json's default path — where every number is float64 — so 18446744073709551615 became 18446744073709552000 inside the verification harness itself: the exact mangling class the gate exists to catch on the DuckDB side, reproduced in the tool built to catch it. Fixed with UseNumber, then pinned with a test asserting that the float64-rounded near-miss must fail the comparison — the harness now proves it can distinguish the values it was built to protect. That beat is the note's thesis in miniature: every comparison harness is one more codec, and a codec can be wrong. (It's also the uint64 twin of our published int64-json-boundary class.) ## Reproducing it The harness bug is four lines of Go, no database required: var v []map[string]any json.Unmarshal([]byte(`[{"u":18446744073709551615}]`), &v) fmt.Println(v[0]["u"]) // 1.8446744073709552e+19 — float64, exactness gone // json.NewDecoder(...).UseNumber() preserves it as the literal "18446744073709551615" The boundary check itself, against any sluice Parquet export (DuckDB CLI): SELECT typeof(u), u FROM read_parquet('export/native.parquet') LIMIT 1; -- want: UBIGINT, 18446744073709551615 — a reader that lost the UINT_64 -- annotation reports BIGINT and -1 SELECT DISTINCT row_group_id, row_group_num_rows FROM parquet_metadata('export/native.parquet'); -- one row group per source chunk: the alignment contract as an external reader counts it And the class demonstration needs no bug at all: write any value your library round-trips through a lossy representation (the published zero-value case: false → NULL → false), and observe that a write-then-read-with-the-same-library test is green by construction. ## The transferable lesson A format boundary is only tested when something on the other side does the reading. Round-trip tests through one library verify that library's self-consistency — necessary, and structurally incapable of seeing symmetric bugs, which are exactly the bugs a library upgrade introduces. If a file format is your product's interface, put a reader you don't ship into the gate, feed it your worst values (the extremes, the annotations, the metadata — not the happy middle), and consider leaving its version unpinned so the ecosystem drifts into your tests rather than past them. Then apply the same skepticism to the checker: its own deserialization path is a codec too, and the first value it mangles will be one of the extremes you chose it to protect. ## Primary sources - sluice v0.99.259 changelog — the DuckDB parquet-compat CI workflow (matrix contents, exact-value comparison, unpinned latest). - The 2026-07-15 repo audit, MED-T3 — the self-consistent-boundary finding, including the audit's own independent DuckDB probe verifying today's compatibility. - sluice's duckdbverify harness — the UseNumber comment and the uint64-near-miss-must-fail pin (TestWant_Uint64SurvivesTheJSONRoundTrip: “the first cut decoded checks.json with plain Unmarshal and 18446744073709551615 became …552000”); the UBIGINT stake (“without the UINT_64 annotation surviving, uint64 max reads as -1”). - Companion field notes — parquet-zero-value-null (the symmetric-bug shape observed for real; this note is its constructive sequel), int64-json-boundary (the checker's own bug is that class's uint64 twin), verifier-rode-the-same-reader (the same independence principle at the dump-reader boundary). =============================================================== # Your dump reader is quadratic in a knob somebody else set — twice (https://sluicesync.com/field-notes/quadratic-in-a-knob-somebody-else-set/) A dump reader's cost was quadratic in statement size — and statement size isn't the reader's variable, it's the --statement-size flag chosen by whoever took the dump. We found it, fixed it, benchmarked the fix, shipped "order-of-magnitude on the giant chunk" — and the same-day regression cycle measured no end-to-end difference at all, because the same complexity class lived one layer down, in a buffer sized to the statement tail. Two acts: the quadratic, and the quadratic that survived its own fix. Observed — sluice's mydumper source engine, in two rounds: the statement splitter (2026-07-15 repo audit, MED-P1, measured; fixed v0.99.259) and the value decoder beneath it (Bug 191, filed by the v0.99.259 regression cycle the day the splitter fix shipped; fixed v0.99.261). Both were sluice's own bugs, and the second one is the reason this note exists: fidelity was byte-exact throughout — this is purely a wall-clock/complexity story, but one that crossed into a loud failure cliff before it was done. ## Act one: the carry-rescan splitter A block-based statement splitter is the natural way to read a dump file: consume 1 MiB blocks, find statement boundaries, emit complete statements. The catch is that a boundary can't be recognized without lexer context — quotes, backtick identifiers, comments, and two-character delimiters (--, /*) all straddle block boundaries — so the easy correctness answer is: keep the unfinished statement tail (the “carry”), and on the next block, re-lex carry+block from byte 0. That makes cost quadratic in statement size, not file size. And statement size belongs to the producer: mydumper's --statement-size flag (default ~1 MiB, raisable to tens of MiB by anyone chasing fewer round trips at load time). The audit measured it on identical bytes: 64 MiB as 64 default-sized statements read in 165 ms; as one statement, 2.52 s; double the statement and it quadruples (128 MiB = 9.86 s); ~40 s at the reader's named 256 MiB ceiling. A dump taken with --statement-size 64M read ~15× slower per byte than its default-sized sibling — no error, nothing to see but a slow restore. The v0.99.259 fix persists the lexer state across blocks: a small state machine whose pending one-byte lookaheads replace the re-scan, so the trajectory — and every statement boundary — is independent of where reads fall, and each byte is examined exactly once. The fix commit's own benchmark: 2.23 s → 128 ms on the 64 MiB statement (~17×, 524 MB/s, on par with the 148 ms the same bytes cost as 64 statements). The verification craft is worth stealing: the old whole-input splitter stays in the codebase as a differential oracle, and the incremental lexer is pinned byte-identical against it for every token family spanning a block boundary at every offset (block sizes 1 through 8), plus a seeded-random backstop. ## Act two: the quadratic below the quadratic The release notes said “order-of-magnitude on the giant chunk.” The same-day regression cycle built exactly that shape — a 49 MiB, 12,010-row single-statement dump — and measured: migrate 340.8 s on v0.99.259 vs 348.9 s on v0.99.258. verify --depth count, which isolates the source read: 354–377 s vs 334–369 s across three runs each. No differential. The splitter really was linear as claimed — its re-lex accounted for only ~2–3 s of the ~350 even on the old binary. Bisection fit the remaining cost to rows × statement_size at ~1.8 GB/s effective: 750 rows × 49 MiB → 20.4 s; 12,000 rows × 3 MiB → 22.5 s; the same rows split into 12 × 4 MiB statements → 29.1 s. One layer below the splitter, the quoted-string value decoder allocated each value's output buffer as make([]byte, 0, len(s)) — where s was the remaining statement tail from the value's opening quote. Roughly 25 MiB of capacity, zeroed, for every ~4 KiB value: about 300 GB of large-object allocation churn per chunk. Same class, same producer-owned knob, different layer. Fixing it (v0.99.261) surfaced two things the first act had framed too narrowly: - The default shape paid the tax too. Generalizing the scanner over the quote character exposed a worse same-class sibling in the double-quote path — which is mydumper ≥1.0's default emit shape — copying the entire tail into a fresh allocation per value. Even default ~1 MiB statements paid rows × ~0.5 MiB average; the “only raised --statement-size dumps are affected” framing was itself a layer-one conclusion. - The perf class had crossed into a correctness cliff. The decode stall starved the in-flight LOAD DATA stream past the target's net_read_timeout (30 s on the rig): v0.99.258 failed deterministically — 2 of 2 runs — on the gzipped twin of the same dump with Error 1159 (08S01): Got timeout reading communication packets. v0.99.259's splitter work moved the stall back under the cliff at that size; only v0.99.261 bounds the gap by value size instead of statement size (the changelog honestly says “plausibly closed” — larger values still cost what they cost). The v0.99.261 decoder pre-scans to the closing quote with the same escape grammar, then sizes the buffer to the value. And this time the benchmark is at the pipeline level: end-to-end row reads on a 16 MiB statement went 1.81 s / 34.4 GB allocated → 75 ms / 139 MB, linear through 48 MiB. Byte-exactness is pinned by a million-input differential fuzz against the old decoder as oracle, plus the delimiter × escape-shape matrix — and the regression cycle's fidelity oracle for the whole path was the MySQL server itself parsing the same statements (a three-way digest match, both binaries against server truth). ## Reproducing it Take the same data twice with real mydumper and time the reads: docker run --rm --network host mydumper/mydumper:v1.0.3-1 \ mydumper -h 127.0.0.1 -u root -p secret -B mydb -o /dump-default # ~1 MiB statements docker run --rm --network host mydumper/mydumper:v1.0.3-1 \ mydumper -h 127.0.0.1 -u root -p secret -B mydb -o /dump-64m -s 64000000 # one giant statement per chunk time sluice verify --depth count --source-driver=mydumper --source ./dump-64m \ --target-driver=mysql --target '' # isolates the source read On a table with ≥5k normal-size rows and a 16 MiB+ single statement: sluice ≤ v0.99.258 is quadratic at both layers; v0.99.259/260 shows little end-to-end change on many-row giant statements (the act-two shape); ≥ v0.99.261 reads both dumps at the same per-byte rate. To see the cliff, gzip the giant dump's chunks and load into a MySQL with net_read_timeout at its 30 s default — v0.99.258 aborts with Error 1159. ## The transferable lesson Two, one per act. Any incremental parser that re-scans its carry — or sizes work against its remaining input — is quadratic in its largest token, and the largest token's size may be a flag somebody else set at dump time; your complexity class has an owner, and it might not be you. And a complexity-class fix needs an end-to-end measurement, because the same class can live at more than one layer of the same path: we fixed the layer, benchmarked the layer, and the pipeline stayed quadratic. Benchmark the pipeline. (The write-side twins of this class are already published: rewriting a whole manifest per chunk, and re-encoding a growing state blob per checkpoint — those grow an object once per write; this one re-paid a growing tail once per value.) ## Primary sources - sluice v0.99.259 changelog (the linear splitter, with the oracle-pin methodology) and v0.99.261 changelog (Bug 191: the value-decoder fix, the pipeline-level 1.81 s → 75 ms numbers, the double-quote sibling, the million-input differential fuzz). - The 2026-07-15 repo audit, MED-P1 — the measured 165 ms / 2.52 s / 9.86 s quadratic confirmation. - sluice-testing Bug 191 and session report v0.99.259 (F1/F5) — the no-differential measurement, the rows × statement_size bisection, the ~300 GB allocation figure, and the deterministic Error 1159 cliff. - mydumper documentation — --statement-size (-s). - Companion field notes — backup-manifest-quadratic and migrate-state-quadratic-blob (the write-side quadratic twins), mydumper-format-family (--statement-size as one more producer axis). =============================================================== # The dump reader skipped what it couldn't lex — and the verifier rode the same reader (https://sluicesync.com/field-notes/verifier-rode-the-same-reader/) A statement-splitting dump reader dispatched on the first token and let anything that lexed empty fall through as "comment-only." A UTF-8 BOM glued to the first INSERT made it lex empty: the whole statement vanished at exit 0. And verify --depth count counted through the identical blind spot, so the safety net confirmed the loss instead of catching it. Then the fix's own third act: the refusal reached verify's report but not its exit code. Observed — the 2026-07-15 repo audit's CRITICAL-1 finding against sluice's mydumper source engine, exhibited live on the shipped binary by the v0.99.257 regression cycle (both scenarios, exact row counts below). This is a confession note: the exposure window was v0.99.247–v0.99.256, the engine's entire published life. The reader fix shipped in v0.99.257; the verify exit-code half (Bug 190) followed in v0.99.258. ## What happened A mydumper-format dump is a directory of .sql chunk files, each a stream of statements. sluice's reader split each chunk into statements, lexed the leading keyword, and switched on it: INSERT streams rows, SET hits the session-header gates, and — the load-bearing default — any other statement refuses loudly. Except one arm: a statement whose keyword lexed to the empty string was treated as a comment-only fragment and skipped, without verifying it actually was one. Two mundane inputs weaponize that posture: - A UTF-8 BOM (EF BB BF) at the start of a chunk. mydumper itself never writes one — but PowerShell and plenty of Windows editors prepend it on re-save. The BOM glues itself to the first statement, its keyword lexes empty, and the entire first INSERT — up to ~1 MiB of rows at mydumper's default statement size — silently vanishes. “Bulk copy complete,” exit 0. - A severed INSERT tail — (2,'b'),(3,'c');, the classic torn-dump shape. Digits don't lex as a keyword; the fragment's rows vanish the same way. The knife-twist is the verifier. sluice verify --depth count counted source rows through the same processChunk dispatch as the copy path — so it re-counted through the identical blind spot and reported the short counts as matching. The audit's phrasing stuck: the safety net confirms the loss instead of catching it. Note the irony in the design: the engine's default branch was “ANY other statement refused loudly” — but that refusal was unreachable for exactly the statements that don't lex to a keyword. The loud-failure posture existed; the skip arm sat in front of it. ## The live differential The v0.99.257 regression cycle ran both shapes on real mydumper v1.0.3 dumps (10-row table), shipped binaries both sides: scenario v0.99.256 v0.99.257 ------------------------ -------------------------- ----------------------------- BOM on header-less chunk rc=0, 5 of 10 rows land; BOM WARN, all 10 rows, md5 verify --depth count rc=0 exact, verify clean CONFIRMS the loss severed fragment spliced rc=0, 8 of 10 rows; migrate refuses rc=1 naming between INSERTs verify confirms the chunk file + a quoted head of the offending bytes pure-comment fragment clean clean (no false refusal) One more shape from the same cycle: a BOM on a chunk with headers glued to the leading /*!40101 SET NAMES…*/ line — no row loss there, but a session header silently vanishing is the same skip class. ## The third act — loud in the text, silent in the exit code The v0.99.257 fix made the verify door inherit the refusal — and the same cycle found that on the torn dump, verify printed the refusal in its table row (t SKIPPED (source count error: … does not begin with a SQL keyword …)) and still exited 0. Verify's exit policy counted only count mismatches; any per-table count error landed as an informative skip. An rc-gated verify — a script, a CI job — passed while a table was never verified. That was filed as Bug 190 and fixed as a class in v0.99.258: every table verify could not examine (count error either side, sample-hash error, missing-on-target) now counts toward a tables_unverified failure class and the run exits 2, with the summary trailer stating “an unverified table is not a pass; non-zero exit code follows.” Mismatches keep exit 1; deliberate --exclude-table exclusions stay exit-neutral. ## What sluice does about it Since v0.99.257: a leading BOM is stripped losslessly with a WARN (matching the flat-file engines' posture; data-chunk and schema paths both), any other keyword-less non-comment fragment refuses loudly naming the file and a quoted 40-byte head of the bytes, and unterminated /*!NNNNN versioned comments refuse instead of silently skipping. The skip arm is now reserved for provably-inert content — pure comments and whitespace. Since v0.99.258, verify's exit code tells the truth about what it couldn't check. ## Reproducing it Seconds to observe, on any mydumper dump (real ones via docker run mydumper/mydumper:v1.0.3-1). Splice a fragment into a data chunk: printf '(999,"x");\n' >> dump/mydb.t.00000.sql # or splice mid-file # sluice <= 0.99.256: migrate rc=0, fragment rows silently absent; # verify --depth count rc=0 confirms the short count # sluice = 0.99.257: migrate refuses rc=1 naming the file + bytes; # verify PRINTS the refusal but exits 0 (Bug 190) # sluice >= 0.99.258: verify exits 2 — "1 could not be verified" sluice migrate --source-driver=mydumper --source ./dump --target-driver=mysql --target '' sluice verify --depth count --source-driver=mydumper --source ./dump --target-driver=mysql --target ''; echo $? For the BOM half: re-save any chunk through PowerShell's default Set-Content, or printf '\xef\xbb\xbf' | cat - chunk.sql > bom.sql. ## The transferable lesson Two, one per act. In a statement stream, “skip what you don't recognize” is the silent-loss posture: a skip must be reserved for content you can prove inert, and everything else refused naming the bytes — otherwise your loud-failure default is unreachable for exactly the inputs that need it. And a verifier that shares its reader with the copy path inherits every one of the reader's blind spots — it can only catch loss introduced downstream of the shared parse. After you fix the reader, check the verifier's exit-code contract too: “loud in the text, silent in the rc” is the same confirming-not-catching failure one layer up. A detector that cannot examine a table must not report overall success. ## Primary sources - sluice v0.99.257 changelog (the fragment refusal + BOM strip) and v0.99.258 changelog (Bug 190, the tables_unverified exit-2 class). - The 2026-07-15 repo audit, finding CRITICAL-1 — observed via the shipped binary, three independent end-to-end repros. - sluice-testing session reports v0.99.257 (F1, the live differential table above) and v0.99.258 (F2, Bug 190 closed on every door). - Unicode/UTF-8 — the byte-order mark EF BB BF; mydumper writes none, Windows tooling adds them on re-save. =============================================================== # The alert cleared at the exact moment the slot died (https://sluicesync.com/field-notes/alert-cleared-when-slot-died/) Monitor a Postgres replication slot by WAL-retention pressure and you inherit a sign flip at the terminal event: when Postgres invalidates the slot, pg_replication_slots reports wal_status='lost' and the lag columns go NULL — not huge. Coerce NULL to zero, compute 0% pressure, and your threshold evaluator concludes the condition cleared. The operator is told the pressure resolved at precisely the moment it became fatal. Observed — live on PG16, first by the 2026-07-15 repo audit (MED-D0-9, against sluice's own slot-health evaluator) and then reproduced as a staged before/after differential by the v0.99.258 regression cycle, exact log lines below. This was sluice's bug, not Postgres's: the catalog behavior is documented. Fixed in v0.99.258. One mitigating fact up front: the CDC streamer itself fails loudly on a lost slot, so this was an alerting-truth inversion, not a data-loss path — the feature that existed to give operators early warning gave them a false all-clear instead. ## What happened sluice's slot-health watcher pages on WAL-retention pressure: percent of max_slot_wal_keep_size retained for the slot, WARN at 70%, CRITICAL at 85%. The natural implementation reads the slot's lag from pg_replication_slots, computes a percentage, and compares against thresholds — and that implementation contains the trap twice over: - When Postgres invalidates a slot (the WAL it needs got recycled past the cap), the row doesn't show an enormous lag. It shows wal_status='lost' and NULL in the lag columns — the audit's live probe row was active=f | lost | NULL | NULL. - sluice's Postgres reporter mapped NULL lag to 0 bytes, and the threshold evaluator never read WALStatus at all. NULL lag → 0% pressure → below every threshold → Cleared=true → an INFO reading “condition cleared.” So the observed sequence on the shipped binary was: page CRITICAL at 85%, keep paging as pressure climbs — then, at the exact tick the slot became irrecoverable, log “condition cleared” and go quiet. Terminal states in monitoring views often present as absent data rather than extreme data, and absent-means-clean defaults invert the alert exactly when it matters most. ## The live differential The v0.99.258 cycle staged it on a throwaway postgres:16 with max_slot_wal_keep_size=1MB: stall the consumer realistically, burn WAL to ~92% of the cap (lag 969,120 bytes, identical on both binaries), take one in-condition retention pressure CRITICAL tick, then burn past the cap and CHECKPOINT so the slot invalidates live. binary at the terminal event --------- ------------------------------------------------------------ v0.99.257 next tick emits `postgres: slot-health condition cleared` with wal_status=lost lag_bytes=0 — the false all-clear — and NO lost page ever (lost=0, cleared=1) v0.99.258 exactly ONE terminal ERROR: `slot INVALIDATED (wal_status=lost) — terminal, re-snapshot required`; ZERO clears; the pre-invalidation retention WARN intact A staging detail worth recording: a plain fast burn on v0.99.257 (clean straight to lost, no sampled in-condition tick) emits nothing at all — reproducing the audit's exact false-clear shape needs the staged burn, one retention tick first. ## The fix's shape — and its two siblings The repair is instructive beyond the one bug. First, dispatch on wal_status before any percentage math: lost pages CRITICAL exactly once and latches — never repeats, never clears — because the state is terminal by definition. But unreserved pages CRITICAL and stays clearable, because Postgres documents that an unreserved slot can recover; latching a recoverable state would re-create the inversion in the other direction (a stale alarm the operator learns to ignore). Truthful alerting cuts both ways. Two siblings shipped in the same pass, both variations on “the net must be truthful at the terminal event”: - A probe that fails silently is a disabled net. A revoked role or killed connection had the health probe logging DEBUG forever while the operator believed the alerting was live. Now five consecutive probe failures escalate to a WARN — “retention/invalidation alerts are blind until it recovers” — once per streak, with a recovery INFO. The cycle proved it live: REVOKE SELECT ON pg_catalog.pg_replication_slots, CDC keeps applying while the probe is blind, the WARN fires at exactly consecutive_failures=5. - An edge-once latch must advance on delivery, not on decision. The schema-drift page's once-per-stall latch advanced before the notification was sent, so one transient sink error (a 502 at the stall moment) permanently swallowed the only page a persistent stall would ever get. The latch now advances only on successful delivery. Plus one-tick hysteresis on threshold downgrades, so a catch-up hovering at the 85% boundary doesn't page every 30 seconds in both directions. ## Reproducing it Throwaway container, ~5 minutes (this is the regression-cycle recipe): docker run -d --name slotpg -e POSTGRES_PASSWORD=x -p 5460:5432 postgres:16 \ -c wal_level=logical -c max_slot_wal_keep_size=1MB -- create a logical slot, attach a consumer (sluice sync or pg_recvlogical), then stall it -- burn WAL past the cap and force recycling: SELECT pg_switch_wal(); CHECKPOINT; -- repeat with junk writes until: SELECT active, wal_status, safe_wal_size FROM pg_replication_slots; -- f | lost | NULL <- the terminal row: status text, NULL numbers Any monitor that computes pressure from the lag/safe_wal_size columns and treats NULL as zero will read that row as 0% — sluice ≤ v0.99.257 logged “condition cleared” on it; ≥ v0.99.258 pages terminal CRITICAL once. To see the false clear specifically, let the monitor sample one in-condition tick before the burn crosses the cap. ## The transferable lesson When a monitored resource dies, its metrics don't spike — they vanish. Check the status column before the arithmetic, and treat absent data as “cannot assert health,” never as zero. Then audit the alert lifecycle at its edges: a terminal state should latch (one page, no clears, no repeats), a documented-recoverable state should not, a probe that can't see must say so, and a once-only notification must not mark itself sent until it was. Every one of those edges defaults to the quiet-and-wrong behavior if unexamined — and the quiet failure of an alerting net is indistinguishable from good news. ## Primary sources - PostgreSQL documentation — pg_replication_slots.wal_status (lost: “this slot can no longer be used”; the lag/safe_wal_size columns go NULL) and max_slot_wal_keep_size. - sluice v0.99.258 changelog — the lost/unreserved dispatch, probe-outage escalation, delivery-gated drift latch, and downgrade hysteresis; the 2026-07-15 audit findings MED-D0-9/10/11. - sluice-testing session report v0.99.258 (F4/F4b) — the staged false-clear differential and the exactly-once terminal page, live on PG16. - Companion field notes — postgres-slot-leaks and postgres-idle-slot-failover (how slots die; this note covers how their death reads as good news to your alerting). =============================================================== # Same document, different winner — SQLite reads the FIRST duplicate JSON key, Postgres jsonb keeps the LAST (https://sluicesync.com/field-notes/same-document-different-winner/) RFC 8259 declines to define what a duplicate object key means, and two mainstream engines quietly picked opposite answers. So "this text column validated as JSON, promote it to jsonb" silently changes which value every future query reads — at exit 0, with the stored bytes looking fine in any spot check. The sharpest edge: the promotion's validator was SQLite's own json_valid, a validator that happily accepts exactly what the target type destroys. Observed — the 2026-07-15 repo audit (MED-D0-3) against sluice's --infer-types jsonb promotion, with the Postgres half verified on a real PG 16.14 and the value flip exhibited live on the shipped v0.99.257 binary by the v0.99.258 regression cycle. Fixed in v0.99.258: a column holding any duplicate-key document is never promoted. Both engine behaviors are documented or by-design — there is nothing to file upstream; the bug was ours, in assuming “valid” meant “preservable.” ## What happened JSON's spec has a deliberate hole: RFC 8259 says object names “SHOULD be unique” and leaves the behavior for duplicates undefined. Every implementation picked something: - SQLite says {"a":1,"a":2} is valid (json_valid(…) returns 1) and json_extract(…, '$.a') returns 1 — the first duplicate wins. - Postgres jsonb parses the same bytes and stores {"a": 2} — the last duplicate wins, documented behavior, verified on PG 16.14 (nested objects included). sluice's --infer-types path (auto-engaged for CSV/TSV/NDJSON sources, which stage through SQLite) offered an innocuous-looking upgrade: if every value in a JSON-hinted text column validates as JSON, promote the target column to jsonb. The validator was SQLite's json_valid. So a document with duplicate keys sailed through validation, landed in jsonb, and Postgres silently rewrote which value the document carries — while the disclosure sluice printed affirmatively described the transform as mere “whitespace/key-order” normalization. Most documents carry no duplicates, so any spot check looks clean; the one that does is precisely the one the promotion corrupts. ## The live differential The v0.99.258 regression cycle ran a CSV with a jsonb-hinted payload column carrying {"a":1,"a":2} (plus a clean settings control column) through --infer-types on both shipped binaries: binary payload column stored value --------- -------------- ------------------------------------------ v0.99.257 jsonb {"a": 2} — the first duplicate silently dropped, rc=0, no signal v0.99.258 text {"a":1,"a":2} byte-exact; the disclosure names the dup-key class and the column; the clean sibling still promotes to jsonb The gate is per-column: one poisoned column doesn't demote the rest. ## Why “is it valid?” was the wrong question The two predicates look interchangeable and aren't: - Is this valid JSON? — what json_valid answers. Duplicate keys: yes. - Does the target's JSON type preserve this document? — what a promotion actually needs. Duplicate keys under jsonb: no. (jsonb is a parsed binary representation; last-wins is the price of key deduplication. Postgres's plain json type, which stores text verbatim, would preserve it — jsonb does not.) Duplicate keys are the one place the same document legally means two different things in two engines: a reader pointed at the SQLite staging copy sees a = 1; a reader pointed at the promoted Postgres column sees a = 2. Nothing errored anywhere. The fix therefore had to be a scan, not a validator swap: sluice now runs a json_tree per-parent duplicate-key aggregate over every nesting depth (escaped key spellings included) and refuses to promote any column holding even one duplicate-key document — it stays text, source bytes intact, and the promotion disclosure names the collapse instead of calling it normalization. ## Reproducing it No sluice required to see the engine disagreement: -- SQLite SELECT json_valid('{"a":1,"a":2}'); -- 1 (valid) SELECT json_extract('{"a":1,"a":2}', '$.a'); -- 1 (FIRST wins) -- Postgres (verified on 16.14) SELECT '{"a":1,"a":2}'::jsonb; -- {"a": 2} (LAST wins) SELECT '{"x":{"a":1,"a":2}}'::jsonb; -- {"x": {"a": 2}} (nested too) And the migration-shaped consequence, with sluice (this is the regression-cycle fixture): cat > docs.csv <<'EOF' id,payload,settings 1,"{""a"":1,""a"":2}","{""ok"":true}" EOF sluice migrate --source-driver csv --source ./docs.csv --csv-header \ --target-driver postgres --target '' # <= v0.99.257: payload promoted to jsonb, PG stores {"a": 2}, rc=0 # >= v0.99.258: payload stays text, byte-exact; settings still promotes; # the disclosure names the duplicate-key column (Column names matter: the promotion only considers JSON-hinted names — settings, metadata, payload, attributes, *_json.) ## The transferable lesson “Is it valid X?” and “does the target's X type preserve it?” are different predicates, and the gap between them is exactly where a format's undefined corners live. When a spec says SHOULD and stays silent on the consequences, every engine's answer is a coin flip you have to look up — and any pipeline that upgrades a value into a parsed representation (jsonb, a binary protobuf, a normalized XML store) needs to scan for the inputs where parsing is lossy, not just the inputs where parsing fails. Validate with the destroyer, and you will bless exactly what it destroys. ## Primary sources - RFC 8259 §4 — object names “SHOULD be unique”; behavior for duplicates is explicitly implementation-defined. - PostgreSQL documentation — jsonb: “duplicate object keys… only the last value is kept.” - SQLite json1 documentation — json_valid and json_extract (first-key behavior observable directly). - sluice v0.99.258 changelog — the duplicate-key non-promotion and the corrected disclosure; the 2026-07-15 audit finding MED-D0-3; sluice-testing session report v0.99.258 (F5, the live value flip). - Companion field notes — mysql-json-where-cast (cross-engine JSON surprises) and int64-json-boundary (another of JSON's unspecified corners biting databases). =============================================================== # Persist a resume cursor as JSON and it silently teleports (https://sluicesync.com/field-notes/json-cursor-teleport/) A resumable keyset walk persists its last-processed PK between runs, and the obvious store is JSON — which quietly rewrites database values on the way through. We saw half the class at design time ([]byte → base64, time.Time → RFC 3339) and normalized it. The half we missed fired live: Go's json.Marshal replaces invalid-UTF-8 bytes with U+FFFD, and numbers ride float64 past 2^53 — so a resumed walk skipped 73,100 of 100,000 rows at exit 0. Resume state is a codec too, and ours had zero coverage. Observed — in two acts. Act one caught at design time while building sluice backfill's resume path (ADR-0159, v0.99.244) and normalized before ship. Act two found by the 2026-07-15 repo audit (CRITICAL-2 and HIGH-1, both confirmed on live MySQL) and then exhibited on the shipped binaries by the v0.99.257 regression cycle with exact magnitudes — the numbers below are from real differential runs. Fixed in v0.99.257 with a typed envelope. The exposure was sluice's own: backfill resume since v0.99.244, and the migrate copy-resume cursor rode the same store. ## Act one: the halves we saw A keyset walk — backfill, chunked copy, verify — persists a cursor between runs: the last-processed primary-key tuple, re-bound into the resume predicate WHERE (pk…) > (cursor…). The obvious store for a heterogeneous tuple is JSON, and Go's encoding/json rewrites two whole type families on the way through: - []byte marshals as base64. A binary PK cursor comes back as its base64 text — a syntactically valid comparison value that lands nowhere near the real key. - time.Time marshals as RFC 3339 (2026-07-14T10:30:00Z). MySQL does not reliably parse the T/Z shape inside a comparison. Both re-bound values are valid — no parse error, no refusal — just a walk that resumes from somewhere other than where it stopped. A misplaced cursor is the worst failure class for resumable work because the loss shows up as position, not as a message. The v0.99.244 design saw this and normalized at the scan boundary: []byte to its raw string form, time.Time to the engine's own comparable literal, pinned by unit tests in both engines. ## Act two: the halves we missed The audit's finding was that the normalization itself handed encoding/json a Go string of raw bytes — and json.Marshal silently replaces every invalid-UTF-8 byte with U+FFFD (EF BF BD). A BINARY PK cursor 0x9F8041FE10 round-trips to 0xEFBFBDEFBFBD41EFBFBD10: 5 bytes become 11, and the mangled value sorts bytewise greater — so on live MySQL the resumed predicate (id) > () skips every PK range between the true cursor and the imposter. BINARY(16) UUID keys are mainstream; the run reports success; --verify is optional; and a following contract step (DROP the old column) makes the loss permanent. The second missed half was the documented one: TableProgress decoded its JSON with a plain decoder — no UseNumber — so every number rode float64. A persisted cursor of 9007199254740995 decoded as …996 (+1: a row skipped forever); a snowflake-magnitude 1750000000000000123 drifted −123 (float64 granularity ≈256 up there), replaying past the documented at-most-one-chunk bound. The regression cycle put exact magnitudes on all of it, running the shipped binaries against each other: shape v0.99.256 (before) v0.99.257 (after) --------------------------- ---------------------------------- ----------------------------- backfill, BINARY(16) PKs resumes its own mangled cursor at coded SLUICE-E-BACKFILL- (every key leading 0x80) rc=0, marks the run complete — CORRUPT-CURSOR, rc=3, names 73,100 of 100,000 rows NEVER --restart + the U+FFFD visited (exactly 100,000 minus fingerprint, writes nothing; the 26,900 persisted as copied) --restart heals, 100,000 exact backfill, PG BIGINT dense cursor 1152921504606879676 rounds envelope cursor; resume exact near 2^60 up +68; EXACTLY the predicted 68 rows skipped, row-for-row migrate --resume, bytea PK its own writer persisted base64 WARN "persisted resume cursor text; resume rc=0, complete, is not trustworthy; truncating 46,000 of 200,000 rows never and re-copying" → 200,000 copied exact, checksum == source The float64 row deserves a second look: the skip window matched the drift arithmetic row-for-row. This class isn't flaky — it's deterministic corruption of position, which is why it survives every rerun. ## The part that stings: we already owned the fix sluice's backup values had been protected from exactly these rewrites since v0.99.159 — Bug 172 introduced a tagged-envelope codec ({"_t":"i64"}-style) precisely because JSON mangles bytes and big integers. The value path also had a year of the family-matrix test discipline: pin every type family, not one representative. The cursor store got neither: it stayed a bare json.Marshal, and its one resume integration pin exercised only INT primary keys. Checkpoint state is a codec with all the same failure modes as the data path — it just corrupts where you are instead of what you have. The v0.99.257 fix applies the owned remedy: cursor slices persist as typed envelopes ({"_t":"i64"|"u64"|"bytes"|"f64"|"time"}); valid-UTF-8 strings, bools, and nulls stay bare, and a string containing U+FFFD is enveloped as bytes so mangled and legitimate stay distinguishable. Legacy bare cursors parse exact-int64-first (including BIGINT UNSIGNED above MaxInt64), so pre-envelope integer cursors keep resuming losslessly — the cycle proved a v0.99.256-written cursor resumes under v0.99.257 with no refusal. Provably-mangled legacy cursors are handled where the PK types are known: backfill refuses with a coded error naming --restart; migrate self-heals through its existing truncate-and-redo disposition. And resume fidelity is now integration-pinned per orderable PK family — large-int, binary, composite, temporal, multibyte string — on real MySQL and Postgres. ## Reproducing it The rewrites are visible in a few lines of Go — no database required: cursor := map[string]any{ "pk": string([]byte{0x9F, 0x80, 0x41, 0xFE, 0x10}), // raw bytes as string "n": int64(9007199254740995), "ts": time.Date(2026, 7, 14, 10, 30, 0, 0, time.UTC), } b, _ := json.Marshal(cursor) fmt.Println(string(b)) // "pk" is now U+FFFD-riddled (5 bytes -> 11), "ts" is a T/Z literal MySQL // won't compare; decode "n" without UseNumber and it comes back ...996 The end-to-end shape, on sluice: create a table with a BINARY(16) PK whose keys lead with 0x80+ bytes, start sluice backfill (or an interrupted migrate) against v0.99.256, kill it mid-walk, and resume: rc=0, “complete,” and every row above the true cursor's mangled ghost untouched — the NULL-visible fixture (--set 'new = old + 1' --where 'new IS NULL') makes the skipped rows countable. The same states under ≥ v0.99.257 refuse coded or self-heal. ## The transferable lesson JSON is a lossy wire format for database values — it base64s or replacement-characters bytes, reformats times, and floats integers — and checkpoint/resume state is where those rewrites do maximum damage, because a corrupted cursor doesn't corrupt a cell you might notice: it relocates the walk, silently, at exit 0. Treat resume state as a codec: give every type family crossing the boundary an explicit representation decision (a tagged envelope beats per-type normalization — our normalization was itself the vector for the missed half), decode integers exactly, and pin the round-trip per PK family on real engines. And when your data path already has a hardened codec and a testing discipline, ask what other stores in the system quietly round-trip the same values without either — ours was the one that decides which rows get processed at all. ## Primary sources - Go encoding/json — invalid UTF-8 in strings is replaced with U+FFFD on marshal; numbers decode as float64 without UseNumber; []byte marshals as base64; time.Time as RFC 3339. - The 2026-07-15 repo audit, CRITICAL-2 and HIGH-1 — both observed on live MySQL 8.0, including the 0x9F8041FE10 → 0xEFBFBDEFBFBD41EFBFBD10 round trip. - sluice v0.99.257 changelog — the tagged cursor envelope, exact-integer-first legacy parse, coded SLUICE-E-BACKFILL-CORRUPT-CURSOR, and the per-PK-family integration pins; ADR-0159 for the act-one normalization. - sluice-testing session report v0.99.257 (F2, 71/71) — the live differential magnitudes: 73,100/100,000, the exact-68-row float64 window, 46,000/200,000 on the migrate path, and the legacy-trust proof. - Companion field note — 253 is a database boundary now: the same family where the damage lands on a value instead of a position. =============================================================== # The two MySQL escapes that keep their backslash (https://sluicesync.com/field-notes/mysql-like-escapes-keep-backslash/) MySQL's string-literal escape table has a trap in its last two rows: \% and \_ do not evaluate to % and _ — they evaluate to the two bytes \% and \_, backslash included. Every other unrecognized escape drops the backslash. A uniform unescaper — which is what almost every hand-rolled MySQL-literal decoder is — silently shortens data containing literal backslash-percent sequences by one byte. Observed — building sluice's mydumper flat-file source engine (ADR-0161, fixed in the shared quoted-string decoder, v0.99.247). The wrong rule was unreachable on sluice's live read paths — MySQL's own literal printing never emits \% or \_ — and became load-bearing the moment arbitrary dump files entered the picture. ## What happened MySQL string literals have a short table of named escapes (\0, \b, \n, \r, \t, \Z, \\, \', \") and a general rule for everything else: an unrecognized \x evaluates to x — the backslash is dropped. Nearly every hand-rolled decoder implements the table plus the general rule and stops. The actual grammar has two more rows. \% evaluates to the two bytes \% and \_ to \_ — backslash kept. They're LIKE-pattern escapes: preserved so that a pattern-matching wildcard escape survives the trip through a string literal into a LIKE context. Outside any LIKE, in plain data, the parser still applies that rule — so a stored value containing a literal backslash-percent must be written as '\%' in a dump, and must decode back to backslash-percent, not percent. A decoder applying the uniform drop-the-backslash rule to those two sequences silently emits one byte fewer. No error, no warning — the string is simply shorter, and almost-right: \%discount\% becomes %discount%, plausible enough to pass any casual inspection. ## Why it stayed hidden, and where it bit The correctness oracle for reading a dump file is precise: decode exactly the bytes MySQL's parser would store if myloader replayed this literal. sluice's shared quoted-string decoder predated the dump reader and carried the uniform rule — harmlessly, because its inputs were MySQL's own literal output (information_schema defaults and the like), and MySQL's literal printer never produces \% or \_. Dump files broke that assumption: a dump is arbitrary literals headed for MySQL's grammar, covering the entire escape surface, including the two rows the uniform rule gets wrong. The fix landed in the shared decoder, with pins for both sequences in every quoting shape the dump family produces. ## Reproducing it mysql> CREATE TABLE t (s VARCHAR(20)); mysql> INSERT INTO t VALUES ('50\%'); -- literal evaluates to: 50\% mysql> SELECT s, LENGTH(s) FROM t; -- 50\% 4 <- backslash kept: four bytes, not three Now dump the table (mydumper or mysqldump both re-emit the value as the literal '50\%') and run the dump through any decoder that applies the uniform drop-the-backslash rule to unknown escapes — it emits 50%, three bytes, one silently gone. The diff oracle is the server itself: replay the dump into MySQL (myloader/mysql client), SELECT LENGTH(s), and compare against your decoder's output length; MySQL says 4, the naive unescaper says 3. ## The transferable lesson When you implement a decoder for someone else's literal grammar, the exceptions to the general rule are the entire job — the general rule is the easy 95% that every implementation gets right, and the two rows that contradict it are where the silent byte-level corruption lives. Read the grammar's table to the end, and test the decoder against the oracle that matters: not “does it look right,” but “does it produce exactly the bytes the original parser would store.” One-character mechanism, silent-corruption consequence — the cheapest class of bug to prevent and among the hardest to notice after the fact. ## Primary sources - MySQL Reference Manual — String Literals: the escape-sequence table, including \% and \_ evaluating to \% and \_ (“used to search for literal instances of % and _ in pattern-matching contexts”), and the drop-the-backslash rule for other unrecognized escapes. - sluice ADR-0161 §4 — the shared quoted-string decoder and the keep-the-backslash fix, pinned across the dump family's quoting shapes. =============================================================== # mydumper chunk numbers are PK ranges, not a sequence (https://sluicesync.com/field-notes/mydumper-chunk-numbers-pk-ranges/) Every consumer's instinct says numbered chunk files — table.00000.sql, 00001, … — form a sequence, so a missing file is detectable as a gap. Ground truth from real mydumper: the numbers are derived from primary-key ranges. Healthy dumps have gaps, -r dumps start at 00001, and a deleted trailing chunk leaves no gap at all — so contiguity is neither necessary nor sufficient. Meanwhile a deleted middle chunk streams silently short at exit 0, and the loss detector the format actually ships is the metadata everyone skips as informational. Observed — the 2026-07-15 repo audit (MED-D0-2: a dump with chunks 00000+00002 streamed 2 of 3 rows at exit 0, zero WARNs, and verify re-counted the same directory) plus a ground-truth probe against real mydumper v1.0.3 in Docker during the fix work. The silent short-stream was sluice's own gap — the reader explicitly dropped the dump's -metadata/-checksum companions as “informational only.” Two detection nets shipped in v0.99.258 and were proven both-sides by the regression cycle (numbers below). mydumper's numbering itself is by design; nothing to file upstream. ## What happened Delete one data chunk from the middle of a mydumper dump and restore it. What should happen: an error, or at least a warning. What did happen through sluice v0.99.257: the remaining chunks stream, every row in them lands, exit 0, no signal anywhere. The regression cycle's fixture — three chunks, 15 rows total, rm the middle one — landed 10 rows with zero WARNs. A verifier re-scanning the same directory confirms the short count. Chunk-file-level loss is maximally quiet because each chunk is a complete, well-formed SQL file; nothing about the survivors reveals the absence. The obvious net is a contiguity check on the chunk numbers. That's where the ground-truth probe earned its keep, because the obvious net is wrong: - Chunk numbers are derived from PK ranges, not a counter. A table with sparse primary keys legitimately dumped as 00001–00003 plus 450001–450003 on real v1.0.3 — observed once, on one probe, but it only takes one healthy dump with gaps to make a contiguity refusal reject good data. - Dumps taken with -r (rows-per-chunk) start at 00001, not 00000. - A deleted trailing chunk leaves no gap at all — the numbering just ends earlier. So contiguity is neither necessary (healthy dumps have gaps) nor sufficient (trailing loss shows no gap). An alarm built on the assumed semantics would have both false-alarmed and under-detected. ## The net was inside the dump the whole time mydumper records per-table row counts in its own metadata — rows = N entries in the modern dump-wide ini form, bare-integer -metadata companion files on older versions. The probe found the modern counts exact on v1.0.3. That's the free cross-check that catches chunk-level loss regardless of numbering: count what you streamed, compare with what the producer said it wrote. sluice's reader had been explicitly discarding those companions as “informational only” — forfeiting the only loss detector the format ships. v0.99.258 added both nets, deliberately as WARNs rather than refusals (cross-producer metadata fidelity is unverified, and gaps can be legitimate): a non-contiguous chunk-number WARN at open naming the gap, and the decisive post-stream row-count tripwire on both the bulk-copy and verify-count doors. The regression cycle's differential, same deleted-middle-chunk fixture: binary outcome --------- ---------------------------------------------------------- v0.99.257 rc=0, 10 of 15 rows, ZERO warns — the silent short-stream v0.99.258 rc=0, same 10 rows, BOTH nets fire: the contiguity WARN (gap_after_chunk=0 next_chunk=2) and the count WARN naming metadata_rows=15 chunk_rows=10 One honest residual, stated because it's exactly the class this note is about: pscale-dump (the PlanetScale producer of the same format family) writes empty metadata companions, which sluice ignores leniently — so on that producer, a deleted trailing chunk leaves neither a gap nor a count mismatch. The net is only as good as the producer's metadata, per producer. ## Reproducing it Real mydumper via Docker, any MySQL: docker run --rm --network host mydumper/mydumper:v1.0.3-1 \ mydumper -h 127.0.0.1 -u root -p secret -B mydb -o /dump -r 5 # small -r forces multiple chunks ls dump/mydb.t.*.sql # note: -r dumps start at 00001; sparse-PK tables can jump grep -A2 '\[`mydb`.`t`\]' dump/metadata # the dump's own per-table "rows = N" rm dump/mydb.t.00002.sql # delete a middle chunk sluice migrate --source-driver=mydumper --source ./dump --target-driver=mysql --target '' # <= v0.99.257: rc=0, silently short, no signal # >= v0.99.258: rc=0 + the gap WARN + "metadata records a different row count" naming both numbers To see the numbering semantics directly: dump a table whose PKs live in two distant bands (say 1–1000 and 450000–451000) and observe the chunk numbers jump with the keyspace. ## The transferable lesson Two lessons stacked. First: an identifier derived from data is not a sequence — before you alarm on a producer's numbering, verify what the numbers mean, on the real producer, or your check will reject healthy inputs and miss unhealthy ones. (The verification cost here was one Docker probe; the alternative was a contiguity refusal that false-alarms on every sparse-PK dump.) Second, the sharper one: the metadata your reader skips as “informational” may be the only loss detector the format ships. A dump format's row counts, checksums, and manifests exist because the producer knew exactly what it wrote — a consumer that discards them is choosing to verify nothing against the one party with ground truth. ## Primary sources - mydumper documentation and observed v1.0.3 behavior — chunk numbering (PK-range-derived; -r starts at 00001; sparse keyspaces produce non-contiguous numbers, observed once on the fix's Docker probe) and the metadata rows = entries (exact on v1.0.3). - sluice v0.99.258 changelog — the gap WARN + row-count tripwire, including the ground-truth note (“chunk numbers are PK-range-derived, so gaps can be legitimate — the row-count tripwire is the real net”); the 2026-07-15 audit finding MED-D0-2. - sluice-testing session report v0.99.258 (F3) — the deleted-middle-chunk differential live on both binaries. - Companion field notes — mydumper-format-family (producer forks in the same format, including pscale-dump's empty metadata) and verifier-rode-the-same-reader (statement-level skips through the same reader). =============================================================== # Your dump already rounded your floats (https://sluicesync.com/field-notes/mydumper-float-display-rounding/) mydumper renders single-precision FLOAT through mysqld's ~6-significant-digit float-to-text formatter: 8388608 lands in the dump file as 8.38861e6, which parses back to a different float32 — while DOUBLE columns in the very same run dump at full round-trip precision. The loss is in the file, at dump time. Restore it, archive it, trust it: the low bits are already gone. Observed — building sluice's mydumper flat-file source engine (ADR-0161, v0.99.247), caught by the real-dump oracle: comparing rows read from an actual mydumper v1.0.3 dump against the same table read live. Every value matched except single-precision FLOATs. ## What happened We built a reader for mydumper-format dump directories, and its central test is an equivalence oracle: dump a corpus with real mydumper, read the dump with sluice, migrate the same source live with sluice's MySQL engine, and compare row by row. The oracle flagged FLOAT. A FLOAT column holding 8388608 (223 — a value a float32 represents exactly) appears in the .sql chunk as 8.38861e6. Parse that back and you get 8388610, a different float32 — float32 spacing at 223 is 1.0, so the six-digit rendering steps to a neighboring representable value. Meanwhile, in the same run, the same dump, DOUBLE columns holding 3.141592653589793, 0.1, and 1.7976931348623157e308 all dump at full shortest-round-trip precision. That FLOAT/DOUBLE split was proven, not assumed — the corpus carries beyond-6-digit DOUBLE values precisely so a regression in the sibling family would be caught. The split is the trap. The natural spot-check for “does my dump preserve float precision?” is to eyeball a DOUBLE column — doubles are where people expect precision to live — and the DOUBLE evidence says full precision. The FLOAT columns, silently, are already rounded in the bytes on disk, with no warning anywhere in the toolchain: not from mydumper, not from myloader on the way back in, not from the server. ## Why (the mechanism) mydumper reads with a bare SELECT, which means values arrive as text formatted by mysqld's float-to-text path — and MySQL's display conversion for single-precision FLOAT renders roughly six significant digits (a very old server-side behavior; MySQL Bug #43262 is the long-lived upstream thread), while DOUBLE gets a full-precision rendering. So the divergence isn't in mydumper's own code; it inherits the server's formatter, and the dump faithfully records the formatter's output rather than the column's value. We've written about this exact formatter class before, live: Vitess's VStream COPY phase delivers FLOATs through the same server-side text conversion, rounded, while its binlog phase delivers exact bits — same value, two precisions, depending on the phase. This note is the same class at rest: one of the most widely used MySQL logical-dump tools, writing the rounded rendering into the archive itself. As of this writing we have not filed it upstream with mydumper; the mechanism sits in the server's formatter, and the sibling Vitess report is pending the same filing decision. ## Reproducing it Any MySQL plus the mydumper container (v1.0.3 is what we ground-truthed): mysql> CREATE TABLE f (v FLOAT, d DOUBLE); mysql> INSERT INTO f VALUES (8388608, 3.141592653589793); $ docker run --rm -v $PWD/dump:/dump mydumper/mydumper \ mydumper -h -u -p -B testdb -o /dump $ grep -o '([^)]*)' dump/testdb.f.00000.sql (8.38861e6,3.141592653589793) -- ^ FLOAT: six significant digits — parses back to 8388610, a -- different float32 ^ DOUBLE: full precision, same run The side-by-side is the point: check only the DOUBLE column and the dump looks lossless. 8388608 is 223, chosen because float32 spacing there is exactly 1.0 — the re-parsed 8388610 is provably a different value, not a rendering nicety. ## What sluice does about it A reader cannot re-read precision the file never contained, so sluice does the only honest thing: on a mydumper-format source, it WARNs once per table naming the FLOAT columns and pointing at the remedy — migrate that table from the live server (where sluice's reader fetches exact bits) rather than from the dump. DOUBLEs are read from the dump at full fidelity. The WARN is pinned in tests so it can't silently vanish. ## The transferable lesson A logical backup is only as faithful as the value-to-text formatter that produced it, and FLOAT is the family that formatter rounds on MySQL. If your archival or migration path runs through a SQL-text dump, audit the single-precision columns specifically — checking DOUBLE tells you nothing about FLOAT, because the two families take different formatting paths in the same run. And if you build tooling on dumps: when the file provably can't carry the fidelity you promise, warn on the family and name the columns; silence here is a rounded archive that someone will trust years from now. ## Primary sources - MySQL Bug #43262 — FLOAT displayed at reduced precision through the server's float-to-text conversion (the upstream root of the class). - mydumper — reads via SELECT, so dumped values are the server formatter's text output (v1.0.3 ground-truthed). - Companion field note — Vitess copy phase rounds your FLOATs: the same formatter class, streaming instead of at rest. - sluice ADR-0161 §4 — the named FLOAT display-rounding wart, the per-table WARN, and the DOUBLE-proven-unaffected oracle. =============================================================== # "mydumper format" is a family, not a spec (https://sluicesync.com/field-notes/mydumper-format-family/) pscale database dump produces "mydumper format" — same metadata file, same schema files, same ~1 MB extended-INSERT chunks, byte-compatible enough that one reader serves both. The shared layout hides three producer forks: binary travels differently, string quoting differs, and TIMESTAMP semantics hinge on a header one producer always writes and the other never does. Observed — building and validating sluice's mydumper-family source engine (ADR-0161, v0.99.247): real mydumper v1.0.3 dumps (including a probe against a +08:00 server), a survey of the planetscale/cli dumper source, and a live pscale database dump of a seeded corpus verified byte-identical end to end against the live server (2026-07-15). ## What happened There is no mydumper spec — there is mydumper's output, and there are other tools that produce “the same format.” PlanetScale's pscale database dump is the important sibling: same directory layout, same file naming, same statement shapes. One reader can serve both, and ours does. But calibrating that reader surfaced three places where “the same format” quietly forks by producer. Fork one: binary encoding. Vanilla mydumper can emit binary columns as hex-blob literals (0x…) — unambiguous, escape-free. The pscale writer has no hex path at all: every BLOB byte rides on backslash-escape fidelity through a quoted string. A reader tested only against hex-blob dumps has never exercised the code that pscale dumps depend on entirely — and escape decoding is exactly where the subtle bugs live (see our companion note on the two MySQL escapes that keep their backslash). Same format on the label; disjoint fidelity paths underneath. Fork two: string quoting. mydumper emits single-quoted string literals; the pscale writer double-quotes, with backslash escapes for quotes. Both are valid MySQL literal spellings (barring ANSI_QUOTES servers), and a reader must decode both — we found this fork only by probing a real pscale dump. Fork three, the one with instant-shift stakes: TIMESTAMP semantics are per-chunk, not per-format. mydumper v1.0.3 unconditionally converts TIMESTAMPs to UTC and stamps every file with /*!40103 SET TIME_ZONE='+00:00' */ — probed against a +08:00 server to be sure. The format, however, merely permits that header. The pscale dumper emits no TIME_ZONE header anywhere — no SET statements at all, and its metadata file is literally empty, zero bytes. A chunk with no header carrying server-local instants is byte-indistinguishable from one carrying UTC instants: the same digits, a silent hours-wide shift, wearing the same file extension. For real pscale dumps the story ends well: PlanetScale sessions are UTC-pinned, so the header-less chunks do carry UTC and an assume-UTC reader is correct — our end-to-end probe (dump → sluice → MySQL, all 18 corpus columns, five rows, byte-identical against the live oracle, including the escape and binary edge cases) confirms it. But that correctness is a fact about one producer's server configuration, not about the format. ## What sluice does about it Trust only what the file declares. The reader decodes both binary shapes and both quoting shapes through one decoder pinned against both producers. A TIME_ZONE header other than UTC refuses loudly — in every spelling MySQL accepts (SESSION/GLOBAL/LOCAL, @@time_zone, @@session.time_zone), so no qualified form slips the gate. And a table with TIMESTAMP columns whose chunks declared no time zone gets a once-per-table WARN naming the columns: on a pscale dump that WARN is the normal case and the assumption is right; on an unknown producer's dump it's the only signal the operator will ever get that an instant shift is possible. (The family forks on the consumer side too: the vendor's own restore tool executes statements verbatim, silently skips compressed and csv/json data files, and hard-errors on real mydumper output — a story for another note.) ## Reproducing it The producer forks are directly observable by diffing the two tools' output over the same table (mydumper: any MySQL + the mydumper/mydumper image; pscale dump: a PlanetScale account): $ mydumper -B db -o ./md-dump # vanilla mydumper v1.0.3 $ pscale database dump --output ./ps-dump $ grep -r "TIME_ZONE" md-dump/ | head -1 md-dump/db.t.00000.sql:/*!40103 SET TIME_ZONE='+00:00' */; $ grep -rc "TIME_ZONE" ps-dump/ # 0 matches, every file $ wc -c ps-dump/metadata # 0 bytes — literally empty $ grep -o "VALUES.*" md-dump/db.t.00000.sql | head -1 # single-quoted strings $ grep -o "VALUES.*" ps-dump/db.t.00001.sql | head -1 # double-quoted strings For the TZ stakes specifically, run mydumper against a server with time_zone set to +08:00 and confirm the dumped TIMESTAMP values converted to UTC under the stamped header — that's the probe that established the flagship's behavior the header-less sibling can't declare. ## The transferable lesson A dump format defined by a flagship tool's output forks per producer, in exactly the places the layout doesn't show: binary encoding, literal quoting, and session-dependent semantics like time zones. A reader that generalizes from one producer's dumps has tested a sibling format, not the family. Calibrate against every producer you claim to read, with real output from each; refuse loudly what a file declares and you can't honor; and where the file declares nothing, warn rather than silently inherit the flagship's behavior — the absence of a header is information about the producer, not permission to assume. ## Primary sources - mydumper — output layout and the SET TIME_ZONE='+00:00' header (v1.0.3, ground-truthed including a non-UTC server probe). - planetscale/cli — internal/dumper (the writer surveyed: escape-only binary, double-quoted strings, no SET headers, empty metadata). - sluice ADR-0161 — the mydumper-family source engine: both-shape binary decode, the all-spellings TIME_ZONE gate, and the missing-header WARN. =============================================================== # CSV has no NULL — and in a one-column file, the "blank line" you skip was a NULL row (https://sluicesync.com/field-notes/csv-has-no-null/) RFC 4180 defines quoting, delimiters, and line endings, and says nothing about NULL. NULL-vs-empty is pure producer convention riding on the quoted/unquoted distinction — which Go's encoding/csv collapses. And at exactly one column wide, the universal skip-blank-lines convention is byte-indistinguishable from a legitimate record whose only field is empty. Observed — building sluice's CSV/TSV source drivers (ADR-0163, v0.99.250). The one-column silent row drop was caught by the pre-land value-fidelity review and was never in any published version — a caught-before-ship story, which is part of the point. ## What happened CSV's spec has a hole where NULL should be. RFC 4180 never mentions it, so every producer invents a convention, and the most important one — Postgres COPY … CSV — encodes the distinction in quoting: NULL is written as an unquoted empty field, empty string as "". Which means the quoted/unquoted distinction is load-bearing data. Go's encoding/csv, like many standard-library parsers, erases it: a,,b and a,"",b come back as the same record. A reader built on it literally cannot implement the COPY convention, no matter how careful the code above it is. That alone forced sluice's flat-file driver to carry its own strict RFC 4180 lexer. The sharper edge showed up in review. sluice's contract is that NULL representation must be declared, never sniffed: --csv-null='' adopts the COPY convention, --csv-null='\N' declares that literal, and with no declaration an unquoted empty field refuses loudly rather than guessing. The pre-land review asked what happens in a one-column file — and found that the near-universal skip-blank-lines convention (which encoding/csv also bakes in) fired first. In a one-column CSV, a legitimate record whose single field is empty is a blank line, byte for byte. The skip consumed it before any NULL logic ran: under --csv-null='' a NULL row was silently dropped, and with no declaration the promised ambiguity refusal was silently bypassed. Exit 0, one row short per NULL. ## Why (the mechanism) Three facts stack: - The format has no NULL, so NULL semantics live in producer convention — and the dominant convention hangs on quoting. - A widely used parser layer collapses the quoting distinction, so the convention is unimplementable on top of it. - “Skip blank lines” is safe at every record width except one. At width ≥ 2 a blank line can't be a record (it would be ragged). At width 1, an empty line is exactly a one-empty-field record, and skipping it is a silent row drop. The fix keys blank-line handling on the established record width: at width 1, an empty line is a record and flows through the declared NULL contract like any other field (a blank line before the first record, when width is not yet established, is still skipped). Everything else in the contract stays strict: a quoted field is always data ("NULL" is a four-character string), the representation must be declared by the operator, and an undeclared ambiguity is a coded refusal naming the record and column. ## Where the class generalizes The same bare-token-versus-quoted-string line shows up wherever a text format smuggles typing through quoting: in SQL dumps, NULL the keyword is SQL NULL while 'NULL' is a string, and sluice's dump reader draws exactly the same line. Any layer that normalizes the two spellings — a parser, a pretty-printer, a well-meaning cleanup script — destroys the only bit that distinguishes absence from empty. ## Reproducing it The whole class fits in a five-byte-wide file. Save this as one.csv — a one-column file: header, a row, a blank line, a row (under the COPY convention that blank line is a NULL row, not filler): a 1 2 Then run the flag matrix against sluice ≥ v0.99.250 (any target): sluice migrate --source-driver csv --source ./one.csv --csv-header --csv-null='' ... # 3 rows land: '1', NULL, '2' — the blank line is a record sluice migrate --source-driver csv --source ./one.csv --csv-header ... # refused: SLUICE-E-CSV-NULL-AMBIGUOUS naming the record — no declaration, no guess sluice migrate --source-driver csv --source ./one.csv --csv-header --csv-null='\N' ... # 3 rows land: '1', '' (empty string), '2' — declared repr resolves the ambiguity A reader built on Go's encoding/csv cannot pass the first case: it both collapses a,"",b into a,,b at width > 1 and skips the blank line here before any NULL logic runs — the row silently vanishes. ## The transferable lesson Two lessons, one per half. First: when a format leaves a semantic undefined, make the operator declare it — sniffing NULL conventions from data is guessing with confidence, and the honest posture for an undeclared ambiguity is a loud refusal. Second: every “obviously skippable” input shape deserves the question at what record width does this stop being skippable? Degenerate widths — one column, zero rows, a single field — are where whitespace conventions and record semantics collide, and the collision is silent precisely because both interpretations are byte-identical. ## Primary sources - RFC 4180 — Common Format and MIME Type for CSV Files (note the absence: no NULL representation). - PostgreSQL documentation — COPY … CSV: NULL as unquoted empty by default, quoted empty as empty string. - Go encoding/csv — quoted and unquoted fields are returned identically; blank lines are skipped. - sluice ADR-0163 — the flat-file CSV/TSV/NDJSON source drivers; the declared-never-sniffed NULL contract and the width-1 finding. =============================================================== # The Parquet library nulled every false — and the round-trip test couldn't see it (https://sluicesync.com/field-notes/parquet-zero-value-null/) Hand parquet-go rows as map[string]any and it decides NULL-vs-present for optional columns by asking whether the Go value is the zero value — so false, 0, -0.0, "", the epoch, and midnight all silently export as NULL. The sharper half is why nobody notices: a Parquet NULL's accessors read back as exactly the Go zero value, so a naive write-then-read test writes false, reads false, and goes green while the file says NULL. Observed — building sluice's backup export-as-parquet surface (ADR-0164, v0.99.251), against parquet-go/parquet-go v0.30.1. Caught at implementation and independently re-verified by the pre-land value-fidelity review; the silent-null shape was never in any published version of sluice. ## What happened sluice's Parquet export feeds rows to parquet-go as map[string]any — the natural shape when your rows are dynamically typed. parquet-go's map-row deconstruction has to decide, for each optional column, whether the entry is present or NULL, and it decides with reflect.Value.IsZero: a Go zero value in an optional column is treated as parquet NULL. That single inference nulls an entire family of perfectly legitimate SQL values: boolean false, integer 0, float 0.0 and -0.0, the empty string, the zero time (and with it any epoch instant or midnight time-of-day that encodes to it), day-zero dates, an unscaled-zero decimal. Every one of them is a real, present value in the source database. Every one of them would have exported as NULL. ## Why the obvious test is blind The reason this class survives testing is structural, not sloppiness. In Parquet, a NULL value's accessors return the type's default — which in Go-shaped terms is exactly the zero value. So the naive round-trip oracle: write false -> read back -> got false -> green passes perfectly while the file durably says NULL. The test is blind at precisely the values the bug eats, because at the accessor layer NULL and zero are indistinguishable. The only honest oracle reads below the accessor layer, where presence is first-class — parquet-go's raw column values expose value.IsNull(), and any downstream engine (DuckDB, Spark) will show you the NULL too. It's the columnar cousin of a discipline we learned the hard way on database targets: ground-truth on the layer where the two outcomes actually differ, not on a layer that collapses them. ## What sluice does about it The fix is one named wart at one chokepoint: boxLeafValue wraps every scalar leaf value in a pointer before it enters the writer. A non-nil pointer is never “zero,” so the presence signal becomes what SQL semantics demand — nil means NULL, everything else means the value. (List elements are unaffected; parquet-go's repeated path doesn't zero-collapse, and that's pinned too.) Two kinds of test keep it honest. First, IsNull()==false pins on every zero-shaped value in every type family, asserted on the raw column values, at both the unit level and against a live-Postgres integration corpus. Second — and this is the part we'd argue for anywhere — a tripwire that proves the wart is real in the pinned library version: it deliberately bypasses the boxing, writes a bare false, and asserts it comes back NULL. If a future parquet-go upgrade changes the upstream semantics, that test fails loudly and says so, instead of leaving boxLeafValue behind as cargo-cult defensive code nobody remembers the reason for. Assert your workaround's necessity, not just its effect. As of this writing the behavior is unfiled upstream with parquet-go (pinned v0.30.1). It may well be intended map-row semantics — at this API level there is no presence opt-out akin to omitempty — which is a design question for upstream, but the trap for integrators is real either way. ## Reproducing it Minimal Go program against parquet-go v0.30.1 (adapted from sluice's tripwire pin), then let DuckDB be the below-the-accessors oracle: package main import ( "os" "github.com/parquet-go/parquet-go" ) func main() { schema := parquet.NewSchema("row", parquet.Group{ "v": parquet.Optional(parquet.Leaf(parquet.BooleanType)), }) f, _ := os.Create("out.parquet") w := parquet.NewGenericWriter[map[string]any](f, schema) w.Write([]map[string]any{{"v": false}}) // a bare Go zero value w.Close() f.Close() } $ duckdb -c "SELECT v, v IS NULL FROM 'out.parquet'" -- v = NULL, IS NULL = true <- the false was exported as NULL Read the same file back through parquet-go's row accessors and you get false — green to any naive round-trip. Boxing the value as a pointer (map[string]any{"v": &b}) exports it present. (DuckDB also confirms the fix: v = false, IS NULL = false.) ## The same export refused another silent-loss shape A supporting beat from the same chunk: Postgres does not declare array dimensionality in the type system — int[] and int[][] are one and the same column type — so a Parquet schema derived from the catalog can only ever say LIST. When a multi-dimensional value shows up at export time, the faithful options are refuse or flatten, and flatten is exactly the silent dimensionality collapse we've been burned by before (a numeric[][] that quietly became one-dimensional through a different codec). The export refuses loudly. If your schema-derivation step can't know the shape, the value-encoding step must not guess it. (A cheerful aside for the analytics-minded: sluice's backup chunks are JSON-Lines under gzip, which DuckDB reads directly via read_json_auto — the zero-export path. The Parquet surface exists for when you want columnar files; the wart above is what it cost to make them faithful.) ## The transferable lesson A serialization library that infers presence from value shape makes every zero-shaped value a silent-loss candidate — false, 0, "", and the epoch are data, not absence, and any bridge from SQL to such a library needs an explicit presence signal (a pointer, an option type, a validity bit) rather than trusting the value to speak for itself. And test it at the layer where NULL and zero are distinguishable: round-trip tests that read through accessors will wave this entire class through, green, forever. ## Primary sources - parquet-go — GenericWriter over map[string]any rows and the raw column-value API (Value.IsNull) used as the honest oracle. - Apache Parquet format — definition levels: presence in an optional column is first-class metadata, distinct from any default value. - sluice ADR-0164 — backup export-as-parquet; “The zero-value-as-null wart (named, pinned)”. =============================================================== # The parent table that returns rows it doesn't own (https://sluicesync.com/field-notes/inherits-rows-it-doesnt-own/) Old-style Postgres inheritance presents parent and children to information_schema as ordinary, unrelated BASE TABLEs — while a SELECT on the parent, without ONLY, also returns every child's rows. The standard migration recipe (enumerate BASE TABLEs, copy each) therefore lands the child data twice: flattened into the parent's target table and again in each child's. Silently, exit 0; the only symptom is a row count that's too big. Observed — a preflight-gap review comparing sluice's Postgres census against a vendor discovery tool's (roadmap item 68, 2026-07-15; refused loudly since v0.99.253), then proven live by the post-release regression cycle in a before/after differential on the last unguarded release — the numbers are below. This one is a confession as much as a note: the silent-duplication window was every prior sluice release with a Postgres source. The declarative-partition twin of the same trap was guarded back in v0.92.0; the legacy twin never was, and our own changelog now advises anyone who migrated an INHERITS hierarchy through v0.99.252 to verify their target for duplicated child rows. ## What happened Postgres has two table-hierarchy mechanisms. Declarative partitioning (PARTITION BY) is the modern one; old-style inheritance (CREATE TABLE child () INHERITS (parent)) is the pre-PG-10 ancestor, still fully supported and still in the wild. Both share the query-time behavior that matters here: reading the parent without the ONLY keyword returns the children's rows too. Where they differ is catalog visibility, and that difference decided which trap got caught four months before the other. A declarative partition parent announces itself — it has a row in pg_partitioned_table, its relkind is 'p', and information_schema shows the children oddly enough to make you look. We hit that in v0.92.0 (Bug 100), saw the silent flatten-plus-duplicate shape, and shipped a loud refusal. An INHERITS parent announces nothing: parent and children all present to information_schema.tables as plain BASE TABLEs, relkind 'r', indistinguishable from any other tables. The only catalog signal that a hierarchy exists at all is a row in pg_inherits whose parent has relkind 'r' — a system catalog the standard enumeration recipe never consults. So the recipe every migration tool starts from — SELECT … FROM information_schema.tables WHERE table_type = 'BASE TABLE', copy each — does exactly the wrong thing: it copies each child as its own table, and it copies the parent with a SELECT that, lacking ONLY, sweeps in every child's rows again. The ground truth is compact enough to pin in one pair of queries (this is the real-PG assertion in our integration suite): -- one row inserted into the child, zero into the parent: SELECT count(*) FROM measurements; -- 1 (parent SELECT sees the child's row) SELECT count(*) FROM ONLY measurements; -- 0 (the parent owns nothing) An unguarded migration copies that row twice — once into the parent's target table, once into the child's. ## The live differential The claim above started as a code-read plus that integration pin. The post-release regression cycle then ran the whole shape live, same source both times: a parent with 3 rows of its own, child1 () INHERITS (parent) with 4 rows, child2 with 2 — so SELECT count(*) FROM parent reads 9 and FROM ONLY parent reads 3. binary exit target parent duplication --------- ---- ------------- -------------------------------------- v0.99.252 rc=0 9 rows child1's 4 ids present TWICE on the (3 own + all target (in parent AND in child1's own 6 child rows) table); no warning, no signal anywhere v0.99.253 rc=1 refused pre-DDL: target database has 0 tables; (nothing refusal names the parent, the double- landed) land mechanism, all three recovery paths The unguarded run is the silent-loss shape in full: exit 0, every table “migrated,” and the only tell is that the parent's target count is 9 where the source's ONLY count is 3. The guarded run refuses before any DDL, so a refused migration leaves the target untouched rather than half-built. The same cycle also proved the recovery path (--exclude-table on the parent proceeds, child checksums source==target) and that the two hierarchy probes don't cross-fire: a declarative partition parent still refuses via the original Bug-100 wording with no INHERITS text — relkind 'r' and 'p' stay disjoint live, not just in the catalog query. ## The second beat: the same filter hides tables entirely The same table_type = 'BASE TABLE' filter has an inverse failure. FDW foreign tables are relkind 'f' — not BASE TABLEs — so they never enter the enumeration at all. That skip is arguably correct (a foreign table holds no local rows; materializing another server's data would be its own surprise), but it was silent: an FDW-fronted table simply vanished from the migration with no signal that data the application sees wasn't coming along. The census for those lives in pg_foreign_table joined to pg_foreign_server — again, system catalogs, not information_schema. Put together: for migration purposes, information_schema.tables both double-counts (inheritance parents return rows they don't own) and under-counts (foreign tables don't appear at all). The portable view is a flattened projection of a catalog whose relkind distinctions are exactly the ones a copy tool needs. This beat got its live differential too: on v0.99.252 a foreign table vanished from the run with no signal at all; on v0.99.253 the WARN names both the table and the foreign server its data actually lives on, the data outcome is identical, and excluding the table acknowledges and silences it. ## What sluice does about it Since v0.99.253, two disjoint relkind-aware probes. Inheritance parents (pg_inherits parents with relkind 'r' — deliberately disjoint from Bug 100's relkind 'p' probe) are a loud refusal at migrate and sync cold start, not a WARN: both outcomes of proceeding — duplication if parent and children are in scope, quiet hierarchy loss if not — are data-shape corruption an operator would only discover by counting rows. Foreign tables get a WARN naming each skipped table and the foreign server its data actually lives on; the skip itself stays, because the wart was the silence, not the skip. Both are filter-aware — excluding the offending tables acknowledges and silences them. The refusal's recovery paths carry one more trap worth spelling out. The obvious fix — exclude the parent, copy the children individually — is only safe if the parent stores no rows of its own, so the refusal tells the operator to check SELECT count(*) FROM ONLY parent first: a non-zero count means the parent's own rows would silently vanish from the migration instead. The guard against double-counting must not become an instrument of under-counting. ## Reproducing it Any Postgres, no special setup — this is the exact fixture the regression cycle ran: CREATE TABLE parent (id int PRIMARY KEY, note text); CREATE TABLE child1 () INHERITS (parent); CREATE TABLE child2 () INHERITS (parent); INSERT INTO parent VALUES (1,'p'),(2,'p'),(3,'p'); INSERT INTO child1 VALUES (11,'c1'),(12,'c1'),(13,'c1'),(14,'c1'); INSERT INTO child2 VALUES (21,'c2'),(22,'c2'); SELECT count(*) FROM parent; -- 9 (children included) SELECT count(*) FROM ONLY parent; -- 3 (what the parent owns) Now migrate with any tool that enumerates information_schema BASE TABLEs and copies each — sluice ≤ v0.99.252 (sluice migrate --source 'postgres://...' --target ...) exhibits it: exit 0, target parent has 9 rows, and child1's four ids exist twice on the target (SELECT count(*) FROM parent p JOIN child1 c USING (id) on the target = 4). sluice ≥ v0.99.253 refuses rc=1 before any DDL. The ONLY-count check above is also the guard to run before taking the exclude-the-parent recovery path. ## The transferable lesson If you enumerate Postgres tables for any copy-shaped purpose, information_schema is not enough: consult pg_inherits and relkind, or you will double-count inheritance hierarchies and never see foreign tables at all. The meta-lesson is about catalog signals and time-to-detection: we caught the declarative twin of this bug four months earlier not because it was worse but because its catalog signal exists — the trap with no signal is the one that ships. When you fix a class, ask which of its siblings differs only in being quieter; and when your recovery advice says “just exclude the parent,” check what the parent owns before you drop it from the copy. ## Primary sources - PostgreSQL documentation — inheritance (SELECT on a parent includes children unless ONLY is used) and the pg_inherits, pg_class (relkind), pg_foreign_table, and pg_foreign_server catalogs. - PostgreSQL documentation — information_schema.tables (foreign tables are not BASE TABLE; inheritance is not represented). - sluice v0.99.253 changelog — the INHERITS refusal and foreign-table WARN census, including the verify-your-target advice for prior releases; the v0.92.0 changelog for the declarative twin (Bug 100). =============================================================== # information_schema reports a numeric scale of 2046 (https://sluicesync.com/field-notes/numeric-scale-2046/) Ask information_schema.columns for the scale of a numeric(5,-2) column and it answers 2046. The real scale is -2. The standards-blessed, portable way to read numeric precision and scale is quietly wrong for every negative-scale column — because the catalog view forgot to sign-extend a field the rest of Postgres sign-extends. Observed — while fixing sluice's Postgres schema reader for array-element type modifiers (Bug 195, fixed v0.99.265); the 2046 reading was ground-truthed on PostgreSQL 17. The sluice-side loss it caused — negative-scale and array-element numeric columns mis-sized on read — is fixed; the information_schema behavior it exposed is PostgreSQL's own and reproducible in seconds. ## What happened Since PostgreSQL 15, numeric accepts a negative scale: numeric(5,-2) is a five-digit number rounded to the nearest hundred, so 12345 stores as 12300. It is a real, occasionally useful feature. It also introduced a corner the standards view never learned about. Inside the catalog, a column's precision and scale live packed into pg_attribute.atttypmod — a single integer. For numeric, the scale is an 11-bit two's-complement field. Postgres's own macro, NUMERIC_TYPMOD_SCALE, sign-extends that field when it decodes it, so a stored scale of −2 comes back as −2. information_schema.columns does not sign-extend. It masks the raw 11 bits and returns them as-is, so the −2 comes back as its raw two's-complement encoding: 2046 (that is 2048 − 2). The arithmetic is checkable by hand: numeric(5,-2) has typmod 329730; the low 16 bits are 2046, the next bits are the precision 5. Decode the typmod the way the server does and you get scale −2; decode it the way the catalog view does — or with a naive typmod reader that masks without sign-extending — and you get 2046, a scale that exceeds the precision cap and is on its face impossible. ## Why it matters Reading precision/scale from information_schema is the portable, engine-agnostic move — it is what you reach for precisely to avoid poking at pg_attribute and version-specific typmod encodings. That is what makes this quiet: the “correct,” standards-first path mis-sizes every negative-scale numeric column, and a tool that then recreates the column downstream carries the 2046 forward as if it were a real scale. The same 2046 also falls out of any hand-rolled typmod decoder that masks the scale bits without sign-extending them — the mistake is the catalog view's, and it is easy to reproduce in your own code. ## Is the catalog "wrong"? Worth stating carefully. Negative numeric scale landed in PG 15; information_schema.columns predates it by decades and reports numeric_scale as an int derived from the typmod without the sign extension the type system now needs. Whether that is a documented limitation or an oversight worth an upstream report is a fair question we have not resolved — the fact itself is reproducible in seconds, so nothing downstream depends on the answer. The pragmatic reading: the standards view is a rendering of the catalog, and renderings go stale when the type system grows new corners. ## Reproducing it Any PostgreSQL 15 or later: CREATE TABLE t (n numeric(5,-2)); SELECT numeric_precision, numeric_scale FROM information_schema.columns WHERE table_name = 't' AND column_name = 'n'; -- numeric_precision | numeric_scale -- 5 | 2046 <- the raw 11-bit encoding of -2 -- the ground truth, decoded the way the server does: SELECT atttypmod FROM pg_attribute WHERE attrelid = 't'::regclass AND attname = 'n'; -- 329730 -- scale = sign-extend( (atttypmod - 4) & 0x7FF ) = -2 -- precision = ((atttypmod - 4) >> 16) & 0xFFFF = 5 ## What sluice does about it sluice's Postgres reader now decodes the numeric typmod directly and sign-extends it the way NUMERIC_TYPMOD_SCALE does, preferring the typmod over information_schema for the numeric family — scalar columns and array elements alike. It keeps information_schema only as a fallback for the case where the typmod genuinely isn't available (a DOMAIN-unwrapped column carries atttypmod = -1). ## The transferable lesson When a number out of information_schema looks impossible — a scale larger than its own precision, a length that can't be right — don't paper over it; go read the typmod, and decode it the way the server does, sign extension included. The standards view is a convenience projection of the catalog, and every time the type system grows a new corner (negative scale here, and there will be others), the projection can lag it. 2046 is the tell that you have crossed one of those corners. ## Primary sources - PostgreSQL documentation — arbitrary-precision numbers (negative scale for numeric, PG 15+) and information_schema.columns. - PostgreSQL source — the NUMERIC_TYPMOD_SCALE / NUMERIC_TYPMOD_PRECISION typmod macros (the sign-extended decode the catalog view omits). - sluice Bug 195 and CHANGELOG v0.99.265 — the negative-scale/array-element typmod fix and the 2046 ground-truth on PG 17. =============================================================== # pgx's AfterConnect replaces, it doesn't chain (https://sluicesync.com/field-notes/pgx-afterconnect-replaces/) pgx stdlib gives you one slot to run setup on each new physical connection. Install two features through it — a session GUC pin and a PostGIS codec registration — and the second silently evicts the first. Whichever you register last is the only one that runs. No error, no warning; one of your two features just quietly stops working, on exactly the connections that need it. Observed — while wiring an engine-wide extra_float_digits pin as a per-connection default (Bug 194's belt, shipped v0.99.265). Caught during code review before it shipped, so this was never a released defect — but the trap is one refactor away for anyone using pgx's connection hooks, so it is worth writing down. ## What happened Fixing a float-rendering class (a server default of extra_float_digits=0 rounds floats rendered as text) called for a belt-and-suspenders default: every pgx pool sluice opens should run SET extra_float_digits = 3 on each new physical connection. Why per-connection rather than trusting the typed decode path? Because the typed lanes are only immune while pgx returns binary results. A DSN carrying default_query_exec_mode=exec or simple_protocol flips pgx to text results, where the float decoder parses whatever the session happened to render. So the pin has to be a genuine per-connection default, not a one-shot on a single connection. The natural home for that is stdlib.OptionAfterConnect — a callback pgx runs after each connection opens. The problem is its shape: it is a single option that holds one function, and registering it replaces whatever was there. It does not chain. sluice's change applier already used AfterConnect to register the PostGIS geometry codec on its connections. Installing the float pin as a pool default the obvious way would have overwritten that registration — so the geometry codec would silently stop loading on exactly the pools that apply CDC changes. Wire it the other way and the pin is the one that gets dropped. Either ordering is a silent one-or-the-other: two features, one slot, last writer wins. ## The mechanism, stated plainly OptionAfterConnect is a set-the-callback API, not an add-a-callback API. It reads like registration — “run this after connect” — but it is assignment: the field holds exactly one function, and each call clobbers the previous one. Two independent features that both legitimately need per-connection setup collide with no diagnostic, because assignment doesn't fail; it just wins. ## What sluice does about it The fix is a one-line compose helper that explicitly chains hooks — it wraps the existing callback so both run, in order — and every place that needs AfterConnect installs through it. It is unit-pinned, so a future third hook can't quietly reintroduce the eviction: the test asserts that composing two hooks runs both. ## The transferable lesson When a library exposes a single-slot callback for per-connection (or per-anything) setup, treat “install my hook” as “overwrite whoever's hook was there.” Before you add one, grep for existing installers of the same slot — and if you find one, don't reorder the two calls and call it fixed, because the collision will come back the next time someone adds a third. Make composition the only supported way to install: a helper that chains, and a test that fails if a raw assignment sneaks back in. This is the connection-hook cousin of a broader rule — a single-writer slot is a shared resource, and shared resources need an arbiter, not a convention. ## Primary sources - pgx stdlib documentation — OptionAfterConnect (the per-connection callback and its last-wins assignment semantics) and the query exec modes (exec / simple_protocol) that select text vs binary results. - sluice Bug 194 review finding F2 and CHANGELOG v0.99.265 — the engine-wide extra_float_digits per-connection pin, the geometry-codec collision, and the composeAfterConnect helper with its unit pin. - sluice field note — the one-line fix that unpinned itself through the pooler (the float-rendering class this belt backs up). =============================================================== # Your floats are fine; your diff tool is comparing two renderings (https://sluicesync.com/field-notes/extra-float-digits/) One server sets extra_float_digits=0, another runs the modern default of 1, and every text-level float comparison between them reports differences that do not exist. The stored bits are identical; only the rendering moved. The reassuring direction of this bug — data exact, report wrong — is exactly what makes it waste hours: everything you inspect by hand re-renders through the same setting that skewed the report. Observed — validating a Supabase-sourced migration (2026-07-15), where ::text comparisons disagreed while float8send proved the copy bit-exact. Internally part of roadmap item 69's Supabase leg. This is the reader-facing cut of the fact; the engineering story of pinning the setting across every session sluice renders in is its own note — the one-line fix that unpinned itself through the pooler (Bug 194, fixed v0.99.265). ## What happened A float8 value has one binary identity and, historically, more than one text rendering. Since PostgreSQL 12 the default extra_float_digits is 1, which selects shortest-round-trip formatting: the fewest digits that parse back to exactly the same double. Before that the default was 0 — roughly 15 significant digits, a rendering that can round away the low bits of the printed form even though the stored value is untouched. Supabase ships extra_float_digits=0 as a server default. So a pipeline that copies floats between a stock-default source and a Supabase target — or the reverse — and then verifies with any text-level comparison (::text, a CSV export diff, a checksum over rendered rows) sees phantom mismatches on values whose double-precision bits are identical. The data is right. The report is wrong. ## Why (the mechanism) Text formatting is a session/server GUC, not a property of the value. The same stored double renders differently under extra_float_digits 0 and 1, and any comparison performed at the text layer inherits whichever setting each session happened to have. The failure is doubly treacherous because it points in the reassuring direction: an operator who sees float diffs after a migration assumes the copy corrupted the data, when here the copy is provably exact and only the two renderings diverge. The proof, when you need it, is to compare at the layer where the value actually lives: -- the binary identity of the stored value, rendering-independent: SELECT md5(string_agg(float8send(f)::text, ',' ORDER BY id)) FROM t; -- or pin the rendering before comparing through it: SET extra_float_digits = 1; float8send returns the raw 8 bytes of the double; if those agree on both sides, the values agree, whatever the text says. ## The part that isn't just cosmetic It is tempting to file this as a pure display concern — the bits are safe, so any binary copy is fine. That is true only for a pipeline that never renders a float as text. sluice does, in several places: a raw-copy lane that can move floats as server-rendered text, CDC paths that render tuple text server-side, and — the sharp edge — a verifier that hashes server-rendered ::text samples. In each of those, extra_float_digits governs the actual bytes crossing the boundary, so a source at 0 could round a value in transit, and the verifier could both report a false mismatch on identical data and, worse, a false clean: a source at 0 renders a true value byte-for-byte the same as a target holding that value's rounded corruption. A checker that reads through the setting under test cannot adjudicate it. sluice closed all of this in v0.99.265 by pinning extra_float_digits=3 in every session it renders a float in — the four-pin fix arc is the companion note. The point for anyone else: “the bits are safe, text is cosmetic” holds only until some layer of your pipeline moves the value as text. ## Reproducing it Any Postgres 12+; no provider account needed (Supabase just ships the non-default as a server default): SET extra_float_digits = 0; SELECT pi()::float8::text; -- 3.14159265358979 (15 digits, lossy text) SET extra_float_digits = 1; SELECT pi()::float8::text; -- 3.141592653589793 (shortest round-trip) -- same stored value both times; only the rendering moved: SELECT float8send(pi()); -- \x400921fb54442d18 (identical under both) Run the first SELECT on a Supabase session and the second on stock Postgres and you have the cross-server “mismatch” in miniature: two texts, one value, and float8send as the tiebreaker. ## The transferable lesson A float's text form is a session setting wearing a value's clothes. Never let a text-level comparison adjudicate float fidelity across two servers unless you have pinned the rendering GUC on both sides — and when a float “mismatch” appears after a migration, check the formatting layer before the data layer. The reassuring direction of this bug is exactly why it wastes hours: everything you inspect by hand re-renders through the same setting that skewed the report. And if any stage of your own pipeline moves floats as text, the setting stops being cosmetic and becomes a corruption surface — pin it, on every session that renders. ## Primary sources - PostgreSQL documentation — extra_float_digits: shortest-round-trip output at the default of 1 (PG 12+); smaller values round the rendering. - PostgreSQL release notes (12) — the change of the default from 0 to 1. - sluice managed-services notes — the Supabase section (server default extra_float_digits=0); and the field note on pinning the setting across every session sluice renders in. =============================================================== # The one-line fix that unpinned itself through the pooler (https://sluicesync.com/field-notes/float-pin-through-pooler/) A server that ships extra_float_digits=0 rounds every float Postgres renders as text, and sluice renders floats as text in four different sessions. The fix reads like one line — SET extra_float_digits = 3 before you read. It is four pins and a transaction, because a bare SET followed by a COPY silently lands on two different backends under a transaction-mode pooler, the fix's own error hint was steering users onto that pooler, and one of the four sessions is the verifier — which had been blessing the corruption it exists to catch. Observed — hardening sluice against the extra_float_digits float-rendering class (Bug 194, filed against a Supabase-sourced validation; the class fix and its four review findings shipped in v0.99.265). The unpinned-through-the-pooler shape was caught in pre-land review and was never in any released version; the shipped v0.99.265 pins are transaction-scoped from day one. This is the engineering companion to a shorter note on the reader-facing fact — your floats are fine; your diff tool is comparing two renderings. ## What the setting does, and why it's a data bug not just a display bug A float8 has one binary identity and, historically, more than one text rendering. Since PostgreSQL 12 the default extra_float_digits is 1, which selects shortest-round-trip output: the fewest digits that parse back to exactly the same double. At 0 — the pre-12 default, which some managed providers (Supabase among them) still ship server-wide — Postgres renders through the legacy ~15-significant-digit path, which can round away the low bits of the printed form. The comfortable assumption is that this is only a display concern: the stored bits are untouched, so a binary copy is safe. That assumption was wrong for sluice, because sluice does not always move floats in binary. Its raw-copy lane can move them as server-rendered text; its CDC paths render tuple text server-side; and its verifier hashes server-rendered text. Every one of those is a session in which extra_float_digits governs the bytes that actually cross the boundary — so at 0, a float could be rounded in transit, a genuine value corruption, not a cosmetic one. On the Supabase run, pi drifted through a green stream; only DBL_MAX was loud enough to fail visibly. ## Four faces, four reasons the obvious pin can't reach them The fix is to pin SET extra_float_digits = 3 (maximum precision) in every session that renders a float as text. There turned out to be four, and each resisted the naive placement for its own reason: - The raw-copy TEXT lane. The COPY reads floats as text; pin the session before the COPY. This is the one that looks trivial and is the subject of the pooler trap below. - The pgoutput CDC stream. Tuple text is rendered in the walsender's session, not the applier's. A logical replication=database walsender accepts a plain SQL SET — verified live — so the pin goes there. - The trigger-CDC capture function. The change image is built by to_jsonb() in the firing application's session — a session sluice never opens and can never pin from the outside. The only surface that survives arbitrary application sessions is the trigger function itself, so the pin becomes a per-function SET extra_float_digits = 3 clause on the capture function's definition. - The verifier. Its server-side ::text sample hashes were the quiet horror. Two endpoints with different extra_float_digits render the same stored float differently, so verify reported false mismatches on identical data — and, worse, a source at 0 renders a true value byte-for-byte the same as a target holding that value's rounded corruption, so verify reported a false clean, blessing the exact corruption it exists to catch. A verifier that reads through the setting under test cannot adjudicate it. ## The pin that unpinned itself The raw-copy pin looked like the easy one, and it hid the sharpest finding. The first instinct — pin the GUC as a startup parameter on the connection — cannot work here: Supabase's pooler names this exact GUC in its ignore_startup_parameters list, and pgbouncer refuses startup packets carrying parameters it does not track. So the pin has to be a SQL statement issued after connect. But a bare autocommit SET followed by a COPY is also not pooler-proof. Under transaction-mode pooling — Supabase's recommended :6543 endpoint, and the default shape of pgbouncer's busiest mode — a server backend is assigned per transaction. Two separate autocommit statements can land on two different backends: the SET pins backend A, the COPY reads from backend B, still at the default rounding. Result: rc=0, stream green, bug fully alive. And the cruelest detail — the fix's own IPv6-only remediation hint had been telling users to reach for the transaction-mode :6543 endpoint, i.e. steering them directly onto the one path where the pin evaporates. The resolution is an explicit transaction with SET LOCAL. A transaction is precisely what pins one backend for its duration under transaction-mode pooling, so the SET LOCAL and the COPY are guaranteed to share a backend; LOCAL scopes the pin to that transaction so nothing leaks back into the shared pool. This was pinned against a real pgbouncer 1.25 in pool_mode=transaction with a Supavisor-shaped ignore_startup_parameters, not a mock. One more trap lived inside that transaction. sluice's snapshot reader already runs its COPY inside the exported-snapshot transaction that pins consistency across parallel readers. Wrapping the pin in a fresh BEGIN there would warn (a transaction already open) and a COMMIT would destroy the exported snapshot, breaking every other reader. So the pins detect the ambient transaction via the connection's TxStatus and join it — issuing SET LOCAL inside the snapshot transaction — rather than opening a nested one. ## What sluice does about it Since v0.99.265, all four faces are pinned, transaction-scoped, and proven through a real pooler. There is one operational step the release notes flag as action-required: trigger-CDC users must re-run sluice trigger setup after upgrading, because the capture-function pin lands via CREATE OR REPLACE — installed triggers keep capturing rounded floats until the function is replaced. Nothing here is an upstream bug to file: Supavisor stripping the GUC and pgbouncer's transaction-mode backend assignment are documented, intended pooler behavior. The lesson is entirely about building on top of them correctly. ## The transferable lesson A session-GUC fix is only as strong as the session model underneath it. Before you trust SET x = y; , ask three questions: can a pooler strip it (then it can't be a startup parameter); can the pooler put the SET and the work on different backends (then it must be one transaction with SET LOCAL); and are you already inside a transaction whose semantics a naive BEGIN/COMMIT would wreck (then join, don't nest). Then find every session that renders the value, not just the one you were looking at — including the verifier, because a checker that reads through the setting under test will confirm both the false mismatch and the false clean, and the false clean is the one that ships. ## Primary sources - PostgreSQL documentation — extra_float_digits (shortest-round-trip at the default of 1 since PG 12) and the streaming-replication protocol (replication=database walsenders accept SQL). - pgbouncer documentation — pool_mode=transaction (per-transaction server assignment) and ignore_startup_parameters; Supabase's Supavisor docs for the stripped GUC and the transaction-mode :6543 endpoint. - sluice Bug 194 and CHANGELOG v0.99.265 — the four-face pin, the F1 pooler-unpin finding, the SET LOCAL + ambient-snapshot-transaction resolution against real pgbouncer 1.25, and the trigger-setup re-run action-required note. - sluice field notes — your floats are fine; your diff tool is comparing two renderings (the reader-facing GUC fact), and the verifier rode the same reader (the false-clean kinship). =============================================================== # The platform default that eats every UPDATE (https://sluicesync.com/field-notes/binlog-row-image-minimal/) Point a MySQL CDC pipeline at Azure Database for MySQL and the cold copy is exact, the stream stays green, the counts stay equal — and every UPDATE silently vanishes. The cause is a one-line server default nobody set on purpose: Azure ships binlog_row_image=MINIMAL, and under MINIMAL an UPDATE's before-image carries only the primary key. Azure is the first major managed platform to make MINIMAL the out-of-box posture. Observed — a live probe of a throwaway Azure Database for MySQL Flexible Server (8.0.45-azure, defaults, 2026-07-16) on the shipped v0.99.263 binary; filed as Bug 193, fixed and closed in v0.99.266. The silent-loss half was demonstrated end to end: twelve source UPDATEs, zero target changes, on a stream that reported healthy the whole time. This is the MySQL sibling of an earlier Postgres note — same class, different engine, different knob. ## What happened binlog_row_image controls how much of a row MySQL writes into a ROW-format binlog event. Under the stock default, FULL, an UPDATE logs the complete before-image (every column's old value) and the complete after-image. Under MINIMAL, the server logs only the columns it needs: the primary key in the before-image, and only the changed columns in the after-image. It is a bandwidth optimization, and for MySQL-to-MySQL replication where both ends agree on the row identity, it is harmless. For a CDC applier that reconstructs an UPDATE as UPDATE target SET ... WHERE , it is not harmless. sluice built its WHERE clause from the full before-image. Under MINIMAL every non-PK column in that before-image arrives as nil — not “NULL the value,” but “absent from the event” — and the applier turned each absent column into a col IS NULL predicate. The resulting WHERE matched no real row. And here the second mechanism closes the trap: a CDC applier must tolerate an UPDATE that affects zero rows, because on a warm resume it may replay a change already applied, and refusing zero-row updates would wedge every resume. So the zero-row miss was swallowed by design, logged at DEBUG and nowhere else. The stream stayed green, the lifetime row counts stayed equal, and the content quietly diverged. The live numbers make the shape concrete: twelve UPDATEs on the source produced zero changes on the target, while INSERTs and DELETEs in the same batches applied fine. Eleven rows of 4997 ended up diverged — about 0.2%. verify --depth sample at its default 100-row draw passed over that divergence; only full-table sampling named the diverged primary keys. A safety net calibrated for 5%-scale corruption cannot see a 0.2% one. ## The irony inside our own code sluice had already fixed this exact class — for DELETE. An earlier bug (Bug 88) taught the DELETE path that a MINIMAL before-image is PK-only, and its narrowing helper carries a comment that spells out the whole nil → IS NULL → zero-match → silent-divergence chain. The narrowing lived one switch-case above the UPDATE arm and never reached it. Fixing the instance and not the class, inside the same function: the DELETE path knew the danger by name while the UPDATE path a few lines down walked straight into it. ## The trap waiting for the fixer The naive fix — narrow the UPDATE's WHERE to the primary key, the way DELETE was fixed — converts a silent skip into a silent corruption. MINIMAL's after-image also omits unchanged columns. So a PK-narrowed UPDATE would SET every after-image column, and the columns MINIMAL left out would be written as NULL, nulling out data the UPDATE never touched. “Nil because absent from the event” and “nil because the value is NULL” are different facts that look identical in a decoded row, and telling them apart requires the binlog's present-columns bitmap, not the value. The fix has to restrict the SET list to columns actually present in the event. ## Same class, one variable over: PARTIAL_JSON Setting binlog_row_image=FULL is not the end of the family. A sibling option, binlog_row_value_options=PARTIAL_JSON (MySQL 8.0.3+), makes the server log in-place JSON updates as diffs — PARTIAL_UPDATE_ROWS_EVENTs carrying JSON_SET/JSON_REPLACE/JSON_REMOVE deltas — rather than whole column values. An applier that writes whole values silently drops the update, even under FULL row images. Proven live: an in-place JSON_SET under PARTIAL_JSON left the target at its old value on a green stream — the identical symptom as MINIMAL, one knob to the left of the headline knob. The lesson generalizes: any server option that lets MySQL log less than a whole value is a silent-loss surface for a value-reconstructing applier, and the row-image knob is only the loudest member of the set. ## What sluice does about it Since v0.99.266, a coded preflight (SLUICE-E-CDC-ROW-IMAGE-PARTIAL) reads @@GLOBAL.binlog_row_image at every CDC start — the sync snapshot opener, so a cold start refuses before the bulk copy rather than after; warm resume; and backup incremental. When it is not FULL, the run refuses loudly and names the remedy: SET GLOBAL binlog_row_image=FULL, or on Azure az mysql flexible-server parameter set --name binlog_row_image --value FULL (dynamic, about 19 seconds, no restart, after which UPDATEs converge exactly). NOBLOB refuses too. The PARTIAL_JSON option gets its own preflight, read tolerantly — the variable does not exist before MySQL 8.0.3, and a read failure must not itself refuse — with the SET GLOBAL binlog_row_value_options='' remedy. Behind the preflight sits a defense-in-depth belt keyed on the present-columns bitmap, for any partial image that slips a session-level override the global preflight couldn't see. The belt carries one more subtlety worth stating, because it is where identity and visibility diverge. It refuses a partial image that skipped a column — but on DELETE it must fire only on the PK-less case. With a real primary key the before-image always carries the PK, so MINIMAL images are correct by construction and belting there would regress the working replay of MINIMAL-era DELETE segments. The trap is a table with a UNIQUE NOT NULL column but no declared primary key: MySQL keys its MINIMAL before-image on that unique index (the “primary key equivalent”), which a WHERE index_name = 'PRIMARY' catalog lookup cannot see — so the applier thinks the table is keyless, keeps the nil-filled columns, and zero-matches silently. A truly keyless table's MINIMAL before-image, by contrast, carries every column (there is no PKE to minimize toward), skips nothing, and must still replay. The belt is placed exactly where the identity MySQL logged is not the identity a PRIMARY-only lookup can reconstruct. ## Reproducing it Requires a MySQL 8.0+ server you can set globals on (any local container works; Azure Database for MySQL reproduces it out of the box with no configuration at all): SET GLOBAL binlog_row_image = MINIMAL; -- Azure's default; stock MySQL default is FULL -- cold-copy a table, start CDC, then on the source: UPDATE t SET note = 'changed' WHERE id = 200; -- the before-image carries only the id column; every other column is absent (nil) -- a WHERE built from the full before-image emits note IS NULL, matches nothing, -- and a zero-rows-affected UPDATE is tolerated for resume idempotency -> silent drop On Azure specifically: SELECT @@binlog_row_image; returns MINIMAL on a fresh Flexible Server. Watch a handful of UPDATEs apply as zero target changes while INSERT/DELETE in the same run land correctly, and confirm verify --depth sample passes over the divergence at its default draw. Setting binlog_row_image=FULL (dynamic, no restart) makes subsequent UPDATEs converge. ## The transferable lesson The row image decides whether your UPDATEs can be matched on the target, and replay tolerance — the thing that makes resumes safe — is exactly what hides the miss when they can't. When a managed platform's default differs from stock, treat that default as part of the wire contract, not an operator choice you can assume away: Azure's out-of-box MINIMAL means every Azure-MySQL sync source hits this on the first run. And when you fix a silent-match class, fix it for every statement kind that reconstructs a row, not the one that crashed — the DELETE arm knowing this by name while the UPDATE arm a few lines down did not is how a fixed bug reappears under a different verb. ## Primary sources - MySQL Reference Manual — binlog_row_image (FULL/MINIMAL/NOBLOB) and binlog_row_value_options (PARTIAL_JSON, 8.0.3+); the ROW-format before-image/after-image semantics. - Azure Database for MySQL Flexible Server — server-parameter defaults (binlog_row_image=MINIMAL) and az mysql flexible-server parameter set (dynamic parameter changes without restart). - sluice Bug 193 and the Azure Flexible Server probe report — the twelve-UPDATE/zero-change differential, the verify-sampling-power finding, the present-columns-bitmap fix, and the PARTIAL_JSON and PKE-visibility beats. - sluice field note — REPLICA IDENTITY FULL ate our UPDATEs (the Postgres analogue: the row image, not the engine, decides whether your UPDATEs match). =============================================================== # The retention variable that tells five different truths (https://sluicesync.com/field-notes/managed-mysql-binlog-retention/) binlog_expire_logs_seconds is the variable every MySQL CDC preflight checks to learn how long it has before the resume position is purged. On five managed platforms it means five different things: on two it lies (the number is days, the real window is minutes); on one it is honest and enforced; on one it is honest the other way (never-expire); and on one there is no knob behind it at all. Whether anything SQL-visible is the answer decides whether a tool can detect the trap or only guess at it from the hostname. Observed — a wave of live throwaway probes across DigitalOcean, AWS RDS, Google Cloud SQL, Vultr, and Azure managed MySQL (2026-07-16/17, on the shipped v0.99.263 binary), each created, watched for its true purge cadence, and torn down. Every cadence below is one probe at one instance size — measured, not a platform constant. The sluice-side advisories named here shipped across v0.99.253–264. ## Why a snapshot-then-CDC tool cares The binlog retention window bounds how long the initial copy may take. A snapshot-then-CDC migration captures its CDC start position up front, copies the tables, then resumes streaming from that position. If the binlogs behind the position are purged before the copy finishes — or during any later pause — the resume point is gone. In sluice's case that surfaces loudly (a coded position-invalid error naming the purged file), which sounds safe until you notice the failure mode that follows: an auto-resnapshot retry re-copies from scratch, exceeds the same window again, and loses its restart point again. Every individual error is loud; the loop as a whole silently never converges. So the true window is not a nicety — it is the difference between a migration that finishes and one that livelocks. ## Five platforms, five meanings of the same number DigitalOcean — the variable lies (API-only truth). On a fresh Managed MySQL cluster, @@binlog_expire_logs_seconds reads 259200 (three days). An out-of-band platform reaper purges every binlog file roughly 13–16 minutes after it's created. There is no SQL query that reveals the real policy — the DO config API doesn't even expose a retention field until you set one. The only signal you're in this regime is the host suffix *.db.ondigitalocean.com. The remedy is a hidden knob: a config-API binlog_retention_period (seconds, 600–86400), immediate, no restart. AWS RDS — the variable lies, but tighter, and the truth is SQL-visible. @@binlog_expire_logs_seconds reads 2592000 (thirty days); the real window in our probe was about 5–11 minutes, as RDS purges each file once its automated backup has uploaded, on a sweep that runs roughly every five minutes. Plan for the floor. Crucially, RDS exposes the real policy to SQL: CALL mysql.rds_show_configuration; returns the actual binlog retention hours (NULL means “purge as soon as possible”), and the remedy is plain SQL too — CALL mysql.rds_set_configuration('binlog retention hours', 24) — capped at 168 hours (seven days). An attached, caught-up stream does not hold the purger back: files behind a live stream purged on schedule, and a persisted position was dead about ten minutes after a clean detach. Google Cloud SQL — the variable is honest and the floor is enforced. @@binlog_expire_logs_seconds reads 86400 (one day), it is the real on-disk window, and no out-of-band reaper exists on any observable timescale — files sat 3+ hours past creation, and a 35-minute detached stream warm-resumed on pure defaults. The platform even refuses to set the window below a day (a database-flag value under 86400 returns HTTP 400). The one genuinely dangerous knob is the point-in-time-recovery toggle: disabling binary logging restarts the instance and deletes every binlog, and re-enabling restarts it again and resets numbering to 000001, permanently invalidating positions on either side of the round-trip. And a decoy: --retained-transaction-log-days looks like the retention knob but governs the PITR copies in Cloud Storage, which the replication protocol can't read — the database flag binlog_expire_logs_seconds is the real one. Vultr — the variable lies, and there is no knob at all. Vultr's DBaaS is the same Aiven-derived platform as DigitalOcean's, and it shares DO's headline hazard without DO's escape hatch: @@binlog_expire_logs_seconds reads 259200 (the identical DO value), the real window is ~10–16 minutes, and Vultr exposes no retention control anywhere. The advanced-options API rejects binlog_retention_period by name (“is not a valid configuration option”), the database-update API silently ignores it, and SET GLOBAL / SET PERSIST / PURGE BINARY LOGS are all denied to the admin user. The ~10-minute floor is permanent and unconfigurable — treat Vultr MySQL as a migrate-and-cut-over source, not a pausable one. Azure — the variable is honest in the other direction. Azure Database for MySQL Flexible Server ships binlog_expire_logs_seconds=0, which in stock MySQL means “never expire” — and across 85 minutes of observation, including straight through a full backup, nothing was purged; a 35-minute detached stream warm-resumed on defaults. The risk inverts: with no effective expiry, binlogs accumulate against the instance's storage, so on Azure the operator's job is to bound retention, not extend it. (Azure has its own separate, sharper trap — a binlog_row_image=MINIMAL default that silently drops UPDATEs — but that is a different note.) ## The comparative picture DigitalOcean AWS RDS Cloud SQL Vultr Azure var reads 3 days 30 days 1 day 3 days 0 (never) real window ~13-16 min ~5-11 min 1 day (floor) ~10-16 min unbounded truth lives API-only SQL-visible the variable nowhere the variable remedy config API plain SQL gcloud flag NONE set knob (storage) detect by host suffix host + SQL @@version host suffix host + @@version (-google) *.vultrdb.com (-azure) ## Detection beats pattern-guessing The pivotal axis isn't the window length — it's where the truth lives, because that decides what kind of preflight is even possible. Where a platform exposes its real policy to SQL (RDS's mysql.rds_configuration, Cloud SQL's honest variable, Azure's honest variable), a tool can read the actual value and stay silent when the operator has already fixed it — a precise, no-false-positive check. Where the truth is invisible to SQL (DO's API-only policy, Vultr's absent knob), the only honest signal is the connection-string hostname, so the tool is stuck with a blunter instrument: an unconditional warning triggered by the host suffix, which fires even on a correctly-configured cluster because it has no way to know. A variable-based preflight there would be worse than nothing — it would confidently report a three-day window the platform doesn't honor. There is one more wrinkle: Cloud SQL has no hostname to match at all — it connects by bare IP, or through an auth proxy at 127.0.0.1. Its in-band fingerprint carries the detection instead: @@version ends in -google. Vultr, at the other extreme, has a clean host suffix (*.vultrdb.com) but a bare @@version_comment of “Source distribution” — no in-band signal — so the host pattern is the only thing to match on. Fingerprint the platform however you can, then check for a SQL-visible truth source before you decide whether to detect or to guess. ## What sluice does about it - DigitalOcean (v0.99.253): an unconditional host-pattern WARN on *.db.ondigitalocean.com at sync and backup start, naming the binlog_retention_period config-API knob — because the host is the only signal that exists. - AWS RDS (v0.99.263): a detect-first advisory — sluice reads binlog retention hours from mysql.rds_configuration and WARNs only when it is NULL or under a day, staying silent when the operator has set it, and degrading to the DO-style unconditional pattern WARN if the probe can't run. It also fires on warm resumes, because an attached stream is no shield. - Google Cloud SQL (v0.99.264): detection keys on @@version -google (no host to match); there's nothing to warn about at defaults, so the advisory is the inverse — recovery text on a position-invalid error explaining that the PITR toggle resets binlog numbering and invalidates prior positions. - Vultr: the same platform lineage as DO with no SQL-visible truth and no remedy, so it warrants the DO-style unconditional host-pattern WARN — with stronger wording than DO's, because DO's message can point at a knob and Vultr's cannot. - Azure: retention is safe by default, so no retention WARN; the Azure teeth are elsewhere (the row-image preflight). A mild INFO to bound retention for storage hygiene on long-lived syncs is the only retention-flavored note it needs. ## The transferable lesson On managed databases, binlog_expire_logs_seconds reports what the engine would do, not what the platform does — and the gap runs in both directions, from “3 days on the label, 15 minutes in practice” to “0 means never, and the risk is your disk.” Never let the variable stand in for the window. Instead: fingerprint which platform you're on (host suffix or @@version), look for a platform-native truth source you can query, and only fall back to hostname pattern-guessing where none exists. Set the window explicitly through the platform's own knob — and if there is no knob (Vultr), design around a fixed, unconfigurable floor. Then audit your retry logic for the livelock shape, because “every error was loud” is no defense when the loop as a whole silently never terminates. ## Primary sources - DigitalOcean Managed MySQL configuration API (binlog_retention_period, 600–86400 s); AWS RDS mysql.rds_set_configuration / mysql.rds_show_configuration (binlog retention hours, default NULL, max 168) and the automated-backups prerequisite; Google Cloud SQL database flags (binlog_expire_logs_seconds, floor 86400) and the PITR binlog coupling; Azure Database for MySQL Flexible Server server parameters. - MySQL Reference Manual — binlog_expire_logs_seconds (what the variable governs when the engine, not a platform reaper, owns expiry). - sluice managed-services notes and the five probe reports — DO, RDS, Cloud SQL, Vultr, and Azure MySQL retention observations and detect-first advisories. =============================================================== # The join that's 1:1 on MySQL 8 and fans out on MariaDB (https://sluicesync.com/field-notes/mariadb-check-constraint-fanout/) MariaDB has no distinct JSON storage type — a JSON column is LONGTEXT plus an auto-generated CHECK named after the column — and its constraint names are unique per table, not per schema. A catalog join that is provably 1:1 on MySQL 8 becomes a cartesian fan-out on MariaDB, and because JSON columns are named after their column it fires for the most ordinary schema imaginable: two tables that each have a `meta` JSON column. The fix cannot be symmetric, because MySQL 8's CHECK_CONSTRAINTS has no TABLE_NAME column to join on. Observed — adding MariaDB type fidelity to sluice's schema reader (v0.99.270, ADR-0169, tracked as Bug 198), ground-truthed live on mariadb:11.4 and mariadb:10.11. It surfaced not on an exotic schema but on the plainest one: two tables, each with a JSON column of the same name. ## JSON is a CHECK, named after the column MariaDB has no distinct JSON storage type. A column declared JSON is stored as LONGTEXT with an auto-generated constraint — a CHECK whose expression is exactly json_valid() and whose name is the column name. Declare meta JSON and you get a longtext column plus a CHECK named meta. That is the first surprise, and on its own it is harmless — sluice reads the column back as textual JSON and strips the MariaDB-internal auto-CHECK from the IR so it is not re-emitted as an invalid json_valid() CHECK on a Postgres target (a user-authored CHECK on the same column is preserved). ## The uniqueness scope nobody advertises The second surprise is a catalog convention that differs between two engines sharing a wire protocol: MariaDB constraint names are unique per table; MySQL 8's are unique per schema. So two tables in one MariaDB database can each carry a constraint named meta — and because a JSON column's auto-CHECK is named after the column, they routinely do. sluice's reader joined information_schema.check_constraints to table_constraints on (constraint_schema, constraint_name) to recover each CHECK's expression. On MySQL 8 that pair is a key, so the join is 1:1. On MariaDB it is not: any two tables sharing a constraint name make the join fan out, each table picking up every same-named CHECK once per sharing table — and cross-contaminating, so table a captures table b's CHECK too. The emitted CREATE TABLE then carries duplicate CHECKs and is refused loudly at creation, before any row is copied: Error 1826 (HY000): Duplicate CHECK constraint name 'meta' -- MySQL / MariaDB target SQLSTATE 42710: constraint "meta" ... already exists -- Postgres target This is a loud failure, not silent loss — but it blocks migrate and backup/restore of an entirely ordinary schema. A single-table database does not reproduce it; you need two tables sharing a name, which JSON columns named meta, data, or payload hand you for free. ## Why the fix can't be symmetric The obvious repair is to add table_name to the join predicate. It works on MariaDB. It does not compile on MySQL 8: information_schema.CHECK_CONSTRAINTS on MySQL 8 has no TABLE_NAME column at all — referencing it is a hard SQL error, not an empty result (live-verified; MariaDB's CHECK_CONSTRAINTS does carry it). So the corrected join has to be flavor-gated: MariaDB gets the extra cc.table_name = tc.table_name predicate, and the MySQL-8 query is left exactly as it was. ## The transferable lesson Constraint-name uniqueness scope is a catalog convention, not a protocol guarantee — and it differs between engines that otherwise speak the same wire protocol and share most of information_schema. A query that is provably 1:1 on one is a cartesian product on the other, and the failure hides behind the most ordinary column name in your schema. When you port a metadata query across a MySQL-family flavor, re-derive the join's cardinality against the actual catalog, and expect the corrected version to be asymmetric: the columns you need to disambiguate on one engine may not exist on the other. ## Primary sources - sluice ADR-0169 (MariaDB flavor Phase 2 — JSON identity and the check-constraint join fan-out) and CHANGELOG 0.99.270; live read-back on mariadb:11.4 and mariadb:10.11. - MariaDB Knowledge Base — CHECK constraints (per-table name scope; the implicit json_valid() constraint on JSON columns) and information_schema.CHECK_CONSTRAINTS (which carries TABLE_NAME). - MySQL 8.0 Reference Manual — information_schema.CHECK_CONSTRAINTS (schema-scoped names; no TABLE_NAME column). =============================================================== # MariaDB accepts a geometry SRID it won't show you (https://sluicesync.com/field-notes/mariadb-geometry-srid-hidden/) Declare POINT REF_SYSTEM_ID=4326 and MariaDB really stores the SRID — but SHOW CREATE TABLE drops the attribute and echoes a bare `point DEFAULT NULL`, and unlike MySQL 8 there is no srs_id column in information_schema.COLUMNS. Parse the SRID the documented way and every geometry column silently reads back as SRID 0. The declared value lives only in the OGC-standard GEOMETRY_COLUMNS view. Observed — adding geometry support to sluice's MariaDB reader (v0.99.270, ADR-0169), on the mandatory live read-back the discipline requires. A geometry(POINT, 4326) column written and read straight back came out as SRID 0. ## The attribute SHOW CREATE TABLE drops You declare a column p POINT REF_SYSTEM_ID=4326 on a MariaDB table, and the SRID is genuinely stored. But MariaDB will not hand it back the way you would expect. SHOW CREATE TABLE echoes the column as a bare `p` point DEFAULT NULL — the REF_SYSTEM_ID attribute is simply gone from the round-trip. The intuitive, documented plan — parse the SRID out of SHOW CREATE TABLE — therefore reads back SRID 0 on every geometry column, silently. And unlike MySQL 8, there is no fallback in the column catalog: MariaDB has no srs_id column in information_schema.COLUMNS. The place MySQL 8 stores the per-column SRID does not exist here. ## Why the loss is quiet The drop inserts and queries without complaint, because MariaDB does not enforce a declared column SRID anyway — a value with a different SRID inserts fine, and ST_SRID is a property of each value, not of the column. So nothing downstream objects to a column that reads back as SRID 0. A spatial-reference identifier that silently degrades to 0 is exactly the kind of value-fidelity loss that never trips an error and never shows up in a row count. ## Where the SRID actually lives The declared column SRID is recorded — just in the OGC-standard information_schema.GEOMETRY_COLUMNS view, keyed by schema, table, and column, in its SRID field (present and correct on both 11.4 and 10.11). Read it from there and write it back as MariaDB's REF_SYSTEM_ID= type attribute (the grammar requires that attribute before NOT NULL), and a geometry(POINT, 4326) column round-trips its SRID in both directions. ## The transferable lesson On MariaDB, the catalog surface that echoes a spatial reference and the one that stores it are different objects, and the intuitive one omits it. The only reason this did not ship as a silent SRID-to-0 loss is that the mandatory live read-back — write a known SRID, read it straight back, assert it survived — caught it exactly where a self-consistent fixture would have been blind. When a catalog attribute round-trips through SHOW CREATE, verify it against an independent view before trusting it; a value that inserts without complaint is not the same as a value that was preserved. ## Primary sources - sluice ADR-0169 (MariaDB flavor Phase 2 — geometry SRID recovery) and CHANGELOG 0.99.270; live read-back on mariadb:11.4 and mariadb:10.11. - MariaDB Knowledge Base — geometry column definitions (REF_SYSTEM_ID) and the OGC GEOMETRY_COLUMNS table; SRID is per-value (ST_SRID), not enforced per column. - MySQL 8.0 Reference Manual — information_schema.COLUMNS.SRS_ID (the per-column SRID column MariaDB lacks). =============================================================== # The type that migrates clean and corrupts under CDC (https://sluicesync.com/field-notes/mariadb-native-type-cdc/) MariaDB's native uuid/inet6/inet4 round-trip perfectly under a bulk migrate, because the driver hands them back as formatted text. Turn on CDC and the same columns can corrupt: the binlog carries the raw storage bytes, not the text — bulk copy and the binlog are different transports with different representations. sluice met that first with a loud refusal, then decoded the bytes faithfully one release later — and the real byte layout was not the one the roadmap predicted, which is the richer lesson. Observed — landing continuous CDC for sluice's MariaDB flavor (v0.99.271, ADR-0170), then decoding these types faithfully one release later (v0.99.272, ADR-0171). Phase 2 had already proven they round-trip under bulk migrate; Phase 3's CDC path is where the representation split bites — v271 met it with a loud, coded refusal, and v272 lifted that refusal by ground-truthing MariaDB's actual binlog byte layout on a live server. ## Clean under migrate MariaDB grew native network types over several releases: uuid (10.7+), inet6 (10.5+), and inet4 (10.10+). Under a bulk migrate they round-trip perfectly, because the query driver hands them back as formatted text — a CHAR(36) UUID string, a VARCHAR(45) address — which sluice maps to Postgres native uuid/inet or to a MySQL-family target's CHAR(36)/VARCHAR(45). Nothing to see; the value is lossless. ## Corrupt under CDC Turn on CDC and the same columns can silently corrupt. The binlog does not carry the formatted text — it carries the raw storage bytes: 16 bytes for uuid and inet6, 4 for inet4. sluice's value-decoder was written for MySQL, where these values live in a VARCHAR column and the binlog bytes are the text. Point that decoder at MariaDB's raw storage bytes and it stringifies them into garbage. The trap is that the loudness is target-dependent. Feed the garbage string to a Postgres target and it rejects it — invalid input syntax for type uuid, SQLSTATE 22P02, loud and honest. Feed the same string to a MySQL-family target's CHAR(36)/VARCHAR(45) and it is silently accepted. So mariadb → mysql (or → mariadb, → planetscale) CDC was a reachable silent-corruption path while the Postgres direction failed cleanly. A type's loudness under one target is not its loudness under all. ## The interim close: refuse on every target (v0.99.271) The first fix was a flavor-gated, source-side, coded refusal fired before any data moves, at all three points a native uuid/inet column could enter a stream: CDC stream start, cold-start snapshot open, and mid-stream schema add-table. It was the same refusal on every target — SLUICE-E-CDC-MARIADB-NATIVE-TYPE-UNSUPPORTED — because “Postgres would have caught it” is not a reason to let a MySQL-family target silently accept it. The steer was to bulk migrate those columns (unaffected) or exclude them from CDC scope. A loud refusal is a sound interim close: it trades a feature for a guarantee, and it holds until the faithful decode is proven rather than assumed. ## The real fix, and the byte layout that wasn't what we expected (v0.99.272) Decoding the raw bytes faithfully is the proper close, and it is a value-fidelity hazard in its own right, which is why it shipped a release later rather than the same day. The roadmap draft anticipated the well-known trap: MariaDB's UUID_TO_BIN(x, 1) folklore says the native uuid storage reorders the timestamp fields relative to string order, so a straight big-endian decode would produce a valid-looking but wrong UUID. The live probe falsified that. MariaDB stores UUID canonical big-endian — no reorder at all; the folklore was wrong. The real hazards were quieter and elsewhere. MariaDB frames these types length-prefixed and strips trailing 0x00 bytes on the wire, so a nil uuid, 0.0.0.0, or :: arrive as empty — zero bytes, not zero-filled. A decoder that sizes its output from len(raw) would silently shorten every zero-suffixed value; the faithful decoder right-pads to the fixed width and takes that width from the declared data_type, never from the received length. And inet6 text is rendered by MariaDB's BSD inet_ntop6, which diverges from Go's net/netip on the IPv4-compatible ::a.b.c.d forms — so even the text formatter has to match the server's, not the language's. All of it was verified byte-exact against live 11.4 and 10.11 and pinned with a full byte→text family matrix plus same-engine and cross-engine CDC value-fidelity tests. The lesson inside the lesson: the danger was not where the well-known trap said it would be, and only reading the actual bytes off a live server told us so. ## The transferable lesson “It migrated fine” tells you nothing about the CDC path. Bulk copy and the replication log are different transports with different representations of the same column — one may hand you formatted text while the other hands you raw storage bytes — and a decoder proven on one is not proven on the other. When a decode error's loudness depends on the target type, the safe interim is a source-side refusal that doesn't depend on the target catching it: pin the class, refuse on all targets, and treat “a plausible wrong value” as worse than a stop. But a refusal is a placeholder, and closing it properly means reading the actual bytes off a live server — because the well-known trap may be folklore (MariaDB's UUID isn't reordered) while the real hazard sits somewhere quieter (stripped trailing zeros, a text formatter that follows the server's C library and not your language's). The extremes are where you learn the byte layout, so decode the value that's all zeros, not the one in the middle. ## Primary sources - sluice ADR-0170 (MariaDB flavor Phase 3 — CDC) with the coded error SLUICE-E-CDC-MARIADB-NATIVE-TYPE-UNSUPPORTED, and ADR-0171 / CHANGELOG 0.99.272 (the faithful binlog decode that lifted the refusal: canonical big-endian UUID, stripped trailing 0x00, width from the declared data_type, BSD inet_ntop6 text). Ground-truthed byte-exact on mariadb:11.4 and mariadb:10.11. - MariaDB Knowledge Base — UUID, INET6, and INET4 data types (storage widths; the actual on-disk byte order). - Related field note: BIT crosses the wire as bytes, and the engines disagree on layout — another value whose wire representation is not its text. =============================================================== # MariaDB has no BEGIN, and won't tell you if your position survived (https://sluicesync.com/field-notes/mariadb-gtid-no-begin/) Porting a MySQL binlog CDC reader to MariaDB surfaces two assumptions MySQL quietly baked in. A MariaDB transaction opens with a MariadbGTIDEvent and no BEGIN, so a pump that only handles MySQL's GTIDEvent never advances its position. And you cannot pre-check whether your resume position still exists: @@gtid_binlog_state is unchanged across PURGE BINARY LOGS, so a dead position looks live — the only honest signal is the stream itself throwing error 1236. Observed — porting sluice's MySQL binlog CDC reader to MariaDB (v0.99.271, ADR-0170), ground-truthed on mariadb:11.4 and mariadb:10.11. Two roadmap-draft premises were falsified by the live probe; both are below. ## Assumption one: a transaction opens with BEGIN A MySQL transaction opens with a BEGIN QueryEvent, and a binlog reader keys its transaction boundary — and its GTID accumulation — off that event. MariaDB emits no BEGIN. A plain-DML transaction is exactly: MARIADB_GTID -> TABLE_MAP -> WRITE/UPDATE/DELETE_ROWS -> XID and the opening event is a MariadbGTIDEvent — a different event type from MySQL's GTIDEvent. A pump that handles only GTIDEvent never sees a transaction boundary and never advances its GTID set, so every resume position it emits is stale. That is not a crash; it is a silent wrong-position gap on the next restart. The fix is to handle MariadbGTIDEvent as the transaction opener and synthesize the begin the vanilla pump expected. Folklore correction. The oft-repeated claim that MariaDB emits a per-transaction dummy QueryEvent you must filter “for performance” is false on the current LTS lines — there is no dummy event. Adding an over-broad filter “to be safe” is exactly how you would silently skip a real DDL. ## Assumption two: you can ask whether your position still exists Before resuming, you would like to ask the server: is my saved position still in the binlog? On MySQL you can. On MariaDB there is no honest way. It has no GTID_SUBSET function and no @@gtid_purged. The tempting variable, @@gtid_binlog_state, reports the newest GTID per domain — and it is completely unchanged across PURGE BINARY LOGS. Ground-truthed: it read 0-1-15 before purging every prior file and 0-1-15 after. So a position below the purged floor is indistinguishable from a live one; a naive containment check returns a false “reachable.” The only faithful signal is the stream itself. Starting replication from a purged position throws error 1236 (“Could not find GTID state requested by slave in any binlog files…”) on the first event. sluice classifies that reactive error as an invalid position and routes it to a clean cold-start re-snapshot rather than a silent wrong-position stream. Reachability on MariaDB is only knowable by trying. ## The shape underneath: domain GTIDs MariaDB's GTIDs are domain-server-sequence (e.g. 0-1-15), not MySQL's server_uuid:sequence. Cold-start reads the position from @@gtid_binlog_pos (not @@gtid_current_pos), and the reader parses, serializes, resumes, and advances the domain-based set per event. The master-status probe accepts SHOW BINLOG STATUS (MariaDB's spelling, working on 10.11+) alongside the older forms, so no supported server pays an extra round-trip. ## The transferable lesson A “resume position” is only as safe as the server's ability to tell you it is still reachable — and that ability is not universal even across engines that share a binlog format. Two things a MySQL reader assumes for free, MariaDB withholds: a transaction boundary you can key on, and a way to test a position without streaming it. When you port a replication reader, do not trust that the transaction opens the way you expect or that a reachability check exists; verify both against the live server, and design the purged-position path around a reactive error, because that may be the only signal you get. ## Primary sources - sluice ADR-0170 (MariaDB flavor Phase 3 — CDC; the MariadbGTIDEvent pump fix and the reachability model) and CHANGELOG 0.99.271; ground-truthed on mariadb:11.4 and mariadb:10.11. - MariaDB Knowledge Base — Global Transaction ID (domain-server-sequence), @@gtid_binlog_pos / @@gtid_binlog_state, and SHOW BINLOG STATUS. - Related field note: the position that leads or trails the data — another way a resume position and the data it names can disagree. =============================================================== # The row image you can't preflight, because a proxy is in the way (https://sluicesync.com/field-notes/proxy-cant-preflight-row-image/) A self-hosted Vitess running binlog_row_image=NOBLOB drops an unchanged BLOB from an UPDATE's after-image — the same silent-overwrite class as the binlog NOBLOB case, reached through the Vitess door. But the vanilla defense, reading @@GLOBAL.binlog_row_image before the stream starts, cannot exist here: sluice connects to a vtgate, a proxy in front of a fleet of tablets, and there is no single row-image posture to read. The only authoritative signal is the wire itself — and the tablet underneath is loud when the experimental flag is off and goes silent at exactly the setting the guard exists for. Observed — extending sluice's partial-row-image discipline to the VStream reader (v0.99.272, ADR-0172, roadmap item 74), filed from the Bug-193 review. The proto and tablet semantics below were ground-truthed against the vendored vitess.io/vitess@v0.24.2 (the module version in go.mod) — the bit order matches vttablet's own isBitSet. Honest scope: the belt is unit-pinned to the exact wire shape Vitess produces (the NULL-cell after-image plus its packed bitmap), directly and through the real dispatch path; a full self-hosted Vitess NOBLOB cluster streamed end-to-end is a recommended follow-up, not something this note claims was run. ## The same loss, one door over A companion note covers the vanilla binlog case: under binlog_row_image=MINIMAL/NOBLOB, an UPDATE's row image omits columns, a value-reconstructing applier writes what it decodes, and the missing columns turn into silent loss on a green stream. Vitess reaches the identical class through a different reader. Vitess 16+ can run its underlying mysqlds under NOBLOB via an experimental flag (AllowNoBlobBinlogRowImage), and when it does, a tablet emits a partial UPDATE after-image that leaves an unchanged BLOB/TEXT column out. Here is the sharp mechanical detail. For an omitted column, vttablet leaves the zero value in the row, which the wire encoder serializes as a cell of length −1 — and length −1 is the NULL-cell encoding. A VStream reader that has no bitmap to consult (decodeVStreamRow) reads that −1 as a genuine SQL NULL. On UPDATE apply, it writes NULL over the column's real, unchanged value: the same Bug-193 silent corruption, arriving through the Vitess door disguised as a legitimate NULL. ## Why you can't preflight it The vanilla binlog reader defends this with a stream-start preflight: read @@GLOBAL.binlog_row_image, and if it isn't FULL, refuse before any data moves. That defense is structurally impossible for VStream, and the reason is the proxy. sluice does not connect to a mysqld — it connects to a vtgate. A self-hosted Vitess is a fleet of tablets, each with its own mysqld and its own binlog_row_image, and vtgate exposes no aggregate row-image posture to probe. Route a @@GLOBAL.binlog_row_image query through vtgate and it lands on one arbitrary tablet, answering for that tablet and not the fleet — a global that doesn't exist, reported as if it did. A preflight there would be worse than none: a confident, wrong all-clear. So the authoritative signal has to be the one Vitess already puts on the wire, per row. Each RowChange carries a DataColumns presence bitmap — one bit per column, set if the column is present in the after-image, unset if it was omitted — and a companion JsonPartialValues bitmap for the PARTIAL_JSON sibling (a JSON column logged as a JSON_SET/JSON_REPLACE/JSON_REMOVE diff rather than the value). On a FULL stream both bitmaps are absent, so the row image is the whole truth. When a bit in DataColumns is unset, that column was dropped — and that unset bit, not the value's length, is the fact you have to key on, because the value's length is lying to you. ## The twist: loud when the flag is off, silent when it's on The part worth the note is what the tablet does on its own. With the NOBLOB experimental flag off, a partial row image makes vttablet abort the stream loudly — partial row image encountered: ensure binlog_row_image is set to 'full'. That is reassuring, and it is a trap, because it fires in exactly the configuration you don't need help with. Turn the flag on — the one setting under which NOBLOB actually produces partial images through VStream — and vttablet stops aborting. It emits the partial image with its DataColumns bitmap and streams on. The layer underneath is loud at the safe setting and goes silent at precisely the dangerous one, so trusting its self-defense would leave the guarded case unguarded. ## Refuse, not carry-forward — and only self-hosted Vitess reaches it Given an omitted column, the two faithful options are to carry the prior value forward or to refuse. Carry-forward is impossible from the event alone: NOBLOB omits the unchanged BLOB from the before-image too, so the prior value simply is not in the RowChange. Recovering it would mean reading the target — replica-apply semantics sluice deliberately does not attempt — so the honest close is a loud refusal. (NOBLOB omits columns only from UPDATE after-images; INSERT and DELETE log every column, so refusing on the after-image covers the whole class.) One scoping fact keeps this from touching the managed flavor at all: PlanetScale pins binlog_row_image=FULL, so its DataColumns is never populated and the belt's fast path returns immediately. Only a self-hosted Vitess deliberately running the NOBLOB flag can reach this cell. ## What sluice does about it Since v0.99.272 a decode-time belt (refuseVStreamPartialRowImage, called per RowChange in dispatchRow before decode) scans the two bitmaps: any unset bit in DataColumns, or any set bit in JsonPartialValues, refuses loudly with SLUICE-E-CDC-ROW-IMAGE-PARTIAL, naming the first offending column. It reuses the same coded error as the vanilla binlog door rather than minting a Vitess-specific one — it is the same silent-loss class, and an operator grepping the code should find one entry covering both doors. On a FULL stream both bitmaps are absent, the belt returns nil, and the decode path is byte-identical to before, so PlanetScale and every FULL stream are unaffected. Recovery on a self-hosted cluster is to set binlog_row_image=FULL on the source tablets and restart (a fresh cold start if the partial-image window's UPDATEs matter). ## The transferable lesson When the configuration that governs correctness lives behind a proxy, a preflight that reads “the” setting is reading a fiction — a fleet has no single value, and the proxy will happily answer for one member as though it spoke for all. Prefer the signal the wire already carries per record over a global you have to ask for. And do not lean on a lower layer's own loud failure as your safety net without checking when it fires: here the tablet aborts at the safe setting and falls silent at the dangerous one, so the only defense that holds is the one that reads the per-row truth for itself. ## Primary sources - sluice ADR-0172 (VStream partial-row-image belt) and CHANGELOG 0.99.272; the coded error SLUICE-E-CDC-ROW-IMAGE-PARTIAL, shared with the vanilla binlog door. Ground-truthed against vitess.io/vitess@v0.24.2 (the RowChange.DataColumns/JsonPartialValues bitmaps and vttablet's isBitSet bit order). - Vitess — the VReplicationExperimentalFlagAllowNoBlobBinlogRowImage flag (Vitess 16+) and the vstreamer's partial-after-image behavior; the tablet's own “partial row image encountered” abort that fires only when the flag is off. - MySQL Reference Manual — binlog_row_image (NOBLOB omits unchanged BLOB/TEXT from the row image) and binlog_row_value_options (PARTIAL_JSON). - Sibling field note — the platform default that eats every UPDATE (the same silent-overwrite class through the vanilla binlog door; Bug 193). =============================================================== # The crash was the good outcome (https://sluicesync.com/field-notes/crash-was-the-good-outcome/) sluice's trigger-based Postgres CDC captures change images with to_jsonb(), and JSON has no token for a non-finite float — so ±Infinity and NaN travel as the strings "Infinity"/"NaN". The decoder normalized scalar leaves but skipped array elements, so the first UPDATE touching a float8[] column crashed the apply loop loudly and re-crashed on every restart. That loud crash was the value contract working: the fix not taken — blindly coercing every array leaf to float64 — would have truncated a numeric[] element and turned a text[] holding the literal word "Infinity" into a number. Observed — a live crash-loop on AWS RDS PostgreSQL 16.14 during the v0.99.263 validation cycle (2026-07-16), nothing RDS-specific about it; fixed in v0.99.263. It affected every prior release of the postgres-trigger engine that carried array columns through UPDATE/DELETE payloads. Zero silent loss: the failure was loud and position-safe. This is a story about a crash being the right outcome, and the two silent corruptions the obvious fix would have shipped instead. ## How the capture format meets a non-finite float The trigger-CDC engine records each change image by calling to_jsonb() on the row inside the capture trigger. JSON has exactly one numeric type and no spelling for Infinity, -Infinity, or NaN — so PostgreSQL renders those as the JSON strings "Infinity"/"-Infinity"/"NaN", while every finite number arrives, under a UseNumber decode, as a json.Number. The decoder already knew this: it normalized scalar leaves, mapping the non-finite spellings back to their float values and json.Number to the target type. It simply never applied that normalization to array elements. So the first UPDATE that touched a float8[] column arrived with elements the apply layer had never normalized, and the apply failed loudly: column "floats": expected float64, got json.Number. Because the failure happened before the position advanced, the stream was position-safe — and deterministically re-crashed on the same change-log row at every restart. A wedged stream, not a data loss. Observed live on RDS PG 16.14; the same code path wedges on any Postgres. ## The fix not taken The tempting one-liner is to coerce every array leaf to float64 and move on. That would have converted one loud crash into two silent corruptions: - A numeric(p,s) array element carries arbitrary precision. Route it through float64 and it loses digits — silently, because the result is still a valid number. - A text[] element can legitimately hold the literal word "Infinity". Map every non-finite spelling to a float upstream and that string becomes the number ∞ — a text value silently turned into a float. The value that looked like a float was not always a float, and the payload spelling could not tell you which. So the coercion had to move down, into a type-aware layer that dispatches on the destination column family rather than on what the payload happens to look like: the float branch parses json.Number/int64/the non-finite spellings, the numeric branch takes json.Number.String() digit-lossless, the temporal branch parses ISO-8601 strings, and every branch refuses anything outside its own shapes loudly. It was pinned per the project's family-matrix discipline — every element family × shape × operation, not just the float8[] that crashed. ## The coda: -0 dies inside the database One more thing surfaced en route, and it is not fixable downstream at all. to_jsonb('-0'::float8) returns 0 on PostgreSQL 16, while the SQL read path's float8out faithfully says -0. The reason is that jsonb numbers are numeric, and numeric has no signed zero. So any capture format that routes a value through the database's own JSON type destroys a float's zero sign inside the source database, before the pipeline ever sees the payload — there is nothing a decoder or applier can do about information the capture already threw away. Bulk copy and slot-based CDC of the same value carry the sign fine; only the JSON-mediated trigger capture loses it. It ships as a documented, pinned capture-fidelity limitation. ## The transferable lesson A capture format that routes values through the database's own JSON type inherits that type's number semantics — no non-finite tokens, no signed zero, one lossy numeric. And a payload decoder must dispatch on the destination type family, never on what the payload value happens to look like: the same three characters, "Infinity", are a float in one column and a string in the next. When a strict decode crashes loudly on a value it can't place, that is the type contract doing its job — the dangerous fix is the one that makes the crash go away by guessing. ## Primary sources - PostgreSQL documentation — JSON types (jsonb numbers are numeric; no non-finite values, no signed zero) and to_jsonb(). - sluice v0.99.263 changelog and the RDS-Postgres validation report (F3) — the live crash-loop, the type-aware array-element decoder, the family-matrix pins, and the to_jsonb('-0'::float8) → 0 capture-fidelity wart. - Related field notes — Vitess copy phase rounds your FLOATs (another “the loss is in the capture format” story) and the pgx codec that flattened numeric[][] (the array-family-matrix discipline). =============================================================== # The privilege catalog is not the permission system (https://sluicesync.com/field-notes/privilege-catalog-not-permission-system/) Three live-proven ways a cloud provider's privilege catalog lied, in both directions. On RDS the master role shows rolreplication=f permanently yet creates and drops logical slots, because the capability is gated on membership in rds_replication, not the role attribute — so the stock "rolsuper OR rolreplication" preflight false-refuses the entire platform. On RDS MySQL, SHOW GRANTS shows RELOAD yet FLUSH TABLES WITH READ LOCK returns 1045. And a capability probe checked pg_create_event_trigger — a predefined role that exists in no stock PostgreSQL. The catalog describes a permission model; it is not the permission model. Observed — live throwaway probes of AWS RDS PostgreSQL 16 and RDS MySQL during the v0.99.263 validation cycle (2026-07-16), each instance created, exercised, and torn down. The sluice-side false refusals fixed in v0.99.263; the platform behaviors are AWS by design. All three are ways the privilege catalog — pg_roles, SHOW GRANTS, a predefined-role membership check — disagreed with the permission system that actually adjudicates the operation. ## False negative: the role that can't replicate, but does The RDS master role shows rolsuper=f, rolreplication=f — permanently, by design — and yet it creates and drops logical replication slots without complaint. RDS gates that capability on membership in the rds_replication role, not on the rolreplication attribute the catalog exposes. Any preflight that checks rolsuper OR rolreplication — the obvious, stock-correct query, and the one sluice shipped — false-refuses the entire platform, and by the same model all of Aurora. Worse, the refusal was deliberately given no opt-out flag, on the premise that “the role genuinely cannot create a slot” — a premise RDS quietly falsifies — and all three of its recovery suggestions were wrong or lossy there, so an RDS user could never even reach a refusal that contained a remedy that worked. The fix keeps the catalog check but adds the membership rule: pg_has_role(current_user, 'rds_replication', 'MEMBER'), guarded on the role's existence so a stock server still reads cleanly. ## False positive: the grant that's present but not honored On RDS MySQL, SHOW GRANTS for the master user lists RELOAD — the privilege FLUSH TABLES WITH READ LOCK requires. Run FTWRL anyway and it returns 1045 Access denied. The platform blocks the operation above the grant layer, so the classic “grant RELOAD and retry” remediation is a dead end no grant can open. The honest hint sluice now emits doesn't promise a grant fixes it: it says the lock-free fallback is expected here, and to quiesce writers if the brief no-freeze window matters for the snapshot's consistency. ## The probe that was wrong everywhere The event-trigger capability check tested membership in pg_create_event_trigger — a predefined role that exists in no stock PostgreSQL. PG 16's only pg_create* predefined role is pg_create_subscription. So the check evaluated to “not a member” on every host, making that capability tier effectively superuser-only everywhere, and it was doubly wrong on RDS, where the master role can create event triggers. This one wasn't a provider quirk at all — it was a phantom role name that made the catalog probe meaningless on every server. ## Why the fixes use different probe strategies The transferable part is that the two Postgres hazards demanded opposite probe strategies, for a concrete reason. The event-trigger check became attempt-based: CREATE EVENT TRIGGER inside a transaction that always rolls back. An attempt is the only check that can't drift from a provider's patched permission model — it asks the permission system the exact question instead of reading a catalog that may not reflect it. But the slot check deliberately stayed catalog-based, with the membership rule added, because slot creation is non-transactional: a crashed attempt-probe leaks a slot that pins WAL on the source until someone drops it, and slot creation also fails for non-permission reasons an attempt would mis-attribute to a permission problem. The rule is: attempt-probe a capability when the attempt is cheap and fully reversible; read the catalog (correctly) when the attempt has a cost or a side effect a rollback can't undo. Closing beat, same provider: RDS couples wal_level to automated backups — set retention to 0 and wal_level drops to minimal, one notch below the replica every CDC doc assumes — so the cost-minimized instance is two steps from CDC-ready, not one. ## The transferable lesson A privilege catalog is a description of a permission model, maintained by the same people who can override the model out of band — so on a managed platform it lies in both directions: a capability you have that the catalog denies (membership-gated, not attribute-gated), and a capability the catalog grants that the platform blocks above it. When correctness depends on whether an operation will actually succeed, and the operation is cheap and reversible, ask by attempting it inside a rolled-back transaction rather than by reading a catalog row. And when the attempt is not reversible — a slot that leaks, a change that persists — read the catalog, but read the whole permission model, membership rules included, not just the one attribute column that looks like the answer. ## Primary sources - AWS RDS documentation — rds_replication role membership for logical replication; mysql.rds_* configuration and the RELOAD/FTWRL restriction on managed MySQL; the wal_level / backup-retention coupling. - PostgreSQL documentation — predefined roles (pg_create_subscription; there is no pg_create_event_trigger) and pg_has_role(). - sluice v0.99.263 changelog and the RDS-Postgres / RDS-MySQL probe reports — the false-refusal fix, the membership-aware slot preflight, the attempt-based event-trigger probe, and the FTWRL and wal_level findings. - Related field note — replication slots don't die with your process (why an attempt-probe that leaks a slot is the strategy you avoid). =============================================================== # The heartbeat aged seven hours at write time (https://sluicesync.com/field-notes/heartbeat-aged-seven-hours/) A concurrent-run guard refuses to start a second backfill walk while the state row's heartbeat is fresher than five minutes. The heartbeat was a bare timestamp written with CURRENT_TIMESTAMP — which Postgres renders in the server's timezone before storing — while the reading driver scans a bare timestamp back as UTC. Two individually defensible behaviors compose into an age reading wrong by exactly the server's UTC offset, and the sign of that offset picks your failure mode. It shipped because every CI container runs UTC, where the bug is arithmetically invisible. Observed — a 2026-07-16 audit of sluice's backfill concurrent-run guard, differential-verified live in the fix (v0.99.263): pre-fix, a heartbeat read +7h old on America/Los_Angeles and -2h old on Europe/Berlin. The affected guard shipped in v0.99.260, so the live-bug window was v0.99.260–262 on non-UTC PostgreSQL servers. The MySQL side was already correct (the connection forces its session time_zone). ## Two defensible behaviors, composed The guard is textbook: don't start a second backfill walk while the state row's heartbeat is newer than five minutes, so a live walk isn't joined by a duplicate. The heartbeat column was a bare timestamp (no time zone), written with CURRENT_TIMESTAMP. Each half is reasonable on its own: - PostgreSQL's CURRENT_TIMESTAMP is a timestamptz; assigning it into a bare timestamp column converts it to the server's TimeZone and drops the offset. So the stored wall-clock digits are in the server's local zone. - The reading driver (pgx) scans a bare timestamp back as a time.Time in UTC — it has no zone to attach, so it labels the naive value UTC. Compose them and the age computation — time.Now() minus the scanned heartbeat — is wrong by exactly the server's UTC offset, because the value was written in one frame and read in another. Nothing in the code path is individually incorrect; the bug lives in the seam between the write's zone and the read's assumption. ## The sign of the offset picks your failure Which way it breaks depends on which side of UTC the server sits: - Server behind UTC (e.g. America/Los_Angeles, -7h): a heartbeat written one second ago reads seven hours old. The guard believes the previous walk is long dead, so a second walk starts and the two interleave — the exact hazard the guard exists to prevent. Silent: no error, just a guard that has quietly stopped guarding. - Server ahead of UTC (e.g. Europe/Berlin, +2h): a crashed run's genuinely-stale heartbeat reads two hours in the future, i.e. perpetually fresh, so a legitimate resume is falsely refused until the offset drains. Both were reproduced live in the fix — +7h of phantom age on Los Angeles, -2h on Berlin. ## Why it shipped Every CI container runs in UTC, where the server offset is zero and the two zone assumptions cancel exactly. A naive-timestamp freshness bug is arithmetically invisible under UTC and arms itself the moment someone's managed instance defaults to a regional zone — which many do. A test suite that never leaves UTC cannot see it; a single non-UTC container in the matrix would have. ## What sluice does about it The fix is one expression: write the heartbeat as timezone('utc', now()) so the stored naive value is UTC, matching what pgx assumes on read (storing a timestamptz outright works too). The codebase had already solved the identical class once — the shard-consolidation control table compares its lease against timezone('utc', now()) — which is the tell that this is a recurring class, not a one-off: freshness math breaks wherever a client clock meets a server-written naive timestamp. ## The transferable lesson Never compare time.Now() against a bare TIMESTAMP the server filled in with its own local-zone function. A timestamp without a time zone is a value whose meaning depends on who reads it, and a freshness computation puts the writer and the reader on opposite sides of that ambiguity. Either store an explicit timestamptz, or write the naive column through timezone('utc', now()) and read it as UTC — and put at least one non-UTC server in your test matrix, because the whole failure class is invisible at offset zero. ## Primary sources - PostgreSQL documentation — date/time functions (CURRENT_TIMESTAMP is timestamptz; conversion into timestamp without time zone uses the session TimeZone) and the timezone() AT TIME ZONE construct. - pgx documentation — scanning a timestamp without time zone yields a UTC time.Time. - sluice v0.99.263 changelog (M1.3) and the 2026-07-16 audit finding — the live +7h/-2h differential and the timezone('utc', now()) fix, plus the pre-existing control-table sibling. =============================================================== # MariaDB reports its defaults in a different dialect (https://sluicesync.com/field-notes/mariadb-catalog-default-dialect/) "MariaDB is basically MySQL" survives the wire protocol and dies in information_schema. Point the same DDL at both and COLUMN_DEFAULT speaks two dialects: MySQL 8 reports a string default as bare abc, MariaDB reports 'abc' with the quotes; a defaultless nullable column is SQL NULL on MySQL and the four-character string NULL on MariaDB; DEFAULT CURRENT_TIMESTAMP reads as current_timestamp() with an empty extra. A MySQL-convention reader emits corrupted defaults — and MariaDB's SYSTEM VERSIONED tables and SEQUENCEs vanish behind the near-universal table_type='BASE TABLE' filter. Observed — scoping the mariadb flavor for sluice, side by side on mariadb:11.4, mariadb:10.11, and mysql:8.4 (2026-07-16); the flavor shipped in v0.99.268 (ADR-0168). Before the flavor existed, every MariaDB-source operation failed loudly on one MySQL-8-only catalog column, so no silent-loss path was ever reachable in a released version — and the shim that keeps it that way had to land the same instant that protective wall came down. ## The defaults table speaks a different dialect MariaDB and MySQL 8 share a wire protocol, most of information_schema, and almost all DDL. They do not share how COLUMNS.COLUMN_DEFAULT renders a default. On identical DDL: MySQL 8 MariaDB DEFAULT 'abc' abc 'abc' (quotes kept) nullable, no default NULL (SQL NULL) NULL (the 4-char string) DEFAULT CURRENT_TIMESTAMP CURRENT_TIMESTAMP current_timestamp() extra DEFAULT_GENERATED (empty) A reader written to MySQL 8's conventions, pointed at MariaDB, therefore: emits string defaults with literal embedded quotes; gives every defaultless nullable column the literal word NULL as its default (silent on a text target, which happily stores the string “NULL”); and turns the timestamp expression into the string 'current_timestamp()' — loud 1067 Invalid default value on a datetime column, silent on a varchar. That is a silent default-corruption class hiding entirely behind the “basically MySQL” assumption. ## The tables your BASE-TABLE filter can't see The second silent class is enumeration. MariaDB reports SYSTEM VERSIONED (temporal) tables and SEQUENCE objects as distinct table_type values, so the near-universal table_type = 'BASE TABLE' filter — the one every “list the tables to copy” query uses — silently skips data-bearing objects. It is the same class as an unguarded enumerate-and-copy missing old-style inheritance parents or foreign tables on Postgres: the filter that looks like a safe way to exclude views quietly excludes real data. And MariaDB's JSON is a longtext alias whose type identity survives only in an auto-generated json_valid() CHECK constraint — so even “is this column JSON?” has a MariaDB-specific answer. ## The wall that was accidentally protective Here is the ironic part. Before the flavor shipped, every one of those corruptions was unreachable — because one MySQL-8.0-only catalog column that sluice's reader selected (columns.srs_id, absent on MariaDB) made every MariaDB-source operation fail loudly, up front, before any default or table-type could be mis-read. The error wall was accidentally protecting users from the dialect gap behind it. Which is exactly why “just fix the failing query” was the dangerous patch: removing the wall without simultaneously handling the defaults dialect and the invisible objects would have converted a loud failure into the silent corruptions above. ## What sluice does about it The v0.99.268 mariadb flavor landed the catalog-query fix and the compensating shim atomically — the discipline the scoping work flagged as mandatory. translateMariaDBDefault normalizes every default shape (quoted string literals with '' doubling and the \0 \n \r \\ schema-metadata escape set, including NUL-bearing binary defaults re-encoded to MySQL's 0x… hex; the bare keyword NULL for defaultless-nullable; current_timestamp()-with-empty-extra canonicalized) to a byte-identical IR read. The invisible-object census now refuses loudly, naming every SYSTEM VERSIONED table and SEQUENCE rather than skipping it. The two MySQL-8-only columns select constants on the MariaDB variant. A server-fingerprint guard closes the last hole in both directions: the mariadb flavor refuses a non-MariaDB server (the shim would actively mis-read MySQL's bare abc defaults as expressions — coded SLUICE-E-DRIVER-HOST-MISMATCH), while a plain mysql connection whose VERSION() carries -MariaDB WARNs toward the mariadb driver. ## The transferable lesson “Compatible” databases diverge exactly where you stop looking — not in the wire protocol you tested, but in how information_schema renders a default or classifies a table. When you port a catalog reader to a claimed-compatible engine, diff the metadata output on identical DDL rather than trusting the compatibility label. And when a query is failing loudly on the new engine, treat that failure as possibly load-bearing: it may be the only thing standing between your users and a silent-corruption class behind it, so the fix that removes the wall and the shim that handles what the wall was hiding must ship in the same change, never one without the other. ## Primary sources - sluice ADR-0168 (MariaDB flavor Phase 1) and CHANGELOG 0.99.268; the scoping probe on mariadb:11.4, mariadb:10.11, and mysql:8.4. - MariaDB Knowledge Base — information_schema.COLUMNS (COLUMN_DEFAULT quoting and the NULL-string convention), system-versioned tables and sequences (TABLE_TYPE), and the implicit json_valid() CHECK on JSON columns. - MySQL 8.0 Reference Manual — information_schema.COLUMNS (bare default rendering, DEFAULT_GENERATED extra) and SRS_ID. - Related field notes — the parent table that returns rows it doesn't own (the BASE-TABLE-filter miss on Postgres) and the join that's 1:1 on MySQL 8 and fans out on MariaDB (more MariaDB catalog divergence). =============================================================== # MariaDB 11.4's default collation doesn't exist on MySQL 8 (https://sluicesync.com/field-notes/mariadb-default-collation-remap/) Migrate a MariaDB 11.4 schema to a MySQL-family target and sluice WARNs on nearly every string column — because 11.4 made utf8mb4_uca1400_ai_ci (UCA 14.0.0) the server default, and no MySQL 8 server implements it. sluice maps each affected column to the closest equivalent MySQL 8 has, utf8mb4_0900_ai_ci (UCA 9.0.0), preserves every byte, and surfaces the swap. This note is why that WARN is correct-by-design, not a data problem: the bytes are intact; only the collation weights (and PAD semantics) for a small set of characters change. Observed — live-validating MariaDB as a migrate source (2026-07-17): MariaDB 11.4 → vanilla MySQL 8.4 and MariaDB 11.4 → PlanetScale (MySQL) on the shipped mariadb flavor (v0.99.268+). Both runs completed all seven tables with a clean verify --depth count — and both logged a collation WARN on essentially every string column. Nothing was wrong. This note is why that WARN is the tool being honest, not a failure. ## A warning on almost every VARCHAR Point sluice migrate at a MariaDB 11.4 source landing on a MySQL or PlanetScale target and the log fills with a repeating line, once per table, naming each string column: WARN mysql: column data is preserved; some source collations do not exist on this target's server family, so the closest equivalent is used (edge-case sort/comparison order may differ — UCA version and PAD semantics) table=customers columns="email (utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci)" On a schema of any width that is a lot of WARNs — it fires on essentially every VARCHAR/TEXT/CHAR column of an 11.4 source. The volume is alarming the first time you see it. It is also entirely expected, and it means exactly what it says. ## Where the collation went MariaDB 11.4 changed its server default collation to utf8mb4_uca1400_ai_ci — the Unicode Collation Algorithm 14.0.0 weight tables, accent- and case-insensitive. So a column declared with no explicit COLLATE clause on an 11.4 server inherits uca1400, and that is now the common case rather than the exotic one. MySQL 8's newest UCA collation is utf8mb4_0900_ai_ci — UCA 9.0.0. There is no uca1400 collation on any MySQL 8 server, of any flavor. A CREATE TABLE that names utf8mb4_uca1400_ai_ci on a MySQL 8 target fails outright with Error 1273: Unknown collation. The two engines' newest Unicode collations are five UCA revisions apart, and the names don't overlap. ## What sluice does — and why it isn't loss sluice's cross-flavor collation remap (roadmap item 73) maps every utf8mb4_uca1400_* collation to the closest same-semantics collation the MySQL 8 target actually implements — utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci, and the accent/case-sensitive and binary variants alongside it — so the emitted DDL is valid on the whole supported MySQL-family floor instead of dying on Error 1273. Crucially, the remap is never silent: the CREATE-TABLE path emits one WARN per table naming each remapped column, and the ALTER paths emit one per column, both carrying the same message — “column data is preserved … the closest equivalent is used …”. That first clause is the whole point. A collation is a comparison and sort rule, not an encoding — it does not touch the stored bytes. Every string value migrates byte-for-byte identical; what the remap changes is only the rule the target uses to order and compare those bytes. And even that rule is almost the same: UCA 14.0.0 and UCA 9.0.0 assign identical weights to the overwhelming majority of characters, differing only for scripts and codepoints added or reweighted between the two Unicode revisions. The one genuine semantic delta beyond that is PAD: the uca1400 collations are PAD SPACE while the 0900 set is NO PAD, so trailing-space handling in comparisons can differ. sluice deliberately declines to guess for language-specific tailorings (utf8mb4_uca1400_de_* and friends): those pass through verbatim and fail loudly on a target that lacks them, rather than silently substituting a different language's collation table. ## When to actually care For the common case — application text compared for equality, sorted for display, indexed for lookup — the closest-equivalent mapping is correct and you can ignore the WARN. It matters only if your application depends on the exact collation ordering of characters that were reweighted between UCA 9.0.0 and 14.0.0, or on PAD SPACE trailing-space equality. If it does, review the named columns and set an explicit COLLATE that both engines share (or one you have validated on the target), rather than relying on the inherited default. A future UX polish could de-noise the per-column storm into a single per-run summary; the honesty of surfacing every swap is the part that must not change. ## The transferable lesson “Compatible” engines drift at their defaults, and a version bump can move a default from exotic to universal overnight — MariaDB 11.4 turned uca1400 from a column you had to ask for into the collation every string column inherits. When the target can't reproduce a source attribute exactly, the right move is a preserving substitution that fails loud when it can't be honest, and a WARN that says precisely what changed and what didn't. A warning on every column is not the tool struggling; it is the tool refusing to let a semantic difference cross the boundary unannounced. Read the message: “column data is preserved” is a promise, not a hedge. ## Primary sources - sluice roadmap item 73 (MariaDB flavor) — crossFlavorCollationRemap and the utf8mb4_uca1400_* ↔ utf8mb4_0900_* remap tables in internal/engines/mysql, with the per-table (CREATE) and per-column (ALTER) WARNs; live-validated on mariadb:11.4 → mysql:8.4 and → PlanetScale (2026-07-17). - MariaDB Knowledge Base — Unicode collation versions; utf8mb4_uca1400_ai_ci as the 11.4 default (UCA 14.0.0, PAD SPACE). - MySQL 8.0 Reference Manual — utf8mb4_0900_ai_ci (UCA 9.0.0, NO PAD); Error 1273 Unknown collation. - Related — the Migrating from MariaDB guide (this WARN in context), and sibling MariaDB notes: MariaDB reports its defaults in a different dialect and MariaDB and MySQL 8 disagree on which coordinate comes first (another correct-by-design MariaDB divergence). =============================================================== # MariaDB and MySQL 8 disagree on which coordinate comes first (https://sluicesync.com/field-notes/mariadb-geometry-axis-order/) Migrate a POINT in SRID 4326 from MariaDB to MySQL 8 and a naive ST_AsText diff shows the longitude and latitude swapped — POINT(-122.4194 37.7749) on the source reads POINT(37.7749 -122.4194) on the target. Nothing is corrupt. sluice copied the WKB faithfully and re-attached the SRID; the point is in the same place, and ST_Latitude/ST_Longitude match to the digit. The two engines just default to opposite axis orders when they render a geographic SRID as text. Compare the coordinates, not the string. Observed — live-validating MariaDB as a migrate source (2026-07-17): MariaDB 11.4 → vanilla MySQL 8.4 and MariaDB 11.4 → PlanetScale (MySQL) on the shipped mariadb flavor. A places table with a POINT REF_SYSTEM_ID=4326 column migrated with the SRID carried and a clean row count — and a first-glance ST_AsText comparison showed the coordinates reversed. This note is why that reversal is a display convention, not data loss. ## The diff that looks like corruption The source point on MariaDB renders as POINT(-122.4194 37.7749) — longitude first. Read the same migrated point back on the MySQL 8 target with plain ST_AsText and you get POINT(37.7749 -122.4194) — latitude first. If your validation is a text diff of ST_AsText(geom) source vs target, every SRID-4326 geometry flags as a mismatch, and a raw byte comparison of ST_AsBinary(geom) differs too. It reads exactly like the coordinates were transposed in flight. ## What sluice actually moved sluice's geometry value contract (docs/value-types.md) is raw WKB — Well-Known Binary — carried as bytes, with the column's SRID recorded separately (read, for MariaDB, from the OGC-standard information_schema.GEOMETRY_COLUMNS view, since MariaDB won't echo the SRID any other way — see the sibling note below). On write, the MySQL row writer prepends MySQL's on-wire prefix to that byte-identical WKB payload and hands it to the server. WKB, by the OGC standard, is always stored in Cartesian x-y (longitude-latitude) order regardless of SRID, and ST_GeomFromWKB reads it that way — so the point MySQL stores is the same point MariaDB held. The proof is in the coordinate accessors, which are axis-order-agnostic: on the target, ST_Latitude(geom) = 37.7749 and ST_Longitude(geom) = -122.4194 — matching the source to the digit. The location did not move. ## Two engines, two default axis orders What differs is only how each engine renders a geographic SRID back to text and to output-WKB. EPSG:4326 formally defines its axis order as latitude-then-longitude. MySQL 8 honors that: its ST_AsText and ST_AsBinary default to lat-long for a geographic SRS. MariaDB defaults to long-lat (x-y) display for the same SRID. Same stored point, two conventions for printing it — so the text renderings disagree while the geometry is identical. It is the geospatial cousin of comparing two dates by their formatted strings: 07/08 and 08/07 can be the same day. ## How to compare correctly Do not diff ST_AsText(geom) or ST_AsBinary(geom) across the two engines. Compare with the axis-order-agnostic accessors — ST_Latitude(geom) and ST_Longitude(geom) — or pin the rendering explicitly: on the MySQL 8 target, ST_AsText(geom, 'axis-order=long-lat') reproduces the MariaDB source string exactly. Either check confirms what the migration actually did: preserved the point, byte-faithful in the WKB and correct in its SRID. ## The transferable lesson A byte-level or text-level diff of a geometry across engines tests the engines' rendering conventions, not the fidelity of the value. sluice's job at the boundary is to move the WKB and the SRID intact, and it does — but SRID 4326 carries a standardized axis order that MySQL applies to its output functions and MariaDB does not, so the honest comparison is one that reads the coordinates by name, not by position. When a spatial value “looks swapped” after a cross-engine copy, reach for ST_Latitude/ST_Longitude before you reach for the panic button; a naive ST_AsText diff is comparing two spellings of the same place. ## Primary sources - sluice geometry value contract (docs/value-types.md: ir.Geometry = raw WKB) and the MySQL row writer's prefix; live-validated mariadb:11.4 → mysql:8.4 and → PlanetScale (2026-07-17), ST_Latitude/ST_Longitude matched source to target. - MySQL 8.0 Reference Manual — geographic-SRS axis order; the axis-order option to ST_AsText/ST_GeomFromText; WKB is SRS-independent x-y. - MariaDB Knowledge Base — geometry ST_AsText / axis-order handling for SRID 4326. - Related — the Migrating from MariaDB guide, and sibling MariaDB notes: MariaDB accepts a geometry SRID it won't show you (where the SRID lives) and MariaDB 11.4's default collation doesn't exist on MySQL 8 (another correct-by-design MariaDB divergence). =============================================================== # The read replica is a better migrate source and a worse CDC source than the docs (https://sluicesync.com/field-notes/read-replica-source-pg16/) "You can't do logical replication from a read replica" is Postgres ≤15 lore, and PG 16 quietly flipped both halves of it — in opposite directions for a migration tool. On the read side it got better than the docs: pg_export_snapshot() now works on a standby, so a parallel bulk copy from a replica is fully snapshot-consistent. On the CDC side it got more fragile than "impossible": a slot can be created on a PG 16+ standby, but CREATE_REPLICATION_SLOT blocks until the idle primary emits its next running-xacts record, and the publication DDL CDC needs can't run on a hot standby at all. Observed — a live probe of a Supabase PostgreSQL 17.6.1 read replica (us-west-1, 2026-07-17) on the shipped v0.99.263–265 binaries; the coded refusal shipped in v0.99.267 (filed as Bug 197). “CDC from a standby is unsupported by design” is a Postgres-platform truth, not a sluice limitation — but which parts of that truth still hold depends on your major version, and PG 16 moved the line under both halves of the old rule. ## The read side got better than the docs promise Under Postgres ≤15, pg_export_snapshot() errored during recovery, so a parallel bulk copy from a standby could not pin all its reader connections to one consistent snapshot — it fell back to independent per-connection reads with no cross-table consistency. PG 16 lifted that restriction as part of the logical-decoding-on-standby work. So on a Supabase PG 17 replica, sluice's parallel readers all pinned to one shared exported snapshot and took a fully snapshot-consistent copy from a replica — the probe confirmed the shared snapshot engaged (pg_export_snapshot() returned a handle inside a REPEATABLE READ READ ONLY transaction with no error). sluice's own code comment still claimed the old fallback, now correctly re-scoped to PG ≤15. A read replica is a legitimately good, consistency-preserving bulk-migrate source on modern Postgres — better than the ≤15 lore says. ## The CDC side got more fragile than “impossible” The same PG 16 work made the CDC story worse in a subtler way than a flat “no.” A logical slot can be created on a PG 16+ standby now — but CREATE_REPLICATION_SLOT blocks until the primary emits its next xl_running_xacts WAL record. On an idle primary that record simply doesn't come, so the call hangs (≥2 minutes observed before it eventually proceeded once activity resumed). The documented nudge, pg_log_standby_snapshot(), is superuser-only, and managed platforms withhold it — Supabase returns permission denied. So the slot creation that “works” on a standby can stall indefinitely with no visible cause and no operator lever to unstick it. sluice never even reaches that stall, because a step before it is a harder wall: CDC has to CREATE or ALTER its publication on the source, and publication DDL cannot run inside a read-only transaction on a hot standby at all. Pre-create a FOR ALL TABLES publication and sluice drops it to re-scope, which is itself a write; a scoped ALTER PUBLICATION … SET TABLE runs unconditionally on the same path. Either way the replica answers with cannot execute CREATE PUBLICATION in a read-only transaction (SQLSTATE 25006) — a raw error that reads like a sluice bug rather than a platform boundary. ## What sluice does about it Since v0.99.267, sluice detects a standby source before it tries the publication write and refuses with a coded SLUICE-E-CDC-STANDBY-SOURCE that names the source as a read replica, steers to the primary endpoint for CDC, and notes that the replica remains a perfectly good bulk-migrate source. A belt on the raw SQLSTATE 25006 catches the same condition if it surfaces through a path the preflight didn't cover, so the operator never sees the steering-free raw error that looks like an internal failure. The migrate-from-standby capability is left fully intact — it is a bonus, not a thing to warn about. ## The transferable lesson “Replicas can't decode” is a rule with a version number on it, and Postgres 16 moved the boundary under it in both directions at once — so a tool that treats the ≤15 lore as timeless is wrong twice: it under-uses the replica as a bulk source (the consistent snapshot is available now) and mis-describes the CDC wall (it isn't the slot that's impossible — it's the publication write, plus an idle-primary running-xacts stall the slot creation hides behind). When a capability is gated on the server's recovery state, pin down which major version you're on and which specific operation the platform actually blocks, and turn the raw platform error into a coded refusal that names the real boundary — because 25006 on its own reads like your bug, not the standby's rule. ## Primary sources - PostgreSQL documentation — pg_export_snapshot() (usable during recovery from PG 16), logical decoding on standbys, and pg_log_standby_snapshot() (superuser-only). - PostgreSQL error 25006 (read_only_sql_transaction) — why CREATE PUBLICATION can't run on a hot standby. - sluice v0.99.267 changelog and the Supabase read-replica probe report (Bug 197) — the engaged shared snapshot on the standby, the idle-primary slot-creation hang, and the coded SLUICE-E-CDC-STANDBY-SOURCE refusal plus its 25006 belt. - Related field note — every HA knob on, and the slot still vanished at failover (more standby/slot lifecycle surprises). =============================================================== # gocloud classifies "301" by substring — and ~2% of your S3 request IDs contain it (https://sluicesync.com/field-notes/gocloud-classifies-301-by-substring/) sluice's backup-chain concurrent-writer guard is a compare-and-swap on S3's create-only conditional PUT: the loser of two racing PUTs gets a 412 PreconditionFailed, mapped to a coded conflict refusal. It was reading that 412 through gocloud's portable error class — and gocloud's s3blob classifier carries strings.Contains(err.Error(), "301"). S3 stamps a random hex RequestID on every response, so whenever that hex happens to contain the digits 301 — about 2% of requests — a genuine 412 is misclassified as NoSuchBucket. Classify from the structured API error, never from a substring of the rendered one. Observed — surfaced as a v0.99.268 tag-CI flake on TestBlobStore_MinIO_ConditionalPutChainGuard, root-caused and fixed in v0.99.269. No data loss: the misclassification turned a coded conflict refusal into a confusing “not found,” loud either way — but the guard's whole job is to be legible at the moment two writers race, and this made it lie about why it refused. The gocloud substring hack is present and unchanged in the current release; sluice works around it locally. ## The guard and the status code it keys on sluice's backup-chain concurrent-writer guard is a compare-and-swap built on S3's create-only conditional PUT (If-None-Match: *): when two writers race to claim the same chain slot, the loser's PUT is rejected with 412 PreconditionFailed, which sluice maps to “someone else won, refuse loudly” — the coded SLUICE-E-BACKUP-CHAIN-CONFLICT. The correctness of that guard depends on reliably telling a 412 apart from every other S3 error. ## The 2% misclassifier sluice was reading the 412 through gocloud's portable error class rather than the raw API error. And gocloud's s3blob driver classifies some errors by substring-matching the rendered error string: strings.Contains(err.Error(), "301"), intended to catch an S3 invalid-bucket 301 redirect and map it to NoSuchBucket. The problem is what else contains the digits 301. S3 stamps a random hex RequestID (and HostID) on every response and folds them into the rendered error string. Whenever that hex happens to contain the substring 301 — about 2% of requests — a genuine 412 gets classified as NoSuchBucket, which gocloud surfaces as NotFound. So the losing writer, perhaps 2% of the time, saw a baffling “not found” instead of the coded chain-conflict — and it stayed invisible until a CI run happened to draw RequestID 18C30130E2747EAB and flaked the MinIO CAS test. That same ~2% was latent in production the whole time for any real chain-conflict loser; CI just drew the unlucky hex first. ## The fix: read the structured code The fix stops reading the rendered string entirely. sluice inspects the structured smithy API error — apiErr.ErrorCode() == "PreconditionFailed" — which is a field the AWS SDK populates from the response, not a phrase in a human-readable message. It falls back to gocloud's derived class only for the fileblob/memblob drivers, which carry no API error to inspect. A three-digit HTTP status is a needle; the rendered error string is a haystack full of opaque identifiers that can contain that needle by chance. ## The transferable lesson Never classify an error by substring-matching its rendered text when a structured code is available. A rendered error string is written for humans and, on cloud APIs, salted with random per-request identifiers — request IDs, host IDs, trace tokens — so any digit sequence you match on will eventually appear by chance in one of them. A heuristic that is “usually right” on error text is a probabilistic misclassifier the instant that text carries a random ID: it doesn't fail loudly, it fails a stable small fraction of the time, which is the hardest kind of bug to catch. Reach past the portability layer to the provider's structured error code, and keep the string-matching only for backends that genuinely have nothing else. ## Primary sources - sluice v0.99.269 changelog and fix commit — the smithy ErrorCode() detection with a fileblob/memblob fallback, and the RequestID 18C30130E2747EAB flake that surfaced it. - gocloud.dev blob/s3blob — the portable error classification, including the 301 substring check in the vendored version. - AWS SDK for Go v2 (smithy) — the structured API error type and ErrorCode(); S3's RequestId/HostId response fields. - Related field note — object stores can now say “that changed since you read it” (the create-only CAS this conflict-classification lives inside). =============================================================== # ACTIVE_HEALTHY through a five-minute recovery (https://sluicesync.com/field-notes/active-healthy-not-liveness/) Flooding a 1 GB Supabase Micro instance with WAL pushed it into crash recovery — FATAL 57P03, every connection refused for five and a half minutes while it replayed — and the Supabase Management API kept reporting status=ACTIVE_HEALTHY. A control-plane status field is an assertion of intent, not a data-plane liveness signal, so it is misleading for backend readiness: probe with a real query. The finding rode a separate, concrete result: a logical replication slot's WAL runway is set by the compute tier (512 MB on Micro, 2 GB on Small), not by the PITR add-on, live-proven with a paired differential. Observed — a live probe on a Supabase PostgreSQL 17.6.1 project (us-west-1, 2026-07-17, torn down). No data loss: the WAL was synthetic logical messages, the corpus untouched. The slot-runway advisory shipped field-validated; the ACTIVE_HEALTHY-through-crash-recovery behavior is a Supabase Management API observation, not a sluice bug. Both halves matter to anyone automating a migration against a managed provider's control plane. ## The slot's WAL runway is the compute tier's, not the PITR add-on's A detached or slow logical replication slot survives only as long as the server is willing to retain the WAL it hasn't consumed — governed by max_slot_wal_keep_size. On Supabase that ceiling is set by compute tier: 512 MB on Micro, 2048 MB on Small, applied at the resize restart. It is not, as one might assume, a function of the point-in-time-recovery add-on. A paired differential made this concrete. A detached pgoutput slot (active=false) on a Small instance climbed to 1377 MB of retained WAL still at wal_status='reserved' — about 2.7× the Micro ceiling, safe_wal_size still +720 MB, never even reaching extended. The identical detached slot on a Micro went wal_status='unreserved' with a negative safe_wal_size at 551 MB, then wal_status='lost' with invalidation_reason='wal_removed' after the next checkpoint — a forced re-snapshot. So the lever for a wider detach or downtime window is a compute bump (~$15/mo to Small for 2 GB), not the ~$100/mo PITR add-on — which only reaches 2 GB transitively because it requires Small. ## The status field stayed green through crash recovery The sharper finding surfaced while pushing the Micro repro. Bulk-generating multiple GB of incompressible WAL at ~150 MB/s against a 1 GB-RAM Micro drove the instance into crash recovery: FATAL 57P03: the database system is not accepting connections / Hot standby mode is disabled, every connection refused for about five and a half minutes (16:38:34→16:44:10 UTC) while it replayed WAL. Throughout that window, the Supabase Management API kept reporting status=ACTIVE_HEALTHY. The control-plane status field did not reflect the in-database outage — it is an assertion about the platform's intent for the instance, not a probe of whether the backend is accepting queries, so it is misleading if you read it as backend liveness. There is a grim symmetry in how it ended: the checkpoint that completed crash recovery is precisely the one that flipped the over-budget slot from unreserved to lost. The slot outlived the crash and died on the checkpoint that ended it. ## What this means for a migration tool sluice's slot-runway advisory now reflects the compute-tier ceiling rather than treating retention as a PITR property — the guidance for widening a detach window is a compute bump, stated where it matters. But the status-field lesson generalizes past any one tool: a snapshot-then-CDC migration that polls a provider's control-plane status to decide whether the source is healthy enough to proceed can be told “healthy” while every connection is being refused. The only trustworthy liveness signal is a real query against the backend — SELECT 1, or the slot's own pg_replication_slots row — not the dashboard's green. ## The transferable lesson A managed provider's status endpoint is a control-plane assertion, not a data-plane liveness guarantee. An undersized or overloaded instance can be in crash recovery, refusing every connection, while the API and dashboard stay ACTIVE_HEALTHY — so probe the backend with a real query before you trust “healthy,” especially before a step that assumes the source is reachable. And when you reason about how long a replication slot can survive a detach or a slow consumer, find the setting that actually governs the WAL runway — here the compute tier, not the backup add-on the intuition reaches for — and validate it with a paired differential rather than inferring it from the pricing page. ## Primary sources - PostgreSQL documentation — pg_replication_slots (wal_status reserved/extended/unreserved/lost, safe_wal_size) and max_slot_wal_keep_size; error 57P03 (cannot_connect_now) during recovery. - Supabase — compute add-ons and per-tier max_slot_wal_keep_size (512 MB Micro / 2048 MB Small), the Management API project-status endpoint, and PITR (requires Small). - sluice managed-services advisory (compute-tier slot runway) and the 2026-07-17 Supabase compute-tier probe — the paired Small-vs-Micro slot-survival differential and the crash-recovery-through-ACTIVE_HEALTHY incident. - Related field notes — the alert cleared at the exact moment the slot died and replication slots don't die with your process (the same control-plane-vs-data-plane theme, at the slot). =============================================================== # The optimization that trimmed away the column a later feature needed (https://sluicesync.com/field-notes/optimization-trimmed-the-column/) Continuous filtered sync — replicate only the rows matching --where — has one genuinely hard case: an UPDATE can move a row out of the filter's scope, and that has to become a target DELETE or the now-out-of-scope row silently leaks. sluice designed exactly that, a before×after row-move truth table. Then end-to-end testing over a real change stream caught it leaking anyway, because both CDC readers already narrow the UPDATE before-image down to the primary key — an earlier correctness fix — so by the time the filter evaluated the OLD row, the filtered column was no longer in it. A data-narrowing optimization can silently defeat a feature added later that needs the trimmed-away data, and neither piece's own unit test can see it. Observed — building continuous filtered sync (sluice sync --where, v0.99.276, ADR-0173 Phase 2). The leak was caught by end-to-end testing over a real change stream during development and fixed before the feature shipped — an in-development near-miss caught by the process, not a released regression. Ground-truthed against ADR-0173 and the MySQL/Postgres CDC readers' before-image narrowing. ## The one genuinely hard case in filtering a change stream Filtering a one-shot copy is easy: push the WHERE down into the source read and only matching rows ever cross the wire. Filtering a continuous change stream is the classic partial-replication problem, because no source delivers a filtered stream — the binlog, the logical-replication slot, and VStream all hand you every change — so sluice evaluates the predicate itself, per event. And a single UPDATE can change a row so it newly matches the filter, or no longer matches it. Evaluating each event in isolation silently corrupts the target both ways: a row that moves out of scope (its old image matched, its new image doesn't) has to become a target DELETE — drop the event and the stale, now-out-of-scope row is left behind forever; a row that moves in has to become an INSERT, because the target has no base row for the UPDATE to land on. So sluice pinned the semantics as a truth table — the predicate evaluated on both the before- and after-image, translated to the correct target op: before matches? after matches? target op ----------------------------------------------------------- no yes INSERT the after-image (moved IN) yes no DELETE by key (moved OUT) yes yes UPDATE as-is no no drop (never in scope) The move-OUT → DELETE row is the whole point of the table: it is the one guard standing between the operator and a silent leak of a row that no longer belongs in the filtered destination. ## The table was right, and it leaked anyway End-to-end testing over a real change stream caught a move-OUT leaking despite the table. The reason is the sharp part, and it had nothing to do with the row-move logic — it was a pre-existing optimization from an unrelated fix, sitting one layer down. Both CDC readers already narrow an UPDATE's before-image down to just the primary-key (identity) columns before handing it up. That narrowing is not laziness; it is itself a correctness fix. Building an UPDATE's WHERE over every old column is exactly what silently ate our UPDATEs once a jsonb value failed its equality round-trip — so the readers deliberately reduce the before-image to the key, which is all the applier's WHERE needs. But the filter's row-move check needs something the applier never did: the value of the filtered column in the OLD row. And by the time the intercept ran, the reader had already trimmed that column away. The predicate evaluated the OLD image, found the country column simply absent, read it as non-matching, and classified a genuine move-OUT as (before = false, after = false) — "never in scope" — and dropped it. The exact silent leak the truth table existed to prevent, produced by an optimization that predated the feature by many releases. ## The fix: full before-image only where it's needed, zero-value-safe The narrowing is correct and worth keeping everywhere it's still safe — which is every table that isn't filtered. So the fix is a per-table opt-out on the reader (SetFullBeforeImageTables): the filtered tables emit the full before-image so the predicate can read every old column, every other table keeps the key-only narrowing, and the intercept re-narrows the before-image back to the primary key before the applier builds its key-only WHERE. The optimization survives; the feature gets the column it needs; the applier is unchanged. The shape of the opt-out matters as much as its existence. The reader defaults to narrowing — the safe, common behavior — and full before-images are the opt-in for the handful of filtered tables. That is the zero-value-safe direction: every caller that never sets the field (every unfiltered sync, every test, every future construction path) gets the Go zero value, which is exactly the safe default. A field named for the rare behavior, defaulting on, would have silently inverted for everyone who didn't go through the filtered path. And a filtered stream still requires full before-images to exist at the source — REPLICA IDENTITY FULL on Postgres, binlog_row_image=FULL on MySQL — refused loudly at sync-start (SLUICE-E-WHERE-CDC-BEFORE-IMAGE) rather than allowed to run on a partial image the predicate can't read. ## Why only end-to-end testing could see it Both halves passed their own unit tests, and both were right. The narrowing test is green: the applier still gets its key, the UPDATE still matches. The filter test is green: hand it a full before-image and it classifies every row-move correctly. The bug lived only in the composition — the narrowed image flowing into the filter's evaluator — and that composition exists only in the real, end-to-end change stream. No unit test of either piece could witness it, because each piece, tested against the input its own author imagined, does exactly what it should. This is the same discipline as sluice's rule that anything round-tripping through a store is a codec that needs the full family matrix, and that a verifier must not ride the same reader it verifies: correctness at each boundary does not compose into correctness across them, and only exercising the real path proves the seam. ## The transferable lesson An optimization that narrows data — a before-image trimmed to the key, a projection that drops unused columns, a covering-index-only read, a payload slimmed to what today's consumer needs — is never a local change. It silently changes what every future consumer of that data is able to see. Add a feature later that needs one of the trimmed-away columns and it won't fail loudly; it will read the missing column as absent and quietly do the wrong thing, while both the optimization's tests and the feature's tests stay green. Before you narrow data on a shared path, write down what the narrowing assumes about who reads it downstream — and when you add a consumer, check what the path upstream already threw away. The only test that reliably catches the gap is the one that runs the whole path end to end. ## Primary sources - sluice ADR-0173 (row-level --where filter), §Status implementation note and the Phase-2 row-move table; CHANGELOG 0.99.276. The reader opt-out SetFullBeforeImageTables (MySQL and Postgres CDC readers) and the sync-start refusal SLUICE-E-WHERE-CDC-BEFORE-IMAGE. - Companion field note — the predicate you evaluate twice has to agree, or refuse (the other half of the same filtered-CDC design: why the client-side evaluator restricts its grammar). - Related field notes — REPLICA IDENTITY FULL ate our UPDATEs (why the before-image is narrowed to the key in the first place), the row image you can't preflight and the platform default that eats every UPDATE (the row-image family — why REPLICA IDENTITY FULL / binlog_row_image=FULL become required here), and the zero value is a loaded gun (why the opt-out defaults to the safe behavior). =============================================================== # The predicate you evaluate twice has to agree, or refuse (https://sluicesync.com/field-notes/predicate-in-two-engines/) Continuous filtered sync applies one --where predicate in two places: pushed down to the source for the initial snapshot, and evaluated client-side per event for the change stream, because no source delivers a filtered stream. If the two evaluations can disagree, the stream silently leaks or drops rows — and string equality is the canonical trap, because equality itself is collation-defined, not byte-defined. A byte-exact client-side compare of name = 'ANA' diverges from a case- or accent-insensitive source collation. So sluice restricts the client-side grammar to what it can reproduce faithfully and refuses everything else loudly at sync-start, rather than approximate a comparison the source would answer differently. Observed — designing the client-side evaluator for continuous filtered sync (sluice sync --where, v0.99.276, ADR-0173 Phase 2). This is the companion to the row-move note: same feature, the other correctness boundary. A deliberate loud-refusal design, not a bug — grounded in ADR-0173 and the rowpredicate grammar. ## One predicate, evaluated in two engines A filtered migration runs the same --where predicate in two very different places. For the initial bulk copy (and the snapshot leg of a sync), sluice pushes the predicate down to the source as native SQL — SELECT … WHERE () — and the source's own engine evaluates it, indexes and collations and all. But a continuous change stream has no such option: the binlog, the logical-replication slot, and VStream all deliver every change with no server-side filter, so sluice must evaluate the predicate client-side, per event, over the decoded row values. The same filter, computed by two different evaluators — and the whole design depends on them producing the identical answer for every row. If they can diverge, a row the snapshot included can be dropped by the stream (or vice versa): silent, count-invisible scope drift. ## Where they diverge: equality is collation-defined For most comparisons the two agree trivially — a numeric =, an IN over integers, an IS NULL. The trap is strings, because equality itself is defined by the column's collation, not by the bytes. A source column under a case- or accent-insensitive collation — MySQL's default utf8mb4_0900_ai_ci, a Postgres CITEXT or non-deterministic ICU collation — matches name = 'ANA' against the stored values Ana, ANA, and Àna. A naive client-side comparator does a byte compare and matches none of them. So the source says a row is in scope and the client says it isn't: the row is silently dropped from the stream (or, symmetrically, leaked). The same divergence hides in string ordering under a linguistic collation, in a timezone-aware temporal comparison (the source interprets a bare literal in its session zone; the client holds a UTC instant), and in any function or subquery whose semantics the client can't reproduce. ## What sluice does: refuse, don't approximate sluice's answer is a hard boundary drawn at sync-start. The client-side evaluator accepts only a restricted grammar it can reproduce faithfully: a column compared to a literal with =, !=/<>, or (on numeric and tz-naive temporal columns) the ordering operators; IN / NOT IN; IS [NOT] NULL; combined with AND / OR / NOT and parentheses — on numeric, boolean, case-sensitive-string, and tz-naive-temporal columns. Anything it cannot evaluate without risking divergence from the source — a function, a subquery, arithmetic, LIKE, string ordering, a string comparison on a collation that isn't provably case- and accent-sensitive, a tz-aware temporal comparison — is refused loudly, up front, with SLUICE-E-WHERE-CDC-UNSUPPORTED-PREDICATE, naming the construct. The motivating case — country IN ('US','CA') — works; a case-insensitive match is refused with a hint to normalize on the source (a generated lower-cased column) and filter on that, or to use migrate --where if only the one-shot subset is needed. The evaluator also uses SQL three-valued logic and treats UNKNOWN as not-matching, so a NULL-involving comparison can never accidentally widen scope. ## The transferable lesson When the same predicate is evaluated by two engines — here the source's collation-aware SQL and a client-side comparator — correctness requires that they provably agree, not that they usually do. And string equality is the place that intuition fails, because a database's = is a collation operation, not a byte comparison: two engines can legitimately disagree on whether 'ANA' equals 'Àna'. Where you can't guarantee agreement, the safe move is to refuse the predicate at the boundary, loudly, before any data moves — not to ship an approximation that is right on the ASCII rows in your test fixture and silently wrong on the first accented one in production. ## Primary sources - sluice ADR-0173 (row-level --where filter), §Status grammar-restriction note and Phase-2 decision; CHANGELOG 0.99.276. The rowpredicate grammar and its compile-time fidelity gates; the coded refusal SLUICE-E-WHERE-CDC-UNSUPPORTED-PREDICATE. - Companion field note — the optimization that trimmed away the column a later feature needed (the row-move half of the same filtered-CDC design). - Related field notes — MySQL won't match a JSON column by bind parameter and REPLICA IDENTITY FULL ate our UPDATEs (other places a comparison that reads correctly silently stops matching). =============================================================== # You can't filter a parent table without orphaning its children (https://sluicesync.com/field-notes/filter-a-parent-orphans-the-child/) Row-level filtering — copy only the rows matching --where — reads like a per-table setting: give each table a predicate, keep the rows that match. But a relational schema couples those filters through its foreign keys. Filter a parent table down to a subset and the child rows you copied still point at parent rows the filter excluded, so the deferred ADD CONSTRAINT FOREIGN KEY fails with SQLSTATE 23503 on the target. A tool that filtered the parent quietly would hand you a database that looks complete and violates its own declared keys. sluice refuses loudly instead, names the constraint, and makes you choose how to reconcile. Observed — building row-level filtering (sluice migrate --where, v0.99.276, ADR-0173 Phase 1). A shipped, loud refusal — the trap sluice won't walk into — not a bug. Grounded in ADR-0173's referential-integrity section, the SLUICE-E-WHERE-FK-ORPHAN refusal, and the --allow-degraded-fks degrade path in the migrate pipeline. ## Subsetting is not a per-table operation The --where surface invites you to think table by table: --where users=country IN ('US','CA'), --where orders=created_at >= '2026-01-01'. Each predicate scopes one table's rows, independently. That mental model is exactly right for the copy — each table's read pushes its own predicate down to the source — and exactly wrong for the result, because the tables are not independent. A foreign key is a promise that every value in a child column exists in a parent column. Filter the parent's rows and you can silently break that promise for child rows you kept. Concretely: orders has a user_id FK into users. You filter users to the US/CA subset but copy orders whole. Half your orders now reference users that were never copied. On the source those orders were fine — the parents existed. On the target, when sluice adds the deferred foreign key after the bulk copy (constraints are created last, so the copy isn't fighting them row by row), the constraint validation scans the child table, finds rows whose user_id has no matching parent, and fails with SQLSTATE 23503. The migration stops with a foreign-key violation that originated three steps earlier, in a filter on a different table. ## The failure you don't want is the silent one The 23503 is loud and it stops the run — that is the good outcome. The outcome to fear is the one where a subsetting tool "helpfully" copies the child rows anyway and leaves the foreign key off, or degrades it without telling you. Now you have a target that looks like a faithful subset: all the tables are there, the row counts look plausible, nothing errored. And it quietly violates its own schema — orphaned children pointing at absent parents, a referential guarantee the application still assumes holds. Every query that joins through that key silently returns less than it should, and no checksum on the copied bytes will ever flag it, because every copied row is byte-perfect. The corruption is in what wasn't copied, and in the constraint that was silently dropped to tolerate it. ## sluice refuses, names the constraint, and offers two honest paths sluice never leaves a silent orphan. When a --where run hits 23503 on the deferred FK add, it refuses with SLUICE-E-WHERE-FK-ORPHAN, naming the exact constraint, and points at the two ways forward: Filter consistently. The clean answer when the schema allows it: filter the child so it only admits rows whose parent survives the parent filter — scope orders by the same country the users filter uses, so no kept order references a dropped user. The subset stays referentially closed and the foreign key validates. --allow-degraded-fks (Postgres target). When you genuinely want the orphans — a partial extract where the missing parents are acceptable — this degrades that constraint to NOT VALID: the foreign key is still attached to the target catalog and still rejects any new write that would orphan a row, but the existing orphans from the copy are tolerated. Crucially, the degrade is explicit and surfaced at the end of the run, not silent, and you finish the reconciliation yourself with ALTER TABLE … VALIDATE CONSTRAINT once you've backfilled or accepted the gaps. MySQL has no per-constraint NOT VALID semantic, so the flag refuses loudly against a MySQL target rather than pretend it can degrade a constraint it can't. The distinction that matters: the constraint is never quietly dropped. Either it validates (you filtered consistently), or it's explicitly degraded with your opt-in and a remedy you run, or the run refuses. There is no path where a foreign key silently disappears to make a subset "work." ## The proper answer is genuinely hard, and it's deferred honestly The eventual right answer to "I filtered a parent and want my children" is referential-aware subsetting: given a filtered child set, automatically pull in the parent rows those children reference — the transitive closure over the foreign-key graph, the same problem pg_dump's --table and Jailer-style extractors solve. sluice files this as a deferred follow-on rather than half-implementing it, because it is hard in the ways that matter: cyclic foreign keys, self-references, and the performance of computing the closure over a large graph. A partial version that handled the easy cases and silently mishandled the cycles would be worse than an honest refusal. So Phase 1 ships the simple, predictable predicate plus a loud refusal and an explicit degrade, and names the closure as the thing still to build. ## The transferable lesson When you add a filter to one table in a relational database, you have not made a local change — you have potentially invalidated every foreign key that points into it. "Filter each table independently" is a UI convenience, not a data model; the data model couples the tables through their keys, and a subset is only correct if it's referentially closed. Before you let a tool carve out a subset, ask what happens to the constraints that span the cut: does it validate them, degrade them silently, or refuse? The only safe answers are validate or refuse-with-an-explicit-opt-out. A subset that silently drops a foreign key to fit isn't a smaller copy of the database — it's a different database that happens to share its rows. ## Primary sources - sluice ADR-0173 (row-level --where filter), §Decision Phase 1 "Referential integrity is the load-bearing gotcha" and §Consequences; CHANGELOG 0.99.276. The refusal SLUICE-E-WHERE-FK-ORPHAN and the --allow-degraded-fks degrade-to-NOT VALID path (Postgres target; refused on MySQL). - Operator guide — filtered / subset migration (the full --where walkthrough, including the FK-orphan section and both reconciliation paths). - Companion field notes — the optimization that trimmed away the column a later feature needed and the predicate you evaluate twice has to agree, or refuse (the two continuous-sync halves of the same row-level-filtering feature). =============================================================== # The change stream that won't drop your row (https://sluicesync.com/field-notes/vstream-filter-keeps-both-images/) Filtering a continuous change stream has one genuinely hard case: a row updated so it no longer matches the filter has to become a target DELETE, or the now-out-of-scope row leaks forever. When you push that filter server-side into someone else's stream — a Vitess VStream rule — the load-bearing question is what the stream emits for a row that leaves the filter. Assume it drops the event and you leak. The Vitess source settles it: for a non-vindex filter, when either the before- or after-image passes, VStream emits the change with BOTH images — so the move-out arrives as a full UPDATE, never dropped. The catch is that it tells you the row touched the filter, not which side matched, so you still have to decide the move direction yourself. Observed — building continuous filtered sync on the Vitess / PlanetScale VStream path (sluice sync --where, v0.99.278, ADR-0174 Piece 2). Ground-truthed against the vendored Vitess v0.24.2 vstreamer source and proven on a real Vitess-24 cluster. ## The move-out is the whole difficulty Filtering a one-shot copy is easy — push the WHERE into the read and only matching rows cross the wire. Filtering a continuous stream is the partial-replication problem, and its hard case is the row that leaves the filter: an UPDATE that changes a row so it no longer matches has to become a target DELETE, or the stale, now-out-of-scope row sits on the target forever. Evaluate each event's new image in isolation and you drop that UPDATE (its after-image doesn't match) — a silent leak. Getting it right needs the row's old image too, so you can see it used to be in scope. ## Pushing the filter into someone else's stream Vitess VStream can filter server-side: each table's rule takes a query, and select * from t where () makes vtgate evaluate the predicate itself — filtering the copy phase and the streaming phase natively, with the source's own collation, no client-side scan. That's the efficient path. But it raised the fear that gates the whole design: when a row updates out of the filter, does VStream deliver a DELETE, or does it just… stop matching the row and silently drop the event? For a filter that isn't on the sharding key, Vitess VReplication has historically had exactly this stale-row caveat. If VStream dropped the move-out, sluice would never see it — and never delete it. The leak would be invisible. ## What the source actually does: both images, if either matches The answer is in vstreamer.processRowEvent. For each row change it computes whether the before-image passes the filter and whether the after-image passes, then: if !afterOK && !beforeOK { continue // neither in scope -> genuinely dropped } if !hasVindex { // a plain --where, no sharding-key term afterOK = true // ...emit BOTH images if EITHER passed beforeOK = true } So for a non-vindex filter, a row where either image matches is emitted with both its before- and after-images. A move-out — before-image in scope, after-image out — is not dropped and not reshaped into something lossy: it arrives as a complete UPDATE carrying the old in-scope row and the new out-of-scope row. sluice's client-side row-move table reads that as "was in scope, now isn't" → a target DELETE by key. Validated end-to-end on a real Vitess-24 cluster: filtered copy excludes out-of-scope rows server-side, a move-in becomes an INSERT, a move-out becomes a DELETE, nothing leaks. ## The twist: it tells you *that*, not *which* The sharp part is what VStream does not tell you. Because it forces both flags true whenever either matched, the event carries both images unconditionally — it reports that the row touched the filter's scope, never which side matched. So you cannot read the move direction off the event; you have to re-evaluate the predicate yourself, on both images, to distinguish a move-in (INSERT) from a move-out (DELETE) from an in-scope update. The server-side filter is an efficiency layer — it thins the stream to rows that matter — but the classification is still yours, and it has to agree with what the server filtered on (which is why the client-side evaluation must reproduce the source's collation exactly; see linking the source's comparator). One more consequence of "server-side, at open": the VStream copy sends its filter rules to vtgate when the stream is constructed, before your code gets the handle back. A filter applied a moment later — after the stream opens — is too late for the first table's copy, which has already started unfiltered. The predicate has to be threaded into the open, not set afterward, or the first table leaks. ## The transferable lesson When you push a filter (or a projection, or a subscription) into a stream you don't own, the load-bearing question isn't "will it filter" — it's "what does it emit for a row that leaves the filter." Three answers are common and only one is safe: it drops the event (you silently leak the stale row), it emits a synthetic delete (convenient, but now you trust its delete semantics), or it hands you both images and makes you classify (more work, no lost information). Find out which before you rely on it — read the source or test the move-out explicitly on the real system, because the happy-path "rows I want show up" test passes under all three. Vitess picked the third, which is why a filtered VStream is safe to build a delete-on-move-out on; do not assume the stream you're pushing into made the same choice. ## Primary sources - Vitess v0.24.2 — go/vt/vttablet/tabletserver/vstreamer/vstreamer.go processRowEvent ("if the target is not sharded, pass both images if either after or before passes"). sluice ADR-0174 Piece 2; the vitesscluster-tagged move-out cluster test. - Companion field notes — the optimization that trimmed away the column a later feature needed (why the move-out needs the full before-image) and you can't reimplement MySQL's = (why the client-side re-classification must match the source's own evaluation). --- Canonical page: https://sluicesync.com/field-notes/vstream-filter-keeps-both-images/ · Full docs index: https://sluicesync.com/llms.txt =============================================================== # You can't reimplement MySQL's =, so link its comparator in (https://sluicesync.com/field-notes/reuse-the-source-comparator/) A filtered change stream evaluates the same predicate in two places — pushed down to the source, and client-side per event — and they have to agree exactly or the stream silently leaks or drops rows. String equality is where they diverge, because MySQL's default collation is case- and accent-insensitive: 'EU' equals 'eu' equals 'Eu'. sluice first refused such filters rather than approximate them. The resolution wasn't a better approximation — it was to stop reimplementing the comparison and link in the source engine's own comparator, so the two evaluations are the same code by construction. Observed — this is the sequel to the predicate you evaluate twice has to agree, or refuse. That note explained why sluice refused case-insensitive string filters on a continuous stream; v0.99.278 (ADR-0174 Piece 1) makes them work — faithfully. Grounded in internal/rowpredicate/collation.go. ## The same predicate, evaluated twice A filtered migration runs one --where in two very different evaluators: the source pushes it down as native SQL (the source's engine matches the rows), and the continuous change stream evaluates it client-side, per event, over decoded row values — because no source delivers a filtered stream. The whole design depends on those two producing the identical answer for every row. Where they disagree, a row the source considered in-scope gets dropped by the client (or vice versa): silent, count-invisible scope drift. ## String equality is defined by collation, not bytes They agree trivially on numbers and IS NULL. Strings are the trap, because equality itself is a collation operation. MySQL's default utf8mb4_0900_ai_ci is case- and accent-insensitive: region = 'EU' matches the stored values eu, Eu, and Éu. A client-side byte comparison matches none of them — so the source says "in scope," the client says "out," and the stream drops a row it should have kept. The obvious fixes are all wrong in the tail: strings.ToLower ignores accents and the Turkish dotless-i; a hand-rolled Unicode case-fold still isn't MySQL's specific per-collation implementation; ß vs ss, locale tailoring, and version differences all lurk. Any reimplementation is a model of the source's =, and it diverges exactly where you didn't test. ## The fix: link the source's own comparator So sluice doesn't reimplement the comparison — it reuses the source engine's own. The client-side evaluator imports Vitess's collations and evalengine packages (already in the module graph) and, for a string column under a case/accent-insensitive collation, compares via evalengine.NullsafeCompare under the column's declared collation ID — the identical code path MySQL and Vitess fold case and accents with. That closes the case/accent axis. But "reuse the library" turned out to be necessary, not sufficient — and a later audit proved it. The library's comparator reproduces a collation's weights (case, accent, expansion like ß→ss) but not two other axes of MySQL's =: its PAD_ATTRIBUTE — NullsafeCompare compares NO-PAD regardless of the collation's real attribute, yet every legacy collation (utf8mb4_general_ci, _bin, latin1_*) is PAD SPACE, so region = 'EU' matches a stored 'EU ' on the source but not through the library — and its charset (it reads the value bytes under the collation's charset, so a non-UTF-8 column would be mis-decoded). Reusing the comparator silently dropped/leaked trailing-space rows on the default legacy collation until the gap was caught. The fix was not a cleverer reuse — it was to ground-truth the client-side classification against a real server's own WHERE (a collation family×shape matrix run against an actual MySQL and Postgres), which surfaced the pad/charset divergence the library-verifies-library test could never see. A collation the library can't resolve, a non-UTF-8 charset, or a Postgres non-deterministic ICU collation refuses loudly rather than guess. ## The transferable lesson When your code must reproduce another system's semantics — a collation's equality, a database's timezone math, a driver's numeric coercion, a hash's canonicalization — reimplementing it is a standing invitation to divergence, and the divergence hides on the inputs your tests didn't imagine. If the real implementation is linkable, link it: call the source system's actual comparator, not your model of it. But linking is necessary, not sufficient — a library can reproduce some axes of an operation and quietly not others (a collation's weights but not its pad attribute or charset, as here). So the ground truth is not the library; it is the real system. Verify the reuse against the actual server's own answer, on the hard shapes — never against the library, which could share the same blind spot (a library-verifies-library test is exactly how this shipped green). And when the semantics genuinely aren't linkable, the honest move is the first note's move — refuse, don't approximate. ## Primary sources - sluice internal/rowpredicate/collation.go — collations.NewEnvironment + evalengine.NullsafeCompare; ADR-0174 Piece 1; CHANGELOG 0.99.278. - Prequel — the predicate you evaluate twice has to agree, or refuse (why sluice refused these filters before this landed). - Companion — the change stream that won't drop your row (why the client-side re-classification is mandatory even when the filter is pushed server-side). --- Canonical page: https://sluicesync.com/field-notes/reuse-the-source-comparator/ · Full docs index: https://sluicesync.com/llms.txt =============================================================== # The = that ignores your trailing spaces (https://sluicesync.com/field-notes/collation-pad-space-trailing-space/) MySQL's WHERE region = 'EU' matches a stored 'EU ' — on a legacy collation. Every collation except the 8.0 utf8mb4_0900_* family is PAD SPACE, which ignores trailing spaces in comparison; the modern default is NO PAD and doesn't. That single per-collation attribute is easy to miss, and a client-side comparator that reproduces a collation's case and accent folding but not its pad attribute silently disagrees with the source on the most common legacy collation. This one shipped as a real silent row-loss — and it shipped green because the test compared the comparator to itself instead of to a real server. Observed — a post-release audit of continuous filtered sync (sluice sync --where). Ground-truthed against real MySQL 8.0 and Postgres 16; the divergence was a CONFIRMED Critical in a shipped, published build, fixed in the next patch. This is the companion to you can't reimplement MySQL's =, so link its comparator in — the specific axis that made "link the comparator" necessary but not sufficient. ## Two collations, two answers, one stored value Create the same column under two collations and store the same four values: CREATE TABLE g (region VARCHAR(16) COLLATE utf8mb4_general_ci); -- legacy CREATE TABLE n (region VARCHAR(16) COLLATE utf8mb4_0900_ai_ci); -- MySQL 8 default INSERT INTO g VALUES ('EU'),('EU '),('EU '),('eu'); INSERT INTO n VALUES ('EU'),('EU '),('EU '),('eu'); SELECT region FROM g WHERE region = 'EU'; -- EU, EU_, EU__, eu (all four) SELECT region FROM n WHERE region = 'EU'; -- EU, eu (no trailing-space rows) Same query, same data, different result — because the two collations have a different PAD_ATTRIBUTE. utf8mb4_general_ci is PAD SPACE: it ignores trailing spaces in a comparison, so 'EU' equals 'EU ' equals 'EU '. utf8mb4_0900_ai_ci — the MySQL-8 default — is NO PAD: trailing spaces are significant, so 'EU ' is a different value. You can read the attribute straight from the catalog: SELECT collation_name, pad_attribute FROM information_schema.collations WHERE collation_name IN ('utf8mb4_general_ci','utf8mb4_0900_ai_ci','utf8mb4_bin'); -- utf8mb4_general_ci PAD SPACE -- utf8mb4_bin PAD SPACE (case-sensitive, but STILL pad-space) -- utf8mb4_0900_ai_ci NO PAD The trap is that every collation is PAD SPACE except the UCA-9.0.0 _0900_ family introduced in MySQL 8. So the pre-8.0 default, the MariaDB default, utf8mb4_bin, latin1_swedish_ci — the collations most legacy data actually lives under — all ignore trailing spaces, and the one collation a modern developer tests against is the one that doesn't. It is precisely the kind of per-object attribute you forget exists until it bites. ## Where it bites: a comparator that folds case but not pad Continuous filtered replication has to evaluate region = 'EU' client-side, per change event, and get the same answer the source's WHERE would (the evaluate-in-two-engines problem). The right move is not to reimplement collation — it's to link the engine's own comparator and call it under the column's collation. That reproduces the collation's weights — case-insensitivity, accent-insensitivity, ß→ss expansion — exactly. But the comparator reproduces the weights, not the whole of =. The library's compare is NO-PAD regardless of the collation's real pad attribute. So on a PAD SPACE column, the source keeps a stored 'EU ' in scope (its = ignores the trailing space) while the client-side reuse reads 'EU ' ≠ 'EU' and calls it out of scope. In a filtered change stream that means an INSERT of 'EU ' is silently dropped and a row updated to 'EU ' is silently deleted from the target — exit 0, no warning, on the default legacy collation, gated only on a trailing space (common from CSV, fixed-width, form, and legacy sources). Reusing the real comparator was necessary; it was not sufficient, because it faithfully reproduces one axis of the operation and silently not another. The fix, once the axis is named, is small: right-trim trailing spaces before comparing on a PAD SPACE collation (reproducing exactly what PAD SPACE = does), skip the trim on NO PAD, and refuse the axes that genuinely can't be reproduced — a non-UTF-8 charset the comparator would mis-decode, a Postgres non-deterministic ICU collation. ## Why it shipped green: the test compared the comparator to itself The sharpest part isn't the pad attribute — it's that a full test suite passed over this the whole time. The tests asserted the client-side comparator's output against hand-written expected booleans that were themselves reasoned from the same library. Library verifying library: the test and the code shared the exact blind spot, so a comparator that disagreed with a real MySQL on trailing spaces was green on every run, and stayed green through the release. The only thing that surfaced it was an audit that wrote a different kind of test — a family matrix that, for each collation × each shape (trailing space, leading space, case, accent, expansion), runs the literal SELECT … WHERE col = 'lit' on a real server and asserts the client-side classification equals that. It fails loudly on the shipped code and passes only on the fix, and it now stands as the gate. ## The transferable lesson Two lessons braid here. The narrow one: a collation is not just its case/accent rules — PAD_ATTRIBUTE (and charset, and determinism) are part of what = means, and the split is per-collation and counterintuitive (legacy = PAD SPACE, modern default = NO PAD). The broad one is the reusable discipline: when you stand in for another system's operation — even by linking its own library — you must prove the reproduction against the real system, on the shapes that distinguish the axes, not against a test that shares your model. A green suite that only ever asked the library what the library thinks is not evidence; it's the same guess, twice. Verify a codec, a comparator, a canonicalizer against the ground truth it's imitating — a real server, a real reader — because the divergence you didn't reproduce is exactly the one your look-alike test can't see. ## Primary sources - information_schema.collations.PAD_ATTRIBUTE; MySQL's PAD SPACE vs NO PAD comparison semantics (the _0900_ UCA-9.0.0 collations are the NO-PAD exception). sluice ADR-0174; the collation family-matrix gate (real MySQL + real Postgres). - Companion field notes — you can't reimplement MySQL's =, so link its comparator in (the technique this is the caveat to) and the predicate you evaluate twice has to agree, or refuse (why the client-side evaluation must match the source at all). --- Canonical page: https://sluicesync.com/field-notes/collation-pad-space-trailing-space/ · Full docs index: https://sluicesync.com/llms.txt =============================================================== # MySQL has had this collation column since 8.0; MariaDB added it in 12.1 (https://sluicesync.com/field-notes/mariadb-no-pad-attribute-column/) MySQL 8 has an information_schema.COLLATIONS.PAD_ATTRIBUTE column that says whether a collation is PAD SPACE (trailing spaces ignored in =) or NO PAD. MariaDB shipped without it for years — absent through the whole 11.x LTS line and 12.0, added only in 12.1 — so on the MariaDB most people run, the attribute that decides whether 'EU' matches a stored 'EU ' is not in the catalog and you read the _nopad_ collation name instead. A portable reader can't assume the column is present or absent across versions; the version-robust signals are the name token and the server's own behavior, and sluice's parity gate anchors to both. Observed — scoping sluice's filtered-sync --where collation comparator, which has to decide a column's PAD attribute to reproduce the source's own = for a filtered replica. Ground-truthed across the MariaDB line — 11.4, 11.8, 12.0, 12.1, 12.2, 12.3 — and mysql:8.0 (2026-07-20). The parity gate that keeps sluice's classifier honest ships on main (after v0.99.283). ## A collation column MySQL has had since 8.0 MySQL 8 tells you authoritatively whether a collation compares PAD SPACE (trailing spaces ignored in =) or NO PAD (they are significant) with a first-class column in information_schema.COLLATIONS, present since 8.0: SELECT COLLATION_NAME, PAD_ATTRIBUTE FROM information_schema.COLLATIONS WHERE COLLATION_NAME = 'utf8mb4_general_ci'; -- utf8mb4_general_ci PAD SPACE MariaDB — a fork that markets MySQL compatibility — shipped without that column for years. It is not there on the MariaDB most production systems run today: information_schema.COLLATIONS.PAD_ATTRIBUTE present? MySQL 8.0+ yes MariaDB 11.4 no (LTS) MariaDB 11.8 no (LTS) MariaDB 12.0 no MariaDB 12.1 yes <- added here MariaDB 12.2 yes MariaDB 12.3 yes So the attribute that decides whether a stored 'EU ' matches WHERE region = 'EU' is a queryable catalog fact on MySQL 8, absent on the entire MariaDB 11.x LTS line and 12.0, and present again from MariaDB 12.1. Query PAD_ATTRIBUTE against 11.8 and you don't get a NULL or an empty result — you get ERROR 1054: Unknown column 'PAD_ATTRIBUTE'. ## The catalog surface is version-dependent, so you can't assume it That is the trap. A reader written against MySQL, or against MariaDB 12.1+, that SELECTs PAD_ATTRIBUTE works — until it meets an 11.x server and the query errors out. A reader that assumes MariaDB never has it (as an earlier version of this very note wrongly did) is wrong from 12.1 on. Neither “it's there” nor “it's not there” is safe across a compatible fork's version range. What is stable: MariaDB names its NO-PAD collations with a _nopad_ token (utf8mb4_nopad_bin, utf8mb4_general_nopad_ci, utf8mb4_unicode_nopad_ci) in every version, and the server's actual = behavior is queryable everywhere. The version-independent signals are the name and the behavior — not the catalog. ## Guess wrong and it is silent A tool reproducing a source's = for a filtered replica has to know the PAD attribute: on a PAD SPACE column it must right-trim trailing spaces before comparing, on a NO-PAD column it must not. Treat MariaDB's utf8mb4_nopad_bin as PAD SPACE — the default outcome if you only special-case MySQL's _0900_ / binary — and you right-trim a column whose = is trailing-space-significant. A stored 'EU ' then wrongly compares equal to 'EU', and a row that moved out of a region = 'EU' filter (its trailing space meaning it no longer matches) is mis-classified as still in scope: a silent, trailing-space-only row-move error, exit 0. That is exactly the shape an internal audit (SL-COLL-1) caught before it shipped. ## What sluice does about it sluice's collationNoPad classifier keys off the version-independent name signal — MySQL's _0900_ family, binary, and MariaDB's nopad token — with no catalog query, so it behaves identically on MariaDB 11.8 and 12.3. Because that rests on a naming convention, it is pinned by a dual-oracle real-server parity test that runs every CI cycle. On MySQL (where the column has always existed) it asserts collationNoPad(name) equals the server's own PAD_ATTRIBUTE for every collation the catalog knows (286 of them), so a future MySQL NO-PAD collation whose name escapes the heuristic fails CI. On MariaDB it ground-truths behaviorally across the whole LTS spread — it stores 'EU ' and checks whether WHERE v = 'EU' matches it (match ⇒ PAD SPACE, no match ⇒ NO PAD), then asserts the classifier agrees with the real server — because a behavioral probe is the one oracle that works whether or not a given release happens to expose PAD_ATTRIBUTE. The name heuristic is never trusted on its own; it is only ever a proxy for a real-server oracle. ## The transferable lesson information_schema is not a portable contract across a fork — and it isn't even stable across the fork's own versions. A column MySQL has had since 8.0 was absent from MariaDB's current LTS line and only landed in 12.1, so a reader that keys off it works on some MariaDB versions and errors or mis-reads on others. When you depend on a catalog attribute of a claimed-compatible engine, don't assume it is present or absent: find a version-independent signal (here, the collation name and the server's own comparison behavior), and anchor it to a real-server oracle — a catalog query where the catalog has it, a behavioral probe where it might not — so the day a version moves the ground under you, a test fails instead of a row quietly changing scope. ## Primary sources - sluice internal/engines/mysql/collation.go (collationNoPad) and the dual-oracle parity gate collation_pad_attribute_integration_test.go (MySQL catalog assertion over all 286 collations + the MariaDB behavioral probe), ADR-0174; ground-truthed on mysql:8.0 and MariaDB 11.4 / 11.8 / 12.0 / 12.1 / 12.2 / 12.3 (2026-07-20). - MySQL 8.0 Reference Manual — information_schema.COLLATIONS (PAD_ATTRIBUTE, values PAD SPACE / NO PAD, present since 8.0). - MariaDB Knowledge Base — information_schema.COLLATIONS (the PAD_ATTRIBUTE column, added in the 12.1 series) and the *_nopad_* NO-PAD collations. - Related field notes — the = that ignores your trailing spaces (the PAD-SPACE row-loss this prevents on MySQL), MariaDB reports its defaults in a different dialect, and you can't reimplement MySQL's =.