prolog-notebook 0.1.2 → 0.3.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/src/session.js ADDED
@@ -0,0 +1,227 @@
1
+ // The async session interface, and the in-process implementation of it.
2
+ //
3
+ // Everything above the engine talks to this, never to PrologSession directly. The
4
+ // methods return promises whether or not the work is actually asynchronous,
5
+ // because one of the two implementations runs the engine in a worker and the
6
+ // other does not, and a caller that has to know which is a caller that will one
7
+ // day be ported wrongly.
8
+ //
9
+ // await session.consult(text, 'cell-p-family')
10
+ // const query = session.query('is_son(X)')
11
+ // let r; while (!(r = await query.next()).done) …
12
+ // await session.abort()
13
+ //
14
+ // WHY ASYNC AT ALL: a Prolog query is synchronous WASM, so a non-terminating goal
15
+ // blocks whatever thread it runs on — no timer fires, no button responds, nothing
16
+ // repaints. Non-termination is chapter material in a Prolog book, so the engine
17
+ // has to live somewhere that can be terminated. See docs/modes.md and 869ejgkfq.
18
+
19
+ /**
20
+ * What every session replays after an abort.
21
+ *
22
+ * Abort is "throw the engine away and build a new one", which is only affordable
23
+ * because of a decision already made: one cell, one virtual file (869eddzfp), so
24
+ * the clause store is rebuilt from the cells in about 3.5 ms each. Nothing
25
+ * cooperative has to reach inside a running Prolog goal.
26
+ *
27
+ * Insertion order is document order, which is also execution order.
28
+ */
29
+ export class ConsultLog {
30
+ constructor() {
31
+ this.entries = new Map();
32
+ }
33
+
34
+ record(name, text) {
35
+ // Re-consulting a cell replaces it, exactly as SWI does, so the log holds one
36
+ // entry per cell rather than a history of edits.
37
+ this.entries.set(name, text);
38
+ }
39
+
40
+ forget(name) {
41
+ this.entries.delete(name);
42
+ }
43
+
44
+ /**
45
+ * Is this cell already loaded, at exactly this text?
46
+ *
47
+ * What makes "Run consults the cells above it" cheap enough to do on every
48
+ * click: the second Run of a chapter consults nothing, because nothing has
49
+ * changed. An edited cell answers false for itself and only for itself.
50
+ */
51
+ isCurrent(name, text) {
52
+ return this.entries.has(name) && this.entries.get(name) === text;
53
+ }
54
+
55
+ clear() {
56
+ this.entries.clear();
57
+ }
58
+
59
+ *[Symbol.iterator]() {
60
+ for (const [name, text] of this.entries) yield { name, text };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Take one cell's clauses back out of a running engine.
66
+ *
67
+ * Shared by both sessions because it is not really a session operation at all —
68
+ * it is one consult and one log edit, and two copies of that would be two things
69
+ * to keep in step.
70
+ *
71
+ * HOW IT WORKS, because it looks like a trick and is not: one cell is one virtual
72
+ * file (869eddzfp), and consulting a file replaces the clauses that came from it.
73
+ * Consulting nothing therefore leaves nothing. This is SWI's own semantics, not
74
+ * bookkeeping of ours, so a predicate whose only clauses lived in this cell goes
75
+ * back to being genuinely unknown — and a query that calls it says so, exactly as
76
+ * a real toplevel would.
77
+ *
78
+ * There is deliberately NO dependency handling here. A cell that mentioned this
79
+ * one is untouched, because Prolog has no load-time name binding: `q(X) :- p(X)`
80
+ * merely mentions p/1 and looks it up when called. The consequence surfaces as an
81
+ * ordinary Prolog error at call time, which is the truth rather than a cascade we
82
+ * would have to invent.
83
+ *
84
+ * @param {{consult: Function, log: ConsultLog}} session
85
+ * @param {string} name
86
+ */
87
+ export async function unconsult(session, name) {
88
+ if (!session.log.entries.has(name)) return { ok: true, unloaded: false };
89
+ const result = await session.consult('', name);
90
+ // Forgotten rather than recorded as empty, so a restart does not spend a
91
+ // consult loading nothing.
92
+ session.log.forget(name);
93
+ return { ...result, unloaded: result.ok };
94
+ }
95
+
96
+ let anonymousCells = 0;
97
+
98
+ /**
99
+ * A consult with no cell name still needs a stable one, or the replay log cannot
100
+ * key it and an abort would lose it.
101
+ */
102
+ export function defaultCellName() {
103
+ return `cell-anon-${++anonymousCells}`;
104
+ }
105
+
106
+ /**
107
+ * A session that runs the engine on the caller's own thread.
108
+ *
109
+ * Correct, simple, and NOT protected: a non-terminating goal hangs the process,
110
+ * because there is no second thread to notice. That is acceptable for Node — the
111
+ * CLI is not an interactive page and CI has its own timeouts — and it is stated
112
+ * here rather than implied, so nobody discovers it in a browser.
113
+ */
114
+ export class InProcessSession {
115
+ /**
116
+ * @param {import('./engine.js').PrologSession} engine
117
+ * @param {() => Promise<import('./engine.js').PrologSession>} rebuild
118
+ */
119
+ constructor(engine, rebuild) {
120
+ this.engine = engine;
121
+ this.rebuild = rebuild;
122
+ this.log = new ConsultLog();
123
+ // The one query allowed to be open. See supersede() below.
124
+ this.open = null;
125
+ }
126
+
127
+ /**
128
+ * ONE OPEN SEQUENCE PER SESSION, and the reason is not tidiness.
129
+ *
130
+ * SWI keeps open queries on a stack and swipl-wasm enforces it: stepping or
131
+ * closing anything but the innermost throws "Attempt to access not innermost
132
+ * query". A page cannot promise the order — the order is whatever the reader
133
+ * clicks — so the constraint is met by construction instead: there is never
134
+ * more than one open query, which means the one being closed is always the
135
+ * innermost, which means the close is always legal (869epzqpc).
136
+ *
137
+ * The caller is told, through the query's own `onSuperseded`, because a
138
+ * sequence that ends without saying so is exactly the silence this replaces.
139
+ */
140
+ supersede() {
141
+ const previous = this.open;
142
+ this.open = null;
143
+ if (!previous) return;
144
+ // Straight to the engine's query: closing is synchronous in this process, and
145
+ // the frame must be gone before the next PL_open_query, not merely scheduled.
146
+ previous.query.close({ superseded: true });
147
+ previous.onSuperseded?.();
148
+ }
149
+
150
+ async consult(text, name = defaultCellName()) {
151
+ const result = this.engine.consult(text, name);
152
+ if (result.ok) this.log.record(name, text);
153
+ return result;
154
+ }
155
+
156
+ query(goal) {
157
+ // The frame opens here, in the engine's constructor, so the previous one has
158
+ // to be closed BEFORE this line rather than after: once a second query is
159
+ // open, the first is no longer innermost and can never be closed at all.
160
+ this.supersede();
161
+ const query = new InProcessQuery(this.engine.query(goal), this);
162
+ this.open = query;
163
+ return query;
164
+ }
165
+
166
+ async unconsult(name) {
167
+ return unconsult(this, name);
168
+ }
169
+
170
+ /**
171
+ * Discard the engine and replay the consults into a fresh one.
172
+ *
173
+ * In-process this can only happen BETWEEN operations — a goal that is already
174
+ * looping has the thread and will not give it back. Use the worker-backed
175
+ * session where that matters.
176
+ */
177
+ async restart() {
178
+ // Every frame died with the engine. Saying so here rather than leaving a
179
+ // handle that points into a heap that no longer exists.
180
+ this.open = null;
181
+ this.engine = await this.rebuild();
182
+ for (const { name, text } of this.log) this.engine.consult(text, name);
183
+ }
184
+
185
+ async abort() {
186
+ return this.restart();
187
+ }
188
+
189
+ async close() {}
190
+ }
191
+
192
+ // No formatSolution() on a session, deliberately. The engine-backed one renders
193
+ // through SWI itself and the worker-backed one could not — it would have to fall
194
+ // back to the engine-free spelling, so the same call would quietly mean two
195
+ // different things depending on where it ran. Use `query.next().text`, which SWI
196
+ // renders in both, or the exported formatSolution() when there is no engine at all.
197
+
198
+ class InProcessQuery {
199
+ constructor(query, session) {
200
+ this.query = query;
201
+ this.session = session;
202
+ /** Set by the caller to hear that its sequence was closed for another one. */
203
+ this.onSuperseded = null;
204
+ }
205
+
206
+ async next() {
207
+ const r = this.query.next();
208
+ if (r.done) this.#release();
209
+ return r;
210
+ }
211
+
212
+ async all(limit) {
213
+ const r = this.query.all(limit);
214
+ this.#release();
215
+ return r;
216
+ }
217
+
218
+ async close(options) {
219
+ this.query.close(options);
220
+ this.#release();
221
+ }
222
+
223
+ /** The frame is gone, so this query is no longer the one holding the session's. */
224
+ #release() {
225
+ if (this.session?.open === this) this.session.open = null;
226
+ }
227
+ }
package/src/version.js ADDED
@@ -0,0 +1,51 @@
1
+ // Who this is, in one place, for everything that has to say so.
2
+ //
3
+ // The command reads package.json at run time; a page cannot — there is no
4
+ // filesystem behind a `<script type="module">` and no build step to inline
5
+ // anything. So the facts live here, as constants both can import, and tests
6
+ // assert that they still agree with package.json and with LICENSE. Two files to
7
+ // touch at release, and a suite that fails loudly when only one of them is.
8
+
9
+ /** The name a person would say. `prolog-notebook` is what npm installs. */
10
+ export const NAME = 'Prolog Notebook';
11
+
12
+ /** Must equal package.json's `version` — test/run.test.mjs enforces it. */
13
+ export const VERSION = '0.3.1';
14
+
15
+ /** The two facts a licence notice is actually made of. */
16
+ export const YEAR = '2026';
17
+ export const HOLDER = 'Johnny Jarecsni';
18
+
19
+ /** Must agree with LICENSE on the year and the holder. Same enforcement. */
20
+ export const COPYRIGHT = `Copyright (C) ${YEAR} ${HOLDER}`;
21
+
22
+ export const LICENSE = 'MIT';
23
+
24
+ /**
25
+ * The line a command prints and a page shows in its panel, so the two cannot
26
+ * describe the same release differently.
27
+ *
28
+ * @returns {string} e.g. "Prolog Notebook v0.2.0 - Copyright (C) 2026 … , MIT License."
29
+ */
30
+ export function banner() {
31
+ return `${NAME} v${VERSION} - ${COPYRIGHT}, ${LICENSE} License.`;
32
+ }
33
+
34
+ /**
35
+ * The same facts for a page, which has a card to fit them in rather than a
36
+ * terminal to fill.
37
+ *
38
+ * TWO SHORT LINES BY CONSTRUCTION, not by shrinking the type: what is running,
39
+ * then who owns it. The engine's version joins the first line once it is known,
40
+ * because that line is the identity of the thing doing the work — and `©` rather
41
+ * than `Copyright (C)` because that is how a page writes it, while the words in
42
+ * both come from the same constants.
43
+ *
44
+ * @returns {{running: string, legal: string}}
45
+ */
46
+ export function colophon() {
47
+ return {
48
+ running: `${NAME} v${VERSION}`,
49
+ legal: `© ${YEAR} ${HOLDER} · ${LICENSE} License`,
50
+ };
51
+ }
package/src/worker.js ADDED
@@ -0,0 +1,77 @@
1
+ // The engine's home. A CLASSIC worker, deliberately.
2
+ //
3
+ // swipl-wasm ships only UMD/global scripts — `var SWIPL = …` at top level, with no
4
+ // ESM export — so a module worker's `import()` would hand back an empty namespace.
5
+ // importScripts is the only thing that puts SWIPL where we can reach it. Our own
6
+ // engine is an ES module, and dynamic import() works fine inside a classic worker,
7
+ // so nothing has to be bundled or duplicated to get both here.
8
+ //
9
+ // Everything in this file is protocol plumbing. The Prolog lives in engine.js and
10
+ // is not aware it is in a worker.
11
+
12
+ /* global importScripts */
13
+
14
+ let session = null;
15
+ const queries = new Map();
16
+ let nextQueryId = 1;
17
+
18
+ self.onmessage = async ({ data }) => {
19
+ const { id, op } = data;
20
+ try {
21
+ self.postMessage({ id, ok: true, value: await handle(op, data) });
22
+ } catch (error) {
23
+ self.postMessage({ id, ok: false, error: error?.message ?? String(error) });
24
+ }
25
+ };
26
+
27
+ async function handle(op, args) {
28
+ switch (op) {
29
+ case 'boot': {
30
+ importScripts(args.swiplUrl);
31
+ const { PrologSession } = await import(args.engineUrl);
32
+ if (typeof self.SWIPL !== 'function') {
33
+ throw new Error(`swipl-wasm did not define SWIPL after loading ${args.swiplUrl}`);
34
+ }
35
+ session = await PrologSession.create(self.SWIPL, args.options ?? {});
36
+ return true;
37
+ }
38
+
39
+ case 'consult':
40
+ return session.consult(args.text, args.name);
41
+
42
+ case 'open': {
43
+ const qid = nextQueryId++;
44
+ queries.set(qid, session.query(args.goal));
45
+ return qid;
46
+ }
47
+
48
+ case 'next': {
49
+ const query = queries.get(args.qid);
50
+ if (!query) throw new Error(`no open query ${args.qid}`);
51
+ const result = query.next();
52
+ if (result.done) queries.delete(args.qid);
53
+ return result;
54
+ }
55
+
56
+ case 'all': {
57
+ const query = queries.get(args.qid);
58
+ if (!query) throw new Error(`no open query ${args.qid}`);
59
+ queries.delete(args.qid);
60
+ return query.all(args.limit);
61
+ }
62
+
63
+ case 'close': {
64
+ // CLOSING MUST REACH THE ENGINE. Forgetting the id here — which is all this
65
+ // did until 869epzqpc — leaves the query open inside SWI for the life of
66
+ // the session, and every later query then nests inside a frame nobody can
67
+ // ever step or release.
68
+ const query = queries.get(args.qid);
69
+ queries.delete(args.qid);
70
+ query?.close();
71
+ return true;
72
+ }
73
+
74
+ default:
75
+ throw new Error(`unknown worker op "${op}"`);
76
+ }
77
+ }