@apso/cli 0.31.0 → 0.33.1

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.
@@ -0,0 +1,22 @@
1
+ /// <reference types="node" />
2
+ import BaseCommand from "../../lib/base-command";
3
+ import { CopyResult } from "../../lib/import";
4
+ /** Resolve the target connection string from flag/env, else undefined. */
5
+ export declare function resolveTargetConnectionString(flagValue: string | undefined, env?: NodeJS.ProcessEnv): string | undefined;
6
+ /** Render a copy result (or dry-run plan) as a human-readable summary. */
7
+ export declare function formatCopyResult(result: CopyResult): string;
8
+ export default class ImportData extends BaseCommand {
9
+ static description: string;
10
+ static examples: string[];
11
+ static flags: {
12
+ source: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
13
+ target: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
14
+ schema: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
15
+ tables: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
16
+ "batch-size": import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
17
+ "dry-run": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
18
+ yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
19
+ };
20
+ private promptConnection;
21
+ run(): Promise<void>;
22
+ }
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCopyResult = exports.resolveTargetConnectionString = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const core_1 = require("@oclif/core");
6
+ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
7
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
8
+ const import_1 = require("../../lib/import");
9
+ const supabase_1 = require("./supabase");
10
+ /** Resolve the target connection string from flag/env, else undefined. */
11
+ function resolveTargetConnectionString(flagValue, env = process.env) {
12
+ return flagValue || env.TARGET_DATABASE_URL || env.APSO_DATABASE_URL || undefined;
13
+ }
14
+ exports.resolveTargetConnectionString = resolveTargetConnectionString;
15
+ /** Render a copy result (or dry-run plan) as a human-readable summary. */
16
+ function formatCopyResult(result) {
17
+ const lines = [];
18
+ const total = result.perTable.reduce((sum, t) => sum + t.rows, 0);
19
+ lines.push(result.dryRun
20
+ ? `Plan: ${result.perTable.length} table(s), ${total} source row(s) to copy.`
21
+ : `Copied ${total} row(s) across ${result.perTable.length} table(s).`);
22
+ for (const t of result.perTable) {
23
+ lines.push(` ${t.table}: ${t.rows} row(s)`);
24
+ }
25
+ if (result.missingTargetTables.length > 0) {
26
+ lines.push(` Skipped (no matching target table): ${result.missingTargetTables.join(", ")}`);
27
+ }
28
+ if (result.cyclicTables.length > 0) {
29
+ lines.push(` FK cycle (copied best-effort, may need a second pass): ${result.cyclicTables.join(", ")}`);
30
+ }
31
+ if (result.skippedColumns.length > 0) {
32
+ lines.push(" Skipped columns:");
33
+ for (const s of result.skippedColumns) {
34
+ lines.push(` - ${s.table}.${s.column} (${s.reason})`);
35
+ }
36
+ }
37
+ if (result.dryRun && result.blockedTables.length > 0) {
38
+ lines.push(` WARNING: target table(s) already have rows and would block a real run: ${result.blockedTables.join(", ")}`);
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+ exports.formatCopyResult = formatCopyResult;
43
+ class ImportData extends base_command_1.default {
44
+ async promptConnection(role) {
45
+ const answer = await inquirer_1.default.prompt([
46
+ {
47
+ type: "password",
48
+ name: "value",
49
+ mask: "*",
50
+ message: `${role} Postgres connection string:`,
51
+ validate: (input) => input.trim().length > 0 || "A connection string is required",
52
+ },
53
+ ]);
54
+ return answer.value.trim();
55
+ }
56
+ async run() {
57
+ const { flags } = await this.parse(ImportData);
58
+ const sourceConn = (0, supabase_1.resolveConnectionString)(flags.source) ||
59
+ (await this.promptConnection("Source"));
60
+ const targetConn = resolveTargetConnectionString(flags.target) ||
61
+ (await this.promptConnection("Target"));
62
+ const tableFilter = flags.tables
63
+ ? flags.tables.split(",").map((t) => t.trim()).filter(Boolean)
64
+ : undefined;
65
+ let sourceReader;
66
+ let targetWriter;
67
+ try {
68
+ this.log("Introspecting source and target schemas...");
69
+ const sourceSchema = await new import_1.PgIntrospector(sourceConn).introspect(flags.schema);
70
+ const targetSchema = await new import_1.PgIntrospector(targetConn).introspect(flags.schema);
71
+ const plan = (0, import_1.planCopy)(sourceSchema, (0, import_1.targetColumnsFromSchema)(targetSchema), tableFilter);
72
+ if (plan.tables.length === 0) {
73
+ this.warn("No tables to copy (no source/target table overlap).");
74
+ return;
75
+ }
76
+ sourceReader = await import_1.PgSourceReader.connect(sourceConn, flags.schema);
77
+ targetWriter = await import_1.PgTargetWriter.connect(targetConn, flags.schema);
78
+ if (flags["dry-run"]) {
79
+ const result = await (0, import_1.executeCopy)(plan, sourceReader, targetWriter, {
80
+ batchSize: flags["batch-size"],
81
+ dryRun: true,
82
+ });
83
+ this.log("");
84
+ this.log(formatCopyResult(result));
85
+ this.log("");
86
+ this.log("Dry run — no rows written.");
87
+ return;
88
+ }
89
+ if (!flags.yes) {
90
+ const tableList = plan.tables.map((t) => t.targetTable).join(", ");
91
+ const { confirm } = await inquirer_1.default.prompt([
92
+ {
93
+ type: "confirm",
94
+ name: "confirm",
95
+ message: `Copy data into ${plan.tables.length} target table(s) (${tableList})?`,
96
+ default: false,
97
+ },
98
+ ]);
99
+ if (!confirm) {
100
+ this.log("Data copy cancelled.");
101
+ return;
102
+ }
103
+ }
104
+ this.log("Copying data...");
105
+ const result = await (0, import_1.executeCopy)(plan, sourceReader, targetWriter, {
106
+ batchSize: flags["batch-size"],
107
+ });
108
+ this.log("");
109
+ this.log(formatCopyResult(result));
110
+ this.log("Done.");
111
+ }
112
+ catch (error) {
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ const redacted = (0, import_1.redactConnectionString)(message.split(sourceConn).join("****").split(targetConn).join("****"));
115
+ this.error(redacted);
116
+ }
117
+ finally {
118
+ if (sourceReader)
119
+ await sourceReader.close();
120
+ if (targetWriter)
121
+ await targetWriter.close();
122
+ }
123
+ }
124
+ }
125
+ ImportData.description = "Copy table data from a source Supabase (Postgres) database into the target database rebuilt from the imported schema. Reads the source read-only; inserts into empty target tables.";
126
+ ImportData.examples = [
127
+ `$ apso import data -s "postgresql://...source..." -t "postgresql://...target..."`,
128
+ `$ apso import data --dry-run`,
129
+ `$ apso import data --tables users,orders`,
130
+ ];
131
+ ImportData.flags = {
132
+ source: core_1.Flags.string({
133
+ char: "s",
134
+ description: "Source connection string. Falls back to SUPABASE_DB_URL or DATABASE_URL, then prompts.",
135
+ }),
136
+ target: core_1.Flags.string({
137
+ char: "t",
138
+ description: "Target connection string. Falls back to TARGET_DATABASE_URL or APSO_DATABASE_URL, then prompts.",
139
+ }),
140
+ schema: core_1.Flags.string({ description: "Database schema", default: "public" }),
141
+ tables: core_1.Flags.string({
142
+ description: "Comma-separated list of source tables to copy (default: all)",
143
+ }),
144
+ "batch-size": core_1.Flags.integer({
145
+ description: "Rows per insert batch",
146
+ default: 500,
147
+ }),
148
+ "dry-run": core_1.Flags.boolean({
149
+ description: "Plan and report row counts without writing",
150
+ default: false,
151
+ }),
152
+ yes: core_1.Flags.boolean({
153
+ char: "y",
154
+ description: "Skip the confirmation prompt",
155
+ default: false,
156
+ }),
157
+ };
158
+ exports.default = ImportData;
@@ -287,7 +287,14 @@ async function apiRequest(path, options = {}) {
287
287
  }
288
288
  // Handle server errors with retry
289
289
  if (response.status >= 500) {
290
- lastError = new ApiClientError(errorData.message || "Server error", response.status, errorData.details);
290
+ // The BFF returns its message in `.error`; the backend uses `.message`.
291
+ const errObj = errorData;
292
+ const serverMsg = errorData.message ||
293
+ (typeof errObj.error === "string"
294
+ ? errObj.error
295
+ : undefined) ||
296
+ "Server error";
297
+ lastError = new ApiClientError(serverMsg, response.status, errorData.details);
291
298
  await sleep(getBackoffDelay(attempt));
292
299
  continue;
293
300
  }
@@ -138,5 +138,16 @@ export declare const codeApi: {
138
138
  bucket?: string;
139
139
  key?: string;
140
140
  }>;
141
+ /**
142
+ * Record S3 metadata after the presigned PUT completes. Without this the
143
+ * service row's s3_code_key stays null and a server-side push fails with
144
+ * "No S3 code found". Mirrors the browser upload flow (upload-url -> PUT ->
145
+ * finalize).
146
+ */
147
+ finalize(serviceId: string): Promise<{
148
+ success: boolean;
149
+ version?: string;
150
+ size?: number;
151
+ }>;
141
152
  };
142
153
  export {};
@@ -377,4 +377,13 @@ exports.codeApi = {
377
377
  async getUploadUrl(serviceId) {
378
378
  return client_1.api.get(`/api/services/${serviceId}/code/upload-url`);
379
379
  },
380
+ /**
381
+ * Record S3 metadata after the presigned PUT completes. Without this the
382
+ * service row's s3_code_key stays null and a server-side push fails with
383
+ * "No S3 code found". Mirrors the browser upload flow (upload-url -> PUT ->
384
+ * finalize).
385
+ */
386
+ async finalize(serviceId) {
387
+ return client_1.api.post(`/api/services/${serviceId}/code/finalize`, {});
388
+ },
380
389
  };
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- /** Zip the project source into a buffer, skipping build/vcs/dependency dirs. */
3
+ /** Zip the project source into a buffer, skipping build/vcs/dependency/secret files. */
4
4
  export declare function zipProject(cwd: string): Buffer;
5
5
  /**
6
6
  * Server-side push: upload the project zip to S3 and have the platform push it
@@ -15,12 +15,28 @@ const EXCLUDE_DIRS = new Set([
15
15
  ".apso",
16
16
  "coverage",
17
17
  ]);
18
- /** Zip the project source into a buffer, skipping build/vcs/dependency dirs. */
18
+ /**
19
+ * Never push local secrets to the repo. `.env` and `.env.*` (e.g. `.env.local`)
20
+ * routinely hold DB passwords and session secrets; `.env.example` is the one
21
+ * env file that is meant to be committed. The backend injects real production
22
+ * env at runtime, so these are never needed in the pushed code.
23
+ */
24
+ function isSecretEnvFile(basename) {
25
+ if (basename === ".env.example" || basename === ".env.sample")
26
+ return false;
27
+ return basename === ".env" || basename.startsWith(".env.");
28
+ }
29
+ /** Zip the project source into a buffer, skipping build/vcs/dependency/secret files. */
19
30
  function zipProject(cwd) {
20
31
  const zip = new adm_zip_1.default();
21
32
  zip.addLocalFolder(cwd, "", (filename) => {
22
33
  const parts = filename.split(path.sep);
23
- return !parts.some((p) => EXCLUDE_DIRS.has(p));
34
+ if (parts.some((p) => EXCLUDE_DIRS.has(p)))
35
+ return false;
36
+ const base = parts[parts.length - 1];
37
+ if (isSecretEnvFile(base))
38
+ return false;
39
+ return true;
24
40
  });
25
41
  return zip.toBuffer();
26
42
  }
@@ -41,6 +57,11 @@ async function serverSidePush(opts) {
41
57
  if (!res.ok) {
42
58
  throw new Error(`Code upload failed (${res.status}). ${await res.text().catch(() => "")}`);
43
59
  }
60
+ // Record the S3 metadata (bucket/key/version) on the service row. The
61
+ // presigned PUT lands the zip in S3 but does NOT persist where it went; the
62
+ // backend push reads s3_code_key from the DB, so without this it fails with
63
+ // "No S3 code found". Same three-step flow the browser upload uses.
64
+ await services_1.codeApi.finalize(opts.serviceId);
44
65
  await services_1.githubApi.push(opts.serviceId, opts.connectionId, {
45
66
  branch: opts.branch,
46
67
  message: opts.message,
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Database Import — data copy (phase 2)
3
+ *
4
+ * Plans and executes a row copy from a source Postgres/Supabase database into a
5
+ * target database that was rebuilt from the imported schema (phase 1). The
6
+ * planning logic is pure and fully unit-testable; the actual reads/writes go
7
+ * through the injectable `SourceReader` / `TargetWriter` interfaces so the
8
+ * executor can be tested with fakes (mirroring phase 1's `Introspector`).
9
+ *
10
+ * Mapping rules mirror phase 1 exactly:
11
+ * - target table name = snakeCase(entity name) = snakeCase(source table name)
12
+ * - single-column FK = renamed to the platform's camelCase `<ref>Id`
13
+ * - every other column = copied under its original name
14
+ */
15
+ import { IntrospectedSchema, IntrospectedTable } from "./types";
16
+ export interface ColumnMapping {
17
+ sourceColumn: string;
18
+ targetColumn: string;
19
+ }
20
+ export interface TableCopyPlan {
21
+ sourceTable: string;
22
+ targetTable: string;
23
+ /** Kept source columns (those whose mapped target column exists), in order. */
24
+ sourceColumns: string[];
25
+ /** Target column names, parallel to sourceColumns. */
26
+ targetColumns: string[];
27
+ /** Source PK column used to order paged reads (null when there is no PK). */
28
+ orderBy: string | null;
29
+ /** Target PK column whose sequence to reset after copy (null if not serial). */
30
+ serialPkColumn: string | null;
31
+ }
32
+ export interface CopyPlan {
33
+ tables: TableCopyPlan[];
34
+ skippedColumns: Array<{
35
+ table: string;
36
+ column: string;
37
+ reason: string;
38
+ }>;
39
+ /** Source tables with no corresponding target table (skipped entirely). */
40
+ missingTargetTables: string[];
41
+ /** Tables involved in a FK cycle (copied best-effort, after acyclic tables). */
42
+ cyclicTables: string[];
43
+ }
44
+ /** The target table name the generator produces for a given source table. */
45
+ export declare function targetTableName(sourceTable: string): string;
46
+ /** Map every source column to its target column name (FK rename or identity). */
47
+ export declare function buildColumnMapping(table: IntrospectedTable): ColumnMapping[];
48
+ /**
49
+ * Order tables so each is copied after the tables it references via FK
50
+ * (parents before children). Self-references are ignored; tables in a cycle are
51
+ * reported and appended best-effort at the end.
52
+ */
53
+ export declare function topoSortTables(tables: IntrospectedTable[]): {
54
+ order: string[];
55
+ cyclic: string[];
56
+ };
57
+ /**
58
+ * Build a full copy plan from the source schema and the set of columns that
59
+ * actually exist in each target table. Source columns whose mapped target
60
+ * column is missing are dropped (and reported); source tables with no target
61
+ * table are skipped entirely.
62
+ */
63
+ export declare function planCopy(source: IntrospectedSchema, targetColumnsByTable: Map<string, Set<string>>, tableFilter?: string[]): CopyPlan;
64
+ /** Coerce a value read from pg into something safe to bind on insert. */
65
+ export declare function coerceValue(value: unknown): unknown;
66
+ /** Build a parameterized multi-row INSERT statement for a batch. */
67
+ export declare function buildInsertSql(table: string, columns: string[], rows: unknown[][], schema?: string): {
68
+ text: string;
69
+ values: unknown[];
70
+ };
71
+ export interface SourceReader {
72
+ totalRows(table: string): Promise<number>;
73
+ readBatch(table: string, columns: string[], orderBy: string | null, offset: number, limit: number): Promise<unknown[][]>;
74
+ }
75
+ export interface TargetWriter {
76
+ rowCount(table: string): Promise<number>;
77
+ insertBatch(table: string, columns: string[], rows: unknown[][]): Promise<void>;
78
+ resetSequence(table: string, column: string): Promise<void>;
79
+ begin(): Promise<void>;
80
+ commit(): Promise<void>;
81
+ rollback(): Promise<void>;
82
+ }
83
+ export interface CopyResult {
84
+ dryRun: boolean;
85
+ perTable: Array<{
86
+ table: string;
87
+ rows: number;
88
+ }>;
89
+ skippedColumns: CopyPlan["skippedColumns"];
90
+ missingTargetTables: string[];
91
+ cyclicTables: string[];
92
+ /** Target tables that already had rows (blocks a real, insert-only run). */
93
+ blockedTables: string[];
94
+ }
95
+ export interface ExecuteOptions {
96
+ batchSize?: number;
97
+ dryRun?: boolean;
98
+ }
99
+ /**
100
+ * Execute a copy plan. Insert-only: if any target table already has rows the
101
+ * run aborts before writing anything. The whole copy runs in one transaction
102
+ * on the target, so a failure rolls everything back.
103
+ */
104
+ export declare function executeCopy(plan: CopyPlan, source: SourceReader, target: TargetWriter, options?: ExecuteOptions): Promise<CopyResult>;
105
+ /** Build the target column lookup used by planCopy from an introspected target. */
106
+ export declare function targetColumnsFromSchema(target: IntrospectedSchema): Map<string, Set<string>>;
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ /**
3
+ * Database Import — data copy (phase 2)
4
+ *
5
+ * Plans and executes a row copy from a source Postgres/Supabase database into a
6
+ * target database that was rebuilt from the imported schema (phase 1). The
7
+ * planning logic is pure and fully unit-testable; the actual reads/writes go
8
+ * through the injectable `SourceReader` / `TargetWriter` interfaces so the
9
+ * executor can be tested with fakes (mirroring phase 1's `Introspector`).
10
+ *
11
+ * Mapping rules mirror phase 1 exactly:
12
+ * - target table name = snakeCase(entity name) = snakeCase(source table name)
13
+ * - single-column FK = renamed to the platform's camelCase `<ref>Id`
14
+ * - every other column = copied under its original name
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.targetColumnsFromSchema = exports.executeCopy = exports.buildInsertSql = exports.coerceValue = exports.planCopy = exports.topoSortTables = exports.buildColumnMapping = exports.targetTableName = void 0;
18
+ const casing_1 = require("../utils/casing");
19
+ const relationships_1 = require("../utils/relationships");
20
+ const pg_to_apsorc_1 = require("./pg-to-apsorc");
21
+ const INTEGER_PK_UDTS = new Set(["int2", "int4", "int8"]);
22
+ /** The target table name the generator produces for a given source table. */
23
+ function targetTableName(sourceTable) {
24
+ return (0, casing_1.snakeCase)(sourceTable);
25
+ }
26
+ exports.targetTableName = targetTableName;
27
+ /** Map every source column to its target column name (FK rename or identity). */
28
+ function buildColumnMapping(table) {
29
+ const fkRefByColumn = new Map();
30
+ for (const fk of table.foreignKeys) {
31
+ if (fk.columns.length === 1) {
32
+ fkRefByColumn.set(fk.columns[0], fk.referencedTable);
33
+ }
34
+ }
35
+ return [...table.columns]
36
+ .sort((a, b) => a.ordinal - b.ordinal)
37
+ .map((col) => {
38
+ const refTable = fkRefByColumn.get(col.name);
39
+ if (refTable) {
40
+ const { toName } = (0, pg_to_apsorc_1.deriveToName)(col.name, refTable);
41
+ const targetColumn = (0, relationships_1.getRelationshipIdField)({
42
+ name: refTable,
43
+ type: "ManyToOne",
44
+ referenceName: toName,
45
+ });
46
+ return { sourceColumn: col.name, targetColumn };
47
+ }
48
+ return { sourceColumn: col.name, targetColumn: col.name };
49
+ });
50
+ }
51
+ exports.buildColumnMapping = buildColumnMapping;
52
+ /**
53
+ * Order tables so each is copied after the tables it references via FK
54
+ * (parents before children). Self-references are ignored; tables in a cycle are
55
+ * reported and appended best-effort at the end.
56
+ */
57
+ function topoSortTables(tables) {
58
+ const names = new Set(tables.map((t) => t.name));
59
+ const deps = new Map();
60
+ for (const t of tables) {
61
+ const d = new Set();
62
+ for (const fk of t.foreignKeys) {
63
+ if (fk.referencedTable !== t.name && names.has(fk.referencedTable)) {
64
+ d.add(fk.referencedTable);
65
+ }
66
+ }
67
+ deps.set(t.name, d);
68
+ }
69
+ const order = [];
70
+ const visited = new Set();
71
+ let progress = true;
72
+ while (order.length < tables.length && progress) {
73
+ progress = false;
74
+ for (const t of tables) {
75
+ if (visited.has(t.name))
76
+ continue;
77
+ if ([...deps.get(t.name)].every((x) => visited.has(x))) {
78
+ order.push(t.name);
79
+ visited.add(t.name);
80
+ progress = true;
81
+ }
82
+ }
83
+ }
84
+ const cyclic = tables
85
+ .filter((t) => !visited.has(t.name))
86
+ .map((t) => t.name);
87
+ for (const c of cyclic)
88
+ order.push(c); // best-effort append
89
+ return { order, cyclic };
90
+ }
91
+ exports.topoSortTables = topoSortTables;
92
+ /**
93
+ * Build a full copy plan from the source schema and the set of columns that
94
+ * actually exist in each target table. Source columns whose mapped target
95
+ * column is missing are dropped (and reported); source tables with no target
96
+ * table are skipped entirely.
97
+ */
98
+ function planCopy(source, targetColumnsByTable, tableFilter) {
99
+ var _a;
100
+ const filter = tableFilter && tableFilter.length > 0 ? new Set(tableFilter) : null;
101
+ const consideredTables = source.tables.filter((t) => !filter || filter.has(t.name));
102
+ const { order, cyclic } = topoSortTables(consideredTables);
103
+ const byName = new Map(consideredTables.map((t) => [t.name, t]));
104
+ const tables = [];
105
+ const skippedColumns = [];
106
+ const missingTargetTables = [];
107
+ for (const sourceTable of order) {
108
+ const table = byName.get(sourceTable);
109
+ const targetTable = targetTableName(sourceTable);
110
+ const targetColumns = targetColumnsByTable.get(targetTable);
111
+ if (!targetColumns) {
112
+ missingTargetTables.push(sourceTable);
113
+ continue;
114
+ }
115
+ const mapping = buildColumnMapping(table);
116
+ const keptSource = [];
117
+ const keptTarget = [];
118
+ for (const m of mapping) {
119
+ if (targetColumns.has(m.targetColumn)) {
120
+ keptSource.push(m.sourceColumn);
121
+ keptTarget.push(m.targetColumn);
122
+ }
123
+ else {
124
+ skippedColumns.push({
125
+ table: sourceTable,
126
+ column: m.sourceColumn,
127
+ reason: `target column "${m.targetColumn}" not found in ${targetTable}`,
128
+ });
129
+ }
130
+ }
131
+ // Determine ordering + serial PK handling from a single-column PK.
132
+ let orderBy = null;
133
+ let serialPkColumn = null;
134
+ if (table.primaryKey.length === 1) {
135
+ const pkCol = table.primaryKey[0];
136
+ orderBy = pkCol;
137
+ const pk = table.columns.find((c) => c.name === pkCol);
138
+ const targetPk = (_a = mapping.find((m) => m.sourceColumn === pkCol)) === null || _a === void 0 ? void 0 : _a.targetColumn;
139
+ if (pk && targetPk && INTEGER_PK_UDTS.has(pk.udtName)) {
140
+ serialPkColumn = targetPk;
141
+ }
142
+ }
143
+ tables.push({
144
+ sourceTable,
145
+ targetTable,
146
+ sourceColumns: keptSource,
147
+ targetColumns: keptTarget,
148
+ orderBy,
149
+ serialPkColumn,
150
+ });
151
+ }
152
+ return { tables, skippedColumns, missingTargetTables, cyclicTables: cyclic };
153
+ }
154
+ exports.planCopy = planCopy;
155
+ /** Coerce a value read from pg into something safe to bind on insert. */
156
+ function coerceValue(value) {
157
+ if (value === null || value === undefined)
158
+ return null;
159
+ if (value instanceof Date)
160
+ return value;
161
+ if (Buffer.isBuffer(value))
162
+ return value;
163
+ if (typeof value === "object")
164
+ return JSON.stringify(value);
165
+ return value;
166
+ }
167
+ exports.coerceValue = coerceValue;
168
+ /** Build a parameterized multi-row INSERT statement for a batch. */
169
+ function buildInsertSql(table, columns, rows, schema) {
170
+ const tableRef = schema ? `"${schema}"."${table}"` : `"${table}"`;
171
+ const colList = columns.map((c) => `"${c}"`).join(", ");
172
+ const values = [];
173
+ const tuples = rows.map((row) => {
174
+ const placeholders = row.map((val) => {
175
+ values.push(coerceValue(val));
176
+ return `$${values.length}`;
177
+ });
178
+ return `(${placeholders.join(", ")})`;
179
+ });
180
+ return {
181
+ text: `INSERT INTO ${tableRef} (${colList}) VALUES ${tuples.join(", ")}`,
182
+ values,
183
+ };
184
+ }
185
+ exports.buildInsertSql = buildInsertSql;
186
+ /**
187
+ * Execute a copy plan. Insert-only: if any target table already has rows the
188
+ * run aborts before writing anything. The whole copy runs in one transaction
189
+ * on the target, so a failure rolls everything back.
190
+ */
191
+ async function executeCopy(plan, source, target, options = {}) {
192
+ var _a, _b;
193
+ const batchSize = (_a = options.batchSize) !== null && _a !== void 0 ? _a : 500;
194
+ const dryRun = (_b = options.dryRun) !== null && _b !== void 0 ? _b : false;
195
+ // Pre-flight: detect non-empty target tables.
196
+ const blockedTables = [];
197
+ for (const t of plan.tables) {
198
+ // eslint-disable-next-line no-await-in-loop
199
+ const count = await target.rowCount(t.targetTable);
200
+ if (count > 0)
201
+ blockedTables.push(t.targetTable);
202
+ }
203
+ const perTable = [];
204
+ if (dryRun) {
205
+ for (const t of plan.tables) {
206
+ // eslint-disable-next-line no-await-in-loop
207
+ const rows = await source.totalRows(t.sourceTable);
208
+ perTable.push({ table: t.targetTable, rows });
209
+ }
210
+ return {
211
+ dryRun: true,
212
+ perTable,
213
+ skippedColumns: plan.skippedColumns,
214
+ missingTargetTables: plan.missingTargetTables,
215
+ cyclicTables: plan.cyclicTables,
216
+ blockedTables,
217
+ };
218
+ }
219
+ if (blockedTables.length > 0) {
220
+ throw new Error(`Aborting: target table(s) already contain rows: ${blockedTables.join(", ")}. Use a fresh target or clear these tables first.`);
221
+ }
222
+ await target.begin();
223
+ try {
224
+ for (const t of plan.tables) {
225
+ let offset = 0;
226
+ let copied = 0;
227
+ // eslint-disable-next-line no-constant-condition
228
+ while (true) {
229
+ // eslint-disable-next-line no-await-in-loop
230
+ const rows = await source.readBatch(t.sourceTable, t.sourceColumns, t.orderBy, offset, batchSize);
231
+ if (rows.length === 0)
232
+ break;
233
+ // eslint-disable-next-line no-await-in-loop
234
+ await target.insertBatch(t.targetTable, t.targetColumns, rows);
235
+ copied += rows.length;
236
+ offset += rows.length;
237
+ if (rows.length < batchSize)
238
+ break;
239
+ }
240
+ if (t.serialPkColumn && copied > 0) {
241
+ // eslint-disable-next-line no-await-in-loop
242
+ await target.resetSequence(t.targetTable, t.serialPkColumn);
243
+ }
244
+ perTable.push({ table: t.targetTable, rows: copied });
245
+ }
246
+ await target.commit();
247
+ }
248
+ catch (error) {
249
+ await target.rollback();
250
+ throw error;
251
+ }
252
+ return {
253
+ dryRun: false,
254
+ perTable,
255
+ skippedColumns: plan.skippedColumns,
256
+ missingTargetTables: plan.missingTargetTables,
257
+ cyclicTables: plan.cyclicTables,
258
+ blockedTables,
259
+ };
260
+ }
261
+ exports.executeCopy = executeCopy;
262
+ /** Build the target column lookup used by planCopy from an introspected target. */
263
+ function targetColumnsFromSchema(target) {
264
+ return new Map(target.tables.map((t) => [t.name, new Set(t.columns.map((c) => c.name))]));
265
+ }
266
+ exports.targetColumnsFromSchema = targetColumnsFromSchema;
@@ -2,3 +2,6 @@ export * from "./types";
2
2
  export { pgToApsorc, findUnknownEmittedTypes } from "./pg-to-apsorc";
3
3
  export type { ApsorcImportOutput, ApsorcImportEntityOutput, ApsorcImportFieldOutput, ApsorcImportRelationshipOutput, } from "./pg-to-apsorc";
4
4
  export { PgIntrospector, redactConnectionString, buildSchema, SYSTEM_SCHEMAS } from "./introspect";
5
+ export { planCopy, executeCopy, buildColumnMapping, topoSortTables, targetTableName, targetColumnsFromSchema, buildInsertSql, coerceValue, } from "./copy-data";
6
+ export type { CopyPlan, CopyResult, TableCopyPlan, ColumnMapping, SourceReader, TargetWriter, ExecuteOptions, } from "./copy-data";
7
+ export { PgSourceReader, PgTargetWriter } from "./pg-data";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SYSTEM_SCHEMAS = exports.buildSchema = exports.redactConnectionString = exports.PgIntrospector = exports.findUnknownEmittedTypes = exports.pgToApsorc = void 0;
3
+ exports.PgTargetWriter = exports.PgSourceReader = exports.coerceValue = exports.buildInsertSql = exports.targetColumnsFromSchema = exports.targetTableName = exports.topoSortTables = exports.buildColumnMapping = exports.executeCopy = exports.planCopy = exports.SYSTEM_SCHEMAS = exports.buildSchema = exports.redactConnectionString = exports.PgIntrospector = exports.findUnknownEmittedTypes = exports.pgToApsorc = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  tslib_1.__exportStar(require("./types"), exports);
6
6
  var pg_to_apsorc_1 = require("./pg-to-apsorc");
@@ -11,3 +11,15 @@ Object.defineProperty(exports, "PgIntrospector", { enumerable: true, get: functi
11
11
  Object.defineProperty(exports, "redactConnectionString", { enumerable: true, get: function () { return introspect_1.redactConnectionString; } });
12
12
  Object.defineProperty(exports, "buildSchema", { enumerable: true, get: function () { return introspect_1.buildSchema; } });
13
13
  Object.defineProperty(exports, "SYSTEM_SCHEMAS", { enumerable: true, get: function () { return introspect_1.SYSTEM_SCHEMAS; } });
14
+ var copy_data_1 = require("./copy-data");
15
+ Object.defineProperty(exports, "planCopy", { enumerable: true, get: function () { return copy_data_1.planCopy; } });
16
+ Object.defineProperty(exports, "executeCopy", { enumerable: true, get: function () { return copy_data_1.executeCopy; } });
17
+ Object.defineProperty(exports, "buildColumnMapping", { enumerable: true, get: function () { return copy_data_1.buildColumnMapping; } });
18
+ Object.defineProperty(exports, "topoSortTables", { enumerable: true, get: function () { return copy_data_1.topoSortTables; } });
19
+ Object.defineProperty(exports, "targetTableName", { enumerable: true, get: function () { return copy_data_1.targetTableName; } });
20
+ Object.defineProperty(exports, "targetColumnsFromSchema", { enumerable: true, get: function () { return copy_data_1.targetColumnsFromSchema; } });
21
+ Object.defineProperty(exports, "buildInsertSql", { enumerable: true, get: function () { return copy_data_1.buildInsertSql; } });
22
+ Object.defineProperty(exports, "coerceValue", { enumerable: true, get: function () { return copy_data_1.coerceValue; } });
23
+ var pg_data_1 = require("./pg-data");
24
+ Object.defineProperty(exports, "PgSourceReader", { enumerable: true, get: function () { return pg_data_1.PgSourceReader; } });
25
+ Object.defineProperty(exports, "PgTargetWriter", { enumerable: true, get: function () { return pg_data_1.PgTargetWriter; } });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Database Import — pg-backed data access for the copy executor.
3
+ *
4
+ * Thin wrappers around `pg` implementing the SourceReader / TargetWriter
5
+ * interfaces from copy-data.ts. `pg` is imported lazily and SSL is enabled by
6
+ * default (Supabase requires it). Integration-level: covered by manual/E2E
7
+ * runs, while the copy logic itself is unit-tested with fakes.
8
+ */
9
+ import { SourceReader, TargetWriter } from "./copy-data";
10
+ /** Reads rows from the source database (read-only). */
11
+ export declare class PgSourceReader implements SourceReader {
12
+ private readonly client;
13
+ private readonly schema;
14
+ private constructor();
15
+ static connect(connectionString: string, schema: string): Promise<PgSourceReader>;
16
+ totalRows(table: string): Promise<number>;
17
+ readBatch(table: string, columns: string[], orderBy: string | null, offset: number, limit: number): Promise<unknown[][]>;
18
+ close(): Promise<void>;
19
+ }
20
+ /** Writes rows into the target database within a single transaction. */
21
+ export declare class PgTargetWriter implements TargetWriter {
22
+ private readonly client;
23
+ private readonly schema;
24
+ private constructor();
25
+ static connect(connectionString: string, schema: string): Promise<PgTargetWriter>;
26
+ rowCount(table: string): Promise<number>;
27
+ insertBatch(table: string, columns: string[], rows: unknown[][]): Promise<void>;
28
+ resetSequence(table: string, column: string): Promise<void>;
29
+ begin(): Promise<void>;
30
+ commit(): Promise<void>;
31
+ rollback(): Promise<void>;
32
+ close(): Promise<void>;
33
+ }
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * Database Import — pg-backed data access for the copy executor.
4
+ *
5
+ * Thin wrappers around `pg` implementing the SourceReader / TargetWriter
6
+ * interfaces from copy-data.ts. `pg` is imported lazily and SSL is enabled by
7
+ * default (Supabase requires it). Integration-level: covered by manual/E2E
8
+ * runs, while the copy logic itself is unit-tested with fakes.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || function (mod) {
27
+ if (mod && mod.__esModule) return mod;
28
+ var result = {};
29
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
30
+ __setModuleDefault(result, mod);
31
+ return result;
32
+ };
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.PgTargetWriter = exports.PgSourceReader = void 0;
35
+ async function createClient(connectionString) {
36
+ var _a, _b;
37
+ const pg = await Promise.resolve().then(() => __importStar(require("pg")));
38
+ const Client = (_a = pg.Client) !== null && _a !== void 0 ? _a : (_b = pg.default) === null || _b === void 0 ? void 0 : _b.Client;
39
+ const client = new Client({
40
+ connectionString,
41
+ ssl: { rejectUnauthorized: false },
42
+ });
43
+ await client.connect();
44
+ return client;
45
+ }
46
+ function qualified(schema, table) {
47
+ return `"${schema}"."${table}"`;
48
+ }
49
+ /** Reads rows from the source database (read-only). */
50
+ class PgSourceReader {
51
+ constructor(client, schema) {
52
+ this.client = client;
53
+ this.schema = schema;
54
+ }
55
+ static async connect(connectionString, schema) {
56
+ return new PgSourceReader(await createClient(connectionString), schema);
57
+ }
58
+ async totalRows(table) {
59
+ const res = await this.client.query(`SELECT count(*)::int AS n FROM ${qualified(this.schema, table)}`);
60
+ return res.rows[0].n;
61
+ }
62
+ async readBatch(table, columns, orderBy, offset, limit) {
63
+ const cols = columns.map((c) => `"${c}"`).join(", ");
64
+ const order = orderBy ? ` ORDER BY "${orderBy}"` : "";
65
+ const res = await this.client.query(`SELECT ${cols} FROM ${qualified(this.schema, table)}${order} OFFSET $1 LIMIT $2`, [offset, limit]);
66
+ return res.rows.map((row) => columns.map((c) => row[c]));
67
+ }
68
+ async close() {
69
+ await this.client.end();
70
+ }
71
+ }
72
+ exports.PgSourceReader = PgSourceReader;
73
+ /** Writes rows into the target database within a single transaction. */
74
+ class PgTargetWriter {
75
+ constructor(client, schema) {
76
+ this.client = client;
77
+ this.schema = schema;
78
+ }
79
+ static async connect(connectionString, schema) {
80
+ return new PgTargetWriter(await createClient(connectionString), schema);
81
+ }
82
+ async rowCount(table) {
83
+ const res = await this.client.query(`SELECT count(*)::int AS n FROM ${qualified(this.schema, table)}`);
84
+ return res.rows[0].n;
85
+ }
86
+ async insertBatch(table, columns, rows) {
87
+ const { buildInsertSql } = await Promise.resolve().then(() => __importStar(require("./copy-data")));
88
+ const { text, values } = buildInsertSql(table, columns, rows, this.schema);
89
+ await this.client.query(text, values);
90
+ }
91
+ async resetSequence(table, column) {
92
+ // Advance the column's owned sequence past the max value just inserted.
93
+ await this.client.query(`SELECT setval(
94
+ pg_get_serial_sequence($1, $2),
95
+ (SELECT COALESCE(MAX("${column}"), 0) FROM ${qualified(this.schema, table)}),
96
+ true
97
+ )`, [`${this.schema}.${table}`, column]);
98
+ }
99
+ async begin() {
100
+ await this.client.query("BEGIN");
101
+ }
102
+ async commit() {
103
+ await this.client.query("COMMIT");
104
+ }
105
+ async rollback() {
106
+ await this.client.query("ROLLBACK");
107
+ }
108
+ async close() {
109
+ await this.client.end();
110
+ }
111
+ }
112
+ exports.PgTargetWriter = PgTargetWriter;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.31.0",
3
+ "version": "0.33.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.31.0",
9
+ "version": "0.33.1",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@biomejs/biome": "^1.9.4",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.31.0",
2
+ "version": "0.33.1",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
@@ -627,6 +627,71 @@
627
627
  },
628
628
  "args": {}
629
629
  },
630
+ "import:data": {
631
+ "id": "import:data",
632
+ "description": "Copy table data from a source Supabase (Postgres) database into the target database rebuilt from the imported schema. Reads the source read-only; inserts into empty target tables.",
633
+ "strict": true,
634
+ "pluginName": "@apso/cli",
635
+ "pluginAlias": "@apso/cli",
636
+ "pluginType": "core",
637
+ "aliases": [],
638
+ "hiddenAliases": [],
639
+ "examples": [
640
+ "$ apso import data -s \"postgresql://...source...\" -t \"postgresql://...target...\"",
641
+ "$ apso import data --dry-run",
642
+ "$ apso import data --tables users,orders"
643
+ ],
644
+ "flags": {
645
+ "source": {
646
+ "name": "source",
647
+ "type": "option",
648
+ "char": "s",
649
+ "description": "Source connection string. Falls back to SUPABASE_DB_URL or DATABASE_URL, then prompts.",
650
+ "multiple": false
651
+ },
652
+ "target": {
653
+ "name": "target",
654
+ "type": "option",
655
+ "char": "t",
656
+ "description": "Target connection string. Falls back to TARGET_DATABASE_URL or APSO_DATABASE_URL, then prompts.",
657
+ "multiple": false
658
+ },
659
+ "schema": {
660
+ "name": "schema",
661
+ "type": "option",
662
+ "description": "Database schema",
663
+ "multiple": false,
664
+ "default": "public"
665
+ },
666
+ "tables": {
667
+ "name": "tables",
668
+ "type": "option",
669
+ "description": "Comma-separated list of source tables to copy (default: all)",
670
+ "multiple": false
671
+ },
672
+ "batch-size": {
673
+ "name": "batch-size",
674
+ "type": "option",
675
+ "description": "Rows per insert batch",
676
+ "multiple": false,
677
+ "default": 500
678
+ },
679
+ "dry-run": {
680
+ "name": "dry-run",
681
+ "type": "boolean",
682
+ "description": "Plan and report row counts without writing",
683
+ "allowNo": false
684
+ },
685
+ "yes": {
686
+ "name": "yes",
687
+ "type": "boolean",
688
+ "char": "y",
689
+ "description": "Skip the confirmation prompt",
690
+ "allowNo": false
691
+ }
692
+ },
693
+ "args": {}
694
+ },
630
695
  "import:supabase": {
631
696
  "id": "import:supabase",
632
697
  "description": "Introspect an existing Supabase (Postgres) database and generate a local .apsorc schema. Read-only: it never writes to the source database.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.31.0",
3
+ "version": "0.33.1",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",