@gregiteen/ssss-cli 0.10.2 → 0.10.3
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 +8 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/package.json +2 -2
- package/scripts/check-events-streaming.mjs +41 -0
- package/src/events.mjs +34 -19
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.3] - 2026-10-01
|
|
11
|
+
### Fixed
|
|
12
|
+
- Read event logs in 64 KiB chunks when building duplicate-ID indexes or
|
|
13
|
+
replaying events. Large histories no longer require one full-log string.
|
|
14
|
+
UTF-8 decoding, replay cursors, file locks and duplicate detection retain
|
|
15
|
+
their contracts. A regression check guards bounded reads and corrupt-log
|
|
16
|
+
rejection.
|
|
17
|
+
|
|
10
18
|
## [0.10.2] - 2026-09-28
|
|
11
19
|
### Fixed
|
|
12
20
|
- The 0.10.0 notes promised pointer fields for large-file assets, but the
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.10.
|
|
1
|
+
0.10.3
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"file_count": 14,
|
|
107
107
|
"provenance": {
|
|
108
108
|
"content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
|
|
109
|
-
"exporter": "@gregiteen/ssss-cli@0.10.
|
|
109
|
+
"exporter": "@gregiteen/ssss-cli@0.10.3"
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"files": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gregiteen/ssss-cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
|
|
5
5
|
"main": "src/engine.mjs",
|
|
6
6
|
"exports": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
],
|
|
57
57
|
"scripts": {
|
|
58
58
|
"conformance": "node ./scripts/conformance.mjs conformance",
|
|
59
|
-
"test": "node ./scripts/conformance.mjs conformance --engine"
|
|
59
|
+
"test": "node ./scripts/conformance.mjs conformance --engine && node ./scripts/check-events-streaming.mjs"
|
|
60
60
|
},
|
|
61
61
|
"keywords": [
|
|
62
62
|
"ssss",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { JsonlEventStore, createCanonicalEvent } from '../src/events.mjs';
|
|
6
|
+
|
|
7
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-event-stream-'));
|
|
8
|
+
const file = path.join(root, 'acme.jsonl');
|
|
9
|
+
const make = index => createCanonicalEvent({
|
|
10
|
+
workspace_id: 'acme', action: 'event', subject: 'stream-test',
|
|
11
|
+
principal: { id: 'test', kind: 'service' }, operation_id: `operation-${index}`,
|
|
12
|
+
idempotency_key: `key-${index}`, payload: { text: 'a'.repeat(65500) + '🧠é'.repeat(25000) }
|
|
13
|
+
});
|
|
14
|
+
const events = [make(1), make(2)];
|
|
15
|
+
const originals = { alloc: Buffer.alloc, readFileSync: fs.readFileSync, readSync: fs.readSync };
|
|
16
|
+
try {
|
|
17
|
+
fs.writeFileSync(file, events.map(event => JSON.stringify(event)).join('\n') + '\n');
|
|
18
|
+
Buffer.alloc = (size, ...rest) => { assert.ok(size <= 65536, 'Event reader allocated the whole log'); return originals.alloc(size, ...rest); };
|
|
19
|
+
fs.readFileSync = (target, ...rest) => { assert.notEqual(target, file, 'Event reader loaded the whole log'); return originals.readFileSync(target, ...rest); };
|
|
20
|
+
let largestRead = 0;
|
|
21
|
+
fs.readSync = (fd, buffer, offset, length, position) => {
|
|
22
|
+
largestRead = Math.max(largestRead, length);
|
|
23
|
+
assert.ok(length <= 65536, 'Event reader requested the whole log');
|
|
24
|
+
return originals.readSync(fd, buffer, offset, Math.min(length, 4093), position);
|
|
25
|
+
};
|
|
26
|
+
const store = new JsonlEventStore(root);
|
|
27
|
+
await assert.rejects(store.append(events[0]), /Duplicate event_id/);
|
|
28
|
+
const third = make(3);
|
|
29
|
+
await store.append(third);
|
|
30
|
+
const replayed = [];
|
|
31
|
+
for await (const entry of store.replay({ workspaceId: 'acme', cursor: 1 })) replayed.push(entry);
|
|
32
|
+
assert.deepEqual(replayed.map(entry => entry.cursor), [2, 3]);
|
|
33
|
+
assert.deepEqual(replayed.map(entry => entry.event), [events[1], third]);
|
|
34
|
+
await assert.rejects(new JsonlEventStore(root).append(third), /Duplicate event_id/);
|
|
35
|
+
fs.appendFileSync(file, '{invalid JSON}\n');
|
|
36
|
+
await assert.rejects(new JsonlEventStore(root).append(make(4)), SyntaxError);
|
|
37
|
+
console.log(JSON.stringify({ ok: true, boundedReads: true, utf8: true, cursor: true, duplicate: true, corruptFailClosed: true, largestRead }));
|
|
38
|
+
} finally {
|
|
39
|
+
Buffer.alloc = originals.alloc; fs.readFileSync = originals.readFileSync; fs.readSync = originals.readSync;
|
|
40
|
+
fs.rmSync(root, { recursive: true });
|
|
41
|
+
}
|
package/src/events.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
6
|
import { withFileLock } from './file-lock.mjs';
|
|
6
7
|
|
|
7
8
|
// UUIDv5 namespace for event ids derived from idempotency keys.
|
|
@@ -83,22 +84,33 @@ function sameStat(a, b) {
|
|
|
83
84
|
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
/**
|
|
87
|
+
/** Decode one line at a time, never materializing the complete log as a string. */
|
|
88
|
+
function* logLines(fd, size) {
|
|
89
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
90
|
+
const decoder = new StringDecoder('utf8');
|
|
91
|
+
let offset = 0, pending = '';
|
|
92
|
+
while (offset < size) {
|
|
93
|
+
const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset);
|
|
94
|
+
if (read === 0) break;
|
|
95
|
+
offset += read;
|
|
96
|
+
const text = pending + decoder.write(buffer.subarray(0, read));
|
|
97
|
+
let start = 0, newline;
|
|
98
|
+
while ((newline = text.indexOf('\n', start)) !== -1) {
|
|
99
|
+
yield text.slice(start, newline);
|
|
100
|
+
start = newline + 1;
|
|
101
|
+
}
|
|
102
|
+
pending = text.slice(start);
|
|
103
|
+
}
|
|
104
|
+
pending += decoder.end();
|
|
105
|
+
if (pending) yield pending;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read every event_id, bounded to the bytes covered by the returned stat. */
|
|
87
109
|
function loadIndex(file) {
|
|
88
110
|
const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
89
111
|
try {
|
|
90
|
-
const stat = fs.fstatSync(fd);
|
|
91
|
-
const
|
|
92
|
-
let offset = 0;
|
|
93
|
-
while (offset < bytes.length) {
|
|
94
|
-
const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
95
|
-
if (read === 0) break;
|
|
96
|
-
offset += read;
|
|
97
|
-
}
|
|
98
|
-
const ids = new Set();
|
|
99
|
-
for (const line of bytes.subarray(0, offset).toString('utf8').split('\n')) {
|
|
100
|
-
if (line.trim()) ids.add(JSON.parse(line).event_id);
|
|
101
|
-
}
|
|
112
|
+
const stat = fs.fstatSync(fd), ids = new Set();
|
|
113
|
+
for (const line of logLines(fd, stat.size)) if (line.trim()) ids.add(JSON.parse(line).event_id);
|
|
102
114
|
return { ids, stat };
|
|
103
115
|
} finally { fs.closeSync(fd); }
|
|
104
116
|
}
|
|
@@ -168,12 +180,15 @@ export class JsonlEventStore {
|
|
|
168
180
|
for (const file of files) {
|
|
169
181
|
if (!fs.existsSync(file)) continue;
|
|
170
182
|
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
184
|
+
try {
|
|
185
|
+
for (const line of logLines(fd, fs.fstatSync(fd).size)) {
|
|
186
|
+
if (!line.trim()) continue;
|
|
187
|
+
cursor++;
|
|
188
|
+
if (cursor <= (options.cursor || 0)) continue;
|
|
189
|
+
yield { cursor, event: JSON.parse(line) };
|
|
190
|
+
}
|
|
191
|
+
} finally { fs.closeSync(fd); }
|
|
177
192
|
}
|
|
178
193
|
}
|
|
179
194
|
}
|