@effected/jsonl 0.1.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/Envelope.js +267 -0
- package/Journal.js +475 -0
- package/JsonlError.js +246 -0
- package/JsonlEvent.js +45 -0
- package/LICENSE +21 -0
- package/Line.js +219 -0
- package/LineSlice.js +62 -0
- package/README.md +181 -0
- package/Slice.js +23 -0
- package/index.d.ts +1032 -0
- package/index.js +8 -0
- package/internal/merge.js +112 -0
- package/internal/tail.js +143 -0
- package/internal/utf8.js +43 -0
- package/package.json +45 -0
- package/tsdoc-metadata.json +11 -0
package/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { LineSlice } from "./LineSlice.js";
|
|
2
|
+
import { InvalidData, JournalClosed, JournalNotFound, JournalResync, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData } from "./JsonlError.js";
|
|
3
|
+
import { Line, ParsedLine } from "./Line.js";
|
|
4
|
+
import { Envelope, EnvelopeFrame } from "./Envelope.js";
|
|
5
|
+
import { Journal } from "./Journal.js";
|
|
6
|
+
import { JsonlEvent, JsonlEventTypeId } from "./JsonlEvent.js";
|
|
7
|
+
|
|
8
|
+
export { Envelope, EnvelopeFrame, InvalidData, Journal, JournalClosed, JournalNotFound, JournalResync, JsonlEvent, JsonlEventTypeId, Line, LineSlice, MalformedLine, ParsedLine, TerminalViolation, UnknownEvent, UnserializableData };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//#region src/internal/merge.ts
|
|
2
|
+
/**
|
|
3
|
+
* Pollution-safe shallow merge for `appendPatch`.
|
|
4
|
+
*
|
|
5
|
+
* Ported from `@effected/config-file`'s `internal/deepMerge.ts` recipe, minus
|
|
6
|
+
* the recursion: `appendPatch` is a **shallow** merge by decision, so a nested
|
|
7
|
+
* object in the patch replaces the one beneath it rather than merging into it.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Keys that must never be copied from either side.
|
|
13
|
+
*
|
|
14
|
+
* `__proto__` reaches `Object.prototype`'s inherited accessor; `constructor`
|
|
15
|
+
* and `prototype` are the neighbouring escape hatches. Filtering only the
|
|
16
|
+
* *patch* would not be enough — the base is decoded journal data, which an
|
|
17
|
+
* external writer controls just as directly.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
const FORBIDDEN = /* @__PURE__ */ new Set([
|
|
22
|
+
"__proto__",
|
|
23
|
+
"constructor",
|
|
24
|
+
"prototype"
|
|
25
|
+
]);
|
|
26
|
+
/**
|
|
27
|
+
* Create an own data property, never invoking a setter inherited from the
|
|
28
|
+
* prototype chain.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const define = (target, key, value) => {
|
|
33
|
+
Object.defineProperty(target, key, {
|
|
34
|
+
value,
|
|
35
|
+
writable: true,
|
|
36
|
+
enumerable: true,
|
|
37
|
+
configurable: true
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Whether a value can take part in a merge at all.
|
|
42
|
+
*
|
|
43
|
+
* Record-**like**, deliberately: a decoded `Schema.Class` payload is a class
|
|
44
|
+
* instance, and excluding those would make the kit's dominant payload idiom
|
|
45
|
+
* silently unpatchable. Arrays, `Date`s, scalars and `null` are excluded —
|
|
46
|
+
* `Object.prototype.toString` distinguishes them where a `typeof` check does
|
|
47
|
+
* not.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
const isRecordLike = (value) => typeof value === "object" && value !== null && Object.prototype.toString.call(value) === "[object Object]";
|
|
52
|
+
/**
|
|
53
|
+
* Whether `patch` may be merged into `base`.
|
|
54
|
+
*
|
|
55
|
+
* **Asymmetric, and deliberately unlike `@effected/config-file`'s `canMerge`.**
|
|
56
|
+
* There, two *peer documents* are merged and requiring an identical prototype
|
|
57
|
+
* keeps the merge honest. Here the patch is a caller-supplied **partial** —
|
|
58
|
+
* `{ round: 2 }` written at the call site — so it is a plain literal even when
|
|
59
|
+
* the base is a decoded `Schema.Class` instance. A symmetric same-prototype
|
|
60
|
+
* test would therefore reject exactly the case that matters most and fall back
|
|
61
|
+
* to replacement, which is the silent-loss trap this guard exists to close.
|
|
62
|
+
*
|
|
63
|
+
* So: both must be record-like, and the patch must not bring a *conflicting*
|
|
64
|
+
* prototype — it is either a plain object (the normal case) or shares the
|
|
65
|
+
* base's. Two instances of different classes do not merge.
|
|
66
|
+
*
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
const canMerge = (base, patch) => {
|
|
70
|
+
if (!isRecordLike(base) || !isRecordLike(patch)) return false;
|
|
71
|
+
const patchProto = Object.getPrototypeOf(patch);
|
|
72
|
+
return patchProto === Object.prototype || patchProto === null || patchProto === Object.getPrototypeOf(base);
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Shallow-merge `patch` over `base`, with `patch` winning.
|
|
76
|
+
*
|
|
77
|
+
* **Assignment is the hazard, not the keys.** `result[key] = value` and
|
|
78
|
+
* `Object.assign` both use `[[Set]]`, which for a key named `__proto__` reaches
|
|
79
|
+
* `Object.prototype`'s inherited accessor and reassigns the result's prototype
|
|
80
|
+
* to attacker-controlled data — defeating the key filter that appears to be
|
|
81
|
+
* guarding it. `Object.defineProperty` defines an own data property and never
|
|
82
|
+
* consults the prototype chain, so it is the only safe copy primitive here.
|
|
83
|
+
*
|
|
84
|
+
* The result is built on `base`'s prototype so a decoded payload survives as
|
|
85
|
+
* whatever it was, rather than being flattened into a bare object literal.
|
|
86
|
+
*
|
|
87
|
+
* **The merged value is TRANSIENT, and that is what makes this safe.** Building
|
|
88
|
+
* on `base`'s prototype means a class-based payload passes `instanceof`
|
|
89
|
+
* **without its constructor having run** — the object was assembled by
|
|
90
|
+
* `Object.create` plus `defineProperty`, not by `new`. That is acceptable here
|
|
91
|
+
* only because the value's sole use is to be encoded: the caller receives a
|
|
92
|
+
* genuine instance produced by the subsequent decode, never this one. If a
|
|
93
|
+
* future refactor ever returns the merged value to a caller directly, any
|
|
94
|
+
* invariant a constructor establishes is silently bypassed.
|
|
95
|
+
*
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
const shallowMerge = (base, patch) => {
|
|
99
|
+
const result = Object.create(Object.getPrototypeOf(base));
|
|
100
|
+
for (const key of Object.keys(base)) {
|
|
101
|
+
if (FORBIDDEN.has(key)) continue;
|
|
102
|
+
define(result, key, base[key]);
|
|
103
|
+
}
|
|
104
|
+
for (const key of Object.keys(patch)) {
|
|
105
|
+
if (FORBIDDEN.has(key)) continue;
|
|
106
|
+
define(result, key, patch[key]);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
export { canMerge, isRecordLike, shallowMerge };
|
package/internal/tail.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/tail.ts
|
|
4
|
+
/** UTF-8 BOM, as bytes. `U+FEFF` encodes to these three. */
|
|
5
|
+
const BOM = [
|
|
6
|
+
239,
|
|
7
|
+
187,
|
|
8
|
+
191
|
|
9
|
+
];
|
|
10
|
+
/** Byte value of `\n`. Cannot occur inside a UTF-8 multi-byte sequence. */
|
|
11
|
+
const LF = 10;
|
|
12
|
+
/**
|
|
13
|
+
* The default tail window.
|
|
14
|
+
*
|
|
15
|
+
* Large enough that a snapshot journal's last line is almost always inside it
|
|
16
|
+
* on the first read, small enough that reading it is cheap against a journal of
|
|
17
|
+
* any age. When it misses, {@link readTail} widens rather than failing.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_WINDOW = 8192;
|
|
22
|
+
/** Does the buffer begin with a UTF-8 BOM? */
|
|
23
|
+
const hasBom = (bytes) => bytes.length >= 3 && bytes[0] === BOM[0] && bytes[1] === BOM[1] && bytes[2] === BOM[2];
|
|
24
|
+
/**
|
|
25
|
+
* Probe the first three bytes of a file for a BOM.
|
|
26
|
+
*
|
|
27
|
+
* This is a property of the FILE, so it is read once from the start rather than
|
|
28
|
+
* inferred from whatever window happens to be in hand. Inferring it from window
|
|
29
|
+
* position was a real defect: a BOM'd journal larger than the window never has
|
|
30
|
+
* a window at offset 0, so every offset it emitted was physical — silently off
|
|
31
|
+
* by three against a file the package itself had described as post-BOM.
|
|
32
|
+
*
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
const probeBomBytes = (fs, path) => Effect.gen(function* () {
|
|
36
|
+
const head = yield* (yield* fs.open(path, { flag: "r" })).readAlloc(BOM.length);
|
|
37
|
+
const bytes = Option.getOrElse(head, () => /* @__PURE__ */ new Uint8Array(0));
|
|
38
|
+
return hasBom(bytes) ? BOM.length : 0;
|
|
39
|
+
}).pipe(Effect.scoped);
|
|
40
|
+
/**
|
|
41
|
+
* Read the last `window` bytes of a journal, decoded from a line boundary.
|
|
42
|
+
*
|
|
43
|
+
* Three disciplines, each load-bearing:
|
|
44
|
+
*
|
|
45
|
+
* 1. **Start at a line boundary.** Unless the window reaches offset 0, the
|
|
46
|
+
* leading partial line is discarded — everything up to and including the
|
|
47
|
+
* first `\n`. This is also what makes the decode safe across a chunk
|
|
48
|
+
* boundary: `\n` is `0x0A` and **cannot** appear inside a UTF-8 multi-byte
|
|
49
|
+
* sequence, so a window that starts just past one starts on a character
|
|
50
|
+
* boundary too. No separate mid-character guard is needed.
|
|
51
|
+
* 2. **Strip exactly one leading BOM, at the byte level**, and only when the
|
|
52
|
+
* window genuinely starts at file offset 0. Reads here are offset-based, so
|
|
53
|
+
* an implicit strip (as `readFileString` performs) would desynchronize every
|
|
54
|
+
* offset the package emits; doing it explicitly keeps the convention stated.
|
|
55
|
+
* A `U+FEFF` anywhere else is content and is left alone.
|
|
56
|
+
* 3. **Never read the whole file** unless the file is smaller than the window.
|
|
57
|
+
*
|
|
58
|
+
* **`window` is not clamped**, deliberately. `Journal`'s historical read sizes
|
|
59
|
+
* its window to the region it has to return, so a clamp here would silently
|
|
60
|
+
* truncate that read rather than bound it. The clamp belongs with the paged
|
|
61
|
+
* rewrite of that read — one that emits per window and can therefore honour a
|
|
62
|
+
* maximum — and is carried on spencerbeggs/effected#233, not added underneath
|
|
63
|
+
* it.
|
|
64
|
+
*
|
|
65
|
+
* @internal
|
|
66
|
+
*/
|
|
67
|
+
const readTail = (fs, path, window, bomBytes) => Effect.gen(function* () {
|
|
68
|
+
const info = yield* fs.stat(path);
|
|
69
|
+
const physicalSize = Number(info.size);
|
|
70
|
+
const logicalSize = physicalSize - bomBytes;
|
|
71
|
+
const from = Math.max(bomBytes, physicalSize - window);
|
|
72
|
+
const file = yield* fs.open(path, { flag: "r" });
|
|
73
|
+
yield* file.seek(from, "start");
|
|
74
|
+
const read = yield* file.readAlloc(physicalSize - from);
|
|
75
|
+
const bytes = Option.getOrElse(read, () => /* @__PURE__ */ new Uint8Array(0));
|
|
76
|
+
const windowAtFileStart = from === bomBytes;
|
|
77
|
+
let cursor = 0;
|
|
78
|
+
if (!windowAtFileStart) {
|
|
79
|
+
const newline = bytes.indexOf(LF);
|
|
80
|
+
cursor = newline === -1 ? bytes.length : newline + 1;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
text: new TextDecoder().decode(bytes.subarray(cursor)),
|
|
84
|
+
start: from + cursor - bomBytes,
|
|
85
|
+
atFileStart: windowAtFileStart,
|
|
86
|
+
size: logicalSize
|
|
87
|
+
};
|
|
88
|
+
}).pipe(Effect.scoped);
|
|
89
|
+
/**
|
|
90
|
+
* Read widening windows until `decode` finds something, or the whole file has
|
|
91
|
+
* been seen.
|
|
92
|
+
*
|
|
93
|
+
* The widening is what makes the bounded read *correct* rather than merely
|
|
94
|
+
* cheap: a journal whose last line is longer than the initial window would
|
|
95
|
+
* otherwise report "no valid envelope" for a perfectly healthy file.
|
|
96
|
+
*
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
const readTailUntil = (fs, path, bomBytes, decode, initialWindow = DEFAULT_WINDOW) => Effect.gen(function* () {
|
|
100
|
+
let window = initialWindow;
|
|
101
|
+
for (;;) {
|
|
102
|
+
const tail = yield* readTail(fs, path, window, bomBytes);
|
|
103
|
+
const found = decode(tail);
|
|
104
|
+
if (Option.isSome(found)) return found;
|
|
105
|
+
if (tail.atFileStart) return Option.none();
|
|
106
|
+
window *= 4;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
/**
|
|
110
|
+
* Read a byte range and decode it as text, safely across chunk boundaries.
|
|
111
|
+
*
|
|
112
|
+
* The byte→string seam lives here, in the service layer, because `Line.split`
|
|
113
|
+
* is string-in by design and the pure core must never learn about buffers.
|
|
114
|
+
*
|
|
115
|
+
* `TextDecoder` is used in **streaming mode** (`{ stream: true }`) so a
|
|
116
|
+
* multi-byte character split across two reads is reassembled rather than
|
|
117
|
+
* mangled. A naive per-chunk `decode` corrupts any such character — a bug that
|
|
118
|
+
* appears only with non-ASCII payloads at specific sizes, which is exactly the
|
|
119
|
+
* kind that reaches production. The final `decode()` with no argument flushes
|
|
120
|
+
* any trailing partial sequence.
|
|
121
|
+
*
|
|
122
|
+
* @internal
|
|
123
|
+
*/
|
|
124
|
+
const readRangeText = (fs, path, from, length) => Effect.gen(function* () {
|
|
125
|
+
if (length <= 0) return "";
|
|
126
|
+
const file = yield* fs.open(path, { flag: "r" });
|
|
127
|
+
yield* file.seek(from, "start");
|
|
128
|
+
const decoder = new TextDecoder();
|
|
129
|
+
let text = "";
|
|
130
|
+
let remaining = length;
|
|
131
|
+
while (remaining > 0) {
|
|
132
|
+
const chunk = yield* file.readAlloc(Math.min(remaining, CHUNK));
|
|
133
|
+
if (Option.isNone(chunk) || chunk.value.length === 0) break;
|
|
134
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
135
|
+
remaining -= chunk.value.length;
|
|
136
|
+
}
|
|
137
|
+
return text + decoder.decode();
|
|
138
|
+
}).pipe(Effect.scoped);
|
|
139
|
+
/** Read granularity for incremental tail reads. */
|
|
140
|
+
const CHUNK = 65536;
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
export { DEFAULT_WINDOW, probeBomBytes, readRangeText, readTail, readTailUntil };
|
package/internal/utf8.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/internal/utf8.ts
|
|
2
|
+
/**
|
|
3
|
+
* UTF-8 byte accounting.
|
|
4
|
+
*
|
|
5
|
+
* A zero-dependency leaf so that both the line splitter and the error taxonomy
|
|
6
|
+
* can measure bytes without either importing the other — the same cycle
|
|
7
|
+
* firewall the other pure-tier packages use.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The UTF-8 byte length of a JavaScript string.
|
|
13
|
+
*
|
|
14
|
+
* Journal offsets are **bytes**, because that is what `FileSystem.stream`'s
|
|
15
|
+
* `offset` option and every persisted cursor mean. `String.length` counts
|
|
16
|
+
* UTF-16 code units and is wrong for every non-ASCII journal: `"\u{1F600}"` has
|
|
17
|
+
* length 2 and occupies 4 bytes.
|
|
18
|
+
*
|
|
19
|
+
* Matches `TextEncoder#encode(...).length` exactly, including the WHATWG rule
|
|
20
|
+
* that an unpaired surrogate encodes as U+FFFD (3 bytes), but without
|
|
21
|
+
* allocating a buffer per call.
|
|
22
|
+
*
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
const utf8Length = (text) => {
|
|
26
|
+
let bytes = 0;
|
|
27
|
+
for (let index = 0; index < text.length; index++) {
|
|
28
|
+
const code = text.charCodeAt(index);
|
|
29
|
+
if (code < 128) bytes += 1;
|
|
30
|
+
else if (code < 2048) bytes += 2;
|
|
31
|
+
else if (code >= 55296 && code <= 56319) {
|
|
32
|
+
const next = index + 1 < text.length ? text.charCodeAt(index + 1) : 0;
|
|
33
|
+
if (next >= 56320 && next <= 57343) {
|
|
34
|
+
bytes += 4;
|
|
35
|
+
index++;
|
|
36
|
+
} else bytes += 3;
|
|
37
|
+
} else bytes += 3;
|
|
38
|
+
}
|
|
39
|
+
return bytes;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
export { utf8Length };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effected/jsonl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Append-only, schema-validated JSONL journals as a definable Effect service.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"jsonl",
|
|
8
|
+
"journal",
|
|
9
|
+
"append-only",
|
|
10
|
+
"watch",
|
|
11
|
+
"effect",
|
|
12
|
+
"effected"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/jsonl#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/spencerbeggs/effected/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/spencerbeggs/effected.git",
|
|
21
|
+
"directory": "packages/jsonl"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "C. Spencer Beggs",
|
|
26
|
+
"email": "spencer@beggs.codes",
|
|
27
|
+
"url": "https://spencerbeg.gs"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./index.d.ts",
|
|
34
|
+
"import": "./index.js",
|
|
35
|
+
"default": "./index.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"effect": "4.0.0-beta.101"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=24.11.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.58.12"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|