ardent-cli 0.0.28 → 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.
- package/dist/index.js +602 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -343,6 +343,15 @@ var ApiClient = class {
|
|
|
343
343
|
});
|
|
344
344
|
return this.handleResponse(response);
|
|
345
345
|
}
|
|
346
|
+
async put(path, body) {
|
|
347
|
+
const url = `${getApiUrl()}${path}`;
|
|
348
|
+
const response = await fetch(url, {
|
|
349
|
+
method: "PUT",
|
|
350
|
+
headers: this.getHeaders(),
|
|
351
|
+
body: JSON.stringify(body)
|
|
352
|
+
});
|
|
353
|
+
return this.handleResponse(response);
|
|
354
|
+
}
|
|
346
355
|
};
|
|
347
356
|
var api = new ApiClient();
|
|
348
357
|
function isNetworkError(err) {
|
|
@@ -987,6 +996,395 @@ Example:
|
|
|
987
996
|
}
|
|
988
997
|
}
|
|
989
998
|
|
|
999
|
+
// src/lib/prompt.ts
|
|
1000
|
+
import { createInterface } from "readline/promises";
|
|
1001
|
+
function isTtyInteractive() {
|
|
1002
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1003
|
+
}
|
|
1004
|
+
async function readOneLine(question) {
|
|
1005
|
+
const rl = createInterface({
|
|
1006
|
+
input: process.stdin,
|
|
1007
|
+
output: process.stdout
|
|
1008
|
+
});
|
|
1009
|
+
try {
|
|
1010
|
+
const answer = await rl.question(question);
|
|
1011
|
+
return answer.trim();
|
|
1012
|
+
} finally {
|
|
1013
|
+
rl.close();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async function confirm(question, options = {}) {
|
|
1017
|
+
if (!isTtyInteractive()) {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
const defaultYes = options.defaultYes ?? true;
|
|
1021
|
+
const suffix = defaultYes ? "[Y/n] " : "[y/N] ";
|
|
1022
|
+
const answer = (await readOneLine(`${question} ${suffix}`)).toLowerCase();
|
|
1023
|
+
if (answer === "") return defaultYes;
|
|
1024
|
+
if (answer === "y" || answer === "yes") return true;
|
|
1025
|
+
if (answer === "n" || answer === "no") return false;
|
|
1026
|
+
const retry = (await readOneLine(`Please answer yes or no. ${suffix}`)).toLowerCase();
|
|
1027
|
+
if (retry === "") return defaultYes;
|
|
1028
|
+
if (retry === "y" || retry === "yes") return true;
|
|
1029
|
+
if (retry === "n" || retry === "no") return false;
|
|
1030
|
+
console.log('Unrecognised input; treating as "no".');
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
async function multiSelect(items, promptForItem, options = {}) {
|
|
1034
|
+
if (!isTtyInteractive()) {
|
|
1035
|
+
return [];
|
|
1036
|
+
}
|
|
1037
|
+
const accepted = [];
|
|
1038
|
+
for (const item of items) {
|
|
1039
|
+
const yes = await confirm(promptForItem(item), { defaultYes: options.defaultYes });
|
|
1040
|
+
if (yes) accepted.push(item);
|
|
1041
|
+
}
|
|
1042
|
+
return accepted;
|
|
1043
|
+
}
|
|
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
|
+
|
|
990
1388
|
// src/commands/connector/create.ts
|
|
991
1389
|
function parsePostgresUrl(url) {
|
|
992
1390
|
const atIndex = url.lastIndexOf("@");
|
|
@@ -1007,6 +1405,57 @@ function printEngineSetupRecoveryHint(connectorName) {
|
|
|
1007
1405
|
console.error(" Inspect: ardent connector list");
|
|
1008
1406
|
console.error(` Retry: ardent connector retry-setup ${connectorName}`);
|
|
1009
1407
|
}
|
|
1408
|
+
async function promptForUnsupportedExtensions(connectorId, unsupported, alreadyPersisted) {
|
|
1409
|
+
function mergeAllowlist(newlyAccepted) {
|
|
1410
|
+
return Array.from(/* @__PURE__ */ new Set([...alreadyPersisted, ...newlyAccepted])).sort();
|
|
1411
|
+
}
|
|
1412
|
+
console.log("");
|
|
1413
|
+
if (unsupported.length === 1) {
|
|
1414
|
+
const ext = unsupported[0];
|
|
1415
|
+
console.log(
|
|
1416
|
+
`Found 1 extension on your source database that isn't supported on branches:`
|
|
1417
|
+
);
|
|
1418
|
+
console.log(` \u2022 ${ext}`);
|
|
1419
|
+
console.log("");
|
|
1420
|
+
console.log(
|
|
1421
|
+
"Branches from this connector will fail unless this extension is excluded."
|
|
1422
|
+
);
|
|
1423
|
+
const yes = await confirm(`Branch without ${ext}?`, { defaultYes: true });
|
|
1424
|
+
if (!yes) return "aborted";
|
|
1425
|
+
const merged2 = mergeAllowlist([ext]);
|
|
1426
|
+
await api.put(`/v1/connectors/${connectorId}`, {
|
|
1427
|
+
drop_extensions: merged2
|
|
1428
|
+
});
|
|
1429
|
+
console.log(`\u2713 Saved drop selection: ${merged2.join(", ")}`);
|
|
1430
|
+
return "applied";
|
|
1431
|
+
}
|
|
1432
|
+
console.log(
|
|
1433
|
+
`Found ${unsupported.length} extensions on your source database that aren't supported on branches:`
|
|
1434
|
+
);
|
|
1435
|
+
for (const ext of unsupported) {
|
|
1436
|
+
console.log(` \u2022 ${ext}`);
|
|
1437
|
+
}
|
|
1438
|
+
console.log("");
|
|
1439
|
+
console.log(
|
|
1440
|
+
"Pick which extensions to exclude from branches. Branches from this connector"
|
|
1441
|
+
);
|
|
1442
|
+
console.log(
|
|
1443
|
+
"will fail unless every unsupported extension you keep is configured by support."
|
|
1444
|
+
);
|
|
1445
|
+
console.log("");
|
|
1446
|
+
const accepted = await multiSelect(
|
|
1447
|
+
unsupported,
|
|
1448
|
+
(ext) => `Branch without ${ext}?`,
|
|
1449
|
+
{ defaultYes: true }
|
|
1450
|
+
);
|
|
1451
|
+
if (accepted.length === 0) return "aborted";
|
|
1452
|
+
const merged = mergeAllowlist(accepted);
|
|
1453
|
+
await api.put(`/v1/connectors/${connectorId}`, {
|
|
1454
|
+
drop_extensions: merged
|
|
1455
|
+
});
|
|
1456
|
+
console.log(`\u2713 Saved drop selection: ${merged.join(", ")}`);
|
|
1457
|
+
return "applied";
|
|
1458
|
+
}
|
|
1010
1459
|
async function createAction2(type, url, options) {
|
|
1011
1460
|
const supportedTypes = ["postgresql"];
|
|
1012
1461
|
if (!supportedTypes.includes(type.toLowerCase())) {
|
|
@@ -1114,7 +1563,15 @@ async function createAction2(type, url, options) {
|
|
|
1114
1563
|
}
|
|
1115
1564
|
const created = await api.post("/v1/connectors", createPayload);
|
|
1116
1565
|
const connectorId = created.id;
|
|
1566
|
+
const replicaIdentityPreflightOptions = {
|
|
1567
|
+
replicaIdentityDecisions: options.replicaIdentityDecisions,
|
|
1568
|
+
acceptReplicaIdentityDefaults: options.acceptReplicaIdentityDefaults
|
|
1569
|
+
};
|
|
1117
1570
|
if (isByoc) {
|
|
1571
|
+
await handleReplicaIdentityPreflight(
|
|
1572
|
+
connectorId,
|
|
1573
|
+
replicaIdentityPreflightOptions
|
|
1574
|
+
);
|
|
1118
1575
|
console.log("Setting up branching engine...");
|
|
1119
1576
|
try {
|
|
1120
1577
|
await runEngineSetupWithPolling(connectorId, connectorName);
|
|
@@ -1145,7 +1602,41 @@ async function createAction2(type, url, options) {
|
|
|
1145
1602
|
selected_paths: ["*"]
|
|
1146
1603
|
});
|
|
1147
1604
|
const connector = await api.get(`/v1/connectors/${connectorId}`);
|
|
1605
|
+
const preview = connector.unsupported_extensions_preview ?? null;
|
|
1606
|
+
const unsupportedExtensions = preview?.unsupported ?? [];
|
|
1607
|
+
const dropExtensionsPersisted = preview?.drop_extensions_persisted ?? [];
|
|
1608
|
+
if (unsupportedExtensions.length > 0) {
|
|
1609
|
+
const promptOutcome = await promptForUnsupportedExtensions(
|
|
1610
|
+
connectorId,
|
|
1611
|
+
unsupportedExtensions,
|
|
1612
|
+
dropExtensionsPersisted
|
|
1613
|
+
);
|
|
1614
|
+
if (promptOutcome === "aborted") {
|
|
1615
|
+
trackEvent("CLI: connector create aborted", {
|
|
1616
|
+
reason: "declined_extension_drops",
|
|
1617
|
+
unsupported_count: unsupportedExtensions.length
|
|
1618
|
+
});
|
|
1619
|
+
console.error(
|
|
1620
|
+
"\u2717 Connector setup aborted because no extension drop selection was made."
|
|
1621
|
+
);
|
|
1622
|
+
console.error(
|
|
1623
|
+
" Branches cannot be created until the connector has a drop selection."
|
|
1624
|
+
);
|
|
1625
|
+
console.error(" Run this when ready:");
|
|
1626
|
+
console.error(
|
|
1627
|
+
` ardent connector update ${connectorName} --drop-extensions <ext,...>`
|
|
1628
|
+
);
|
|
1629
|
+
console.error(
|
|
1630
|
+
` ardent connector retry-setup ${connectorName}`
|
|
1631
|
+
);
|
|
1632
|
+
process.exit(1);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1148
1635
|
if (connector.branching_engine_status === "configuration_verified") {
|
|
1636
|
+
await handleReplicaIdentityPreflight(
|
|
1637
|
+
connectorId,
|
|
1638
|
+
replicaIdentityPreflightOptions
|
|
1639
|
+
);
|
|
1149
1640
|
console.log("Setting up branching engine...");
|
|
1150
1641
|
try {
|
|
1151
1642
|
await runEngineSetupWithPolling(connectorId, connectorName);
|
|
@@ -1404,7 +1895,7 @@ function connectorRetrySetupFailureTelemetry(err) {
|
|
|
1404
1895
|
}
|
|
1405
1896
|
return { reason: "api_error" };
|
|
1406
1897
|
}
|
|
1407
|
-
async function retrySetupAction(name) {
|
|
1898
|
+
async function retrySetupAction(name, options = {}) {
|
|
1408
1899
|
const currentProjectId = getConfig("currentProjectId");
|
|
1409
1900
|
if (!currentProjectId) {
|
|
1410
1901
|
console.error("\u2717 No current project set. Switch to a project first:");
|
|
@@ -1438,6 +1929,16 @@ async function retrySetupAction(name) {
|
|
|
1438
1929
|
connector_id: connector.id,
|
|
1439
1930
|
starting_engine_status: connector.branching_engine_status ?? null
|
|
1440
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
|
+
);
|
|
1441
1942
|
console.log(`Setting up branching engine for ${name}...`);
|
|
1442
1943
|
try {
|
|
1443
1944
|
const { dispatched } = await runEngineSetupWithPolling(connector.id, name);
|
|
@@ -1535,6 +2036,86 @@ async function switchAction2(name) {
|
|
|
1535
2036
|
trackEvent("CLI: connector switch");
|
|
1536
2037
|
}
|
|
1537
2038
|
|
|
2039
|
+
// src/lib/drop_extensions.ts
|
|
2040
|
+
function parseDropExtensions(raw) {
|
|
2041
|
+
if (raw === void 0) return null;
|
|
2042
|
+
if (raw.trim() === "") return [];
|
|
2043
|
+
return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// src/commands/connector/update.ts
|
|
2047
|
+
async function resolveConnectorByName(name) {
|
|
2048
|
+
const cached = getCacheEntry("connectors");
|
|
2049
|
+
let connector = cached?.data.find((c) => c.name === name);
|
|
2050
|
+
if (!connector) {
|
|
2051
|
+
try {
|
|
2052
|
+
const result = await api.get(
|
|
2053
|
+
"/v1/cli/connectors"
|
|
2054
|
+
);
|
|
2055
|
+
if (result.connectors) {
|
|
2056
|
+
setCacheEntry("connectors", result.connectors);
|
|
2057
|
+
connector = result.connectors.find((c) => c.name === name);
|
|
2058
|
+
}
|
|
2059
|
+
} catch (err) {
|
|
2060
|
+
if (isNetworkError(err)) {
|
|
2061
|
+
console.error("\u2717 Connector not found in cache and offline");
|
|
2062
|
+
process.exit(1);
|
|
2063
|
+
}
|
|
2064
|
+
throw err;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
if (!connector) {
|
|
2068
|
+
console.error(`\u2717 Connector "${name}" not found`);
|
|
2069
|
+
console.log(" Run: ardent connector list");
|
|
2070
|
+
process.exit(1);
|
|
2071
|
+
}
|
|
2072
|
+
return connector;
|
|
2073
|
+
}
|
|
2074
|
+
async function updateAction(name, options = {}) {
|
|
2075
|
+
const dropExtensions = parseDropExtensions(options.dropExtensions);
|
|
2076
|
+
if (dropExtensions === null) {
|
|
2077
|
+
console.error("\u2717 Nothing to update.");
|
|
2078
|
+
console.error(" Try: ardent connector update <name> --drop-extensions <ext,...>");
|
|
2079
|
+
process.exit(1);
|
|
2080
|
+
}
|
|
2081
|
+
const connector = await resolveConnectorByName(name);
|
|
2082
|
+
try {
|
|
2083
|
+
const payload = { drop_extensions: dropExtensions };
|
|
2084
|
+
await api.put(`/v1/connectors/${connector.id}`, payload);
|
|
2085
|
+
trackEvent("CLI: connector update succeeded", {
|
|
2086
|
+
drop_extensions_count: dropExtensions.length
|
|
2087
|
+
});
|
|
2088
|
+
if (dropExtensions.length === 0) {
|
|
2089
|
+
console.log(`\u2713 Cleared extension drop allowlist on '${name}'`);
|
|
2090
|
+
} else {
|
|
2091
|
+
console.log(
|
|
2092
|
+
`\u2713 Updated extension drop allowlist on '${name}': ${dropExtensions.join(", ")}`
|
|
2093
|
+
);
|
|
2094
|
+
console.log(
|
|
2095
|
+
" These extensions will be omitted from future branches created from this connector."
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
if (isPermissionError(err)) {
|
|
2100
|
+
trackEvent("CLI: connector update failed", { reason: "permission_denied" });
|
|
2101
|
+
console.error("\u2717 You don't have permission to update this connector.");
|
|
2102
|
+
console.error("");
|
|
2103
|
+
console.error(" Ask your organization admin to either:");
|
|
2104
|
+
console.error(" \u2022 Update the connector for you, or");
|
|
2105
|
+
console.error(" \u2022 Upgrade your role to Admin");
|
|
2106
|
+
process.exit(1);
|
|
2107
|
+
}
|
|
2108
|
+
if (isNetworkError(err)) {
|
|
2109
|
+
trackEvent("CLI: connector update failed", { reason: "offline" });
|
|
2110
|
+
console.error("\u2717 Cannot update connector while offline");
|
|
2111
|
+
process.exit(1);
|
|
2112
|
+
}
|
|
2113
|
+
trackEvent("CLI: connector update failed", { reason: "api_error" });
|
|
2114
|
+
console.error("\u2717 Failed:", err instanceof Error ? err.message : err);
|
|
2115
|
+
process.exit(1);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
1538
2119
|
// src/commands/connector/index.ts
|
|
1539
2120
|
var connectorCommand = new Command2("connector").description("Manage database connectors");
|
|
1540
2121
|
connectorCommand.command("create <type> [url]").description("Create a new connector").option("-n, --name <name>", "Connector name").option("--byoc <provider>", "Bring your own Neon project (e.g. neon)").option("--api-key <key>", "Neon API key (required with --byoc)").option("--project-id <id>", "Neon project ID (required with --byoc)").option(
|
|
@@ -1552,12 +2133,28 @@ connectorCommand.command("create <type> [url]").description("Create a new connec
|
|
|
1552
2133
|
).option(
|
|
1553
2134
|
"--aws-cluster-name <name>",
|
|
1554
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."
|
|
1555
2142
|
).action(createAction2);
|
|
1556
2143
|
connectorCommand.command("list").description("List your connectors").action(listAction2);
|
|
1557
2144
|
connectorCommand.command("switch <name>").description("Switch to a different connector").action(switchAction2);
|
|
1558
2145
|
connectorCommand.command("retry-setup <name>").description(
|
|
1559
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."
|
|
1560
2153
|
).action(retrySetupAction);
|
|
2154
|
+
connectorCommand.command("update <name>").description("Update a connector's configuration").option(
|
|
2155
|
+
"--drop-extensions <list>",
|
|
2156
|
+
"Comma-separated list of source extensions to drop on branches (e.g. pgmq,supabase_vault). Pass an empty string to clear the allowlist."
|
|
2157
|
+
).action(updateAction);
|
|
1561
2158
|
connectorCommand.command("delete <name>").description("Delete a connector by name").option(
|
|
1562
2159
|
"--force",
|
|
1563
2160
|
"Skip the in-flight WAL/Kafka drain wait. Any un-replicated changes are abandoned and recorded for operator triage."
|
|
@@ -2044,7 +2641,7 @@ projectCommand.command("delete <name>").description("Delete a project by name").
|
|
|
2044
2641
|
import { Command as Command6 } from "commander";
|
|
2045
2642
|
|
|
2046
2643
|
// src/lib/settings.ts
|
|
2047
|
-
import { readFileSync as
|
|
2644
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
2048
2645
|
var SETTING_KEYS = ["default_db", "branch_sql"];
|
|
2049
2646
|
function requireConnectorAndOrg() {
|
|
2050
2647
|
const connectorId = getConfig("currentConnectorId");
|
|
@@ -2094,7 +2691,7 @@ function resolveValueFromArg(value) {
|
|
|
2094
2691
|
if (value.startsWith("@")) {
|
|
2095
2692
|
const path = value.slice(1);
|
|
2096
2693
|
try {
|
|
2097
|
-
return
|
|
2694
|
+
return readFileSync4(path, "utf-8");
|
|
2098
2695
|
} catch (err) {
|
|
2099
2696
|
throw new Error(
|
|
2100
2697
|
`Failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -2395,7 +2992,7 @@ var logoutCommand = new Command7("logout").description("Logout from Ardent").act
|
|
|
2395
2992
|
var statusCommand = new Command7("status").description("Show status").action(statusAction);
|
|
2396
2993
|
|
|
2397
2994
|
// src/lib/update-check.ts
|
|
2398
|
-
import { existsSync as existsSync2, readFileSync as
|
|
2995
|
+
import { existsSync as existsSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
2399
2996
|
import { join as join2 } from "path";
|
|
2400
2997
|
import { homedir as homedir2 } from "os";
|
|
2401
2998
|
var UPDATE_CHECK_FILE = join2(homedir2(), ".ardent", "update-check.json");
|
|
@@ -2404,7 +3001,7 @@ var PACKAGE_NAME = "ardent-cli";
|
|
|
2404
3001
|
function loadCache() {
|
|
2405
3002
|
try {
|
|
2406
3003
|
if (existsSync2(UPDATE_CHECK_FILE)) {
|
|
2407
|
-
return JSON.parse(
|
|
3004
|
+
return JSON.parse(readFileSync5(UPDATE_CHECK_FILE, "utf-8"));
|
|
2408
3005
|
}
|
|
2409
3006
|
} catch {
|
|
2410
3007
|
}
|