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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.28.0",
3
+ "version": "1.28.1",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -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
  }