Use cases

Migrating from Snaplet to PrivaCI

Snaplet shut down on August 31, 2024. The team open-sourced its packages and handed maintenance of the generation client to the Supabase community organization, but the community snapshot repository is now archived and is not a maintained path forward for production anonymization workflows.

Snaplet handled two distinct jobs: it captured real production data, anonymized it, and restored it to a local or staging database; and separately it could generate synthetic seed data from scratch for populating empty databases. PrivaCI covers the capture-and-anonymize half only. If your Snaplet workflow ran snaplet snapshot capture against a real production database, transformed PII columns in snaplet.config.ts, and restored the result to staging — this guide covers that migration. If you were using snaplet seed to generate rows from a schema without a production source, PrivaCI does not replace that workflow.

For the capture-and-anonymize path — the common snaplet alternative for teams leaving Snaplet — the migration maps cleanly.

What your Snaplet config looked like

Snaplet's transform configuration lived in a TypeScript file using the @snaplet/copycat library for deterministic value generation. A typical config for a SaaS platform with users, addresses, and payment data:

// .snaplet/config.ts
import { copycat } from "@snaplet/copycat";
import { defineConfig } from "@snaplet/sdk";

export default defineConfig({
  transform: {
    public: {
      users: ({ row }) => ({
        email:         copycat.email(row.id),
        full_name:     copycat.fullName(row.id),
        phone:         copycat.phoneNumber(row.id),
        date_of_birth: null,
        avatar_url:    null,
      }),

      addresses: ({ row }) => ({
        line1: copycat.streetAddress(row.id),
        city:  copycat.city(row.id),
        // postcode left as-is for geographic distribution in tests
      }),

      payments: ({ row }) => ({
        card_last4:    "9999",
        billing_name:  copycat.fullName(row.id),
        billing_email: copycat.email(row.id),
      }),
    },
  },

  subset: {
    targets: [
      { table: "public.users", percent: 10 },
    ],
  },
});

The workflow was two commands:

snaplet snapshot capture --db-url=$SOURCE_DATABASE_URL  # snapshot production
snaplet snapshot restore --latest                        # restore to local/staging

snapshot capture read the source, applied the transforms, and wrote a snapshot artifact. snapshot restore loaded it into the target. Two commands, no persistent service.

Translating copycat transforms to mask-rules.yaml

PrivaCI's mask-rules.yaml covers the same column-level transforms, with YAML in place of TypeScript. copycat functions achieved determinism by hashing the seed value (usually row.id); PrivaCI achieves determinism through an explicit global_salt combined with the row's primary key. The outcome is identical — the same source row always maps to the same masked value — but the salt is an explicit secret you control rather than an internal implementation detail.

For the full provider list, see the configuration reference.

Snaplet / copycat PrivaCI action
copycat.email(row.id) { action: fake, provider: email }
copycat.fullName(row.id) { action: fake, provider: full_name }
copycat.firstName(row.id) { action: fake, provider: first_name }
copycat.lastName(row.id) { action: fake, provider: last_name }
copycat.phoneNumber(row.id) { action: fake, provider: phone }
copycat.streetAddress(row.id) { action: fake, provider: street }
copycat.city(row.id) { action: fake, provider: city }
copycat.username(row.id) { action: fake, provider: username }
copycat.url(row.id) { action: hash } — no built-in URL provider; hash gives a stable opaque value
copycat.word(row.id) { action: fake, provider: company } — or { action: hash }
copycat.scramble(row.value) { action: regex_mask, pattern: "..." }
null (literal) { action: null }
"9999" (literal) { action: static, value: "9999" }
(column omitted from transform) (omit column — passthrough is the default)

Note on copycat.streetAddress vs PrivaCI's address provider: the address provider emits a full one-line address including city, postcode, and country. For a line1-style column that only holds the street component, use provider: street — otherwise city and postcode end up concatenated into the street field.

Snaplet's TypeScript config could express row-conditional logic with arbitrary JavaScript. On Commercial (Standard and Compliance), PrivaCI supports declarative CEL when: guards on column actions — for example mask notes only when status == 'closed'. That covers common row predicates without embedding JavaScript. Arbitrary JS, cross-column transforms, and custom hashing still have no direct equivalent — review those columns in dry-run and map them to a static rule or keyed action.

The equivalent mask-rules.yaml for the Snaplet config above:

# mask-rules.yaml
version: "1.0"
global_salt: env://ANONYMIZATION_SALT
strict_autodetect: true
on_existing_data: fail

tables:
  public.users:
    strategy: transform
    columns:
      email:         { action: fake, provider: email }
      full_name:     { action: fake, provider: full_name }
      phone:         { action: fake, provider: phone }
      date_of_birth: { action: null }
      avatar_url:    { action: null }

  public.addresses:
    strategy: transform
    columns:
      line1: { action: fake, provider: street }
      city:  { action: fake, provider: city }
      # postcode omitted — passthrough is the default

  public.payments:
    strategy: transform
    columns:
      card_last4:    { action: static, value: "9999" }
      billing_name:  { action: fake, provider: full_name }
      billing_email: { action: fake, provider: email }

strict_autodetect: true causes dry-run to exit non-zero if any column matches the PII pattern library but has no explicit YAML rule — enforcing masking policy as code rather than relying on auto-detect alone. Add explicit passthrough rules for columns you intentionally leave unchanged.

What replaces snapshot capture and restore

Snaplet's two-step snapshot capturesnapshot restore workflow collapses into a single run. PrivaCI reads directly from the source, masks in the streaming pipeline, and writes to the target in one pass. There is no intermediate snapshot artifact on disk or in cloud storage.

The image's entrypoint is already privaci, so commands go directly after the image name:

export ANONYMIZATION_SALT="$(openssl rand -hex 32)"
export SOURCE_DB_URL="postgresql://user:pass@prod-replica:5432/app"
export TARGET_DB_URL="postgresql://user:pass@staging:5432/app"

# Verify the masking plan — no writes
docker run --rm \
  -e SOURCE_DB_URL -e TARGET_DB_URL -e ANONYMIZATION_SALT \
  -v "$PWD/mask-rules.yaml:/config/mask-rules.yaml:ro" \
  ghcr.io/boundarylogic/privaci:1.3.0 \
  dry-run --config /config/mask-rules.yaml

# Run the mask
docker run --rm \
  -e SOURCE_DB_URL -e TARGET_DB_URL -e ANONYMIZATION_SALT \
  -v "$PWD/mask-rules.yaml:/config/mask-rules.yaml:ro" \
  ghcr.io/boundarylogic/privaci:1.3.0 \
  run --config /config/mask-rules.yaml

dry-run reads the source catalog, builds the FK dependency graph, runs PII auto-detection over any columns not covered by your YAML, and exits without writing. Review the output before the first real run.

PrivaCI is a stateless container — no Node.js runtime to manage, no global CLI to install, no .snaplet directory in the repository. The image and the YAML file are the only artifacts.

Migrating Snaplet's subset configuration

Subsetting requires the Compliance tier. Snaplet's percent-based subsetting selected a random sample from the root table. PrivaCI Commercial's subsetting uses SQL predicates with the commercial image and a commercial-extensions.yaml file, passed alongside the masking config. On AWS Marketplace, attach an IAM task role that can verify your subscription at container start — see deployment options.

docker run --rm \
  -e SOURCE_DB_URL -e TARGET_DB_URL -e ANONYMIZATION_SALT \
  -v "$PWD/mask-rules.yaml:/config/mask-rules.yaml:ro" \
  -v "$PWD/commercial-extensions.yaml:/config/commercial-extensions.yaml:ro" \
  ghcr.io/boundarylogic/privaci-commercial:1.0.13 \
  run \
    --config /config/mask-rules.yaml \
    --commercial-extensions /config/commercial-extensions.yaml
# commercial-extensions.yaml
version: "1.0"
subset:
  - table: public.users
    predicate: "created_at >= now() - interval '90 days'"

A percent: 10 Snaplet subset translates to a time window or an explicit predicate depending on what distribution your tests actually need. Time windows are usually more useful than random percentages — they carry realistic patterns and include edge cases that accumulate over time.

PrivaCI resolves the FK closure automatically from the root predicate. PrivaCI reads the FK graph from pg_catalog and pulls dependent rows without additional configuration.

For local development workflows — where Snaplet was typically run to produce a small slice for each developer — the CI ephemeral guide covers the equivalent pattern as a GitHub Actions job that spins up a masked Postgres service container per pull request.

What PrivaCI adds beyond Snaplet's anonymization scope

FK graph traversal by default. PrivaCI builds a full dependency graph from pg_catalog at run start, sorts tables topologically, and defers constraints to break cycles — FK integrity is handled automatically.

Crash-safe resume. PrivaCI writes a per-batch checkpoint to the target database. A run interrupted mid-table resumes from the exact batch where it stopped. Snaplet's snapshot model had no resume — an interrupted capture meant starting over.

Signed compliance reports (Commercial). After a run, the run UUID is printed in the output and stored in _privaci.runs on the target. Pass it to the commercial image to generate a tamper-evident report. Set PRIVACI_REPORT_SIGNING_KEY_PEM to your Ed25519 private key; without it the output is unsigned JSON (fine for dev).

docker run --rm \
  -e TARGET_DB_URL \
  -e PRIVACI_REPORT_SIGNING_KEY_PEM \
  -v "/tmp:/tmp" \
  ghcr.io/boundarylogic/privaci-commercial:1.0.13 \
  report --run <run-uuid> --format json --output /tmp/report.json

Schema drift detection (Commercial). detect-drift flags new PII columns added since the last run before they reach a lower environment — the failure mode that silent passthrough never catches.

What PrivaCI doesn't cover

Snaplet seed / synthetic generation. PrivaCI requires a live source database. If your workflow used snaplet seed to generate rows from a schema definition without a production source, that use case is not covered.

Snapshot artifacts. Snaplet's snapshots could be committed to version control or shared across a team as files. PrivaCI writes directly to a target database; there is no intermediate dump artifact.

TypeScript / JavaScript transform logic. Conditional or computed transforms written as JavaScript functions in snaplet.config.ts need to be expressed as declarative YAML rules or handled outside PrivaCI.

Next steps

The OSS engine covers the core anonymization workflow and is free under ELv2. Run dry-run against your source schema to see which columns auto-detect flags before you write a line of config:

docker run --rm ghcr.io/boundarylogic/privaci:1.3.0 --help

For a scheduled staging refresh, see the staging guide. For tenant-scoped subsetting from a production replica, see the support repro guide. Commercial features — subsetting, signed reports, drift detection, JSONB path masking — are available from $149/month on AWS Marketplace (Standard and Compliance tiers).