@pepys/testing 0.3.1 → 0.4.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 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 written for the test:
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 { toTagged } from '@zgeoff/std';
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 rows written for the test', async () => {
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.writeRows(shelfBookConsumer, 'shelf_book', [
41
- { id: toTagged<EntityID>('shelf:book:atlas'), title: 'The Coral Atlas' },
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
- expect(await store.query(shelfTitleQuery, { id: 'shelf:book:atlas' })).toStrictEqual({
45
- title: 'The Coral Atlas',
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.1",
3
+ "version": "0.4.1",
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.3.1",
23
- "@pepys/kit": "0.3.1",
24
- "@pepys/kysely-turso": "0.3.1",
23
+ "@pepys/ingest": "0.4.1",
24
+ "@pepys/kit": "0.4.1",
25
+ "@pepys/kysely-turso": "0.4.1",
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 stream = toStreamName(declared);
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
- if (tail.value === from) {
39
- continue;
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
- const read = await log.read(stream, toStreamOffset(from), tail.value - from);
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 [stream, tail] of tails) {
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 { compareCodePoints } from '../utils/compare-code-points.ts';
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 seen = new Set(applied);
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 seen;
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 scopeKind = consumer.input.scopeKind;
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