@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/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 };
@@ -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 };
@@ -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
+ }