pm-csv 2026.10.4 → 2026.10.12
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/CHANGELOG.md +16 -0
- package/README.md +35 -16
- package/dist/atomic-update.d.ts +42 -0
- package/dist/atomic-update.d.ts.map +1 -0
- package/dist/atomic-update.js +182 -0
- package/dist/atomic-update.js.map +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +83 -42
- package/dist/index.js.map +1 -1
- package/docs/atomic-compensation.md +76 -0
- package/manifest.json +1 -1
- package/package.json +16 -11
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, createReadStream } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { certifyCompleteListResult, inspectCompleteListResult } from "@unbrained/pm-cli/sdk";
|
|
5
|
+
import { certifyCompleteListResult, inspectCompleteListResult, runWorkspaceTransactionGc } from "@unbrained/pm-cli/sdk";
|
|
6
|
+
import { captureUpdateCompensation, compensateUpdate, inspectUpdate, UPDATE_COMPENSATION_VERSION } from "./atomic-update.js";
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Error contract
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
@@ -1210,19 +1211,55 @@ export function atomicTransactionId(filePath, opts = {}) {
|
|
|
1210
1211
|
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, resolveDelimiter(opts.delimiter), opts.encoding ?? "utf-8", opts.skipHeaders ?? false);
|
|
1211
1212
|
return deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows));
|
|
1212
1213
|
}
|
|
1214
|
+
/** Bind a CSV step to its format, operation, target, and original row. */
|
|
1215
|
+
function atomicStepId(rowIndex, existingId) {
|
|
1216
|
+
const operation = existingId ? `update-${existingId}` : "create";
|
|
1217
|
+
return `csv-import-v${UPDATE_COMPENSATION_VERSION}-${operation}-row-${rowIndex}`;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Recover the original plan's update targets and create roles from SDK journal
|
|
1221
|
+
* step identities. Mutable CSV key tags cannot retarget an interrupted update
|
|
1222
|
+
* or turn a previously planned create into an update. Incompatible identities
|
|
1223
|
+
* refuse replay before any mutation; the SDK validates the remaining journal.
|
|
1224
|
+
* The public read-only inventory supplies the path, including after SDK layout
|
|
1225
|
+
* changes. Infinite retention and dry-run prevent recovery receipts being pruned.
|
|
1226
|
+
*/
|
|
1227
|
+
async function loadAtomicBindings(pmRoot, transactionId) {
|
|
1228
|
+
const bindings = new Map();
|
|
1229
|
+
const inventory = await runWorkspaceTransactionGc(pmRoot, { dryRun: true, retentionDays: Infinity });
|
|
1230
|
+
const entry = inventory.entries.find(candidate => basename(candidate.file) === `${transactionId}.json`);
|
|
1231
|
+
if (!entry)
|
|
1232
|
+
return bindings;
|
|
1233
|
+
const journal = JSON.parse(readFileSync(resolve(pmRoot, entry.file), "utf8"));
|
|
1234
|
+
if (journal === null || typeof journal !== "object" || Array.isArray(journal)
|
|
1235
|
+
|| !("stepIds" in journal) || !Array.isArray(journal.stepIds)
|
|
1236
|
+
|| !journal.stepIds.every((id) => typeof id === "string")) {
|
|
1237
|
+
throw new Error(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
|
|
1238
|
+
}
|
|
1239
|
+
const pattern = new RegExp(String.raw `^csv-import-v${UPDATE_COMPENSATION_VERSION}-(?:create|update-(.+))-row-(\d+)$`);
|
|
1240
|
+
for (const id of journal.stepIds) {
|
|
1241
|
+
const match = pattern.exec(id);
|
|
1242
|
+
if (!match)
|
|
1243
|
+
throw new Error(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
|
|
1244
|
+
bindings.set(Number(match[2]), match[1]);
|
|
1245
|
+
}
|
|
1246
|
+
return bindings;
|
|
1247
|
+
}
|
|
1213
1248
|
/**
|
|
1214
1249
|
* Load every item stamped with this transaction's per-row ownership marker
|
|
1215
1250
|
* (`csv-txrow:<transactionId>#<rowIndex>`) and build a single lookup the step
|
|
1216
1251
|
* `inspect()` uses to detect rows already applied by a prior (interrupted) run:
|
|
1217
1252
|
* - byRowIndex: rowIndex -> item id
|
|
1218
1253
|
*
|
|
1219
|
-
*
|
|
1254
|
+
* For creates, the per-row marker is the source of truth for resume: it makes
|
|
1220
1255
|
* matching per-row-precise, so a CSV with duplicate titles or duplicate keys
|
|
1221
1256
|
* can never trick inspect() into skipping the wrong row. The batch-level
|
|
1222
1257
|
* `csv-tx:<transactionId>` marker is also stamped (handy for scanning) but is
|
|
1223
1258
|
* NOT used for matching. Only items carrying a per-row marker for THIS
|
|
1224
1259
|
* transaction are included, so pre-existing items this transaction never
|
|
1225
|
-
* touched are never mistaken for already-applied steps.
|
|
1260
|
+
* touched are never mistaken for already-applied steps. Updates additionally
|
|
1261
|
+
* inspect immutable history and bind their targets through journal step ids,
|
|
1262
|
+
* so removing mutable tags cannot retarget an interrupted update.
|
|
1226
1263
|
*
|
|
1227
1264
|
* Presence of the row marker — NOT item status — is the applied signal. A row
|
|
1228
1265
|
* whose CSV `status` is `closed`/`canceled` is legitimately imported as a
|
|
@@ -1366,12 +1403,8 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
|
|
|
1366
1403
|
if (status === undefined)
|
|
1367
1404
|
return; // absent or unverified; never invent closure
|
|
1368
1405
|
if (status !== "closed") {
|
|
1369
|
-
// Compensation
|
|
1370
|
-
|
|
1371
|
-
// it bypasses `--validate-close` (off). `require_close_reason` still applies
|
|
1372
|
-
// and is satisfied by `reason`. Used by both the atomic transaction path
|
|
1373
|
-
// (rollback) and the non-atomic `upsertCreate` close-failure path.
|
|
1374
|
-
const closeResult = spawnSync("pm", ["--path", pmRoot, "close", id, "--reason", reason, "--validate-close", "off"], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1406
|
+
// Compensation records the rollback outcome through the normal close contract.
|
|
1407
|
+
const closeResult = spawnSync("pm", csvCloseArgs(pmRoot, id, reason, "The failed CSV import leaves no active created item.", "Closing the created item as CSV import compensation."), { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1375
1408
|
if (closeResult.status !== 0) {
|
|
1376
1409
|
// A non-zero receipt is not proof either way: the close may have persisted
|
|
1377
1410
|
// before its receipt failed. Re-read the item and clean markers only with
|
|
@@ -1397,8 +1430,8 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
|
|
|
1397
1430
|
* {@link ImportResult} as the non-atomic path is returned (counts derived from
|
|
1398
1431
|
* the committed step results). On failure the coordinator attempts reverse-
|
|
1399
1432
|
* order compensation, but callers receive a conservative reconciliation
|
|
1400
|
-
* warning because create cleanup is best-effort and
|
|
1401
|
-
*
|
|
1433
|
+
* warning because create cleanup is best-effort and concurrent writers can
|
|
1434
|
+
* prevent safe update compensation. An interrupted run resumes from the
|
|
1402
1435
|
* durable journal (inspect() skips already applied rows).
|
|
1403
1436
|
*/
|
|
1404
1437
|
async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
@@ -1427,6 +1460,7 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1427
1460
|
const rowTagFor = (rowIndex) => `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}${rowIndex}`;
|
|
1428
1461
|
// Detect rows a prior interrupted run already applied (resumability) plus
|
|
1429
1462
|
// the dedup index for --key upsert decisions, both from one fresh scan.
|
|
1463
|
+
const bindings = await loadAtomicBindings(pmRoot, transactionId);
|
|
1430
1464
|
const applied = loadAppliedByTransaction(pmRoot, transactionId);
|
|
1431
1465
|
const keyIndex = opts.keyField ? loadKeyIndex(pmRoot) : new Map();
|
|
1432
1466
|
// In-batch duplicate-KEY guard: keys already claimed by an earlier planned
|
|
@@ -1464,7 +1498,8 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1464
1498
|
result.skipped++;
|
|
1465
1499
|
continue;
|
|
1466
1500
|
}
|
|
1467
|
-
const existingId =
|
|
1501
|
+
const existingId = bindings.has(rowIndex) ? bindings.get(rowIndex)
|
|
1502
|
+
: normalizedKey ? keyIndex.get(normalizedKey) : undefined;
|
|
1468
1503
|
if (normalizedKey && !existingId)
|
|
1469
1504
|
claimedKeys.set(normalizedKey, rowIndex);
|
|
1470
1505
|
planned.push({ rowIndex, lineNo, parsed, keyValue, existingId, isUpdate: Boolean(existingId) });
|
|
@@ -1474,9 +1509,8 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1474
1509
|
// path (parity) plus the csv-tx batch marker AND the per-row marker;
|
|
1475
1510
|
// compensate() closes the created id.
|
|
1476
1511
|
// - update (--key match): apply() updates the pre-existing item and stamps
|
|
1477
|
-
// both markers;
|
|
1478
|
-
//
|
|
1479
|
-
// compensation is therefore scoped to creates).
|
|
1512
|
+
// both markers; prepareCompensation captures prior state before apply;
|
|
1513
|
+
// compensate restores it under the item lock after a history check.
|
|
1480
1514
|
//
|
|
1481
1515
|
// Resume/compensation matching is per-row-precise via the per-row marker
|
|
1482
1516
|
// (csv-txrow:<transactionId>#<rowIndex>), so duplicate titles or duplicate
|
|
@@ -1488,11 +1522,14 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1488
1522
|
// left over from a prior interrupted run (which the pre-run `applied` lookup
|
|
1489
1523
|
// already detects).
|
|
1490
1524
|
const steps = planned.map((row) => {
|
|
1491
|
-
const stepId =
|
|
1525
|
+
const stepId = atomicStepId(row.rowIndex, row.existingId);
|
|
1492
1526
|
const rowTag = rowTagFor(row.rowIndex);
|
|
1493
1527
|
// Set by apply() in this run; undefined before that or on a fresh resume.
|
|
1494
1528
|
let appliedId;
|
|
1495
1529
|
const inspect = async () => {
|
|
1530
|
+
if (row.isUpdate) {
|
|
1531
|
+
return inspectUpdate(pmRoot, row.existingId, rowTag, row.parsed.status === "closed" || row.parsed.status === "canceled");
|
|
1532
|
+
}
|
|
1496
1533
|
// A step applied earlier in THIS run (apply() captured the id).
|
|
1497
1534
|
if (appliedId)
|
|
1498
1535
|
return { state: "applied", result: appliedId };
|
|
@@ -1507,7 +1544,7 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1507
1544
|
const apply = async () => {
|
|
1508
1545
|
const ownershipTags = [ownershipTag, rowTag];
|
|
1509
1546
|
if (row.isUpdate) {
|
|
1510
|
-
upsertUpdate(pmRoot, row.existingId, row.parsed, opts.source, ownershipTags);
|
|
1547
|
+
upsertUpdate(pmRoot, row.existingId, row.parsed, opts.source, ownershipTags, `${rowTag}:apply`);
|
|
1511
1548
|
appliedId = row.existingId;
|
|
1512
1549
|
return row.existingId;
|
|
1513
1550
|
}
|
|
@@ -1515,12 +1552,12 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1515
1552
|
appliedId = newId;
|
|
1516
1553
|
return newId;
|
|
1517
1554
|
};
|
|
1518
|
-
const prepareCompensation = async () => undefined;
|
|
1519
|
-
const compensate = async () => {
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
if (row.isUpdate)
|
|
1555
|
+
const prepareCompensation = async () => row.isUpdate ? captureUpdateCompensation(pmRoot, row.existingId, rowTag, author) : undefined;
|
|
1556
|
+
const compensate = async (data) => {
|
|
1557
|
+
if (row.isUpdate) {
|
|
1558
|
+
await compensateUpdate(pmRoot, row.existingId, rowTag, author, data);
|
|
1523
1559
|
return;
|
|
1560
|
+
}
|
|
1524
1561
|
// Prefer the id captured by apply() in this run; fall back to the
|
|
1525
1562
|
// pre-run per-row lookup (resume-compensation of a prior run's applied step).
|
|
1526
1563
|
const id = appliedId ?? applied.byRowIndex.get(row.rowIndex);
|
|
@@ -1540,8 +1577,8 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1540
1577
|
// The coordinator invokes compensations in reverse order, but this package
|
|
1541
1578
|
// cannot prove a clean tracker: create cleanup deliberately tolerates an
|
|
1542
1579
|
// unavailable status lookup or failed close so the remaining sweep can
|
|
1543
|
-
// continue, and update
|
|
1544
|
-
throw new CommandError(`Atomic CSV import failed. The transaction coordinator attempted reverse-order compensation, but pm-csv cannot claim a clean tracker: created items are closed on a best-effort basis, and pre-existing
|
|
1580
|
+
// continue, and update compensation refuses concurrent-writer conflicts.
|
|
1581
|
+
throw new CommandError(`Atomic CSV import failed. The transaction coordinator attempted reverse-order compensation, but pm-csv cannot claim a clean tracker: created items are closed on a best-effort basis, and pre-existing updates restore captured prior values unless a conflict prevents safe compensation. Mutations from transaction ${transactionId} may remain; inspect and reconcile that transaction before retrying. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
|
|
1545
1582
|
}
|
|
1546
1583
|
// Tally the ImportResult from the committed step results. Each committed
|
|
1547
1584
|
// result is the created/updated item id. Only rows that were NOT already
|
|
@@ -1549,7 +1586,7 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1549
1586
|
// count toward imported/updated — resumed rows from a prior interrupted run
|
|
1550
1587
|
// are already in the tracker and must not be double-counted.
|
|
1551
1588
|
for (const row of planned) {
|
|
1552
|
-
const stepId =
|
|
1589
|
+
const stepId = atomicStepId(row.rowIndex, row.existingId);
|
|
1553
1590
|
const res = committed.results[stepId];
|
|
1554
1591
|
if (res === undefined)
|
|
1555
1592
|
continue;
|
|
@@ -1635,6 +1672,11 @@ function appendMutableItemArgs(args, p, preserveEmptyBody) {
|
|
|
1635
1672
|
args.push("--deadline", p.deadline);
|
|
1636
1673
|
appendRelationalArgs(args, p);
|
|
1637
1674
|
}
|
|
1675
|
+
/** Build a normal validated close with factual CSV provenance and outcome fields. */
|
|
1676
|
+
function csvCloseArgs(pmRoot, id, reason, expected, actual) {
|
|
1677
|
+
return ["--path", pmRoot, "close", id, "--reason", reason,
|
|
1678
|
+
"--resolution", reason, "--expected-result", expected, "--actual-result", actual];
|
|
1679
|
+
}
|
|
1638
1680
|
/**
|
|
1639
1681
|
* Build the `pm close --reason` text for an imported row whose CSV status is
|
|
1640
1682
|
* terminal (`closed`/`canceled`). Since pm-cli 2026.8.3 the CLI enforces
|
|
@@ -1671,8 +1713,8 @@ function importCloseReason(status, source) {
|
|
|
1671
1713
|
* operator can find and reconcile the open orphan manually. The created
|
|
1672
1714
|
* open item is left behind in this one unrecoverable case.
|
|
1673
1715
|
* 2. `pm close` failed after the item was persisted as open. The orphan is
|
|
1674
|
-
* compensated (closed) via {@link compensateCreate} with
|
|
1675
|
-
*
|
|
1716
|
+
* compensated (closed) via {@link compensateCreate} with explicit rollback
|
|
1717
|
+
* outcome fields, so a verified cleanup leaves no open orphan and a retry re-imports
|
|
1676
1718
|
* it. If
|
|
1677
1719
|
* compensation also fails, the thrown error carries the created id and
|
|
1678
1720
|
* states the item was left open, so the partial state is actionable and a
|
|
@@ -1745,7 +1787,7 @@ function upsertCreate(pmRoot, p, keyValue, source, extraTags) {
|
|
|
1745
1787
|
// and the real resulting state (closed, or still-open with the close's
|
|
1746
1788
|
// failure cause), so the operator never stops looking for an open orphan.
|
|
1747
1789
|
if (id) {
|
|
1748
|
-
const rc = spawnSync("pm",
|
|
1790
|
+
const rc = spawnSync("pm", csvCloseArgs(pmRoot, id, "csv import create overran its receipt buffer; closing the orphaned created item", "The failed CSV create leaves no active orphan.", "Closing the identified created item after its receipt failed."), { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1749
1791
|
const createCause = describePmNullStatus(r, "create");
|
|
1750
1792
|
if (rc.status === 0) {
|
|
1751
1793
|
throw new CommandError(`${createCause}. The create for row (title '${p.title}', id ${id}) may have persisted; the orphaned item ${id} was closed — retry the import.`);
|
|
@@ -1768,7 +1810,7 @@ function upsertCreate(pmRoot, p, keyValue, source, extraTags) {
|
|
|
1768
1810
|
throw new Error(`pm create succeeded for row (title '${p.title}', source status '${p.status}') but returned no id, so the source status cannot be applied and the created item cannot be identified or safely reconciled; it may have been left as an orphan — reconcile manually`);
|
|
1769
1811
|
}
|
|
1770
1812
|
if (p.status === "closed") {
|
|
1771
|
-
const cr = spawnSync("pm",
|
|
1813
|
+
const cr = spawnSync("pm", csvCloseArgs(pmRoot, id, importCloseReason("closed", source), "Persist the imported CSV row with source status closed.", "CSV fields imported; applying the source status closed."), { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1772
1814
|
// Deliberately NO assertPmOutputFit(cr, "close") here: a null close status
|
|
1773
1815
|
// (receipt overrun or a signal kill) must route INTO the compensation branch
|
|
1774
1816
|
// below rather than bypass it. The close may have persisted before its
|
|
@@ -1778,8 +1820,7 @@ function upsertCreate(pmRoot, p, keyValue, source, extraTags) {
|
|
|
1778
1820
|
// (2) The close failed but the item is already persisted as open.
|
|
1779
1821
|
// Compensate (close) the orphan so a verified cleanup leaves no open item
|
|
1780
1822
|
// that a retry without --key-field could duplicate.
|
|
1781
|
-
// Compensation
|
|
1782
|
-
// not a user closure) so a strict tracker cannot block the cleanup.
|
|
1823
|
+
// Compensation supplies explicit rollback outcome fields for strict closure.
|
|
1783
1824
|
compensateCreate(pmRoot, id, [], "csv import row failed; closing orphan created item");
|
|
1784
1825
|
const closeErr = cr.stderr?.trim() || cr.stdout?.trim() || "pm close failed";
|
|
1785
1826
|
// Only a status of exactly `closed` is evidence that compensation worked.
|
|
@@ -1815,13 +1856,14 @@ function upsertCreate(pmRoot, p, keyValue, source, extraTags) {
|
|
|
1815
1856
|
* carries the item id and states explicitly that the mutation may already have
|
|
1816
1857
|
* been applied, so the operator can verify and reconcile by id. The guarantee
|
|
1817
1858
|
* is NOT that no mutation is left behind — an update has no inverse without the
|
|
1818
|
-
* prior field values
|
|
1819
|
-
*
|
|
1820
|
-
* write on top of a failed one (see the deferred capture-prior-values feature).
|
|
1859
|
+
* prior field values. Atomic imports journal those values before calling this
|
|
1860
|
+
* helper and compensate from them; non-atomic imports report failures by id.
|
|
1821
1861
|
*/
|
|
1822
|
-
function upsertUpdate(pmRoot, id, p, source, extraTags) {
|
|
1862
|
+
function upsertUpdate(pmRoot, id, p, source, extraTags, historyMessage) {
|
|
1823
1863
|
const args = ["--path", pmRoot, "update", id, "--title", p.title];
|
|
1824
1864
|
appendMutableItemArgs(args, p, true);
|
|
1865
|
+
if (historyMessage)
|
|
1866
|
+
args.push("--message", historyMessage);
|
|
1825
1867
|
// Preserve the csv-key tag (additive) and refresh the user tags.
|
|
1826
1868
|
const addTags = [...p.tags];
|
|
1827
1869
|
if (source)
|
|
@@ -1839,9 +1881,8 @@ function upsertUpdate(pmRoot, id, p, source, extraTags) {
|
|
|
1839
1881
|
// failure must carry the item id and state that explicitly — matching the
|
|
1840
1882
|
// create path. The guarantee is that no mutation is left un-IDENTIFIED, NOT
|
|
1841
1883
|
// that no mutation is left behind: an update has no inverse without the prior
|
|
1842
|
-
// field values
|
|
1843
|
-
//
|
|
1844
|
-
// write on top of a failed one.
|
|
1884
|
+
// field values. The atomic coordinator journals those before calling this
|
|
1885
|
+
// helper; a non-atomic update only reports its failure by id.
|
|
1845
1886
|
if (r.status === null) {
|
|
1846
1887
|
throw new CommandError(`${describePmNullStatus(r, "update")}. The update for item ${id} may already have been applied — verify the item by id before retrying.`);
|
|
1847
1888
|
}
|
|
@@ -1854,7 +1895,7 @@ function upsertUpdate(pmRoot, id, p, source, extraTags) {
|
|
|
1854
1895
|
// and the explicit caveat that the terminal transition may already have taken
|
|
1855
1896
|
// effect too — the close, like the update, is a single non-atomic subprocess.
|
|
1856
1897
|
if (p.status === "closed" || p.status === "canceled") {
|
|
1857
|
-
const cr = spawnSync("pm", [
|
|
1898
|
+
const cr = spawnSync("pm", [...csvCloseArgs(pmRoot, id, importCloseReason(p.status, source), `Persist the imported CSV update with source status ${p.status}.`, `CSV fields updated; applying the source status ${p.status}.`), ...(historyMessage ? ["--message", historyMessage.replace(/:apply$/, ":close")] : [])], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1858
1899
|
if (cr.status === null) {
|
|
1859
1900
|
throw new CommandError(`${describePmNullStatus(cr, "close")}. The update for item ${id} was applied, but the terminal ${p.status} close may already have been applied too — verify the item by id before retrying.`);
|
|
1860
1901
|
}
|
|
@@ -2282,7 +2323,7 @@ function prepareCsvExport(pmRoot, rawOptions) {
|
|
|
2282
2323
|
}
|
|
2283
2324
|
export default {
|
|
2284
2325
|
name: "pm-csv",
|
|
2285
|
-
version: "2026.10.
|
|
2326
|
+
version: "2026.10.12",
|
|
2286
2327
|
activate(api) {
|
|
2287
2328
|
// -----------------------------------------------------------------------
|
|
2288
2329
|
// Schema: register an optional `csv_source` provenance field so imported
|
|
@@ -2340,7 +2381,7 @@ export default {
|
|
|
2340
2381
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
2341
2382
|
{ long: "--skip-headers", description: "The CSV file has no header row; map columns positionally to the standard import order (title, type, status, priority, tags, deadline, body, parent, assignee, sprint, release, blocked_by)" },
|
|
2342
2383
|
{ long: "--stream", description: "Stream the file row-by-row instead of loading it fully into memory (recommended for large CSV files)" },
|
|
2343
|
-
{ long: "--atomic", description: "Use one writer-locked, crash-recoverable transaction. On failure, attempt best-effort create compensation; pre-existing
|
|
2384
|
+
{ long: "--atomic", description: "Use one writer-locked, crash-recoverable transaction. On failure, attempt best-effort create compensation; pre-existing updates restore captured prior values unless a conflict prevents safe compensation. Inspect and reconcile any marked orphan before retrying. Incompatible with --stream" },
|
|
2344
2385
|
],
|
|
2345
2386
|
async run(ctx) {
|
|
2346
2387
|
const filePath = ctx.args[0];
|