pm-csv 2026.10.22 → 2026.10.24

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,11 @@
1
- import { readFileSync, writeFileSync, createReadStream } from "node:fs";
1
+ import { readFileSync, writeFileSync, createReadStream, mkdirSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { certifyCompleteListResult, inspectCompleteListResult, runWorkspaceTransactionGc } from "@unbrained/pm-cli/sdk";
6
+ import { importLineage, writeLineageBundle, LineageRefusal } from "./lineage.js";
7
+ import { applyEditSession, exportEditSession, EditRefusal } from "./roundtrip.js";
8
+ import { registerTableSchemaCommands } from "./table-schema.js";
6
9
  import { captureUpdateCompensation, compensateUpdate, inspectUpdate, UPDATE_COMPENSATION_VERSION } from "./atomic-update.js";
7
10
  // ---------------------------------------------------------------------------
8
11
  // Error contract
@@ -92,15 +95,18 @@ const SUPPORTED_ENCODINGS = ["utf-8", "utf8", "utf16le", "latin1"];
92
95
  * - Escaped quotes ("" inside a quoted field → single ")
93
96
  * - Custom delimiter
94
97
  * - CRLF and LF line endings
98
+ * - Explicit quoted empty records at EOF, without inventing a row after a final newline
95
99
  */
96
100
  function parseCSV(text, delimiter = ",") {
97
101
  const rows = [];
98
102
  let row = [];
99
103
  let field = "";
100
104
  let inQuotes = false;
105
+ let recordStarted = false;
101
106
  let i = 0;
102
107
  while (i < text.length) {
103
108
  const ch = text[i];
109
+ recordStarted = true;
104
110
  if (inQuotes) {
105
111
  if (ch === '"') {
106
112
  // Peek ahead: "" means escaped quote
@@ -136,6 +142,7 @@ function parseCSV(text, delimiter = ",") {
136
142
  field = "";
137
143
  rows.push(row);
138
144
  row = [];
145
+ recordStarted = false;
139
146
  i += 2;
140
147
  continue;
141
148
  }
@@ -144,14 +151,15 @@ function parseCSV(text, delimiter = ",") {
144
151
  field = "";
145
152
  rows.push(row);
146
153
  row = [];
154
+ recordStarted = false;
147
155
  i++;
148
156
  continue;
149
157
  }
150
158
  field += ch;
151
159
  i++;
152
160
  }
153
- // Flush last field / row
154
- if (field !== "" || row.length > 0) {
161
+ // An explicit quoted empty field starts a record even though its value is empty.
162
+ if (recordStarted) {
155
163
  row.push(field);
156
164
  rows.push(row);
157
165
  }
@@ -207,6 +215,7 @@ class StreamingCSVParser {
207
215
  field = "";
208
216
  row = [];
209
217
  inQuotes = false;
218
+ recordStarted = false;
210
219
  pendingBoundaryChar = "";
211
220
  constructor(delimiter, onRow) {
212
221
  this.delimiter = delimiter;
@@ -218,6 +227,7 @@ class StreamingCSVParser {
218
227
  let i = 0;
219
228
  while (i < text.length) {
220
229
  const ch = text[i];
230
+ this.recordStarted = true;
221
231
  if (this.inQuotes) {
222
232
  if (ch === '"') {
223
233
  if (i + 1 === text.length) {
@@ -253,6 +263,7 @@ class StreamingCSVParser {
253
263
  this.field = "";
254
264
  this.onRow(this.row);
255
265
  this.row = [];
266
+ this.recordStarted = false;
256
267
  i += 2;
257
268
  continue;
258
269
  }
@@ -265,6 +276,7 @@ class StreamingCSVParser {
265
276
  this.field = "";
266
277
  this.onRow(this.row);
267
278
  this.row = [];
279
+ this.recordStarted = false;
268
280
  i++;
269
281
  continue;
270
282
  }
@@ -280,11 +292,12 @@ class StreamingCSVParser {
280
292
  this.field += this.pendingBoundaryChar;
281
293
  }
282
294
  this.pendingBoundaryChar = "";
283
- if (this.field !== "" || this.row.length > 0) {
295
+ if (this.recordStarted) {
284
296
  this.row.push(this.field);
285
297
  this.onRow(this.row);
286
298
  this.field = "";
287
299
  this.row = [];
300
+ this.recordStarted = false;
288
301
  }
289
302
  }
290
303
  }
@@ -1165,6 +1178,17 @@ function importCSVInMemory(pmRoot, filePath, opts) {
1165
1178
  processImportRows(pmRoot, dataRows, resolvedHeaders.headers, opts, result);
1166
1179
  return result;
1167
1180
  }
1181
+ /** Import an already captured table through the existing mapping, upsert and mutation core.
1182
+ * Callers own structural/strict validation; this entry never rereads a mutable input file.
1183
+ */
1184
+ export function importParsedCSV(pmRoot, table, opts) {
1185
+ const resolved = resolveImportHeaders(table[0], opts);
1186
+ const result = createImportResult();
1187
+ result.fieldMapWarnings = resolved.fieldMapWarnings;
1188
+ result.autoMappings = resolved.autoMappings;
1189
+ processImportRows(pmRoot, table.slice(1), resolved.headers, opts, result);
1190
+ return result;
1191
+ }
1168
1192
  // ---------------------------------------------------------------------------
1169
1193
  // Atomic import (pm-cli >= 2026.7.19 commitWorkspaceTransaction)
1170
1194
  // ---------------------------------------------------------------------------
@@ -1220,7 +1244,8 @@ function atomicStepId(rowIndex, existingId) {
1220
1244
  * Recover the original plan's update targets and create roles from SDK journal
1221
1245
  * step identities. Mutable CSV key tags cannot retarget an interrupted update
1222
1246
  * or turn a previously planned create into an update. Incompatible identities
1223
- * refuse replay before any mutation; the SDK validates the remaining journal.
1247
+ * refuse replay before any mutation. Check the journal's schema and transaction
1248
+ * identity before trusting its bindings; the SDK validates the remaining journal.
1224
1249
  * The public read-only inventory supplies the path, including after SDK layout
1225
1250
  * changes. Infinite retention and dry-run prevent recovery receipts being pruned.
1226
1251
  */
@@ -1234,13 +1259,17 @@ async function loadAtomicBindings(pmRoot, transactionId) {
1234
1259
  if (journal === null || typeof journal !== "object" || Array.isArray(journal)
1235
1260
  || !("stepIds" in journal) || !Array.isArray(journal.stepIds)
1236
1261
  || !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.`);
1262
+ throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
1263
+ }
1264
+ if (!("schemaVersion" in journal) || journal.schemaVersion !== 1
1265
+ || !("transactionId" in journal) || journal.transactionId !== transactionId) {
1266
+ throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV journal identity. Refusing before transaction coordination.`);
1238
1267
  }
1239
1268
  const pattern = new RegExp(String.raw `^csv-import-v${UPDATE_COMPENSATION_VERSION}-(?:create|update-(.+))-row-(\d+)$`);
1240
1269
  for (const id of journal.stepIds) {
1241
1270
  const match = pattern.exec(id);
1242
1271
  if (!match)
1243
- throw new Error(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
1272
+ throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
1244
1273
  bindings.set(Number(match[2]), match[1]);
1245
1274
  }
1246
1275
  return bindings;
@@ -2323,8 +2352,9 @@ function prepareCsvExport(pmRoot, rawOptions) {
2323
2352
  }
2324
2353
  export default {
2325
2354
  name: "pm-csv",
2326
- version: "2026.10.22",
2355
+ version: "2026.10.24",
2327
2356
  activate(api) {
2357
+ registerTableSchemaCommands(api);
2328
2358
  // -----------------------------------------------------------------------
2329
2359
  // Schema: register an optional `csv_source` provenance field so imported
2330
2360
  // items can record where they came from (set via `pm csv import --source`).
@@ -2335,6 +2365,105 @@ export default {
2335
2365
  api.registerItemFields([
2336
2366
  { name: "csv_source", type: "string", optional: true },
2337
2367
  ]);
2368
+ api.registerCommand({
2369
+ name: "csv bundle",
2370
+ description: "Import with portable cell lineage and a minimal rejected-row retry CSV (maximum 1000 rows).",
2371
+ arguments: [{ name: "file", required: true, description: "UTF-8 CSV input" }],
2372
+ flags: [
2373
+ { long: "--output", value_name: "directory", description: "New private bundle directory" },
2374
+ { long: "--columns", value_name: "fields", description: "Explicit permitted import and diagnostic fields, including title" },
2375
+ { long: "--map", value_name: "mapping", description: "Source=target pairs, for example Summary=title" },
2376
+ ],
2377
+ async run(ctx) {
2378
+ const output = ctx.options["output"];
2379
+ const columns = ctx.options["columns"];
2380
+ if (!output || !columns || !ctx.args[0])
2381
+ throw new LineageRefusal("csv_lineage_invalid", "Provide input.csv --output new-bundle --columns title,priority; only explicitly selected fields can be imported or exported.");
2382
+ let table;
2383
+ try {
2384
+ table = parseCSV(stripBOM(readFileSync(ctx.args[0], "utf8")));
2385
+ mkdirSync(output, { mode: 0o700 });
2386
+ }
2387
+ catch {
2388
+ throw new LineageRefusal("csv_bundle_io", "Provide readable UTF-8 input and a new writable output directory; existing bundles are preserved.");
2389
+ }
2390
+ let fieldMap;
2391
+ try {
2392
+ const parsed = parseFieldMap(ctx.options["map"]);
2393
+ fieldMap = Object.fromEntries(Object.entries(parsed).map(([header, field]) => [
2394
+ table[0]?.find(source => source.trim().toLowerCase() === header) ?? header, field,
2395
+ ]));
2396
+ }
2397
+ catch {
2398
+ throw new LineageRefusal("csv_lineage_invalid", "Use --map Source=title,Rank=priority; map only permitted fields.");
2399
+ }
2400
+ const bundle = await importLineage(ctx.pm_root, table, {
2401
+ columns: columns.split(",").map(field => field.trim()),
2402
+ fieldMap,
2403
+ author: ctx.global["author"] ?? "pm-csv",
2404
+ });
2405
+ writeLineageBundle(output, bundle);
2406
+ return bundle.receipt;
2407
+ },
2408
+ });
2409
+ api.registerCommand({
2410
+ name: "csv checkout",
2411
+ description: "Export a conflict-aware CSV edit session and immutable baseline JSON (maximum 1000 items).",
2412
+ flags: [
2413
+ { long: "--output", value_name: "file", description: "New CSV filename; baseline uses <file>.session.json" },
2414
+ { long: "--ids", value_name: "ids", description: "Comma-separated canonical item IDs (default: complete tracker)" },
2415
+ ],
2416
+ async run(ctx) {
2417
+ const output = ctx.options["output"];
2418
+ if (!output)
2419
+ throw new EditRefusal("csv_edit_invalid", "Provide --output items.csv; retain items.csv.session.json alongside the CSV.");
2420
+ const selection = ctx.options["ids"];
2421
+ const ids = selection ? selection.split(",").map(id => id.trim())
2422
+ : parseCSV(buildCsvExport(ctx.pm_root, { delimiter: ",", columns: ["id"] }).csvText).slice(1).map(row => row[0]);
2423
+ const { session, table } = await exportEditSession(ctx.pm_root, ids);
2424
+ // Exclusive creation prevents accidental replacement of an earlier edit session.
2425
+ try {
2426
+ writeFileSync(`${output}.session.json`, JSON.stringify(session), { encoding: "utf8", flag: "wx", mode: 0o600 });
2427
+ writeFileSync(output, serializeCSV(table, ",") + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
2428
+ }
2429
+ catch (error) {
2430
+ const refusal = new EditRefusal("csv_edit_invalid", "Use new writable checkout filenames. Existing files were preserved; an incomplete new baseline may remain if CSV creation failed.");
2431
+ refusal.cause = error;
2432
+ throw refusal;
2433
+ }
2434
+ return { exported: session.rows.length, session: session.digest };
2435
+ },
2436
+ });
2437
+ api.registerCommand({
2438
+ name: "csv apply",
2439
+ description: "Apply an edited checkout as a three-way patch; conflicts refuse before application.",
2440
+ arguments: [{ name: "file", required: true, description: "Edited checkout CSV" }],
2441
+ flags: [
2442
+ { long: "--session", value_name: "file", description: "Baseline JSON (default: <file>.session.json)" },
2443
+ { long: "--dry-run", description: "Read-only conflict check and compact patch counts" },
2444
+ ],
2445
+ async run(ctx) {
2446
+ const file = ctx.args[0];
2447
+ if (!file)
2448
+ throw new EditRefusal("csv_edit_invalid", "Provide the edited CSV filename.");
2449
+ const baseline = ctx.options["session"] ?? `${file}.session.json`;
2450
+ let session;
2451
+ let text;
2452
+ try {
2453
+ session = JSON.parse(readFileSync(baseline, "utf8"));
2454
+ text = stripBOM(readFileSync(file, "utf8"));
2455
+ }
2456
+ catch (error) {
2457
+ const refusal = new EditRefusal("csv_session_invalid", "Provide a readable CSV and the unchanged checkout session JSON.");
2458
+ refusal.cause = error;
2459
+ throw refusal;
2460
+ }
2461
+ return { ...await applyEditSession(ctx.pm_root, session, parseCSV(text), {
2462
+ author: ctx.global["author"] ?? "pm-csv",
2463
+ dryRun: readBoolOption(ctx.options, "dry-run", "dryRun"),
2464
+ }) };
2465
+ },
2466
+ });
2338
2467
  // -----------------------------------------------------------------------
2339
2468
  // Command: pm csv import <file>
2340
2469
  // -----------------------------------------------------------------------
@@ -2598,7 +2727,7 @@ export default {
2598
2727
  // ---------------------------------------------------------------------------
2599
2728
  // Named exports — pure helpers exposed for unit testing (no side effects).
2600
2729
  // ---------------------------------------------------------------------------
2601
- export { parseCSV, serializeCSV, serializeField, stripBOM, resolveDelimiter, parseFieldMap, resolveImportFieldMap, applyFieldMap, normalizeStatus, parseTags, stringifyTags, encodeKeyTagValue, decodeKeyTagValue, errorMessage, normalizeKeyValue, selectExportColumns, resolveEncoding, validateParsedCSV, validateCSV, strictValidationIssues, parseImportFilter, rowMatchesFilter, discoverCustomFields, EXPORT_COLUMNS, IMPORT_COLUMNS, StreamingCSVParser, streamCSVFile, levenshtein, suggestClosest, validateFieldMapTargets, checkMapSourcesPresent, };
2730
+ export { parseCSV, readBoolOption, serializeCSV, serializeField, stripBOM, resolveDelimiter, parseFieldMap, resolveImportFieldMap, applyFieldMap, normalizeStatus, parseTags, stringifyTags, encodeKeyTagValue, decodeKeyTagValue, errorMessage, normalizeKeyValue, selectExportColumns, resolveEncoding, validateParsedCSV, validateCSV, strictValidationIssues, parseImportFilter, rowMatchesFilter, discoverCustomFields, EXPORT_COLUMNS, IMPORT_COLUMNS, StreamingCSVParser, streamCSVFile, levenshtein, suggestClosest, validateFieldMapTargets, checkMapSourcesPresent, };
2602
2731
  // ---------------------------------------------------------------------------
2603
2732
  // Internal store helpers exposed for regression testing against oversized pm
2604
2733
  // roots. Unlike the pure helpers above, these READ or WRITE the given pm root
@@ -2615,4 +2744,7 @@ export { loadAppliedByTransaction, itemStatus, compensateCreate };
2615
2744
  // ONLY for those tests; production callers keep using the command paths.
2616
2745
  export { loadKeyIndex, buildCsvExport };
2617
2746
  export { importCSV };
2747
+ export { applyEditSession, exportEditSession, parseEditSession, editSessionHeader, EDIT_FIELDS, EditRefusal } from "./roundtrip.js";
2748
+ export { importLineage, writeLineageBundle, lineageDigest, LINEAGE_FIELDS, LineageRefusal } from "./lineage.js";
2749
+ export { describeTable, previewTable, parseTableSchema, importNegotiatedTable, TableSchemaRefusal } from "./table-schema.js";
2618
2750
  //# sourceMappingURL=index.js.map