Documentation
¶
Overview ¶
Package compat defines the engine-neutral contract used by the compatibility implementation. It deliberately requires explicit targets: a claim of exact compatibility has no meaning without source and destination versions.
Index ¶
- func CompileDDL(target Target, schema Schema) ([]string, error)
- func CompileDropTable(target Target, table string) (string, error)
- func CompileDropTableIfExists(target Target, table string) (string, error)
- func Placeholder(engine Engine, position int) string
- func RequireEquivalent(report VerificationReport) error
- func RequireExact(findings []Finding) error
- func SnapshotDigest(snapshot Snapshot) (string, error)
- type Assignment
- type CatalogObject
- type Change
- type ChangeKind
- type Column
- type CommonTableExpr
- type CompoundSelect
- type ConflictError
- type Constraint
- type ConstraintKind
- type Contract
- type Domain
- type Engine
- type Expression
- type Feature
- type Finding
- type GeneratedColumn
- type Index
- type IndexColumn
- type Inspection
- type Join
- type MappingStatus
- type Ordering
- type Projection
- type Reference
- type ReferentialAction
- type Routine
- type RoutineAction
- type RoutineOrdering
- type RoutineParameter
- type RoutineResultColumn
- type Row
- type Schema
- type SearchResult
- type SelectQuery
- type Snapshot
- type Store
- func (store *Store) ApplyChanges(ctx context.Context, schema Schema, changes []Change) error
- func (store *Store) ApplyChangesTolerant(ctx context.Context, schema Schema, changes []Change) error
- func (store *Store) ApplySchema(ctx context.Context, schema Schema) error
- func (store *Store) CallRoutine(ctx context.Context, schema Schema, name string, arguments map[string]Value) error
- func (store *Store) Close() error
- func (store *Store) DropTable(ctx context.Context, table string) error
- func (store *Store) DropTableIfExists(ctx context.Context, table string) error
- func (store *Store) ExportSnapshot(ctx context.Context, schema Schema) (Snapshot, error)
- func (store *Store) ImportSnapshot(ctx context.Context, snapshot Snapshot) error
- func (store *Store) InspectSchema(ctx context.Context) (Inspection, error)
- func (store *Store) InstallChangeCapture(ctx context.Context, schema Schema) error
- func (store *Store) IsForeignKeyViolation(err error) bool
- func (store *Store) IsUniqueViolation(err error) bool
- func (store *Store) QueryRoutine(ctx context.Context, schema Schema, name string, arguments map[string]Value) ([]Row, error)
- func (store *Store) ReadCapturedChanges(ctx context.Context, schema Schema, after uint64, limit int) ([]Change, error)
- func (store *Store) SearchText(ctx context.Context, table, idColumn string, textColumns []string, ...) ([]SearchResult, error)
- func (store *Store) TableExists(ctx context.Context, table string) (bool, error)
- type Table
- type TableSource
- type Target
- type Trigger
- type TriggerAction
- type Type
- type TypeFamily
- type Value
- type ValueKind
- type VerificationReport
- type Version
- type View
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CompileDDL ¶
CompileDDL emits the target-specific physical schema from the canonical AST. Callers must audit transformed features before treating the result as an exact migration plan.
func CompileDropTable ¶ added in v0.2.0
CompileDropTable compiles the statement that drops one existing table.
It does NOT emit IF EXISTS: dropping a table that is not there is a mistake in the caller's model of the database, and the engine's "no such table" error is the only signal that surfaces it. Idempotence is available explicitly through CompileDropTableIfExists / Store.DropTableIfExists — two named entry points instead of an unnamed boolean at the call site, so a reader of the call always sees which semantics were chosen.
The table name is quoted with quoteIdentifier, so a name containing double quotes, a semicolon or any other punctuation stays a single identifier and can never introduce extra SQL.
func CompileDropTableIfExists ¶ added in v0.2.0
CompileDropTableIfExists compiles the idempotent form, `DROP TABLE IF EXISTS`, for callers that genuinely want "make sure this table is gone" (teardown, reruns of a rebuild). IF EXISTS is spelled out in the function name precisely because it hides a real error — a typo in the table name becomes a silent no-op — so choosing it must be visible at the call site.
The IF EXISTS keyword is likewise identical in both engines.
func Placeholder ¶ added in v0.4.0
Placeholder composes the bind-parameter marker for one argument on the given engine: `?` on SQLite, `$n` on PostgreSQL. It is the single place in this package that knows that difference — every internal statement builder (metadata upsert, insertRow, replication, capture, routine WHERE/LIMIT/OFFSET) goes through it, so there is exactly one implementation, not a copy per caller.
It is exported because a consumer sometimes *must* write raw SQL and would otherwise branch on the engine by hand — the one thing this package exists to avoid. The reason raw SQL is unavoidable is structural, not a defect of the caller: CallRoutine and QueryRoutine open their own transaction, so they cannot be used inside a transaction the consumer already owns (DDL plus its registry and metadata rows that must commit together). It is the same limit that already motivated splitting the pure CompileDropTable from the executing DropTable.
Contract:
- position is 1-based: the first argument is position 1.
- The caller is responsible for the order of the arguments matching the positions it emitted. SQLite binds `?` markers by their order of appearance in the statement, PostgreSQL binds `$n` by its number, so the two agree only when the emitted sequence is 1, 2, 3, … in the same order the values are passed to Exec/Query. Emitting `$2` before `$1`, or reusing `$1` twice, compiles on PostgreSQL and means something different on SQLite; this function cannot detect that and does not try to.
- The returned value is a SQL fragment, never a value. It exists so caller data is bound, not concatenated.
A position below 1 is a caller bug, and it makes BOTH engines reject the statement. That convergence is deliberate and is the whole reason this function does not just emit `?` unconditionally.
PostgreSQL rejects an invalid position on its own: `$0` is "there is no parameter $0" (SQLSTATE 42P02) and `$-1` is a syntax error (42601). SQLite is the engine that needed the guard, because a bare `?` carries no number — the mistake would simply vanish and the statement would run, which is the "works in development, explodes in production" divergence this package exists to prevent. So for position < 1 SQLite is given the numbered marker `?0`, which it rejects with `variable number must be between ?1 and ?32766`.
The emitted marker for an invalid position is the CONSTANT `?0`, not `?` plus the position. That distinction is not cosmetic and was measured against real SQLite: `?-1` does NOT tokenize as an invalid numbered marker, it tokenizes as the positional `?` followed by the arithmetic `- 1`. `WHERE "id" = ?-1` is therefore accepted as `WHERE "id" = ? - 1` and quietly returns the WRONG rows (binding 2 matches the row with id 1) — strictly worse than the bug it was meant to surface. `?0` is invalid for every position below 1, so it is the marker used for all of them.
Position >= 1 is untouched: SQLite still emits the plain positional `?`, byte-identical to what this package has always emitted.
Normalizing an invalid position to 1 was rejected as a fix: it would hide the caller's bug behind SQL that silently means something else. Adding an error return was also rejected: the signature is a pure string composer used inside expression concatenation (`"... = " + Placeholder(engine, 1)`), and threading an error through every internal statement builder would buy nothing that the engine-level rejection does not already guarantee.
func RequireEquivalent ¶
func RequireEquivalent(report VerificationReport) error
func RequireExact ¶
func SnapshotDigest ¶
SnapshotDigest creates a deterministic data-integrity proof for a canonical snapshot. Row order is normalized; table, column and value names are encoded by encoding/json with stable map ordering.
Types ¶
type Assignment ¶
type Assignment struct {
Column string `json:"column"`
Value Expression `json:"value"`
}
type CatalogObject ¶
type Change ¶
type Change struct {
Source Target `json:"source"`
Sequence uint64 `json:"sequence"`
CommittedAt time.Time `json:"committed_at"`
Kind ChangeKind `json:"kind"`
Table string `json:"table"`
PrimaryKey Row `json:"primary_key"`
Before Row `json:"before,omitempty"`
After Row `json:"after,omitempty"`
TransactionID string `json:"transaction_id"`
}
Change is an engine-neutral mutation. Sequence is monotonically increasing per source, which lets a destination apply the same ordered history.
func OrderedChanges ¶
OrderedChanges rejects ambiguous or out-of-order histories before applying them to the other engine.
type ChangeKind ¶
type ChangeKind string
const ( Insert ChangeKind = "insert" Update ChangeKind = "update" Delete ChangeKind = "delete" )
type Column ¶
type Column struct {
Name string `json:"name"`
Type Type `json:"type"`
Nullable bool `json:"nullable"`
Default *Expression `json:"default,omitempty"`
// DomainRef, when set, names a Schema.Domains entry this column takes its
// CHECK/NOT NULL/DEFAULT from. It is additive and omitempty, so a column
// without it stays byte-identical. The column MUST still carry Type equal to
// the domain's base type: the data chain (export/import canonicalization) keys
// off Column.Type.Family, and both engines physically store the domain value
// as its base type, so the redundant Type keeps that path unchanged and keeps
// PG(native domain) and SQLite(inlined) storing identical values. A
// domain-referencing column must be otherwise neutral — Nullable true, no own
// Default, no Generated — so the domain is the single source of the constraint
// (validated in Schema.Validate). On PostgreSQL the column's SQL type is the
// domain name; on SQLite it is the base type with the domain's CHECK/NOT
// NULL/DEFAULT inlined.
DomainRef string `json:"domain,omitempty"`
// Generated, when set, makes this a STORED generated column whose value is
// computed from Expression rather than supplied on INSERT/UPDATE. It is an
// additive, omitempty field: a column without it stays byte-identical in the
// canonical JSON and in every emitted statement. See GeneratedColumn.
Generated *GeneratedColumn `json:"generated,omitempty"`
}
type CommonTableExpr ¶
type CommonTableExpr struct {
Name string `json:"name"`
Query SelectQuery `json:"query"`
}
CommonTableExpr is one named, non-recursive common table expression: the CTE name and the query it binds. Referenced from a FROM clause by its name like an ordinary table, a CTE has identical materialized-result semantics in SQLite and PostgreSQL. A CTE name shadows a real table of the same name for the duration of the query in both engines (standard SQL), so no special resolution is needed.
type CompoundSelect ¶
type CompoundSelect struct {
Operator string `json:"operator"`
Query SelectQuery `json:"query"`
}
CompoundSelect is one branch of a compound (set-operation) SELECT: the set operator that joins it to everything to its left, plus the branch query. The operator is one of "union", "union_all", "intersect", "except"; all four have identical set semantics in SQLite and PostgreSQL. A chain that mixes "intersect" with any other operator is rejected, because INTERSECT binds more tightly than UNION/EXCEPT in PostgreSQL but has equal (left-associative) precedence in SQLite, so a flat left-associative chain would group differently between the two engines.
type ConflictError ¶
func (*ConflictError) Error ¶
func (err *ConflictError) Error() string
type Constraint ¶
type Constraint struct {
Kind ConstraintKind `json:"kind"`
Columns []string `json:"columns"`
References *Reference `json:"references,omitempty"`
Expression *Expression `json:"expression,omitempty"`
}
type ConstraintKind ¶
type ConstraintKind string
const ( PrimaryKey ConstraintKind = "primary_key" UniqueKey ConstraintKind = "unique" ForeignKey ConstraintKind = "foreign_key" Check ConstraintKind = "check" )
type Contract ¶
type Contract struct {
Source Target `json:"source"`
Destination Target `json:"destination"`
RequiredFeatures []Feature `json:"required_features"`
}
Contract is the complete compatibility claim made for one migration or synchronization relationship. RequiredFeatures must be audited before data movement begins; unknown capabilities are failures, never silent fallbacks.
type Domain ¶
type Domain struct {
Name string `json:"name"`
Type Type `json:"type"`
Check *Expression `json:"check,omitempty"`
NotNull bool `json:"not_null,omitempty"`
Default *Expression `json:"default,omitempty"`
}
Domain is a named SQL domain: a base Type plus an optional CHECK constraint, NOT NULL flag and DEFAULT expression. It is asymmetric across the two engines by nature:
- PostgreSQL emits a native CREATE DOMAIN and columns reference it by name.
- SQLite has no domains, so every column that references the domain is compiled INLINE with the domain's base type and the same CHECK/NOT NULL/DEFAULT — semantically equivalent (same base type, same constraint), the only portable rendering.
The CHECK expression refers to the value under test with the placeholder node Expression{Kind:"domain_value"}: it compiles to the SQL keyword VALUE on PostgreSQL (the domain's own value) and to the referencing column's name when inlined on SQLite. Grammar validity of the CHECK is enforced at compile time by the same compileExpression path as any other expression, so an out-of-grammar CHECK fails with an explicit error.
A domain is a schema-level constraint, not data: the stored value is identical whether the constraint is enforced by a native PG domain or by an inline SQLite CHECK, so data fidelity is preserved. Only the canonical path (schema created by this layer, kept in __compat_schema) round-trips the domain exactly; external inspection cannot rebuild a domain that never physically existed as such (see docs/COMPATIBILITY.md).
type Expression ¶
type Expression struct {
Kind string `json:"kind"`
Value string `json:"value,omitempty"`
Args []Expression `json:"args,omitempty"`
}
Expression is an AST placeholder. Raw SQL is intentionally excluded from this layer because exact compatibility requires the expression to be parsed and compiled separately for each target dialect.
type Feature ¶
type Feature string
const ( Tables Feature = "tables" PrimaryKeys Feature = "primary_keys" ForeignKeys Feature = "foreign_keys" CanonicalForeignKeys Feature = "canonical_foreign_keys" CheckRules Feature = "check_constraints" CanonicalChecks Feature = "canonical_check_constraints" Transactions Feature = "transactions" Indexes Feature = "indexes" CanonicalIndexes Feature = "canonical_indexes" JSONValues Feature = "json" UUIDValues Feature = "uuid" Triggers Feature = "triggers" CanonicalTriggers Feature = "canonical_triggers" Views Feature = "views" CanonicalViews Feature = "canonical_views" StoredRoutines Feature = "stored_routines" CanonicalRoutines Feature = "canonical_routines" FullText Feature = "full_text" CanonicalFullText Feature = "canonical_full_text" CanonicalVectors Feature = "canonical_vectors" )
func InferFeatures ¶
InferFeatures derives the minimum capabilities required by a canonical schema. Migration callers use this to prevent a schema from claiming an exact plan while omitting its difficult capabilities from the contract.
type Finding ¶
type Finding struct {
Feature Feature `json:"feature"`
Status MappingStatus `json:"status"`
Reason string `json:"reason,omitempty"`
}
type GeneratedColumn ¶
type GeneratedColumn struct {
Expression Expression `json:"expression"`
Stored bool `json:"stored"`
}
GeneratedColumn describes a STORED generated column: `col TYPE GENERATED ALWAYS AS (<expression>) STORED`. Only STORED is supported because it is computed and physically stored identically by SQLite (>= 3.31) and PostgreSQL (>= 12) with this exact syntax; the value is recomputed on the destination from the same deterministic expression, which is the equivalence proof. VIRTUAL is deliberately not supported (PostgreSQL cannot express it), so Stored must be true — a false Stored is rejected by Schema.Validate rather than silently emitting divergent DDL. Expression uses the canonical grammar (compat/sqlparse.go, parseCatalogExpression) and is compiled by the same compileExpression path as any other expression, so an out-of-grammar expression fails at compile time with an explicit error. A generated column cannot also carry a Default and cannot be part of a canonical primary key (both engines restrict this).
type Index ¶
type Index struct {
Name string `json:"name"`
Table string `json:"table"`
Unique bool `json:"unique,omitempty"`
Columns []IndexColumn `json:"columns"`
Where *Expression `json:"where,omitempty"`
}
type IndexColumn ¶
type IndexColumn struct {
Column string `json:"column,omitempty"`
Descending bool `json:"descending,omitempty"`
Expression *Expression `json:"expression,omitempty"`
}
IndexColumn is one key of an index. Ordinarily the key is a plain column (Column). When Expression is set the key is that catalog expression (Section 3 grammar) compiled inside parentheses — an expression index, supported by SQLite (>= 3.9) and PostgreSQL with identical `(expr)` key syntax — and Column is left empty. Descending applies to either form. Expression is additive and omitted from JSON when nil, so a plain-column index stays byte-identical in canonical metadata and in every emitted statement.
type Inspection ¶
type Inspection struct {
Schema Schema `json:"schema"`
Exact bool `json:"exact"`
Source string `json:"source"`
Unresolved []CatalogObject `json:"unresolved,omitempty"`
}
type Join ¶
type Join struct {
Kind string `json:"kind"`
Table TableSource `json:"table"`
On Expression `json:"on"`
}
type MappingStatus ¶
type MappingStatus string
const ( Exact MappingStatus = "exact" Transformed MappingStatus = "transformed" Emulated MappingStatus = "emulated" Unsupported MappingStatus = "unsupported" Unknown MappingStatus = "unknown" )
type Ordering ¶
type Ordering struct {
Expression Expression `json:"expression"`
Descending bool `json:"descending,omitempty"`
}
type Projection ¶
type Projection struct {
Expression Expression `json:"expression"`
Alias string `json:"alias,omitempty"`
}
type Reference ¶
type Reference struct {
Table string `json:"table"`
Columns []string `json:"columns"`
OnUpdate ReferentialAction `json:"on_update,omitempty"`
OnDelete ReferentialAction `json:"on_delete,omitempty"`
}
type ReferentialAction ¶
type ReferentialAction string
const ( NoAction ReferentialAction = "no_action" Restrict ReferentialAction = "restrict" Cascade ReferentialAction = "cascade" SetNull ReferentialAction = "set_null" SetDefault ReferentialAction = "set_default" )
type Routine ¶
type Routine struct {
Name string `json:"name"`
Parameters []RoutineParameter `json:"parameters,omitempty"`
Actions []RoutineAction `json:"actions"`
}
type RoutineAction ¶
type RoutineAction struct {
Kind string `json:"kind"`
Table string `json:"table"`
Assignments []Assignment `json:"assignments,omitempty"`
Where *Expression `json:"where,omitempty"`
// The fields below belong to the read action ("select") and are all additive
// and omitempty, so an insert/update/delete action serializes byte-identically
// to how it did before the read action existed.
//
// Relation is the source of a "select": a table OR a view. It is a separate
// field from Table (which names the written table of a write action) because a
// read may target a view, where the joins/aggregates live — the canonical
// division that keeps the read action free of joins.
Relation string `json:"relation,omitempty"`
// Columns are the DECLARED output columns of a "select". A View carries no
// output types (it is {Name, Query}) and they cannot be inferred in general
// (count(*), arbitrary expressions), so the action declares them: this layer
// declares, it does not guess. The declaration is also the check — a value
// that is not of the declared family fails loudly in canonicalValue instead of
// reaching the caller as garbage.
Columns []RoutineResultColumn `json:"columns,omitempty"`
// OrderBy is the DECLARED ordering of a "select". Order columns are never
// caller-supplied: a column name cannot be bound as a placeholder, so taking
// it from the caller would be an injection path. It is mandatory whenever
// Limit or Offset is present, because without a total order "page 2" can mean
// different rows on each engine.
OrderBy []RoutineOrdering `json:"order_by,omitempty"`
// Limit and Offset are values, so they MAY come from the caller: each is a
// routine expression resolving to a non-negative integer ("parameter" or
// "integer"), bound as a placeholder after the WHERE placeholders. Offset
// requires Limit: SQLite has no OFFSET without LIMIT, and emitting a synthetic
// "LIMIT -1" to paper over that would be exactly the silent divergence this
// layer refuses.
Limit *Expression `json:"limit,omitempty"`
Offset *Expression `json:"offset,omitempty"`
}
type RoutineOrdering ¶ added in v0.3.0
type RoutineOrdering struct {
Column string `json:"column"`
Descending bool `json:"descending,omitempty"`
}
RoutineOrdering is one declared ORDER BY key of a "select" routine action. It is a plain column name (quoted at compile time), never an expression and never a caller-supplied value.
type RoutineParameter ¶
type RoutineResultColumn ¶ added in v0.3.0
RoutineResultColumn is one declared output column of a "select" routine action: the column name as projected by the relation, plus the canonical type family used to canonicalize every scanned value (the same contract as Column.Type on a table). It mirrors RoutineParameter, which declares the inputs.
type Schema ¶
type Schema struct {
Tables []Table `json:"tables"`
Indexes []Index `json:"indexes,omitempty"`
// Domains is the list of named SQL domains (a base type plus an optional
// CHECK, NOT NULL and DEFAULT). It is additive and omitempty, so a schema
// without domains stays byte-identical in the canonical JSON and in every
// emitted statement. PostgreSQL has native domains (CREATE DOMAIN emitted
// before the tables); SQLite has none, so a column that references a domain is
// inlined with the domain's base type + CHECK (+ NOT NULL/DEFAULT). See Domain.
Domains []Domain `json:"domains,omitempty"`
Views []View `json:"views,omitempty"`
Triggers []Trigger `json:"triggers,omitempty"`
Routines []Routine `json:"routines,omitempty"`
}
Schema is the engine-neutral representation used before emitting SQLite or PostgreSQL DDL. Every engine-specific construct must be represented as an explicit capability rather than hidden in a raw SQL string.
type SearchResult ¶
type SearchResult struct {
ID string `json:"id"`
}
type SelectQuery ¶
type SelectQuery struct {
// With is the list of non-recursive common table expressions (CTEs) that
// precede this query: WITH name AS (SELECT ...), ... <query>. Each CTE query
// is itself a SelectQuery in the same bounded grammar. WITH RECURSIVE is
// deliberately not modeled and is rejected at parse time, because its
// termination and row-ordering semantics are hard to guarantee byte-identical
// between SQLite and PostgreSQL. Absent CTEs leave the query byte-identical,
// and the field is omitted from JSON so existing view snapshots do not change.
With []CommonTableExpr `json:"with,omitempty"`
Distinct bool `json:"distinct,omitempty"`
Columns []Projection `json:"columns"`
From TableSource `json:"from"`
Joins []Join `json:"joins,omitempty"`
Where *Expression `json:"where,omitempty"`
GroupBy []Expression `json:"group_by,omitempty"`
Having *Expression `json:"having,omitempty"`
// Compounds is the left-associative chain of set operations applied after
// this (the leading) SELECT: q0 op1 q1 op2 q2 ... The trailing OrderBy,
// Limit and Offset below apply to the whole compound, not to the last
// branch, so each CompoundSelect.Query carries none of them. Absent
// compounds leave the single-SELECT behavior byte-identical, and the field
// is omitted from JSON so existing view snapshots do not change.
Compounds []CompoundSelect `json:"compounds,omitempty"`
OrderBy []Ordering `json:"order_by,omitempty"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
}
type Store ¶
Store is the database/sql boundary for a concrete engine. All values crossing this boundary are converted into the canonical Value representation.
func (*Store) ApplyChanges ¶
ApplyChanges applies one ordered source stream atomically. Reapplying the same stream is safe: committed source sequences are recorded in the target transaction and skipped on subsequent attempts.
func (*Store) ApplyChangesTolerant ¶
func (store *Store) ApplyChangesTolerant(ctx context.Context, schema Schema, changes []Change) error
ApplyChangesTolerant applies one ordered source stream atomically with an opt-in, catch-up conflict policy. It exists for zero-window migrations whose capture-install → snapshot → catch-up sequence inherently overlaps: a change journaled after capture was installed may already have traveled inside the snapshot, so re-applying it would trip a spurious ConflictError even though the destination already reflects the change's final state.
The only difference from ApplyChanges is conflict resolution. A change is treated as already applied — and recorded in __compat_applied_changes as if it had just been applied — when the destination's CURRENT state already equals the change's FINAL state:
- insert: the row already exists and rowsEqual(after, actual);
- update: the row exists and rowsEqual(after, actual) even though Before no longer matches (the snapshot already carried the after state);
- delete: the row no longer exists.
Any other divergence remains a strict ConflictError: the tolerant mode is a catch-up convenience, not a bypass. ApplyChanges is unchanged.
func (*Store) ApplySchema ¶
func (*Store) CallRoutine ¶
func (store *Store) CallRoutine(ctx context.Context, schema Schema, name string, arguments map[string]Value) error
CallRoutine executes a canonical routine inside one transaction. The routine is stored in the canonical schema and therefore has the same implementation on SQLite and PostgreSQL instead of relying on engine-specific languages.
func (*Store) DropTable ¶ added in v0.2.0
DropTable compiles (CompileDropTable) and executes the drop against this store's database. Engine errors — a missing table, or a foreign key from another table pointing at this one — are wrapped with the operation name and propagated; nothing is retried with CASCADE.
Note on the change-capture triggers installed by InstallChangeCapture: both engines drop the triggers attached to the table together with the table itself. On PostgreSQL the trigger FUNCTIONS (`__compat_capture_<table>_<kind>_fn`) are separate catalog objects and survive the drop, orphaned but harmless; re-running InstallChangeCapture recreates them with CREATE OR REPLACE. The rows already written to `__compat_change_journal` for the dropped table are also kept on both engines — the journal is an append-only history and this package never deletes data implicitly.
func (*Store) DropTableIfExists ¶ added in v0.2.0
DropTableIfExists is DropTable with the idempotent statement; see CompileDropTableIfExists for why the choice is explicit in the name.
func (*Store) ExportSnapshot ¶
func (*Store) ImportSnapshot ¶
ImportSnapshot creates the canonical schema and inserts every canonical row. It is intentionally append-only; replacement and synchronization policies are handled by higher layers so data is never deleted implicitly.
func (*Store) InspectSchema ¶
func (store *Store) InspectSchema(ctx context.Context) (Inspection, error)
InspectSchema reconstructs the exact canonical schema when the database was managed by this compatibility layer. For external databases it falls back to catalog inspection and explicitly reports objects not yet translated.
func (*Store) InstallChangeCapture ¶
InstallChangeCapture installs engine-native triggers which journal every committed row mutation in canonical, ordered form.
func (*Store) IsForeignKeyViolation ¶ added in v0.5.0
IsForeignKeyViolation reports whether err is the engine's way of saying "a foreign key rejected this write". Like IsUniqueViolation it is a classifier over the driver error, not a new error type: nothing is wrapped, replaced or re-raised, and it is deliberately its sibling in shape.
It exists for the same reason: the portable-looking way to detect it is to match the text `FOREIGN KEY constraint failed`, which is SQLite's wording. PostgreSQL says `insert or update on table "..." violates foreign key constraint "..."` and reports SQLSTATE 23503, so a consumer that migrates keeps compiling and silently stops detecting the referential rejection: a clean 400 turns into a 500. The alternative the consumer is forced into without this predicate — checking that the referenced row exists BEFORE the write — is worse than merely verbose: it leaves a race window, because the referenced row can disappear between the check and the write, and the honest rejection then arrives as a 500.
Classification is by **structured code**, never by message text:
- SQLite (modernc.org/sqlite): `*sqlite.Error` with the extended result code SQLITE_CONSTRAINT_FOREIGNKEY (787) **or** SQLITE_CONSTRAINT_TRIGGER (1811). The sibling constraint codes SQLITE_CONSTRAINT_UNIQUE (2067) and SQLITE_CONSTRAINT_PRIMARYKEY (1555) are different rejections and are not folded in; 1555 belongs to IsUniqueViolation because a primary key IS a unique constraint.
- PostgreSQL (github.com/jackc/pgx/v5): `*pgconn.PgError` with SQLSTATE 23503 (foreign_key_violation).
**Why 1811 is in the SQLite list — measured, not assumed.** SQLite implements a foreign key declared `ON DELETE RESTRICT` / `ON UPDATE RESTRICT` with its internal trigger machinery, so the rejection on the PARENT side carries SQLITE_CONSTRAINT_TRIGGER instead of SQLITE_CONSTRAINT_FOREIGNKEY. With DDL identical except for the referential action (SQLite 3, modernc driver):
NO ACTION INSERT child with a missing parent -> FOREIGN KEY constraint failed 787 NO ACTION DELETE the referenced parent -> FOREIGN KEY constraint failed 787 NO ACTION UPDATE the referenced parent key -> FOREIGN KEY constraint failed 787 RESTRICT INSERT child with a missing parent -> FOREIGN KEY constraint failed 787 RESTRICT DELETE the referenced parent -> FOREIGN KEY constraint failed 1811 RESTRICT UPDATE the referenced parent key -> FOREIGN KEY constraint failed 1811
The **message text is byte-identical in all six**, so it could not separate them even if this predicate were willing to read it (it is not). PostgreSQL answers 23503 to every one of those writes. Leaving 1811 out therefore made the two engines classify the SAME canonical schema and the SAME operation differently — the silent divergence this package exists to prevent — and it was found by a real consumer that declares RESTRICT. `no_action` (declared or omitted) stays at 787; `cascade` and `set_null` do not reject at all on the parent side (they propagate), so they never reach this predicate — except that a `set_null` into a NOT NULL column fails with SQLITE_CONSTRAINT_NOTNULL (1299), which is correctly NOT classified here.
**The residue this buys, stated rather than hidden.** 1811 has a second producer: a trigger that aborts with RAISE(ABORT|FAIL|ROLLBACK). It is unreachable from the canonical grammar — a trigger action is one of exactly three kinds (INSERT/UPDATE/DELETE) and the expression grammar is a closed allowlist by node kind with no RAISE, so a schema this package compiles cannot emit one (pinned by TestCanonicalTriggerGrammarCannotRaiseAbort). A consumer CAN still create its own trigger with raw SQL against store.DB, and if that trigger aborts, this predicate will call it a foreign-key violation. That is an accepted, bounded consequence — raw consumer SQL is already outside the package's guarantees — and it is fixed by a test (TestConsumerRawTriggerAbortClassifiesAsForeignKeyViolation) so it stays measured and visible instead of becoming a future surprise. The alternative, separating the two by message text, is exactly what this classifier refuses to do, and would not work anyway: the RESTRICT text is the ordinary `FOREIGN KEY constraint failed` and the RAISE text is whatever the consumer wrote.
Both lookups go through errors.As, so an error the consumer has wrapped — `fmt.Errorf("save entry: %w", err)`, which is how it will actually arrive after crossing a repository layer — is still classified. A nil error is false.
It dispatches on this store's engine rather than trying both drivers, for the same reason IsUniqueViolation does: the answer must be the one the engine actually connected to would give, and a PostgreSQL error reaching a SQLite store is a caller bug that should not be papered over by a lucky match.
**Documented limitation — it cannot tell you WHICH DIRECTION of the violation happened.** Both engines report the very same code for the two opposite cases:
- writing a row whose foreign key points at a parent that does not exist (the caller sent a bad reference — usually a 400), and
- deleting or updating a parent row that a child still references (the caller's target is in use — usually a 409).
PostgreSQL raises 23503 for both, differing only in the message text and in which table it names. SQLite raises 787 for both when the key is NO ACTION, and 787 (child side) versus 1811 (parent side) when it is RESTRICT — a difference that is an artifact of the referential action, NOT a usable direction signal: it does not exist on PostgreSQL and it does not exist on SQLite for a NO ACTION key, so a caller keying off it would be writing code that means different things per engine and per DDL. Telling the directions apart would therefore require reading the message or the constraint name — exactly the thing this predicate exists to avoid — so it is documented rather than resolved, in the same spirit as IsUniqueViolation documenting that it cannot say WHICH unique constraint was violated. A caller that must answer 400 vs 409 has to know it from the operation it issued (an INSERT of a child cannot be a "still referenced" failure; a DELETE of a parent cannot be a "missing parent" one), not from this boolean.
**Reachability note for SQLite.** SQLite only enforces foreign keys when the connection has `PRAGMA foreign_keys` on, and it is OFF by default, so with it off there would be no violation to classify at all. It is not off here: OpenSQLite pins `_pragma=foreign_keys(1)` in the DSN and caps the pool at a single connection (the pragma is connection-scoped), which is the same guarantee DropTable already relies on. A `Store` built by hand around a `*sql.DB` opened without that pragma is outside the package's contract; there the write simply succeeds and this predicate is never consulted.
func (*Store) IsUniqueViolation ¶ added in v0.4.0
IsUniqueViolation reports whether err is the engine's way of saying "a unique constraint rejected this write". It is a classifier over the driver error, not a new error type: nothing is wrapped, replaced or re-raised.
It exists because the portable-looking way to detect a duplicate is to match the text `UNIQUE constraint failed`, which is SQLite's wording. PostgreSQL says `duplicate key value violates unique constraint "..."` and reports SQLSTATE 23505, so a consumer that migrates keeps compiling and silently stops detecting duplicates: a clean 400 turns into a 500, and nobody finds out until a user duplicates a value in production. That regression has no compiler signal, which is what makes it worth a primitive.
Classification is by **structured code**, never by message text:
- SQLite (modernc.org/sqlite): `*sqlite.Error` with the extended result code SQLITE_CONSTRAINT_UNIQUE (2067) or SQLITE_CONSTRAINT_PRIMARYKEY (1555). Both are included on purpose — a primary key IS a unique constraint, and PostgreSQL reports a duplicate primary key with the very same 23505 it uses for a UNIQUE index. Leaving 1555 out would make the two engines disagree on the identical write.
- PostgreSQL (github.com/jackc/pgx/v5): `*pgconn.PgError` with SQLSTATE 23505 (unique_violation), which covers both UNIQUE and PRIMARY KEY.
Both lookups go through errors.As, so an error the consumer has wrapped — `fmt.Errorf("save entry: %w", err)`, which is how it will actually arrive after crossing a repository layer — is still classified. A nil error is false.
It dispatches on this store's engine rather than trying both drivers: the answer must be the one the engine actually connected to would give, and a PostgreSQL error reaching a SQLite store is a bug in the caller that should not be papered over by a lucky match.
**Documented limitation — it returns a boolean, and cannot tell you WHICH constraint was violated.** That is a deliberate stop, not an oversight: the engines report different granularities. SQLite names the `table.column` list in the message; PostgreSQL names the CONSTRAINT, which it also auto-generates when the declaration does not fix one (`zzt_email_key`, `zzt_pkey`). Exposing "which constraint" would mean mapping engine-chosen names back onto the canonical schema — a larger API with its own ambiguities. The consequence the caller must know: **with two unique constraints on the same table you cannot tell which one was violated**, on either engine. If a caller needs to distinguish them (to report a different field to the user), the portable way is to check the candidate values before the write, not to parse this error.
func (*Store) QueryRoutine ¶ added in v0.3.0
func (store *Store) QueryRoutine(ctx context.Context, schema Schema, name string, arguments map[string]Value) ([]Row, error)
QueryRoutine executes a canonical READ routine and returns canonical rows. It is the read counterpart of CallRoutine, which stays write-only and unchanged: CallRoutine returns only error and has production consumers, so reading is new, additive surface rather than a signature change.
The routine must contain exactly one action, of kind "select" (enforced by Schema.Validate too). The statement is composed from pieces that already exist: compileRoutineWhere for the filter — which emits the right placeholder per engine and binds every caller value instead of inlining it, and already maps LIKE to ILIKE on PostgreSQL — and the exportTable pattern for reading, which canonicalizes every scanned value by type family. Here the family comes from the action's DECLARED output columns, because a view has no output types.
Every identifier (relation, output columns, order keys) goes through quoteIdentifier; every caller value, including LIMIT and OFFSET, is bound as a placeholder. Nothing supplied by the caller is ever concatenated into the SQL.
func (*Store) ReadCapturedChanges ¶
func (store *Store) ReadCapturedChanges(ctx context.Context, schema Schema, after uint64, limit int) ([]Change, error)
ReadCapturedChanges reads an ordered source stream after the supplied cursor.
func (*Store) SearchText ¶
func (store *Store) SearchText(ctx context.Context, table, idColumn string, textColumns []string, query string) ([]SearchResult, error)
SearchText implements deterministic Unicode token matching in the common Go runtime. It does not delegate tokenization or ranking to either database.
func (*Store) TableExists ¶ added in v0.4.0
TableExists reports whether the table physically exists in the engine's own catalog: `sqlite_master` on SQLite, `pg_class` on PostgreSQL (that relation does not exist there, and `sqlite_master` has no PostgreSQL equivalent, which is exactly why the consumer cannot write this once).
It does NOT consult `__compat_schema`, and that is the whole point of the method rather than an implementation detail. InspectSchema prefers the stored canonical metadata over the physical catalog, which is right for the canonical path and wrong in precisely the situations where this question gets asked: a hand-written migration, or a half-finished table rebuild, where the metadata is the thing about to be rewritten and therefore cannot be the oracle. That cache has already caused four incidents across this family of projects. This method is the honest way out — it asks the engine, not the cache — and callers should read a disagreement between TableExists and InspectSchema as a real signal, not as a bug.
Engine notes, verified against real engines rather than assumed:
- PostgreSQL: the lookup is restricted to `current_schema()`, which is the first existing schema of the session `search_path` and therefore the exact place an unqualified `CREATE TABLE` from ApplySchema lands. Asking any other schema of the database — or asking without a schema filter — would answer a different question than "did ApplySchema create it here?".
- A VIEW with the searched name answers false on both engines: SQLite filters `type = 'table'` and PostgreSQL filters `relkind IN ('r','p')` (ordinary and partitioned tables). `information_schema.tables` is deliberately not used on PostgreSQL because it also lists views.
- The name is matched as a value through a bound placeholder, never spliced into the SQL, so it needs no quoting and a name containing a quote or a semicolon is simply a name. It is matched **byte for byte** on both engines: this package quotes every identifier it emits, so `CREATE TABLE "Foo"` stores `Foo` in both catalogs and neither engine folds the case. `TableExists(ctx, "foo")` is therefore false for a table declared as `Foo`, identically on SQLite and PostgreSQL — pass the name exactly as it was declared in the canonical schema.
The four reserved internal tables are NOT rejected here (unlike DropTable): the question is physical, and `__compat_schema` really does exist once this package has written it.
type Table ¶
type Table struct {
Name string `json:"name"`
Columns []Column `json:"columns"`
Constraints []Constraint `json:"constraints,omitempty"`
}
type TableSource ¶
type TableSource struct {
Table string `json:"table,omitempty"`
Alias string `json:"alias,omitempty"`
Subquery *SelectQuery `json:"subquery,omitempty"`
}
TableSource is a FROM/JOIN source: either a named table (Table) or a derived table (Subquery). Exactly one of Table or Subquery is set. A derived table is a full SelectQuery evaluated as a table; both engines give it identical results (it cannot be correlated with the enclosing query in standard SQL). A derived table requires an Alias. Subquery is omitted from JSON when absent, so existing table-source snapshots do not change.
type Trigger ¶
type Trigger struct {
Name string `json:"name"`
Table string `json:"table"`
Timing string `json:"timing"`
Event string `json:"event"`
When *Expression `json:"when,omitempty"`
Actions []TriggerAction `json:"actions"`
}
type TriggerAction ¶
type TriggerAction struct {
Kind string `json:"kind"`
Table string `json:"table"`
Assignments []Assignment `json:"assignments,omitempty"`
Where *Expression `json:"where,omitempty"`
}
type Type ¶
type Type struct {
Family TypeFamily `json:"family"`
// Arguments preserve details such as precision, scale, length or array
// dimensions without coupling the canonical schema to SQL text.
Arguments []int `json:"arguments,omitempty"`
}
type TypeFamily ¶
type TypeFamily string
const ( BooleanType TypeFamily = "boolean" IntegerType TypeFamily = "integer" DecimalType TypeFamily = "decimal" FloatType TypeFamily = "float" TextType TypeFamily = "text" BinaryType TypeFamily = "binary" DateType TypeFamily = "date" TimestampType TypeFamily = "timestamp" JSONType TypeFamily = "json" UUIDType TypeFamily = "uuid" VectorType TypeFamily = "vector" )
type Value ¶
Value keeps data independent from either driver's Go representation. Values are encoded canonically before persistence in a migration artifact or change journal, preserving null and type information across both engines.
type ValueKind ¶
type ValueKind string
const ( NullValue ValueKind = "null" BooleanValue ValueKind = "boolean" IntegerValue ValueKind = "integer" DecimalValue ValueKind = "decimal" FloatValue ValueKind = "float" TextValue ValueKind = "text" BinaryValue ValueKind = "binary" DateValue ValueKind = "date" TimestampValue ValueKind = "timestamp" JSONValue ValueKind = "json" UUIDValue ValueKind = "uuid" VectorValue ValueKind = "vector" )
type VerificationReport ¶
type VerificationReport struct {
SourceDigest string `json:"source_digest"`
DestinationDigest string `json:"destination_digest"`
Equivalent bool `json:"equivalent"`
}
func VerifySnapshots ¶
func VerifySnapshots(source, destination Snapshot) (VerificationReport, error)
type View ¶
type View struct {
Name string `json:"name"`
Query SelectQuery `json:"query"`
}