@rebasepro/cli 0.23.0 → 0.24.1-canary.g19596ef
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/bin/rebase.js +4 -1
- package/dist/agent-setup-B31F00DC.js +324 -0
- package/dist/agent-setup-B31F00DC.js.map +1 -0
- package/dist/api-keys-Bl1I9q7M.js +525 -0
- package/dist/api-keys-Bl1I9q7M.js.map +1 -0
- package/dist/apps-CNgQJjg5.js +340 -0
- package/dist/apps-CNgQJjg5.js.map +1 -0
- package/dist/args-BnmsEXvu.js +109 -0
- package/dist/args-BnmsEXvu.js.map +1 -0
- package/dist/atlas-dev-database-D_LP_Woq.js +62 -0
- package/dist/atlas-dev-database-D_LP_Woq.js.map +1 -0
- package/dist/auth-aySHEF45.js +414 -0
- package/dist/auth-aySHEF45.js.map +1 -0
- package/dist/branch-pointer-DvSHBjjz.js +2 -0
- package/dist/build-DIHWIqO4.js +336 -0
- package/dist/build-DIHWIqO4.js.map +1 -0
- package/dist/bundle-CqURVN-K.js +1621 -0
- package/dist/bundle-CqURVN-K.js.map +1 -0
- package/dist/bundle.d.ts +12 -3
- package/dist/cli.d.ts +23 -2
- package/dist/cloud-DjByQlkW.js +9667 -0
- package/dist/cloud-DjByQlkW.js.map +1 -0
- package/dist/collection-drift-BKj_BoMq.js +106 -0
- package/dist/collection-drift-BKj_BoMq.js.map +1 -0
- package/dist/commands/__test_support__/parser-specs.d.ts +13 -0
- package/dist/commands/agent-setup.d.ts +116 -0
- package/dist/commands/api-keys.d.ts +86 -23
- package/dist/commands/apps.d.ts +17 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/build.d.ts +5 -1
- package/dist/commands/cloud/auth.d.ts +4 -3
- package/dist/commands/cloud/bundle-deploy.d.ts +45 -0
- package/dist/commands/cloud/context.d.ts +44 -5
- package/dist/commands/cloud/cron.d.ts +53 -0
- package/dist/commands/cloud/debug.d.ts +134 -0
- package/dist/commands/cloud/index.d.ts +89 -12
- package/dist/commands/cloud/token-capabilities.d.ts +76 -0
- package/dist/commands/cloud/tokens.d.ts +23 -0
- package/dist/commands/console.d.ts +12 -0
- package/dist/commands/db.d.ts +79 -25
- package/dist/commands/dev.d.ts +181 -13
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/generate_sdk.d.ts +29 -2
- package/dist/commands/init.d.ts +115 -14
- package/dist/commands/schema.d.ts +6 -0
- package/dist/commands/skills.d.ts +126 -7
- package/dist/commands/start.d.ts +44 -0
- package/dist/commands/storage.d.ts +46 -0
- package/dist/commands/upgrade.d.ts +1 -0
- package/dist/consent-ClRHmcxf.js +76 -0
- package/dist/consent-ClRHmcxf.js.map +1 -0
- package/dist/console-D6SJf8k_.js +102 -0
- package/dist/console-D6SJf8k_.js.map +1 -0
- package/dist/context-BqhRydBg.js +1234 -0
- package/dist/context-BqhRydBg.js.map +1 -0
- package/dist/{daemon-entry-LTFKtpHy.js → daemon-entry-Da-D3rmH.js} +3 -4
- package/dist/daemon-entry-Da-D3rmH.js.map +1 -0
- package/dist/daemon-pzsQwDHa.js +2 -0
- package/dist/db-B7RuNuaQ.js +1312 -0
- package/dist/db-B7RuNuaQ.js.map +1 -0
- package/dist/derive-Dl0z2OWi.js +484 -0
- package/dist/derive-Dl0z2OWi.js.map +1 -0
- package/dist/dev-CYr_WXan.js +1599 -0
- package/dist/dev-CYr_WXan.js.map +1 -0
- package/dist/dev-db/atlas-dev-database.d.ts +32 -0
- package/dist/dev-db/daemon-entry.d.ts +13 -0
- package/dist/dev-db/daemon.d.ts +10 -0
- package/dist/dev-db/last-dev-database.d.ts +41 -0
- package/dist/dev-db/prepare.d.ts +29 -3
- package/dist/dev-db/pull.d.ts +90 -5
- package/dist/dev-db/resolve.d.ts +26 -2
- package/dist/dev-preflight-B-LYyBf1.js +2 -0
- package/dist/dev-server-DG8Tt-uf.js +96 -0
- package/dist/dev-server-DG8Tt-uf.js.map +1 -0
- package/dist/doctor-BP8cZ-7F.js +771 -0
- package/dist/doctor-BP8cZ-7F.js.map +1 -0
- package/dist/eject-DizdoecV.js +335 -0
- package/dist/eject-DizdoecV.js.map +1 -0
- package/dist/fold-static-BC9CYicw.js +201 -0
- package/dist/fold-static-BC9CYicw.js.map +1 -0
- package/dist/fold-static.d.ts +26 -3
- package/dist/function-portability-BIvlvR9q.js +265 -0
- package/dist/function-portability-BIvlvR9q.js.map +1 -0
- package/dist/generate_sdk-BPqdJc3Q.js +489 -0
- package/dist/generate_sdk-BPqdJc3Q.js.map +1 -0
- package/dist/index.es.js +199 -22095
- package/dist/index.es.js.map +1 -1
- package/dist/init-Utstvh2Q.js +1149 -0
- package/dist/init-Utstvh2Q.js.map +1 -0
- package/dist/last-dev-database-B58g89uA.js +2 -0
- package/dist/manifest-DRp0UVTj.js +880 -0
- package/dist/manifest-DRp0UVTj.js.map +1 -0
- package/dist/manifest.d.ts +56 -4
- package/dist/normalize-imports-X0Tgze9y.js +107 -0
- package/dist/normalize-imports-X0Tgze9y.js.map +1 -0
- package/dist/package-manager-DSEOwTqM.js +193 -0
- package/dist/package-manager-DSEOwTqM.js.map +1 -0
- package/dist/path-flags-BFgG9V6y.js +91 -0
- package/dist/path-flags-BFgG9V6y.js.map +1 -0
- package/dist/precompress.d.ts +17 -0
- package/dist/prepare-CDN6lSpz.js +1236 -0
- package/dist/prepare-CDN6lSpz.js.map +1 -0
- package/dist/{project-3WCbrrfW.js → project-Wp2zoyeN.js} +34 -184
- package/dist/project-Wp2zoyeN.js.map +1 -0
- package/dist/pull-CrGYMEsy.js +458 -0
- package/dist/pull-CrGYMEsy.js.map +1 -0
- package/dist/resolve-B2O-wKKk.js +2 -0
- package/dist/resources-FD7AfZx8.js +183 -0
- package/dist/resources-FD7AfZx8.js.map +1 -0
- package/dist/schema-DMdr431S.js +125 -0
- package/dist/schema-DMdr431S.js.map +1 -0
- package/dist/skills-DTFibT1m.js +642 -0
- package/dist/skills-DTFibT1m.js.map +1 -0
- package/dist/start-DEa-hOBh.js +267 -0
- package/dist/start-DEa-hOBh.js.map +1 -0
- package/dist/{state-C59Elrnt.js → state-Do7ZJYkF.js} +49 -4
- package/dist/state-Do7ZJYkF.js.map +1 -0
- package/dist/status-DxnJTekv.js +352 -0
- package/dist/status-DxnJTekv.js.map +1 -0
- package/dist/storage-DyVq-f_F.js +303 -0
- package/dist/storage-DyVq-f_F.js.map +1 -0
- package/dist/telemetry/identity.d.ts +10 -1
- package/dist/telemetry-C8Uq8phb.js +129 -0
- package/dist/telemetry-C8Uq8phb.js.map +1 -0
- package/dist/telemetry-EMFRVRQi.js +351 -0
- package/dist/telemetry-EMFRVRQi.js.map +1 -0
- package/dist/unknown-command-DNwDcX3w.js +98 -0
- package/dist/unknown-command-DNwDcX3w.js.map +1 -0
- package/dist/upgrade-CtZtRkTE.js +1332 -0
- package/dist/upgrade-CtZtRkTE.js.map +1 -0
- package/dist/upgrade.d.ts +38 -5
- package/dist/utils/dev-preflight.d.ts +47 -5
- package/dist/utils/dev-server.d.ts +30 -0
- package/dist/utils/path-flags.d.ts +31 -0
- package/dist/utils/project.d.ts +16 -0
- package/dist/utils/temp-archive.d.ts +9 -0
- package/dist/version-Buyyo1ut.js +58 -0
- package/dist/version-Buyyo1ut.js.map +1 -0
- package/package.json +7 -7
- package/templates/eject/Dockerfile +4 -2
- package/templates/eject/backend/src/env.ts +1 -1
- package/templates/eject/backend/src/index.ts +42 -4
- package/templates/eject/docker-compose.custom.yml +8 -4
- package/templates/generated-schemas/blank.ts +40 -0
- package/templates/generated-schemas/blog.ts +127 -0
- package/templates/generated-schemas/ecommerce.ts +93 -0
- package/templates/overlays/baas/ai-instructions.md +1 -1
- package/templates/overlays/baas/config/package.json +1 -0
- package/templates/overlays/baas/config/storage.ts +5 -3
- package/templates/overlays/baas/package.json +1 -0
- package/templates/template/.cursorrules +1 -1
- package/templates/template/.env.example +20 -10
- package/templates/template/.github/copilot-instructions.md +1 -1
- package/templates/template/.mcp.json +2 -2
- package/templates/template/.windsurfrules +1 -1
- package/templates/template/AGENTS.md +1 -1
- package/templates/template/CLAUDE.md +1 -1
- package/templates/template/README.md +21 -15
- package/templates/template/ai-instructions.md +5 -4
- package/templates/template/backend/functions/hello.ts +8 -0
- package/templates/template/backend/package.json +1 -1
- package/templates/template/config/resources.ts +7 -6
- package/templates/template/config/storage.ts +5 -3
- package/templates/template/docker-compose.yml +15 -9
- package/templates/template/frontend/vite.config.ts +13 -3
- package/templates/template/gitignore +6 -1
- package/templates/template/package.json +5 -3
- package/templates/template/scripts/example.ts +6 -4
- package/templates/template/tsconfig.json +25 -0
- package/dist/branch-pointer-dBiClJ0p.js +0 -111
- package/dist/branch-pointer-dBiClJ0p.js.map +0 -1
- package/dist/constraints-DKGbNfUW.js +0 -48
- package/dist/constraints-DKGbNfUW.js.map +0 -1
- package/dist/daemon-DR9Tk0La.js +0 -355
- package/dist/daemon-DR9Tk0La.js.map +0 -1
- package/dist/daemon-entry-LTFKtpHy.js.map +0 -1
- package/dist/dev-preflight-CNLl4rdo.js +0 -367
- package/dist/dev-preflight-CNLl4rdo.js.map +0 -1
- package/dist/project-3WCbrrfW.js.map +0 -1
- package/dist/pull-Dywm5FWP.js +0 -232
- package/dist/pull-Dywm5FWP.js.map +0 -1
- package/dist/resolve-BAeRUBhg.js +0 -74
- package/dist/resolve-BAeRUBhg.js.map +0 -1
- package/dist/state-C59Elrnt.js.map +0 -1
- package/templates/template/backend/src/schema.generated.ts +0 -13
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
//#region src/dev-db/pull.ts
|
|
3
|
+
/**
|
|
4
|
+
* `rebase db pull` — copy a database's contents into local development.
|
|
5
|
+
*
|
|
6
|
+
* The common case is production into local, and the reason it exists is that
|
|
7
|
+
* the alternative is worse: without it people hand-roll a `pg_dump | psql` and
|
|
8
|
+
* get the flags wrong in ways that either fail loudly at 2am or, more often,
|
|
9
|
+
* quietly restore half a schema.
|
|
10
|
+
*
|
|
11
|
+
* Three things this command insists on, because copying a production database
|
|
12
|
+
* onto a laptop is a data-protection event whether or not anyone calls it one:
|
|
13
|
+
*
|
|
14
|
+
* 1. **It says what it is about to do, in full, before doing it** — which
|
|
15
|
+
* database it will read, which one it will overwrite, and where the data will
|
|
16
|
+
* come to rest on disk. The target path matters: people forget that
|
|
17
|
+
* `.rebase/pgdata` is a directory their backup software may be indexing.
|
|
18
|
+
*
|
|
19
|
+
* 2. **It refuses to run unattended without being told to.** The target is
|
|
20
|
+
* destroyed, so a mistyped `--from` with no confirmation would take the
|
|
21
|
+
* developer's working database with it.
|
|
22
|
+
*
|
|
23
|
+
* 3. **It will not write to a remote database.** The target is always the local
|
|
24
|
+
* development database; there is no flag that makes this push. A tool that
|
|
25
|
+
* can copy in both directions eventually copies in the wrong one.
|
|
26
|
+
*
|
|
27
|
+
* Anonymization is opt-in (`--anonymize`), which is a deliberate choice and not
|
|
28
|
+
* an obviously safe one — the flag nobody types is the flag nobody gets. It is
|
|
29
|
+
* a best-effort pass over columns whose *names* look like personal data, and
|
|
30
|
+
* {@link ANONYMIZE_PATTERNS} says exactly which. It cannot find personal data in
|
|
31
|
+
* a column called `notes`, and this file says so rather than implying a
|
|
32
|
+
* guarantee it cannot keep.
|
|
33
|
+
*
|
|
34
|
+
* What it does guarantee is that it never half-happens: {@link anonymizeCopy}
|
|
35
|
+
* runs the pass in one transaction, and when the pass fails it empties the
|
|
36
|
+
* tables it would have rewritten rather than leave their real values behind.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Column-name patterns the anonymizer overwrites.
|
|
40
|
+
*
|
|
41
|
+
* Names, not contents: inspecting values would be slower, and would still miss
|
|
42
|
+
* the same things. This is a reasonable-effort measure for making a local copy
|
|
43
|
+
* less dangerous, and it is not a compliance control.
|
|
44
|
+
*/
|
|
45
|
+
var ANONYMIZE_PATTERNS = [
|
|
46
|
+
{
|
|
47
|
+
pattern: /^(.*_)?e?mail(_.*)?$/i,
|
|
48
|
+
value: null,
|
|
49
|
+
prefix: "user-",
|
|
50
|
+
suffix: "@example.invalid"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
pattern: /^(.*_)?(phone|mobile|tel|telephone)(_.*)?$/i,
|
|
54
|
+
value: "+10000000000",
|
|
55
|
+
prefix: "+1-",
|
|
56
|
+
suffix: ""
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
pattern: /^(.*_)?(first_name|last_name|full_name|surname|given_name)(_.*)?$/i,
|
|
60
|
+
value: "Redacted",
|
|
61
|
+
prefix: "Redacted-",
|
|
62
|
+
suffix: ""
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
pattern: /^(.*_)?(address|street|postcode|zip|zipcode)(_.*)?$/i,
|
|
66
|
+
value: "Redacted",
|
|
67
|
+
prefix: "Redacted-",
|
|
68
|
+
suffix: ""
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
pattern: /^(.*_)?(ssn|tax_id|national_id|passport)(_.*)?$/i,
|
|
72
|
+
value: "REDACTED",
|
|
73
|
+
prefix: "REDACTED-",
|
|
74
|
+
suffix: ""
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
pattern: /^(.*_)?(password|password_hash|secret|token|api_key|access_token|refresh_token)(_.*)?$/i,
|
|
78
|
+
value: "REDACTED",
|
|
79
|
+
prefix: "REDACTED-",
|
|
80
|
+
suffix: ""
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
pattern: /^(.*_)?(ip|ip_address|user_agent)(_.*)?$/i,
|
|
84
|
+
value: "REDACTED",
|
|
85
|
+
prefix: "REDACTED-",
|
|
86
|
+
suffix: ""
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
function shouldAnonymize(columnName) {
|
|
90
|
+
return ANONYMIZE_PATTERNS.some((rule) => rule.pattern.test(columnName));
|
|
91
|
+
}
|
|
92
|
+
/** Random hex of this length, per row. `gen_random_uuid()` is core since PostgreSQL 13. */
|
|
93
|
+
function randomHex(length) {
|
|
94
|
+
return `left(replace(gen_random_uuid()::text, '-', ''), ${length})`;
|
|
95
|
+
}
|
|
96
|
+
function sqlLiteral(value) {
|
|
97
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fewest random hex digits a unique replacement keeps. Twelve is 16^12 values:
|
|
101
|
+
* a collision needs millions of rows, where eight would need tens of thousands.
|
|
102
|
+
*/
|
|
103
|
+
var MIN_UNIQUE_HEX = 12;
|
|
104
|
+
/**
|
|
105
|
+
* The SQL expression that overwrites one column, or null when no rule names it.
|
|
106
|
+
*
|
|
107
|
+
* Unique per row when an index makes the column unique, and always cut to the
|
|
108
|
+
* column's declared length — `zip char(5)` refused `'Redacted'` outright, and a
|
|
109
|
+
* failed statement now empties the tables rather than skip one. To fit a short
|
|
110
|
+
* unique column the prefix goes first, then the suffix, and the random part is
|
|
111
|
+
* kept to at least {@link MIN_UNIQUE_HEX} digits while there is room for them.
|
|
112
|
+
* A NULL stays NULL: an empty optional field is not personal data.
|
|
113
|
+
*/
|
|
114
|
+
function replacementFor(column) {
|
|
115
|
+
const rule = ANONYMIZE_PATTERNS.find((candidate) => candidate.pattern.test(column.column));
|
|
116
|
+
if (!rule) return null;
|
|
117
|
+
const limit = typeof column.maxLength === "number" && column.maxLength > 0 ? column.maxLength : null;
|
|
118
|
+
let value;
|
|
119
|
+
if (rule.value !== null && !column.unique) value = sqlLiteral(limit === null ? rule.value : rule.value.slice(0, limit));
|
|
120
|
+
else {
|
|
121
|
+
const [prefix, suffix] = [
|
|
122
|
+
[rule.prefix, rule.suffix],
|
|
123
|
+
["", rule.suffix],
|
|
124
|
+
["", ""]
|
|
125
|
+
].find(([prefix, suffix]) => limit === null || limit - prefix.length - suffix.length >= MIN_UNIQUE_HEX) ?? ["", ""];
|
|
126
|
+
const hex = Math.min(32, limit === null ? 32 : limit - prefix.length - suffix.length);
|
|
127
|
+
value = `concat(${sqlLiteral(prefix)}, ${randomHex(Math.max(1, hex))}, ${sqlLiteral(suffix)})`;
|
|
128
|
+
}
|
|
129
|
+
return `CASE WHEN ${quoteIdentifier(column.column)} IS NULL THEN NULL ELSE ${value} END`;
|
|
130
|
+
}
|
|
131
|
+
/** A double-quoted identifier, with any `"` in it doubled — a table called `order` or `a"b` still works. */
|
|
132
|
+
function quoteIdentifier(name) {
|
|
133
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Anonymizable columns: name looks personal, and the type can hold the
|
|
137
|
+
* replacement.
|
|
138
|
+
*
|
|
139
|
+
* The type check is what stops this generating `UPDATE … SET user_id =
|
|
140
|
+
* 'Redacted'` for an integer column called `user_id_email_seq` and failing the
|
|
141
|
+
* whole pass on a technicality.
|
|
142
|
+
*/
|
|
143
|
+
function anonymizableColumns(columns) {
|
|
144
|
+
const textual = /* @__PURE__ */ new Set([
|
|
145
|
+
"text",
|
|
146
|
+
"character varying",
|
|
147
|
+
"varchar",
|
|
148
|
+
"character",
|
|
149
|
+
"char",
|
|
150
|
+
"citext"
|
|
151
|
+
]);
|
|
152
|
+
return columns.filter((column) => shouldAnonymize(column.column) && textual.has(column.dataType.toLowerCase()));
|
|
153
|
+
}
|
|
154
|
+
/** `"schema"."table"` for each table the pass rewrites, sorted. */
|
|
155
|
+
function anonymizedTables(columns) {
|
|
156
|
+
return [...new Set(anonymizableColumns(columns).map((column) => `${quoteIdentifier(column.schema)}.${quoteIdentifier(column.table)}`))].sort();
|
|
157
|
+
}
|
|
158
|
+
/** `UPDATE` statements for one anonymization pass, in a stable order. */
|
|
159
|
+
function anonymizeStatements(columns) {
|
|
160
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
161
|
+
for (const column of anonymizableColumns(columns)) {
|
|
162
|
+
const key = `${quoteIdentifier(column.schema)}.${quoteIdentifier(column.table)}`;
|
|
163
|
+
byTable.set(key, [...byTable.get(key) ?? [], column]);
|
|
164
|
+
}
|
|
165
|
+
return [...byTable.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([table, cols]) => {
|
|
166
|
+
return `UPDATE ${table} SET ${[...cols].sort((a, b) => a.column.localeCompare(b.column)).map((column) => `${quoteIdentifier(column.column)} = ${replacementFor(column)}`).join(", ")};`;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every column the pass could overwrite, with what decides how.
|
|
171
|
+
*
|
|
172
|
+
* Base tables only — a view has an `email` column too, and is not something to
|
|
173
|
+
* UPDATE. Generated columns are skipped for the same reason: `email_domain
|
|
174
|
+
* GENERATED ALWAYS AS (…)` matches by name and refuses any value but DEFAULT,
|
|
175
|
+
* and it follows the column it is generated from anyway. An extension type such
|
|
176
|
+
* as `citext` reports `USER-DEFINED`, so its own name is what is compared.
|
|
177
|
+
*
|
|
178
|
+
* `unique` comes from the indexes, through `pg_depend` as well as `indkey`: a
|
|
179
|
+
* unique index on `lower(email)` names no column in `indkey`, and is exactly
|
|
180
|
+
* what a constant replacement would violate.
|
|
181
|
+
*/
|
|
182
|
+
var ANONYMIZE_COLUMNS_SQL = `
|
|
183
|
+
WITH unique_columns AS (
|
|
184
|
+
SELECT DISTINCT i.indrelid AS relid, a.attname AS column_name
|
|
185
|
+
FROM pg_index i
|
|
186
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum > 0 AND NOT a.attisdropped
|
|
187
|
+
WHERE i.indisunique
|
|
188
|
+
AND (a.attnum = ANY (i.indkey)
|
|
189
|
+
OR EXISTS (SELECT 1 FROM pg_depend d
|
|
190
|
+
WHERE d.classid = 'pg_class'::regclass AND d.objid = i.indexrelid
|
|
191
|
+
AND d.refclassid = 'pg_class'::regclass AND d.refobjid = i.indrelid
|
|
192
|
+
AND d.refobjsubid = a.attnum))
|
|
193
|
+
)
|
|
194
|
+
SELECT c.table_schema AS "schema",
|
|
195
|
+
c.table_name AS "table",
|
|
196
|
+
c.column_name AS "column",
|
|
197
|
+
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS "dataType",
|
|
198
|
+
c.character_maximum_length AS "maxLength",
|
|
199
|
+
(u.column_name IS NOT NULL) AS "unique"
|
|
200
|
+
FROM information_schema.columns c
|
|
201
|
+
JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
202
|
+
LEFT JOIN unique_columns u
|
|
203
|
+
ON u.relid = format('%I.%I', c.table_schema, c.table_name)::regclass AND u.column_name = c.column_name
|
|
204
|
+
WHERE t.table_type = 'BASE TABLE'
|
|
205
|
+
AND c.is_generated = 'NEVER'
|
|
206
|
+
AND c.table_schema NOT IN ('pg_catalog', 'information_schema')`;
|
|
207
|
+
function errorText(error) {
|
|
208
|
+
return error instanceof Error && error.message ? error.message : String(error);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Overwrite the personal-looking columns of a restored copy, all or nothing.
|
|
212
|
+
*
|
|
213
|
+
* One transaction, so a failure part-way leaves no table half done — the old
|
|
214
|
+
* pass ran its UPDATEs one by one, so a failure on `rebase.refresh_tokens` left
|
|
215
|
+
* `public.authors` anonymized and `rebase.users` holding every real email.
|
|
216
|
+
*
|
|
217
|
+
* A rollback alone would be worse than that, though: it puts back the real
|
|
218
|
+
* value of every column, in a copy the developer asked to be anonymized. So on
|
|
219
|
+
* failure the tables the pass would have rewritten are emptied (`TRUNCATE …
|
|
220
|
+
* CASCADE`, which takes the rows that reference them too), and the outcome
|
|
221
|
+
* says which — or, if even that failed, says the copy holds real data, so the
|
|
222
|
+
* caller can say it louder.
|
|
223
|
+
*
|
|
224
|
+
* `row_security = off` makes a role that RLS would filter fail the pass instead
|
|
225
|
+
* of rewriting only the rows its policies show it, which looked like success.
|
|
226
|
+
*/
|
|
227
|
+
async function anonymizeCopy(client) {
|
|
228
|
+
let listed = false;
|
|
229
|
+
let tables = [];
|
|
230
|
+
try {
|
|
231
|
+
const { rows } = await client.query(ANONYMIZE_COLUMNS_SQL);
|
|
232
|
+
tables = anonymizedTables(rows);
|
|
233
|
+
listed = true;
|
|
234
|
+
await client.query("BEGIN");
|
|
235
|
+
await client.query("SET LOCAL row_security = off");
|
|
236
|
+
for (const statement of anonymizeStatements(rows)) await client.query(statement);
|
|
237
|
+
await client.query("COMMIT");
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
tables
|
|
241
|
+
};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const reason = errorText(error);
|
|
244
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
245
|
+
if (!listed) return {
|
|
246
|
+
ok: false,
|
|
247
|
+
error: reason,
|
|
248
|
+
emptied: [],
|
|
249
|
+
emptyError: "the tables to empty could not be listed"
|
|
250
|
+
};
|
|
251
|
+
if (tables.length === 0) return {
|
|
252
|
+
ok: false,
|
|
253
|
+
error: reason,
|
|
254
|
+
emptied: [],
|
|
255
|
+
emptyError: null
|
|
256
|
+
};
|
|
257
|
+
try {
|
|
258
|
+
await client.query(`TRUNCATE TABLE ${tables.join(", ")} CASCADE`);
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
error: reason,
|
|
262
|
+
emptied: tables,
|
|
263
|
+
emptyError: null
|
|
264
|
+
};
|
|
265
|
+
} catch (emptyError) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
error: reason,
|
|
269
|
+
emptied: [],
|
|
270
|
+
emptyError: errorText(emptyError)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Schemas the restored copy needs the app role provisioned on.
|
|
277
|
+
*
|
|
278
|
+
* `pg_dump --no-privileges` strips every GRANT, so a pulled database arrives
|
|
279
|
+
* with its RLS policies and its `FORCE ROW LEVEL SECURITY` intact and no
|
|
280
|
+
* privileges behind them. Measured on a 30-table project: 68 policies restored,
|
|
281
|
+
* 14 tables with RLS on, and **0** grants to `rebase_user` — where the source
|
|
282
|
+
* had 60. Reading one table as the role Rebase serves every request through:
|
|
283
|
+
*
|
|
284
|
+
* source: 6
|
|
285
|
+
* copy: ERROR: permission denied for table leads
|
|
286
|
+
*
|
|
287
|
+
* The dump flags are right and stay: without `--no-owner`/`--no-privileges`
|
|
288
|
+
* every `ALTER … OWNER TO` and `GRANT … TO <prod role>` in the dump fails
|
|
289
|
+
* against roles that do not exist on a laptop, and buries the real output. The
|
|
290
|
+
* repair belongs after the restore, and belongs to `ensureAppRole` — the same
|
|
291
|
+
* routine boot and `rebase db push` call — rather than to a second list of
|
|
292
|
+
* grants written here.
|
|
293
|
+
*
|
|
294
|
+
* Which schemas: every non-system schema the restored database actually has.
|
|
295
|
+
* Boot knows its `managedSchemas` from the collections; a restore knows only
|
|
296
|
+
* what arrived, and a source may carry schemas this project does not declare.
|
|
297
|
+
* `pg_catalog`, `information_schema` and the `pg_*` internals are never ours to
|
|
298
|
+
* grant on and PostgreSQL would refuse anyway.
|
|
299
|
+
*/
|
|
300
|
+
function provisionableSchemas(rows) {
|
|
301
|
+
const skip = (name) => name === "information_schema" || name.startsWith("pg_");
|
|
302
|
+
return [...new Set(rows.map((row) => row.schema))].filter((name) => !skip(name)).sort();
|
|
303
|
+
}
|
|
304
|
+
/** Host and database of a connection string, with no credentials in it. */
|
|
305
|
+
function describeTarget(connectionString) {
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(connectionString);
|
|
308
|
+
const database = url.pathname.replace(/^\//, "") || "(default)";
|
|
309
|
+
return `${url.hostname}${url.port ? `:${url.port}` : ""}/${database}`;
|
|
310
|
+
} catch {
|
|
311
|
+
return "(unparseable connection string)";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* `pg_dump` arguments for the source.
|
|
316
|
+
*
|
|
317
|
+
* `--no-owner` and `--no-acl` because the roles on a production server do not
|
|
318
|
+
* exist locally, and without them every `ALTER … OWNER TO` in the dump fails and
|
|
319
|
+
* buries the real output in noise. `--format=custom` so `pg_restore` can be told
|
|
320
|
+
* to continue past errors selectively rather than all-or-nothing.
|
|
321
|
+
*/
|
|
322
|
+
function dumpArgs(plan) {
|
|
323
|
+
const args = [
|
|
324
|
+
"--format=custom",
|
|
325
|
+
"--no-owner",
|
|
326
|
+
"--no-acl",
|
|
327
|
+
"--no-privileges"
|
|
328
|
+
];
|
|
329
|
+
for (const schema of plan.schemas) args.push("--schema", schema);
|
|
330
|
+
args.push("--dbname", splitPassword(plan.source).connectionString);
|
|
331
|
+
return args;
|
|
332
|
+
}
|
|
333
|
+
/** The environment `pg_dump` runs with: the source's password, if it has one. */
|
|
334
|
+
function dumpEnv(plan) {
|
|
335
|
+
return passwordEnv(plan.source);
|
|
336
|
+
}
|
|
337
|
+
/** The environment `pg_restore` runs with: the target's password, if it has one. */
|
|
338
|
+
function restoreEnv(plan) {
|
|
339
|
+
return passwordEnv(plan.target);
|
|
340
|
+
}
|
|
341
|
+
function passwordEnv(connectionString) {
|
|
342
|
+
const { password } = splitPassword(connectionString);
|
|
343
|
+
return password === null ? {} : { PGPASSWORD: password };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* A libpq connection string without its password, and the password.
|
|
347
|
+
*
|
|
348
|
+
* The password goes in `PGPASSWORD`, never on a command line, where every
|
|
349
|
+
* process listing can read it and every failure prints it back (execa's error
|
|
350
|
+
* repeats the command). The same rule `db backup` follows; this mirrors
|
|
351
|
+
* `splitPassword` in `@rebasepro/server-postgres`'s connection-string utils,
|
|
352
|
+
* which is not exported. Covers the user-info password and a `password=`
|
|
353
|
+
* parameter; anything that does not parse as a postgres URL is left alone.
|
|
354
|
+
*/
|
|
355
|
+
function splitPassword(connectionString) {
|
|
356
|
+
let url;
|
|
357
|
+
try {
|
|
358
|
+
url = new URL(connectionString);
|
|
359
|
+
} catch {
|
|
360
|
+
return {
|
|
361
|
+
connectionString,
|
|
362
|
+
password: null
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return {
|
|
366
|
+
connectionString,
|
|
367
|
+
password: null
|
|
368
|
+
};
|
|
369
|
+
const fromUserInfo = url.password ? decodePercent(url.password) : null;
|
|
370
|
+
const fromParameter = url.searchParams.get("password");
|
|
371
|
+
if (fromUserInfo === null && fromParameter === null) return {
|
|
372
|
+
connectionString,
|
|
373
|
+
password: null
|
|
374
|
+
};
|
|
375
|
+
url.password = "";
|
|
376
|
+
if (fromParameter !== null) {
|
|
377
|
+
url.searchParams.delete("password");
|
|
378
|
+
url.search = Array.from(url.searchParams.entries()).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
connectionString: url.toString(),
|
|
382
|
+
password: fromUserInfo ?? fromParameter
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function decodePercent(value) {
|
|
386
|
+
try {
|
|
387
|
+
return decodeURIComponent(value);
|
|
388
|
+
} catch {
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* `pg_restore` arguments for the target.
|
|
394
|
+
*
|
|
395
|
+
* `--clean --if-exists` because a pull replaces what is there: restoring into a
|
|
396
|
+
* database that already has the tables would otherwise fail on every one of
|
|
397
|
+
* them. `--no-owner` for the same reason as the dump.
|
|
398
|
+
*/
|
|
399
|
+
function restoreArgs(plan, dumpFile) {
|
|
400
|
+
return [
|
|
401
|
+
"--clean",
|
|
402
|
+
"--if-exists",
|
|
403
|
+
"--no-owner",
|
|
404
|
+
"--no-privileges",
|
|
405
|
+
"--dbname",
|
|
406
|
+
splitPassword(plan.target).connectionString,
|
|
407
|
+
dumpFile
|
|
408
|
+
];
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Is this PostgreSQL client tool on PATH, and what version? Both are checked
|
|
412
|
+
* before anything destructive: discovering `pg_restore` is missing after the
|
|
413
|
+
* dump has been taken — or after `--clean` has emptied the target — is the
|
|
414
|
+
* worst possible ordering.
|
|
415
|
+
*/
|
|
416
|
+
async function findPgTool(tool) {
|
|
417
|
+
try {
|
|
418
|
+
const { stdout } = await execa(tool, ["--version"]);
|
|
419
|
+
return stdout.trim();
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* What a finished `pg_restore` says about the copy.
|
|
426
|
+
*
|
|
427
|
+
* `--clean` empties the target before anything is written, so every way this
|
|
428
|
+
* can end short of exit 0 leaves a partial or empty local database, and none of
|
|
429
|
+
* them may be reported as a copy. A non-zero exit is pg_restore saying it
|
|
430
|
+
* skipped statements — "errors ignored on restore: N" — and whatever those
|
|
431
|
+
* statements created (a table, its rows, an RLS policy) is missing locally.
|
|
432
|
+
*
|
|
433
|
+
* `started` says whether anything can have arrived, which is what decides
|
|
434
|
+
* whether an anonymization pass still has data to redact.
|
|
435
|
+
*/
|
|
436
|
+
function restoreOutcome(run) {
|
|
437
|
+
if (run.signal) return {
|
|
438
|
+
started: true,
|
|
439
|
+
failure: `pg_restore was stopped by ${run.signal} before it finished.`
|
|
440
|
+
};
|
|
441
|
+
if (run.exitCode === void 0) return {
|
|
442
|
+
started: false,
|
|
443
|
+
failure: `pg_restore could not be started (${run.code ?? "unknown error"}).`
|
|
444
|
+
};
|
|
445
|
+
if (run.exitCode === 0) return {
|
|
446
|
+
started: true,
|
|
447
|
+
failure: null
|
|
448
|
+
};
|
|
449
|
+
const ignored = /errors ignored on restore: (\d+)/.exec(run.stderr ?? "");
|
|
450
|
+
return {
|
|
451
|
+
started: true,
|
|
452
|
+
failure: ignored ? `pg_restore could not restore ${ignored[1]} object(s) — the errors are above.` : `pg_restore exited with code ${run.exitCode}.`
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
export { anonymizeCopy, describeTarget, dumpArgs, dumpEnv, findPgTool, provisionableSchemas, restoreArgs, restoreEnv, restoreOutcome };
|
|
457
|
+
|
|
458
|
+
//# sourceMappingURL=pull-CrGYMEsy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pull-CrGYMEsy.js","names":[],"sources":["../src/dev-db/pull.ts"],"sourcesContent":["/**\n * `rebase db pull` — copy a database's contents into local development.\n *\n * The common case is production into local, and the reason it exists is that\n * the alternative is worse: without it people hand-roll a `pg_dump | psql` and\n * get the flags wrong in ways that either fail loudly at 2am or, more often,\n * quietly restore half a schema.\n *\n * Three things this command insists on, because copying a production database\n * onto a laptop is a data-protection event whether or not anyone calls it one:\n *\n * 1. **It says what it is about to do, in full, before doing it** — which\n * database it will read, which one it will overwrite, and where the data will\n * come to rest on disk. The target path matters: people forget that\n * `.rebase/pgdata` is a directory their backup software may be indexing.\n *\n * 2. **It refuses to run unattended without being told to.** The target is\n * destroyed, so a mistyped `--from` with no confirmation would take the\n * developer's working database with it.\n *\n * 3. **It will not write to a remote database.** The target is always the local\n * development database; there is no flag that makes this push. A tool that\n * can copy in both directions eventually copies in the wrong one.\n *\n * Anonymization is opt-in (`--anonymize`), which is a deliberate choice and not\n * an obviously safe one — the flag nobody types is the flag nobody gets. It is\n * a best-effort pass over columns whose *names* look like personal data, and\n * {@link ANONYMIZE_PATTERNS} says exactly which. It cannot find personal data in\n * a column called `notes`, and this file says so rather than implying a\n * guarantee it cannot keep.\n *\n * What it does guarantee is that it never half-happens: {@link anonymizeCopy}\n * runs the pass in one transaction, and when the pass fails it empties the\n * tables it would have rewritten rather than leave their real values behind.\n */\n\nimport { execa } from \"execa\";\nimport type { Client } from \"pg\";\n\n/**\n * How one kind of personal-looking column is overwritten.\n *\n * Two forms, because a constant is the most readable replacement and is wrong\n * for exactly one kind of column: a unique one. Every `token_hash` becoming\n * `'REDACTED'` is what `UNIQUE(token_hash)` refused on the second row of\n * `rebase.refresh_tokens` — so on any project where someone had signed in twice.\n */\nexport interface AnonymizeRule {\n pattern: RegExp;\n /** The value for a column no unique index covers, or null to always use the unique form. */\n value: string | null;\n /** The unique form is `prefix` + random hex + `suffix`, cut to fit the column. */\n prefix: string;\n suffix: string;\n}\n\n/**\n * Column-name patterns the anonymizer overwrites.\n *\n * Names, not contents: inspecting values would be slower, and would still miss\n * the same things. This is a reasonable-effort measure for making a local copy\n * less dangerous, and it is not a compliance control.\n */\nexport const ANONYMIZE_PATTERNS: readonly AnonymizeRule[] = [\n // Emails are unique in practice even where nothing enforces it, so they\n // always get a value of their own.\n { pattern: /^(.*_)?e?mail(_.*)?$/i, value: null, prefix: \"user-\", suffix: \"@example.invalid\" },\n { pattern: /^(.*_)?(phone|mobile|tel|telephone)(_.*)?$/i, value: \"+10000000000\", prefix: \"+1-\", suffix: \"\" },\n { pattern: /^(.*_)?(first_name|last_name|full_name|surname|given_name)(_.*)?$/i, value: \"Redacted\", prefix: \"Redacted-\", suffix: \"\" },\n { pattern: /^(.*_)?(address|street|postcode|zip|zipcode)(_.*)?$/i, value: \"Redacted\", prefix: \"Redacted-\", suffix: \"\" },\n { pattern: /^(.*_)?(ssn|tax_id|national_id|passport)(_.*)?$/i, value: \"REDACTED\", prefix: \"REDACTED-\", suffix: \"\" },\n { pattern: /^(.*_)?(password|password_hash|secret|token|api_key|access_token|refresh_token)(_.*)?$/i, value: \"REDACTED\", prefix: \"REDACTED-\", suffix: \"\" },\n { pattern: /^(.*_)?(ip|ip_address|user_agent)(_.*)?$/i, value: \"REDACTED\", prefix: \"REDACTED-\", suffix: \"\" }\n];\n\nexport function shouldAnonymize(columnName: string): boolean {\n return ANONYMIZE_PATTERNS.some((rule) => rule.pattern.test(columnName));\n}\n\n/** A text-ish column the anonymizer can overwrite without a type error. */\nexport interface ColumnRef {\n schema: string;\n table: string;\n column: string;\n dataType: string;\n /** `character_maximum_length`: null or absent for an unbounded type. */\n maxLength?: number | null;\n /** Covered by a unique index — a primary key, a UNIQUE constraint, or a unique expression index. */\n unique?: boolean;\n}\n\n/** Random hex of this length, per row. `gen_random_uuid()` is core since PostgreSQL 13. */\nfunction randomHex(length: number): string {\n return `left(replace(gen_random_uuid()::text, '-', ''), ${length})`;\n}\n\nfunction sqlLiteral(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n\n/**\n * Fewest random hex digits a unique replacement keeps. Twelve is 16^12 values:\n * a collision needs millions of rows, where eight would need tens of thousands.\n */\nconst MIN_UNIQUE_HEX = 12;\n\n/**\n * The SQL expression that overwrites one column, or null when no rule names it.\n *\n * Unique per row when an index makes the column unique, and always cut to the\n * column's declared length — `zip char(5)` refused `'Redacted'` outright, and a\n * failed statement now empties the tables rather than skip one. To fit a short\n * unique column the prefix goes first, then the suffix, and the random part is\n * kept to at least {@link MIN_UNIQUE_HEX} digits while there is room for them.\n * A NULL stays NULL: an empty optional field is not personal data.\n */\nexport function replacementFor(column: ColumnRef): string | null {\n const rule = ANONYMIZE_PATTERNS.find((candidate) => candidate.pattern.test(column.column));\n if (!rule) return null;\n const limit = typeof column.maxLength === \"number\" && column.maxLength > 0 ? column.maxLength : null;\n\n let value: string;\n if (rule.value !== null && !column.unique) {\n value = sqlLiteral(limit === null ? rule.value : rule.value.slice(0, limit));\n } else {\n const shapes = [[rule.prefix, rule.suffix], [\"\", rule.suffix], [\"\", \"\"]];\n const fitting = shapes.find(([prefix, suffix]) =>\n limit === null || limit - prefix.length - suffix.length >= MIN_UNIQUE_HEX);\n const [prefix, suffix] = fitting ?? [\"\", \"\"];\n const hex = Math.min(32, limit === null ? 32 : limit - prefix.length - suffix.length);\n value = `concat(${sqlLiteral(prefix)}, ${randomHex(Math.max(1, hex))}, ${sqlLiteral(suffix)})`;\n }\n\n const name = quoteIdentifier(column.column);\n return `CASE WHEN ${name} IS NULL THEN NULL ELSE ${value} END`;\n}\n\n/** A double-quoted identifier, with any `\"` in it doubled — a table called `order` or `a\"b` still works. */\nexport function quoteIdentifier(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/**\n * Anonymizable columns: name looks personal, and the type can hold the\n * replacement.\n *\n * The type check is what stops this generating `UPDATE … SET user_id =\n * 'Redacted'` for an integer column called `user_id_email_seq` and failing the\n * whole pass on a technicality.\n */\nexport function anonymizableColumns(columns: readonly ColumnRef[]): ColumnRef[] {\n const textual = new Set([\"text\", \"character varying\", \"varchar\", \"character\", \"char\", \"citext\"]);\n\n return columns.filter((column) => shouldAnonymize(column.column) && textual.has(column.dataType.toLowerCase()));\n}\n\n/** `\"schema\".\"table\"` for each table the pass rewrites, sorted. */\nexport function anonymizedTables(columns: readonly ColumnRef[]): string[] {\n return [...new Set(anonymizableColumns(columns).map((column) =>\n `${quoteIdentifier(column.schema)}.${quoteIdentifier(column.table)}`))].sort();\n}\n\n/** `UPDATE` statements for one anonymization pass, in a stable order. */\nexport function anonymizeStatements(columns: readonly ColumnRef[]): string[] {\n const byTable = new Map<string, ColumnRef[]>();\n for (const column of anonymizableColumns(columns)) {\n const key = `${quoteIdentifier(column.schema)}.${quoteIdentifier(column.table)}`;\n byTable.set(key, [...(byTable.get(key) ?? []), column]);\n }\n\n return [...byTable.entries()]\n .sort(([a], [b]) => a.localeCompare(b))\n .map(([table, cols]) => {\n const assignments = [...cols]\n .sort((a, b) => a.column.localeCompare(b.column))\n .map((column) => `${quoteIdentifier(column.column)} = ${replacementFor(column)}`)\n .join(\", \");\n\n return `UPDATE ${table} SET ${assignments};`;\n });\n}\n\n/**\n * Every column the pass could overwrite, with what decides how.\n *\n * Base tables only — a view has an `email` column too, and is not something to\n * UPDATE. Generated columns are skipped for the same reason: `email_domain\n * GENERATED ALWAYS AS (…)` matches by name and refuses any value but DEFAULT,\n * and it follows the column it is generated from anyway. An extension type such\n * as `citext` reports `USER-DEFINED`, so its own name is what is compared.\n *\n * `unique` comes from the indexes, through `pg_depend` as well as `indkey`: a\n * unique index on `lower(email)` names no column in `indkey`, and is exactly\n * what a constant replacement would violate.\n */\nexport const ANONYMIZE_COLUMNS_SQL = `\nWITH unique_columns AS (\n SELECT DISTINCT i.indrelid AS relid, a.attname AS column_name\n FROM pg_index i\n JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum > 0 AND NOT a.attisdropped\n WHERE i.indisunique\n AND (a.attnum = ANY (i.indkey)\n OR EXISTS (SELECT 1 FROM pg_depend d\n WHERE d.classid = 'pg_class'::regclass AND d.objid = i.indexrelid\n AND d.refclassid = 'pg_class'::regclass AND d.refobjid = i.indrelid\n AND d.refobjsubid = a.attnum))\n)\nSELECT c.table_schema AS \"schema\",\n c.table_name AS \"table\",\n c.column_name AS \"column\",\n CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS \"dataType\",\n c.character_maximum_length AS \"maxLength\",\n (u.column_name IS NOT NULL) AS \"unique\"\n FROM information_schema.columns c\n JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name\n LEFT JOIN unique_columns u\n ON u.relid = format('%I.%I', c.table_schema, c.table_name)::regclass AND u.column_name = c.column_name\n WHERE t.table_type = 'BASE TABLE'\n AND c.is_generated = 'NEVER'\n AND c.table_schema NOT IN ('pg_catalog', 'information_schema')`;\n\n/** How {@link anonymizeCopy} ended. */\nexport type AnonymizeOutcome =\n | { ok: true; tables: string[] }\n | {\n ok: false;\n /** Why the pass failed. Nothing it wrote was kept. */\n error: string;\n /** The tables emptied instead, so none of their real values are left. */\n emptied: string[];\n /** Why they could not be emptied, when they could not: the copy then holds real data. */\n emptyError: string | null;\n };\n\nfunction errorText(error: unknown): string {\n return error instanceof Error && error.message ? error.message : String(error);\n}\n\n/**\n * Overwrite the personal-looking columns of a restored copy, all or nothing.\n *\n * One transaction, so a failure part-way leaves no table half done — the old\n * pass ran its UPDATEs one by one, so a failure on `rebase.refresh_tokens` left\n * `public.authors` anonymized and `rebase.users` holding every real email.\n *\n * A rollback alone would be worse than that, though: it puts back the real\n * value of every column, in a copy the developer asked to be anonymized. So on\n * failure the tables the pass would have rewritten are emptied (`TRUNCATE …\n * CASCADE`, which takes the rows that reference them too), and the outcome\n * says which — or, if even that failed, says the copy holds real data, so the\n * caller can say it louder.\n *\n * `row_security = off` makes a role that RLS would filter fail the pass instead\n * of rewriting only the rows its policies show it, which looked like success.\n */\nexport async function anonymizeCopy(client: Client): Promise<AnonymizeOutcome> {\n let listed = false;\n let tables: string[] = [];\n try {\n const { rows } = await client.query<ColumnRef>(ANONYMIZE_COLUMNS_SQL);\n tables = anonymizedTables(rows);\n listed = true;\n await client.query(\"BEGIN\");\n await client.query(\"SET LOCAL row_security = off\");\n for (const statement of anonymizeStatements(rows)) await client.query(statement);\n await client.query(\"COMMIT\");\n\n return { ok: true, tables };\n } catch (error) {\n const reason = errorText(error);\n await client.query(\"ROLLBACK\").catch(() => undefined);\n if (!listed) {\n return { ok: false, error: reason, emptied: [], emptyError: \"the tables to empty could not be listed\" };\n }\n if (tables.length === 0) return { ok: false, error: reason, emptied: [], emptyError: null };\n try {\n await client.query(`TRUNCATE TABLE ${tables.join(\", \")} CASCADE`);\n\n return { ok: false, error: reason, emptied: tables, emptyError: null };\n } catch (emptyError) {\n return { ok: false, error: reason, emptied: [], emptyError: errorText(emptyError) };\n }\n }\n}\n\n/**\n * Schemas the restored copy needs the app role provisioned on.\n *\n * `pg_dump --no-privileges` strips every GRANT, so a pulled database arrives\n * with its RLS policies and its `FORCE ROW LEVEL SECURITY` intact and no\n * privileges behind them. Measured on a 30-table project: 68 policies restored,\n * 14 tables with RLS on, and **0** grants to `rebase_user` — where the source\n * had 60. Reading one table as the role Rebase serves every request through:\n *\n * source: 6\n * copy: ERROR: permission denied for table leads\n *\n * The dump flags are right and stay: without `--no-owner`/`--no-privileges`\n * every `ALTER … OWNER TO` and `GRANT … TO <prod role>` in the dump fails\n * against roles that do not exist on a laptop, and buries the real output. The\n * repair belongs after the restore, and belongs to `ensureAppRole` — the same\n * routine boot and `rebase db push` call — rather than to a second list of\n * grants written here.\n *\n * Which schemas: every non-system schema the restored database actually has.\n * Boot knows its `managedSchemas` from the collections; a restore knows only\n * what arrived, and a source may carry schemas this project does not declare.\n * `pg_catalog`, `information_schema` and the `pg_*` internals are never ours to\n * grant on and PostgreSQL would refuse anyway.\n */\nexport function provisionableSchemas(rows: readonly { schema: string }[]): string[] {\n const skip = (name: string) => name === \"information_schema\" || name.startsWith(\"pg_\");\n\n return [...new Set(rows.map((row) => row.schema))].filter((name) => !skip(name)).sort();\n}\n\n/** Host and database of a connection string, with no credentials in it. */\nexport function describeTarget(connectionString: string): string {\n try {\n const url = new URL(connectionString);\n const database = url.pathname.replace(/^\\//, \"\") || \"(default)\";\n\n return `${url.hostname}${url.port ? `:${url.port}` : \"\"}/${database}`;\n } catch {\n // Never echo the raw string: it carries a password, and this line is\n // printed to a terminal people paste into issues.\n return \"(unparseable connection string)\";\n }\n}\n\nexport interface PullPlan {\n /** Where the data comes from. */\n source: string;\n /** Where it lands. Always local. */\n target: string;\n anonymize: boolean;\n /** Schemas to copy. Empty means every non-system schema. */\n schemas: string[];\n}\n\n/**\n * `pg_dump` arguments for the source.\n *\n * `--no-owner` and `--no-acl` because the roles on a production server do not\n * exist locally, and without them every `ALTER … OWNER TO` in the dump fails and\n * buries the real output in noise. `--format=custom` so `pg_restore` can be told\n * to continue past errors selectively rather than all-or-nothing.\n */\nexport function dumpArgs(plan: PullPlan): string[] {\n const args = [\"--format=custom\", \"--no-owner\", \"--no-acl\", \"--no-privileges\"];\n for (const schema of plan.schemas) args.push(\"--schema\", schema);\n args.push(\"--dbname\", splitPassword(plan.source).connectionString);\n\n return args;\n}\n\n/** The environment `pg_dump` runs with: the source's password, if it has one. */\nexport function dumpEnv(plan: PullPlan): Record<string, string> {\n return passwordEnv(plan.source);\n}\n\n/** The environment `pg_restore` runs with: the target's password, if it has one. */\nexport function restoreEnv(plan: PullPlan): Record<string, string> {\n return passwordEnv(plan.target);\n}\n\nfunction passwordEnv(connectionString: string): Record<string, string> {\n const { password } = splitPassword(connectionString);\n return password === null ? {} : { PGPASSWORD: password };\n}\n\n/**\n * A libpq connection string without its password, and the password.\n *\n * The password goes in `PGPASSWORD`, never on a command line, where every\n * process listing can read it and every failure prints it back (execa's error\n * repeats the command). The same rule `db backup` follows; this mirrors\n * `splitPassword` in `@rebasepro/server-postgres`'s connection-string utils,\n * which is not exported. Covers the user-info password and a `password=`\n * parameter; anything that does not parse as a postgres URL is left alone.\n */\nfunction splitPassword(connectionString: string): { connectionString: string; password: string | null } {\n let url: URL;\n try {\n url = new URL(connectionString);\n } catch {\n return { connectionString, password: null };\n }\n if (url.protocol !== \"postgres:\" && url.protocol !== \"postgresql:\") return { connectionString, password: null };\n\n const fromUserInfo = url.password ? decodePercent(url.password) : null;\n const fromParameter = url.searchParams.get(\"password\");\n if (fromUserInfo === null && fromParameter === null) return { connectionString, password: null };\n\n url.password = \"\";\n if (fromParameter !== null) {\n url.searchParams.delete(\"password\");\n // By hand: URLSearchParams writes a space as `+`, which libpq reads literally.\n url.search = Array.from(url.searchParams.entries())\n .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)\n .join(\"&\");\n }\n return { connectionString: url.toString(), password: fromUserInfo ?? fromParameter };\n}\n\nfunction decodePercent(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\n/**\n * `pg_restore` arguments for the target.\n *\n * `--clean --if-exists` because a pull replaces what is there: restoring into a\n * database that already has the tables would otherwise fail on every one of\n * them. `--no-owner` for the same reason as the dump.\n */\nexport function restoreArgs(plan: PullPlan, dumpFile: string): string[] {\n return [\"--clean\", \"--if-exists\", \"--no-owner\", \"--no-privileges\", \"--dbname\", splitPassword(plan.target).connectionString, dumpFile];\n}\n\n/**\n * Is this PostgreSQL client tool on PATH, and what version? Both are checked\n * before anything destructive: discovering `pg_restore` is missing after the\n * dump has been taken — or after `--clean` has emptied the target — is the\n * worst possible ordering.\n */\nexport async function findPgTool(tool: \"pg_dump\" | \"pg_restore\"): Promise<string | null> {\n try {\n const { stdout } = await execa(tool, [\"--version\"]);\n\n return stdout.trim();\n } catch {\n return null;\n }\n}\n\n/** The fields of a finished `pg_restore` run that decide {@link restoreOutcome}. */\nexport interface RestoreRun {\n exitCode?: number;\n signal?: string;\n /** A spawn failure's code — `ENOENT` when the binary vanished. */\n code?: string;\n stderr?: string;\n}\n\n/**\n * What a finished `pg_restore` says about the copy.\n *\n * `--clean` empties the target before anything is written, so every way this\n * can end short of exit 0 leaves a partial or empty local database, and none of\n * them may be reported as a copy. A non-zero exit is pg_restore saying it\n * skipped statements — \"errors ignored on restore: N\" — and whatever those\n * statements created (a table, its rows, an RLS policy) is missing locally.\n *\n * `started` says whether anything can have arrived, which is what decides\n * whether an anonymization pass still has data to redact.\n */\nexport function restoreOutcome(run: RestoreRun): { started: boolean; failure: string | null } {\n if (run.signal) {\n return { started: true, failure: `pg_restore was stopped by ${run.signal} before it finished.` };\n }\n if (run.exitCode === undefined) {\n return { started: false, failure: `pg_restore could not be started (${run.code ?? \"unknown error\"}).` };\n }\n if (run.exitCode === 0) return { started: true, failure: null };\n\n const ignored = /errors ignored on restore: (\\d+)/.exec(run.stderr ?? \"\");\n\n return {\n started: true,\n failure: ignored\n ? `pg_restore could not restore ${ignored[1]} object(s) — the errors are above.`\n : `pg_restore exited with code ${run.exitCode}.`\n };\n}\n"],"mappings":"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"}
|