ardent-cli 0.0.29 → 0.0.30

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.
Files changed (2) hide show
  1. package/dist/index.js +382 -5
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1042,6 +1042,349 @@ async function multiSelect(items, promptForItem, options = {}) {
1042
1042
  return accepted;
1043
1043
  }
1044
1044
 
1045
+ // src/lib/replica_identity_prompt.ts
1046
+ import { createInterface as createInterface2 } from "readline/promises";
1047
+
1048
+ // src/lib/replica_identity_decisions.ts
1049
+ import { readFileSync as readFileSync3 } from "fs";
1050
+ var REPLICA_IDENTITY_DECISIONS = [
1051
+ "exclude",
1052
+ "add_pk",
1053
+ "replica_identity_full"
1054
+ ];
1055
+ function isReplicaIdentityDecision(value) {
1056
+ return typeof value === "string" && REPLICA_IDENTITY_DECISIONS.includes(value);
1057
+ }
1058
+ function loadDecisionsFromFile(path) {
1059
+ let raw;
1060
+ try {
1061
+ raw = readFileSync3(path, "utf-8");
1062
+ } catch (err) {
1063
+ throw new Error(
1064
+ `Could not read replica-identity decisions file ${path}: ${err instanceof Error ? err.message : String(err)}`
1065
+ );
1066
+ }
1067
+ let parsed;
1068
+ try {
1069
+ parsed = JSON.parse(raw);
1070
+ } catch (err) {
1071
+ throw new Error(
1072
+ `Replica-identity decisions file ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
1073
+ );
1074
+ }
1075
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1076
+ throw new Error(
1077
+ `Replica-identity decisions file ${path} must be a JSON object mapping "database.schema.table" to a decision.`
1078
+ );
1079
+ }
1080
+ const decisions = {};
1081
+ for (const [key, value] of Object.entries(parsed)) {
1082
+ const parts = key.split(".");
1083
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
1084
+ throw new Error(
1085
+ `Replica-identity decisions file ${path}: key ${JSON.stringify(
1086
+ key
1087
+ )} must be of form "database.schema.table" with three non-empty components.`
1088
+ );
1089
+ }
1090
+ if (!isReplicaIdentityDecision(value)) {
1091
+ throw new Error(
1092
+ `Replica-identity decisions file ${path}: value for ${JSON.stringify(
1093
+ key
1094
+ )} must be one of ${REPLICA_IDENTITY_DECISIONS.join(
1095
+ ", "
1096
+ )} (got ${JSON.stringify(value)}).`
1097
+ );
1098
+ }
1099
+ decisions[key] = value;
1100
+ }
1101
+ return decisions;
1102
+ }
1103
+ function validateDecisionsAgainstPreflight(decisions, preflight) {
1104
+ const known = new Set(
1105
+ preflight.no_replication_identity_tables.map((row) => row.fqn)
1106
+ );
1107
+ const unknown = Object.keys(decisions).filter((fqn) => !known.has(fqn)).sort();
1108
+ if (unknown.length > 0) {
1109
+ throw new Error(
1110
+ "Replica-identity decisions reference tables that are not in this connector's current preflight list: " + unknown.join(", ") + ".\n Re-run `ardent connector list` to refresh, then re-submit using FQNs from the connector's preflight (key shape: database.schema.table)."
1111
+ );
1112
+ }
1113
+ const missing = preflight.no_replication_identity_tables.map((row) => row.fqn).filter((fqn) => !Object.prototype.hasOwnProperty.call(decisions, fqn)).sort();
1114
+ if (missing.length > 0) {
1115
+ throw new Error(
1116
+ "Replica-identity decisions file is incomplete. It must include a decision for every table in this connector's current preflight list. Missing: " + missing.join(", ") + ".\n Add those FQNs to the file, or re-run with --accept-replica-identity-defaults to explicitly keep existing choices and exclude undecided tables."
1117
+ );
1118
+ }
1119
+ }
1120
+ function buildDefaultedDecisions(preflight) {
1121
+ const decisions = {};
1122
+ for (const row of preflight.no_replication_identity_tables) {
1123
+ decisions[row.fqn] = row.current_decision;
1124
+ }
1125
+ return decisions;
1126
+ }
1127
+ function formatRowCountEstimate(value) {
1128
+ if (value === null || value < 0) return "unknown";
1129
+ return value.toLocaleString("en-US");
1130
+ }
1131
+
1132
+ // src/lib/replica_identity_prompt.ts
1133
+ var DECISION_DESCRIPTIONS = {
1134
+ exclude: "Exclude the table from replication. Branches will not carry rows for this table.",
1135
+ add_pk: "After adding a PRIMARY KEY on source, keep this table in replication. Setup checks the source before replication starts.",
1136
+ replica_identity_full: "You will run ALTER TABLE ... REPLICA IDENTITY FULL on source. Increases WAL volume on UPDATE/DELETE for this table."
1137
+ };
1138
+ function isInteractive() {
1139
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1140
+ }
1141
+ function logPreflightTable(rows) {
1142
+ console.log("");
1143
+ const noun = rows.length === 1 ? "table" : "tables";
1144
+ console.log(
1145
+ `Found ${rows.length} ${noun} on your source database with no replication identity:`
1146
+ );
1147
+ for (const row of rows) {
1148
+ const estimate = formatRowCountEstimate(row.row_count_estimate);
1149
+ console.log(
1150
+ ` - ${row.fqn} (~${estimate} rows, currently: ${row.current_decision})`
1151
+ );
1152
+ }
1153
+ console.log("");
1154
+ console.log(
1155
+ "Replication needs a PRIMARY KEY, a UNIQUE NOT NULL column, or REPLICA IDENTITY FULL"
1156
+ );
1157
+ console.log(
1158
+ "to carry UPDATE/DELETE rows. Tables without any of these cannot be replicated."
1159
+ );
1160
+ console.log("Ardent never runs source-side DDL on your behalf.");
1161
+ console.log("");
1162
+ }
1163
+ async function promptForDecision(rl, row, index, total) {
1164
+ const estimate = formatRowCountEstimate(row.row_count_estimate);
1165
+ console.log(
1166
+ `[${index + 1}/${total}] ${row.fqn} (~${estimate} rows, currently: ${row.current_decision})`
1167
+ );
1168
+ for (let optionIndex = 0; optionIndex < REPLICA_IDENTITY_DECISIONS.length; optionIndex += 1) {
1169
+ const option = REPLICA_IDENTITY_DECISIONS[optionIndex];
1170
+ console.log(
1171
+ ` ${optionIndex + 1}) ${option} -- ${DECISION_DESCRIPTIONS[option]}`
1172
+ );
1173
+ }
1174
+ const defaultIndex = REPLICA_IDENTITY_DECISIONS.indexOf(row.current_decision);
1175
+ while (true) {
1176
+ const raw = await rl.question(
1177
+ `Choose [1-${REPLICA_IDENTITY_DECISIONS.length}, default=${defaultIndex + 1}]: `
1178
+ );
1179
+ const answer = raw.trim();
1180
+ if (answer === "") return row.current_decision;
1181
+ const numeric = Number.parseInt(answer, 10);
1182
+ if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= REPLICA_IDENTITY_DECISIONS.length) {
1183
+ return REPLICA_IDENTITY_DECISIONS[numeric - 1];
1184
+ }
1185
+ if (REPLICA_IDENTITY_DECISIONS.includes(answer)) {
1186
+ return answer;
1187
+ }
1188
+ console.log(
1189
+ `Unrecognized response. Enter 1-${REPLICA_IDENTITY_DECISIONS.length} or one of: ${REPLICA_IDENTITY_DECISIONS.join(", ")}.`
1190
+ );
1191
+ }
1192
+ }
1193
+ async function runInteractivePrompt(rows) {
1194
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1195
+ try {
1196
+ const decisions = {};
1197
+ for (let index = 0; index < rows.length; index += 1) {
1198
+ const row = rows[index];
1199
+ decisions[row.fqn] = await promptForDecision(rl, row, index, rows.length);
1200
+ console.log("");
1201
+ }
1202
+ return decisions;
1203
+ } finally {
1204
+ rl.close();
1205
+ }
1206
+ }
1207
+ function logAcceptDefaults(rows, decisions) {
1208
+ logPreflightTable(rows);
1209
+ console.log(
1210
+ "Saving the current/default decision for every table above. Undecided tables will be excluded from replication."
1211
+ );
1212
+ for (const fqn of Object.keys(decisions).sort()) {
1213
+ console.log(` - ${fqn} -> ${decisions[fqn]}`);
1214
+ }
1215
+ console.log("");
1216
+ }
1217
+ function logSubmissionDisallowed() {
1218
+ console.error("");
1219
+ console.error(
1220
+ "\u2717 Replica-identity decisions cannot be changed while setup is already in progress."
1221
+ );
1222
+ console.error(
1223
+ " Wait for the current setup attempt to finish, then retry setup if the connector still shows setup pending."
1224
+ );
1225
+ console.error(" Contact Ardent support if the connector stays validating.");
1226
+ }
1227
+ function logNonInteractiveRequired(decisionsNeeded) {
1228
+ const noun = decisionsNeeded === 1 ? "table" : "tables";
1229
+ const verb = decisionsNeeded === 1 ? "has" : "have";
1230
+ console.error("");
1231
+ console.error(
1232
+ `\u2717 ${decisionsNeeded} ${noun} on your source database ${verb} no replication identity. A decision is required before setup can continue.`
1233
+ );
1234
+ console.error("");
1235
+ console.error(" This shell is not interactive. Re-run with either:");
1236
+ console.error(
1237
+ ' --replica-identity-decisions <path> Complete JSON file: { "database.schema.table": "exclude|add_pk|replica_identity_full" }'
1238
+ );
1239
+ console.error(
1240
+ " --accept-replica-identity-defaults explicitly keep current choices and exclude undecided tables"
1241
+ );
1242
+ }
1243
+ function renderReplicaIdentityFullSql(preflight) {
1244
+ const sql = (preflight?.replica_identity_full_sql ?? "").trim();
1245
+ if (sql.length === 0) return;
1246
+ console.log("");
1247
+ console.log(
1248
+ "Run the following on your source database before the next snapshot:"
1249
+ );
1250
+ console.log("");
1251
+ console.log(sql);
1252
+ console.log("");
1253
+ }
1254
+ async function submitDecisions(connectorId, decisions) {
1255
+ const response = await api.put(
1256
+ `/v1/connectors/${connectorId}/replica-identity-decisions`,
1257
+ { decisions }
1258
+ );
1259
+ return response.replica_identity_preflight ?? null;
1260
+ }
1261
+ function summarizeDecisions(decisions) {
1262
+ const summary = {
1263
+ decisions_total: Object.keys(decisions).length,
1264
+ decisions_exclude: 0,
1265
+ decisions_add_pk: 0,
1266
+ decisions_replica_identity_full: 0,
1267
+ decisions_unknown: 0
1268
+ };
1269
+ for (const value of Object.values(decisions)) {
1270
+ if (value === "exclude") {
1271
+ summary.decisions_exclude += 1;
1272
+ } else if (value === "add_pk") {
1273
+ summary.decisions_add_pk += 1;
1274
+ } else if (value === "replica_identity_full") {
1275
+ summary.decisions_replica_identity_full += 1;
1276
+ } else {
1277
+ summary.decisions_unknown += 1;
1278
+ }
1279
+ }
1280
+ return summary;
1281
+ }
1282
+ async function handleReplicaIdentityPreflight(connectorId, options, behavior = {}) {
1283
+ const allowDecisionSubmission = behavior.allowDecisionSubmission ?? true;
1284
+ if (options.replicaIdentityDecisions && options.acceptReplicaIdentityDefaults) {
1285
+ console.error(
1286
+ "\u2717 --replica-identity-decisions and --accept-replica-identity-defaults are mutually exclusive."
1287
+ );
1288
+ console.error(" Pass one or the other.");
1289
+ process.exit(1);
1290
+ }
1291
+ let connector;
1292
+ try {
1293
+ connector = await api.get(
1294
+ `/v1/connectors/${connectorId}`
1295
+ );
1296
+ } catch (err) {
1297
+ console.error(
1298
+ "\u2717 Could not fetch connector for replica-identity preflight:",
1299
+ err instanceof Error ? err.message : String(err)
1300
+ );
1301
+ process.exit(1);
1302
+ }
1303
+ const preflight = connector.replica_identity_preflight ?? null;
1304
+ if (!preflight) {
1305
+ return { submitted: false, preflight: null };
1306
+ }
1307
+ if (preflight.status === "unavailable") {
1308
+ trackEvent("CLI: replica identity preflight unavailable", {
1309
+ connector_id: connectorId
1310
+ });
1311
+ console.error("");
1312
+ console.error(
1313
+ "\u2717 Replica identity preflight is unavailable for this connector. Contact Ardent support."
1314
+ );
1315
+ process.exit(1);
1316
+ }
1317
+ if (preflight.decisions_needed === 0) {
1318
+ return { submitted: false, preflight };
1319
+ }
1320
+ const allDecisionsRecorded = preflight.decisions_recorded >= preflight.decisions_needed;
1321
+ if (allDecisionsRecorded && !options.replicaIdentityDecisions && !options.acceptReplicaIdentityDefaults) {
1322
+ renderReplicaIdentityFullSql(preflight);
1323
+ return { submitted: false, preflight };
1324
+ }
1325
+ if (!allowDecisionSubmission) {
1326
+ logSubmissionDisallowed();
1327
+ process.exit(1);
1328
+ }
1329
+ let decisions;
1330
+ let submissionMode;
1331
+ if (options.replicaIdentityDecisions) {
1332
+ try {
1333
+ decisions = loadDecisionsFromFile(options.replicaIdentityDecisions);
1334
+ validateDecisionsAgainstPreflight(decisions, preflight);
1335
+ } catch (err) {
1336
+ console.error(
1337
+ "\u2717",
1338
+ err instanceof Error ? err.message : String(err)
1339
+ );
1340
+ process.exit(1);
1341
+ }
1342
+ submissionMode = "file";
1343
+ } else if (options.acceptReplicaIdentityDefaults) {
1344
+ decisions = buildDefaultedDecisions(preflight);
1345
+ logAcceptDefaults(preflight.no_replication_identity_tables, decisions);
1346
+ submissionMode = "defaults";
1347
+ } else if (isInteractive()) {
1348
+ logPreflightTable(preflight.no_replication_identity_tables);
1349
+ decisions = await runInteractivePrompt(
1350
+ preflight.no_replication_identity_tables
1351
+ );
1352
+ submissionMode = "interactive";
1353
+ } else {
1354
+ trackEvent("CLI: replica identity preflight non_interactive_required", {
1355
+ connector_id: connectorId,
1356
+ decisions_needed: preflight.decisions_needed
1357
+ });
1358
+ logNonInteractiveRequired(preflight.decisions_needed);
1359
+ process.exit(1);
1360
+ }
1361
+ let refreshed;
1362
+ try {
1363
+ refreshed = await submitDecisions(connectorId, decisions);
1364
+ } catch (err) {
1365
+ trackEvent("CLI: replica identity preflight submission failed", {
1366
+ connector_id: connectorId,
1367
+ mode: submissionMode
1368
+ });
1369
+ console.error(
1370
+ "\u2717 Failed to save replica-identity decisions:",
1371
+ err instanceof Error ? err.message : String(err)
1372
+ );
1373
+ process.exit(1);
1374
+ }
1375
+ trackEvent("CLI: replica identity preflight submitted", {
1376
+ connector_id: connectorId,
1377
+ mode: submissionMode,
1378
+ ...summarizeDecisions(decisions)
1379
+ });
1380
+ renderReplicaIdentityFullSql(refreshed);
1381
+ const total = Object.keys(decisions).length;
1382
+ console.log(
1383
+ `\u2713 Replica-identity decisions saved (${total} ${total === 1 ? "table" : "tables"}).`
1384
+ );
1385
+ return { submitted: true, preflight: refreshed };
1386
+ }
1387
+
1045
1388
  // src/commands/connector/create.ts
1046
1389
  function parsePostgresUrl(url) {
1047
1390
  const atIndex = url.lastIndexOf("@");
@@ -1220,7 +1563,15 @@ async function createAction2(type, url, options) {
1220
1563
  }
1221
1564
  const created = await api.post("/v1/connectors", createPayload);
1222
1565
  const connectorId = created.id;
1566
+ const replicaIdentityPreflightOptions = {
1567
+ replicaIdentityDecisions: options.replicaIdentityDecisions,
1568
+ acceptReplicaIdentityDefaults: options.acceptReplicaIdentityDefaults
1569
+ };
1223
1570
  if (isByoc) {
1571
+ await handleReplicaIdentityPreflight(
1572
+ connectorId,
1573
+ replicaIdentityPreflightOptions
1574
+ );
1224
1575
  console.log("Setting up branching engine...");
1225
1576
  try {
1226
1577
  await runEngineSetupWithPolling(connectorId, connectorName);
@@ -1282,6 +1633,10 @@ async function createAction2(type, url, options) {
1282
1633
  }
1283
1634
  }
1284
1635
  if (connector.branching_engine_status === "configuration_verified") {
1636
+ await handleReplicaIdentityPreflight(
1637
+ connectorId,
1638
+ replicaIdentityPreflightOptions
1639
+ );
1285
1640
  console.log("Setting up branching engine...");
1286
1641
  try {
1287
1642
  await runEngineSetupWithPolling(connectorId, connectorName);
@@ -1540,7 +1895,7 @@ function connectorRetrySetupFailureTelemetry(err) {
1540
1895
  }
1541
1896
  return { reason: "api_error" };
1542
1897
  }
1543
- async function retrySetupAction(name) {
1898
+ async function retrySetupAction(name, options = {}) {
1544
1899
  const currentProjectId = getConfig("currentProjectId");
1545
1900
  if (!currentProjectId) {
1546
1901
  console.error("\u2717 No current project set. Switch to a project first:");
@@ -1574,6 +1929,16 @@ async function retrySetupAction(name) {
1574
1929
  connector_id: connector.id,
1575
1930
  starting_engine_status: connector.branching_engine_status ?? null
1576
1931
  });
1932
+ await handleReplicaIdentityPreflight(
1933
+ connector.id,
1934
+ {
1935
+ replicaIdentityDecisions: options.replicaIdentityDecisions,
1936
+ acceptReplicaIdentityDefaults: options.acceptReplicaIdentityDefaults
1937
+ },
1938
+ {
1939
+ allowDecisionSubmission: connector.branching_engine_status !== "validating"
1940
+ }
1941
+ );
1577
1942
  console.log(`Setting up branching engine for ${name}...`);
1578
1943
  try {
1579
1944
  const { dispatched } = await runEngineSetupWithPolling(connector.id, name);
@@ -1768,11 +2133,23 @@ connectorCommand.command("create <type> [url]").description("Create a new connec
1768
2133
  ).option(
1769
2134
  "--aws-cluster-name <name>",
1770
2135
  "EKS cluster name in the customer account where pgstream pods run (required with --deployment-model=customer-cloud)"
2136
+ ).option(
2137
+ "--replica-identity-decisions <path>",
2138
+ 'Path to a complete JSON file mapping every current "database.schema.table" preflight FQN -> "exclude" | "add_pk" | "replica_identity_full".'
2139
+ ).option(
2140
+ "--accept-replica-identity-defaults",
2141
+ "Non-interactively save the current/default decision for every table with no replication identity. Undecided tables are excluded from replication."
1771
2142
  ).action(createAction2);
1772
2143
  connectorCommand.command("list").description("List your connectors").action(listAction2);
1773
2144
  connectorCommand.command("switch <name>").description("Switch to a different connector").action(switchAction2);
1774
2145
  connectorCommand.command("retry-setup <name>").description(
1775
2146
  "Retry branching engine setup for a connector that didn't finish (status: configuration_verified or validating)"
2147
+ ).option(
2148
+ "--replica-identity-decisions <path>",
2149
+ 'Path to a complete JSON file mapping every current "database.schema.table" preflight FQN -> "exclude" | "add_pk" | "replica_identity_full".'
2150
+ ).option(
2151
+ "--accept-replica-identity-defaults",
2152
+ "Non-interactively save the current/default decision for every table with no replication identity. Undecided tables are excluded from replication."
1776
2153
  ).action(retrySetupAction);
1777
2154
  connectorCommand.command("update <name>").description("Update a connector's configuration").option(
1778
2155
  "--drop-extensions <list>",
@@ -2264,7 +2641,7 @@ projectCommand.command("delete <name>").description("Delete a project by name").
2264
2641
  import { Command as Command6 } from "commander";
2265
2642
 
2266
2643
  // src/lib/settings.ts
2267
- import { readFileSync as readFileSync3 } from "fs";
2644
+ import { readFileSync as readFileSync4 } from "fs";
2268
2645
  var SETTING_KEYS = ["default_db", "branch_sql"];
2269
2646
  function requireConnectorAndOrg() {
2270
2647
  const connectorId = getConfig("currentConnectorId");
@@ -2314,7 +2691,7 @@ function resolveValueFromArg(value) {
2314
2691
  if (value.startsWith("@")) {
2315
2692
  const path = value.slice(1);
2316
2693
  try {
2317
- return readFileSync3(path, "utf-8");
2694
+ return readFileSync4(path, "utf-8");
2318
2695
  } catch (err) {
2319
2696
  throw new Error(
2320
2697
  `Failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -2615,7 +2992,7 @@ var logoutCommand = new Command7("logout").description("Logout from Ardent").act
2615
2992
  var statusCommand = new Command7("status").description("Show status").action(statusAction);
2616
2993
 
2617
2994
  // src/lib/update-check.ts
2618
- import { existsSync as existsSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
2995
+ import { existsSync as existsSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
2619
2996
  import { join as join2 } from "path";
2620
2997
  import { homedir as homedir2 } from "os";
2621
2998
  var UPDATE_CHECK_FILE = join2(homedir2(), ".ardent", "update-check.json");
@@ -2624,7 +3001,7 @@ var PACKAGE_NAME = "ardent-cli";
2624
3001
  function loadCache() {
2625
3002
  try {
2626
3003
  if (existsSync2(UPDATE_CHECK_FILE)) {
2627
- return JSON.parse(readFileSync4(UPDATE_CHECK_FILE, "utf-8"));
3004
+ return JSON.parse(readFileSync5(UPDATE_CHECK_FILE, "utf-8"));
2628
3005
  }
2629
3006
  } catch {
2630
3007
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ardent-cli",
3
- "version": "0.0.29",
3
+ "version": "0.0.30",
4
4
  "description": "Git for Data infrastructure",
5
5
  "type": "module",
6
6
  "bin": {