@classytic/repo-core 0.29.0 → 0.30.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/CHANGELOG.md +27 -0
- package/dist/sync/index.d.mts +90 -55
- package/dist/sync/index.mjs +0 -0
- package/dist/testing/change-log-conformance.d.mts +21 -0
- package/dist/testing/change-log-conformance.mjs +204 -0
- package/dist/testing/command-stream-conformance.d.mts +15 -0
- package/dist/testing/command-stream-conformance.mjs +163 -0
- package/dist/testing/index.d.mts +3 -1
- package/dist/testing/index.mjs +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to `@classytic/repo-core` are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.30.0] - 2026-10-06
|
|
8
|
+
|
|
9
|
+
### Added — command streams and a shared catalog in `./sync`
|
|
10
|
+
|
|
11
|
+
- **`CommandStreamStore`**: the server side of a command-sync protocol. It records one verdict per
|
|
12
|
+
sequence number (carrying the command's `fingerprint`) and a per-stream watermark bound to a
|
|
13
|
+
tenant, all in the transaction of the command's effects. `createMemoryCommandStreamStore` is
|
|
14
|
+
the reference implementation; mongokit ships the durable one.
|
|
15
|
+
- **Resolutions and aliases** on the same contract:
|
|
16
|
+
- outcome `resolved`;
|
|
17
|
+
- a refused verdict keeps its `body` and `dependsOn`;
|
|
18
|
+
- `resolution` attribution;
|
|
19
|
+
- `record` replaces;
|
|
20
|
+
- `blockedOn(commandId)`;
|
|
21
|
+
- `alias` / `aliasOf`: tenant-scoped, and one local id names one record.
|
|
22
|
+
- **`runCommandStreamStoreConformance`** (`./testing`): eight cases. One of them is serialisation:
|
|
23
|
+
twenty concurrent advances of one stream must reach 20.
|
|
24
|
+
- **`runChangeLogStoreConformance`** (`./testing`): eleven cases. These include commit order: an
|
|
25
|
+
entry that commits after a reader's checkpoint must still reach that reader.
|
|
26
|
+
- **`ChangesSinceOptions.sharedScopes`**: company-wide scopes (a catalog) that every tenant reads,
|
|
27
|
+
whatever tenant their entries carry.
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
|
|
31
|
+
- `PushMutation`, `PushVerdict`, `PushVerdictStatus`. Nothing consumed them; command streams
|
|
32
|
+
replace them.
|
|
33
|
+
|
|
7
34
|
## [0.29.0] - 2026-09-23
|
|
8
35
|
|
|
9
36
|
### ⚠ Changed — ONE list-query grammar, held to by every parser (`./query-parser`)
|
package/dist/sync/index.d.mts
CHANGED
|
@@ -1,39 +1,24 @@
|
|
|
1
1
|
//#region src/sync/index.d.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* incremental replication (`arc-sync`-style capability packages, POS offline,
|
|
5
|
-
* bulk delta export, cross-service read models).
|
|
3
|
+
* The sync contracts kits implement and a sync engine (`@classytic/arc-sync`) consumes:
|
|
6
4
|
*
|
|
7
|
-
*
|
|
5
|
+
* - `ChangeLogStore` — the server's change FEED: append-only, commit-ordered, tombstones for
|
|
6
|
+
* deletes, opaque cursors a client resumes from.
|
|
7
|
+
* - `CommandStreamStore` — per-client COMMAND STREAMS: one recorded verdict per sequence number,
|
|
8
|
+
* committed in the transaction of the command's effects.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - Drive/Graph delta APIs → OPAQUE server-issued cursors (`deltaLink` /
|
|
12
|
-
* `startPageToken`) — clients never parse them
|
|
13
|
-
* - Mongo change streams → resumable, ordered, per-scope feeds
|
|
14
|
-
* - Replicache push/pull → client mutation ids for idempotent PUSH,
|
|
15
|
-
* server-authoritative conflict verdicts
|
|
10
|
+
* Contracts only; `MemoryChangeLogStore` / `createMemoryCommandStreamStore` are the references,
|
|
11
|
+
* and `@classytic/repo-core/testing` holds the conformance suites every durable store passes.
|
|
16
12
|
*
|
|
17
|
-
*
|
|
18
|
-
* like DataAdapter/EventTransport/IdempotencyStore). `MemoryChangeLogStore` is
|
|
19
|
-
* the reference impl. Durable stores + capture plugins live in the KITS
|
|
20
|
-
* (mongokit/prismakit/sqlitekit repository plugins); the HTTP surface
|
|
21
|
-
* (`/sync/pull`, `/sync/push`) is an arc module; arc re-exports this contract
|
|
22
|
-
* at `@classytic/arc/sync`. repo-core is source of truth.
|
|
13
|
+
* ## Feed semantics (stores MUST honor)
|
|
23
14
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* to converge by applying every entry after C — including deletions.
|
|
32
|
-
* 4. **`since` is exclusive** of the given cursor and returns entries in
|
|
33
|
-
* cursor order. `hasMore: true` means call again with the new cursor.
|
|
34
|
-
* 5. **Compaction (`prune`) may drop superseded intermediate versions but
|
|
35
|
-
* NEVER the latest state or an un-consumed tombstone horizon** — a store
|
|
36
|
-
* advertises its horizon so clients older than it do a full resync.
|
|
15
|
+
* 1. **Cursors are opaque, totally ordered per store, in COMMIT order.** A reader must never
|
|
16
|
+
* checkpoint past an entry that commits later.
|
|
17
|
+
* 2. **`append` joins the caller's transaction** when given a `session`.
|
|
18
|
+
* 3. **Deletes are tombstones**, never gaps.
|
|
19
|
+
* 4. **`since` is exclusive** of the cursor and returns entries in cursor order.
|
|
20
|
+
* 5. **`prune` keeps the latest state and advertises its horizon**; an older cursor gets
|
|
21
|
+
* `CursorExpiredError` and resyncs.
|
|
37
22
|
*/
|
|
38
23
|
/** What happened to a document. Field-level patches are deliberately out of
|
|
39
24
|
* scope — version-checked upserts + server authority beat CRDT complexity
|
|
@@ -47,7 +32,7 @@ interface ChangeEntry<TDoc = unknown> {
|
|
|
47
32
|
readonly op: ChangeOp;
|
|
48
33
|
/**
|
|
49
34
|
* Monotonic per-document version — the optimistic-concurrency token.
|
|
50
|
-
*
|
|
35
|
+
* A client ignores an entry not newer than the version it holds.
|
|
51
36
|
*/
|
|
52
37
|
readonly version: number;
|
|
53
38
|
/** Full document snapshot for `upsert`; absent for `delete` (tombstone). */
|
|
@@ -66,6 +51,11 @@ interface ChangesSinceOptions {
|
|
|
66
51
|
readonly scopes?: readonly string[];
|
|
67
52
|
/** Tenant partition — REQUIRED by multi-tenant stores. */
|
|
68
53
|
readonly tenantId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Scopes every tenant reads whatever their entries' tenant (company-wide resources: the catalog
|
|
56
|
+
* every branch sells from). Only meaningful with `tenantId`; still narrowed by `scopes`.
|
|
57
|
+
*/
|
|
58
|
+
readonly sharedScopes?: readonly string[];
|
|
69
59
|
}
|
|
70
60
|
interface ChangesPage<TDoc = unknown> {
|
|
71
61
|
readonly changes: readonly ChangeEntry<TDoc>[];
|
|
@@ -74,29 +64,6 @@ interface ChangesPage<TDoc = unknown> {
|
|
|
74
64
|
/** True → more entries exist; pull again immediately. */
|
|
75
65
|
readonly hasMore: boolean;
|
|
76
66
|
}
|
|
77
|
-
interface PushMutation<TDoc = unknown> {
|
|
78
|
-
readonly scope: string;
|
|
79
|
-
readonly docId: string;
|
|
80
|
-
readonly op: ChangeOp;
|
|
81
|
-
/** Version the client last saw — the optimistic-concurrency precondition. */
|
|
82
|
-
readonly baseVersion?: number;
|
|
83
|
-
readonly doc?: TDoc;
|
|
84
|
-
/**
|
|
85
|
-
* Client-unique id (`<clientId>:<seq>`) — replays of the same id MUST be
|
|
86
|
-
* acknowledged as already-applied, never re-executed (at-least-once safe).
|
|
87
|
-
*/
|
|
88
|
-
readonly mutationId: string;
|
|
89
|
-
}
|
|
90
|
-
type PushVerdictStatus = 'applied' | 'already_applied' | 'conflict' | 'rejected';
|
|
91
|
-
interface PushVerdict<TDoc = unknown> {
|
|
92
|
-
readonly mutationId: string;
|
|
93
|
-
readonly status: PushVerdictStatus;
|
|
94
|
-
/** Authoritative post-push version (also on conflict: the WINNING version). */
|
|
95
|
-
readonly version?: number;
|
|
96
|
-
/** Authoritative doc on conflict so the client can rebase (server wins). */
|
|
97
|
-
readonly current?: TDoc;
|
|
98
|
-
readonly reason?: string;
|
|
99
|
-
}
|
|
100
67
|
interface ChangeLogAppendOptions {
|
|
101
68
|
/** DB session/transaction handle — append atomically with the business write. */
|
|
102
69
|
readonly session?: unknown;
|
|
@@ -127,5 +94,73 @@ declare class MemoryChangeLogStore<TDoc = unknown> implements ChangeLogStore<TDo
|
|
|
127
94
|
since(cursor: string, options?: ChangesSinceOptions): Promise<ChangesPage<TDoc>>;
|
|
128
95
|
latestCursor(): Promise<string>;
|
|
129
96
|
}
|
|
97
|
+
/** `resolved`: a refused command settled afterwards by a recorded resolution. */
|
|
98
|
+
type CommandOutcome = 'applied' | 'rejected' | 'blocked' | 'resolved';
|
|
99
|
+
/** The one outcome recorded for one command — and for its `seq` on its stream. */
|
|
100
|
+
interface CommandVerdictRecord {
|
|
101
|
+
readonly commandId: string;
|
|
102
|
+
readonly stream: string;
|
|
103
|
+
readonly seq: number;
|
|
104
|
+
readonly outcome: CommandOutcome;
|
|
105
|
+
/** Why it was rejected or blocked — a stable code. */
|
|
106
|
+
readonly code?: string;
|
|
107
|
+
/** `fingerprintRequest` of the command: a retry with other bytes under the same id is a fault. */
|
|
108
|
+
readonly fingerprint: string;
|
|
109
|
+
/** The command's bytes, kept while it is refused — a resolution re-evaluates them unchanged. */
|
|
110
|
+
readonly body?: string;
|
|
111
|
+
/** What a `blocked` command waits on — how a resolution finds the dependants to re-evaluate. */
|
|
112
|
+
readonly dependsOn?: readonly string[];
|
|
113
|
+
/** On a `resolved` verdict: the decision that settled it, and who made it. */
|
|
114
|
+
readonly resolution?: {
|
|
115
|
+
readonly action: string;
|
|
116
|
+
readonly by: string;
|
|
117
|
+
readonly at: string;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** A device-minted id the server resolved to one of its own records (a matched customer, a merged shift). */
|
|
121
|
+
interface CommandAliasRecord {
|
|
122
|
+
readonly tenantId: string;
|
|
123
|
+
readonly localId: string;
|
|
124
|
+
readonly serverId: string;
|
|
125
|
+
}
|
|
126
|
+
interface CommandStreamRecord {
|
|
127
|
+
/** The tenant the stream was bound to by its first command. */
|
|
128
|
+
readonly tenantId: string;
|
|
129
|
+
/** Highest seq with a recorded verdict; every seq up to it has one. */
|
|
130
|
+
readonly processed: number;
|
|
131
|
+
}
|
|
132
|
+
/** One store transaction. Pass `session` to the feed's `append` and to every domain write. */
|
|
133
|
+
interface CommandStreamTx {
|
|
134
|
+
readonly session?: unknown;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Per-client command streams. Every method but `transaction` runs inside one; the transaction
|
|
138
|
+
* commits stream, verdict, feed entries and the domain's writes together, or none of them.
|
|
139
|
+
*/
|
|
140
|
+
interface CommandStreamStore<TTx extends CommandStreamTx = CommandStreamTx> {
|
|
141
|
+
/** The feed the domain's writes are captured into — verdicts are appended to it too. */
|
|
142
|
+
readonly changes: ChangeLogStore;
|
|
143
|
+
/**
|
|
144
|
+
* Commit when `work` returns, discard everything when it throws. Two transactions moving one
|
|
145
|
+
* stream must SERIALISE (a retried write conflict, a lock): neither may commit on a stale read.
|
|
146
|
+
*/
|
|
147
|
+
transaction<T>(work: (tx: TTx) => Promise<T>): Promise<T>;
|
|
148
|
+
stream(tx: TTx, id: string): Promise<CommandStreamRecord | null>;
|
|
149
|
+
/** Move `from` → `to`, creating the stream (bound to `tenantId`) at `from === 0`. False when not at `from`. */
|
|
150
|
+
advance(tx: TTx, id: string, tenantId: string, from: number, to: number): Promise<boolean>;
|
|
151
|
+
verdict(tx: TTx, commandId: string): Promise<CommandVerdictRecord | null>;
|
|
152
|
+
/** Record a verdict, replacing the command's earlier one (a resolution settles a refusal). */
|
|
153
|
+
record(tx: TTx, verdict: CommandVerdictRecord): Promise<void>;
|
|
154
|
+
/** The `blocked` verdicts waiting on `commandId`, in seq order. */
|
|
155
|
+
blockedOn(tx: TTx, commandId: string): Promise<CommandVerdictRecord[]>;
|
|
156
|
+
/** Record that `localId` names `serverId` in this tenant. One alias per local id. */
|
|
157
|
+
alias(tx: TTx, alias: CommandAliasRecord): Promise<void>;
|
|
158
|
+
aliasOf(tx: TTx, tenantId: string, localId: string): Promise<string | null>;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The reference `CommandStreamStore`: transactions run one at a time over a copy of the state, and
|
|
162
|
+
* the feed entries appended with their `session` reach `changes` only on commit.
|
|
163
|
+
*/
|
|
164
|
+
declare function createMemoryCommandStreamStore(feed?: ChangeLogStore): CommandStreamStore;
|
|
130
165
|
//#endregion
|
|
131
|
-
export { ChangeEntry, ChangeLogAppendOptions, ChangeLogStore, ChangeOp, ChangesPage, ChangesSinceOptions,
|
|
166
|
+
export { ChangeEntry, ChangeLogAppendOptions, ChangeLogStore, ChangeOp, ChangesPage, ChangesSinceOptions, CommandAliasRecord, CommandOutcome, CommandStreamRecord, CommandStreamStore, CommandStreamTx, CommandVerdictRecord, CursorExpiredError, MemoryChangeLogStore, createMemoryCommandStreamStore };
|
package/dist/sync/index.mjs
CHANGED
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ChangeLogStore } from "../sync/index.mjs";
|
|
2
|
+
//#region src/testing/change-log-conformance.d.ts
|
|
3
|
+
interface ChangeLogConformanceHarness {
|
|
4
|
+
/** A store over an EMPTY backend, per scenario. */
|
|
5
|
+
createStore(): ChangeLogStore | Promise<ChangeLogStore>;
|
|
6
|
+
/**
|
|
7
|
+
* Run `work` in a transaction on the store's backend and commit it, or abort when it throws.
|
|
8
|
+
* Omit for a store with no transactions (the memory reference); the atomicity cases then skip.
|
|
9
|
+
*/
|
|
10
|
+
transaction?<T>(work: (session: unknown) => Promise<T>): Promise<T>;
|
|
11
|
+
}
|
|
12
|
+
/** The contract's cases, runner-agnostic — each `run` throws on failure. */
|
|
13
|
+
declare function changeLogStoreCases(harness: ChangeLogConformanceHarness): {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly run: () => Promise<void>;
|
|
16
|
+
readonly needsTransaction?: boolean;
|
|
17
|
+
}[];
|
|
18
|
+
/** The contract as a vitest suite. */
|
|
19
|
+
declare function runChangeLogStoreConformance(harness: ChangeLogConformanceHarness): void;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { ChangeLogConformanceHarness, changeLogStoreCases, runChangeLogStoreConformance };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it } from "vitest";
|
|
2
|
+
//#region src/testing/change-log-conformance.ts
|
|
3
|
+
/**
|
|
4
|
+
* `runChangeLogStoreConformance` — the contract every `ChangeLogStore` passes. A kit runs it against
|
|
5
|
+
* its real backend; passing it is what "implements the store" means.
|
|
6
|
+
*
|
|
7
|
+
* describe('mongokit/sync conformance', () => {
|
|
8
|
+
* runChangeLogStoreConformance({ createStore: () => createMongoChangeLogStore(...), transaction });
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* The case that matters is the checkpoint one: a store whose cursors can commit OUT of order loses
|
|
12
|
+
* an entry for every client that read between them — it passes everything else and drops data.
|
|
13
|
+
*/
|
|
14
|
+
let n = 0;
|
|
15
|
+
const upsert = (over = {}) => {
|
|
16
|
+
n += 1;
|
|
17
|
+
return {
|
|
18
|
+
scope: "order",
|
|
19
|
+
docId: `doc-${n}`,
|
|
20
|
+
op: "upsert",
|
|
21
|
+
version: 1,
|
|
22
|
+
doc: { n },
|
|
23
|
+
tenantId: "branch-a",
|
|
24
|
+
...over
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
function check(condition, message) {
|
|
28
|
+
if (!condition) throw new Error(`[ChangeLogStore contract] ${message}`);
|
|
29
|
+
}
|
|
30
|
+
/** Every entry after `cursor`, page by page — what a client converges by. */
|
|
31
|
+
async function drain(store, cursor, options = {}) {
|
|
32
|
+
const out = [];
|
|
33
|
+
let at = cursor;
|
|
34
|
+
for (let guard = 0; guard < 1e4; guard++) {
|
|
35
|
+
const page = await store.since(at, options);
|
|
36
|
+
out.push(...page.changes);
|
|
37
|
+
at = page.cursor;
|
|
38
|
+
if (!page.hasMore) return {
|
|
39
|
+
entries: out,
|
|
40
|
+
cursor: at
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
throw new Error("[ChangeLogStore contract] since() never reported hasMore: false");
|
|
44
|
+
}
|
|
45
|
+
/** The contract's cases, runner-agnostic — each `run` throws on failure. */
|
|
46
|
+
function changeLogStoreCases(harness) {
|
|
47
|
+
const transact = (work) => {
|
|
48
|
+
if (!harness.transaction) throw new Error("[ChangeLogStore contract] this case needs transaction()");
|
|
49
|
+
return harness.transaction(work);
|
|
50
|
+
};
|
|
51
|
+
return [
|
|
52
|
+
["an empty store has no entries and an empty head cursor", async (store) => {
|
|
53
|
+
check(await store.latestCursor() === "", "latestCursor of an empty store must be \"\"");
|
|
54
|
+
const page = await store.since("");
|
|
55
|
+
check(page.changes.length === 0 && !page.hasMore, "since(\"\") of an empty store must be empty");
|
|
56
|
+
}],
|
|
57
|
+
["append assigns a cursor and a capture time, and round-trips the rest", async (store) => {
|
|
58
|
+
const entry = upsert({ doc: {
|
|
59
|
+
name: "Mug",
|
|
60
|
+
price: 450
|
|
61
|
+
} });
|
|
62
|
+
const saved = await store.append(entry);
|
|
63
|
+
check(typeof saved.cursor === "string" && saved.cursor.length > 0, "append must assign a cursor");
|
|
64
|
+
check(saved.at instanceof Date, "append must assign `at` as a Date");
|
|
65
|
+
const [read] = (await store.since("")).changes;
|
|
66
|
+
check(read && read.docId === entry.docId && read.scope === entry.scope && read.op === "upsert", "since must return the entry");
|
|
67
|
+
check(read.version === 1 && read.tenantId === "branch-a", "version and tenantId must round-trip");
|
|
68
|
+
check(JSON.stringify(read.doc) === JSON.stringify(entry.doc), "the doc snapshot must round-trip");
|
|
69
|
+
check(read.cursor === saved.cursor, "since must return the cursor append assigned");
|
|
70
|
+
}],
|
|
71
|
+
["since is exclusive and returns entries in append order", async (store) => {
|
|
72
|
+
const a = await store.append(upsert());
|
|
73
|
+
const b = await store.append(upsert());
|
|
74
|
+
const c = await store.append(upsert());
|
|
75
|
+
check((await drain(store, a.cursor)).entries.map((e) => e.cursor).join() === [b.cursor, c.cursor].join(), "since(a) must be [b, c], in order");
|
|
76
|
+
}],
|
|
77
|
+
["paging with a small limit delivers every entry exactly once", async (store) => {
|
|
78
|
+
for (let i = 0; i < 7; i++) await store.append(upsert());
|
|
79
|
+
const { entries } = await drain(store, "", { limit: 3 });
|
|
80
|
+
check(entries.length === 7, `paging must deliver all 7 entries, got ${entries.length}`);
|
|
81
|
+
check(new Set(entries.map((e) => e.cursor)).size === 7, "no entry may be delivered twice");
|
|
82
|
+
}],
|
|
83
|
+
["a delete is a tombstone with no doc", async (store) => {
|
|
84
|
+
await store.append({
|
|
85
|
+
scope: "order",
|
|
86
|
+
docId: "gone",
|
|
87
|
+
op: "delete",
|
|
88
|
+
version: 2,
|
|
89
|
+
tenantId: "branch-a"
|
|
90
|
+
});
|
|
91
|
+
const [tomb] = (await store.since("")).changes;
|
|
92
|
+
check(tomb?.op === "delete" && tomb.docId === "gone" && tomb.doc === void 0, "a delete must come back as a tombstone without a doc");
|
|
93
|
+
}],
|
|
94
|
+
["a tenant (branch) sees only its own entries", async (store) => {
|
|
95
|
+
await store.append(upsert({ tenantId: "branch-a" }));
|
|
96
|
+
await store.append(upsert({ tenantId: "branch-b" }));
|
|
97
|
+
await store.append(upsert({ tenantId: "branch-a" }));
|
|
98
|
+
const a = await drain(store, "", { tenantId: "branch-a" });
|
|
99
|
+
const b = await drain(store, "", { tenantId: "branch-b" });
|
|
100
|
+
check(a.entries.length === 2 && a.entries.every((e) => e.tenantId === "branch-a"), "branch-a must see its 2 entries only");
|
|
101
|
+
check(b.entries.length === 1 && b.entries[0]?.tenantId === "branch-b", "branch-b must see its 1 entry only");
|
|
102
|
+
}],
|
|
103
|
+
["a shared scope reaches every tenant; its other scopes stay partitioned", async (store) => {
|
|
104
|
+
const { tenantId: _, ...companyWide } = upsert({ scope: "product" });
|
|
105
|
+
await store.append(companyWide);
|
|
106
|
+
await store.append(upsert({
|
|
107
|
+
scope: "product",
|
|
108
|
+
tenantId: "branch-b"
|
|
109
|
+
}));
|
|
110
|
+
await store.append(upsert({
|
|
111
|
+
scope: "order",
|
|
112
|
+
tenantId: "branch-b"
|
|
113
|
+
}));
|
|
114
|
+
await store.append(upsert({
|
|
115
|
+
scope: "order",
|
|
116
|
+
tenantId: "branch-a"
|
|
117
|
+
}));
|
|
118
|
+
const { entries } = await drain(store, "", {
|
|
119
|
+
tenantId: "branch-a",
|
|
120
|
+
sharedScopes: ["product"]
|
|
121
|
+
});
|
|
122
|
+
check(entries.map((e) => e.scope).join() === "product,product,order", "branch-a must read every product and only its own order");
|
|
123
|
+
const narrowed = await drain(store, "", {
|
|
124
|
+
tenantId: "branch-a",
|
|
125
|
+
sharedScopes: ["product"],
|
|
126
|
+
scopes: ["order"]
|
|
127
|
+
});
|
|
128
|
+
check(narrowed.entries.length === 1 && narrowed.entries[0]?.tenantId === "branch-a", "scopes must still narrow a shared scope away");
|
|
129
|
+
}],
|
|
130
|
+
["scopes narrow the feed to the resources a client syncs", async (store) => {
|
|
131
|
+
await store.append(upsert({ scope: "order" }));
|
|
132
|
+
await store.append(upsert({ scope: "product" }));
|
|
133
|
+
await store.append(upsert({ scope: "shift" }));
|
|
134
|
+
const { entries } = await drain(store, "", { scopes: ["order", "shift"] });
|
|
135
|
+
check(entries.map((e) => e.scope).join() === "order,shift", "scopes must select order and shift, in order");
|
|
136
|
+
}],
|
|
137
|
+
["latestCursor is a checkpoint after which the filtered feed is empty", async (store) => {
|
|
138
|
+
await store.append(upsert({ tenantId: "branch-a" }));
|
|
139
|
+
await store.append(upsert({ tenantId: "branch-b" }));
|
|
140
|
+
const head = await store.latestCursor({ tenantId: "branch-a" });
|
|
141
|
+
check((await store.since(head, { tenantId: "branch-a" })).changes.length === 0, "nothing for branch-a after its head");
|
|
142
|
+
const later = await store.append(upsert({ tenantId: "branch-a" }));
|
|
143
|
+
const next = await store.since(head, { tenantId: "branch-a" });
|
|
144
|
+
check(next.changes.length === 1 && next.changes[0]?.cursor === later.cursor, "a later entry must follow the head");
|
|
145
|
+
}],
|
|
146
|
+
["concurrent appends get distinct cursors and none is lost", async (store) => {
|
|
147
|
+
const saved = await Promise.all(Array.from({ length: 25 }, () => store.append(upsert())));
|
|
148
|
+
check(new Set(saved.map((e) => e.cursor)).size === 25, "every concurrent append must get its own cursor");
|
|
149
|
+
const { entries } = await drain(store, "", { limit: 10 });
|
|
150
|
+
check(entries.length === 25, `all 25 must be delivered, got ${entries.length}`);
|
|
151
|
+
}],
|
|
152
|
+
[
|
|
153
|
+
"an entry appended in an aborted transaction never appears",
|
|
154
|
+
async (store) => {
|
|
155
|
+
await transact(async (session) => {
|
|
156
|
+
await store.append(upsert({ docId: "rolled-back" }), { session });
|
|
157
|
+
throw new Error("abort");
|
|
158
|
+
}).catch(() => void 0);
|
|
159
|
+
await store.append(upsert({ docId: "kept" }), {});
|
|
160
|
+
const { entries } = await drain(store, "");
|
|
161
|
+
check(!entries.some((e) => e.docId === "rolled-back"), "an aborted append must not be in the feed");
|
|
162
|
+
check(entries.some((e) => e.docId === "kept"), "a committed append must be in the feed");
|
|
163
|
+
},
|
|
164
|
+
true
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
"an entry committed after a client checkpointed is still delivered after that checkpoint",
|
|
168
|
+
async (store) => {
|
|
169
|
+
let releaseA;
|
|
170
|
+
const aMayCommit = new Promise((resolve) => releaseA = resolve);
|
|
171
|
+
let aAppended;
|
|
172
|
+
const aHasAppended = new Promise((resolve) => aAppended = resolve);
|
|
173
|
+
const a = transact(async (session) => {
|
|
174
|
+
await store.append(upsert({ docId: "slow" }), { session });
|
|
175
|
+
aAppended();
|
|
176
|
+
await aMayCommit;
|
|
177
|
+
});
|
|
178
|
+
await aHasAppended;
|
|
179
|
+
const b = store.append(upsert({ docId: "fast" }));
|
|
180
|
+
const bCommittedFirst = await Promise.race([b.then(() => true), new Promise((r) => setTimeout(() => r(false), 300))]);
|
|
181
|
+
const checkpoint = bCommittedFirst ? (await drain(store, "")).cursor : "";
|
|
182
|
+
releaseA();
|
|
183
|
+
await Promise.all([a, b]);
|
|
184
|
+
const all = (await drain(store, "")).entries.map((e) => e.docId);
|
|
185
|
+
check(all.includes("slow") && all.includes("fast"), "both entries must be in the feed");
|
|
186
|
+
if (bCommittedFirst) check((await drain(store, checkpoint)).entries.map((e) => e.docId).includes("slow"), "an entry committed after a checkpoint was lost to the client that took it");
|
|
187
|
+
},
|
|
188
|
+
true
|
|
189
|
+
]
|
|
190
|
+
].map(([name, body, needsTransaction]) => ({
|
|
191
|
+
name,
|
|
192
|
+
needsTransaction: needsTransaction === true,
|
|
193
|
+
run: async () => body(await harness.createStore())
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
/** The contract as a vitest suite. */
|
|
197
|
+
function runChangeLogStoreConformance(harness) {
|
|
198
|
+
describe("ChangeLogStore conformance", () => {
|
|
199
|
+
for (const c of changeLogStoreCases(harness)) if (c.needsTransaction && !harness.transaction) it.skip(`${c.name} (no transactions)`, c.run);
|
|
200
|
+
else it(c.name, c.run, 3e4);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
export { changeLogStoreCases, runChangeLogStoreConformance };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CommandStreamStore } from "../sync/index.mjs";
|
|
2
|
+
//#region src/testing/command-stream-conformance.d.ts
|
|
3
|
+
interface CommandStreamConformanceHarness {
|
|
4
|
+
/** A store over an EMPTY backend, per scenario. */
|
|
5
|
+
createStore(): CommandStreamStore | Promise<CommandStreamStore>;
|
|
6
|
+
}
|
|
7
|
+
/** The contract's cases, runner-agnostic — each `run` throws on failure. */
|
|
8
|
+
declare function commandStreamStoreCases(harness: CommandStreamConformanceHarness): {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly run: () => Promise<void>;
|
|
11
|
+
}[];
|
|
12
|
+
/** The contract as a vitest suite. */
|
|
13
|
+
declare function runCommandStreamStoreConformance(harness: CommandStreamConformanceHarness): void;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { CommandStreamConformanceHarness, commandStreamStoreCases, runCommandStreamStoreConformance };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it } from "vitest";
|
|
2
|
+
//#region src/testing/command-stream-conformance.ts
|
|
3
|
+
/**
|
|
4
|
+
* `runCommandStreamStoreConformance` — the contract every `CommandStreamStore` passes, run against
|
|
5
|
+
* its real backend. The serialisation case is the one that matters: a store whose transactions can
|
|
6
|
+
* both commit on a stale read gives two commands the same seq, and passes everything else.
|
|
7
|
+
*/
|
|
8
|
+
function check(condition, message) {
|
|
9
|
+
if (!condition) throw new Error(`[CommandStreamStore contract] ${message}`);
|
|
10
|
+
}
|
|
11
|
+
const verdict = (over = {}) => ({
|
|
12
|
+
commandId: "c1",
|
|
13
|
+
stream: "s1",
|
|
14
|
+
seq: 1,
|
|
15
|
+
outcome: "applied",
|
|
16
|
+
fingerprint: "f1",
|
|
17
|
+
...over
|
|
18
|
+
});
|
|
19
|
+
/** The contract's cases, runner-agnostic — each `run` throws on failure. */
|
|
20
|
+
function commandStreamStoreCases(harness) {
|
|
21
|
+
return [
|
|
22
|
+
["an unknown stream and command read as null", async (store) => {
|
|
23
|
+
await store.transaction(async (tx) => {
|
|
24
|
+
check(await store.stream(tx, "s1") === null, "an unknown stream must be null");
|
|
25
|
+
check(await store.verdict(tx, "c1") === null, "an unknown verdict must be null");
|
|
26
|
+
});
|
|
27
|
+
}],
|
|
28
|
+
["advance creates the stream bound to its tenant, then moves only from where it stands", async (store) => {
|
|
29
|
+
await store.transaction(async (tx) => {
|
|
30
|
+
check(await store.advance(tx, "s1", "branch-a", 0, 1), "advance 0 → 1 must create the stream");
|
|
31
|
+
check(!await store.advance(tx, "s1", "branch-a", 0, 1), "advance from a stale position must be false");
|
|
32
|
+
check(await store.advance(tx, "s1", "branch-b", 1, 2), "advance 1 → 2 must succeed");
|
|
33
|
+
});
|
|
34
|
+
const stream = await store.transaction((tx) => store.stream(tx, "s1"));
|
|
35
|
+
check(stream?.processed === 2, "the watermark must be 2");
|
|
36
|
+
check(stream.tenantId === "branch-a", "the stream stays bound to its FIRST tenant");
|
|
37
|
+
}],
|
|
38
|
+
["a verdict round-trips, with its code and fingerprint", async (store) => {
|
|
39
|
+
await store.transaction((tx) => store.record(tx, verdict({
|
|
40
|
+
outcome: "rejected",
|
|
41
|
+
code: "pos.closed"
|
|
42
|
+
})));
|
|
43
|
+
await store.transaction((tx) => store.record(tx, verdict({
|
|
44
|
+
commandId: "c2",
|
|
45
|
+
seq: 2
|
|
46
|
+
})));
|
|
47
|
+
const [rejected, applied] = await store.transaction(async (tx) => [await store.verdict(tx, "c1"), await store.verdict(tx, "c2")]);
|
|
48
|
+
check(rejected?.outcome === "rejected" && rejected.code === "pos.closed" && rejected.fingerprint === "f1", "the rejected verdict must round-trip");
|
|
49
|
+
check(applied?.outcome === "applied" && applied.code === void 0 && applied.seq === 2, "an applied verdict has no code");
|
|
50
|
+
}],
|
|
51
|
+
["a later record replaces a verdict — how a resolution settles a refusal", async (store) => {
|
|
52
|
+
await store.transaction((tx) => store.record(tx, verdict({
|
|
53
|
+
outcome: "rejected",
|
|
54
|
+
code: "x",
|
|
55
|
+
body: "{\"id\":\"c1\"}"
|
|
56
|
+
})));
|
|
57
|
+
await store.transaction((tx) => store.record(tx, verdict({ outcome: "resolved" })));
|
|
58
|
+
const settled = await store.transaction((tx) => store.verdict(tx, "c1"));
|
|
59
|
+
check(settled?.outcome === "resolved" && settled.code === void 0, "the second record must replace the first");
|
|
60
|
+
check(settled.body === void 0, "a replaced verdict keeps nothing of the first");
|
|
61
|
+
}],
|
|
62
|
+
["blockedOn finds the blocked verdicts waiting on a command, in seq order", async (store) => {
|
|
63
|
+
await store.transaction(async (tx) => {
|
|
64
|
+
await store.record(tx, verdict({
|
|
65
|
+
commandId: "c3",
|
|
66
|
+
seq: 3,
|
|
67
|
+
outcome: "blocked",
|
|
68
|
+
dependsOn: ["c1"],
|
|
69
|
+
body: "{}"
|
|
70
|
+
}));
|
|
71
|
+
await store.record(tx, verdict({
|
|
72
|
+
commandId: "c2",
|
|
73
|
+
seq: 2,
|
|
74
|
+
outcome: "blocked",
|
|
75
|
+
dependsOn: ["c1"],
|
|
76
|
+
body: "{}"
|
|
77
|
+
}));
|
|
78
|
+
await store.record(tx, verdict({
|
|
79
|
+
commandId: "c4",
|
|
80
|
+
seq: 4,
|
|
81
|
+
outcome: "blocked",
|
|
82
|
+
dependsOn: ["c9"],
|
|
83
|
+
body: "{}"
|
|
84
|
+
}));
|
|
85
|
+
await store.record(tx, verdict({
|
|
86
|
+
commandId: "c5",
|
|
87
|
+
seq: 5,
|
|
88
|
+
outcome: "applied",
|
|
89
|
+
dependsOn: ["c1"]
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
const waiting = await store.transaction((tx) => store.blockedOn(tx, "c1"));
|
|
93
|
+
check(waiting.map((v) => v.commandId).join() === "c2,c3", `expected c2,c3 — got ${waiting.map((v) => v.commandId).join()}`);
|
|
94
|
+
check(waiting[0]?.body === "{}" && waiting[0].dependsOn?.[0] === "c1", "a blocked verdict keeps its body and what it waits on");
|
|
95
|
+
}],
|
|
96
|
+
["an alias resolves in its own tenant only, and a local id names one record", async (store) => {
|
|
97
|
+
await store.transaction((tx) => store.alias(tx, {
|
|
98
|
+
tenantId: "branch-a",
|
|
99
|
+
localId: "cust-local",
|
|
100
|
+
serverId: "cust-1"
|
|
101
|
+
}));
|
|
102
|
+
const [own, other] = await store.transaction(async (tx) => [await store.aliasOf(tx, "branch-a", "cust-local"), await store.aliasOf(tx, "branch-b", "cust-local")]);
|
|
103
|
+
check(own === "cust-1" && other === null, "an alias is tenant-scoped");
|
|
104
|
+
await store.transaction((tx) => store.alias(tx, {
|
|
105
|
+
tenantId: "branch-a",
|
|
106
|
+
localId: "cust-local",
|
|
107
|
+
serverId: "cust-1"
|
|
108
|
+
}));
|
|
109
|
+
let refused = false;
|
|
110
|
+
await store.transaction((tx) => store.alias(tx, {
|
|
111
|
+
tenantId: "branch-a",
|
|
112
|
+
localId: "cust-local",
|
|
113
|
+
serverId: "cust-2"
|
|
114
|
+
})).catch(() => refused = true);
|
|
115
|
+
check(refused, "a local id already naming one record must not be re-pointed");
|
|
116
|
+
}],
|
|
117
|
+
["a transaction that throws leaves no stream, verdict or feed entry", async (store) => {
|
|
118
|
+
await store.transaction(async (tx) => {
|
|
119
|
+
await store.advance(tx, "s1", "branch-a", 0, 1);
|
|
120
|
+
await store.record(tx, verdict());
|
|
121
|
+
await store.alias(tx, {
|
|
122
|
+
tenantId: "branch-a",
|
|
123
|
+
localId: "cust-local",
|
|
124
|
+
serverId: "cust-1"
|
|
125
|
+
});
|
|
126
|
+
await store.changes.append({
|
|
127
|
+
scope: "order",
|
|
128
|
+
docId: "o1",
|
|
129
|
+
op: "upsert",
|
|
130
|
+
version: 1,
|
|
131
|
+
doc: {},
|
|
132
|
+
tenantId: "branch-a"
|
|
133
|
+
}, { session: tx.session });
|
|
134
|
+
throw new Error("abort");
|
|
135
|
+
}).catch(() => void 0);
|
|
136
|
+
await store.transaction(async (tx) => {
|
|
137
|
+
check(await store.stream(tx, "s1") === null, "an aborted advance must not persist");
|
|
138
|
+
check(await store.verdict(tx, "c1") === null, "an aborted verdict must not persist");
|
|
139
|
+
check(await store.aliasOf(tx, "branch-a", "cust-local") === null, "an aborted alias must not persist");
|
|
140
|
+
});
|
|
141
|
+
check((await store.changes.since("")).changes.length === 0, "an aborted feed entry must not persist");
|
|
142
|
+
}],
|
|
143
|
+
["transactions moving one stream serialise: twenty concurrent advances reach 20", async (store) => {
|
|
144
|
+
await Promise.all(Array.from({ length: 20 }, () => store.transaction(async (tx) => {
|
|
145
|
+
const at = (await store.stream(tx, "s1"))?.processed ?? 0;
|
|
146
|
+
check(await store.advance(tx, "s1", "branch-a", at, at + 1), "a serialised advance must find the stream where it read it");
|
|
147
|
+
})));
|
|
148
|
+
const stream = await store.transaction((tx) => store.stream(tx, "s1"));
|
|
149
|
+
check(stream?.processed === 20, `twenty advances must reach 20, got ${stream?.processed}`);
|
|
150
|
+
}]
|
|
151
|
+
].map(([name, body]) => ({
|
|
152
|
+
name,
|
|
153
|
+
run: async () => body(await harness.createStore())
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
/** The contract as a vitest suite. */
|
|
157
|
+
function runCommandStreamStoreConformance(harness) {
|
|
158
|
+
describe("CommandStreamStore conformance", () => {
|
|
159
|
+
for (const c of commandStreamStoreCases(harness)) it(c.name, c.run, 6e4);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
export { commandStreamStoreCases, runCommandStreamStoreConformance };
|
package/dist/testing/index.d.mts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { AggregateOpsSupport } from "../repository/capabilities.mjs";
|
|
2
|
+
import { ChangeLogConformanceHarness, changeLogStoreCases, runChangeLogStoreConformance } from "./change-log-conformance.mjs";
|
|
3
|
+
import { CommandStreamConformanceHarness, commandStreamStoreCases, runCommandStreamStoreConformance } from "./command-stream-conformance.mjs";
|
|
2
4
|
import { ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
|
|
3
5
|
import { runStandardRepoConformance } from "./conformance.mjs";
|
|
4
6
|
import { IdempotencyConformanceHarness, idempotencyStoreCases, runIdempotencyStoreConformance } from "./idempotency-conformance.mjs";
|
|
@@ -6,4 +8,4 @@ import { LockConformanceHarness, runLockAdapterConformance } from "./lock-confor
|
|
|
6
8
|
import { PurgeConformanceContext, PurgeConformanceHarness, runPurgeConformance } from "./purge-conformance.mjs";
|
|
7
9
|
import { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, QueryGrammarCaseOptions, QueryGrammarConformanceOptions, QueryGrammarHarness, QueryGrammarParsed, runQueryGrammarConformance } from "./query-grammar-conformance.mjs";
|
|
8
10
|
import { UsageConformanceHarness, runUsageStoreContract } from "./usage-conformance.mjs";
|
|
9
|
-
export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type IdempotencyConformanceHarness, type LockConformanceHarness, type PurgeConformanceContext, type PurgeConformanceHarness, QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, type QueryGrammarCaseOptions, type QueryGrammarConformanceOptions, type QueryGrammarHarness, type QueryGrammarParsed, type UsageConformanceHarness, idempotencyStoreCases, runIdempotencyStoreConformance, runLockAdapterConformance, runPurgeConformance, runQueryGrammarConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
11
|
+
export { type AggregateOpsSupport, type ChangeLogConformanceHarness, type CommandStreamConformanceHarness, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type IdempotencyConformanceHarness, type LockConformanceHarness, type PurgeConformanceContext, type PurgeConformanceHarness, QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, type QueryGrammarCaseOptions, type QueryGrammarConformanceOptions, type QueryGrammarHarness, type QueryGrammarParsed, type UsageConformanceHarness, changeLogStoreCases, commandStreamStoreCases, idempotencyStoreCases, runChangeLogStoreConformance, runCommandStreamStoreConformance, runIdempotencyStoreConformance, runLockAdapterConformance, runPurgeConformance, runQueryGrammarConformance, runStandardRepoConformance, runUsageStoreContract };
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { changeLogStoreCases, runChangeLogStoreConformance } from "./change-log-conformance.mjs";
|
|
2
|
+
import { commandStreamStoreCases, runCommandStreamStoreConformance } from "./command-stream-conformance.mjs";
|
|
1
3
|
import { runStandardRepoConformance } from "./conformance.mjs";
|
|
2
4
|
import { idempotencyStoreCases, runIdempotencyStoreConformance } from "./idempotency-conformance.mjs";
|
|
3
5
|
import { runLockAdapterConformance } from "./lock-conformance.mjs";
|
|
4
6
|
import { runPurgeConformance } from "./purge-conformance.mjs";
|
|
5
7
|
import { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, runQueryGrammarConformance } from "./query-grammar-conformance.mjs";
|
|
6
8
|
import { runUsageStoreContract } from "./usage-conformance.mjs";
|
|
7
|
-
export { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, idempotencyStoreCases, runIdempotencyStoreConformance, runLockAdapterConformance, runPurgeConformance, runQueryGrammarConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
9
|
+
export { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, changeLogStoreCases, commandStreamStoreCases, idempotencyStoreCases, runChangeLogStoreConformance, runCommandStreamStoreConformance, runIdempotencyStoreConformance, runLockAdapterConformance, runPurgeConformance, runQueryGrammarConformance, runStandardRepoConformance, runUsageStoreContract };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/repo-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design \u2014 no plugins ship here; each kit owns its own.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|