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/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
- * The per-row marker is the source of truth for resume/compensation: it makes
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 is an internal rollback, not a user closure: it must
1370
- // reliably undo the create regardless of closure-validation governance, so
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 updates to pre-existing
1401
- * items are intentionally not reverted. An interrupted run resumes from the
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 = normalizedKey ? keyIndex.get(normalizedKey) : undefined;
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; compensate() is a no-op (an arbitrary update cannot be
1478
- // safely reverted without capturing prior state; best-effort
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 = `csv-import-row-${row.rowIndex}`;
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
- // Only creates are compensated (closed); updates to pre-existing items
1521
- // are intentionally left in place (documented limitation).
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 steps intentionally preserve pre-existing items.
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 item updates are intentionally not reverted. Mutations from transaction ${transactionId} may remain; inspect and reconcile that transaction before retrying. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
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 = `csv-import-row-${row.rowIndex}`;
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 `--validate-close
1675
- * off`, so a verified cleanup leaves no open orphan and a retry re-imports
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", ["--path", pmRoot, "close", id, "--validate-close", "off", "--reason", "csv import create overran its receipt buffer; closing the orphaned created item"], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
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", ["--path", pmRoot, "close", id, "--reason", importCloseReason("closed", source)], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
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 bypasses closure-validation governance (it is a rollback,
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, which this extension does not capture, so a failed update
1819
- * is reported loudly by id rather than compensated with a second unreviewed
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, which this extension does not capture, so a failed update is
1843
- // reported loudly by id rather than papered over with a second unreviewed
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", ["--path", pmRoot, "close", id, "--reason", importCloseReason(p.status, source)], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
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.4",
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 item updates are intentionally not reverted. Inspect and reconcile any marked orphan before retrying. Incompatible with --stream" },
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];