@jarenjs/josl 0.56.0 → 0.67.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/src/limits.js ADDED
@@ -0,0 +1,96 @@
1
+ //#region hostile-input limits
2
+ // The three readers and the CSV machine take the same shape of guard:
3
+ // a limit that defaults to Infinity — nothing in this package refuses a
4
+ // document by size unless a caller asks — and, once asked, is checked
5
+ // while the offending text is still in cutter, token or container
6
+ // state, before a concatenation or a link could cross it. Every limit
7
+ // counts UTF-8 bytes, never JavaScript code units: a limit stated in
8
+ // bytes is the one an HTTP body limit, a disk quota or a proxy speaks.
9
+ //
10
+ // A crossing is a `JoslLimitError` with a stable code — `CSV2xxx`,
11
+ // `JOSL2xxx`, `JSONX2xxx` — and the limit that was crossed. It is never
12
+ // a repair: repair mode heals damaged syntax, and a document that is
13
+ // too large is not damaged, it is refused.
14
+
15
+ /**
16
+ * The CSV limits, by option name.
17
+ * @type {Readonly<Record<'CSV2001' | 'CSV2002' | 'CSV2003' | 'CSV2004', string>>}
18
+ */
19
+ export const CSV_LIMIT_CODES = Object.freeze({
20
+ CSV2001: 'the document exceeds maxTotalBytes',
21
+ CSV2002: 'a record exceeds maxRecordBytes',
22
+ CSV2003: 'a field exceeds maxFieldBytes',
23
+ CSV2004: 'a record has more than maxColumns fields',
24
+ });
25
+
26
+ /**
27
+ * The JOSL/TOML limits, by option name. A "record" is one logical line —
28
+ * the unit the reader buffers before it parses anything.
29
+ * @type {Readonly<Record<'JOSL2001' | 'JOSL2002' | 'JOSL2003' | 'JOSL2004' | 'JOSL2005', string>>}
30
+ */
31
+ export const JOSL_LIMIT_CODES = Object.freeze({
32
+ JOSL2001: 'the document exceeds maxTotalBytes',
33
+ JOSL2002: 'a logical line exceeds maxRecordBytes',
34
+ JOSL2003: 'a token exceeds maxTokenBytes',
35
+ JOSL2004: 'the document nests deeper than maxDepth',
36
+ JOSL2005: 'the root retains more than maxRetainedValues values',
37
+ });
38
+
39
+ /**
40
+ * The JSONX/JSON limits, by option name.
41
+ * @type {Readonly<Record<'JSONX2001' | 'JSONX2002' | 'JSONX2003' | 'JSONX2004', string>>}
42
+ */
43
+ export const JSONX_LIMIT_CODES = Object.freeze({
44
+ JSONX2001: 'the document exceeds maxTotalBytes',
45
+ JSONX2002: 'a token exceeds maxTokenBytes',
46
+ JSONX2003: 'the document nests deeper than maxDepth',
47
+ JSONX2004: 'the root retains more than maxRetainedValues values',
48
+ });
49
+
50
+ /** The option name each code guards. */
51
+ const OPTION_OF = Object.freeze({
52
+ CSV2001: 'maxTotalBytes', CSV2002: 'maxRecordBytes', CSV2003: 'maxFieldBytes', CSV2004: 'maxColumns',
53
+ JOSL2001: 'maxTotalBytes', JOSL2002: 'maxRecordBytes', JOSL2003: 'maxTokenBytes', JOSL2004: 'maxDepth', JOSL2005: 'maxRetainedValues',
54
+ JSONX2001: 'maxTotalBytes', JSONX2002: 'maxTokenBytes', JSONX2003: 'maxDepth', JSONX2004: 'maxRetainedValues',
55
+ });
56
+
57
+ /**
58
+ * Error thrown when a document crosses a limit a caller set. Never a
59
+ * repair, never healed: the text is refused where it stands.
60
+ */
61
+ export class JoslLimitError extends Error {
62
+ /**
63
+ * @param {string} code - The stable `CSV2xxx` / `JOSL2xxx` / `JSONX2xxx` code
64
+ * @param {string} message - What was crossed
65
+ * @param {number} limit - The limit the option set
66
+ * @param {number} [line] - 1-based physical line where the crossing was met, when known
67
+ */
68
+ constructor(code, message, limit, line = undefined) {
69
+ super(`${code}: ${message} (${OPTION_OF[code]} ${limit})${line === undefined ? '' : ` at line ${line}`}`);
70
+ this.name = 'JoslLimitError';
71
+ this.code = code;
72
+ this.limit = limit;
73
+ this.line = line;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Read one limit option: absent is `Infinity`; anything but a positive
79
+ * integer or `Infinity` is a `TypeError` — a limit of zero refuses every
80
+ * document, and a fraction or a string is a caller's mistake.
81
+ * @param {object} options - The reader/writer options
82
+ * @param {string} name - The option name
83
+ * @returns {number} The limit, `Infinity` when unset
84
+ */
85
+ export function limitOption(options, name) {
86
+ const value = options[name];
87
+ if (value === undefined || value === null)
88
+ return Infinity;
89
+ if (value === Infinity)
90
+ return Infinity;
91
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1)
92
+ throw new TypeError(`options.${name} must be a positive integer or Infinity`);
93
+ return value;
94
+ }
95
+
96
+ //#endregion
package/src/machine.js CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  } from '@jarenjs/core/scan';
54
54
 
55
55
  import { JoslSyntaxError } from './errors.js';
56
+ import { JoslLimitError, limitOption } from './limits.js';
56
57
  import {
57
58
  LocalDate,
58
59
  LocalTime,
@@ -61,10 +62,10 @@ import {
61
62
  isValidTimeParts,
62
63
  } from './values.js';
63
64
  import {
64
- getOwn, columnOf, feedMachine, beginParseAll, stickyExec, RE_DATETIME, RE_TIMEONLY,
65
+ getOwn, columnOf, feedMachine, beginParseAll, stickyExec, RE_DATETIME, RE_TIMEONLY, offsetDateTime,
65
66
  } from './util.js';
66
67
  import { setObjectMember } from '@jarenjs/core/object';
67
- import { countCharCode } from '@jarenjs/core/string';
68
+ import { countCharCode, utf8ByteLength } from '@jarenjs/core/string';
68
69
 
69
70
  function isBareKeyCode(c) {
70
71
  return isAsciiLetterCode(c)
@@ -133,6 +134,20 @@ export class JoslMachine {
133
134
  constructor(options = {}) {
134
135
  this.mode = options.mode === 'toml' ? 'toml' : 'josl';
135
136
  this.onEvent = options.onEvent ?? null;
137
+ // Root-item detachment: when set, a completed `[[]]` item is handed
138
+ // to it and dropped from the root, so the root never holds more
139
+ // than the item in progress (iterateJoslStream's reason to exist).
140
+ this.detachRoot = options.detachRoot ?? null;
141
+ this.rootCount = 0; // root items opened so far (indices survive detachment)
142
+ // the hostile-input limits: Infinity unless asked for (limits.js)
143
+ this.maxTotalBytes = limitOption(options, 'maxTotalBytes');
144
+ this.maxRecordBytes = limitOption(options, 'maxRecordBytes');
145
+ this.maxTokenBytes = limitOption(options, 'maxTokenBytes');
146
+ this.maxDepth = limitOption(options, 'maxDepth');
147
+ this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
148
+ this.totalBytes = 0;
149
+ this.retained = 0; // values linked into the root since the last detachment
150
+ this.depth = 0; // inline container nesting while a value is parsed
136
151
  // Logical-line sink used by the CST layer: reports each line's source
137
152
  // span, and the span of a pair's value inside it, so a rewriter can
138
153
  // replace a value without disturbing the bytes around it.
@@ -169,6 +184,18 @@ export class JoslMachine {
169
184
  * @returns {this} The machine, for chaining
170
185
  */
171
186
  feed(chunk) {
187
+ if (this.maxTotalBytes !== Infinity) {
188
+ this.totalBytes += utf8ByteLength(chunk);
189
+ if (this.totalBytes > this.maxTotalBytes)
190
+ throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.startLine);
191
+ }
192
+ if (this.maxRecordBytes !== Infinity && chunk.indexOf('\n') < 0
193
+ && utf8ByteLength(this.buf) + utf8ByteLength(chunk) > this.maxRecordBytes) {
194
+ // the logical line still being cut, plus this chunk, would pass the
195
+ // bound before any newline could end it: refused before the
196
+ // concatenation that would hold it
197
+ throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
198
+ }
172
199
  return feedMachine(this, chunk);
173
200
  }
174
201
 
@@ -206,6 +233,8 @@ export class JoslMachine {
206
233
  */
207
234
  parseAll(text) {
208
235
  text = beginParseAll(this, text);
236
+ if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
237
+ throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
209
238
  // positions are offsets into the whole source, which starts at line 1
210
239
  this.lineOrigin = 1;
211
240
  const tracking = this.onEvent !== null;
@@ -215,6 +244,11 @@ export class JoslMachine {
215
244
  this.lineValueStart = -1;
216
245
  this.lineValueEnd = -1;
217
246
  const end = this.parseLine(text, pos);
247
+ // the whole document is already in memory here: a logical line is
248
+ // judged by its extent once the parser found it (the streaming
249
+ // path refuses it earlier, while it is still being cut)
250
+ if (this.maxRecordBytes !== Infinity && utf8ByteLength(text, pos, end) > this.maxRecordBytes)
251
+ throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
218
252
  const next = end < len && text.charCodeAt(end) === CC_LF ? end + 1 : end;
219
253
  if (this.onLine !== null)
220
254
  this.onLine(pos, next, this.lineValueStart, this.lineValueEnd);
@@ -235,14 +269,47 @@ export class JoslMachine {
235
269
  /**
236
270
  * The (possibly still growing) root value: `{}`-rooted for documents,
237
271
  * `[]`-rooted after a `[[]]` header. Undefined content yields `{}`.
272
+ * Under `detachRoot` a finished document hands its last item over
273
+ * here, so the root ends empty.
238
274
  * @returns {*} Current root value
239
275
  */
240
276
  root() {
241
277
  if (this.rootValue === undefined)
242
278
  this.rootValue = {};
279
+ if (this.ended && this.detachRoot !== null && this.rootIsArray && this.rootValue.length !== 0)
280
+ this.detachLast();
243
281
  return this.rootValue;
244
282
  }
245
283
 
284
+ // Hand the last root item to the detach sink and drop it: the root
285
+ // keeps at most the item in progress, and the retained-value count
286
+ // starts over with it.
287
+ detachLast() {
288
+ const item = this.rootValue.pop();
289
+ this.retained = 0;
290
+ this.detachRoot(item);
291
+ }
292
+
293
+ // One more value linked into the root: a pair, an array element, an
294
+ // inline-table member, a table or a root item. Judged before the link.
295
+ retain() {
296
+ if (++this.retained > this.maxRetainedValues)
297
+ throw new JoslLimitError('JOSL2005', 'the root retains more than maxRetainedValues values', this.maxRetainedValues, this.startLine);
298
+ }
299
+
300
+ // One nesting level deeper — an inline array or table, or a header
301
+ // path segment — judged before it opens.
302
+ deeper(depth) {
303
+ if (depth > this.maxDepth)
304
+ throw new JoslLimitError('JOSL2004', 'the document nests deeper than maxDepth', this.maxDepth, this.startLine);
305
+ }
306
+
307
+ // A token about to be sliced: its byte size, judged first.
308
+ token(line, start, end) {
309
+ if (this.maxTokenBytes !== Infinity && utf8ByteLength(line, start, end) > this.maxTokenBytes)
310
+ throw new JoslLimitError('JOSL2003', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.startLine);
311
+ }
312
+
246
313
  //#endregion
247
314
 
248
315
  //#region chunk cutter
@@ -402,6 +469,8 @@ export class JoslMachine {
402
469
  ? nlPos - 1
403
470
  : nlPos;
404
471
  if (end > start) {
472
+ if (this.maxRecordBytes !== Infinity && utf8ByteLength(buf, start, end) > this.maxRecordBytes)
473
+ throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
405
474
  this.lineOrigin = this.startLine;
406
475
  this.parseLine(buf.slice(start, end));
407
476
  }
@@ -544,7 +613,7 @@ export class JoslMachine {
544
613
 
545
614
  headerBase() {
546
615
  if (this.rootIsArray)
547
- return [this.rootValue[this.rootValue.length - 1], [this.rootValue.length - 1]];
616
+ return [this.rootValue[this.rootValue.length - 1], [this.rootCount - 1]];
548
617
  if (this.rootValue === undefined)
549
618
  this.rootValue = {};
550
619
  return [this.rootValue, []];
@@ -554,6 +623,7 @@ export class JoslMachine {
554
623
  // descending into the last element of arrays-of-tables
555
624
  navigate(keys, pos) {
556
625
  let [t, path] = this.headerBase();
626
+ this.deeper(keys.length);
557
627
  for (let i = 0; i < keys.length - 1; ++i) {
558
628
  const k = keys[i];
559
629
  const ex = getOwn(t, k);
@@ -593,6 +663,7 @@ export class JoslMachine {
593
663
  const k = keys[keys.length - 1];
594
664
  const ex = getOwn(t, k);
595
665
  if (ex === undefined) {
666
+ this.retain();
596
667
  const nt = {};
597
668
  this.meta.set(nt, { explicit: true });
598
669
  setObjectMember(t, k, nt);
@@ -622,6 +693,7 @@ export class JoslMachine {
622
693
  }
623
694
  else if (!Array.isArray(arr) || this.meta.get(arr)?.aot !== true)
624
695
  this.err(pos, `key '${keys.join('.')}' is not an array of tables`);
696
+ this.retain();
625
697
  const el = {};
626
698
  arr.push(el);
627
699
  this.current = el;
@@ -641,14 +713,20 @@ export class JoslMachine {
641
713
  else if (!this.rootIsArray)
642
714
  this.err(pos, 'cannot mix a root table and a root array',
643
715
  'a document that starts with key-value pairs has an object root');
716
+ // the previous item is complete the moment the next header opens:
717
+ // under detachment it leaves the root here
718
+ if (this.detachRoot !== null && this.rootValue.length !== 0)
719
+ this.detachLast();
720
+ this.retain();
644
721
  const el = {};
645
722
  this.rootValue.push(el);
723
+ this.rootCount++;
646
724
  this.current = el;
647
- this.currentPath = [this.rootValue.length - 1];
725
+ this.currentPath = [this.rootCount - 1];
648
726
  this.emit({
649
727
  type: 'root-item',
650
728
  path: this.currentPath,
651
- index: this.rootValue.length - 1,
729
+ index: this.rootCount - 1,
652
730
  line: this.startLine,
653
731
  });
654
732
  }
@@ -710,6 +788,7 @@ export class JoslMachine {
710
788
  const k = keys[keys.length - 1];
711
789
  if (Object.hasOwn(t, k))
712
790
  this.err(pos, `duplicate key '${k}'`);
791
+ this.retain();
713
792
  setObjectMember(t, k, value);
714
793
  }
715
794
 
@@ -736,6 +815,7 @@ export class JoslMachine {
736
815
  pos++;
737
816
  if (pos === start)
738
817
  this.err(pos, 'expected a key');
818
+ this.token(line, start, pos);
739
819
  keys.push(line.slice(start, pos));
740
820
  }
741
821
  pos = this.skipWs(line, pos);
@@ -875,13 +955,16 @@ export class JoslMachine {
875
955
  }
876
956
 
877
957
  parseBasicString(line, pos) {
958
+ const opened = pos;
878
959
  pos++; // consume '"'
879
960
  let out = '';
880
961
  let chunk = pos;
881
962
  while (pos < line.length) {
882
963
  const c = line.charCodeAt(pos);
883
- if (c === CC_DQUOTE)
964
+ if (c === CC_DQUOTE) {
965
+ this.token(line, opened, pos + 1);
884
966
  return [out + line.slice(chunk, pos), pos + 1];
967
+ }
885
968
  if (c === CC_BACKSLASH) {
886
969
  out += line.slice(chunk, pos);
887
970
  const [dec, p] = this.decodeEscape(line, pos);
@@ -903,8 +986,10 @@ export class JoslMachine {
903
986
  const start = pos;
904
987
  while (pos < line.length) {
905
988
  const c = line.charCodeAt(pos);
906
- if (c === CC_SQUOTE)
989
+ if (c === CC_SQUOTE) {
990
+ this.token(line, start - 1, pos + 1);
907
991
  return [line.slice(start, pos), pos + 1];
992
+ }
908
993
  if (c === CC_LF)
909
994
  break;
910
995
  this.checkStringChar(line, pos, false);
@@ -914,6 +999,7 @@ export class JoslMachine {
914
999
  }
915
1000
 
916
1001
  parseMlBasicString(line, pos) {
1002
+ const mlStart = pos;
917
1003
  pos += 3; // consume '"""'
918
1004
  if (line.charCodeAt(pos) === CC_CR && line.charCodeAt(pos + 1) === CC_LF)
919
1005
  pos += 2;
@@ -931,6 +1017,7 @@ export class JoslMachine {
931
1017
  if (n >= 3) {
932
1018
  if (n > 5)
933
1019
  this.err(pos, 'too many quotes closing a multi-line string');
1020
+ this.token(line, mlStart, run);
934
1021
  return [out + line.slice(chunk, pos) + '"'.repeat(n - 3), run];
935
1022
  }
936
1023
  pos = run;
@@ -991,6 +1078,7 @@ export class JoslMachine {
991
1078
  if (n >= 3) {
992
1079
  if (n > 5)
993
1080
  this.err(pos, 'too many quotes closing a multi-line string');
1081
+ this.token(line, start - 3, run);
994
1082
  return [line.slice(start, pos) + "'".repeat(n - 3), run];
995
1083
  }
996
1084
  pos = run;
@@ -1008,15 +1096,19 @@ export class JoslMachine {
1008
1096
 
1009
1097
  parseArray(line, pos) {
1010
1098
  pos++; // consume '['
1099
+ this.deeper(++this.depth);
1011
1100
  const arr = [];
1012
1101
  this.meta.set(arr, { aot: false });
1013
1102
  for (;;) {
1014
1103
  pos = this.skipWsNlComment(line, pos);
1015
1104
  if (pos >= line.length)
1016
1105
  this.err(pos, 'unterminated array', "close the array with ']'");
1017
- if (line.charCodeAt(pos) === CC_RBRACKET)
1106
+ if (line.charCodeAt(pos) === CC_RBRACKET) {
1107
+ this.depth--;
1018
1108
  return [arr, pos + 1];
1109
+ }
1019
1110
  const [v, p] = this.parseValue(line, pos);
1111
+ this.retain();
1020
1112
  arr.push(v);
1021
1113
  pos = this.skipWsNlComment(line, p);
1022
1114
  if (pos >= line.length)
@@ -1026,19 +1118,24 @@ export class JoslMachine {
1026
1118
  pos++;
1027
1119
  continue;
1028
1120
  }
1029
- if (c === CC_RBRACKET)
1121
+ if (c === CC_RBRACKET) {
1122
+ this.depth--;
1030
1123
  return [arr, pos + 1];
1124
+ }
1031
1125
  this.err(pos, "expected ',' or ']' in array");
1032
1126
  }
1033
1127
  }
1034
1128
 
1035
1129
  parseInlineTable(line, pos) {
1036
1130
  pos++; // consume '{'
1131
+ this.deeper(++this.depth);
1037
1132
  const obj = {};
1038
1133
  this.meta.set(obj, { inline: true });
1039
1134
  pos = this.skipWs(line, pos);
1040
- if (pos < line.length && line.charCodeAt(pos) === CC_RBRACE)
1135
+ if (pos < line.length && line.charCodeAt(pos) === CC_RBRACE) {
1136
+ this.depth--;
1041
1137
  return [obj, pos + 1];
1138
+ }
1042
1139
  for (;;) {
1043
1140
  if (pos < line.length && line.charCodeAt(pos) === CC_LF)
1044
1141
  this.err(pos, 'newlines are not allowed inside inline tables',
@@ -1054,8 +1151,10 @@ export class JoslMachine {
1054
1151
  if (pos >= line.length)
1055
1152
  this.err(pos, 'unterminated inline table', "close the table with '}'");
1056
1153
  const c = line.charCodeAt(pos);
1057
- if (c === CC_RBRACE)
1154
+ if (c === CC_RBRACE) {
1155
+ this.depth--;
1058
1156
  return [obj, pos + 1];
1157
+ }
1059
1158
  if (c === CC_COMMA) {
1060
1159
  pos = this.skipWs(line, pos + 1);
1061
1160
  continue;
@@ -1086,6 +1185,7 @@ export class JoslMachine {
1086
1185
  const k = keys[keys.length - 1];
1087
1186
  if (Object.hasOwn(t, k))
1088
1187
  this.err(pos, `duplicate key '${k}'`);
1188
+ this.retain();
1089
1189
  setObjectMember(t, k, value);
1090
1190
  }
1091
1191
 
@@ -1154,8 +1254,7 @@ export class JoslMachine {
1154
1254
  const end = this.checkValueEnd(line, pos + m[0].length);
1155
1255
  if (m[8] === undefined)
1156
1256
  return [new LocalDateTime(date, time), end];
1157
- const offset = m[8] === 'z' || m[8] === 'Z' ? 'Z' : m[8];
1158
- const instant = new Date(`${date.toString()}T${time.toString()}${offset}`);
1257
+ const instant = offsetDateTime(date, time, m[8]);
1159
1258
  if (Number.isNaN(instant.getTime()))
1160
1259
  this.err(pos, `invalid date-time '${m[0]}'`);
1161
1260
  return [instant, end];
package/src/pull.js ADDED
@@ -0,0 +1,40 @@
1
+ //#region pull helpers shared by the async serializers and readers
2
+ // The two facts every pull pipeline in this package relies on, written
3
+ // once: closing an upstream iterator exactly once (a consumer that stops
4
+ // early, an abort, a throw — never a source read to its end, whose
5
+ // `return()` would be a second close), and the rejection an abort
6
+ // carries (the signal's own reason, so a caller's `AbortError` is the
7
+ // one it sees).
8
+
9
+ /**
10
+ * Close an iterator once, swallowing what the close throws: a source
11
+ * that refuses its cancel is already gone.
12
+ * @param {Iterator<any> | AsyncIterator<any>} iterator
13
+ * @returns {Promise<void>}
14
+ */
15
+ export async function closeIterator(iterator) {
16
+ if (typeof iterator.return !== 'function')
17
+ return;
18
+ try {
19
+ await iterator.return(undefined);
20
+ }
21
+ catch {
22
+ // the upstream refused its close; nothing more can be done for it
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The rejection of an aborted pull: the signal's reason when it has one,
28
+ * else an `AbortError`-named error, as the platform spells it.
29
+ * @param {AbortSignal} signal
30
+ * @returns {unknown}
31
+ */
32
+ export function abortedError(signal) {
33
+ if (signal.reason !== undefined)
34
+ return signal.reason;
35
+ const error = new Error('The operation was aborted.');
36
+ error.name = 'AbortError';
37
+ return error;
38
+ }
39
+
40
+ //#endregion
package/src/stream.js CHANGED
@@ -7,6 +7,8 @@
7
7
  // array-of-tables indices), so events are directly JSON-Pointer-able.
8
8
 
9
9
  import { JoslMachine } from './machine.js';
10
+ import { JoslSyntaxError } from './errors.js';
11
+ import { closeIterator, abortedError } from './pull.js';
10
12
 
11
13
  /**
12
14
  * Create an incremental JOSL/TOML reader.
@@ -52,6 +54,62 @@ export async function parseJoslStream(chunks, options = undefined) {
52
54
  return machine.end();
53
55
  }
54
56
 
57
+ /**
58
+ * Yield the `[[]]` root items of a JOSL document as they complete,
59
+ * without ever holding the whole document: each item is DETACHED from
60
+ * the root the moment the next `[[]]` header (or the end) completes it,
61
+ * so `root()`-style retention never grows past one record — the JOSL
62
+ * twin of the JSONX reader's `detach`. A document with a table root is
63
+ * refused by name (`JoslSyntaxError`): a table root is one retained
64
+ * value, not a stream of records. `options.signal` aborts between
65
+ * chunks; an abort, a consumer that stops early or a throw closes the
66
+ * chunk source exactly once.
67
+ * @param {AsyncIterable<string>|Iterable<string>} chunks - Source chunks
68
+ * @param {object} [options] - Reader options; see `createStreamReader`,
69
+ * plus `signal` and the `JOSL2xxx` limits
70
+ * @yields {object} One completed root item at a time
71
+ * @example
72
+ * for await (const record of iterateJoslStream(response.body))
73
+ * await save(record);
74
+ */
75
+ export async function* iterateJoslStream(chunks, options = undefined) {
76
+ /** @type {object[]} */
77
+ const completed = [];
78
+ const machine = new JoslMachine({
79
+ ...(options ?? {}),
80
+ detachRoot: (item) => { completed.push(item); },
81
+ });
82
+ const signal = options?.signal ?? null;
83
+ const iterator = chunks[Symbol.asyncIterator]?.() ?? chunks[Symbol.iterator]();
84
+ let finished = false;
85
+ try {
86
+ for (;;) {
87
+ if (signal !== null && signal.aborted)
88
+ throw abortedError(signal);
89
+ const step = await iterator.next();
90
+ if (step.done) {
91
+ finished = true;
92
+ break;
93
+ }
94
+ machine.feed(step.value);
95
+ if (machine.rootValue !== undefined && !machine.rootIsArray) {
96
+ throw new JoslSyntaxError('iterateJoslStream reads a [[]] root array; this document has a table root',
97
+ machine.lineOrigin, 1, 'stream a document of [[]] records, or read a table root with parseJoslStream');
98
+ }
99
+ while (completed.length !== 0)
100
+ yield /** @type {object} */ (completed.shift());
101
+ }
102
+ }
103
+ finally {
104
+ if (!finished)
105
+ await closeIterator(iterator);
106
+ }
107
+ machine.end();
108
+ while (completed.length !== 0)
109
+ yield /** @type {object} */ (completed.shift());
110
+ }
111
+
55
112
  export { JoslSyntaxError } from './errors.js';
113
+ export { JoslLimitError, JOSL_LIMIT_CODES } from './limits.js';
56
114
 
57
115
  //#endregion
package/src/util.js CHANGED
@@ -17,6 +17,21 @@ export const RE_DATETIME = /(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2}):(\d{2
17
17
  /** A bare `HH:MM:SS` with an optional fraction. @type {RegExp} */
18
18
  export const RE_TIMEONLY = /(\d{2}):(\d{2}):(\d{2})(\.\d+)?/y;
19
19
 
20
+ /**
21
+ * Convert a local date and time with an offset to their native instant.
22
+ * ISO parsing preserves years 0000-0099 and truncates sub-millisecond
23
+ * precision consistently for JOSL and typed CSV. An invalid offset (or
24
+ * an instant Date cannot represent) returns an invalid Date for the
25
+ * caller's own refusal or text-preservation policy.
26
+ * @param {import('./values.js').LocalDate} date - Validated calendar date
27
+ * @param {import('./values.js').LocalTime} time - Validated local time
28
+ * @param {string} offset - Z/z or a signed HH:MM offset
29
+ * @returns {Date}
30
+ */
31
+ export function offsetDateTime(date, time, offset) {
32
+ return new Date(`${date.toString()}T${time.toString()}${offset.toUpperCase()}`);
33
+ }
34
+
20
35
  /**
21
36
  * Run a sticky regex at `pos` and return its match (or null).
22
37
  * @param {RegExp} re - A sticky (`y`) pattern