@pepys/testing 0.3.1 → 0.4.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.
- package/README.md +15 -10
- package/package.json +6 -5
- package/src/fact-log/apply-fact-log.ts +12 -16
- package/src/fact-log/collect-stream-ranges.ts +20 -0
- package/src/test-store/collect-dirty-scopes.ts +18 -0
- package/src/test-store/collect-new-facts.ts +21 -0
- package/src/test-store/run-consumers.ts +5 -29
package/README.md
CHANGED
|
@@ -25,25 +25,30 @@ bun add -d @pepys/testing
|
|
|
25
25
|
|
|
26
26
|
Set the compiler options from the [kit's README](../kit/README.md#configuration).
|
|
27
27
|
|
|
28
|
-
Then test a query on rows
|
|
28
|
+
Then apply facts, and test a query on the rows that the plug-in's consumers write from them. A query
|
|
29
|
+
test that seeds through the consumers fails when a consumer changes the shape of its rows:
|
|
29
30
|
|
|
30
31
|
```ts
|
|
31
32
|
import { expect, test } from 'bun:test';
|
|
32
|
-
import type { EntityID } from '@pepys/kit';
|
|
33
33
|
import { createTestStore } from '@pepys/testing';
|
|
34
|
-
import {
|
|
35
|
-
import { shelfBookConsumer, shelfPlugin, shelfTitleQuery } from './shelf-plugin.ts';
|
|
34
|
+
import { shelfPlugin, shelfSource, shelfTitleQuery } from './shelf-plugin.ts';
|
|
36
35
|
|
|
37
|
-
test('it answers a query from
|
|
36
|
+
test('it answers a query from the row that a shelved fact gives', async () => {
|
|
38
37
|
await using store = await createTestStore({ plugins: [shelfPlugin] });
|
|
39
38
|
|
|
40
|
-
await store.
|
|
41
|
-
{
|
|
39
|
+
await store.apply(shelfSource, [
|
|
40
|
+
{
|
|
41
|
+
type: 'shelf.book.shelved',
|
|
42
|
+
event: 'evt-1',
|
|
43
|
+
entity: 'shelf:book:atlas',
|
|
44
|
+
at: '2026-11-01T08:00:00.000Z',
|
|
45
|
+
payload: { title: 'The Coral Atlas' },
|
|
46
|
+
},
|
|
42
47
|
]);
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
49
|
+
const answer = await store.query(shelfTitleQuery, { id: 'shelf:book:atlas' });
|
|
50
|
+
|
|
51
|
+
expect(answer).toStrictEqual({ title: 'The Coral Atlas' });
|
|
47
52
|
});
|
|
48
53
|
```
|
|
49
54
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Test a pepys plug-in on Bun: an in-memory store that runs its consumers and queries",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src",
|
|
13
13
|
"!src/**/*.test.ts",
|
|
14
|
-
"!src/**/test-utils"
|
|
14
|
+
"!src/**/test-utils",
|
|
15
|
+
"!src/ledger"
|
|
15
16
|
],
|
|
16
17
|
"type": "module",
|
|
17
18
|
"exports": "./src/index.ts",
|
|
@@ -19,9 +20,9 @@
|
|
|
19
20
|
"access": "public"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@pepys/ingest": "0.
|
|
23
|
-
"@pepys/kit": "0.
|
|
24
|
-
"@pepys/kysely-turso": "0.
|
|
23
|
+
"@pepys/ingest": "0.4.0",
|
|
24
|
+
"@pepys/kit": "0.4.0",
|
|
25
|
+
"@pepys/kysely-turso": "0.4.0",
|
|
25
26
|
"@tursodatabase/database": "0.8.3",
|
|
26
27
|
"kysely": "0.29.8",
|
|
27
28
|
"tiny-invariant": "1.3.3",
|
|
@@ -4,6 +4,7 @@ import type { CheckedDeployment, FactEnvelope, StreamName } from '@pepys/kit';
|
|
|
4
4
|
import invariant from 'tiny-invariant';
|
|
5
5
|
import { storeHooks } from '../test-store/store-hooks.ts';
|
|
6
6
|
import type { TestStore } from '../test-store/types.ts';
|
|
7
|
+
import { collectStreamRanges } from './collect-stream-ranges.ts';
|
|
7
8
|
|
|
8
9
|
// The host's part for a test: the records of every stream a consumer reads go to the store as one
|
|
9
10
|
// batch, whole streams in the order the consumers name them. A refusal throws an ApplyError and
|
|
@@ -20,39 +21,34 @@ export async function applyFactLog(
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const streams = new Set(deployment.consumers.flatMap((consumer) => consumer.input.streams));
|
|
23
|
-
|
|
24
|
-
const records: { stream: StreamName; envelope: FactEnvelope<unknown> }[] = [];
|
|
25
|
-
|
|
26
24
|
const tails = new Map<StreamName, number>();
|
|
27
25
|
|
|
28
26
|
for (const declared of streams) {
|
|
29
|
-
const
|
|
30
|
-
const from = hooks.nextOffsets.get(stream) ?? 0;
|
|
31
|
-
|
|
32
|
-
const tail = await log.readTail(stream);
|
|
27
|
+
const tail = await log.readTail(toStreamName(declared));
|
|
33
28
|
|
|
34
29
|
if (!tail.ok) {
|
|
35
30
|
throw new Error(`the fact log has no stream ${declared}, which a consumer reads`);
|
|
36
31
|
}
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
tails.set(toStreamName(declared), tail.value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ranges = collectStreamRanges(tails, hooks.nextOffsets);
|
|
37
|
+
const records: { stream: StreamName; envelope: FactEnvelope<unknown> }[] = [];
|
|
41
38
|
|
|
42
|
-
|
|
39
|
+
for (const range of ranges) {
|
|
40
|
+
const read = await log.read(range.stream, toStreamOffset(range.from), range.tail - range.from);
|
|
43
41
|
|
|
44
42
|
invariant(read.ok, 'a stream with a tail can be read');
|
|
45
43
|
|
|
46
44
|
for (const stored of read.value) {
|
|
47
|
-
records.push({ stream, envelope: stored.fact });
|
|
45
|
+
records.push({ stream: range.stream, envelope: stored.fact });
|
|
48
46
|
}
|
|
49
|
-
|
|
50
|
-
tails.set(stream, tail.value);
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
await hooks.applyEnvelopes(records);
|
|
54
50
|
|
|
55
|
-
for (const
|
|
56
|
-
hooks.nextOffsets.set(stream, tail);
|
|
51
|
+
for (const range of ranges) {
|
|
52
|
+
hooks.nextOffsets.set(range.stream, range.tail);
|
|
57
53
|
}
|
|
58
54
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StreamName } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
export interface StreamRange {
|
|
4
|
+
stream: StreamName;
|
|
5
|
+
from: number;
|
|
6
|
+
tail: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// The records each stream holds past the offset the store applied up to, in the order of `tails`.
|
|
10
|
+
// A stream whose tail is that offset has nothing new, and no range.
|
|
11
|
+
export function collectStreamRanges(
|
|
12
|
+
tails: ReadonlyMap<StreamName, number>,
|
|
13
|
+
nextOffsets: ReadonlyMap<StreamName, number>,
|
|
14
|
+
): StreamRange[] {
|
|
15
|
+
return [...tails].flatMap(([stream, tail]) => {
|
|
16
|
+
const from = nextOffsets.get(stream) ?? 0;
|
|
17
|
+
|
|
18
|
+
return tail === from ? [] : [{ stream, from, tail }];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseEntityID } from '@pepys/kit';
|
|
2
|
+
import type { EntityID } from '@pepys/kit';
|
|
3
|
+
import type { LoggedFact } from '../fact-index/types.ts';
|
|
4
|
+
import { compareCodePoints } from '../utils/compare-code-points.ts';
|
|
5
|
+
|
|
6
|
+
// The entities of the batch's facts that have the scope kind, each once, in sorted order. A copy of
|
|
7
|
+
// a fact marks its scope dirty too, and the replace then finds no change.
|
|
8
|
+
export function collectDirtyScopes(facts: readonly LoggedFact[], scopeKind: string): EntityID[] {
|
|
9
|
+
const scopes = facts
|
|
10
|
+
.map((record) => record.envelope.entity)
|
|
11
|
+
.filter((entity) => {
|
|
12
|
+
const parsed = parseEntityID(entity);
|
|
13
|
+
|
|
14
|
+
return parsed.ok && `${parsed.value.source}:${parsed.value.kind}` === scopeKind;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return [...new Set(scopes)].toSorted(compareCodePoints);
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FactID } from '@pepys/kit';
|
|
2
|
+
import type { LoggedFact } from '../fact-index/types.ts';
|
|
3
|
+
|
|
4
|
+
// The facts a fact-mode consumer has yet to apply, in batch order: a fact ID it applied before, or
|
|
5
|
+
// one an earlier fact of the batch holds, is a copy and runs no second time.
|
|
6
|
+
export function collectNewFacts(
|
|
7
|
+
facts: readonly LoggedFact[],
|
|
8
|
+
applied: ReadonlySet<FactID>,
|
|
9
|
+
): LoggedFact[] {
|
|
10
|
+
const seen = new Set(applied);
|
|
11
|
+
|
|
12
|
+
return facts.filter((record) => {
|
|
13
|
+
if (seen.has(record.envelope.id)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
seen.add(record.envelope.id);
|
|
18
|
+
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { parseEntityID } from '@pepys/kit';
|
|
2
1
|
import type { ConsumerDeclaration, FactID } from '@pepys/kit';
|
|
3
2
|
import invariant from 'tiny-invariant';
|
|
4
3
|
import { buildReadFunctions } from '../consumer-entries/build-read-functions.ts';
|
|
@@ -11,7 +10,8 @@ import { parseOperations } from '../operations/parse-operations.ts';
|
|
|
11
10
|
import { replaceScope } from '../scope-replace/replace-scope.ts';
|
|
12
11
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
13
12
|
import { ApplyError } from '../utils/apply-error.ts';
|
|
14
|
-
import {
|
|
13
|
+
import { collectDirtyScopes } from './collect-dirty-scopes.ts';
|
|
14
|
+
import { collectNewFacts } from './collect-new-facts.ts';
|
|
15
15
|
|
|
16
16
|
// The fact IDs each fact-mode consumer has applied, by consumer name.
|
|
17
17
|
export type AppliedFactIDs = ReadonlyMap<string, ReadonlySet<FactID>>;
|
|
@@ -59,15 +59,7 @@ async function runFactConsumer(
|
|
|
59
59
|
facts: readonly LoggedFact[],
|
|
60
60
|
applied: ReadonlySet<FactID>,
|
|
61
61
|
): Promise<ReadonlySet<FactID>> {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
for (const record of facts) {
|
|
65
|
-
if (seen.has(record.envelope.id)) {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
seen.add(record.envelope.id);
|
|
70
|
-
|
|
62
|
+
for (const record of collectNewFacts(facts, applied)) {
|
|
71
63
|
const host = await readRowFunctions(db, consumer);
|
|
72
64
|
const output = await runEntry(consumer, 'applyFact', JSON.stringify(record.envelope), host);
|
|
73
65
|
|
|
@@ -80,11 +72,9 @@ async function runFactConsumer(
|
|
|
80
72
|
await applyFactOperations(db, consumer, operations.value);
|
|
81
73
|
}
|
|
82
74
|
|
|
83
|
-
return
|
|
75
|
+
return new Set([...applied, ...facts.map((record) => record.envelope.id)]);
|
|
84
76
|
}
|
|
85
77
|
|
|
86
|
-
// The dirty scopes are the entities of the batch's facts that have the scope kind, each once, in
|
|
87
|
-
// sorted order. A copy of a fact marks its scope dirty too, and the replace then finds no change.
|
|
88
78
|
async function runScopeConsumer(
|
|
89
79
|
db: StoreDatabase,
|
|
90
80
|
consumer: ConsumerDeclaration,
|
|
@@ -93,21 +83,7 @@ async function runScopeConsumer(
|
|
|
93
83
|
): Promise<void> {
|
|
94
84
|
invariant(consumer.input.mode === 'scope', 'only a scope consumer has a scope kind');
|
|
95
85
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
const scopes = [
|
|
99
|
-
...new Set(
|
|
100
|
-
facts
|
|
101
|
-
.map((record) => record.envelope.entity)
|
|
102
|
-
.filter((entity) => {
|
|
103
|
-
const parsed = parseEntityID(entity);
|
|
104
|
-
|
|
105
|
-
return parsed.ok && `${parsed.value.source}:${parsed.value.kind}` === scopeKind;
|
|
106
|
-
}),
|
|
107
|
-
),
|
|
108
|
-
].toSorted(compareCodePoints);
|
|
109
|
-
|
|
110
|
-
for (const scope of scopes) {
|
|
86
|
+
for (const scope of collectDirtyScopes(facts, consumer.input.scopeKind)) {
|
|
111
87
|
const scopeFacts = JSON.stringify(collectScopeFacts(index, scope, consumer.input.streams));
|
|
112
88
|
const host = buildReadFunctions('readScopeFacts', new Map([[scope, scopeFacts]]), 'trap');
|
|
113
89
|
|