@pepys/testing 0.0.0-stage → 0.1.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 (43) hide show
  1. package/README.md +35 -2
  2. package/package.json +31 -4
  3. package/src/consumer-entries/build-read-functions.ts +26 -0
  4. package/src/consumer-entries/run-entry.ts +46 -0
  5. package/src/fact-index/build-fact-index.ts +39 -0
  6. package/src/fact-index/collect-scope-facts.ts +42 -0
  7. package/src/fact-index/types.ts +15 -0
  8. package/src/fact-mode/apply-fact-operations.ts +71 -0
  9. package/src/fact-mode/read-row-functions.ts +34 -0
  10. package/src/index.ts +7 -0
  11. package/src/links/read-link-rows.ts +14 -0
  12. package/src/links/remove-link.ts +16 -0
  13. package/src/links/write-link.ts +24 -0
  14. package/src/memory-store-reader/create-memory-store-reader.ts +61 -0
  15. package/src/operations/parse-operations.ts +168 -0
  16. package/src/operations/types.ts +9 -0
  17. package/src/orders/apply-in-both-orders.ts +59 -0
  18. package/src/rows/build-key-filter.ts +19 -0
  19. package/src/rows/build-row-key.ts +8 -0
  20. package/src/rows/find-row-problem.ts +43 -0
  21. package/src/rows/read-table-rows.ts +35 -0
  22. package/src/rows/remove-row.ts +13 -0
  23. package/src/rows/to-row-value.ts +15 -0
  24. package/src/rows/to-sql-value.ts +18 -0
  25. package/src/rows/write-row.ts +21 -0
  26. package/src/scope-replace/build-link-key.ts +6 -0
  27. package/src/scope-replace/build-row-id.ts +4 -0
  28. package/src/scope-replace/collect-scope-result.ts +87 -0
  29. package/src/scope-replace/pick-scope-changes.ts +166 -0
  30. package/src/scope-replace/read-scope-state.ts +61 -0
  31. package/src/scope-replace/replace-scope.ts +42 -0
  32. package/src/scope-replace/scope-replace-cases.json +1745 -0
  33. package/src/scope-replace/types.ts +39 -0
  34. package/src/scope-replace/write-scope-changes.ts +50 -0
  35. package/src/store-database/create-store-database.ts +31 -0
  36. package/src/store-database/format-store-schema.ts +57 -0
  37. package/src/store-database/types.ts +12 -0
  38. package/src/test-store/create-test-store.ts +94 -0
  39. package/src/test-store/parse-applied-facts.ts +64 -0
  40. package/src/test-store/run-consumers.ts +117 -0
  41. package/src/test-store/types.ts +68 -0
  42. package/src/utils/apply-error.ts +13 -0
  43. package/src/utils/types.ts +47 -0
@@ -0,0 +1,39 @@
1
+ import type { ChangeEntry, Row, WireLink } from '../utils/types.ts';
2
+
3
+ export interface KeyedRow {
4
+ table: string;
5
+ key: string;
6
+ row: Row;
7
+ }
8
+
9
+ // What one scope returned, checked and keyed.
10
+ export interface ScopeResult {
11
+ rows: readonly KeyedRow[];
12
+ links: readonly WireLink[];
13
+ }
14
+
15
+ export interface StoredLink {
16
+ scope: string | null;
17
+ link: WireLink;
18
+ }
19
+
20
+ // What the consumer's store holds that the replace of one scope needs. Rows and owners are keyed by
21
+ // `buildRowID`, links by the JSON text of their key.
22
+ export interface ScopeState {
23
+ // the scope of each row that a scope owns
24
+ rowOwners: ReadonlyMap<string, string>;
25
+
26
+ // the stored row of each key that the result names or that the scope owns
27
+ storedRows: ReadonlyMap<string, Row>;
28
+
29
+ // every link of the consumer
30
+ storedLinks: ReadonlyMap<string, StoredLink>;
31
+ }
32
+
33
+ export interface ScopeChanges {
34
+ writtenRows: readonly KeyedRow[];
35
+ writtenLinks: readonly WireLink[];
36
+ deletedRows: readonly Omit<KeyedRow, 'row'>[];
37
+ deletedLinks: readonly WireLink[];
38
+ entries: readonly ChangeEntry[];
39
+ }
@@ -0,0 +1,50 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { removeLink } from '../links/remove-link.ts';
3
+ import { writeLink } from '../links/write-link.ts';
4
+ import { removeRow } from '../rows/remove-row.ts';
5
+ import { writeRow } from '../rows/write-row.ts';
6
+ import type { StoreDatabase } from '../store-database/types.ts';
7
+ import type { ScopeChanges } from './types.ts';
8
+
9
+ export async function writeScopeChanges(
10
+ db: StoreDatabase,
11
+ consumer: ConsumerDeclaration,
12
+ scope: string,
13
+ changes: ScopeChanges,
14
+ ): Promise<void> {
15
+ for (const row of changes.writtenRows) {
16
+ const table = consumer.tables[row.table];
17
+
18
+ if (table !== undefined) {
19
+ await writeRow(db, row.table, table, row.row);
20
+
21
+ await db
22
+ .replaceInto('scope_row')
23
+ .values({ consumer: consumer.name, table_name: row.table, key: row.key, scope })
24
+ .execute();
25
+ }
26
+ }
27
+
28
+ for (const link of changes.writtenLinks) {
29
+ await writeLink(db, consumer.name, scope, link);
30
+ }
31
+
32
+ for (const row of changes.deletedRows) {
33
+ const table = consumer.tables[row.table];
34
+
35
+ if (table !== undefined) {
36
+ await removeRow(db, row.table, table, row.key);
37
+ }
38
+
39
+ await db
40
+ .deleteFrom('scope_row')
41
+ .where('consumer', '=', consumer.name)
42
+ .where('table_name', '=', row.table)
43
+ .where('key', '=', row.key)
44
+ .execute();
45
+ }
46
+
47
+ for (const link of changes.deletedLinks) {
48
+ await removeLink(db, consumer.name, link);
49
+ }
50
+ }
@@ -0,0 +1,31 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { createTursoDialect } from '@pepys/kysely-turso';
3
+ import { connect } from '@tursodatabase/database';
4
+ import type { Database } from '@tursodatabase/database';
5
+ import { Kysely } from 'kysely';
6
+ import { formatStoreSchema } from './format-store-schema.ts';
7
+ import type { StoreDatabase, StoreTables } from './types.ts';
8
+
9
+ interface StoreConnection {
10
+ db: StoreDatabase;
11
+
12
+ // The same database under the table types of one query. Kysely cannot narrow its own table
13
+ // types to a generic set of consumers, so a query call gets its own instance.
14
+ database: Database;
15
+ }
16
+
17
+ // One in-memory database with every table of `consumers`, the link and scope tables, and the two
18
+ // shared views.
19
+ export async function createStoreDatabase(
20
+ consumers: readonly ConsumerDeclaration[],
21
+ ): Promise<StoreConnection> {
22
+ const database = await connect(':memory:');
23
+
24
+ await database.exec(formatStoreSchema(consumers));
25
+
26
+ const db = new Kysely<StoreTables>({
27
+ dialect: createTursoDialect({ database: () => Promise.resolve(database) }),
28
+ });
29
+
30
+ return { db, database };
31
+ }
@@ -0,0 +1,57 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { generateSqliteSchema } from '@pepys/kit/generators';
3
+
4
+ const hostSchema = `
5
+ CREATE TABLE "link" (
6
+ "type" TEXT NOT NULL,
7
+ "from_id" TEXT NOT NULL,
8
+ "to_id" TEXT NOT NULL,
9
+ "scope" TEXT,
10
+ "origin" TEXT NOT NULL,
11
+ "confidence" REAL NOT NULL,
12
+ "evidence" TEXT NOT NULL,
13
+ PRIMARY KEY ("origin", "type", "from_id", "to_id")
14
+ );
15
+
16
+ CREATE TABLE "scope_row" (
17
+ "consumer" TEXT NOT NULL,
18
+ "table_name" TEXT NOT NULL,
19
+ "key" TEXT NOT NULL,
20
+ "scope" TEXT NOT NULL,
21
+ PRIMARY KEY ("consumer", "table_name", "key")
22
+ );
23
+
24
+ CREATE VIEW "link_effective" AS
25
+ SELECT "type", "from_id", "to_id", "scope", "origin", "confidence", "evidence" FROM "link";
26
+ `;
27
+
28
+ const emptyEntityView = `
29
+ CREATE VIEW "entity" AS
30
+ SELECT '' AS "id", '' AS "kind", '' AS "title", NULL AS "url", NULL AS "actor", '' AS "updated_at"
31
+ WHERE 0;
32
+ `;
33
+
34
+ // The kit writes one `entity` view per consumer file, so the one database gets a single view over
35
+ // the entity views of every consumer.
36
+ export function formatStoreSchema(consumers: readonly ConsumerDeclaration[]): string {
37
+ const tables = consumers.map((consumer) =>
38
+ generateSqliteSchema({ ...consumer, entityViews: [] }),
39
+ );
40
+
41
+ const views = consumers.flatMap((consumer) => consumer.entityViews);
42
+
43
+ const entityView =
44
+ views.length === 0
45
+ ? emptyEntityView
46
+ : generateSqliteSchema({
47
+ name: 'entity',
48
+ entry: new URL('file:///entity'),
49
+ input: { mode: 'fact', streams: [] },
50
+ tables: {},
51
+ entityViews: views,
52
+ reads: [],
53
+ links: [],
54
+ });
55
+
56
+ return [...tables, hostSchema, entityView].join('\n');
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { LinkRow, SharedViews } from '@pepys/kit';
2
+ import type { Kysely } from 'kysely';
3
+
4
+ // The tables that the test store keeps beside the consumers' own. The host keeps `link` and
5
+ // `scope_row` in each consumer's file; one database holds every consumer here, so both carry the
6
+ // consumer's name.
7
+ export interface StoreTables extends SharedViews {
8
+ link: LinkRow;
9
+ scope_row: { consumer: string; table_name: string; key: string; scope: string };
10
+ }
11
+
12
+ export type StoreDatabase = Kysely<StoreTables>;
@@ -0,0 +1,94 @@
1
+ import { checkDeployment } from '@pepys/kit';
2
+ import type { Plugin, TableDeclaration } from '@pepys/kit';
3
+ import { buildFactIndex } from '../fact-index/build-fact-index.ts';
4
+ import type { FactIndex } from '../fact-index/types.ts';
5
+ import { readLinkRows } from '../links/read-link-rows.ts';
6
+ import { createMemoryStoreReader } from '../memory-store-reader/create-memory-store-reader.ts';
7
+ import { readTableRows } from '../rows/read-table-rows.ts';
8
+ import { writeRow } from '../rows/write-row.ts';
9
+ import { createStoreDatabase } from '../store-database/create-store-database.ts';
10
+ import { ApplyError } from '../utils/apply-error.ts';
11
+ import { parseAppliedFacts } from './parse-applied-facts.ts';
12
+ import { runConsumers } from './run-consumers.ts';
13
+ import type { AppliedFactIDs } from './run-consumers.ts';
14
+ import type { TestStore } from './types.ts';
15
+
16
+ interface TestStoreOptions {
17
+ plugins: readonly Plugin[];
18
+ }
19
+
20
+ // A store for `bun test`: every consumer of the plug-ins runs its TypeScript entry over one
21
+ // in-memory database. It repeats the host's envelope check, link check and scope replace, and
22
+ // proves neither the scriptc compile nor the Rust host.
23
+ export async function createTestStore(options: TestStoreOptions): Promise<TestStore> {
24
+ const checked = checkDeployment({ name: 'test-store', plugins: options.plugins });
25
+
26
+ if (!checked.ok) {
27
+ throw new Error(`the deployment is invalid: ${JSON.stringify(checked.failure)}`);
28
+ }
29
+
30
+ const deployment = checked.value;
31
+
32
+ const store = await createStoreDatabase(deployment.consumers);
33
+
34
+ const db = store.db;
35
+ const reader = createMemoryStoreReader(store.database, deployment);
36
+ let index: FactIndex = { log: [], firstCopies: new Map() };
37
+
38
+ let applied: AppliedFactIDs = new Map();
39
+
40
+ const getTable = (name: string): TableDeclaration => {
41
+ const table = deployment.consumers.find((consumer) => Object.hasOwn(consumer.tables, name))
42
+ ?.tables[name];
43
+
44
+ if (table === undefined) {
45
+ throw new Error(`no consumer of the deployment has the table ${name}`);
46
+ }
47
+
48
+ return table;
49
+ };
50
+
51
+ return {
52
+ writeRows: async (_consumer, table, rows) => {
53
+ for (const row of rows) {
54
+ await writeRow(db, table, getTable(table), row);
55
+ }
56
+ },
57
+ apply: async (source, facts, applyOptions) => {
58
+ const envelopes = parseAppliedFacts(source, facts, applyOptions?.sender);
59
+
60
+ if (!envelopes.ok) {
61
+ throw new ApplyError(envelopes.failure);
62
+ }
63
+
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;
73
+ },
74
+ query: async (query, input) => {
75
+ const parsed = query.input.parse(input);
76
+
77
+ const result = await reader.withConsumers(query.reads, (queryDB) =>
78
+ query.handler({ input: parsed, db: queryDB }),
79
+ );
80
+
81
+ if (!result.ok) {
82
+ throw new Error(
83
+ `the query ${query.name} reads consumers with no current version: ${result.failure.consumers.join(', ')}`,
84
+ );
85
+ }
86
+
87
+ return query.output.parse(result.value);
88
+ },
89
+ readLinks: () => readLinkRows(db),
90
+ readRows: (table) => readTableRows(db, table, getTable(table)),
91
+ reader,
92
+ [Symbol.asyncDispose]: () => db.destroy(),
93
+ };
94
+ }
@@ -0,0 +1,64 @@
1
+ import { parseFactEnvelope, readFactBatch } from '@pepys/kit';
2
+ import type { FactEnvelope, Result, SourceDefinition } from '@pepys/kit';
3
+ import type { ApplyFailure } from '../utils/types.ts';
4
+ import type { ApplyOptions } from './types.ts';
5
+
6
+ interface AppliedEnvelope {
7
+ stream: string;
8
+ envelope: FactEnvelope<unknown>;
9
+ }
10
+
11
+ // The facts of one apply as ingest would store them: the fact batch read builds each envelope, and
12
+ // the envelope parser checks it.
13
+ export function parseAppliedFacts(
14
+ source: SourceDefinition,
15
+ facts: readonly { payload: unknown }[],
16
+ sender: ApplyOptions['sender'],
17
+ ): Result<AppliedEnvelope[], ApplyFailure> {
18
+ const withOrigin = facts.map((fact) =>
19
+ sender === undefined || !isObject(fact.payload)
20
+ ? fact
21
+ : {
22
+ ...fact,
23
+ payload: { ...fact.payload, origin: { issuer: sender.issuer, subject: sender.subject } },
24
+ },
25
+ );
26
+
27
+ const received = readFactBatch(
28
+ {
29
+ headers: new Headers(),
30
+ body: new TextEncoder().encode(JSON.stringify({ v: 1, facts: withOrigin })),
31
+ },
32
+ { name: source.name, families: source.families },
33
+ );
34
+
35
+ if (received.kind === 'refused') {
36
+ return refuseFacts(received.reason);
37
+ }
38
+
39
+ if (received.kind === 'ignored') {
40
+ return { ok: true, value: [] };
41
+ }
42
+
43
+ const envelopes: AppliedEnvelope[] = [];
44
+
45
+ for (const pending of received.batch.facts) {
46
+ const parsed = parseFactEnvelope(pending.fact);
47
+
48
+ if (!parsed.ok) {
49
+ return refuseFacts(parsed.failure.issues.map((issue) => issue.message).join('; '));
50
+ }
51
+
52
+ envelopes.push({ stream: pending.stream, envelope: parsed.value });
53
+ }
54
+
55
+ return { ok: true, value: envelopes };
56
+ }
57
+
58
+ function isObject(value: unknown): value is Readonly<Record<string, unknown>> {
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ function refuseFacts(reason: string): Result<AppliedEnvelope[], ApplyFailure> {
63
+ return { ok: false, failure: { kind: 'refused-facts', reason } };
64
+ }
@@ -0,0 +1,117 @@
1
+ import { parseEntityID } from '@pepys/kit';
2
+ import type { ConsumerDeclaration } from '@pepys/kit';
3
+ import invariant from 'tiny-invariant';
4
+ import { buildReadFunctions } from '../consumer-entries/build-read-functions.ts';
5
+ import { runEntry } from '../consumer-entries/run-entry.ts';
6
+ import { collectScopeFacts } from '../fact-index/collect-scope-facts.ts';
7
+ import type { FactIndex, LoggedFact } from '../fact-index/types.ts';
8
+ import { applyFactOperations } from '../fact-mode/apply-fact-operations.ts';
9
+ import { readRowFunctions } from '../fact-mode/read-row-functions.ts';
10
+ import { parseOperations } from '../operations/parse-operations.ts';
11
+ import { replaceScope } from '../scope-replace/replace-scope.ts';
12
+ import type { StoreDatabase } from '../store-database/types.ts';
13
+ import { ApplyError } from '../utils/apply-error.ts';
14
+
15
+ // The fact IDs each fact-mode consumer has applied, by consumer name.
16
+ export type AppliedFactIDs = ReadonlyMap<string, ReadonlySet<string>>;
17
+
18
+ interface ConsumerRun {
19
+ index: FactIndex;
20
+ appended: readonly LoggedFact[];
21
+ applied: AppliedFactIDs;
22
+ }
23
+
24
+ // Runs every consumer on one appended batch, in deployment order, inside the caller's transaction,
25
+ // and returns the applied fact IDs after it.
26
+ export async function runConsumers(
27
+ db: StoreDatabase,
28
+ consumers: readonly ConsumerDeclaration[],
29
+ run: ConsumerRun,
30
+ ): Promise<AppliedFactIDs> {
31
+ const applied = new Map(run.applied);
32
+
33
+ for (const consumer of consumers) {
34
+ const facts = run.appended.filter((record) =>
35
+ consumer.input.streams.some((stream) => stream === record.stream),
36
+ );
37
+
38
+ if (consumer.input.mode === 'fact') {
39
+ const seen = await runFactConsumer(
40
+ db,
41
+ consumer,
42
+ facts,
43
+ run.applied.get(consumer.name) ?? new Set(),
44
+ );
45
+
46
+ applied.set(consumer.name, seen);
47
+ } else {
48
+ await runScopeConsumer(db, consumer, facts, run.index);
49
+ }
50
+ }
51
+
52
+ return applied;
53
+ }
54
+
55
+ async function runFactConsumer(
56
+ db: StoreDatabase,
57
+ consumer: ConsumerDeclaration,
58
+ facts: readonly LoggedFact[],
59
+ applied: ReadonlySet<string>,
60
+ ): Promise<ReadonlySet<string>> {
61
+ const seen = new Set(applied);
62
+
63
+ for (const record of facts) {
64
+ if (seen.has(record.envelope.id)) {
65
+ continue;
66
+ }
67
+
68
+ seen.add(record.envelope.id);
69
+
70
+ const host = await readRowFunctions(db, consumer);
71
+ const output = await runEntry(consumer, 'applyFact', JSON.stringify(record.envelope), host);
72
+
73
+ const operations = parseOperations(consumer, output);
74
+
75
+ if (!operations.ok) {
76
+ throw new ApplyError(operations.failure);
77
+ }
78
+
79
+ await applyFactOperations(db, consumer, operations.value);
80
+ }
81
+
82
+ return seen;
83
+ }
84
+
85
+ // The dirty scopes are the entities of the batch's facts that have the scope kind, each once, in
86
+ // sorted order. A copy of a fact marks its scope dirty too, and the replace then finds no change.
87
+ async function runScopeConsumer(
88
+ db: StoreDatabase,
89
+ consumer: ConsumerDeclaration,
90
+ facts: readonly LoggedFact[],
91
+ index: FactIndex,
92
+ ): Promise<void> {
93
+ invariant(consumer.input.mode === 'scope', 'only a scope consumer has a scope kind');
94
+
95
+ const scopeKind = consumer.input.scopeKind;
96
+
97
+ const scopes = [
98
+ ...new Set(
99
+ facts
100
+ .map((record) => record.envelope.entity)
101
+ .filter((entity) => {
102
+ const parsed = parseEntityID(entity);
103
+
104
+ return parsed.ok && `${parsed.value.source}:${parsed.value.kind}` === scopeKind;
105
+ }),
106
+ ),
107
+ ].toSorted((left, right) => (left < right ? -1 : 1));
108
+
109
+ for (const scope of scopes) {
110
+ const scopeFacts = JSON.stringify(collectScopeFacts(index, scope, consumer.input.streams));
111
+ const host = buildReadFunctions('readScopeFacts', new Map([[scope, scopeFacts]]), 'trap');
112
+
113
+ const output = await runEntry(consumer, 'applyScope', scope, host);
114
+
115
+ await replaceScope(db, consumer, scope, output);
116
+ }
117
+ }
@@ -0,0 +1,68 @@
1
+ import type {
2
+ ConsumerDeclaration,
3
+ LinkRow,
4
+ OutgoingFact,
5
+ QueryDefinition,
6
+ Sender,
7
+ SourceDefinition,
8
+ TableRow,
9
+ } from '@pepys/kit';
10
+ import type { z } from 'zod';
11
+ import type { MemoryStoreReader } from '../memory-store-reader/create-memory-store-reader.ts';
12
+ import type { Row } from '../utils/types.ts';
13
+
14
+ type WithOptionalOrigin<Payload> = Payload extends { origin: infer Origin }
15
+ ? Omit<Payload, 'origin'> & { origin?: Origin }
16
+ : Payload;
17
+
18
+ // A fact as a sender writes it in a fact batch. With a `sender` on the apply, the payload's
19
+ // `origin` may be left out: the store writes it, as a source's read does for a proven sender.
20
+ export type AppliedFact<Source extends SourceDefinition> =
21
+ OutgoingFact<Source> extends infer Fact
22
+ ? Fact extends { payload: infer Payload }
23
+ ? Omit<Fact, 'payload'> & { payload: WithOptionalOrigin<Payload> }
24
+ : never
25
+ : never;
26
+
27
+ export interface ApplyOptions {
28
+ // the proven sender of the batch; the store copies it into each payload as `origin`
29
+ sender?: Pick<Sender, 'issuer' | 'subject'> | undefined;
30
+ }
31
+
32
+ export interface TestStore extends AsyncDisposable {
33
+ // Writes rows as they are, with no scope, for a test of a query.
34
+ writeRows: <
35
+ Consumer extends ConsumerDeclaration,
36
+ Table extends keyof Consumer['tables'] & string,
37
+ >(
38
+ consumer: Consumer,
39
+ table: Table,
40
+ rows: readonly TableRow<Consumer['tables'][Table]>[],
41
+ ) => Promise<void>;
42
+
43
+ // Builds the envelopes as a fact batch path does, with `event:` IDs, parses each with the
44
+ // envelope parser, and runs each consumer whose input names the fact's stream. A refusal throws
45
+ // an ApplyError, and the batch then writes nothing.
46
+ apply: <Source extends SourceDefinition>(
47
+ source: Source,
48
+ facts: readonly AppliedFact<Source>[],
49
+ options?: ApplyOptions,
50
+ ) => Promise<void>;
51
+
52
+ query: <
53
+ InputSchema extends z.ZodType,
54
+ OutputSchema extends z.ZodType,
55
+ Consumers extends readonly ConsumerDeclaration[],
56
+ >(
57
+ query: QueryDefinition<string, InputSchema, OutputSchema, Consumers>,
58
+ input: z.input<InputSchema>,
59
+ ) => Promise<z.output<OutputSchema>>;
60
+
61
+ // every link, in key order
62
+ readLinks: () => Promise<readonly LinkRow[]>;
63
+
64
+ // every row of one table in primary key order, as the module wrote it
65
+ readRows: (table: string) => Promise<readonly Row[]>;
66
+
67
+ reader: MemoryStoreReader;
68
+ }
@@ -0,0 +1,13 @@
1
+ import type { ApplyFailure } from './types.ts';
2
+
3
+ // What `apply` throws when it refuses a batch. The batch then wrote nothing.
4
+ export class ApplyError extends Error {
5
+ readonly failure: ApplyFailure;
6
+
7
+ constructor(failure: ApplyFailure) {
8
+ super(`the test store refused the batch: ${JSON.stringify(failure)}`);
9
+
10
+ this.name = 'ApplyError';
11
+ this.failure = failure;
12
+ }
13
+ }
@@ -0,0 +1,47 @@
1
+ import type { LinkProblem } from '@pepys/kit';
2
+
3
+ // Why the test store refused an apply. The kinds from `bad-operation` on are the host's refusals
4
+ // of a module's output, which the scope replace case file names.
5
+ export type ApplyFailure =
6
+ | { kind: 'refused-facts'; reason: string }
7
+ | { kind: 'entry'; consumer: string; reason: string }
8
+
9
+ // output that is no list of operations, or a delete or an unlink from a scope consumer
10
+ | { kind: 'bad-operation'; consumer: string; reason: string }
11
+ | { kind: 'not-owned'; consumer: string; table: string }
12
+ | { kind: 'bad-row'; consumer: string; table: string; reason: string }
13
+ | { kind: 'bad-link'; consumer: string; problem: LinkProblem }
14
+ | { kind: 'duplicate-row'; consumer: string; table: string; key: string }
15
+ | { kind: 'duplicate-link'; consumer: string; key: string }
16
+
17
+ // `owner` is null for a link stored with no scope
18
+ | {
19
+ kind: 'scope-conflict';
20
+ consumer: string;
21
+ scope: string;
22
+ table: string;
23
+ key: string;
24
+ owner: string | null;
25
+ };
26
+
27
+ // A row as the module writes it and as the test store reads it back: booleans as booleans and a
28
+ // JSON column as its value.
29
+ export type Row = Readonly<Record<string, unknown>>;
30
+
31
+ export interface WireLink {
32
+ type: string;
33
+ from: string;
34
+ to: string;
35
+ confidence: number;
36
+ evidence: string;
37
+ }
38
+
39
+ // One State Protocol message of the change log. `key` is the JSON text of the key's values: a
40
+ // row's primary key columns in order, or a link's type, `from` and `to`.
41
+ export interface ChangeEntry {
42
+ type: string;
43
+ key: string;
44
+ value?: unknown;
45
+ old_value?: unknown;
46
+ headers: { operation: 'insert' | 'update' | 'delete' };
47
+ }