Skip to content

Commit 3b9eb91

Browse files
avalleteclaude
andauthored
fix(pg-delta): preserve REPLICA IDENTITY USING INDEX on tables (#235)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6702921 commit 3b9eb91

17 files changed

Lines changed: 706 additions & 194 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@supabase/pg-delta": patch
3+
---
4+
5+
fix(pg-delta): preserve `REPLICA IDENTITY USING INDEX` on tables instead of silently reverting to `DEFAULT` on declarative sync.
6+
7+
The table extractor only stored `replica_identity` as a single character (`'d' | 'n' | 'f' | 'i'`) and discarded the index name when the mode was `'i'`. The diff path then explicitly skipped mode `'i'` ("handled by index changes" — but no such handler existed), and `AlterTableSetReplicaIdentity.serialize()` fell back to `REPLICA IDENTITY DEFAULT` for that mode. Compounding this, `Index.is_replica_identity` participated in equality and was marked non-alterable, so toggling the flag on the index triggered a spurious `DROP INDEX` + `CREATE INDEX` — and Postgres reverts the table to `REPLICA IDENTITY DEFAULT` whenever the configured replica-identity index is dropped.
8+
9+
End result: a table configured with `ALTER TABLE foo REPLICA IDENTITY USING INDEX foo_idx` would extract as `replica_identity = 'i'` but produce no setter on diff. The next `declarative sync` would generate a migration that dropped the user's index, reset the table to `DEFAULT`, and recreated the index — never converging (reported as supabase/cli#5141).
10+
11+
The fix:
12+
13+
- `Table.replica_identity_index` is extracted via `pg_index.indisreplident` and included in `dataFields`, so the index name participates in equality.
14+
- `AlterTableSetReplicaIdentity` now serializes `REPLICA IDENTITY USING INDEX <name>` for mode `'i'` and declares the index as a `requires` dependency so it is created first.
15+
- The table diff emits the change for all modes (including `'i'`) on both `CREATE` and `ALTER`, and re-emits when the configured index name changes while staying in `'i'` mode.
16+
- `Index.is_replica_identity` is no longer in `dataFields` / `NON_ALTERABLE_FIELDS`; the table side is the source of truth, set via `ALTER TABLE`. This stops the spurious `DROP INDEX` + `CREATE INDEX` cycle.
17+
- A new `restoreReplicaIdentityAfterIndexReplace` pass in `post-diff-normalization.ts` re-emits `ALTER TABLE ... REPLICA IDENTITY USING INDEX <name>` after any `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica-identity index of a branch table. This covers the second flavor of the bug: when both main and branch already point at the same replica-identity index, but that index's *definition* changes (e.g. a column added to its key), the index is replaced, Postgres silently flips `relreplident` to `'d'`, and the table-level diff alone cannot see the cross-object interaction. The pass is idempotent — if `diffTables()` already emitted the same setter (because the table is also flipping mode or pointing to a different index), no duplicate is added.
18+
19+
The post-diff layer file `src/core/post-diff-cycle-breaking.ts` is renamed to `post-diff-normalization.ts` and `normalizePostDiffCycles` to `normalizePostDiffChanges` — the file already contained dedup and replacement-superseded pruning that aren't strictly cycle-breaking, and actual cycle breaking moved to the lazy sort-phase dispatcher in a previous release. The rename brings the file in line with the "post-diff normalization" terminology already used in the package's `CLAUDE.md` rule of thumb.

.github/agents/pg-toolbelt.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,69 @@ pg-delta has 45+ integration test files across 3 PG versions, sharded across 15
243243

244244
- Run `bun run test:pg-delta` (full suite) only after all changes are complete and targeted tests pass
245245

246+
### Running integration tests in a sandbox (no systemd, no Docker daemon)
247+
248+
Cloud sandboxes (e.g. Claude Code on the web) typically ship with the Docker
249+
client installed but no running daemon and no registry credentials. If
250+
`docker info` reports `Cannot connect to the Docker daemon`, set it up
251+
yourself instead of giving up and skipping integration coverage:
252+
253+
1. **Start `dockerd` directly** (no systemd in these sandboxes — `systemctl`
254+
and `/etc/init.d/docker` will both fail with `Operation not permitted`):
255+
256+
```bash
257+
sudo dockerd > /tmp/dockerd.log 2>&1 &
258+
sleep 5
259+
docker info | grep "Server Version" # confirm the daemon is up
260+
```
261+
262+
2. **Configure a registry mirror before pulling.** Anonymous Docker Hub pulls
263+
are rate-limited per source IP and the limit is reached almost immediately
264+
on shared CI/sandbox egress. `mirror.gcr.io` is a Google-hosted pull-through
265+
cache for Docker Hub `library/*` and other public images and works without
266+
credentials:
267+
268+
```bash
269+
sudo mkdir -p /etc/docker
270+
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
271+
sudo pkill dockerd; sleep 2
272+
sudo dockerd > /tmp/dockerd.log 2>&1 &
273+
sleep 5
274+
docker info | grep -A1 "Registry Mirrors" # confirm
275+
```
276+
277+
3. **Pre-pull only the images you need.** `tests/global-setup.ts` pulls *all*
278+
Alpine + Supabase tags listed in `tests/constants.ts` at startup. Always
279+
limit the matrix with `PGDELTA_TEST_POSTGRES_VERSIONS=17` (or `15`) so the
280+
preload only fetches the tags relevant to your run:
281+
282+
```bash
283+
docker pull postgres:17.6-alpine
284+
docker pull supabase/postgres:17.6.1.107 # only if your test uses withDbSupabase*
285+
```
286+
287+
4. **Run integration tests as usual** — the global-setup will reuse the cached
288+
images:
289+
290+
```bash
291+
cd packages/pg-delta
292+
PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test tests/integration/<file>.test.ts
293+
```
294+
295+
If you cannot get Docker running (e.g. the sandbox blocks `dockerd`'s
296+
networking even with the mirror), say so explicitly in your final report —
297+
do not silently skip the integration step. For unit-only iteration, you can
298+
bypass the bunfig `preload` (which loads `tests/global-setup.ts` and tries to
299+
contact the Docker daemon) by invoking `bun test` from outside the package
300+
directory:
301+
302+
```bash
303+
cd /tmp && bun test /home/user/pg-toolbelt/packages/pg-delta/src/...
304+
```
305+
306+
This is a workaround for fast unit-test feedback only; integration tests
307+
still need a working Docker daemon.
308+
246309
### Upgrading Supabase test images
247310

248311
When changing `packages/pg-delta/tests/constants.ts`, especially

packages/pg-delta/CLAUDE.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ for (const pgVersion of POSTGRES_VERSIONS) {
8888
Keep cycle handling split by the scope of information it needs:
8989

9090
- **Object-local PostgreSQL semantics stay in `diff*`**. If a single object diff can prove a statement is redundant or invalid on its own, fix it there. Example: `src/core/objects/sequence/sequence.diff.ts` skips `DROP SEQUENCE` when `OWNED BY` means PostgreSQL will already cascade-drop it with the owning table/column.
91-
- **Whole-plan interactions belong in post-diff normalization**. If the fix only becomes obvious after multiple emitted changes are combined, implement it in `src/core/post-diff-cycle-breaking.ts`, wired from `src/core/catalog.diff.ts` after raw diffs and `expandReplaceDependencies()`. Current examples: mutual dropped-table FK cycles and pruning same-table `AlterTableDropColumn` / `AlterTableDropConstraint` changes that are superseded by an expansion-added `DropTable+CreateTable` pair.
91+
- **Whole-plan interactions belong in post-diff normalization**. If the fix only becomes obvious after multiple emitted changes are combined, implement it in `src/core/post-diff-normalization.ts` (`normalizePostDiffChanges`), wired from `src/core/catalog.diff.ts` after raw diffs and `expandReplaceDependencies()`. The pass is the single chokepoint that observes the final `Change[]`, so it catches cross-object effects regardless of whether the relevant change pair was emitted by an object's `diff*` (e.g. `index.diff` for a definition-changed index) or by `expandReplaceDependencies()` (dependency-closure replacement). Current examples: pruning same-table `AlterTableDropColumn` / `AlterTableDropConstraint` changes superseded by an expansion-added `DropTable+CreateTable` pair, deduplicating constraint Add/Validate/Comment on replaced tables, and re-emitting `ALTER TABLE … REPLICA IDENTITY USING INDEX` after a replica-identity index is dropped+recreated.
9292
- **`expandReplaceDependencies()` only computes replacement closure**. It may report metadata such as which tables were promoted to replacement pairs, but it should not own unrelated cycle-pruning policy.
9393
- **`src/core/sort/dependency-filter.ts` is a narrow last resort**. Use it only for safe edge filtering where the emitted statements are already valid and only the graph edge is artificial. Do not extend sort-phase filtering to paper over plans that would still fail at apply time.
9494

@@ -108,6 +108,20 @@ Used to track objects across databases (OIDs differ per environment):
108108
- Sub-entities: `type:schema.parent.name` (e.g. `column:public.users.email`).
109109
- Metadata: `scope:target` (e.g. `comment:public.users`).
110110

111+
**Always build stable identifiers through the `stableId.*` helpers in
112+
`src/core/objects/utils.ts` (or the `<Object>.stableId` getter on a model
113+
instance) — never inline the prefix as a template literal.** Inline strings
114+
like `` `index:${schema}.${table}.${name}` `` drift from the helper if
115+
prefixes or escaping rules change, scatter the format across the codebase,
116+
and were caught in review on this exact pattern. If you need a stable id
117+
for an object type that does not have a helper yet, add the helper to
118+
`stableId` first, then use it everywhere — including new code paths,
119+
post-diff passes, and dependency wiring inside change classes.
120+
121+
When asserting stable ids in tests, the literal form is fine as the
122+
expected value (it documents the on-the-wire format), but the **production
123+
side** of the comparison should still call the helper.
124+
111125
### Integration DSL
112126

113127
- **Filter**: JSON pattern to include/exclude changes (e.g. `{ "not": { "schema": ["pg_catalog"] } }`).

packages/pg-delta/src/core/catalog.diff.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import debug from "debug";
22
import type { Catalog } from "./catalog.model.ts";
33
import { expandReplaceDependencies } from "./expand-replace-dependencies.ts";
4-
import { normalizePostDiffCycles } from "./post-diff-cycle-breaking.ts";
4+
import { normalizePostDiffChanges } from "./post-diff-normalization.ts";
55

66
const debugCatalog = debug("pg-delta:catalog");
77

@@ -238,9 +238,10 @@ export function diffCatalogs(
238238
mainCatalog: main,
239239
branchCatalog: branch,
240240
});
241-
filteredChanges = normalizePostDiffCycles({
241+
filteredChanges = normalizePostDiffChanges({
242242
changes: expandedDependencies.changes,
243243
replacedTableIds: expandedDependencies.replacedTableIds,
244+
branchTables: branch.tables,
244245
});
245246

246247
debugCatalog(

packages/pg-delta/src/core/objects/index/index.diff.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ export function diffIndexes(
104104
"nulls_not_distinct",
105105
"immediate",
106106
"is_clustered",
107-
"is_replica_identity",
108107
"column_collations",
109108
"operator_classes",
110109
"column_options",

packages/pg-delta/src/core/objects/index/index.model.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,11 @@ export class Index extends BasePgModel {
163163
nulls_not_distinct: this.nulls_not_distinct,
164164
immediate: this.immediate,
165165
is_clustered: this.is_clustered,
166-
is_replica_identity: this.is_replica_identity,
166+
// is_replica_identity excluded: the table's `replica_identity` /
167+
// `replica_identity_index` is the source of truth, set via
168+
// ALTER TABLE ... REPLICA IDENTITY USING INDEX. Including this flag here
169+
// would trigger spurious DROP+CREATE of the index whenever the table's
170+
// replica identity changes.
167171
// key_columns excluded: contains attribute numbers that can differ between databases
168172
// even when indexes are logically identical. The definition field already captures
169173
// the logical structure using column names, so we compare by definition instead.

packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ describe.concurrent("table", () => {
343343
).toBe("ALTER TABLE public.test_table REPLICA IDENTITY FULL");
344344
});
345345

346-
test("replica identity DEFAULT and INDEX fallback", async () => {
346+
test("replica identity DEFAULT and USING INDEX", async () => {
347347
const baseProps: Omit<
348348
TableProps,
349349
"owner" | "options" | "replica_identity"
@@ -372,31 +372,23 @@ describe.concurrent("table", () => {
372372
options: null,
373373
replica_identity: "n",
374374
});
375-
const toDefault = new Table({
376-
...baseProps,
377-
owner: "o1",
378-
options: null,
379-
replica_identity: "d",
380-
});
381-
const toIndex = new Table({
382-
...baseProps,
383-
owner: "o1",
384-
options: null,
385-
replica_identity: "i",
386-
});
387375
expect(
388376
new AlterTableSetReplicaIdentity({
389377
table,
390-
mode: toDefault.replica_identity,
391-
}).serialize(),
392-
).toBe("ALTER TABLE public.test_table REPLICA IDENTITY DEFAULT");
393-
// AlterTableSetReplicaIdentity of type "i" will not be emitted in diff, it is handled by index changes, we fallback to DEFAULT here
394-
expect(
395-
new AlterTableSetReplicaIdentity({
396-
table,
397-
mode: toIndex.replica_identity,
378+
mode: "d",
398379
}).serialize(),
399380
).toBe("ALTER TABLE public.test_table REPLICA IDENTITY DEFAULT");
381+
const usingIndex = new AlterTableSetReplicaIdentity({
382+
table,
383+
mode: "i",
384+
indexName: "test_table_pkey",
385+
});
386+
expect(usingIndex.serialize()).toBe(
387+
"ALTER TABLE public.test_table REPLICA IDENTITY USING INDEX test_table_pkey",
388+
);
389+
expect(usingIndex.requires).toContain(
390+
"index:public.test_table.test_table_pkey",
391+
);
400392
});
401393

402394
test("columns add/drop/alter", async () => {

packages/pg-delta/src/core/objects/table/changes/table.alter.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,20 +462,46 @@ export class AlterTableValidateConstraint extends AlterTableChange {
462462

463463
/**
464464
* ALTER TABLE ... REPLICA IDENTITY ...
465+
*
466+
* When `mode === "i"` (USING INDEX), `indexName` is the name of the index to
467+
* use. The extractor populates `Table.replica_identity_index` from
468+
* `pg_index.indisreplident` whenever `Table.replica_identity` is `'i'`, so
469+
* callers that source their props from a `Table` instance can rely on the
470+
* pair being consistent. The non-null assertions in `requires` / `serialize`
471+
* below are justified by that data invariant — the same pattern the FK
472+
* branch of `AlterTableAddConstraint` uses for `foreign_key_columns!` /
473+
* `foreign_key_table!` / `foreign_key_schema!`.
465474
*/
466475
export class AlterTableSetReplicaIdentity extends AlterTableChange {
467476
public readonly table: Table;
468477
public readonly mode: "d" | "n" | "f" | "i";
478+
public readonly indexName: string | null;
469479
public readonly scope = "object" as const;
470480

471-
constructor(props: { table: Table; mode: "d" | "n" | "f" | "i" }) {
481+
constructor(props: {
482+
table: Table;
483+
mode: "d" | "n" | "f" | "i";
484+
indexName?: string | null;
485+
}) {
472486
super();
473487
this.table = props.table;
474488
this.mode = props.mode;
489+
this.indexName = props.indexName ?? null;
475490
}
476491

477492
get requires() {
478-
return [this.table.stableId];
493+
const reqs: string[] = [this.table.stableId];
494+
if (this.mode === "i") {
495+
reqs.push(
496+
stableId.index(
497+
this.table.schema,
498+
this.table.name,
499+
// biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index
500+
this.indexName!,
501+
),
502+
);
503+
}
504+
return reqs;
479505
}
480506

481507
serialize(_options?: SerializeOptions): string {
@@ -486,7 +512,8 @@ export class AlterTableSetReplicaIdentity extends AlterTableChange {
486512
? "NOTHING"
487513
: this.mode === "f"
488514
? "FULL"
489-
: "DEFAULT"; // 'i' (USING INDEX) is handled via index changes; fallback to DEFAULT
515+
: // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index
516+
`USING INDEX ${this.indexName!}`;
490517
return [
491518
"ALTER TABLE",
492519
`${this.table.schema}.${this.table.name}`,

packages/pg-delta/src/core/objects/table/table.diff.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,13 @@ export function diffTables(
245245

246246
// REPLICA IDENTITY: If non-default, emit ALTER TABLE ... REPLICA IDENTITY
247247
if (branchTable.replica_identity !== "d") {
248-
// Skip 'i' (USING INDEX) — handled by index changes
249-
if (branchTable.replica_identity !== "i") {
250-
changes.push(
251-
new AlterTableSetReplicaIdentity({
252-
table: branchTable,
253-
mode: branchTable.replica_identity,
254-
}),
255-
);
256-
}
248+
changes.push(
249+
new AlterTableSetReplicaIdentity({
250+
table: branchTable,
251+
mode: branchTable.replica_identity,
252+
indexName: branchTable.replica_identity_index,
253+
}),
254+
);
257255
}
258256

259257
changes.push(
@@ -404,16 +402,23 @@ export function diffTables(
404402
}
405403

406404
// REPLICA IDENTITY
407-
if (mainTable.replica_identity !== branchTable.replica_identity) {
408-
// Skip when target is 'i' (USING INDEX) — handled by index changes
409-
if (branchTable.replica_identity !== "i") {
410-
changes.push(
411-
new AlterTableSetReplicaIdentity({
412-
table: mainTable,
413-
mode: branchTable.replica_identity,
414-
}),
415-
);
416-
}
405+
// Re-emit when the mode changes, or when staying in 'i' mode but pointing
406+
// at a different index. The index named on the branch must already exist
407+
// before this ALTER runs; AlterTableSetReplicaIdentity declares that
408+
// dependency in its `requires`.
409+
const replicaIdentityChanged =
410+
mainTable.replica_identity !== branchTable.replica_identity ||
411+
(branchTable.replica_identity === "i" &&
412+
mainTable.replica_identity_index !==
413+
branchTable.replica_identity_index);
414+
if (replicaIdentityChanged) {
415+
changes.push(
416+
new AlterTableSetReplicaIdentity({
417+
table: mainTable,
418+
mode: branchTable.replica_identity,
419+
indexName: branchTable.replica_identity_index,
420+
}),
421+
);
417422
}
418423

419424
// OWNER

0 commit comments

Comments
 (0)