@pepys/testing 0.1.0 → 0.2.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 CHANGED
@@ -28,6 +28,9 @@ Set the compiler options from the [kit's README](../kit/README.md#configuration)
28
28
  ## Features
29
29
 
30
30
  - `createTestStore`: apply facts or write rows, then run a query, read rows and read links.
31
+ - `applyFactLog`: apply the facts that `createIngest` of `@pepys/ingest` stored in a memory fact log
32
+ to a test store, in place of the host. A second call applies only the records the log stored after
33
+ the first.
31
34
  - `applyInBothOrders`: apply facts in two orders and compare the stores.
32
35
  - The memory adapter of the store reader, for a test of a core query.
33
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/testing",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {
@@ -19,8 +19,9 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@pepys/kit": "0.1.0",
23
- "@pepys/kysely-turso": "0.1.0",
22
+ "@pepys/ingest": "0.2.0",
23
+ "@pepys/kit": "0.2.0",
24
+ "@pepys/kysely-turso": "0.2.0",
24
25
  "@tursodatabase/database": "0.8.3",
25
26
  "kysely": "0.29.8",
26
27
  "tiny-invariant": "1.3.3",
@@ -1,4 +1,5 @@
1
1
  import type { FactEnvelope } from '@pepys/kit';
2
+ import { compareCodePoints } from '../utils/compare-code-points.ts';
2
3
  import type { FactIndex } from './types.ts';
3
4
 
4
5
  // The read of one scope, as the host serves it: every fact of the entity on `streams`, one per fact
@@ -13,8 +14,8 @@ export function collectScopeFacts(
13
14
  .map((record) => ({ record, sortKey: toSortKey(record.envelope.at) }))
14
15
  .toSorted(
15
16
  (left, right) =>
16
- compareText(left.sortKey, right.sortKey) ||
17
- compareText(left.record.stream, right.record.stream) ||
17
+ compareCodePoints(left.sortKey, right.sortKey) ||
18
+ compareCodePoints(left.record.stream, right.record.stream) ||
18
19
  left.record.seqNum - right.record.seqNum,
19
20
  )
20
21
  .map((entry) => entry.record.envelope);
@@ -32,11 +33,3 @@ function toSortKey(at: string): string {
32
33
 
33
34
  return `${parts.groups['seconds']}.${fraction}${parts.groups['zone']}`;
34
35
  }
35
-
36
- function compareText(left: string, right: string): number {
37
- if (left === right) {
38
- return 0;
39
- }
40
-
41
- return left < right ? -1 : 1;
42
- }
@@ -0,0 +1,58 @@
1
+ import type { MemoryFactLog } from '@pepys/ingest';
2
+ import { toStreamName, toStreamOffset } from '@pepys/kit';
3
+ import type { CheckedDeployment, FactEnvelope, StreamName } from '@pepys/kit';
4
+ import invariant from 'tiny-invariant';
5
+ import { storeHooks } from '../test-store/store-hooks.ts';
6
+ import type { TestStore } from '../test-store/types.ts';
7
+
8
+ // The host's part for a test: the records of every stream a consumer reads go to the store as one
9
+ // batch, whole streams in the order the consumers name them. A refusal throws an ApplyError and
10
+ // moves no offset, so the next call reads the same records again.
11
+ export async function applyFactLog(
12
+ store: TestStore,
13
+ log: MemoryFactLog,
14
+ deployment: CheckedDeployment,
15
+ ): Promise<void> {
16
+ const hooks = storeHooks.get(store);
17
+
18
+ if (hooks === undefined) {
19
+ throw new Error('the store is not the object that createTestStore returned');
20
+ }
21
+
22
+ const streams = new Set(deployment.consumers.flatMap((consumer) => consumer.input.streams));
23
+
24
+ const records: { stream: StreamName; envelope: FactEnvelope<unknown> }[] = [];
25
+
26
+ const tails = new Map<StreamName, number>();
27
+
28
+ 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);
33
+
34
+ if (!tail.ok) {
35
+ throw new Error(`the fact log has no stream ${declared}, which a consumer reads`);
36
+ }
37
+
38
+ if (tail.value === from) {
39
+ continue;
40
+ }
41
+
42
+ const read = await log.read(stream, toStreamOffset(from), tail.value - from);
43
+
44
+ invariant(read.ok, 'a stream with a tail can be read');
45
+
46
+ for (const stored of read.value) {
47
+ records.push({ stream, envelope: stored.fact });
48
+ }
49
+
50
+ tails.set(stream, tail.value);
51
+ }
52
+
53
+ await hooks.applyEnvelopes(records);
54
+
55
+ for (const [stream, tail] of tails) {
56
+ hooks.nextOffsets.set(stream, tail);
57
+ }
58
+ }
@@ -6,6 +6,7 @@ import { removeLink } from '../links/remove-link.ts';
6
6
  import { writeLink } from '../links/write-link.ts';
7
7
  import type { Operation } from '../operations/types.ts';
8
8
  import { buildRowKey } from '../rows/build-row-key.ts';
9
+ import { findKeyProblem } from '../rows/find-key-problem.ts';
9
10
  import { findRowProblem } from '../rows/find-row-problem.ts';
10
11
  import { removeRow } from '../rows/remove-row.ts';
11
12
  import { writeRow } from '../rows/write-row.ts';
@@ -28,12 +29,13 @@ export async function applyFactOperations(
28
29
  }
29
30
  }
30
31
 
31
- if (operation.verb === 'upsert') {
32
- const reason = findRowProblem(
33
- getTable(consumer, operation.table),
34
- operation.row,
35
- operation.inexact,
36
- );
32
+ if (operation.verb === 'upsert' || operation.verb === 'delete') {
33
+ const table = getTable(consumer, operation.table);
34
+
35
+ const reason =
36
+ operation.verb === 'upsert'
37
+ ? findRowProblem(table, operation.row, operation.inexact)
38
+ : findKeyProblem(table, operation.key, operation.inexact);
37
39
 
38
40
  if (reason !== undefined) {
39
41
  throw new ApplyError({
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { applyFactLog } from './fact-log/apply-fact-log.ts';
1
2
  export { applyInBothOrders } from './orders/apply-in-both-orders.ts';
2
3
  export type { OrderReport, StoreSnapshot } from './orders/apply-in-both-orders.ts';
3
4
  export type { MemoryStoreReader } from './memory-store-reader/create-memory-store-reader.ts';
@@ -1,7 +1,6 @@
1
1
  import type { LinkRow } from '@pepys/kit';
2
2
  import type { StoreDatabase } from '../store-database/types.ts';
3
3
 
4
- // Every link, or every link of one consumer, in the order of its key.
5
4
  export function readLinkRows(db: StoreDatabase, origin?: string): Promise<LinkRow[]> {
6
5
  const links = db.selectFrom('link').selectAll();
7
6
 
@@ -3,7 +3,6 @@ import { sql } from 'kysely';
3
3
  import type { RawBuilder } from 'kysely';
4
4
  import { toSQLValue } from './to-sql-value.ts';
5
5
 
6
- // The `where` clause of one row, from the JSON text of its key's values.
7
6
  export function buildKeyFilter(table: TableDeclaration, key: string): RawBuilder<unknown> {
8
7
  const parsed: unknown = JSON.parse(key);
9
8
  const values: readonly unknown[] = Array.isArray(parsed) ? parsed : [];
@@ -1,8 +1,8 @@
1
1
  import type { TableDeclaration } from '@pepys/kit';
2
2
  import type { Row } from '../utils/types.ts';
3
3
 
4
- // The key of a row as the host writes it in `scope_row` and the change log: the JSON text of the
5
- // primary key's values, in key order, for a key of one column and of several.
4
+ // The key of a row as the host writes it in `scope_row` and the change log, for a key of one column
5
+ // and of several. The host sorts an object's keys, so a key that holds an object can differ.
6
6
  export function buildRowKey(table: TableDeclaration, row: Row): string {
7
7
  return JSON.stringify(table.primaryKey.map((column) => row[column] ?? null));
8
8
  }
@@ -0,0 +1,36 @@
1
+ import type { TableDeclaration } from '@pepys/kit';
2
+ import invariant from 'tiny-invariant';
3
+ import type { Row } from '../utils/types.ts';
4
+ import { isColumnValue } from './is-column-value.ts';
5
+
6
+ // The host's check of a delete's key, in the host's order: no column outside the primary key, each
7
+ // key value present fit for its column, then each key column present.
8
+ export function findKeyProblem(
9
+ table: TableDeclaration,
10
+ key: Row,
11
+ inexact: ReadonlySet<string>,
12
+ ): string | undefined {
13
+ const outside = Object.keys(key).find((name) => !table.primaryKey.includes(name));
14
+
15
+ if (outside !== undefined) {
16
+ return `${outside} is not a key column`;
17
+ }
18
+
19
+ const present = table.primaryKey.filter((name) => Object.hasOwn(key, name));
20
+
21
+ const unfit = present.find((name) => {
22
+ const column = table.columns[name];
23
+
24
+ invariant(column, 'the deployment check refuses a key column that the table lacks');
25
+
26
+ return !isColumnValue(column, key[name], inexact.has(name));
27
+ });
28
+
29
+ if (unfit !== undefined) {
30
+ return `the column ${unfit} holds ${JSON.stringify(key[unfit])}`;
31
+ }
32
+
33
+ const absent = table.primaryKey.find((name) => !Object.hasOwn(key, name));
34
+
35
+ return absent === undefined ? undefined : `the key column ${absent} is absent`;
36
+ }
@@ -1,10 +1,9 @@
1
1
  import type { TableDeclaration } from '@pepys/kit';
2
- import { match } from 'ts-pattern';
3
2
  import type { Row } from '../utils/types.ts';
3
+ import { isColumnValue } from './is-column-value.ts';
4
4
 
5
- // The host's check of a row: every column present, no other key, each value of its column's kind,
6
- // and null only where the column is nullable. `inexact` names the columns whose number the module
7
- // wrote with a fraction or an exponent, such as `1.0`: an integer column refuses those.
5
+ // The host's check of a row: every column present, no other key, and each value fit for its
6
+ // column. `inexact` names the columns whose number the module wrote with a fraction or an exponent.
8
7
  export function findRowProblem(
9
8
  table: TableDeclaration,
10
9
  row: Row,
@@ -21,21 +20,8 @@ export function findRowProblem(
21
20
  return `the column ${name} is absent`;
22
21
  }
23
22
 
24
- const value = row[name];
25
-
26
- const fits =
27
- value === null
28
- ? column.nullable === true
29
- : match(column.kind)
30
- .with('text', 'entityID', 'timestamp', () => typeof value === 'string')
31
- .with('boolean', () => typeof value === 'boolean')
32
- .with('json', () => true)
33
- .with('real', () => typeof value === 'number')
34
- .with('integer', () => Number.isSafeInteger(value) && !inexact.has(name))
35
- .exhaustive();
36
-
37
- if (!fits) {
38
- return `the column ${name} holds ${JSON.stringify(value)}`;
23
+ if (!isColumnValue(column, row[name], inexact.has(name))) {
24
+ return `the column ${name} holds ${JSON.stringify(row[name])}`;
39
25
  }
40
26
  }
41
27
 
@@ -0,0 +1,23 @@
1
+ import type { ColumnDeclaration } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+
4
+ // The host's check of one value against its column: of the column's kind, and null only where the
5
+ // column is nullable. `isInexact` marks a number the module wrote with a fraction or an exponent,
6
+ // such as `1.0`, which an integer column refuses.
7
+ export function isColumnValue(
8
+ column: ColumnDeclaration,
9
+ value: unknown,
10
+ isInexact: boolean,
11
+ ): boolean {
12
+ if (value === null) {
13
+ return column.nullable === true;
14
+ }
15
+
16
+ return match(column.kind)
17
+ .with('text', 'entityID', 'timestamp', () => typeof value === 'string')
18
+ .with('boolean', () => typeof value === 'boolean')
19
+ .with('json', () => true)
20
+ .with('real', () => typeof value === 'number')
21
+ .with('integer', () => Number.isSafeInteger(value) && !isInexact)
22
+ .exhaustive();
23
+ }
@@ -1,7 +1,6 @@
1
1
  import type { ColumnDeclaration } from '@pepys/kit';
2
2
  import { match } from 'ts-pattern';
3
3
 
4
- // A stored value as the module wrote it: 0 or 1 back to a boolean, a JSON column's text parsed.
5
4
  export function toRowValue(column: ColumnDeclaration, stored: unknown): unknown {
6
5
  if (stored === null || stored === undefined) {
7
6
  return null;
@@ -1,7 +1,6 @@
1
1
  import type { ColumnDeclaration } from '@pepys/kit';
2
2
  import { match } from 'ts-pattern';
3
3
 
4
- // A value of a checked row as SQLite stores it: a boolean as 0 or 1, a JSON column as its text.
5
4
  export function toSQLValue(column: ColumnDeclaration, value: unknown): string | number | null {
6
5
  if (value === null || value === undefined) {
7
6
  return null;
@@ -1,4 +1,3 @@
1
- // One text for a table and a row key, for a map that holds the rows of several tables.
2
1
  export function buildRowID(table: string, key: string): string {
3
2
  return JSON.stringify([table, key]);
4
3
  }
@@ -1,4 +1,5 @@
1
1
  import type { Result } from '@pepys/kit';
2
+ import { compareCodePoints } from '../utils/compare-code-points.ts';
2
3
  import type { ApplyFailure, ChangeEntry, WireLink } from '../utils/types.ts';
3
4
  import { buildLinkKey } from './build-link-key.ts';
4
5
  import { buildRowID } from './build-row-id.ts';
@@ -74,13 +75,12 @@ export function pickScopeChanges(
74
75
  return { ok: true, value: { writtenRows, writtenLinks, deletedRows, deletedLinks, entries } };
75
76
  }
76
77
 
77
- // Plain `<` on the parts, as Rust's ordered maps and SQLite compare text: never a locale order.
78
78
  function compareKeys(left: readonly string[], right: readonly string[]): number {
79
79
  for (const [index, part] of left.entries()) {
80
80
  const other = right[index] ?? '';
81
81
 
82
82
  if (part !== other) {
83
- return part < other ? -1 : 1;
83
+ return compareCodePoints(part, other);
84
84
  }
85
85
  }
86
86
 
@@ -1740,6 +1740,87 @@
1740
1740
  "ledger_tag": []
1741
1741
  },
1742
1742
  "links": []
1743
+ },
1744
+ {
1745
+ "title": "the change entries come in code point order, so a key above U+FFFF sorts after a key at U+E000",
1746
+ "batches": [
1747
+ {
1748
+ "scopes": [
1749
+ {
1750
+ "scope": "ledger:account:a",
1751
+ "output": "[{\"upsert\":{\"ledger_entry\":{\"id\":\"ledger:entry:\\ud800\\udc00\",\"account_id\":\"ledger:account:a\",\"amount\":5,\"rate\":1.25,\"memo\":\"Rope\",\"details\":{\"till\":\"north\"},\"posted_at\":\"2026-11-01T09:00:00.000Z\"}}},{\"upsert\":{\"ledger_entry\":{\"id\":\"ledger:entry:\\ue000\",\"account_id\":\"ledger:account:a\",\"amount\":5,\"rate\":1.25,\"memo\":\"Rope\",\"details\":{\"till\":\"north\"},\"posted_at\":\"2026-11-01T09:00:00.000Z\"}}}]"
1752
+ }
1753
+ ],
1754
+ "changes": [
1755
+ {
1756
+ "type": "ledger_entry",
1757
+ "key": "[\"ledger:entry:\"]",
1758
+ "value": {
1759
+ "id": "ledger:entry:",
1760
+ "account_id": "ledger:account:a",
1761
+ "amount": 5,
1762
+ "rate": 1.25,
1763
+ "memo": "Rope",
1764
+ "details": {
1765
+ "till": "north"
1766
+ },
1767
+ "posted_at": "2026-11-01T09:00:00.000Z"
1768
+ },
1769
+ "headers": {
1770
+ "operation": "insert"
1771
+ }
1772
+ },
1773
+ {
1774
+ "type": "ledger_entry",
1775
+ "key": "[\"ledger:entry:𐀀\"]",
1776
+ "value": {
1777
+ "id": "ledger:entry:𐀀",
1778
+ "account_id": "ledger:account:a",
1779
+ "amount": 5,
1780
+ "rate": 1.25,
1781
+ "memo": "Rope",
1782
+ "details": {
1783
+ "till": "north"
1784
+ },
1785
+ "posted_at": "2026-11-01T09:00:00.000Z"
1786
+ },
1787
+ "headers": {
1788
+ "operation": "insert"
1789
+ }
1790
+ }
1791
+ ]
1792
+ }
1793
+ ],
1794
+ "rows": {
1795
+ "ledger_account": [],
1796
+ "ledger_entry": [
1797
+ {
1798
+ "id": "ledger:entry:",
1799
+ "account_id": "ledger:account:a",
1800
+ "amount": 5,
1801
+ "rate": 1.25,
1802
+ "memo": "Rope",
1803
+ "details": {
1804
+ "till": "north"
1805
+ },
1806
+ "posted_at": "2026-11-01T09:00:00.000Z"
1807
+ },
1808
+ {
1809
+ "id": "ledger:entry:𐀀",
1810
+ "account_id": "ledger:account:a",
1811
+ "amount": 5,
1812
+ "rate": 1.25,
1813
+ "memo": "Rope",
1814
+ "details": {
1815
+ "till": "north"
1816
+ },
1817
+ "posted_at": "2026-11-01T09:00:00.000Z"
1818
+ }
1819
+ ],
1820
+ "ledger_rate": [],
1821
+ "ledger_tag": []
1822
+ },
1823
+ "links": []
1743
1824
  }
1744
1825
  ]
1745
1826
  }
@@ -11,6 +11,8 @@ import { ApplyError } from '../utils/apply-error.ts';
11
11
  import { parseAppliedFacts } from './parse-applied-facts.ts';
12
12
  import { runConsumers } from './run-consumers.ts';
13
13
  import type { AppliedFactIDs } from './run-consumers.ts';
14
+ import { storeHooks } from './store-hooks.ts';
15
+ import type { StoreHooks } from './store-hooks.ts';
14
16
  import type { TestStore } from './types.ts';
15
17
 
16
18
  interface TestStoreOptions {
@@ -48,7 +50,37 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
48
50
  return table;
49
51
  };
50
52
 
51
- return {
53
+ const applyInTurn = async (records: Parameters<StoreHooks['applyEnvelopes']>[0]) => {
54
+ const next = buildFactIndex(index, records);
55
+
56
+ applied = await db
57
+ .transaction()
58
+ .execute((transaction) =>
59
+ runConsumers(transaction, deployment.consumers, { ...next, applied }),
60
+ );
61
+
62
+ index = next.index;
63
+ };
64
+
65
+ // Each apply builds on the index and applied IDs that the one before it left, so two applies
66
+ // that run at once take turns. A refused apply rejects for its caller only.
67
+ let lastApply: Promise<void> = Promise.resolve();
68
+
69
+ const applyEnvelopes: StoreHooks['applyEnvelopes'] = async (records) => {
70
+ const before = lastApply;
71
+ const turn = Promise.withResolvers<void>();
72
+
73
+ lastApply = turn.promise;
74
+
75
+ try {
76
+ await before;
77
+ await applyInTurn(records);
78
+ } finally {
79
+ turn.resolve();
80
+ }
81
+ };
82
+
83
+ const testStore: TestStore = {
52
84
  writeRows: async (_consumer, table, rows) => {
53
85
  for (const row of rows) {
54
86
  await writeRow(db, table, getTable(table), row);
@@ -61,15 +93,7 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
61
93
  throw new ApplyError(envelopes.failure);
62
94
  }
63
95
 
64
- const next = buildFactIndex(index, envelopes.value);
65
-
66
- applied = await db
67
- .transaction()
68
- .execute((transaction) =>
69
- runConsumers(transaction, deployment.consumers, { ...next, applied }),
70
- );
71
-
72
- index = next.index;
96
+ await applyEnvelopes(envelopes.value);
73
97
  },
74
98
  query: async (query, input) => {
75
99
  const parsed = query.input.parse(input);
@@ -91,4 +115,8 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
91
115
  reader,
92
116
  [Symbol.asyncDispose]: () => db.destroy(),
93
117
  };
118
+
119
+ storeHooks.set(testStore, { applyEnvelopes, nextOffsets: new Map() });
120
+
121
+ return testStore;
94
122
  }
@@ -11,6 +11,7 @@ import { parseOperations } from '../operations/parse-operations.ts';
11
11
  import { replaceScope } from '../scope-replace/replace-scope.ts';
12
12
  import type { StoreDatabase } from '../store-database/types.ts';
13
13
  import { ApplyError } from '../utils/apply-error.ts';
14
+ import { compareCodePoints } from '../utils/compare-code-points.ts';
14
15
 
15
16
  // The fact IDs each fact-mode consumer has applied, by consumer name.
16
17
  export type AppliedFactIDs = ReadonlyMap<string, ReadonlySet<string>>;
@@ -104,7 +105,7 @@ async function runScopeConsumer(
104
105
  return parsed.ok && `${parsed.value.source}:${parsed.value.kind}` === scopeKind;
105
106
  }),
106
107
  ),
107
- ].toSorted((left, right) => (left < right ? -1 : 1));
108
+ ].toSorted(compareCodePoints);
108
109
 
109
110
  for (const scope of scopes) {
110
111
  const scopeFacts = JSON.stringify(collectScopeFacts(index, scope, consumer.input.streams));
@@ -0,0 +1,16 @@
1
+ import type { FactEnvelope, StreamName } from '@pepys/kit';
2
+ import type { TestStore } from './types.ts';
3
+
4
+ export interface StoreHooks {
5
+ // Runs the consumers on the records as one batch in one transaction. A refusal throws an
6
+ // ApplyError, and the batch then writes nothing.
7
+ applyEnvelopes: (
8
+ records: readonly { stream: string; envelope: FactEnvelope<unknown> }[],
9
+ ) => Promise<void>;
10
+
11
+ // the offset of the next record to apply from a fact log, by stream
12
+ nextOffsets: Map<StreamName, number>;
13
+ }
14
+
15
+ // What a test store gives the package's own functions and keeps out of its public type.
16
+ export const storeHooks = new WeakMap<TestStore, StoreHooks>();
@@ -0,0 +1,19 @@
1
+ // The host's Rust strings and SQLite's binary collation order text by code point, but `<` on
2
+ // JavaScript strings compares UTF-16 code units, which puts text above U+FFFF before U+E000.
3
+ export function compareCodePoints(left: string, right: string): number {
4
+ const length = Math.min(left.length, right.length);
5
+ let index = 0;
6
+
7
+ while (index < length) {
8
+ const leftPoint = left.codePointAt(index) ?? 0;
9
+ const rightPoint = right.codePointAt(index) ?? 0;
10
+
11
+ if (leftPoint !== rightPoint) {
12
+ return leftPoint - rightPoint;
13
+ }
14
+
15
+ index += leftPoint > 0xff_ff ? 2 : 1;
16
+ }
17
+
18
+ return left.length - right.length;
19
+ }