@dxos/datalog 0.0.1 → 0.12.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.
Files changed (61) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +58 -1
  3. package/dist/lib/Ast.mjs +72 -0
  4. package/dist/lib/Ast.mjs.map +1 -0
  5. package/dist/lib/Builtin.mjs +37 -0
  6. package/dist/lib/Builtin.mjs.map +1 -0
  7. package/dist/lib/Checker.mjs +3 -0
  8. package/dist/lib/Engine.mjs +3 -0
  9. package/dist/lib/Parser.mjs +2 -0
  10. package/dist/lib/chunk-Checker.mjs +414 -0
  11. package/dist/lib/chunk-Checker.mjs.map +1 -0
  12. package/dist/lib/chunk-Engine.mjs +488 -0
  13. package/dist/lib/chunk-Engine.mjs.map +1 -0
  14. package/dist/lib/chunk-Parser.mjs +366 -0
  15. package/dist/lib/chunk-Parser.mjs.map +1 -0
  16. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  17. package/dist/lib/index.mjs +6 -0
  18. package/dist/types/src/Ast.d.ts +81 -0
  19. package/dist/types/src/Ast.d.ts.map +1 -0
  20. package/dist/types/src/Builtin.d.ts +35 -0
  21. package/dist/types/src/Builtin.d.ts.map +1 -0
  22. package/dist/types/src/Checker.d.ts +61 -0
  23. package/dist/types/src/Checker.d.ts.map +1 -0
  24. package/dist/types/src/Checker.test.d.ts +2 -0
  25. package/dist/types/src/Checker.test.d.ts.map +1 -0
  26. package/dist/types/src/Engine.d.ts +55 -0
  27. package/dist/types/src/Engine.d.ts.map +1 -0
  28. package/dist/types/src/Engine.test.d.ts +2 -0
  29. package/dist/types/src/Engine.test.d.ts.map +1 -0
  30. package/dist/types/src/Engine.workerd.test.d.ts +2 -0
  31. package/dist/types/src/Engine.workerd.test.d.ts.map +1 -0
  32. package/dist/types/src/Parser.d.ts +43 -0
  33. package/dist/types/src/Parser.d.ts.map +1 -0
  34. package/dist/types/src/Parser.test.d.ts +2 -0
  35. package/dist/types/src/Parser.test.d.ts.map +1 -0
  36. package/dist/types/src/index.d.ts +6 -0
  37. package/dist/types/src/index.d.ts.map +1 -0
  38. package/dist/types/src/internal/lexer.d.ts +46 -0
  39. package/dist/types/src/internal/lexer.d.ts.map +1 -0
  40. package/dist/types/src/internal/plan.d.ts +96 -0
  41. package/dist/types/src/internal/plan.d.ts.map +1 -0
  42. package/dist/types/src/internal/relation.d.ts +20 -0
  43. package/dist/types/src/internal/relation.d.ts.map +1 -0
  44. package/dist/types/src/internal/stratify.d.ts +28 -0
  45. package/dist/types/src/internal/stratify.d.ts.map +1 -0
  46. package/dist/types/tsconfig.tsbuildinfo +1 -0
  47. package/package.json +57 -5
  48. package/src/Ast.ts +136 -0
  49. package/src/Builtin.ts +73 -0
  50. package/src/Checker.test.ts +99 -0
  51. package/src/Checker.ts +156 -0
  52. package/src/Engine.test.ts +239 -0
  53. package/src/Engine.ts +614 -0
  54. package/src/Engine.workerd.test.ts +33 -0
  55. package/src/Parser.test.ts +84 -0
  56. package/src/Parser.ts +180 -0
  57. package/src/index.ts +9 -0
  58. package/src/internal/lexer.ts +154 -0
  59. package/src/internal/plan.ts +346 -0
  60. package/src/internal/relation.ts +95 -0
  61. package/src/internal/stratify.ts +114 -0
@@ -0,0 +1,239 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import type * as Ast from './Ast.ts';
8
+ import * as Builtin from './Builtin.ts';
9
+ import * as Checker from './Checker.ts';
10
+ import * as Engine from './Engine.ts';
11
+ import * as Parser from './Parser.ts';
12
+
13
+ const TRANSITIVE_CLOSURE = `
14
+ path(X, Y) :- edge(X, Y).
15
+ path(X, Z) :- path(X, Y), edge(Y, Z).
16
+ `;
17
+
18
+ describe('Engine', () => {
19
+ test('computes transitive closure', ({ expect }) => {
20
+ const engine = Engine.make({ program: Parser.parse(`${TRANSITIVE_CLOSURE} edge(a, b). edge(b, c). edge(c, d).`) });
21
+ expect(sorted(engine.query('path'))).toEqual([
22
+ ['a', 'b'],
23
+ ['a', 'c'],
24
+ ['a', 'd'],
25
+ ['b', 'c'],
26
+ ['b', 'd'],
27
+ ['c', 'd'],
28
+ ]);
29
+ expect(engine.query('path', ['b'])).toHaveLength(2);
30
+ });
31
+
32
+ test('evaluates stratified negation', ({ expect }) => {
33
+ const engine = Engine.make({
34
+ program: Parser.parse(`
35
+ ${TRANSITIVE_CLOSURE}
36
+ node(a). node(b). node(c).
37
+ edge(a, b).
38
+ unreachable(X, Y) :- node(X), node(Y), X != Y, not path(X, Y).
39
+ `),
40
+ });
41
+ expect(sorted(engine.query('unreachable'))).toEqual([
42
+ ['a', 'c'],
43
+ ['b', 'a'],
44
+ ['b', 'c'],
45
+ ['c', 'a'],
46
+ ['c', 'b'],
47
+ ]);
48
+
49
+ const changes = engine.insert('edge', ['b', 'c']);
50
+ expect(sorted(changes.added.get('path') ?? [])).toEqual([
51
+ ['a', 'c'],
52
+ ['b', 'c'],
53
+ ]);
54
+ expect(sorted(changes.removed.get('unreachable') ?? [])).toEqual([
55
+ ['a', 'c'],
56
+ ['b', 'c'],
57
+ ]);
58
+ });
59
+
60
+ test('evaluates aggregates', ({ expect }) => {
61
+ const engine = Engine.make({
62
+ program: Parser.parse(`
63
+ order(alice, 10). order(alice, 5). order(bob, 7). customer(alice). customer(bob). customer(carol).
64
+ orders(C, N) :- customer(C), N = count : { order(C, _) }.
65
+ spend(C, S) :- customer(C), S = sum A : { order(C, A) }.
66
+ largest(C, M) :- customer(C), M = max A : { order(C, A) }.
67
+ smallest(M) :- M = min A : order(_, A).
68
+ busy(C) :- orders(C, N), N >= 2.
69
+ `),
70
+ });
71
+ expect(sorted(engine.query('orders'))).toEqual([
72
+ ['alice', 2],
73
+ ['bob', 1],
74
+ ['carol', 0],
75
+ ]);
76
+ expect(sorted(engine.query('spend'))).toEqual([
77
+ ['alice', 15],
78
+ ['bob', 7],
79
+ ['carol', 0],
80
+ ]);
81
+ expect(sorted(engine.query('largest'))).toEqual([
82
+ ['alice', 10],
83
+ ['bob', 7],
84
+ ]);
85
+ expect(engine.query('smallest')).toEqual([[5]]);
86
+ expect(engine.query('busy')).toEqual([['alice']]);
87
+
88
+ const changes = engine.insert('order', ['bob', 1]);
89
+ expect(changes.added.get('busy')).toEqual([['bob']]);
90
+ expect(changes.removed.get('smallest')).toEqual([[5]]);
91
+ expect(engine.query('smallest')).toEqual([[1]]);
92
+ });
93
+
94
+ test('calls built-ins, including ones that bind arguments', ({ expect }) => {
95
+ const index = new Map([
96
+ ['f1', 'the release shipped'],
97
+ ['f2', 'lunch at noon'],
98
+ ]);
99
+ const about = Builtin.make({
100
+ name: 'about',
101
+ arity: 2,
102
+ modes: ['fb'],
103
+ evaluate: ([fact, text]) =>
104
+ [...index]
105
+ .filter(([id, body]) => (fact === undefined || id === fact) && body.includes(String(text)))
106
+ .map(([id]) => [id, String(text)]),
107
+ });
108
+ const length = Builtin.make({
109
+ name: 'length',
110
+ arity: 2,
111
+ modes: ['bf'],
112
+ evaluate: ([value]) => (value === undefined ? [] : [[value, String(value).length]]),
113
+ });
114
+ const engine = Engine.make({
115
+ builtins: Builtin.registry(about, length),
116
+ relations: { speaker: 2 },
117
+ program: Parser.parse(`
118
+ topic(F) :- about(F, "release").
119
+ long(F, N) :- speaker(F, _), length(F, N), N > 1.
120
+ quiet(F) :- speaker(F, _), not about(F, "release").
121
+ `),
122
+ });
123
+ expect(engine.query('topic')).toEqual([['f1']]);
124
+ engine.insert('speaker', ['f1', 'dima'], ['f2', 'alice']);
125
+ expect(sorted(engine.query('long'))).toEqual([
126
+ ['f1', 2],
127
+ ['f2', 2],
128
+ ]);
129
+ expect(engine.query('quiet')).toEqual([['f2']]);
130
+
131
+ index.set('f3', 'release notes');
132
+ expect(engine.update({}).added.get('topic')).toEqual([['f3']]);
133
+ });
134
+
135
+ test('re-evaluates volatile built-ins on refresh', ({ expect }) => {
136
+ let now = 0;
137
+ const after = Builtin.predicate('after', 1, ([time]) => now >= Number(time), { volatile: true });
138
+ const engine = Engine.make({
139
+ builtins: Builtin.registry(after),
140
+ program: Parser.parse(`
141
+ due(T) :- deadline(T), after(T).
142
+ overdue :- due(_), not done.
143
+ `),
144
+ });
145
+ engine.insert('deadline', [10]);
146
+ expect(engine.query('due')).toEqual([]);
147
+
148
+ now = 20;
149
+ expect(engine.update({}).added.size).toBe(0);
150
+ const changes = engine.update({ refresh: true });
151
+ expect(changes.added.get('due')).toEqual([[10]]);
152
+ expect(changes.added.get('overdue')).toEqual([[]]);
153
+
154
+ expect(engine.insert('done', []).removed.get('overdue')).toEqual([[]]);
155
+ });
156
+
157
+ test('incremental insertion returns only new tuples', ({ expect }) => {
158
+ const engine = Engine.make({ program: Parser.parse(TRANSITIVE_CLOSURE) });
159
+ const first = engine.insert('edge', ['a', 'b'], ['b', 'c']);
160
+ expect(sorted(first.added.get('path') ?? [])).toEqual([
161
+ ['a', 'b'],
162
+ ['a', 'c'],
163
+ ['b', 'c'],
164
+ ]);
165
+ const second = engine.insert('edge', ['c', 'd'], ['a', 'b']);
166
+ expect(second.added.get('edge')).toEqual([['c', 'd']]);
167
+ expect(sorted(second.added.get('path') ?? [])).toEqual([
168
+ ['a', 'd'],
169
+ ['b', 'd'],
170
+ ['c', 'd'],
171
+ ]);
172
+ expect(engine.insert('edge', ['a', 'b']).added.size).toBe(0);
173
+ });
174
+
175
+ test('retraction removes derived tuples', ({ expect }) => {
176
+ const engine = Engine.make({ program: Parser.parse(TRANSITIVE_CLOSURE) });
177
+ engine.insert('edge', ['a', 'b'], ['b', 'c']);
178
+ const changes = engine.update({ retract: [{ relation: 'edge', tuple: ['b', 'c'] }] });
179
+ expect(sorted(changes.removed.get('path') ?? [])).toEqual([
180
+ ['a', 'c'],
181
+ ['b', 'c'],
182
+ ]);
183
+ expect(engine.query('path')).toEqual([['a', 'b']]);
184
+ });
185
+
186
+ test('records provenance', ({ expect }) => {
187
+ const engine = Engine.make({ program: Parser.parse(TRANSITIVE_CLOSURE) });
188
+ engine.insert('edge', ['a', 'b'], ['b', 'c'], ['x', 'y']);
189
+ expect(engine.derivation('path', ['a', 'c'])).toEqual({
190
+ rule: 1,
191
+ premises: [
192
+ { relation: 'path', tuple: ['a', 'b'] },
193
+ { relation: 'edge', tuple: ['b', 'c'] },
194
+ ],
195
+ });
196
+ expect(engine.provenance('path', ['a', 'c'])).toEqual([
197
+ { relation: 'edge', tuple: ['a', 'b'] },
198
+ { relation: 'edge', tuple: ['b', 'c'] },
199
+ ]);
200
+ expect(engine.provenance('edge', ['x', 'y'])).toEqual([{ relation: 'edge', tuple: ['x', 'y'] }]);
201
+ expect(engine.provenance('path', ['c', 'a'])).toEqual([]);
202
+ });
203
+
204
+ test('rejects invalid programs and inconsistent inserts', ({ expect }) => {
205
+ expect(() => Engine.make({ program: Parser.parse('p :- not q. q :- not p.') })).toThrow(Checker.CheckError);
206
+ const engine = Engine.make({ program: Parser.parse(TRANSITIVE_CLOSURE) });
207
+ expect(() => engine.insert('edge', ['a'])).toThrow(/Arity mismatch/);
208
+ });
209
+
210
+ test('distinguishes numbers from numeric strings', ({ expect }) => {
211
+ const engine = Engine.make({ program: Parser.parse('p(1). p("1"). one(X) :- p(X), X = 1.') });
212
+ expect(engine.query('p')).toHaveLength(2);
213
+ expect(engine.query('one')).toEqual([[1]]);
214
+ });
215
+
216
+ test('closes 5,000 edges incrementally within budget', ({ expect }) => {
217
+ const chains = 50;
218
+ const length = 100;
219
+ const engine = Engine.make({ program: Parser.parse(TRANSITIVE_CLOSURE) });
220
+ const start = Date.now();
221
+ for (let chain = 0; chain < chains; chain++) {
222
+ engine.update({
223
+ insert: Array.from({ length }, (_, node) => ({
224
+ relation: 'edge',
225
+ tuple: [`c${chain}n${node}`, `c${chain}n${node + 1}`],
226
+ })),
227
+ });
228
+ }
229
+ const elapsed = Date.now() - start;
230
+ expect(engine.query('path')).toHaveLength(chains * ((length * (length + 1)) / 2));
231
+ expect(elapsed).toBeLessThan(20_000);
232
+
233
+ const changes = engine.insert('edge', ['c0n100', 'c1n0']);
234
+ expect(changes.added.get('path')).toHaveLength(101 * 101);
235
+ });
236
+ });
237
+
238
+ const sorted = (tuples: ReadonlyArray<Ast.Tuple>): Ast.Tuple[] =>
239
+ [...tuples].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));