eyeling 1.28.0 → 1.28.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/README.md +6 -13
- package/dist/browser/eyeling.browser.js +149 -28
- package/examples/alma-rdf-messages.n3 +202 -0
- package/examples/output/alma-rdf-messages.n3 +0 -0
- package/eyeling.js +156 -29
- package/lib/cli.js +140 -27
- package/lib/engine.js +9 -1
- package/notes/rdf-message-logs.md +228 -0
- package/package.json +1 -1
- package/test/stream_messages.test.js +102 -3
- package/tools/bundle.js +7 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# RDF Message Logs internals
|
|
2
|
+
|
|
3
|
+
Eyeling handles RDF Message Logs as an RDF-compatibility normalization step. A message log is not parsed as one flat graph first; it is split into message chunks and rewritten into ordinary N3 facts that describe the stream, its ordered message envelopes, and each message payload graph.
|
|
4
|
+
|
|
5
|
+
There are two execution paths:
|
|
6
|
+
|
|
7
|
+
- normal RDF mode, where a whole message log is normalized into one replay document;
|
|
8
|
+
- `--stream-messages`, where the CLI reads one message at a time and runs the rules once per replayed message.
|
|
9
|
+
|
|
10
|
+
Both paths expose the same basic `eymsg:` vocabulary and use N3 quoted formulas for payload graphs.
|
|
11
|
+
|
|
12
|
+
## Input shape
|
|
13
|
+
|
|
14
|
+
A log is recognized by a message-version directive:
|
|
15
|
+
|
|
16
|
+
```trig
|
|
17
|
+
VERSION "1.2-messages"
|
|
18
|
+
PREFIX : <urn:example#>
|
|
19
|
+
|
|
20
|
+
:a :value 1 .
|
|
21
|
+
|
|
22
|
+
MESSAGE
|
|
23
|
+
|
|
24
|
+
# Empty heartbeat.
|
|
25
|
+
|
|
26
|
+
MESSAGE
|
|
27
|
+
|
|
28
|
+
:b :value 2 .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The current code accepts message versions matching:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
VERSION "1.1-messages"
|
|
35
|
+
VERSION "1.2-messages"
|
|
36
|
+
VERSION "1.2-basic-messages"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Old-style `@version` and `@message` forms are also recognized.
|
|
40
|
+
|
|
41
|
+
## Normal, whole-log RDF mode
|
|
42
|
+
|
|
43
|
+
When `lex(input, { rdf: true })` sees a `*-messages` version directive, `normalizeRdfCompatibility()` dispatches to `normalizeRdfMessageLog()` in `lib/lexer.js`.
|
|
44
|
+
|
|
45
|
+
That function:
|
|
46
|
+
|
|
47
|
+
1. strips the version directive;
|
|
48
|
+
2. splits the text at top-level `MESSAGE` / `@message` delimiters;
|
|
49
|
+
3. creates deterministic stream, envelope, and payload IRIs from a hash of the whole source text;
|
|
50
|
+
4. normalizes each message chunk through the RDF/TriG compatibility layer;
|
|
51
|
+
5. rewrites blank-node labels so each message gets its own blank-node scope;
|
|
52
|
+
6. emits ordinary N3 replay facts.
|
|
53
|
+
|
|
54
|
+
Conceptually, the input above becomes something like:
|
|
55
|
+
|
|
56
|
+
```n3
|
|
57
|
+
<urn:eyeling:message-log:HASH#stream>
|
|
58
|
+
a eymsg:RDFMessageStream ;
|
|
59
|
+
eymsg:messageCount "3"^^xsd:integer ;
|
|
60
|
+
eymsg:orderedEnvelopes (
|
|
61
|
+
<urn:eyeling:message-log:HASH#m001>
|
|
62
|
+
<urn:eyeling:message-log:HASH#m002>
|
|
63
|
+
<urn:eyeling:message-log:HASH#m003>
|
|
64
|
+
) ;
|
|
65
|
+
eymsg:firstEnvelope <urn:eyeling:message-log:HASH#m001> ;
|
|
66
|
+
eymsg:lastEnvelope <urn:eyeling:message-log:HASH#m003> ;
|
|
67
|
+
eymsg:envelope <urn:eyeling:message-log:HASH#m001>,
|
|
68
|
+
<urn:eyeling:message-log:HASH#m002>,
|
|
69
|
+
<urn:eyeling:message-log:HASH#m003> .
|
|
70
|
+
|
|
71
|
+
<urn:eyeling:message-log:HASH#m001>
|
|
72
|
+
a eymsg:MessageEnvelope ;
|
|
73
|
+
eymsg:offset "1"^^xsd:integer ;
|
|
74
|
+
eymsg:payloadKind eymsg:nonEmpty ;
|
|
75
|
+
eymsg:nextEnvelope <urn:eyeling:message-log:HASH#m002> ;
|
|
76
|
+
eymsg:payloadGraph <urn:eyeling:message-log:HASH#m001/payload> .
|
|
77
|
+
|
|
78
|
+
<urn:eyeling:message-log:HASH#m001/payload>
|
|
79
|
+
log:nameOf {
|
|
80
|
+
:a :value 1 .
|
|
81
|
+
} .
|
|
82
|
+
|
|
83
|
+
<urn:eyeling:message-log:HASH#m002>
|
|
84
|
+
a eymsg:MessageEnvelope ;
|
|
85
|
+
eymsg:offset "2"^^xsd:integer ;
|
|
86
|
+
eymsg:payloadKind eymsg:empty ;
|
|
87
|
+
eymsg:nextEnvelope <urn:eyeling:message-log:HASH#m003> .
|
|
88
|
+
|
|
89
|
+
<urn:eyeling:message-log:HASH#m003>
|
|
90
|
+
a eymsg:MessageEnvelope ;
|
|
91
|
+
eymsg:offset "3"^^xsd:integer ;
|
|
92
|
+
eymsg:payloadKind eymsg:nonEmpty ;
|
|
93
|
+
eymsg:payloadGraph <urn:eyeling:message-log:HASH#m003/payload> .
|
|
94
|
+
|
|
95
|
+
<urn:eyeling:message-log:HASH#m003/payload>
|
|
96
|
+
log:nameOf {
|
|
97
|
+
:b :value 2 .
|
|
98
|
+
} .
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
After this rewrite, the ordinary N3 parser and reasoner handle the result. There is no separate RDF Message Log AST.
|
|
102
|
+
|
|
103
|
+
## Payload graphs
|
|
104
|
+
|
|
105
|
+
Payloads are represented exactly like other RDF/TriG named graphs in RDF compatibility mode:
|
|
106
|
+
|
|
107
|
+
```n3
|
|
108
|
+
?Payload log:nameOf { ...payload triples... } .
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The object is an N3 quoted formula. Rules inspect it with formula-aware built-ins such as `log:includes`:
|
|
112
|
+
|
|
113
|
+
```n3
|
|
114
|
+
@prefix eymsg: <https://eyereasoner.github.io/eyeling/vocab/message#> .
|
|
115
|
+
@prefix log: <http://www.w3.org/2000/10/swap/log#> .
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
?Envelope eymsg:payloadGraph ?Payload .
|
|
119
|
+
?Payload log:nameOf ?Graph .
|
|
120
|
+
?Graph log:includes { ?Subject :value ?Value . } .
|
|
121
|
+
} => {
|
|
122
|
+
?Envelope :sawValue ?Value .
|
|
123
|
+
} .
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This is the key design choice: message boundaries are preserved by putting each payload in its own quoted graph instead of merging all message triples into the top-level fact set.
|
|
127
|
+
|
|
128
|
+
## Per-message normalization
|
|
129
|
+
|
|
130
|
+
Each message body is normalized before it is embedded as a payload formula:
|
|
131
|
+
|
|
132
|
+
- RDF 1.2 triple terms are converted to singleton N3 graph terms;
|
|
133
|
+
- RDF 1.2 annotation syntax is expanded;
|
|
134
|
+
- TriG named graphs are converted to `log:nameOf` triples;
|
|
135
|
+
- blank-node labels are rewritten with a message-specific prefix.
|
|
136
|
+
|
|
137
|
+
For example, the same blank-node label in two different messages does not denote the same internal blank node. Message 1 rewrites roughly to:
|
|
138
|
+
|
|
139
|
+
```n3
|
|
140
|
+
_:eyeling_m001_b :value 1 .
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
and message 3 rewrites roughly to:
|
|
144
|
+
|
|
145
|
+
```n3
|
|
146
|
+
_:eyeling_m003_b :value 2 .
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If the message log appears as the second parsed source, the parser's usual source prefixing may add another source prefix around those labels, for example `_:src2_eyeling_m001_b`.
|
|
150
|
+
|
|
151
|
+
## Empty heartbeat messages
|
|
152
|
+
|
|
153
|
+
A message chunk is considered empty when, after directives and comments are ignored, it contains no RDF payload.
|
|
154
|
+
|
|
155
|
+
Empty messages still get an envelope:
|
|
156
|
+
|
|
157
|
+
```n3
|
|
158
|
+
?Envelope a eymsg:MessageEnvelope ;
|
|
159
|
+
eymsg:offset "2"^^xsd:integer ;
|
|
160
|
+
eymsg:payloadKind eymsg:empty .
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
but they do not get an `eymsg:payloadGraph` triple. This lets rules distinguish heartbeats from non-empty data messages without accidentally reusing a previous payload.
|
|
164
|
+
|
|
165
|
+
## `--stream-messages` mode
|
|
166
|
+
|
|
167
|
+
The CLI streaming path lives in `lib/cli.js`.
|
|
168
|
+
|
|
169
|
+
With:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
eyeling --rdf --stream-messages rules.n3 messages.trig
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
the CLI separates ordinary sources from message-log sources. Ordinary sources are parsed once as the reusable rule/program documents. Message logs are then read chunk by chunk.
|
|
176
|
+
|
|
177
|
+
For each chunk, `buildSingleMessageReplayDocument()` creates a small replay document containing one stream, one envelope, and optionally one payload graph:
|
|
178
|
+
|
|
179
|
+
```n3
|
|
180
|
+
<urn:eyeling:message-stream:HASH#stream>
|
|
181
|
+
a eymsg:RDFMessageStream ;
|
|
182
|
+
eymsg:envelope <urn:eyeling:message-stream:HASH#m000001> ;
|
|
183
|
+
eymsg:orderedEnvelopes (<urn:eyeling:message-stream:HASH#m000001>) ;
|
|
184
|
+
eymsg:firstEnvelope <urn:eyeling:message-stream:HASH#m000001> ;
|
|
185
|
+
eymsg:lastEnvelope <urn:eyeling:message-stream:HASH#m000001> .
|
|
186
|
+
|
|
187
|
+
<urn:eyeling:message-stream:HASH#m000001>
|
|
188
|
+
a eymsg:MessageEnvelope ;
|
|
189
|
+
eymsg:offset "1"^^xsd:integer ;
|
|
190
|
+
eymsg:payloadKind eymsg:nonEmpty ;
|
|
191
|
+
eymsg:payloadGraph <urn:eyeling:message-stream:HASH#m000001/payload> .
|
|
192
|
+
|
|
193
|
+
<urn:eyeling:message-stream:HASH#m000001/payload>
|
|
194
|
+
log:nameOf {
|
|
195
|
+
...one message payload...
|
|
196
|
+
} .
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
That one-message replay document is merged with the already-parsed program sources and run immediately. The next message gets a fresh replay document and a fresh run.
|
|
200
|
+
|
|
201
|
+
The streaming replay document intentionally does not expose the whole global message list. It only exposes the current message envelope, because the point of `--stream-messages` is one-message-at-a-time processing without materializing the whole log.
|
|
202
|
+
|
|
203
|
+
## Remote logs
|
|
204
|
+
|
|
205
|
+
For local files, `--stream-messages` reads line by line. For HTTP(S) sources, the CLI first checks the prefix to detect a message-version directive. When processing a remote text/plain RDF Message Log, it downloads the source into a temporary file and then streams that local copy line by line.
|
|
206
|
+
|
|
207
|
+
## Output behavior
|
|
208
|
+
|
|
209
|
+
RDF Message Logs do not introduce special output syntax. Once replay facts have been produced, output is the normal Eyeling output path:
|
|
210
|
+
|
|
211
|
+
- `log:outputString` facts are collected and printed as text;
|
|
212
|
+
- `log:query` output is printed from query conclusions;
|
|
213
|
+
- in RDF mode, triples are printed with RDF-compatible output formatting where possible.
|
|
214
|
+
|
|
215
|
+
## Practical mental model
|
|
216
|
+
|
|
217
|
+
RDF Message Log support is best understood as:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
message-log syntax
|
|
221
|
+
-> split into message chunks
|
|
222
|
+
-> normalize each chunk as RDF/TriG/RDF 1.2
|
|
223
|
+
-> wrap each chunk in an eymsg: envelope
|
|
224
|
+
-> expose the payload as log:nameOf { ... }
|
|
225
|
+
-> run the ordinary N3 reasoner
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
So the feature is not a separate streaming RDF reasoner. It is a replay encoding that turns message boundaries into explicit N3 facts and quoted payload graphs, which ordinary Eyeling rules can inspect.
|
package/package.json
CHANGED
|
@@ -161,6 +161,88 @@ function startFileServer(file) {
|
|
|
161
161
|
throw new Error('test HTTP server did not start');
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
|
|
165
|
+
function startOpenEndedMessageServer() {
|
|
166
|
+
const dir = mkTmpDir();
|
|
167
|
+
const script = path.join(dir, 'server.js');
|
|
168
|
+
const portFile = path.join(dir, 'server.port');
|
|
169
|
+
fs.writeFileSync(
|
|
170
|
+
script,
|
|
171
|
+
[
|
|
172
|
+
"const http = require('node:http');",
|
|
173
|
+
"const fs = require('node:fs');",
|
|
174
|
+
'const portFile = process.argv[2];',
|
|
175
|
+
'const server = http.createServer((req, res) => {',
|
|
176
|
+
" res.writeHead(200, { 'content-type': 'text/plain' });",
|
|
177
|
+
" res.write('VERSION \\\"1.2-messages\\\"\\nPREFIX : <urn:test#>\\n');",
|
|
178
|
+
" res.write(':a :line \\\"one\\\\n\\\".\\nMESSAGE\\n');",
|
|
179
|
+
" setInterval(() => res.write('# keepalive\\n'), 1000);",
|
|
180
|
+
'});',
|
|
181
|
+
"server.listen(0, '127.0.0.1', () => fs.writeFileSync(portFile, String(server.address().port)));",
|
|
182
|
+
'',
|
|
183
|
+
].join('\n'),
|
|
184
|
+
'utf8',
|
|
185
|
+
);
|
|
186
|
+
const child = cp.spawn(process.execPath, [script, portFile], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
187
|
+
const deadline = Date.now() + 5000;
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
if (fs.existsSync(portFile)) {
|
|
190
|
+
const port = fs.readFileSync(portFile, 'utf8').trim();
|
|
191
|
+
return {
|
|
192
|
+
url: `http://127.0.0.1:${port}/messages.txt`,
|
|
193
|
+
stop: () => {
|
|
194
|
+
child.kill();
|
|
195
|
+
rmrf(dir);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
200
|
+
}
|
|
201
|
+
child.kill();
|
|
202
|
+
rmrf(dir);
|
|
203
|
+
throw new Error('test open-ended HTTP server did not start');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function waitForEyelingOutput(args, pattern, opts = {}) {
|
|
207
|
+
const timeoutMs = opts.timeoutMs || 4000;
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const child = cp.spawn(process.execPath, [eyelingJsPath, ...args], {
|
|
210
|
+
cwd: root,
|
|
211
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
212
|
+
});
|
|
213
|
+
let stdout = '';
|
|
214
|
+
let stderr = '';
|
|
215
|
+
let settled = false;
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
if (settled) return;
|
|
218
|
+
settled = true;
|
|
219
|
+
child.kill();
|
|
220
|
+
reject(new Error(`timed out waiting for ${pattern}; stdout=${JSON.stringify(stdout)} stderr=${JSON.stringify(stderr)}`));
|
|
221
|
+
}, timeoutMs);
|
|
222
|
+
|
|
223
|
+
function finish(err) {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
child.kill();
|
|
228
|
+
if (err) reject(err);
|
|
229
|
+
else resolve(stdout);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
child.stdout.on('data', (chunk) => {
|
|
233
|
+
stdout += chunk.toString('utf8');
|
|
234
|
+
if (pattern.test(stdout)) finish();
|
|
235
|
+
});
|
|
236
|
+
child.stderr.on('data', (chunk) => {
|
|
237
|
+
stderr += chunk.toString('utf8');
|
|
238
|
+
});
|
|
239
|
+
child.on('error', finish);
|
|
240
|
+
child.on('exit', (code) => {
|
|
241
|
+
if (!settled && code !== 0) finish(new Error(`eyeling exited with ${code}; stdout=${stdout}; stderr=${stderr}`));
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
164
246
|
const cases = [
|
|
165
247
|
{
|
|
166
248
|
name: 'scoped payload rules run once per RDF Message',
|
|
@@ -220,6 +302,20 @@ const cases = [
|
|
|
220
302
|
}
|
|
221
303
|
},
|
|
222
304
|
},
|
|
305
|
+
{
|
|
306
|
+
name: 'remote HTTP RDF Message Logs emit before the response ends',
|
|
307
|
+
async run(tmp) {
|
|
308
|
+
const rules = path.join(tmp, 'rules.n3');
|
|
309
|
+
writeScopedPayloadRules(rules);
|
|
310
|
+
const server = startOpenEndedMessageServer();
|
|
311
|
+
try {
|
|
312
|
+
const out = await waitForEyelingOutput(['-r', '--stream-messages', rules, server.url], /^one\n/);
|
|
313
|
+
assert.equal(out, 'one\n');
|
|
314
|
+
} finally {
|
|
315
|
+
server.stop();
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
},
|
|
223
319
|
{
|
|
224
320
|
name: 'MARC extraction rules fire over each streamed payload graph',
|
|
225
321
|
run(tmp) {
|
|
@@ -251,7 +347,7 @@ const cases = [
|
|
|
251
347
|
},
|
|
252
348
|
];
|
|
253
349
|
|
|
254
|
-
(function main() {
|
|
350
|
+
(async function main() {
|
|
255
351
|
const suiteStart = msNow();
|
|
256
352
|
info(`Running ${cases.length} stream-message tests`);
|
|
257
353
|
let passed = 0;
|
|
@@ -261,7 +357,7 @@ const cases = [
|
|
|
261
357
|
const testName = numberedName(index, tc.name);
|
|
262
358
|
const start = msNow();
|
|
263
359
|
try {
|
|
264
|
-
tc.run(tmp);
|
|
360
|
+
await tc.run(tmp);
|
|
265
361
|
ok(`${testName} ${C.dim}(${msNow() - start} ms)${C.n}`);
|
|
266
362
|
passed++;
|
|
267
363
|
} catch (e) {
|
|
@@ -281,4 +377,7 @@ const cases = [
|
|
|
281
377
|
}
|
|
282
378
|
fail(`Some stream-message tests failed (${passed}/${cases.length})`);
|
|
283
379
|
process.exit(1);
|
|
284
|
-
})()
|
|
380
|
+
})().catch((e) => {
|
|
381
|
+
fail(e && e.stack ? e.stack : String(e));
|
|
382
|
+
process.exit(1);
|
|
383
|
+
});
|
package/tools/bundle.js
CHANGED
|
@@ -195,7 +195,13 @@ function buildBundleSource({ autoRunMain }) {
|
|
|
195
195
|
out.push(
|
|
196
196
|
' if (__outerModule && __outerRequire && __outerRequire.main === __outerModule && typeof __entry.main === "function") {',
|
|
197
197
|
);
|
|
198
|
-
out.push(' __entry.main();');
|
|
198
|
+
out.push(' const __mainResult = __entry.main();');
|
|
199
|
+
out.push(' if (__mainResult && typeof __mainResult.then === "function") {');
|
|
200
|
+
out.push(' __mainResult.catch((e) => {');
|
|
201
|
+
out.push(' try { if (typeof console !== "undefined" && console.error) console.error(e && e.stack ? e.stack : e && e.message ? e.message : String(e)); } catch (ignoredError) {}');
|
|
202
|
+
out.push(' try { if (typeof process !== "undefined" && process.exit) process.exit(1); } catch (ignoredError) {}');
|
|
203
|
+
out.push(' });');
|
|
204
|
+
out.push(' }');
|
|
199
205
|
out.push(' }');
|
|
200
206
|
out.push(' } catch (ignoredError) {}');
|
|
201
207
|
}
|