@jarenjs/josl 0.49.2 → 0.66.1
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/FORMAT.md +42 -5
- package/README.md +95 -6
- package/dist/types/csv-machine.d.ts +9 -0
- package/dist/types/csv-stream.d.ts +27 -3
- package/dist/types/csv.d.ts +24 -3
- package/dist/types/index.d.ts +4 -3
- package/dist/types/jsonx-stream.d.ts +9 -0
- package/dist/types/limits.d.ts +41 -0
- package/dist/types/machine.d.ts +16 -0
- package/dist/types/pull.d.ts +14 -0
- package/dist/types/stream.d.ts +20 -0
- package/dist/types/util.d.ts +12 -0
- package/dist/types/write.d.ts +24 -2
- package/package.json +2 -2
- package/src/csv-machine.js +75 -4
- package/src/csv-stream.js +83 -22
- package/src/csv.js +65 -36
- package/src/index.js +4 -2
- package/src/jsonx-stream.js +70 -6
- package/src/limits.js +96 -0
- package/src/machine.js +112 -13
- package/src/pull.js +40 -0
- package/src/stream.js +58 -0
- package/src/util.js +15 -0
- package/src/write.js +78 -13
package/src/csv-machine.js
CHANGED
|
@@ -48,8 +48,10 @@ import {
|
|
|
48
48
|
} from '@jarenjs/core/scan';
|
|
49
49
|
|
|
50
50
|
import { CsvSyntaxError } from './errors.js';
|
|
51
|
-
import {
|
|
51
|
+
import { JoslLimitError, limitOption } from './limits.js';
|
|
52
|
+
import { columnOf, feedMachine, beginParseAll, offsetDateTime } from './util.js';
|
|
52
53
|
import { setObjectMember } from '@jarenjs/core/object';
|
|
54
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
53
55
|
import {
|
|
54
56
|
LocalDate,
|
|
55
57
|
LocalTime,
|
|
@@ -182,9 +184,10 @@ export function coerceCsvValue(s) {
|
|
|
182
184
|
const frac = m[7] === undefined ? '' : '.' + m[7];
|
|
183
185
|
if (m[8] !== undefined || m[9] !== undefined) {
|
|
184
186
|
// an offset date-time is an instant, which `Date` holds faithfully
|
|
185
|
-
const offset = m[8]
|
|
186
|
-
const
|
|
187
|
-
|
|
187
|
+
const offset = m[8] ?? `${m[9]}${m[10]}:${m[11]}`;
|
|
188
|
+
const instant = offsetDateTime(new LocalDate(year, month, day),
|
|
189
|
+
new LocalTime(hour, min, sec, frac), offset);
|
|
190
|
+
return Number.isNaN(instant.getTime()) ? s : instant;
|
|
188
191
|
}
|
|
189
192
|
return new LocalDateTime(
|
|
190
193
|
new LocalDate(year, month, day),
|
|
@@ -241,6 +244,16 @@ export class CsvMachine {
|
|
|
241
244
|
this.onEvent = options.onEvent ?? null;
|
|
242
245
|
this.onRepair = options.onRepair ?? null;
|
|
243
246
|
|
|
247
|
+
// the hostile-input limits: Infinity unless asked for, checked while
|
|
248
|
+
// the text is still in cutter or cell state (limits.js)
|
|
249
|
+
this.maxTotalBytes = limitOption(options, 'maxTotalBytes');
|
|
250
|
+
this.maxRecordBytes = limitOption(options, 'maxRecordBytes');
|
|
251
|
+
this.maxFieldBytes = limitOption(options, 'maxFieldBytes');
|
|
252
|
+
this.maxColumns = limitOption(options, 'maxColumns');
|
|
253
|
+
this.limited = this.maxTotalBytes !== Infinity || this.maxRecordBytes !== Infinity
|
|
254
|
+
|| this.maxFieldBytes !== Infinity || this.maxColumns !== Infinity;
|
|
255
|
+
this.totalBytes = 0;
|
|
256
|
+
|
|
244
257
|
const headers = options.headers;
|
|
245
258
|
this.wantHeader = headers === true;
|
|
246
259
|
this.headerFields = Array.isArray(headers)
|
|
@@ -294,9 +307,38 @@ export class CsvMachine {
|
|
|
294
307
|
this.nextLf = -2;
|
|
295
308
|
if (this.nextCr === -1)
|
|
296
309
|
this.nextCr = -2;
|
|
310
|
+
if (this.limited)
|
|
311
|
+
this.count(chunk);
|
|
297
312
|
return feedMachine(this, chunk);
|
|
298
313
|
}
|
|
299
314
|
|
|
315
|
+
// The byte limits, checked BEFORE the chunk is buffered: a chunk that
|
|
316
|
+
// takes the document past maxTotalBytes is refused whole, and a record
|
|
317
|
+
// still being cut — the unconsumed tail plus this chunk — that would
|
|
318
|
+
// pass maxRecordBytes is refused before the concatenation that would
|
|
319
|
+
// hold it. `parseAll` counts its one text the same way.
|
|
320
|
+
count(text) {
|
|
321
|
+
if (this.maxTotalBytes !== Infinity) {
|
|
322
|
+
this.totalBytes += utf8ByteLength(text);
|
|
323
|
+
if (this.totalBytes > this.maxTotalBytes)
|
|
324
|
+
throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.line);
|
|
325
|
+
}
|
|
326
|
+
if (this.maxRecordBytes !== Infinity) {
|
|
327
|
+
// the pending record is what the cutter has not handed off yet;
|
|
328
|
+
// a fresh chunk extends it
|
|
329
|
+
const pending = utf8ByteLength(this.buf) + utf8ByteLength(text);
|
|
330
|
+
if (pending > this.maxRecordBytes && !this.endsRecordWithin(text))
|
|
331
|
+
throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Whether a chunk can end the pending record within the bound: cheap
|
|
336
|
+
// and permissive — a terminator anywhere in the chunk means the cutter
|
|
337
|
+
// gets its chance; the span check in readSpan is the exact judge.
|
|
338
|
+
endsRecordWithin(text) {
|
|
339
|
+
return text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
300
342
|
/**
|
|
301
343
|
* Finish the document, flushing any pending record.
|
|
302
344
|
* @returns {Array} The completed rows
|
|
@@ -326,6 +368,8 @@ export class CsvMachine {
|
|
|
326
368
|
*/
|
|
327
369
|
parseAll(text) {
|
|
328
370
|
text = beginParseAll(this, text);
|
|
371
|
+
if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
|
|
372
|
+
throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
|
|
329
373
|
this.readSpan(text, 0, text.length);
|
|
330
374
|
return this.outRows;
|
|
331
375
|
}
|
|
@@ -586,13 +630,29 @@ export class CsvMachine {
|
|
|
586
630
|
}
|
|
587
631
|
this.recordOrigin = this.line;
|
|
588
632
|
this.dropRecord = false;
|
|
633
|
+
const start = pos;
|
|
589
634
|
pos = this.parseRecord(text, pos, end, cells);
|
|
635
|
+
// the record's source bytes, terminator included — judged before
|
|
636
|
+
// the record is kept (the streaming path also refused it at every
|
|
637
|
+
// chunk boundary while it was still being cut)
|
|
638
|
+
if (this.maxRecordBytes !== Infinity && utf8ByteLength(text, start, pos) > this.maxRecordBytes)
|
|
639
|
+
throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
|
|
590
640
|
if (!this.dropRecord)
|
|
591
641
|
this.emitRecord(cells);
|
|
592
642
|
}
|
|
593
643
|
return pos;
|
|
594
644
|
}
|
|
595
645
|
|
|
646
|
+
// One cell about to be kept: its source bytes (quotes included, as
|
|
647
|
+
// written) and the column count are judged before the slice or the
|
|
648
|
+
// push that would hold it.
|
|
649
|
+
admitCell(cells, text, start, stop) {
|
|
650
|
+
if (this.maxFieldBytes !== Infinity && utf8ByteLength(text, start, stop) > this.maxFieldBytes)
|
|
651
|
+
throw new JoslLimitError('CSV2003', 'a field exceeds maxFieldBytes', this.maxFieldBytes, this.line);
|
|
652
|
+
if (cells.length >= this.maxColumns)
|
|
653
|
+
throw new JoslLimitError('CSV2004', 'a record has more than maxColumns fields', this.maxColumns, this.recordOrigin);
|
|
654
|
+
}
|
|
655
|
+
|
|
596
656
|
// The single grammar path: fill `cells` with one record's fields and
|
|
597
657
|
// return the offset just past the record, including its terminator.
|
|
598
658
|
parseRecord(text, pos, end, cells) {
|
|
@@ -668,6 +728,8 @@ export class CsvMachine {
|
|
|
668
728
|
stop = nlf;
|
|
669
729
|
if (ncr >= 0 && ncr < stop)
|
|
670
730
|
stop = ncr;
|
|
731
|
+
if (this.limited)
|
|
732
|
+
this.admitCell(cells, text, pos, stop);
|
|
671
733
|
const raw = text.slice(pos, stop);
|
|
672
734
|
cells.push(this.plainCells ? raw : this.finish(raw));
|
|
673
735
|
return stop;
|
|
@@ -677,6 +739,7 @@ export class CsvMachine {
|
|
|
677
739
|
parseQuoted(text, pos, end, cells) {
|
|
678
740
|
const quote = this.quote;
|
|
679
741
|
const delim = this.delimiter;
|
|
742
|
+
const opened = pos;
|
|
680
743
|
pos++; // opening quote
|
|
681
744
|
let start = pos;
|
|
682
745
|
let out = null;
|
|
@@ -685,6 +748,8 @@ export class CsvMachine {
|
|
|
685
748
|
// Out of input with the field still open. Closing it here is the
|
|
686
749
|
// only reading that keeps the text.
|
|
687
750
|
this.heal('CSV1001', this.recordOrigin, columnOf(text, pos));
|
|
751
|
+
if (this.limited)
|
|
752
|
+
this.admitCell(cells, text, opened, pos);
|
|
688
753
|
cells.push(this.finishQuoted(joinCell(out, text, start, pos)));
|
|
689
754
|
return pos;
|
|
690
755
|
}
|
|
@@ -697,12 +762,16 @@ export class CsvMachine {
|
|
|
697
762
|
}
|
|
698
763
|
const n = pos + 1 < end ? text.charCodeAt(pos + 1) : -1;
|
|
699
764
|
if (n === quote) { // "" — one literal quote
|
|
765
|
+
if (this.maxFieldBytes !== Infinity && utf8ByteLength(text, opened, pos + 2) > this.maxFieldBytes)
|
|
766
|
+
throw new JoslLimitError('CSV2003', 'a field exceeds maxFieldBytes', this.maxFieldBytes, this.line);
|
|
700
767
|
out = (out === null ? '' : out) + text.slice(start, pos + 1);
|
|
701
768
|
pos += 2;
|
|
702
769
|
start = pos;
|
|
703
770
|
continue;
|
|
704
771
|
}
|
|
705
772
|
if (n === delim || n === CC_LF || n === CC_CR || n === -1) {
|
|
773
|
+
if (this.limited)
|
|
774
|
+
this.admitCell(cells, text, opened, pos + 1);
|
|
706
775
|
cells.push(this.finishQuoted(joinCell(out, text, start, pos)));
|
|
707
776
|
return pos + 1; // past the closing quote
|
|
708
777
|
}
|
|
@@ -733,6 +802,8 @@ export class CsvMachine {
|
|
|
733
802
|
break;
|
|
734
803
|
pos++;
|
|
735
804
|
}
|
|
805
|
+
if (this.limited)
|
|
806
|
+
this.admitCell(cells, text, opened, pos);
|
|
736
807
|
cells.push(this.finishQuoted(closed + text.slice(stray, pos)));
|
|
737
808
|
return pos;
|
|
738
809
|
}
|
package/src/csv-stream.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// test suite asserts exactly that at every chunk size.
|
|
10
10
|
|
|
11
11
|
import { CsvMachine } from './csv-machine.js';
|
|
12
|
-
import { stringifyCsvChunks, formatCsvValue } from './csv.js';
|
|
12
|
+
import { stringifyCsvChunks, formatCsvValue, createCsvRowFormatter } from './csv.js';
|
|
13
|
+
import { closeIterator, abortedError } from './pull.js';
|
|
13
14
|
|
|
14
15
|
//#region reading
|
|
15
16
|
|
|
@@ -83,22 +84,82 @@ export async function parseCsvStream(chunks, options = undefined) {
|
|
|
83
84
|
*/
|
|
84
85
|
export async function* iterateCsvStream(chunks, options = undefined) {
|
|
85
86
|
const machine = new CsvMachine(options);
|
|
87
|
+
const signal = options?.signal ?? null;
|
|
86
88
|
const pending = machine.rows();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
const iterator = chunks[Symbol.asyncIterator]?.() ?? chunks[Symbol.iterator]();
|
|
90
|
+
let finished = false;
|
|
91
|
+
try {
|
|
92
|
+
for (;;) {
|
|
93
|
+
if (signal !== null && signal.aborted)
|
|
94
|
+
throw abortedError(signal);
|
|
95
|
+
const step = await iterator.next();
|
|
96
|
+
if (step.done) {
|
|
97
|
+
finished = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
machine.feed(step.value);
|
|
101
|
+
if (pending.length !== 0) {
|
|
102
|
+
// hand over the completed rows and drop them, so the machine never
|
|
103
|
+
// accumulates the document it is streaming
|
|
104
|
+
const batch = pending.splice(0, pending.length);
|
|
105
|
+
for (const row of batch)
|
|
106
|
+
yield row;
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
}
|
|
110
|
+
finally {
|
|
111
|
+
// a consumer that stops early, an abort, or a throw: the source is
|
|
112
|
+
// closed exactly once; a source read to its end needs no close
|
|
113
|
+
if (!finished)
|
|
114
|
+
await closeIterator(iterator);
|
|
115
|
+
}
|
|
97
116
|
machine.end();
|
|
98
117
|
for (const row of pending.splice(0, pending.length))
|
|
99
118
|
yield row;
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Serialize an async iterable of records as an async iterable of CSV
|
|
123
|
+
* text chunks — the pull form of `stringifyCsvChunks`, byte-identical
|
|
124
|
+
* to it for the same records: the header exactly once before the first
|
|
125
|
+
* object row, one line per chunk. Pull is the backpressure: the next
|
|
126
|
+
* record is requested only when the consumer asks for the next chunk,
|
|
127
|
+
* so a database cursor behind it never runs ahead of the socket in
|
|
128
|
+
* front of it. `options.signal` aborts between pulls (the rejection is
|
|
129
|
+
* the signal's reason); an abort, a consumer that stops early or a throw
|
|
130
|
+
* closes the record source exactly once.
|
|
131
|
+
* @param {AsyncIterable<Array|object>|Iterable<Array|object>} rows - Records
|
|
132
|
+
* @param {object} [options] - Writer options; see `stringifyCsv`, plus `signal`
|
|
133
|
+
* @yields {string} One line at a time
|
|
134
|
+
* @example
|
|
135
|
+
* response.body = stringifyCsvStream(store.collection('rows').query(doc), { signal });
|
|
136
|
+
*/
|
|
137
|
+
export async function* stringifyCsvStream(rows, options = {}) {
|
|
138
|
+
const formatter = createCsvRowFormatter(options);
|
|
139
|
+
const signal = options.signal ?? null;
|
|
140
|
+
const iterator = rows[Symbol.asyncIterator]?.() ?? rows[Symbol.iterator]();
|
|
141
|
+
let finished = false;
|
|
142
|
+
try {
|
|
143
|
+
for (;;) {
|
|
144
|
+
if (signal !== null && signal.aborted)
|
|
145
|
+
throw abortedError(signal);
|
|
146
|
+
const step = await iterator.next();
|
|
147
|
+
if (step.done) {
|
|
148
|
+
finished = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
yield* formatter.lines(step.value);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
if (!finished)
|
|
156
|
+
await closeIterator(iterator);
|
|
157
|
+
}
|
|
158
|
+
const tail = formatter.tail();
|
|
159
|
+
if (tail.length !== 0)
|
|
160
|
+
yield tail;
|
|
161
|
+
}
|
|
162
|
+
|
|
102
163
|
//#endregion
|
|
103
164
|
|
|
104
165
|
//#region writing
|
|
@@ -114,8 +175,7 @@ export class CsvStreamWriter {
|
|
|
114
175
|
this.options = options;
|
|
115
176
|
this.chunks = [];
|
|
116
177
|
this.onChunk = options.onChunk ?? null;
|
|
117
|
-
this.
|
|
118
|
-
this.wroteHeader = options.header === false;
|
|
178
|
+
this.formatter = createCsvRowFormatter(options);
|
|
119
179
|
}
|
|
120
180
|
|
|
121
181
|
#emit(text) {
|
|
@@ -126,23 +186,19 @@ export class CsvStreamWriter {
|
|
|
126
186
|
return this;
|
|
127
187
|
}
|
|
128
188
|
|
|
189
|
+
/** @returns {string[]|null} The columns as decided, once known. */
|
|
190
|
+
get fields() {
|
|
191
|
+
return this.formatter.fields();
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
/**
|
|
130
195
|
* Write one record.
|
|
131
196
|
* @param {Array|object} row - An array, or an object keyed by column
|
|
132
197
|
* @returns {this} The writer, for chaining
|
|
133
198
|
*/
|
|
134
199
|
write(row) {
|
|
135
|
-
|
|
136
|
-
this.fields = Object.keys(row);
|
|
137
|
-
for (const chunk of stringifyCsvChunks([row], {
|
|
138
|
-
...this.options,
|
|
139
|
-
fields: this.fields,
|
|
140
|
-
header: !this.wroteHeader && this.options.header !== false,
|
|
141
|
-
})) {
|
|
200
|
+
for (const chunk of this.formatter.lines(row))
|
|
142
201
|
this.#emit(chunk);
|
|
143
|
-
}
|
|
144
|
-
if (!Array.isArray(row))
|
|
145
|
-
this.wroteHeader = true;
|
|
146
202
|
return this;
|
|
147
203
|
}
|
|
148
204
|
|
|
@@ -166,10 +222,14 @@ export class CsvStreamWriter {
|
|
|
166
222
|
}
|
|
167
223
|
|
|
168
224
|
/**
|
|
169
|
-
* Finish writing
|
|
225
|
+
* Finish writing: the header an explicit field list is still owed
|
|
226
|
+
* goes out when no record was written.
|
|
170
227
|
* @returns {string} The complete document, or `''` with an `onChunk` sink
|
|
171
228
|
*/
|
|
172
229
|
end() {
|
|
230
|
+
const tail = this.formatter.tail();
|
|
231
|
+
if (tail.length !== 0)
|
|
232
|
+
this.#emit(tail);
|
|
173
233
|
return this.toString();
|
|
174
234
|
}
|
|
175
235
|
}
|
|
@@ -192,5 +252,6 @@ export function createCsvStreamWriter(options = undefined) {
|
|
|
192
252
|
//#endregion
|
|
193
253
|
|
|
194
254
|
export { stringifyCsvChunks, formatCsvValue };
|
|
255
|
+
export { JoslLimitError, CSV_LIMIT_CODES } from './limits.js';
|
|
195
256
|
|
|
196
257
|
//#endregion
|
package/src/csv.js
CHANGED
|
@@ -250,8 +250,10 @@ function needsQuoteTester(delimiter, quote) {
|
|
|
250
250
|
|
|
251
251
|
/**
|
|
252
252
|
* Render one value as CSV cell text. `null`/`undefined` become empty,
|
|
253
|
-
* bigints lose the `n` suffix JOSL uses (CSV has no type marks),
|
|
254
|
-
* JOSL date classes and `Date` render as ISO-8601
|
|
253
|
+
* bigints lose the `n` suffix JOSL uses (CSV has no type marks), the
|
|
254
|
+
* JOSL date classes and `Date` render as ISO-8601, and a plain object or
|
|
255
|
+
* an array — a nested document in a flat format — is its JSON text, so
|
|
256
|
+
* a reader gets the value back instead of `[object Object]`.
|
|
255
257
|
* @param {*} value - The value
|
|
256
258
|
* @returns {string} Cell text, unquoted
|
|
257
259
|
*/
|
|
@@ -267,6 +269,8 @@ export function formatCsvValue(value) {
|
|
|
267
269
|
}
|
|
268
270
|
if (value instanceof Date)
|
|
269
271
|
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
|
272
|
+
if (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
273
|
+
return JSON.stringify(value);
|
|
270
274
|
// LocalDate / LocalTime / LocalDateTime, and anything else that knows
|
|
271
275
|
// how to render itself
|
|
272
276
|
return String(value);
|
|
@@ -301,13 +305,19 @@ export function stringifyCsv(rows, options = {}) {
|
|
|
301
305
|
}
|
|
302
306
|
|
|
303
307
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
308
|
+
* The one row formatter behind every CSV writer: the same delimiter,
|
|
309
|
+
* quote and terminator rules and the same header-once rule, whether
|
|
310
|
+
* rows arrive as an array, an iterable, an async iterable or one at a
|
|
311
|
+
* time through the stream writer — so all of them produce byte-identical
|
|
312
|
+
* text for the same records by construction.
|
|
307
313
|
* @param {object} [options] - Writer options; see `stringifyCsv`
|
|
308
|
-
* @
|
|
314
|
+
* @returns {{ lines: (row: Array|object) => string[], tail: () => string, fields: () => string[] | null }}
|
|
315
|
+
* `lines` formats one record as its line — preceded by the header
|
|
316
|
+
* line exactly once, before the first object row — `tail` answers the
|
|
317
|
+
* header an explicit field list is still owed when no record was
|
|
318
|
+
* ever written, `fields` the columns as decided
|
|
309
319
|
*/
|
|
310
|
-
export function
|
|
320
|
+
export function createCsvRowFormatter(options = {}) {
|
|
311
321
|
const delimiter = options.delimiter ?? ',';
|
|
312
322
|
const quote = options.quote ?? '"';
|
|
313
323
|
const newline = options.newline ?? '\r\n';
|
|
@@ -321,39 +331,58 @@ export function* stringifyCsvChunks(rows, options = {}) {
|
|
|
321
331
|
return s;
|
|
322
332
|
return quote + (s.includes(quote) ? s.replaceAll(quote, escaped) : s) + quote;
|
|
323
333
|
};
|
|
334
|
+
const line = (values) => {
|
|
335
|
+
let out = '';
|
|
336
|
+
for (let i = 0; i < values.length; i++)
|
|
337
|
+
out += (i === 0 ? '' : delimiter) + cell(values[i]);
|
|
338
|
+
return out + newline;
|
|
339
|
+
};
|
|
324
340
|
|
|
325
341
|
let fields = options.fields ?? null;
|
|
326
342
|
let emittedHeader = false;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
if (fields === null)
|
|
336
|
-
fields = Object.keys(row);
|
|
337
|
-
if (wantHeader && !emittedHeader) {
|
|
338
|
-
emittedHeader = true;
|
|
339
|
-
let head = '';
|
|
343
|
+
return {
|
|
344
|
+
lines(row) {
|
|
345
|
+
if (Array.isArray(row))
|
|
346
|
+
return [line(row)];
|
|
347
|
+
if (fields === null)
|
|
348
|
+
fields = Object.keys(row);
|
|
349
|
+
const values = new Array(fields.length);
|
|
340
350
|
for (let i = 0; i < fields.length; i++)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
line
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
351
|
+
values[i] = row[fields[i]];
|
|
352
|
+
if (wantHeader && !emittedHeader) {
|
|
353
|
+
emittedHeader = true;
|
|
354
|
+
return [line(fields), line(values)];
|
|
355
|
+
}
|
|
356
|
+
return [line(values)];
|
|
357
|
+
},
|
|
358
|
+
tail() {
|
|
359
|
+
// an explicit field list still deserves its header when there
|
|
360
|
+
// were no records to infer one from
|
|
361
|
+
if (wantHeader && !emittedHeader && fields !== null && options.fields !== undefined) {
|
|
362
|
+
emittedHeader = true;
|
|
363
|
+
return line(fields);
|
|
364
|
+
}
|
|
365
|
+
return '';
|
|
366
|
+
},
|
|
367
|
+
fields: () => fields,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Serialize records as an iterable of chunks, one record at a time, so a
|
|
373
|
+
* large table never exists as a single string. The header precedes the
|
|
374
|
+
* first object row; a chunk is one line.
|
|
375
|
+
* @param {Iterable<Array|object>} rows - Records
|
|
376
|
+
* @param {object} [options] - Writer options; see `stringifyCsv`
|
|
377
|
+
* @yields {string} One record (or the header) at a time
|
|
378
|
+
*/
|
|
379
|
+
export function* stringifyCsvChunks(rows, options = {}) {
|
|
380
|
+
const formatter = createCsvRowFormatter(options);
|
|
381
|
+
for (const row of rows)
|
|
382
|
+
yield* formatter.lines(row);
|
|
383
|
+
const tail = formatter.tail();
|
|
384
|
+
if (tail.length !== 0)
|
|
385
|
+
yield tail;
|
|
357
386
|
}
|
|
358
387
|
|
|
359
388
|
//#endregion
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
export { parseJosl, parseToml } from './parse.js';
|
|
10
10
|
export { parseJoslCst, parseTomlCst, JoslCstDocument } from './cst.js';
|
|
11
|
-
export { createStreamReader, parseJoslStream } from './stream.js';
|
|
11
|
+
export { createStreamReader, parseJoslStream, iterateJoslStream } from './stream.js';
|
|
12
12
|
export {
|
|
13
13
|
stringifyJosl,
|
|
14
14
|
stringifyToml,
|
|
@@ -17,7 +17,7 @@ export {
|
|
|
17
17
|
formatValue,
|
|
18
18
|
formatSection,
|
|
19
19
|
} from './stringify.js';
|
|
20
|
-
export { createStreamWriter, stringifyJoslChunks } from './write.js';
|
|
20
|
+
export { createStreamWriter, stringifyJoslChunks, stringifyJoslStream } from './write.js';
|
|
21
21
|
export { toGbnf, tomlToGbnf } from './gbnf.js';
|
|
22
22
|
export { parseJsonx, stringifyJsonx } from './jsonx.js';
|
|
23
23
|
export { createJsonxStreamReader, parseJsonxStream } from './jsonx-stream.js';
|
|
@@ -35,10 +35,12 @@ export {
|
|
|
35
35
|
createCsvStreamReader,
|
|
36
36
|
parseCsvStream,
|
|
37
37
|
iterateCsvStream,
|
|
38
|
+
stringifyCsvStream,
|
|
38
39
|
createCsvStreamWriter,
|
|
39
40
|
CsvStreamWriter,
|
|
40
41
|
} from './csv-stream.js';
|
|
41
42
|
export { JoslSyntaxError, JoslStringifyError, JsonxSyntaxError, CsvSyntaxError } from './errors.js';
|
|
43
|
+
export { JoslLimitError, CSV_LIMIT_CODES, JOSL_LIMIT_CODES, JSONX_LIMIT_CODES } from './limits.js';
|
|
42
44
|
export {
|
|
43
45
|
LocalDate,
|
|
44
46
|
LocalTime,
|
package/src/jsonx-stream.js
CHANGED
|
@@ -79,7 +79,9 @@ import {
|
|
|
79
79
|
} from '@jarenjs/core/scan';
|
|
80
80
|
|
|
81
81
|
import { JsonxSyntaxError } from './errors.js';
|
|
82
|
+
import { JoslLimitError, limitOption } from './limits.js';
|
|
82
83
|
import { setObjectMember } from '@jarenjs/core/object';
|
|
84
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
83
85
|
import {
|
|
84
86
|
isValueEndCode,
|
|
85
87
|
decodeString,
|
|
@@ -154,6 +156,13 @@ export class JsonxMachine {
|
|
|
154
156
|
this.onEvent = options.onEvent ?? null;
|
|
155
157
|
this.partialText = options.partialText === true;
|
|
156
158
|
this.detach = options.detach === undefined ? null : detachPattern(options.detach);
|
|
159
|
+
// the hostile-input limits: Infinity unless asked for (limits.js)
|
|
160
|
+
this.maxTotalBytes = limitOption(options, 'maxTotalBytes');
|
|
161
|
+
this.maxTokenBytes = limitOption(options, 'maxTokenBytes');
|
|
162
|
+
this.maxDepth = limitOption(options, 'maxDepth');
|
|
163
|
+
this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
|
|
164
|
+
this.totalBytes = 0;
|
|
165
|
+
this.retained = 0; // values linked into the tree
|
|
157
166
|
this.partialFrom = -1; // body offset the next text-partial delta starts at
|
|
158
167
|
this.partialHold = ''; // lone high surrogate held back for the next delta
|
|
159
168
|
this.buf = '';
|
|
@@ -188,12 +197,38 @@ export class JsonxMachine {
|
|
|
188
197
|
if (this.ended)
|
|
189
198
|
throw new Error('cannot feed after end()');
|
|
190
199
|
if (chunk.length !== 0) {
|
|
200
|
+
if (this.maxTotalBytes !== Infinity) {
|
|
201
|
+
this.totalBytes += utf8ByteLength(chunk);
|
|
202
|
+
if (this.totalBytes > this.maxTotalBytes)
|
|
203
|
+
throw new JoslLimitError('JSONX2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.curLine);
|
|
204
|
+
}
|
|
205
|
+
if (this.maxTokenBytes !== Infinity && this.scanPos >= 0) {
|
|
206
|
+
// a token still being scanned grows by this chunk: refused
|
|
207
|
+
// before the concatenation that would hold it
|
|
208
|
+
const pending = utf8ByteLength(this.buf, this.pos, this.buf.length) + utf8ByteLength(chunk);
|
|
209
|
+
if (pending > this.maxTokenBytes)
|
|
210
|
+
throw new JoslLimitError('JSONX2002', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.curLine);
|
|
211
|
+
}
|
|
191
212
|
this.buf += chunk;
|
|
192
213
|
this.pump();
|
|
193
214
|
}
|
|
194
215
|
return this;
|
|
195
216
|
}
|
|
196
217
|
|
|
218
|
+
// A token whose extent is known, judged before it is decoded.
|
|
219
|
+
token(buf, start, end) {
|
|
220
|
+
if (this.maxTokenBytes !== Infinity && utf8ByteLength(buf, start, end) > this.maxTokenBytes)
|
|
221
|
+
throw new JoslLimitError('JSONX2002', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.curLine);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// One more value linked into the tree, judged before the link. A
|
|
225
|
+
// detached value is never linked and never counted: that is what
|
|
226
|
+
// detaching means.
|
|
227
|
+
retain() {
|
|
228
|
+
if (++this.retained > this.maxRetainedValues)
|
|
229
|
+
throw new JoslLimitError('JSONX2004', 'the root retains more than maxRetainedValues values', this.maxRetainedValues, this.curLine);
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
/**
|
|
198
233
|
* Finish the document, flushing any pending token.
|
|
199
234
|
* @returns {*} The completed root value
|
|
@@ -301,6 +336,7 @@ export class JsonxMachine {
|
|
|
301
336
|
const end = this.scanString(buf, pos);
|
|
302
337
|
if (end < 0)
|
|
303
338
|
break pumping;
|
|
339
|
+
this.token(buf, pos, end);
|
|
304
340
|
this.stack[this.stack.length - 1].key = decodeString(buf, pos, this.errCb)[0];
|
|
305
341
|
this.state = ST_COLON;
|
|
306
342
|
pos = end;
|
|
@@ -394,6 +430,7 @@ export class JsonxMachine {
|
|
|
394
430
|
this.emitPartialText(buf, pos, buf.length, true);
|
|
395
431
|
return -1;
|
|
396
432
|
}
|
|
433
|
+
this.token(buf, pos, end);
|
|
397
434
|
const value = decodeString(buf, pos, this.errCb)[0];
|
|
398
435
|
if (this.partialText) {
|
|
399
436
|
// the closing delta completes the run, so the deltas for a string
|
|
@@ -408,8 +445,10 @@ export class JsonxMachine {
|
|
|
408
445
|
if (c === CC_SLASH) {
|
|
409
446
|
if (this.mode === 'json')
|
|
410
447
|
this.errAt(pos, 'a regexp literal is a JSONX extension', 'quote the pattern as a string');
|
|
411
|
-
|
|
448
|
+
const scanned = this.scanRegExp(buf, pos);
|
|
449
|
+
if (scanned < 0)
|
|
412
450
|
return -1;
|
|
451
|
+
this.token(buf, pos, scanned);
|
|
413
452
|
const [re, end] = matchRegExp(buf, pos, this.errCb);
|
|
414
453
|
this.checkValueEnd(buf, end);
|
|
415
454
|
this.completeScalar(re);
|
|
@@ -420,6 +459,7 @@ export class JsonxMachine {
|
|
|
420
459
|
const end = this.scanScalar(buf, pos);
|
|
421
460
|
if (end < 0)
|
|
422
461
|
return -1;
|
|
462
|
+
this.token(buf, pos, end);
|
|
423
463
|
return this.decodeScalarToken(buf, pos, c);
|
|
424
464
|
}
|
|
425
465
|
|
|
@@ -525,9 +565,13 @@ export class JsonxMachine {
|
|
|
525
565
|
const index = frame.count++;
|
|
526
566
|
// A detached scalar is not stored: the `item` event below already
|
|
527
567
|
// carries it, so linking it in would retain exactly what the
|
|
528
|
-
// caller asked not to retain.
|
|
529
|
-
|
|
568
|
+
// caller asked not to retain. A value inside a detached subtree is
|
|
569
|
+
// linked into that subtree, which the root never holds: not counted.
|
|
570
|
+
if (!this.detaches(index)) {
|
|
571
|
+
if (!frame.detached)
|
|
572
|
+
this.retain();
|
|
530
573
|
frame.value[index] = value;
|
|
574
|
+
}
|
|
531
575
|
if (this.onEvent !== null)
|
|
532
576
|
this.onEvent({
|
|
533
577
|
type: 'item',
|
|
@@ -539,8 +583,11 @@ export class JsonxMachine {
|
|
|
539
583
|
this.state = ST_ARR_NEXT;
|
|
540
584
|
}
|
|
541
585
|
else {
|
|
542
|
-
if (!this.detaches(frame.key))
|
|
586
|
+
if (!this.detaches(frame.key)) {
|
|
587
|
+
if (!frame.detached)
|
|
588
|
+
this.retain();
|
|
543
589
|
setObjectMember(frame.value, frame.key, value);
|
|
590
|
+
}
|
|
544
591
|
if (this.onEvent !== null)
|
|
545
592
|
this.onEvent({
|
|
546
593
|
type: 'pair',
|
|
@@ -561,7 +608,12 @@ export class JsonxMachine {
|
|
|
561
608
|
openContainer(isArray) {
|
|
562
609
|
const container = isArray ? [] : {};
|
|
563
610
|
const stack = this.stack;
|
|
611
|
+
if (stack.length + 1 > this.maxDepth)
|
|
612
|
+
throw new JoslLimitError('JSONX2003', 'the document nests deeper than maxDepth', this.maxDepth, this.curLine);
|
|
564
613
|
const parent = stack.length !== 0 ? stack[stack.length - 1] : null;
|
|
614
|
+
// whether this container lives outside the root: detached itself, or
|
|
615
|
+
// inside a detached subtree — nothing linked into it counts as retained
|
|
616
|
+
let detached = parent !== null && parent.detached;
|
|
565
617
|
if (parent === null)
|
|
566
618
|
this.rootValue = container;
|
|
567
619
|
else if (parent.array) {
|
|
@@ -571,13 +623,23 @@ export class JsonxMachine {
|
|
|
571
623
|
// so the memory is not freed later — it is not held in the first
|
|
572
624
|
// place, and a document with a million records never builds a
|
|
573
625
|
// million-slot array either.
|
|
574
|
-
if (
|
|
626
|
+
if (this.detaches(index))
|
|
627
|
+
detached = true;
|
|
628
|
+
else {
|
|
629
|
+
if (!detached)
|
|
630
|
+
this.retain();
|
|
575
631
|
parent.value[index] = container;
|
|
632
|
+
}
|
|
576
633
|
this.path.push(index);
|
|
577
634
|
}
|
|
578
635
|
else {
|
|
579
|
-
if (
|
|
636
|
+
if (this.detaches(parent.key))
|
|
637
|
+
detached = true;
|
|
638
|
+
else {
|
|
639
|
+
if (!detached)
|
|
640
|
+
this.retain();
|
|
580
641
|
setObjectMember(parent.value, parent.key, container);
|
|
642
|
+
}
|
|
581
643
|
this.path.push(parent.key);
|
|
582
644
|
}
|
|
583
645
|
stack.push({
|
|
@@ -586,6 +648,7 @@ export class JsonxMachine {
|
|
|
586
648
|
key: undefined,
|
|
587
649
|
count: 0,
|
|
588
650
|
pathed: parent !== null,
|
|
651
|
+
detached,
|
|
589
652
|
});
|
|
590
653
|
if (this.onEvent !== null)
|
|
591
654
|
this.onEvent({
|
|
@@ -802,5 +865,6 @@ export async function parseJsonxStream(chunks, options = undefined) {
|
|
|
802
865
|
}
|
|
803
866
|
|
|
804
867
|
export { JsonxSyntaxError } from './errors.js';
|
|
868
|
+
export { JoslLimitError, JSONX_LIMIT_CODES } from './limits.js';
|
|
805
869
|
|
|
806
870
|
//#endregion
|