Skip to content

Commit 98526c1

Browse files
authored
Merge pull request #47 from appritechnologies/development
fix(db): apply infra before clone so RLS policies/grants survive
2 parents 038027b + 5023f63 commit 98526c1

18 files changed

Lines changed: 452 additions & 53 deletions

File tree

cli/docs/db.md

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ A session-based database migration workflow for safe schema changes. Clone your
1313
│ │
1414
│ $ postkit db start $ postkit db plan │
1515
│ ┌──────────────────┐ ┌──────────────────┐ │
16-
│ │ 1. Clone remote │ │ 3. Generate │ │
17-
│ │ to local DB │ │ schema.sql │ │
18-
│ │ 2. Start session │ │ 4. Run pgschema │ │
19-
│ │ (track state) │ │ plan (diff) │ │
16+
│ │ 1. Apply infra │ │ 3. Generate │ │
17+
│ │ (roles/schemas)│ │ schema.sql │ │
18+
│ │ 1b. Clone remote │ │ 4. Run pgschema │ │
19+
│ │ structure only │ │ plan (diff) │ │
20+
│ │ 2. Start session │ │ │ │
21+
│ │ (track state) │ │ │ │
2022
│ └────────┬─────────┘ │ 5. Save schema │ │
2123
│ │ │ fingerprint │ │
2224
│ ▼ └────────┬─────────┘ │
@@ -64,6 +66,7 @@ A session-based database migration workflow for safe schema changes. Clone your
6466

6567
- **pgschema** — Bundled with PostKit. Platform-specific binaries are shipped in `vendor/pgschema/` and resolved automatically. No separate installation needed.
6668
- **dbmate** — Installed automatically as an npm dependency. No separate installation needed.
69+
- **psql** (native PostgreSQL client) — Required on the host for `postkit db start` (applying `db/infra/` and, when `localDbUrl` is configured directly, cloning). Checked upfront with a clear error if missing — install via `brew install libpq` (macOS), `apt install postgresql-client` (Linux), or the PostgreSQL installer + PATH entry (Windows).
6770
- **Docker** _(optional)_ — Required only if `db.localDbUrl` is empty. PostKit will automatically spin up a version-matched `postgres:{version}-alpine` container for the session and tear it down when done.
6871

6972
---
@@ -203,6 +206,8 @@ db/
203206

204207
**Note:** `db/infra/` and `seeds/` are excluded from pgschema processing and applied as separate steps. `grants/` is managed by pgschema. Use `postkit db schema add <name>` to scaffold a new schema directory.
205208

209+
> ⚠️ **`db/infra/` must only ever contain roles, schema namespaces (`CREATE SCHEMA`), and extensions — never tables, views, functions, or any pgschema-managed object.** `postkit db start` now applies `db/infra/` to the local database *before* cloning the remote database's structure (see below). If a table were defined in `db/infra/`, it would already exist locally by the time the clone runs its own `CREATE TABLE` for that same table, which fails the clone outright. Tables (and everything else) belong in `db/schema/<name>/tables/` instead, where pgschema manages them declaratively.
210+
206211
### PostKit Directory Structure
207212

208213
PostKit files in `.postkit/db/` are split between gitignored (ephemeral) and committed (shared with team):
@@ -237,16 +242,21 @@ postkit db start --remote staging # Use specific remote
237242
```
238243

239244
**What it does:**
240-
1. Checks prerequisites (pgschema, dbmate installed)
245+
1. Checks prerequisites (pgschema, dbmate, **and `psql`** installed)
241246
2. Resolves target remote (default or specified)
242247
3. Tests connection to remote database and detects its PostgreSQL major version
243248
4. Checks for pending committed migrations by querying the remote's `postkit.schema_migrations` table
244249
5. **If `localDbUrl` is empty**: Checks Docker availability and starts a `postgres:{version}-alpine` container on a free port (15432–15532), where `{version}` matches the remote database's PostgreSQL major version
245-
6. Clones remote database to local. When using an auto-container, `pg_dump` and `psql` run inside the container via `docker exec` (version-matched tools, no host binary required)
246-
7. Creates a session file (`.postkit/db/session.json`) to track state, including the container ID if a container was started
250+
6. **Applies `db/infra/` (roles, schemas, extensions) to the local database** — before anything is cloned
251+
7. Clones the remote database's **structure only** (`pg_dump --schema-only`, no row data) to local. When using an auto-container, `pg_dump` and `psql` run inside the container via `docker exec` (version-matched tools, no host binary required); otherwise both run on the host, which is why `psql` must be installed
252+
8. Creates a session file (`.postkit/db/session.json`) to track state, including the container ID if a container was started
247253

248254
**Auto-container:** When `localDbUrl` is not configured, PostKit manages the full container lifecycle — start on `db start`, stop on `db abort`. The container image always matches the remote PostgreSQL version.
249255

256+
**Why infra is applied before cloning:** the remote's dump includes `CREATE POLICY ... TO <role>`, `GRANT ... TO <role>`, and `ALTER DEFAULT PRIVILEGES ... TO <role>` statements for every custom role referenced by your RLS policies and grants. Those statements fail with `role "<role>" does not exist` if replayed before the role exists — which is exactly what happens on a brand-new local DB/container. Applying `db/infra/` first (same roles/schemas your project already declares) means those statements succeed, so RLS policies and grants clone correctly. The clone's `psql` runs with `-v ON_ERROR_STOP=1`, so if a referenced role genuinely doesn't exist anywhere in `db/infra/`, the clone now fails loudly with a clear error instead of silently dropping that policy/grant.
257+
258+
**Why the clone is schema-only:** copying full production row data (customer records, emails, tokens, etc.) into every disposable local dev container on every `db start` is unnecessary and a privacy/compliance risk. `db start` only needs to reproduce *structure* — tables, RLS policies, grants, indexes, triggers, functions — so it doesn't copy any rows. For synthetic local test data, use `db/schema/<name>/seeds/`, applied separately during `db plan`/`db apply`. (`postkit db deploy`'s own dry-run clone is unaffected by this — it still clones full data, since it's specifically verifying real migrations against realistic data before they touch production.)
259+
250260
---
251261

252262
### `postkit db plan`
@@ -323,12 +333,13 @@ postkit db deploy --dry-run # Verify only, don't touch target
323333
3. If an active session exists, removes it (with confirmation unless `-f`)
324334
4. Tests the target database connection and detects its PostgreSQL major version
325335
5. **If `localDbUrl` is empty**: Starts a temporary `postgres:{version}-alpine` container (version-matched to the target) for the dry-run
326-
6. Clones the target database to the local URL. When using a temp container, cloning runs via `docker exec` inside the container
327-
7. Runs a full dry-run on the local clone: infra, dbmate migrate, seeds
328-
8. If `--dry-run` is set, stops here and reports results without touching the target
329-
9. Reports dry-run results and confirms deployment (unless `-f`)
330-
10. Applies to target: infra, dbmate migrate, seeds
331-
11. Drops the local clone database; stops and removes the temp container if one was used
336+
6. **Applies `db/infra/` (roles, schemas, extensions) to the local/temp database** — before cloning, for the same reason `db start` does (RLS policies and grants in the target's dump reference roles that must already exist)
337+
7. Clones the target database (full data — this dry-run intentionally tests against realistic data) to the local URL. When using a temp container, cloning runs via `docker exec` inside the container
338+
8. Runs a full dry-run on the local clone: infra (reapplied, idempotently), dbmate migrate, seeds
339+
9. If `--dry-run` is set, stops here and reports results without touching the target
340+
10. Reports dry-run results and confirms deployment (unless `-f`)
341+
11. Applies to target: infra, dbmate migrate, seeds
342+
12. Drops the local clone database; stops and removes the temp container if one was used
332343

333344
If the dry run fails, deployment is aborted and no changes are made to the target database.
334345

@@ -629,6 +640,7 @@ postkit db deploy
629640
| Cross-schema views / functions | Manual migration (`postkit db migration`) | dbmate only |
630641
| Schema namespace creation (`CREATE SCHEMA`) | `db/infra/` | infra step (psql) |
631642
| Role creation / extensions | `db/infra/` | infra step (psql) |
643+
| ❌ Tables, views, functions, policies, grants | **Never** `db/infra/` — use `db/schema/<name>/` ||
632644

633645
---
634646

@@ -648,6 +660,8 @@ This is why cross-schema constraints must be written as manual migrations rather
648660
|-------|----------|
649661
| `pgschema is not installed` | Should be bundled in `vendor/pgschema/`. Verify the binary for your platform exists, or install manually and set `db.pgSchemaBin` in config. |
650662
| `dbmate is not installed` | Should be installed via npm. Run `npm install` in the CLI directory, or install manually (`brew install dbmate`) and set `db.dbmateBin` in config. |
663+
| `psql binary not found` | Required by `postkit db start`. Install PostgreSQL client tools: `brew install libpq` (macOS, then `brew link --force libpq`), `apt install postgresql-client` (Linux), or the PostgreSQL installer + add its `bin/` folder to `PATH` (Windows). Open a new terminal and verify with `psql --version`. |
664+
| `role "<name>" does not exist` during clone | A custom role referenced by an RLS policy or grant isn't in `db/infra/`. Add it there (see **What belongs where** above) — `db start`/`db deploy` apply `db/infra/` before cloning specifically to prevent this. |
651665
| `Failed to connect to remote database` | Check the remote URL in `postkit db remote list` |
652666
| `No remotes configured` | Add a remote with `postkit db remote add <name> <url>` |
653667
| `No active migration session` | Run `postkit db start` first |

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@appritech/postkit",
3-
"version": "1.3.0",
3+
"version": "1.3.1",
44
"description": "PostKit - Developer toolkit for database management and more",
55
"type": "module",
66
"main": "dist/index.js",

cli/src/common/shell.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {exec, spawn} from "child_process";
2+
import {Transform} from "stream";
23
import {promisify} from "util";
34
import type {ShellResult} from "./types";
45

@@ -106,14 +107,42 @@ export interface SpawnConfig {
106107
env?: Record<string, string>;
107108
}
108109

110+
/**
111+
* Buffers chunks and rewrites complete lines via `transformLine`, so a regex
112+
* match never straddles a chunk boundary. Partial trailing data is held until
113+
* the next chunk (or flushed as-is at stream end).
114+
*/
115+
function createLineTransform(transformLine: (line: string) => string): Transform {
116+
let buffer = "";
117+
return new Transform({
118+
transform(chunk, _encoding, callback) {
119+
buffer += chunk.toString();
120+
const lines = buffer.split("\n");
121+
buffer = lines.pop() ?? "";
122+
for (const line of lines) {
123+
this.push(transformLine(line) + "\n");
124+
}
125+
callback();
126+
},
127+
flush(callback) {
128+
if (buffer) this.push(transformLine(buffer));
129+
callback();
130+
},
131+
});
132+
}
133+
109134
/**
110135
* Spawns two processes and pipes stdout of the producer into stdin of the consumer.
111136
* Neither command is interpreted by a shell — args are passed directly to the OS.
112137
* Credentials should be supplied via the env field, never inline in args.
138+
*
139+
* `transformLine`, if given, rewrites each line of the producer's output before
140+
* it reaches the consumer's stdin (e.g. to patch pg_dump output before psql).
113141
*/
114142
export async function runPipedCommands(
115143
producer: SpawnConfig,
116144
consumer: SpawnConfig,
145+
transformLine?: (line: string) => string,
117146
): Promise<ShellResult> {
118147
const producerCmd = producer.args[0];
119148
const producerArgs = producer.args.slice(1);
@@ -137,7 +166,11 @@ export async function runPipedCommands(
137166
stdio: ["pipe", "pipe", "pipe"],
138167
});
139168

140-
src.stdout.pipe(dst.stdin);
169+
if (transformLine) {
170+
src.stdout.pipe(createLineTransform(transformLine)).pipe(dst.stdin);
171+
} else {
172+
src.stdout.pipe(dst.stdin);
173+
}
141174

142175
let stdout = "";
143176
let stderr = "";

cli/src/modules/db/commands/deploy.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ export async function deployCommand(options: DeployOptions): Promise<void> {
152152
logger.blank();
153153
}
154154

155-
// 3 fixed steps (test, status, clone) + 3 runSteps × 2 passes (dry-run + target) + 1 fixed step (cleanup)
156-
const totalSteps = 3 + 3 * 2 + 1; // = 10
155+
// 4 fixed steps (test, status, infra-apply, clone) + 3 runSteps × 2 passes (dry-run + target) + 1 fixed step (cleanup)
156+
const totalSteps = 4 + 3 * 2 + 1; // = 11
157157
const migrationNames = pendingMigrations.map(m => m.migrationFile.name);
158158

159159
// Step 1: Test target DB connection
@@ -216,7 +216,15 @@ export async function deployCommand(options: DeployOptions): Promise<void> {
216216
deregisterSignal = onContainerInterrupt(tempContainerID);
217217
}
218218

219-
logger.step(3, totalSteps, "Cloning target database to local...");
219+
// Step 3: Apply infra (roles, schemas, extensions) to the local/temp DB BEFORE
220+
// cloning. target's dump includes CREATE POLICY / GRANT / ALTER DEFAULT
221+
// PRIVILEGES statements naming custom roles (e.g. app_admin, employee) — those
222+
// fail with "role does not exist" if replayed before the roles exist, and since
223+
// the clone's psql now runs with ON_ERROR_STOP, that failure is no longer silent.
224+
logger.step(3, totalSteps, "Applying infrastructure to local database...");
225+
await applyInfraStep(spinner, localDbUrl, "local clone");
226+
227+
logger.step(4, totalSteps, "Cloning target database to local...");
220228
spinner.start("Cloning target database to local for dry-run verification...");
221229
if (tempContainerID) {
222230
await cloneDatabaseViaContainer(tempContainerID, targetUrl, localDbUrl);
@@ -226,12 +234,12 @@ export async function deployCommand(options: DeployOptions): Promise<void> {
226234
const localTableCount = await getTableCount(localDbUrl);
227235
spinner.succeed(`Target cloned to local (${localTableCount} tables)`);
228236

229-
// Steps 4-6: Dry run on local clone
237+
// Steps 5-7: Dry run on local clone
230238
logger.blank();
231239
logger.heading("Dry Run (local verification)");
232240

233241
try {
234-
await runSteps(localDbUrl, "local clone", spinner, 4, totalSteps, migrationNames);
242+
await runSteps(localDbUrl, "local clone", spinner, 5, totalSteps, migrationNames);
235243
} catch (error) {
236244
spinner.fail("Dry run failed on local clone");
237245
logger.error(error instanceof Error ? error.message : String(error));
@@ -269,12 +277,12 @@ export async function deployCommand(options: DeployOptions): Promise<void> {
269277
return;
270278
}
271279

272-
// Steps 7-9: Apply to target
280+
// Steps 8-10: Apply to target
273281
logger.blank();
274282
logger.heading("Deploying to Target");
275283

276284
try {
277-
await runSteps(targetUrl, targetLabel, spinner, 7, totalSteps, migrationNames);
285+
await runSteps(targetUrl, targetLabel, spinner, 8, totalSteps, migrationNames);
278286
} catch (error) {
279287
logger.error(error instanceof Error ? error.message : String(error));
280288
logger.blank();
@@ -288,7 +296,7 @@ export async function deployCommand(options: DeployOptions): Promise<void> {
288296

289297
// Step 10: Drop local clone and stop temp container
290298
logger.blank();
291-
logger.step(10, totalSteps, "Cleaning up local clone...");
299+
logger.step(11, totalSteps, "Cleaning up local clone...");
292300
spinner.start("Dropping local clone database...");
293301

294302
try {

cli/src/modules/db/commands/start.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import {runDbmateStatus} from "../services/dbmate";
1717
import {checkDbPrerequisites} from "../services/prerequisites";
1818
import {resolveLocalDb, cloneDatabaseViaContainer, stopSessionContainer, onContainerInterrupt} from "../services/container";
19+
import {applyInfraStep} from "../services/infra-generator";
1920
import {getPendingCommittedMigrations} from "../utils/committed";
2021
import type {CommandOptions} from "../../../common/types";
2122
import {PostkitError} from "../../../common/errors";
@@ -43,7 +44,7 @@ export async function startCommand(options: StartOptions): Promise<void> {
4344
// Step 1: Check prerequisites
4445
logger.step(1, 5, "Checking prerequisites...");
4546

46-
await checkDbPrerequisites(options.verbose ?? false);
47+
await checkDbPrerequisites(options.verbose ?? false, {requirePsql: true});
4748

4849
// Step 2: Load configuration
4950
logger.step(2, 5, "Loading configuration...");
@@ -54,8 +55,8 @@ export async function startCommand(options: StartOptions): Promise<void> {
5455
let localDbUrl = config.localDbUrl;
5556
const needsContainer = !localDbUrl;
5657

57-
// Total steps: 5 normally, 6 when auto-container is needed
58-
const totalSteps = needsContainer ? 6 : 5;
58+
// Total steps: 6 normally, 7 when auto-container is needed (extra step for infra apply)
59+
const totalSteps = needsContainer ? 7 : 6;
5960

6061
// Resolve remote
6162
let targetRemoteName: string;
@@ -197,24 +198,40 @@ export async function startCommand(options: StartOptions): Promise<void> {
197198
}
198199
}
199200

200-
// Step 5/6: Clone database
201-
const cloneStep = needsContainer ? 6 : 5;
202-
logger.step(cloneStep, totalSteps, "Cloning remote database to local...");
203-
spinner.start("Cloning database (this may take a moment)...");
201+
// Apply infra (roles, schemas, extensions) to the local DB BEFORE cloning.
202+
// pg_dump's output includes CREATE POLICY / GRANT / ALTER DEFAULT PRIVILEGES
203+
// statements that name custom roles (e.g. app_admin, employee, service_role).
204+
// Those statements fail with "role does not exist" if replayed against a
205+
// fresh local DB/container that doesn't have the roles yet — and since psql
206+
// isn't run with ON_ERROR_STOP during the clone, those failures are silent,
207+
// silently dropping RLS policies and grants from the local clone.
208+
const infraStep = needsContainer ? 6 : 5;
209+
logger.step(infraStep, totalSteps, "Applying infrastructure to local database...");
210+
if (options.dryRun) {
211+
spinner.info("Dry run - skipping infra apply");
212+
} else {
213+
await applyInfraStep(spinner, localDbUrl);
214+
}
215+
216+
// Step: Clone database (structure only — no row data. RLS policies and
217+
// grants still clone, since infra was applied above before this runs.)
218+
const cloneStep = needsContainer ? 7 : 6;
219+
logger.step(cloneStep, totalSteps, "Cloning remote database structure to local...");
220+
spinner.start("Cloning database structure (this may take a moment)...");
204221

205222
if (options.dryRun) {
206223
spinner.info("Dry run - skipping database clone");
207224
} else {
208225
if (containerID) {
209226
// Run pg_dump/psql inside the container — version-matched with remote
210-
await cloneDatabaseViaContainer(containerID, targetRemoteUrl, localDbUrl);
227+
await cloneDatabaseViaContainer(containerID, targetRemoteUrl, localDbUrl, true);
211228
} else {
212-
await cloneDatabase(targetRemoteUrl, localDbUrl);
229+
await cloneDatabase(targetRemoteUrl, localDbUrl, true);
213230
}
214-
spinner.succeed("Database cloned successfully");
231+
spinner.succeed("Database structure cloned successfully");
215232

216233
const localTableCount = await getTableCount(localDbUrl);
217-
logger.info(`Local clone has ${localTableCount} tables`);
234+
logger.info(`Local database has ${localTableCount} tables (structure only, no row data)`);
218235
}
219236

220237
// Final step: Create session

0 commit comments

Comments
 (0)