@forzalabs/remora 2.5.0 → 2.6.0

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 CHANGED
@@ -6,6 +6,26 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## V 2.6.0 - 2026-10-26
10
+
11
+ **Upgrading from 2.5.** Nothing in an existing project changes, and nothing that compiled under 2.5 is refused. One thing moves, and it is not opt-out: a `CSV` or `TXT` export now **quotes a field only where the quotes carry meaning**, where it used to quote every field. The values and the nulls are the same values and the same nulls — `"USR-2","free"` is now `USR-2,free` — but the bytes are not, so a destination that matches the file against a fixture, or parses it by splitting on `","` rather than with a CSV reader, needs checking before this version runs. This is the second consecutive release to move those bytes, after 2.4.0's null marker; a proper CSV reader has been unaffected by both.
12
+
13
+ ### Changed
14
+
15
+ - **A positional field is quoted only when it needs to be.** Every field of a `CSV`/`TXT` export and of the internal staged dataset used to be wrapped in quotes unconditionally, which cost two bytes per field per row — written by each worker, concatenated by the merge, re-read by every post-processing pass, and shipped to the destination. A value is now written bare unless it holds the delimiter, a `"`, a line break, or a space at either end, and an **empty string stays quoted** because that is what keeps `""` (the empty string) apart from nothing at all (a null). The header line follows the same rule, so it still parses with the reader the data does.
16
+
17
+ ```
18
+ id,note,amount
19
+ USR-1,"",12.5 <- note is the empty string
20
+ USR-2,, <- note and amount are null
21
+ USR-3,"a, b",12.5 <- note holds the delimiter, so it is quoted
22
+ USR-4," padded ",0 <- note has edge spaces, so it is quoted
23
+ ```
24
+
25
+ **Measured -14.7% on the bytes** of a real 192MB export (201,795,101 → 172,095,071 over 990,000 records of 15 columns), which comes off every stage that reads or writes a staged file and off what the destination stores. The encode itself is 1.17x faster per row, and end to end the wall clock moves inside the harness's own ±6% noise floor — the bytes are the result here, not the throughput. A staged dataset written by an older version, where everything is quoted, still reads back identically, so nothing in a half-finished run is stranded.
26
+
27
+ - **Edge spaces are quoted deliberately**, which is a rule the original specification did not have. Most CSV readers trim an unquoted field — Remora's own does — so a value of ` padded ` written bare would come back shorter than it went out. Quoting it is what keeps the round trip exact, and it costs nothing measurable: the check rides in the same single pass that looks for the delimiter and the quote.
28
+
9
29
  ## V 2.5.0 - 2026-10-26
10
30
 
11
31
  **Upgrading from 2.4.** Nothing in an existing project changes. `FIXED` is a new output format and every rule this release adds fires only on a consumer that declares one, so no configuration needs touching, no exported bytes move, and nothing that compiled under 2.4 is refused. The one thing to know before writing a FIXED output is what the receiver has to do with the file: it leaves Remora as UTF-8 text with `lf` or `crlf` line terminators, so a z/OS target still needs the ASCII→EBCDIC and `RECFM=FB` conversion on its own side (see Added).
@@ -1042,11 +1042,11 @@ Records then legitimately differ in shape from one another, which is why it appl
1042
1042
 
1043
1043
  ### **The Header Line**
1044
1044
 
1045
- A `CSV` or `TXT` file is positional: it carries values and nothing that names them. So both formats write a **header line** first — the exported field names, in column order, quoted and delimited exactly as the rows under them, so the header parses with the same reader the data does:
1045
+ A `CSV` or `TXT` file is positional: it carries values and nothing that names them. So both formats write a **header line** first — the exported field names, in column order, quoted and delimited on exactly the rule the rows under it follow, so the header parses with the same reader the data does:
1046
1046
 
1047
1047
  ```
1048
- "id","primary_email","plan","country"
1049
- "USR-00001","alice.rossi@example.com","free","Italy"
1048
+ id,primary_email,plan,country
1049
+ USR-00001,alice.rossi@example.com,free,Italy
1050
1050
  ```
1051
1051
 
1052
1052
  The names are the exported ones: a field's `alias` where it has one, its `key` otherwise. A `hidden` field is not written, so it is not in the header either. With a `split`, **every** file of the output carries the header, not just the first one — each file has to stand on its own.
@@ -1070,20 +1070,28 @@ It only applies to `CSV` and `TXT`. On a `JSON` or `API` output each record alre
1070
1070
 
1071
1071
  ### **How a value is written**
1072
1072
 
1073
- A `CSV`/`TXT` file writes every value quoted, and that leaves the unquoted empty field free to mean
1074
- something: it is a **null**. An empty string is `""`. The two are different values, and on a
1075
- positional line this is the only way to tell them apart:
1073
+ A `CSV`/`TXT` file **quotes a value only where the quotes carry meaning**. A value is written bare
1074
+ unless it holds the delimiter, a `"`, a line break, or a space at either end — and an empty string
1075
+ is quoted too, because that is what leaves the unquoted empty field free to mean something else: it
1076
+ is a **null**. The two are different values, and on a positional line this is the only way to tell
1077
+ them apart:
1076
1078
 
1077
1079
  ```
1078
- "id","note","amount"
1079
- "USR-1","","12.5" <- note is the empty string
1080
- "USR-2",, <- note and amount are null
1080
+ id,note,amount
1081
+ USR-1,"",12.5 <- note is the empty string
1082
+ USR-2,, <- note and amount are null
1083
+ USR-3,"a, b",12.5 <- note holds the delimiter, so it is quoted
1084
+ USR-4," padded ",0 <- note has edge spaces, so it is quoted
1081
1085
  ```
1082
1086
 
1083
1087
  A reader that treats an unquoted empty field as "no value" — which most CSV readers do, and which is
1084
1088
  what `COPY`, `LOAD DATA` and a pandas `read_csv` all assume — gets a null back. A reader that does
1085
1089
  not sees an empty string, exactly as before.
1086
1090
 
1091
+ Edge spaces are quoted for the same reason the empty string is: most CSV readers, Remora's own
1092
+ included, trim an unquoted field, so ` padded ` written bare would come back shorter than it went
1093
+ out. Quoting it is what makes the round trip exact.
1094
+
1087
1095
  A value holding a **newline** or a carriage return is written as the two characters `\n` or `\r`.
1088
1096
  A line of a remora file is one record, everywhere in the pipeline, so a value carrying a real
1089
1097
  newline would otherwise arrive as two broken rows. The escape is one-way: nothing turns it back into
@@ -1307,7 +1315,7 @@ Apache Parquet: a typed, SNAPPY-compressed **columnar** file, which is what a da
1307
1315
  }
1308
1316
  ```
1309
1317
 
1310
- Two things it gives a reader that a `CSV` export cannot. The file **declares its types**, so nothing downstream has to guess them back out of the text. And it is much smaller: a four-column, 2-million-row dataset is about 28 MB as parquet against 115 MB as quoted CSV.
1318
+ Two things it gives a reader that a `CSV` export cannot. The file **declares its types**, so nothing downstream has to guess them back out of the text. And it is much smaller: a four-column, 2-million-row dataset is about 28 MB as parquet against roughly 100 MB as CSV.
1311
1319
 
1312
1320
  The schema comes from what the consumer already declares — each field's dimension type:
1313
1321
 
package/index.js CHANGED
@@ -11761,7 +11761,7 @@ var CONSTANTS, Constants_default;
11761
11761
  var init_Constants = __esm({
11762
11762
  "../../packages/constants/src/Constants.ts"() {
11763
11763
  CONSTANTS = {
11764
- cliVersion: "2.5.0",
11764
+ cliVersion: "2.6.0",
11765
11765
  backendVersion: 1,
11766
11766
  backendPort: 5088,
11767
11767
  workerVersion: 2,
@@ -33962,21 +33962,29 @@ var init_CSVParser = __esm({
33962
33962
  });
33963
33963
 
33964
33964
  // ../../packages/parsing/src/DatasetCodec.ts
33965
- var NULL_FIELD, NEEDS_ESCAPE, DatasetCodecClass, DatasetCodec, DatasetCodec_default;
33965
+ var NULL_FIELD, EMPTY_FIELD, NEEDS_ESCAPE, QUOTE_CODE, QUOTE_TESTS, DatasetCodecClass, DatasetCodec, DatasetCodec_default;
33966
33966
  var init_DatasetCodec = __esm({
33967
33967
  "../../packages/parsing/src/DatasetCodec.ts"() {
33968
33968
  init_src();
33969
33969
  NULL_FIELD = "";
33970
+ EMPTY_FIELD = '""';
33970
33971
  NEEDS_ESCAPE = /["\n\r]/;
33972
+ QUOTE_CODE = 34;
33973
+ QUOTE_TESTS = /* @__PURE__ */ new Map([[",", /^\s|\s$|[",\n\r]/]]);
33971
33974
  DatasetCodecClass = class {
33972
33975
  constructor() {
33973
33976
  /**
33974
- * One value as an internal positional field: quoted, with `"`, `\n` and `\r` escaped - or the
33975
- * unquoted null marker when there is no value.
33977
+ * One value as an internal positional field. Bare where it can be, quoted where it must be -
33978
+ * around the delimiter, a quote, a line break or an edge space - or the unquoted null marker
33979
+ * when there is no value. The delimiter is a parameter rather than a comma because `redelimit`
33980
+ * re-encodes a staged line with the export's own: a tab is safe bare in the staged file and is
33981
+ * not once that file is re-emitted tab-delimited.
33976
33982
  */
33977
- this.encodeField = (value) => {
33983
+ this.encodeField = (value, delimiter = ",") => {
33978
33984
  if (value === null || value === void 0) return NULL_FIELD;
33979
33985
  const text = typeof value === "string" ? value : String(value);
33986
+ if (text === "") return EMPTY_FIELD;
33987
+ if (!this._quoteTest(delimiter).test(text)) return text;
33980
33988
  if (!NEEDS_ESCAPE.test(text)) return `"${text}"`;
33981
33989
  return `"${text.replaceAll('"', '""').replaceAll("\r", "\\r").replaceAll("\n", "\\n")}"`;
33982
33990
  };
@@ -33988,7 +33996,7 @@ var init_DatasetCodec = __esm({
33988
33996
  for (let index = 0; index < values.length; index++) {
33989
33997
  if (index > 0)
33990
33998
  row += delimiter;
33991
- row += this.encodeField(values[index]);
33999
+ row += this.encodeField(values[index], delimiter);
33992
34000
  }
33993
34001
  return row;
33994
34002
  };
@@ -34008,26 +34016,26 @@ var init_DatasetCodec = __esm({
34008
34016
  let index = 0;
34009
34017
  while (index < length) {
34010
34018
  const char = line.charCodeAt(index);
34011
- if (char === 34) {
34019
+ if (char === QUOTE_CODE) {
34012
34020
  if (!inQuotes) {
34013
34021
  inQuotes = true;
34014
34022
  hasQuotes = true;
34015
34023
  fieldStart = index + 1;
34016
- } else if (line.charCodeAt(index + 1) === 34) {
34024
+ } else if (line.charCodeAt(index + 1) === QUOTE_CODE) {
34017
34025
  index++;
34018
34026
  } else {
34019
34027
  inQuotes = false;
34020
34028
  fieldEnd = index;
34021
34029
  }
34022
34030
  } else if (char === delimiterCode && !inQuotes) {
34023
- fields.push(this._field(line, fieldStart, fieldEnd, hasQuotes));
34031
+ fields.push(this._field(line, fieldStart, hasQuotes ? fieldEnd : index, hasQuotes));
34024
34032
  fieldStart = index + 1;
34025
34033
  fieldEnd = 0;
34026
34034
  hasQuotes = false;
34027
34035
  }
34028
34036
  index++;
34029
34037
  }
34030
- fields.push(this._field(line, fieldStart, fieldEnd, hasQuotes));
34038
+ fields.push(this._field(line, fieldStart, hasQuotes ? fieldEnd : length, hasQuotes));
34031
34039
  return fields;
34032
34040
  };
34033
34041
  /**
@@ -34038,9 +34046,24 @@ var init_DatasetCodec = __esm({
34038
34046
  Affirm_default(delimiter, "Invalid delimiter");
34039
34047
  return this.encodeRow(this.decodeRow(line), delimiter);
34040
34048
  };
34041
- /** One field's text, or null for the unquoted marker. Only a quoted token carries a value. */
34049
+ /**
34050
+ * The quoting test for one delimiter, built on first use. The four characters escaped into the
34051
+ * class are the ones a character class reads as syntax; anything else stands for itself there.
34052
+ */
34053
+ this._quoteTest = (delimiter) => {
34054
+ let test = QUOTE_TESTS.get(delimiter);
34055
+ if (!test) {
34056
+ test = new RegExp(`^\\s|\\s$|["\\n\\r${delimiter.replace(/[\\\]^-]/g, "\\$&")}]`);
34057
+ QUOTE_TESTS.set(delimiter, test);
34058
+ }
34059
+ return test;
34060
+ };
34061
+ /**
34062
+ * One field's text, or null for the unquoted empty marker. A bare token carries its value as it
34063
+ * stands - nothing bare can hold a quote, so there is nothing to unescape in it.
34064
+ */
34042
34065
  this._field = (line, start, end, hasQuotes) => {
34043
- if (!hasQuotes) return null;
34066
+ if (!hasQuotes) return start === end ? null : line.slice(start, end);
34044
34067
  const raw = line.slice(start, end);
34045
34068
  return raw.indexOf('"') === -1 ? raw : raw.replaceAll('""', '"');
34046
34069
  };
@@ -56695,7 +56718,7 @@ var init_OutputExecutor = __esm({
56695
56718
  for (let index = 0; index < visibleKeys.length; index++) {
56696
56719
  if (index > 0)
56697
56720
  row += delimiter;
56698
- row += DatasetCodec_default.encodeField(record[visibleKeys[index]]);
56721
+ row += DatasetCodec_default.encodeField(record[visibleKeys[index]], delimiter);
56699
56722
  }
56700
56723
  return row;
56701
56724
  };
@@ -56880,9 +56903,10 @@ var init_OutputExecutor = __esm({
56880
56903
  return resultList[0];
56881
56904
  };
56882
56905
  /**
56883
- * The header line of a positional export: the visible field names, in column order, quoted and
56884
- * delimited exactly as the rows under them — so the header parses with the same reader the data
56885
- * does. Null for a JSON/API output, where a record already carries its own field names, for a
56906
+ * The header line of a positional export: the visible field names, in column order, written
56907
+ * through the SAME codec as the rows under them — so a name is quoted on the same rule a value
56908
+ * is, and the header parses with the reader the data does. Null for a JSON/API output, where a
56909
+ * record already carries its own field names, for a
56886
56910
  * FIXED one, where a name does not fit the column it describes and the receiver reads by
56887
56911
  * position anyway, and for an output that turns the header off.
56888
56912
  *
@@ -56891,7 +56915,7 @@ var init_OutputExecutor = __esm({
56891
56915
  this._composeHeader = (output, dsSchema) => {
56892
56916
  if (output.header === false) return null;
56893
56917
  if (output.format !== "CSV" && output.format !== "TXT") return null;
56894
- return CSVParser_default.stringifyRow(DatasetSchema_default.names(dsSchema), this._getDelimiter(output));
56918
+ return DatasetCodec_default.encodeRow(DatasetSchema_default.names(dsSchema), this._getDelimiter(output));
56895
56919
  };
56896
56920
  /** The delimiter the exported file is written with. Only TXT configures it; CSV is a comma. */
56897
56921
  this._getDelimiter = (output) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forzalabs/remora",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "A powerful CLI tool for seamless data translation.",
5
5
  "main": "index.js",
6
6
  "private": false,
@@ -11809,7 +11809,7 @@ var CONSTANTS, Constants_default;
11809
11809
  var init_Constants = __esm({
11810
11810
  "../../packages/constants/src/Constants.ts"() {
11811
11811
  CONSTANTS = {
11812
- cliVersion: "2.5.0",
11812
+ cliVersion: "2.6.0",
11813
11813
  backendVersion: 1,
11814
11814
  backendPort: 5088,
11815
11815
  workerVersion: 2,
@@ -33745,21 +33745,29 @@ var init_CSVParser = __esm({
33745
33745
  });
33746
33746
 
33747
33747
  // ../../packages/parsing/src/DatasetCodec.ts
33748
- var NULL_FIELD, NEEDS_ESCAPE, DatasetCodecClass, DatasetCodec, DatasetCodec_default;
33748
+ var NULL_FIELD, EMPTY_FIELD, NEEDS_ESCAPE, QUOTE_CODE, QUOTE_TESTS, DatasetCodecClass, DatasetCodec, DatasetCodec_default;
33749
33749
  var init_DatasetCodec = __esm({
33750
33750
  "../../packages/parsing/src/DatasetCodec.ts"() {
33751
33751
  init_src();
33752
33752
  NULL_FIELD = "";
33753
+ EMPTY_FIELD = '""';
33753
33754
  NEEDS_ESCAPE = /["\n\r]/;
33755
+ QUOTE_CODE = 34;
33756
+ QUOTE_TESTS = /* @__PURE__ */ new Map([[",", /^\s|\s$|[",\n\r]/]]);
33754
33757
  DatasetCodecClass = class {
33755
33758
  constructor() {
33756
33759
  /**
33757
- * One value as an internal positional field: quoted, with `"`, `\n` and `\r` escaped - or the
33758
- * unquoted null marker when there is no value.
33760
+ * One value as an internal positional field. Bare where it can be, quoted where it must be -
33761
+ * around the delimiter, a quote, a line break or an edge space - or the unquoted null marker
33762
+ * when there is no value. The delimiter is a parameter rather than a comma because `redelimit`
33763
+ * re-encodes a staged line with the export's own: a tab is safe bare in the staged file and is
33764
+ * not once that file is re-emitted tab-delimited.
33759
33765
  */
33760
- this.encodeField = (value) => {
33766
+ this.encodeField = (value, delimiter = ",") => {
33761
33767
  if (value === null || value === void 0) return NULL_FIELD;
33762
33768
  const text = typeof value === "string" ? value : String(value);
33769
+ if (text === "") return EMPTY_FIELD;
33770
+ if (!this._quoteTest(delimiter).test(text)) return text;
33763
33771
  if (!NEEDS_ESCAPE.test(text)) return `"${text}"`;
33764
33772
  return `"${text.replaceAll('"', '""').replaceAll("\r", "\\r").replaceAll("\n", "\\n")}"`;
33765
33773
  };
@@ -33771,7 +33779,7 @@ var init_DatasetCodec = __esm({
33771
33779
  for (let index = 0; index < values.length; index++) {
33772
33780
  if (index > 0)
33773
33781
  row += delimiter;
33774
- row += this.encodeField(values[index]);
33782
+ row += this.encodeField(values[index], delimiter);
33775
33783
  }
33776
33784
  return row;
33777
33785
  };
@@ -33791,26 +33799,26 @@ var init_DatasetCodec = __esm({
33791
33799
  let index = 0;
33792
33800
  while (index < length) {
33793
33801
  const char = line.charCodeAt(index);
33794
- if (char === 34) {
33802
+ if (char === QUOTE_CODE) {
33795
33803
  if (!inQuotes) {
33796
33804
  inQuotes = true;
33797
33805
  hasQuotes = true;
33798
33806
  fieldStart = index + 1;
33799
- } else if (line.charCodeAt(index + 1) === 34) {
33807
+ } else if (line.charCodeAt(index + 1) === QUOTE_CODE) {
33800
33808
  index++;
33801
33809
  } else {
33802
33810
  inQuotes = false;
33803
33811
  fieldEnd = index;
33804
33812
  }
33805
33813
  } else if (char === delimiterCode && !inQuotes) {
33806
- fields.push(this._field(line, fieldStart, fieldEnd, hasQuotes));
33814
+ fields.push(this._field(line, fieldStart, hasQuotes ? fieldEnd : index, hasQuotes));
33807
33815
  fieldStart = index + 1;
33808
33816
  fieldEnd = 0;
33809
33817
  hasQuotes = false;
33810
33818
  }
33811
33819
  index++;
33812
33820
  }
33813
- fields.push(this._field(line, fieldStart, fieldEnd, hasQuotes));
33821
+ fields.push(this._field(line, fieldStart, hasQuotes ? fieldEnd : length, hasQuotes));
33814
33822
  return fields;
33815
33823
  };
33816
33824
  /**
@@ -33821,9 +33829,24 @@ var init_DatasetCodec = __esm({
33821
33829
  Affirm_default(delimiter, "Invalid delimiter");
33822
33830
  return this.encodeRow(this.decodeRow(line), delimiter);
33823
33831
  };
33824
- /** One field's text, or null for the unquoted marker. Only a quoted token carries a value. */
33832
+ /**
33833
+ * The quoting test for one delimiter, built on first use. The four characters escaped into the
33834
+ * class are the ones a character class reads as syntax; anything else stands for itself there.
33835
+ */
33836
+ this._quoteTest = (delimiter) => {
33837
+ let test = QUOTE_TESTS.get(delimiter);
33838
+ if (!test) {
33839
+ test = new RegExp(`^\\s|\\s$|["\\n\\r${delimiter.replace(/[\\\]^-]/g, "\\$&")}]`);
33840
+ QUOTE_TESTS.set(delimiter, test);
33841
+ }
33842
+ return test;
33843
+ };
33844
+ /**
33845
+ * One field's text, or null for the unquoted empty marker. A bare token carries its value as it
33846
+ * stands - nothing bare can hold a quote, so there is nothing to unescape in it.
33847
+ */
33825
33848
  this._field = (line, start, end, hasQuotes) => {
33826
- if (!hasQuotes) return null;
33849
+ if (!hasQuotes) return start === end ? null : line.slice(start, end);
33827
33850
  const raw = line.slice(start, end);
33828
33851
  return raw.indexOf('"') === -1 ? raw : raw.replaceAll('""', '"');
33829
33852
  };
@@ -56687,7 +56710,7 @@ var init_OutputExecutor = __esm({
56687
56710
  for (let index = 0; index < visibleKeys.length; index++) {
56688
56711
  if (index > 0)
56689
56712
  row += delimiter;
56690
- row += DatasetCodec_default.encodeField(record[visibleKeys[index]]);
56713
+ row += DatasetCodec_default.encodeField(record[visibleKeys[index]], delimiter);
56691
56714
  }
56692
56715
  return row;
56693
56716
  };
@@ -56872,9 +56895,10 @@ var init_OutputExecutor = __esm({
56872
56895
  return resultList[0];
56873
56896
  };
56874
56897
  /**
56875
- * The header line of a positional export: the visible field names, in column order, quoted and
56876
- * delimited exactly as the rows under them — so the header parses with the same reader the data
56877
- * does. Null for a JSON/API output, where a record already carries its own field names, for a
56898
+ * The header line of a positional export: the visible field names, in column order, written
56899
+ * through the SAME codec as the rows under them — so a name is quoted on the same rule a value
56900
+ * is, and the header parses with the reader the data does. Null for a JSON/API output, where a
56901
+ * record already carries its own field names, for a
56878
56902
  * FIXED one, where a name does not fit the column it describes and the receiver reads by
56879
56903
  * position anyway, and for an output that turns the header off.
56880
56904
  *
@@ -56883,7 +56907,7 @@ var init_OutputExecutor = __esm({
56883
56907
  this._composeHeader = (output, dsSchema) => {
56884
56908
  if (output.header === false) return null;
56885
56909
  if (output.format !== "CSV" && output.format !== "TXT") return null;
56886
- return CSVParser_default.stringifyRow(DatasetSchema_default.names(dsSchema), this._getDelimiter(output));
56910
+ return DatasetCodec_default.encodeRow(DatasetSchema_default.names(dsSchema), this._getDelimiter(output));
56887
56911
  };
56888
56912
  /** The delimiter the exported file is written with. Only TXT configures it; CSV is a comma. */
56889
56913
  this._getDelimiter = (output) => {