pm-csv 2026.10.28 → 2026.10.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/README.md +86 -2
- package/dist/admission.d.ts +93 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +362 -0
- package/dist/admission.js.map +1 -0
- package/dist/atomic-update.d.ts.map +1 -1
- package/dist/atomic-update.js +16 -14
- package/dist/atomic-update.js.map +1 -1
- package/dist/edit-refusal.d.ts +1 -1
- package/dist/edit-refusal.d.ts.map +1 -1
- package/dist/index.d.ts +46 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +364 -117
- package/dist/index.js.map +1 -1
- package/dist/join-plans.d.ts +110 -0
- package/dist/join-plans.d.ts.map +1 -0
- package/dist/join-plans.js +557 -0
- package/dist/join-plans.js.map +1 -0
- package/dist/lineage.d.ts +3 -6
- package/dist/lineage.d.ts.map +1 -1
- package/dist/lineage.js +32 -11
- package/dist/lineage.js.map +1 -1
- package/dist/mutation-intent.d.ts +57 -0
- package/dist/mutation-intent.d.ts.map +1 -0
- package/dist/mutation-intent.js +145 -0
- package/dist/mutation-intent.js.map +1 -0
- package/dist/roundtrip.d.ts +2 -8
- package/dist/roundtrip.d.ts.map +1 -1
- package/dist/roundtrip.js +78 -24
- package/dist/roundtrip.js.map +1 -1
- package/dist/settings-snapshot.d.ts +37 -0
- package/dist/settings-snapshot.d.ts.map +1 -0
- package/dist/settings-snapshot.js +128 -0
- package/dist/settings-snapshot.js.map +1 -0
- package/dist/table-schema.d.ts +10 -7
- package/dist/table-schema.d.ts.map +1 -1
- package/dist/table-schema.js +36 -12
- package/dist/table-schema.js.map +1 -1
- package/docs/import-admission.md +199 -0
- package/docs/join-plans.md +201 -0
- package/docs/join-validation.md +240 -0
- package/docs/spreadsheet-roundtrip.md +17 -2
- package/manifest.json +1 -1
- package/package.json +10 -6
package/dist/index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
import * as nativeSdk from "@unbrained/pm-cli/sdk";
|
|
1
2
|
import { readFileSync, writeFileSync, createReadStream, mkdirSync } from "node:fs";
|
|
2
3
|
import { basename, resolve } from "node:path";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { certifyCompleteListResult, inspectCompleteListResult, readSettingsWithMetadata, resolveRuntimeFieldRegistry, runWorkspaceTransactionGc } from "@unbrained/pm-cli/sdk";
|
|
6
|
+
import { certifyCompleteListResult, inspectCompleteListResult, listAllComplete, readSettingsWithMetadata, resolveRuntimeFieldRegistry, runWorkspaceTransactionGc, create as createNativeCsv, update as updateNativeCsv, resolveRuntimeStatusRegistry, isTerminalStatus } from "@unbrained/pm-cli/sdk";
|
|
7
|
+
import { captureSettings, inSettingsTransaction, normalizePinnedStatus, pinCreateDefaults, readPinnedSettings as readSettings, rethrowSettingsChange, settingsDigest, useSettings, verifySettings, withSettings, withSettingsFence } from "./settings-snapshot.js";
|
|
6
8
|
import { importLineage, writeLineageBundle, LineageRefusal } from "./lineage.js";
|
|
7
9
|
import { applyEditSession, exportEditSession, EditRefusal } from "./roundtrip.js";
|
|
10
|
+
import { buildMutationIntent, mutationScopeArguments, matchesMutationScope } from "./mutation-intent.js";
|
|
11
|
+
import { registerJoinCommands } from "./join-plans.js";
|
|
8
12
|
import { registerTableSchemaCommands } from "./table-schema.js";
|
|
9
|
-
import {
|
|
13
|
+
import { admitCSV, AdmissionRefusal, admissionPreviewFailure, admissionSettingsChanged, admissionTargetRevision, admissionTargetSequence, assertCSVAdmission, findAdmissionFailure, normalizeAdmissionFilter, parseAdmissionOptions, readAdmittedSource, registerAdmissionCommand, requireAdmissionCapability } from "./admission.js";
|
|
14
|
+
import { captureUpdateCompensation, compensateUpdate, inspectUpdate, readUpdateDocument, UPDATE_COMPENSATION_VERSION } from "./atomic-update.js";
|
|
10
15
|
// ---------------------------------------------------------------------------
|
|
11
16
|
// Error contract
|
|
12
17
|
// ---------------------------------------------------------------------------
|
|
@@ -31,10 +36,14 @@ const EXIT_CODE = {
|
|
|
31
36
|
export class CommandError extends Error {
|
|
32
37
|
/** Numeric exit code the runtime propagates to the shell. */
|
|
33
38
|
exitCode;
|
|
34
|
-
|
|
39
|
+
/** Optional stable category for snapshot-sensitive mutation refusals. */
|
|
40
|
+
code;
|
|
41
|
+
constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE, code) {
|
|
35
42
|
super(message);
|
|
36
43
|
this.name = "CommandError";
|
|
37
44
|
this.exitCode = exitCode;
|
|
45
|
+
if (code)
|
|
46
|
+
this.code = code;
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
// Columns accepted on import (order independent — driven by header row).
|
|
@@ -199,16 +208,6 @@ function serializeCSV(rows, delimiterOrOpts) {
|
|
|
199
208
|
.map((row) => row.map((f) => serializeField(f, opts.delimiter)).join(opts.delimiter))
|
|
200
209
|
.join(eol);
|
|
201
210
|
}
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
// Streaming CSV parser — for large files that should not be held in memory.
|
|
204
|
-
// Same RFC-4180 state machine as parseCSV, but fed incrementally so quoted
|
|
205
|
-
// fields spanning chunk boundaries are handled correctly.
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
/**
|
|
208
|
-
* Incremental CSV state machine. Feed text via {@link push} and call
|
|
209
|
-
* {@link end} when the input is exhausted. Each complete row is emitted to the
|
|
210
|
-
* `onRow` callback exactly as in {@link parseCSV}.
|
|
211
|
-
*/
|
|
212
211
|
class StreamingCSVParser {
|
|
213
212
|
delimiter;
|
|
214
213
|
onRow;
|
|
@@ -217,9 +216,27 @@ class StreamingCSVParser {
|
|
|
217
216
|
inQuotes = false;
|
|
218
217
|
recordStarted = false;
|
|
219
218
|
pendingBoundaryChar = "";
|
|
220
|
-
|
|
219
|
+
limits;
|
|
220
|
+
recordCodeUnits = 0;
|
|
221
|
+
constructor(delimiter, onRow, limits) {
|
|
221
222
|
this.delimiter = delimiter;
|
|
222
223
|
this.onRow = onRow;
|
|
224
|
+
this.limits = limits;
|
|
225
|
+
}
|
|
226
|
+
/** Bound allocations before appending even an unterminated adversarial field. */
|
|
227
|
+
appendField(value) {
|
|
228
|
+
if (this.limits && (this.field.length + value.length > this.limits.maxFieldCodeUnits ||
|
|
229
|
+
this.recordCodeUnits + value.length > this.limits.maxRecordCodeUnits))
|
|
230
|
+
throw new RangeError("csv_record_limit");
|
|
231
|
+
this.field += value;
|
|
232
|
+
this.recordCodeUnits += value.length;
|
|
233
|
+
}
|
|
234
|
+
/** Bound empty fields too, so delimiter-only input cannot grow an unbounded array. */
|
|
235
|
+
finishField() {
|
|
236
|
+
if (this.limits && this.row.length >= this.limits.maxColumns)
|
|
237
|
+
throw new RangeError("csv_column_limit");
|
|
238
|
+
this.row.push(this.field);
|
|
239
|
+
this.field = "";
|
|
223
240
|
}
|
|
224
241
|
push(text) {
|
|
225
242
|
text = this.pendingBoundaryChar + text;
|
|
@@ -235,7 +252,7 @@ class StreamingCSVParser {
|
|
|
235
252
|
return;
|
|
236
253
|
}
|
|
237
254
|
if (text[i + 1] === '"') {
|
|
238
|
-
this.
|
|
255
|
+
this.appendField('"');
|
|
239
256
|
i += 2;
|
|
240
257
|
continue;
|
|
241
258
|
}
|
|
@@ -243,7 +260,7 @@ class StreamingCSVParser {
|
|
|
243
260
|
i++;
|
|
244
261
|
continue;
|
|
245
262
|
}
|
|
246
|
-
this.
|
|
263
|
+
this.appendField(ch);
|
|
247
264
|
i++;
|
|
248
265
|
continue;
|
|
249
266
|
}
|
|
@@ -253,16 +270,15 @@ class StreamingCSVParser {
|
|
|
253
270
|
continue;
|
|
254
271
|
}
|
|
255
272
|
if (ch === this.delimiter) {
|
|
256
|
-
this.
|
|
257
|
-
this.field = "";
|
|
273
|
+
this.finishField();
|
|
258
274
|
i++;
|
|
259
275
|
continue;
|
|
260
276
|
}
|
|
261
277
|
if (ch === "\r" && text[i + 1] === "\n") {
|
|
262
|
-
this.
|
|
263
|
-
this.field = "";
|
|
278
|
+
this.finishField();
|
|
264
279
|
this.onRow(this.row);
|
|
265
280
|
this.row = [];
|
|
281
|
+
this.recordCodeUnits = 0;
|
|
266
282
|
this.recordStarted = false;
|
|
267
283
|
i += 2;
|
|
268
284
|
continue;
|
|
@@ -272,15 +288,15 @@ class StreamingCSVParser {
|
|
|
272
288
|
return;
|
|
273
289
|
}
|
|
274
290
|
if (ch === "\n") {
|
|
275
|
-
this.
|
|
276
|
-
this.field = "";
|
|
291
|
+
this.finishField();
|
|
277
292
|
this.onRow(this.row);
|
|
278
293
|
this.row = [];
|
|
294
|
+
this.recordCodeUnits = 0;
|
|
279
295
|
this.recordStarted = false;
|
|
280
296
|
i++;
|
|
281
297
|
continue;
|
|
282
298
|
}
|
|
283
|
-
this.
|
|
299
|
+
this.appendField(ch);
|
|
284
300
|
i++;
|
|
285
301
|
}
|
|
286
302
|
}
|
|
@@ -289,15 +305,18 @@ class StreamingCSVParser {
|
|
|
289
305
|
this.inQuotes = false;
|
|
290
306
|
}
|
|
291
307
|
else if (this.pendingBoundaryChar !== "") {
|
|
292
|
-
this.
|
|
308
|
+
this.appendField(this.pendingBoundaryChar);
|
|
293
309
|
}
|
|
294
310
|
this.pendingBoundaryChar = "";
|
|
311
|
+
if (this.limits && this.inQuotes)
|
|
312
|
+
throw new SyntaxError("csv_unterminated_quote");
|
|
295
313
|
if (this.recordStarted) {
|
|
296
|
-
this.
|
|
314
|
+
this.finishField();
|
|
297
315
|
this.onRow(this.row);
|
|
298
316
|
this.field = "";
|
|
299
317
|
this.row = [];
|
|
300
318
|
this.recordStarted = false;
|
|
319
|
+
this.recordCodeUnits = 0;
|
|
301
320
|
}
|
|
302
321
|
}
|
|
303
322
|
}
|
|
@@ -339,7 +358,7 @@ function streamCSVFile(filePath, delimiter, encoding, onRow) {
|
|
|
339
358
|
*/
|
|
340
359
|
function readBoolOption(options, ...keys) {
|
|
341
360
|
for (const key of keys) {
|
|
342
|
-
if (options[key] !== undefined)
|
|
361
|
+
if (Object.hasOwn(options, key) && options[key] !== undefined)
|
|
343
362
|
return Boolean(options[key]);
|
|
344
363
|
}
|
|
345
364
|
return false;
|
|
@@ -491,7 +510,7 @@ function completeListArgs(pmRoot) {
|
|
|
491
510
|
/** Narrow an unknown JSON value to a non-array record. */
|
|
492
511
|
function asRecord(value) {
|
|
493
512
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
494
|
-
? value
|
|
513
|
+
? Object.assign(Object.create(null), value)
|
|
495
514
|
: undefined;
|
|
496
515
|
}
|
|
497
516
|
/** Render a receipt value while preserving a missing field as evidence. */
|
|
@@ -656,9 +675,9 @@ function errorMessage(error) {
|
|
|
656
675
|
* Example: `--map "Summary=title,Owner=tags"`.
|
|
657
676
|
*/
|
|
658
677
|
function parseFieldMap(spec) {
|
|
659
|
-
const map =
|
|
678
|
+
const map = Object.create(null);
|
|
660
679
|
if (spec === undefined)
|
|
661
|
-
return map;
|
|
680
|
+
return { ...map };
|
|
662
681
|
const parts = (Array.isArray(spec) ? spec : [spec]).flatMap((s) => s.split(","));
|
|
663
682
|
for (const part of parts) {
|
|
664
683
|
const trimmed = part.trim();
|
|
@@ -675,14 +694,14 @@ function parseFieldMap(spec) {
|
|
|
675
694
|
}
|
|
676
695
|
map[from] = to;
|
|
677
696
|
}
|
|
678
|
-
return map;
|
|
697
|
+
return { ...map };
|
|
679
698
|
}
|
|
680
699
|
/**
|
|
681
700
|
* Apply a field map to a list of header names, producing the effective
|
|
682
701
|
* (canonical) header used for column lookup.
|
|
683
702
|
*/
|
|
684
703
|
function applyFieldMap(headers, fieldMap) {
|
|
685
|
-
return headers.map((h) => fieldMap[h]
|
|
704
|
+
return headers.map((h) => Object.hasOwn(fieldMap, h) ? fieldMap[h] : h);
|
|
686
705
|
}
|
|
687
706
|
/**
|
|
688
707
|
* Compute a simple Levenshtein edit distance between two lowercase strings.
|
|
@@ -788,9 +807,9 @@ const AUTO_MAP_ALIASES = {
|
|
|
788
807
|
* alias mappings and never overrides an already-claimed canonical field.
|
|
789
808
|
*/
|
|
790
809
|
function resolveImportFieldMap(headers, explicitMap, autoMap) {
|
|
791
|
-
const fieldMap =
|
|
810
|
+
const fieldMap = Object.assign(Object.create(null), explicitMap);
|
|
792
811
|
if (!autoMap || headers.length === 0)
|
|
793
|
-
return { fieldMap, autoMappings: [] };
|
|
812
|
+
return { fieldMap: { ...fieldMap }, autoMappings: [] };
|
|
794
813
|
const headerCounts = new Map();
|
|
795
814
|
for (const header of headers) {
|
|
796
815
|
headerCounts.set(header, (headerCounts.get(header) ?? 0) + 1);
|
|
@@ -813,22 +832,25 @@ function resolveImportFieldMap(headers, explicitMap, autoMap) {
|
|
|
813
832
|
claimedTargets.add(target);
|
|
814
833
|
autoMappings.push({ from, to: target });
|
|
815
834
|
}
|
|
816
|
-
return { fieldMap, autoMappings };
|
|
835
|
+
return { fieldMap: { ...fieldMap }, autoMappings };
|
|
817
836
|
}
|
|
818
837
|
function formatAutoMappings(mappings) {
|
|
819
838
|
return mappings.map((m) => `${m.from}->${m.to}`).join(", ");
|
|
820
839
|
}
|
|
821
840
|
function autoMappingsToRecord(mappings) {
|
|
822
|
-
const record =
|
|
841
|
+
const record = Object.create(null);
|
|
823
842
|
for (const m of mappings)
|
|
824
843
|
record[m.from] = m.to;
|
|
825
|
-
return record;
|
|
844
|
+
return { ...record };
|
|
826
845
|
}
|
|
827
846
|
/**
|
|
828
847
|
* Map an arbitrary status string (from the CSV) to a valid SDK status.
|
|
829
848
|
* Falls back to "open".
|
|
830
849
|
*/
|
|
831
850
|
function normalizeStatus(raw) {
|
|
851
|
+
const pinned = normalizePinnedStatus(raw);
|
|
852
|
+
if (pinned !== undefined)
|
|
853
|
+
return pinned;
|
|
832
854
|
const s = raw.trim().toLowerCase();
|
|
833
855
|
const map = {
|
|
834
856
|
open: "open",
|
|
@@ -849,7 +871,7 @@ function normalizeStatus(raw) {
|
|
|
849
871
|
cancelled: "canceled",
|
|
850
872
|
draft: "draft",
|
|
851
873
|
};
|
|
852
|
-
return map[s]
|
|
874
|
+
return Object.hasOwn(map, s) ? map[s] : "open";
|
|
853
875
|
}
|
|
854
876
|
/**
|
|
855
877
|
* Parse a comma-separated tags string into an array, stripping whitespace.
|
|
@@ -885,10 +907,11 @@ function resolveEncoding(raw) {
|
|
|
885
907
|
* Read rows from a CSV file, returning header and data rows separately.
|
|
886
908
|
* Skips fully-empty rows. Decodes with the given encoding (default utf-8).
|
|
887
909
|
*/
|
|
888
|
-
function readCSVFile(filePath, delimiter, encoding = "utf-8", skipHeaders = false) {
|
|
910
|
+
function readCSVFile(filePath, delimiter, encoding = "utf-8", skipHeaders = false, admittedDigest, admittedMaxBytes) {
|
|
889
911
|
// Node's BufferEncoding spells utf-8 as "utf8"; normalize.
|
|
890
912
|
const bufEnc = encoding === "utf-8" ? "utf8" : encoding;
|
|
891
|
-
const
|
|
913
|
+
const bytes = admittedDigest === undefined ? readFileSync(filePath) : readAdmittedSource(filePath, admittedDigest, admittedMaxBytes);
|
|
914
|
+
const text = stripBOM(bytes.toString(bufEnc));
|
|
892
915
|
const rows = parseCSV(text, delimiter).filter((r) => r.some((f) => f.trim() !== ""));
|
|
893
916
|
if (rows.length === 0) {
|
|
894
917
|
return { headers: [], dataRows: [] };
|
|
@@ -931,13 +954,13 @@ function rowFields(headers, row) {
|
|
|
931
954
|
get,
|
|
932
955
|
parsed: {
|
|
933
956
|
title: get("title"),
|
|
934
|
-
status: rawStatus ? normalizeStatus(rawStatus) :
|
|
957
|
+
status: rawStatus ? normalizeStatus(rawStatus) : undefined,
|
|
935
958
|
priority: priority !== undefined && !isNaN(priority) ? priority : undefined,
|
|
936
959
|
tags: parseTags(get("tags")),
|
|
937
960
|
type: get("type") || undefined,
|
|
938
961
|
// pm has no milestone/due_date fields; map deadline (accept legacy header).
|
|
939
962
|
deadline: get("deadline") || get("due_date") || undefined,
|
|
940
|
-
body: get("body")
|
|
963
|
+
body: get("body") ? row[col("body")] : undefined,
|
|
941
964
|
parent: get("parent") || undefined,
|
|
942
965
|
assignee: get("assignee") || undefined,
|
|
943
966
|
sprint: get("sprint") || undefined,
|
|
@@ -977,10 +1000,10 @@ function parseImportFilter(statusRaw, typeRaw, priorityRaw) {
|
|
|
977
1000
|
* Pure predicate: does a parsed row satisfy every set filter criterion?
|
|
978
1001
|
* Unset criteria are wildcards. Exposed for unit testing.
|
|
979
1002
|
*/
|
|
980
|
-
function rowMatchesFilter(row, filter) {
|
|
1003
|
+
function rowMatchesFilter(row, filter, defaultStatus = "open") {
|
|
981
1004
|
if (!filter)
|
|
982
1005
|
return true;
|
|
983
|
-
if (filter.status !== undefined && row.status !== filter.status)
|
|
1006
|
+
if (filter.status !== undefined && (row.status ?? defaultStatus) !== filter.status)
|
|
984
1007
|
return false;
|
|
985
1008
|
if (filter.type !== undefined && (row.type ?? "").toLowerCase() !== filter.type)
|
|
986
1009
|
return false;
|
|
@@ -995,7 +1018,6 @@ function rowMatchesFilter(row, filter) {
|
|
|
995
1018
|
* creates — so the envelope's receipt is checked before any key is read.
|
|
996
1019
|
*/
|
|
997
1020
|
function loadKeyIndex(pmRoot, spawn = spawnPm) {
|
|
998
|
-
const index = new Map();
|
|
999
1021
|
const result = spawn(completeListArgs(pmRoot), { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1000
1022
|
// Route a stdout overrun (status: null, empty stderr, ENOBUFS) through
|
|
1001
1023
|
// assertPmOutputFit as a hard, named error instead of the generic
|
|
@@ -1025,6 +1047,11 @@ function loadKeyIndex(pmRoot, spawn = spawnPm) {
|
|
|
1025
1047
|
+ "which would make --upsert create duplicates.");
|
|
1026
1048
|
}
|
|
1027
1049
|
items = assertListAllComplete(parsed);
|
|
1050
|
+
return csvKeyIndex(items);
|
|
1051
|
+
}
|
|
1052
|
+
/** Build only the package-owned provenance-key index from a certified native read. */
|
|
1053
|
+
function csvKeyIndex(items) {
|
|
1054
|
+
const index = new Map();
|
|
1028
1055
|
for (const item of items) {
|
|
1029
1056
|
for (const tag of item.tags ?? []) {
|
|
1030
1057
|
if (tag.startsWith(KEY_TAG_PREFIX)) {
|
|
@@ -1060,6 +1087,7 @@ function processImportRow(pmRoot, headers, row, lineNo, opts, keyIndex, result)
|
|
|
1060
1087
|
result.previews.push({
|
|
1061
1088
|
action: existingId ? "update" : "create",
|
|
1062
1089
|
...parsed,
|
|
1090
|
+
status: parsed.status ?? (existingId ? undefined : "open"),
|
|
1063
1091
|
...(opts.keyField ? { key: keyValue } : {}),
|
|
1064
1092
|
...(opts.source ? { csv_source: opts.source } : {}),
|
|
1065
1093
|
});
|
|
@@ -1122,8 +1150,8 @@ function resolveImportHeaders(rawHeaders, opts) {
|
|
|
1122
1150
|
return { headers, autoMappings: mapResolution.autoMappings, fieldMapWarnings };
|
|
1123
1151
|
}
|
|
1124
1152
|
/** Read and resolve the shared in-memory prelude for normal and atomic imports. */
|
|
1125
|
-
function prepareInMemoryImport(filePath, opts) {
|
|
1126
|
-
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8", opts.skipHeaders ?? false);
|
|
1153
|
+
function prepareInMemoryImport(filePath, opts, admittedDigest) {
|
|
1154
|
+
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8", opts.skipHeaders ?? false, admittedDigest, opts.admission?.maxAtomicBytes);
|
|
1127
1155
|
return {
|
|
1128
1156
|
rawHeaders,
|
|
1129
1157
|
dataRows,
|
|
@@ -1153,7 +1181,21 @@ function processImportRows(pmRoot, dataRows, headers, opts, result) {
|
|
|
1153
1181
|
* @returns The import outcome; rejects with a {@link CommandError} on a usage
|
|
1154
1182
|
* conflict or an unreadable file.
|
|
1155
1183
|
*/
|
|
1156
|
-
function importCSV(pmRoot, filePath, opts) {
|
|
1184
|
+
async function importCSV(pmRoot, filePath, opts) {
|
|
1185
|
+
if (opts.admission !== undefined) {
|
|
1186
|
+
if (!opts.atomic || opts.stream)
|
|
1187
|
+
throw new AdmissionRefusal("csv_admission_options", "CSV admission apply requires --atomic without --stream.");
|
|
1188
|
+
const admission = parseAdmissionOptions({ ...parseAdmissionOptions(opts.admission), mode: "full", delimiter: opts.delimiter,
|
|
1189
|
+
fieldMap: opts.fieldMap, autoMap: opts.autoMap ?? false, encoding: opts.encoding ?? "utf-8", skipHeaders: opts.skipHeaders ?? false, filter: opts.filter });
|
|
1190
|
+
const captured = { ...opts, delimiter: admission.delimiter, fieldMap: admission.fieldMap, encoding: admission.encoding,
|
|
1191
|
+
autoMap: admission.autoMap, skipHeaders: admission.skipHeaders, filter: admission.filter, admission };
|
|
1192
|
+
return withSettings(pmRoot, admissionSettingsChanged, async () => {
|
|
1193
|
+
captured.filter = normalizeAdmissionFilter(admission.filter, await readSettings(pmRoot));
|
|
1194
|
+
const receipt = await admitCSV(pmRoot, filePath, admission);
|
|
1195
|
+
assertCSVAdmission(receipt);
|
|
1196
|
+
return importCSVAtomic(pmRoot, filePath, captured, receipt.digest);
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1157
1199
|
if (opts.atomic && opts.stream) {
|
|
1158
1200
|
return Promise.reject(new CommandError("--atomic cannot be combined with --stream: an unbounded stream cannot be committed as one bounded transaction.", EXIT_CODE.USAGE));
|
|
1159
1201
|
}
|
|
@@ -1236,9 +1278,9 @@ export function atomicTransactionId(filePath, opts = {}) {
|
|
|
1236
1278
|
return deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows));
|
|
1237
1279
|
}
|
|
1238
1280
|
/** Bind a CSV step to its format, operation, target, and original row. */
|
|
1239
|
-
function atomicStepId(rowIndex, existingId) {
|
|
1240
|
-
const operation = existingId ? `update-${existingId}` : "create";
|
|
1241
|
-
return `csv-import-v${UPDATE_COMPENSATION_VERSION}-${operation}-row-${rowIndex}`;
|
|
1281
|
+
function atomicStepId(rowIndex, existingId, revision, creation) {
|
|
1282
|
+
const operation = existingId ? `update-${existingId}` : creation ? `create-${creation}` : "create";
|
|
1283
|
+
return `csv-import-v${UPDATE_COMPENSATION_VERSION}-${operation}-row-${rowIndex}${revision === undefined ? "" : `-revision-${revision}`}`;
|
|
1242
1284
|
}
|
|
1243
1285
|
/**
|
|
1244
1286
|
* Recover the original plan's update targets and create roles from SDK journal
|
|
@@ -1249,30 +1291,42 @@ function atomicStepId(rowIndex, existingId) {
|
|
|
1249
1291
|
* The public read-only inventory supplies the path, including after SDK layout
|
|
1250
1292
|
* changes. Infinite retention and dry-run prevent recovery receipts being pruned.
|
|
1251
1293
|
*/
|
|
1252
|
-
async function loadAtomicBindings(pmRoot, transactionId) {
|
|
1294
|
+
export async function loadAtomicBindings(pmRoot, transactionId) {
|
|
1253
1295
|
const bindings = new Map();
|
|
1254
1296
|
const inventory = await runWorkspaceTransactionGc(pmRoot, { dryRun: true, retentionDays: Infinity });
|
|
1255
1297
|
const entry = inventory.entries.find(candidate => basename(candidate.file) === `${transactionId}.json`);
|
|
1256
1298
|
if (!entry)
|
|
1257
|
-
return bindings;
|
|
1258
|
-
|
|
1299
|
+
return { bindings };
|
|
1300
|
+
let journal;
|
|
1301
|
+
try {
|
|
1302
|
+
journal = JSON.parse(readFileSync(resolve(pmRoot, entry.file), "utf8"));
|
|
1303
|
+
}
|
|
1304
|
+
catch {
|
|
1305
|
+
throw new CommandError("The recorded atomic journal cannot be read. Preserve its audit and reconcile the original attempt before retrying or previewing this import.", EXIT_CODE.USAGE);
|
|
1306
|
+
}
|
|
1259
1307
|
if (journal === null || typeof journal !== "object" || Array.isArray(journal)
|
|
1260
|
-
|| !("stepIds" in journal) || !Array.isArray(journal.stepIds)
|
|
1308
|
+
|| !Object.hasOwn(journal, "stepIds") || !("stepIds" in journal) || !Array.isArray(journal.stepIds)
|
|
1261
1309
|
|| !journal.stepIds.every((id) => typeof id === "string")) {
|
|
1262
1310
|
throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
|
|
1263
1311
|
}
|
|
1264
|
-
if (!("schemaVersion" in journal) || journal.schemaVersion !== 1
|
|
1265
|
-
|| !("transactionId" in journal) || journal.transactionId !== transactionId) {
|
|
1312
|
+
if (!Object.hasOwn(journal, "schemaVersion") || !("schemaVersion" in journal) || journal.schemaVersion !== 1
|
|
1313
|
+
|| !Object.hasOwn(journal, "transactionId") || !("transactionId" in journal) || journal.transactionId !== transactionId) {
|
|
1266
1314
|
throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV journal identity. Refusing before transaction coordination.`);
|
|
1267
1315
|
}
|
|
1268
|
-
const
|
|
1316
|
+
const revisions = new Map();
|
|
1317
|
+
const creations = new Map();
|
|
1318
|
+
const pattern = new RegExp(String.raw `^csv-import-v${UPDATE_COMPENSATION_VERSION}-(?:create(?:-(.+))?|update-(.+))-row-(\d+)(?:-revision-(absent|[a-f0-9]{64}))?$`);
|
|
1269
1319
|
for (const id of journal.stepIds) {
|
|
1270
1320
|
const match = pattern.exec(id);
|
|
1271
1321
|
if (!match)
|
|
1272
1322
|
throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
|
|
1273
|
-
bindings.set(Number(match[
|
|
1323
|
+
bindings.set(Number(match[3]), match[2]);
|
|
1324
|
+
if (match[4] !== undefined)
|
|
1325
|
+
revisions.set(Number(match[3]), match[4]);
|
|
1326
|
+
if (match[1] !== undefined)
|
|
1327
|
+
creations.set(Number(match[3]), match[1]);
|
|
1274
1328
|
}
|
|
1275
|
-
return bindings;
|
|
1329
|
+
return { bindings, status: entry.status, ...(revisions.size ? { revisions } : {}), ...(creations.size ? { creations } : {}) };
|
|
1276
1330
|
}
|
|
1277
1331
|
/**
|
|
1278
1332
|
* Load every item stamped with this transaction's per-row ownership marker
|
|
@@ -1305,7 +1359,6 @@ async function loadAtomicBindings(pmRoot, transactionId) {
|
|
|
1305
1359
|
* compensateCreate.)
|
|
1306
1360
|
*/
|
|
1307
1361
|
function loadAppliedByTransaction(pmRoot, transactionId, spawn = spawnPm) {
|
|
1308
|
-
const byRowIndex = new Map();
|
|
1309
1362
|
const rowMarkerPrefix = `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}`;
|
|
1310
1363
|
const r = spawn(completeListArgs(pmRoot), {
|
|
1311
1364
|
encoding: "utf-8",
|
|
@@ -1343,6 +1396,11 @@ function loadAppliedByTransaction(pmRoot, transactionId, spawn = spawnPm) {
|
|
|
1343
1396
|
// resume scan that misses applied rows silently re-imports them (wrong
|
|
1344
1397
|
// data, the exact failure this package exists to prevent).
|
|
1345
1398
|
items = assertListAllComplete(parsed);
|
|
1399
|
+
return csvAppliedIndex(items, rowMarkerPrefix);
|
|
1400
|
+
}
|
|
1401
|
+
/** Share per-row provenance interpretation between legacy CLI and admitted SDK reads. */
|
|
1402
|
+
function csvAppliedIndex(items, rowMarkerPrefix) {
|
|
1403
|
+
const byRowIndex = new Map();
|
|
1346
1404
|
for (const item of items) {
|
|
1347
1405
|
for (const tag of item.tags ?? []) {
|
|
1348
1406
|
if (!tag.startsWith(rowMarkerPrefix))
|
|
@@ -1446,7 +1504,7 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
|
|
|
1446
1504
|
}
|
|
1447
1505
|
}
|
|
1448
1506
|
if (markers.length > 0) {
|
|
1449
|
-
const markerCleanup = spawnSync("pm", ["--path", pmRoot, "update", id, "--remove-tags", markers.join(",")], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1507
|
+
const markerCleanup = spawnSync("pm", ["--path", pmRoot, "update", id, "--remove-tags", markers.join(","), ...mutationScopeArguments({}, "patch")], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1450
1508
|
if (markerCleanup.status !== 0) {
|
|
1451
1509
|
console.error(`atomic import: compensation marker cleanup failed for ${id}: ${markerCleanup.stderr?.trim() || markerCleanup.stdout?.trim()}`);
|
|
1452
1510
|
}
|
|
@@ -1455,7 +1513,7 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
|
|
|
1455
1513
|
/**
|
|
1456
1514
|
* Atomic in-memory import: every row that would CREATE an item becomes one
|
|
1457
1515
|
* {@link WorkspaceTransactionStep} committed under a single workspace
|
|
1458
|
-
*
|
|
1516
|
+
* SDK-coordinated, crash-recoverable transaction. On success the same
|
|
1459
1517
|
* {@link ImportResult} as the non-atomic path is returned (counts derived from
|
|
1460
1518
|
* the committed step results). On failure the coordinator attempts reverse-
|
|
1461
1519
|
* order compensation, but callers receive a conservative reconciliation
|
|
@@ -1463,35 +1521,49 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
|
|
|
1463
1521
|
* prevent safe update compensation. An interrupted run resumes from the
|
|
1464
1522
|
* durable journal (inspect() skips already applied rows).
|
|
1465
1523
|
*/
|
|
1466
|
-
async function
|
|
1467
|
-
const { rawHeaders, dataRows, result, resolvedHeaders } =
|
|
1524
|
+
async function importCSVAtomicInternal(pmRoot, filePath, opts, prepared, bindingSnapshot) {
|
|
1525
|
+
const { rawHeaders, dataRows, result, resolvedHeaders } = prepared;
|
|
1468
1526
|
if (!resolvedHeaders)
|
|
1469
1527
|
return result;
|
|
1470
1528
|
result.fieldMapWarnings = resolvedHeaders.fieldMapWarnings;
|
|
1471
1529
|
result.autoMappings = resolvedHeaders.autoMappings;
|
|
1472
1530
|
// A dry-run atomic import just previews like the non-atomic path (no
|
|
1473
1531
|
// transaction is committed, nothing is written).
|
|
1474
|
-
if (opts.dryRun) {
|
|
1532
|
+
if (opts.dryRun && opts.admission === undefined) {
|
|
1533
|
+
const existing = await loadAtomicBindings(pmRoot, deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows)));
|
|
1534
|
+
if (existing.status === "compensating")
|
|
1535
|
+
throw new CommandError("Pending atomic compensation must be recovered with the original import options before previewing new work; dry-run did not modify the journal.", EXIT_CODE.USAGE);
|
|
1475
1536
|
processImportRows(pmRoot, dataRows, resolvedHeaders.headers, opts, result);
|
|
1476
1537
|
return result;
|
|
1477
1538
|
}
|
|
1478
1539
|
const commitTransaction = opts.commitTransaction;
|
|
1479
|
-
if (!commitTransaction) {
|
|
1540
|
+
if (!commitTransaction && !opts.dryRun) {
|
|
1480
1541
|
throw new CommandError("--atomic requires the host-injected commitWorkspaceTransaction SDK primitive. Upgrade the pm CLI and invoke pm-csv through the pm host.", EXIT_CODE.USAGE);
|
|
1481
1542
|
}
|
|
1482
1543
|
// Fingerprint the exact parsed content (raw headers + all data rows) so that
|
|
1483
1544
|
// editing the file in place yields a fresh transaction id instead of reusing
|
|
1484
1545
|
// stale per-row markers from a previous import of different content.
|
|
1485
|
-
const
|
|
1546
|
+
const admissionPreview = opts.admission === undefined ? undefined : requireAdmissionCapability(nativeSdk.previewItemMutations);
|
|
1547
|
+
const transactionId = opts.admission === undefined ? deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows))
|
|
1548
|
+
: `csv-import-${requireAdmissionCapability(nativeSdk.deriveMutationTransactionId)(JSON.stringify([
|
|
1549
|
+
"admitted-v1", resolve(filePath), rawHeaders, dataRows, Object.entries(opts.fieldMap).sort(), opts.autoMap, opts.keyField ?? "",
|
|
1550
|
+
[opts.filter?.status, opts.filter?.type, opts.filter?.priority], opts.skipHeaders, opts.source ?? "", settingsDigest(await readSettings(pmRoot)),
|
|
1551
|
+
]))}`;
|
|
1486
1552
|
const author = opts.atomicAuthor ?? "pm-csv";
|
|
1553
|
+
let rollback;
|
|
1487
1554
|
const ownershipTag = `${TX_TAG_PREFIX}${transactionId}`;
|
|
1488
1555
|
// Per-row ownership marker source of truth: `csv-txrow:<transactionId>#<rowIndex>`.
|
|
1489
1556
|
const rowTagFor = (rowIndex) => `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}${rowIndex}`;
|
|
1490
1557
|
// Detect rows a prior interrupted run already applied (resumability) plus
|
|
1491
1558
|
// the dedup index for --key upsert decisions, both from one fresh scan.
|
|
1492
|
-
const bindings = await loadAtomicBindings(pmRoot, transactionId);
|
|
1493
|
-
|
|
1494
|
-
|
|
1559
|
+
const { bindings, status: journalStatus, revisions, creations } = bindingSnapshot ?? await loadAtomicBindings(pmRoot, transactionId);
|
|
1560
|
+
if (opts.admission !== undefined && opts.dryRun && journalStatus === "compensating") {
|
|
1561
|
+
throw new AdmissionRefusal("csv_admission_target_changed", "Recover the retained atomic compensation with the original options before previewing this import; dry-run did not modify the journal.");
|
|
1562
|
+
}
|
|
1563
|
+
const admittedItems = opts.admission === undefined ? undefined : certifyCompleteListResult(await listAllComplete({}, { pmRoot })).items;
|
|
1564
|
+
const applied = admittedItems === undefined ? loadAppliedByTransaction(pmRoot, transactionId)
|
|
1565
|
+
: csvAppliedIndex(admittedItems, `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}`);
|
|
1566
|
+
const keyIndex = admittedItems === undefined ? (opts.keyField ? loadKeyIndex(pmRoot) : new Map()) : csvKeyIndex(admittedItems);
|
|
1495
1567
|
// In-batch duplicate-KEY guard: keys already claimed by an earlier planned
|
|
1496
1568
|
// CREATE in THIS run (the keyIndex is not updated during planning, so
|
|
1497
1569
|
// without this guard two rows sharing a not-yet-existing key would both
|
|
@@ -1499,6 +1571,8 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1499
1571
|
// claimed key is skipped with a clear per-row warning (see README).
|
|
1500
1572
|
const claimedKeys = new Map();
|
|
1501
1573
|
const planned = [];
|
|
1574
|
+
const planningSettings = await readSettings(pmRoot);
|
|
1575
|
+
const defaultStatus = opts.admission === undefined ? undefined : resolveRuntimeStatusRegistry(planningSettings.schema).open_status;
|
|
1502
1576
|
for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
|
|
1503
1577
|
const lineNo = opts.skipHeaders ? rowIndex + 1 : rowIndex + 2;
|
|
1504
1578
|
const row = dataRows[rowIndex];
|
|
@@ -1510,7 +1584,7 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1510
1584
|
result.skipped++;
|
|
1511
1585
|
continue;
|
|
1512
1586
|
}
|
|
1513
|
-
if (!rowMatchesFilter(parsed, opts.filter)) {
|
|
1587
|
+
if (!rowMatchesFilter(parsed, opts.filter, defaultStatus)) {
|
|
1514
1588
|
result.skipped++;
|
|
1515
1589
|
result.filtered++;
|
|
1516
1590
|
continue;
|
|
@@ -1531,7 +1605,89 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1531
1605
|
: normalizedKey ? keyIndex.get(normalizedKey) : undefined;
|
|
1532
1606
|
if (normalizedKey && !existingId)
|
|
1533
1607
|
claimedKeys.set(normalizedKey, rowIndex);
|
|
1534
|
-
|
|
1608
|
+
const revision = opts.admission === undefined ? undefined : revisions?.get(rowIndex) ??
|
|
1609
|
+
(existingId ? await admissionTargetRevision(pmRoot, existingId) : "absent");
|
|
1610
|
+
const creation = revision === undefined || existingId ? undefined : nativeSdk.normalizeItemId(requireAdmissionCapability(nativeSdk.deriveMutationTransactionId)(`${transactionId}:${rowIndex}`), (await readSettings(pmRoot)).id_prefix);
|
|
1611
|
+
if (creations?.has(rowIndex) && creations.get(rowIndex) !== creation)
|
|
1612
|
+
throw new AdmissionRefusal("csv_admission_target_changed", "A recorded creation identity differs from this admitted source domain. Reconcile the retained journal before applying.");
|
|
1613
|
+
planned.push({ rowIndex, lineNo, parsed, keyValue, existingId, isUpdate: Boolean(existingId), revision, creation });
|
|
1614
|
+
}
|
|
1615
|
+
if (opts.admission !== undefined) {
|
|
1616
|
+
await opts.onAdmissionBoundary?.("targets_read");
|
|
1617
|
+
if (bindings.size === 0) {
|
|
1618
|
+
const latest = await loadAtomicBindings(pmRoot, transactionId);
|
|
1619
|
+
if (latest.bindings.size > 0)
|
|
1620
|
+
return importCSVAtomicInternal(pmRoot, filePath, opts, { ...prepared, result: createImportResult() }, latest);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
// Recheck current scope before the SDK can reuse a committed journal receipt.
|
|
1624
|
+
if (bindings.size && (planned.length !== bindings.size || planned.some(row => !bindings.has(row.rowIndex)))) {
|
|
1625
|
+
throw new CommandError("An existing atomic CSV journal has a different recorded row domain. Resume with its original filters, mappings and key options to finish recovery; no new journal or successful empty receipt was produced.", EXIT_CODE.USAGE);
|
|
1626
|
+
}
|
|
1627
|
+
// In particular, legacy creates may carry an unreviewed focus-inherited parent.
|
|
1628
|
+
const comparisonSettings = planningSettings;
|
|
1629
|
+
const sequences = new Map();
|
|
1630
|
+
for (const row of planned)
|
|
1631
|
+
if (row.revision !== undefined && row.isUpdate) {
|
|
1632
|
+
const rows = sequences.get(row.existingId) ?? [];
|
|
1633
|
+
rows.push(row);
|
|
1634
|
+
sequences.set(row.existingId, rows);
|
|
1635
|
+
}
|
|
1636
|
+
const sequenceHeads = new Map();
|
|
1637
|
+
/** Share ordered target progress between preview, durable step inspection and per-write revision guards. */
|
|
1638
|
+
const admittedRowState = async (row) => {
|
|
1639
|
+
const sequence = sequences.get(row.existingId);
|
|
1640
|
+
if (sequence) {
|
|
1641
|
+
const state = await admissionTargetSequence(pmRoot, row.existingId, row.revision, sequence.map(entry => rowTagFor(entry.rowIndex)));
|
|
1642
|
+
sequenceHeads.set(row.existingId, state.revision);
|
|
1643
|
+
return { revision: state.revision, applied: state.completed >= sequence.indexOf(row) };
|
|
1644
|
+
}
|
|
1645
|
+
const revision = await admissionTargetRevision(pmRoot, row.existingId ?? row.creation, row.revision, rowTagFor(row.rowIndex));
|
|
1646
|
+
return { revision, applied: revision !== row.revision };
|
|
1647
|
+
};
|
|
1648
|
+
const admittedApplied = new Set();
|
|
1649
|
+
for (const row of planned) {
|
|
1650
|
+
const owned = applied.byRowIndex.get(row.rowIndex);
|
|
1651
|
+
if (row.revision !== undefined) {
|
|
1652
|
+
if ((await admittedRowState(row)).applied)
|
|
1653
|
+
admittedApplied.add(row.rowIndex);
|
|
1654
|
+
}
|
|
1655
|
+
if (!owned || row.isUpdate)
|
|
1656
|
+
continue;
|
|
1657
|
+
const document = await readUpdateDocument(pmRoot, owned);
|
|
1658
|
+
if (!matchesMutationScope(document, { parent: row.parsed.parent, assignee: row.parsed.assignee,
|
|
1659
|
+
sprint: row.parsed.sprint, release: row.parsed.release, blockedBy: row.parsed.blocked_by,
|
|
1660
|
+
...(!row.parsed.blocked_by ? { dep: [] } : {}) }, comparisonSettings, undefined, "patch")) {
|
|
1661
|
+
throw new CommandError("Atomic CSV replay scope differs from the reviewed rows. Reconcile current parent, assignment, planning and dependency fields before importing again.", EXIT_CODE.USAGE);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (opts.admission !== undefined) {
|
|
1665
|
+
await verifySettings(pmRoot);
|
|
1666
|
+
const pending = planned.filter(row => !admittedApplied.has(row.rowIndex)).map(row => ({
|
|
1667
|
+
op: row.isUpdate ? "update" : "create", id: row.existingId ?? row.creation,
|
|
1668
|
+
options: nativeCsvOptions(row.parsed, comparisonSettings, row.isUpdate, row.isUpdate ? undefined : row.keyValue, opts.source, [ownershipTag, rowTagFor(row.rowIndex)]),
|
|
1669
|
+
}));
|
|
1670
|
+
if (pending.length) {
|
|
1671
|
+
try {
|
|
1672
|
+
await admissionPreview({ pmRoot, author, transactionId: `csv-admit-preview-${transactionId}`, mutations: pending });
|
|
1673
|
+
}
|
|
1674
|
+
catch (error) {
|
|
1675
|
+
throw admissionPreviewFailure(error);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (opts.dryRun) {
|
|
1680
|
+
for (const row of planned) {
|
|
1681
|
+
result.previews.push({ action: admittedApplied.has(row.rowIndex) ? "unchanged" : row.isUpdate ? "update" : "create",
|
|
1682
|
+
...nativeCsvOptions(row.parsed, comparisonSettings, row.isUpdate, row.isUpdate ? undefined : row.keyValue, opts.source, [ownershipTag, rowTagFor(row.rowIndex)]) });
|
|
1683
|
+
if (admittedApplied.has(row.rowIndex))
|
|
1684
|
+
continue;
|
|
1685
|
+
if (row.isUpdate)
|
|
1686
|
+
result.updated++;
|
|
1687
|
+
else
|
|
1688
|
+
result.imported++;
|
|
1689
|
+
}
|
|
1690
|
+
return result;
|
|
1535
1691
|
}
|
|
1536
1692
|
// Build one transaction step per row to be written (create or update).
|
|
1537
1693
|
// - create: apply() shells out to the same `pm create` as the non-atomic
|
|
@@ -1550,12 +1706,16 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1550
1706
|
// `state: "applied"` for items created during THIS run — not just for items
|
|
1551
1707
|
// left over from a prior interrupted run (which the pre-run `applied` lookup
|
|
1552
1708
|
// already detects).
|
|
1709
|
+
const wrote = new Set();
|
|
1553
1710
|
const steps = planned.map((row) => {
|
|
1554
|
-
const stepId = atomicStepId(row.rowIndex, row.existingId);
|
|
1711
|
+
const stepId = atomicStepId(row.rowIndex, row.existingId, row.revision, row.creation);
|
|
1555
1712
|
const rowTag = rowTagFor(row.rowIndex);
|
|
1556
1713
|
// Set by apply() in this run; undefined before that or on a fresh resume.
|
|
1557
1714
|
let appliedId;
|
|
1558
1715
|
const inspect = async () => {
|
|
1716
|
+
if (row.revision !== undefined) {
|
|
1717
|
+
return (await admittedRowState(row)).applied ? { state: "applied", result: row.existingId ?? row.creation } : { state: "pending" };
|
|
1718
|
+
}
|
|
1559
1719
|
if (row.isUpdate) {
|
|
1560
1720
|
return inspectUpdate(pmRoot, row.existingId, rowTag, row.parsed.status === "closed" || row.parsed.status === "canceled");
|
|
1561
1721
|
}
|
|
@@ -1570,8 +1730,26 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1570
1730
|
return { state: "applied", result: id };
|
|
1571
1731
|
return { state: "pending" };
|
|
1572
1732
|
};
|
|
1573
|
-
const apply = async () => {
|
|
1733
|
+
const apply = async () => inSettingsTransaction(pmRoot, () => withSettingsFence(pmRoot, async () => {
|
|
1574
1734
|
const ownershipTags = [ownershipTag, rowTag];
|
|
1735
|
+
if (row.revision !== undefined) {
|
|
1736
|
+
await opts.onAdmissionBoundary?.("before_write", row.existingId ?? row.creation);
|
|
1737
|
+
await verifySettings(pmRoot);
|
|
1738
|
+
await admissionTargetRevision(pmRoot, row.existingId ?? row.creation, sequenceHeads.get(row.existingId) ?? row.revision);
|
|
1739
|
+
if (!row.isUpdate && opts.keyField && row.keyValue && csvKeyIndex(certifyCompleteListResult(await listAllComplete({}, { pmRoot })).items).has(normalizeKeyValue(row.keyValue))) {
|
|
1740
|
+
throw new AdmissionRefusal("csv_admission_target_changed", "A key appeared after admission planning. Re-read current SDK state and preview a fresh import before writing.");
|
|
1741
|
+
}
|
|
1742
|
+
const options = nativeCsvOptions(row.parsed, comparisonSettings, row.isUpdate, row.isUpdate ? undefined : row.keyValue, opts.source, ownershipTags);
|
|
1743
|
+
if (row.isUpdate)
|
|
1744
|
+
await updateNativeCsv(row.existingId, { ...options, author, message: `${rowTag}:apply` }, { pmRoot });
|
|
1745
|
+
else
|
|
1746
|
+
await createNativeCsv({ ...options, id: row.creation, author, message: `${rowTag}:create` }, { pmRoot });
|
|
1747
|
+
appliedId = row.existingId ?? row.creation;
|
|
1748
|
+
wrote.add(row.rowIndex);
|
|
1749
|
+
if (sequences.has(row.existingId))
|
|
1750
|
+
await admittedRowState(row);
|
|
1751
|
+
return appliedId;
|
|
1752
|
+
}
|
|
1575
1753
|
if (row.isUpdate) {
|
|
1576
1754
|
upsertUpdate(pmRoot, row.existingId, row.parsed, opts.source, ownershipTags, `${rowTag}:apply`);
|
|
1577
1755
|
appliedId = row.existingId;
|
|
@@ -1580,28 +1758,53 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1580
1758
|
const newId = upsertCreate(pmRoot, row.parsed, opts.keyField ? row.keyValue : undefined, opts.source, ownershipTags);
|
|
1581
1759
|
appliedId = newId;
|
|
1582
1760
|
return newId;
|
|
1583
|
-
};
|
|
1584
|
-
const prepareCompensation = async () => row.isUpdate ? captureUpdateCompensation(pmRoot, row.existingId, rowTag, author) : undefined;
|
|
1761
|
+
}));
|
|
1762
|
+
const prepareCompensation = async () => row.isUpdate ? inSettingsTransaction(pmRoot, () => withSettingsFence(pmRoot, () => captureUpdateCompensation(pmRoot, row.existingId, rowTag, author))) : undefined;
|
|
1585
1763
|
const compensate = async (data) => {
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1764
|
+
rollback ??= captureSettings(pmRoot, opts.admission === undefined ? importSettingsChanged : admissionSettingsChanged);
|
|
1765
|
+
const snapshot = await rollback;
|
|
1766
|
+
return inSettingsTransaction(pmRoot, () => useSettings(pmRoot, snapshot, () => withSettingsFence(pmRoot, async () => {
|
|
1767
|
+
if (row.isUpdate) {
|
|
1768
|
+
await compensateUpdate(pmRoot, row.existingId, rowTag, author, data);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
// Prefer the id captured by apply() in this run; fall back to the
|
|
1772
|
+
// pre-run per-row lookup (resume-compensation of a prior run's applied step).
|
|
1773
|
+
const id = appliedId ?? row.creation ?? applied.byRowIndex.get(row.rowIndex);
|
|
1774
|
+
// Strip both markers so the rolled-back row is no longer recognized as
|
|
1775
|
+
// applied on a retry (marker-presence — not status — is the applied signal).
|
|
1776
|
+
if (typeof id === "string" && id) {
|
|
1777
|
+
if (row.revision !== undefined) {
|
|
1778
|
+
await opts.onAdmissionBoundary?.("before_compensate", id);
|
|
1779
|
+
await verifySettings(pmRoot);
|
|
1780
|
+
const current = await readUpdateDocument(pmRoot, id);
|
|
1781
|
+
const status = resolveRuntimeStatusRegistry(snapshot.settings.schema).close_status;
|
|
1782
|
+
const options = { ...buildMutationIntent({ parent: current.metadata.parent, assignee: current.metadata.assignee,
|
|
1783
|
+
sprint: current.metadata.sprint, release: current.metadata.release, blockedBy: current.metadata.blocked_by,
|
|
1784
|
+
status, closeReason: "Atomic CSV import rolled back", author, message: `${rowTag}:compensate` }, "patch"), removeTags: [rowTag, ownershipTag] };
|
|
1785
|
+
await admissionTargetRevision(pmRoot, id, "owned", rowTag);
|
|
1786
|
+
await updateNativeCsv(id, options, { pmRoot });
|
|
1787
|
+
}
|
|
1788
|
+
else
|
|
1789
|
+
compensateCreate(pmRoot, id, [rowTag, ownershipTag]);
|
|
1790
|
+
}
|
|
1791
|
+
})));
|
|
1597
1792
|
};
|
|
1598
1793
|
return { id: stepId, inspect, apply, prepareCompensation, compensate };
|
|
1599
1794
|
});
|
|
1600
1795
|
let committed;
|
|
1601
1796
|
try {
|
|
1602
|
-
committed = await commitTransaction({ transactionId, author, steps
|
|
1797
|
+
committed = await commitTransaction({ transactionId, author, steps, onTransition: event => {
|
|
1798
|
+
if (journalStatus === "compensating" && ["compensated", "prepared"].includes(event.transition)) {
|
|
1799
|
+
throw new CommandError("Pending atomic compensation was recovered. Inspect the retained audit and retry the original import; this attempt did not restart forward work.", EXIT_CODE.USAGE);
|
|
1800
|
+
}
|
|
1801
|
+
} });
|
|
1603
1802
|
}
|
|
1604
1803
|
catch (err) {
|
|
1804
|
+
rethrowSettingsChange(err, opts.admission === undefined ? importSettingsChanged : admissionSettingsChanged);
|
|
1805
|
+
const admissionFailure = findAdmissionFailure(err);
|
|
1806
|
+
if (admissionFailure)
|
|
1807
|
+
throw admissionFailure;
|
|
1605
1808
|
const msg = errorMessage(err);
|
|
1606
1809
|
// The coordinator invokes compensations in reverse order, but this package
|
|
1607
1810
|
// cannot prove a clean tracker: create cleanup deliberately tolerates an
|
|
@@ -1609,17 +1812,18 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1609
1812
|
// continue, and update compensation refuses concurrent-writer conflicts.
|
|
1610
1813
|
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);
|
|
1611
1814
|
}
|
|
1815
|
+
await verifySettings(pmRoot);
|
|
1612
1816
|
// Tally the ImportResult from the committed step results. Each committed
|
|
1613
1817
|
// result is the created/updated item id. Only rows that were NOT already
|
|
1614
1818
|
// applied at the start of this run (i.e. newly committed by this attempt)
|
|
1615
1819
|
// count toward imported/updated — resumed rows from a prior interrupted run
|
|
1616
1820
|
// are already in the tracker and must not be double-counted.
|
|
1617
1821
|
for (const row of planned) {
|
|
1618
|
-
const stepId = atomicStepId(row.rowIndex, row.existingId);
|
|
1619
|
-
const res = committed.results[stepId];
|
|
1822
|
+
const stepId = atomicStepId(row.rowIndex, row.existingId, row.revision, row.creation);
|
|
1823
|
+
const res = Object.hasOwn(committed.results, stepId) ? committed.results[stepId] : undefined;
|
|
1620
1824
|
if (res === undefined)
|
|
1621
1825
|
continue;
|
|
1622
|
-
const alreadyApplied = applied.byRowIndex.has(row.rowIndex);
|
|
1826
|
+
const alreadyApplied = row.revision === undefined ? applied.byRowIndex.has(row.rowIndex) : !wrote.has(row.rowIndex);
|
|
1623
1827
|
if (alreadyApplied)
|
|
1624
1828
|
continue;
|
|
1625
1829
|
if (row.isUpdate)
|
|
@@ -1629,6 +1833,20 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
|
|
|
1629
1833
|
}
|
|
1630
1834
|
return result;
|
|
1631
1835
|
}
|
|
1836
|
+
/** Pin atomic planning, retry inspection and row batches to one settings revision. */
|
|
1837
|
+
async function importCSVAtomic(pmRoot, filePath, opts, admittedDigest) {
|
|
1838
|
+
pmRoot = resolve(pmRoot);
|
|
1839
|
+
filePath = resolve(filePath);
|
|
1840
|
+
const captured = { ...opts, fieldMap: { ...opts.fieldMap }, filter: opts.filter ? { ...opts.filter } : undefined };
|
|
1841
|
+
const prepared = prepareInMemoryImport(filePath, captured, admittedDigest);
|
|
1842
|
+
if (captured.dryRun || !captured.commitTransaction)
|
|
1843
|
+
return importCSVAtomicInternal(pmRoot, filePath, captured, prepared);
|
|
1844
|
+
return withSettings(pmRoot, captured.admission === undefined ? importSettingsChanged : admissionSettingsChanged, () => importCSVAtomicInternal(pmRoot, filePath, captured, prepared));
|
|
1845
|
+
}
|
|
1846
|
+
/** Keep settings drift within the existing command error family. */
|
|
1847
|
+
function importSettingsChanged() {
|
|
1848
|
+
return new CommandError("csv_settings_changed: Settings changed during atomic import. Inspect compensation and preview current settings before retrying.", EXIT_CODE.USAGE, "csv_settings_changed");
|
|
1849
|
+
}
|
|
1632
1850
|
/**
|
|
1633
1851
|
* Streaming import: reads the file via a readable stream so large CSV files
|
|
1634
1852
|
* are never fully loaded into memory. The header row (or positional
|
|
@@ -1677,17 +1895,15 @@ async function importCSVStreaming(pmRoot, filePath, opts) {
|
|
|
1677
1895
|
* Flag names verified against the installed `pm create`/`pm update` contracts:
|
|
1678
1896
|
* --parent, --assignee, --sprint, --release, --blocked-by.
|
|
1679
1897
|
*/
|
|
1680
|
-
function
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
if (p.blocked_by)
|
|
1690
|
-
args.push("--blocked-by", p.blocked_by);
|
|
1898
|
+
function csvRelationalOptions(p) {
|
|
1899
|
+
// Updates own only nonempty cells. Absent/empty cells preserve current state,
|
|
1900
|
+
// including all dependency edges; create mode still suppresses ambient defaults.
|
|
1901
|
+
return Object.fromEntries(Object.entries({ parent: p.parent, assignee: p.assignee,
|
|
1902
|
+
sprint: p.sprint, release: p.release, blockedBy: p.blocked_by }).filter(([, value]) => value !== undefined));
|
|
1903
|
+
}
|
|
1904
|
+
/** Render the same selected scope options used by admitted native mutations. */
|
|
1905
|
+
function appendRelationalArgs(args, p, mode) {
|
|
1906
|
+
args.push(...mutationScopeArguments(csvRelationalOptions(p), mode));
|
|
1691
1907
|
}
|
|
1692
1908
|
/** Append item fields shared by create and update mutation commands. */
|
|
1693
1909
|
function appendMutableItemArgs(args, p, preserveEmptyBody) {
|
|
@@ -1699,7 +1915,7 @@ function appendMutableItemArgs(args, p, preserveEmptyBody) {
|
|
|
1699
1915
|
args.push("--type", p.type);
|
|
1700
1916
|
if (p.deadline)
|
|
1701
1917
|
args.push("--deadline", p.deadline);
|
|
1702
|
-
appendRelationalArgs(args, p);
|
|
1918
|
+
appendRelationalArgs(args, p, preserveEmptyBody ? "patch" : "create");
|
|
1703
1919
|
}
|
|
1704
1920
|
/** Build a normal validated close with factual CSV provenance and outcome fields. */
|
|
1705
1921
|
function csvCloseArgs(pmRoot, id, reason, expected, actual) {
|
|
@@ -1718,6 +1934,21 @@ function importCloseReason(status, source) {
|
|
|
1718
1934
|
const origin = source ? ` from ${source}` : "";
|
|
1719
1935
|
return `Imported${origin} (source status: ${status})`;
|
|
1720
1936
|
}
|
|
1937
|
+
/** Native options shared by admitted previews and apply; SDK owns field and lifecycle normalization. */
|
|
1938
|
+
function nativeCsvOptions(p, settings, update, keyValue, source, ownershipTags) {
|
|
1939
|
+
const tags = [...p.tags, ...(!update && keyValue ? [`${KEY_TAG_PREFIX}${encodeKeyTagValue(normalizeKeyValue(keyValue))}`] : []),
|
|
1940
|
+
...(source ? [`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`] : []), ...ownershipTags];
|
|
1941
|
+
const values = buildMutationIntent({ title: p.title, ...(p.type ? { type: p.type } : {}), ...(p.status ? { status: p.status } : {}),
|
|
1942
|
+
...(!update && p.status === undefined ? { status: resolveRuntimeStatusRegistry(settings.schema).open_status } : {}),
|
|
1943
|
+
...(p.priority !== undefined ? { priority: p.priority } : {}), ...(p.body !== undefined ? { body: p.body } : {}),
|
|
1944
|
+
...(p.deadline ? { deadline: p.deadline } : {}), ...csvRelationalOptions(p),
|
|
1945
|
+
...(update ? { addTags: tags } : { tags: tags.join(",") }) }, update ? "patch" : "create");
|
|
1946
|
+
const options = update ? values : pinCreateDefaults(values, settings);
|
|
1947
|
+
if (options.status !== undefined && isTerminalStatus(options.status, resolveRuntimeStatusRegistry(settings.schema))) {
|
|
1948
|
+
options.closeReason = importCloseReason(options.status, source);
|
|
1949
|
+
}
|
|
1950
|
+
return options;
|
|
1951
|
+
}
|
|
1721
1952
|
/**
|
|
1722
1953
|
* Create a new item, optionally carrying a csv-key provenance tag. Returns the
|
|
1723
1954
|
* new id (empty string when `pm create` did not report one AND the row is not
|
|
@@ -1768,7 +1999,7 @@ function upsertCreate(pmRoot, p, keyValue, source, extraTags) {
|
|
|
1768
1999
|
tags.push(...extraTags);
|
|
1769
2000
|
// See the function docstring: terminal `closed` is applied via pm close
|
|
1770
2001
|
// after the create, never via `pm create --status closed`.
|
|
1771
|
-
const createStatus = p.status === "closed" ? "open" : p.status;
|
|
2002
|
+
const createStatus = !p.status || p.status === "closed" ? "open" : p.status;
|
|
1772
2003
|
const args = ["--path", pmRoot, "create", "--title", p.title, "--status", createStatus, "--json"];
|
|
1773
2004
|
appendMutableItemArgs(args, p, false);
|
|
1774
2005
|
if (tags.length > 0)
|
|
@@ -1902,7 +2133,7 @@ function upsertUpdate(pmRoot, id, p, source, extraTags, historyMessage) {
|
|
|
1902
2133
|
if (addTags.length > 0)
|
|
1903
2134
|
args.push("--add-tags", addTags.join(","));
|
|
1904
2135
|
// `update` cannot set a closed status; only set non-closed statuses here.
|
|
1905
|
-
if (p.status !== "closed" && p.status !== "canceled")
|
|
2136
|
+
if (p.status && p.status !== "closed" && p.status !== "canceled")
|
|
1906
2137
|
args.push("--status", p.status);
|
|
1907
2138
|
const r = spawnSync("pm", args, { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
|
|
1908
2139
|
// `pm update` is a WRITE: a null status (receipt overrun or a signal kill)
|
|
@@ -1954,7 +2185,7 @@ function validateCSV(filePath, opts) {
|
|
|
1954
2185
|
* Core validation logic over already-parsed headers + rows. Pure and
|
|
1955
2186
|
* side-effect-free so it can be unit tested directly.
|
|
1956
2187
|
*/
|
|
1957
|
-
function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
|
|
2188
|
+
function validateParsedCSV(rawHeaders, dataRows, fieldMap, statusKnown = status => KNOWN_STATUSES.has(status)) {
|
|
1958
2189
|
const mappedColumns = applyFieldMap(rawHeaders, fieldMap);
|
|
1959
2190
|
const hasTitleColumn = mappedColumns.includes("title");
|
|
1960
2191
|
const issues = [];
|
|
@@ -1980,7 +2211,7 @@ function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
|
|
|
1980
2211
|
}
|
|
1981
2212
|
if (statusIdx >= 0) {
|
|
1982
2213
|
const status = (row[statusIdx] ?? "").trim().toLowerCase();
|
|
1983
|
-
if (status && !
|
|
2214
|
+
if (status && !statusKnown(status))
|
|
1984
2215
|
rowsWithUnknownStatus++;
|
|
1985
2216
|
}
|
|
1986
2217
|
if (priorityIdx >= 0) {
|
|
@@ -2085,6 +2316,13 @@ function assertStrictImportReady(filePath, opts) {
|
|
|
2085
2316
|
}
|
|
2086
2317
|
/** Parse one host option record into the canonical CSV import contract. */
|
|
2087
2318
|
function parseCsvImportInvocation(rawOptions, dryRun) {
|
|
2319
|
+
const admitted = readBoolOption(rawOptions, "admission");
|
|
2320
|
+
if (admitted && ["status", "type", "priority"].some(key => rawOptions[key] !== undefined && (typeof rawOptions[key] !== "string" || !rawOptions[key].trim()))) {
|
|
2321
|
+
throw new AdmissionRefusal("csv_admission_options", "Supply nonempty configured status/type and integer priority filter values for admitted imports.");
|
|
2322
|
+
}
|
|
2323
|
+
const filter = parseImportFilter(rawOptions["status"], rawOptions["type"], rawOptions["priority"]);
|
|
2324
|
+
if (admitted && rawOptions["status"] !== undefined)
|
|
2325
|
+
filter.status = rawOptions["status"];
|
|
2088
2326
|
return {
|
|
2089
2327
|
strict: readBoolOption(rawOptions, "strict"),
|
|
2090
2328
|
options: {
|
|
@@ -2095,16 +2333,17 @@ function parseCsvImportInvocation(rawOptions, dryRun) {
|
|
|
2095
2333
|
keyField: (rawOptions["key"] ?? "").trim().toLowerCase() || undefined,
|
|
2096
2334
|
encoding: resolveEncoding(rawOptions["encoding"]),
|
|
2097
2335
|
source: (rawOptions["source"] ?? "").trim() || undefined,
|
|
2098
|
-
filter
|
|
2336
|
+
filter,
|
|
2099
2337
|
skipHeaders: readBoolOption(rawOptions, "skip-headers", "skipHeaders"),
|
|
2100
2338
|
stream: readBoolOption(rawOptions, "stream"),
|
|
2101
2339
|
atomic: readBoolOption(rawOptions, "atomic"),
|
|
2340
|
+
admission: admitted ? {} : undefined,
|
|
2102
2341
|
},
|
|
2103
2342
|
};
|
|
2104
2343
|
}
|
|
2105
2344
|
/** Validate strict input, then execute one fully parsed import invocation. */
|
|
2106
2345
|
function executeCsvImport(pmRoot, filePath, invocation, commitTransaction, atomicAuthor) {
|
|
2107
|
-
if (invocation.strict) {
|
|
2346
|
+
if (invocation.strict && invocation.options.admission === undefined) {
|
|
2108
2347
|
assertStrictImportReady(filePath, {
|
|
2109
2348
|
delimiter: invocation.options.delimiter,
|
|
2110
2349
|
fieldMap: invocation.options.fieldMap,
|
|
@@ -2177,7 +2416,7 @@ function selectExportColumns(spec, extraValid = []) {
|
|
|
2177
2416
|
*/
|
|
2178
2417
|
async function resolveExportColumns(pmRoot, columnsSpec, discover) {
|
|
2179
2418
|
const discovered = discover ? await discoverCustomFields(pmRoot) : [];
|
|
2180
|
-
const columnSource =
|
|
2419
|
+
const columnSource = Object.create(null);
|
|
2181
2420
|
for (const f of discovered) {
|
|
2182
2421
|
if (f.key !== f.metadataKey)
|
|
2183
2422
|
columnSource[f.key] = f.metadataKey;
|
|
@@ -2253,8 +2492,8 @@ function buildCsvExport(pmRoot, opts, spawn = spawnPm) {
|
|
|
2253
2492
|
item.csv_source = decodeKeyTagValue(sourceTag.slice(SOURCE_TAG_PREFIX.length));
|
|
2254
2493
|
}
|
|
2255
2494
|
const dataRows = items.map((item) => opts.columns.map((col) => {
|
|
2256
|
-
const prop = opts.columnSource
|
|
2257
|
-
const val = item[prop];
|
|
2495
|
+
const prop = opts.columnSource && Object.hasOwn(opts.columnSource, col) ? opts.columnSource[col] : col;
|
|
2496
|
+
const val = Object.hasOwn(item, prop) ? item[prop] : undefined;
|
|
2258
2497
|
if (val === undefined || val === null)
|
|
2259
2498
|
return "";
|
|
2260
2499
|
if (Array.isArray(val)) {
|
|
@@ -2302,9 +2541,11 @@ async function prepareCsvExport(pmRoot, rawOptions) {
|
|
|
2302
2541
|
}
|
|
2303
2542
|
export default {
|
|
2304
2543
|
name: "pm-csv",
|
|
2305
|
-
version: "2026.10.
|
|
2544
|
+
version: "2026.10.30",
|
|
2306
2545
|
activate(api) {
|
|
2307
2546
|
registerTableSchemaCommands(api);
|
|
2547
|
+
registerJoinCommands(api);
|
|
2548
|
+
registerAdmissionCommand(api);
|
|
2308
2549
|
// -----------------------------------------------------------------------
|
|
2309
2550
|
// Schema: register an optional `csv_source` provenance field so imported
|
|
2310
2551
|
// items can record where they came from (set via `pm csv import --source`).
|
|
@@ -2361,7 +2602,7 @@ export default {
|
|
|
2361
2602
|
description: "Export a conflict-aware CSV edit session and immutable baseline JSON (maximum 1000 items).",
|
|
2362
2603
|
flags: [
|
|
2363
2604
|
{ long: "--output", value_name: "file", description: "New CSV filename; baseline uses <file>.session.json" },
|
|
2364
|
-
{ long: "--ids", value_name: "ids", description: "Comma-separated canonical item IDs (default: complete tracker)" },
|
|
2605
|
+
{ long: "--ids", value_name: "ids", description: "Comma-separated distinct canonical item IDs; aliases refuse (default: complete tracker)" },
|
|
2365
2606
|
],
|
|
2366
2607
|
async run(ctx) {
|
|
2367
2608
|
const output = ctx.options["output"];
|
|
@@ -2427,6 +2668,9 @@ export default {
|
|
|
2427
2668
|
"--key for idempotent re-import (upsert), --encoding for non-UTF-8 files, " +
|
|
2428
2669
|
"--source to record import provenance in the csv_source field, and " +
|
|
2429
2670
|
"--status/--type/--priority to import only matching rows (others are skipped). " +
|
|
2671
|
+
"Keyed updates preserve absent columns and empty cells; nonempty cells set supported fields. " +
|
|
2672
|
+
"Use none/null to clear parent/assignee/sprint/release/blocked_by; tags are additive and dependency edges are preserved. " +
|
|
2673
|
+
"Custom fields are export-only. Creates suppress implicit focus and planning defaults. " +
|
|
2430
2674
|
"Use --strict to fail before writing when row-level data issues are present.",
|
|
2431
2675
|
arguments: [{ name: "file", required: true, description: "CSV file to import" }],
|
|
2432
2676
|
intent: "import items from a CSV file into pm",
|
|
@@ -2445,7 +2689,7 @@ export default {
|
|
|
2445
2689
|
"pm csv import items.csv --dry-run",
|
|
2446
2690
|
"pm csv import headerless.csv --skip-headers # no header row, positional columns",
|
|
2447
2691
|
"pm csv import big.csv --stream # stream large files without loading into memory",
|
|
2448
|
-
"pm csv import tasks.csv --atomic #
|
|
2692
|
+
"pm csv import tasks.csv --atomic # SDK-coordinated, crash-recoverable import",
|
|
2449
2693
|
],
|
|
2450
2694
|
flags: [
|
|
2451
2695
|
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
@@ -2461,7 +2705,8 @@ export default {
|
|
|
2461
2705
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
2462
2706
|
{ 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)" },
|
|
2463
2707
|
{ long: "--stream", description: "Stream the file row-by-row instead of loading it fully into memory (recommended for large CSV files)" },
|
|
2464
|
-
{ long: "--
|
|
2708
|
+
{ long: "--admission", description: "Require a full bounded validation pass and the declared atomic materialization budgets; requires --atomic" },
|
|
2709
|
+
{ long: "--atomic", description: "Use one SDK-coordinated, 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" },
|
|
2465
2710
|
],
|
|
2466
2711
|
async run(ctx) {
|
|
2467
2712
|
const filePath = ctx.args[0];
|
|
@@ -2479,7 +2724,7 @@ export default {
|
|
|
2479
2724
|
ctx.sdk?.commitWorkspaceTransaction, ctx.global?.author ?? undefined);
|
|
2480
2725
|
}
|
|
2481
2726
|
catch (err) {
|
|
2482
|
-
if (err instanceof CommandError)
|
|
2727
|
+
if (err instanceof CommandError || err instanceof AdmissionRefusal)
|
|
2483
2728
|
throw err;
|
|
2484
2729
|
const msg = errorMessage(err);
|
|
2485
2730
|
const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
@@ -2697,4 +2942,6 @@ export { importCSV };
|
|
|
2697
2942
|
export { applyEditSession, exportEditSession, parseEditSession, editSessionHeader, EDIT_FIELDS, EditRefusal } from "./roundtrip.js";
|
|
2698
2943
|
export { importLineage, writeLineageBundle, lineageDigest, LINEAGE_FIELDS, LineageRefusal } from "./lineage.js";
|
|
2699
2944
|
export { describeTable, previewTable, parseTableSchema, importNegotiatedTable, TableSchemaRefusal } from "./table-schema.js";
|
|
2945
|
+
export { previewJoins, applyJoins, JoinRefusal } from "./join-plans.js";
|
|
2946
|
+
export { admitCSV, assertCSVAdmission, AdmissionRefusal, ADMISSION_LIMITS } from "./admission.js";
|
|
2700
2947
|
//# sourceMappingURL=index.js.map
|