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/CHANGELOG.md +37 -0
- package/README.md +63 -0
- package/dist/atomic-update.d.ts +3 -2
- package/dist/atomic-update.d.ts.map +1 -1
- package/dist/atomic-update.js +10 -4
- package/dist/atomic-update.js.map +1 -1
- package/dist/edit-refusal.d.ts +21 -0
- package/dist/edit-refusal.d.ts.map +1 -0
- package/dist/edit-refusal.js +21 -0
- package/dist/edit-refusal.js.map +1 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +141 -9
- package/dist/index.js.map +1 -1
- package/dist/lease-guard.d.ts +14 -0
- package/dist/lease-guard.d.ts.map +1 -0
- package/dist/lease-guard.js +25 -0
- package/dist/lease-guard.js.map +1 -0
- package/dist/lineage.d.ts +72 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +137 -0
- package/dist/lineage.js.map +1 -0
- package/dist/roundtrip.d.ts +54 -0
- package/dist/roundtrip.d.ts.map +1 -0
- package/dist/roundtrip.js +255 -0
- package/dist/roundtrip.js.map +1 -0
- package/dist/table-schema.d.ts +80 -0
- package/dist/table-schema.d.ts.map +1 -0
- package/dist/table-schema.js +260 -0
- package/dist/table-schema.js.map +1 -0
- package/docs/compatibility-2026.10.20.md +76 -0
- package/docs/row-lineage-validation.md +49 -0
- package/docs/row-lineage.md +87 -0
- package/docs/spreadsheet-roundtrip.md +146 -0
- package/docs/spreadsheet-validation.md +47 -0
- package/docs/table-schema.md +123 -0
- package/manifest.json +1 -1
- package/package.json +10 -6
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
|
-
//
|
|
154
|
-
if (
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|