slopkit 0.1.2 → 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/DEPS.md +2 -1
- package/README.md +41 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +4 -0
- package/dist/conformance/fixtures.d.ts +54 -3
- package/dist/conformance/fixtures.js +4 -4
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/paging-budget.test.d.ts +1 -0
- package/dist/conformance/paging-budget.test.js +101 -0
- package/dist/conformance/paging-fixtures.d.ts +80 -0
- package/dist/conformance/paging-fixtures.js +135 -0
- package/dist/conformance/paging-network.test.d.ts +1 -0
- package/dist/conformance/paging-network.test.js +123 -0
- package/dist/conformance/paging-state.test.d.ts +1 -0
- package/dist/conformance/paging-state.test.js +180 -0
- package/dist/conformance/paging-storage.test.d.ts +1 -0
- package/dist/conformance/paging-storage.test.js +255 -0
- package/dist/conformance/paging.test.d.ts +1 -0
- package/dist/conformance/paging.test.js +209 -0
- package/dist/conformance/pull.d.ts +74 -0
- package/dist/conformance/pull.js +400 -0
- package/dist/server/ledger.d.ts +13 -0
- package/dist/server/ledger.js +18 -0
- package/dist/server/pull.d.ts +85 -0
- package/dist/server/pull.js +233 -0
- package/dist/sync.d.ts +137 -14
- package/dist/sync.js +293 -41
- package/dist/web/replica.js +47 -12
- package/package.json +17 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { createSync, table } from "../sync.js";
|
|
3
|
+
import { row, rowSchema } from "./fixtures.js";
|
|
4
|
+
/** Real Map with read instrumentation: no timer noise and no substitute for the production table implementation. */
|
|
5
|
+
class MeasuredMap extends Map {
|
|
6
|
+
visits = 0;
|
|
7
|
+
*measure(items) {
|
|
8
|
+
for (const item of items) {
|
|
9
|
+
this.visits++;
|
|
10
|
+
yield item;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
entries() {
|
|
14
|
+
return this.measure(super.entries());
|
|
15
|
+
}
|
|
16
|
+
keys() {
|
|
17
|
+
return this.measure(super.keys());
|
|
18
|
+
}
|
|
19
|
+
values() {
|
|
20
|
+
return this.measure(super.values());
|
|
21
|
+
}
|
|
22
|
+
[Symbol.iterator]() {
|
|
23
|
+
return this.measure(super[Symbol.iterator]());
|
|
24
|
+
}
|
|
25
|
+
get(key) {
|
|
26
|
+
this.visits++;
|
|
27
|
+
return super.get(key);
|
|
28
|
+
}
|
|
29
|
+
forEach(callback,
|
|
30
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Map's receiver is arbitrary, forwarded unchanged
|
|
31
|
+
thisArg) {
|
|
32
|
+
for (const [key, value] of super.entries()) {
|
|
33
|
+
this.visits++;
|
|
34
|
+
callback.call(thisArg, value, key, this);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
test("budget: merging a two-row chunk does not grow with the existing 1k or 100k rows", () => {
|
|
39
|
+
const costs = [1000, 100_000].map((size) => {
|
|
40
|
+
const rows = new MeasuredMap(Array.from({ length: size }, (_, index) => [String(index), row(String(index), index, 1)]));
|
|
41
|
+
const before = { rows };
|
|
42
|
+
const lens = table({
|
|
43
|
+
name: "rows",
|
|
44
|
+
schema: rowSchema,
|
|
45
|
+
rows: (patch) => patch.rows,
|
|
46
|
+
key: (item) => item.id,
|
|
47
|
+
get: (tables) => tables.rows,
|
|
48
|
+
set: (_tables, next) => ({ rows: next }),
|
|
49
|
+
});
|
|
50
|
+
const engine = createSync({
|
|
51
|
+
tables: [lens],
|
|
52
|
+
empty: { rows: new Map() },
|
|
53
|
+
onlineOnly: () => false,
|
|
54
|
+
refused: (_command, reason) => ({ kind: "failed", reason }),
|
|
55
|
+
});
|
|
56
|
+
const replica = {
|
|
57
|
+
dataset: "dataset-a",
|
|
58
|
+
revision: 1,
|
|
59
|
+
tables: before,
|
|
60
|
+
server: { build: "fixture-v1" },
|
|
61
|
+
history: {
|
|
62
|
+
kind: "partial",
|
|
63
|
+
next: "older",
|
|
64
|
+
through: "0",
|
|
65
|
+
held: size,
|
|
66
|
+
total: size + 2,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const [model] = engine.update(engine.initial("page-a", 0), {
|
|
70
|
+
type: "loaded",
|
|
71
|
+
replica,
|
|
72
|
+
commands: [],
|
|
73
|
+
unreadable: 0,
|
|
74
|
+
now: 0,
|
|
75
|
+
});
|
|
76
|
+
const reply = {
|
|
77
|
+
dataset: "dataset-a",
|
|
78
|
+
revision: 2,
|
|
79
|
+
reset: false,
|
|
80
|
+
rows: { rows: [row("new-a", -1, 2), row("new-b", -2, 2)] },
|
|
81
|
+
server: { build: "fixture-v1" },
|
|
82
|
+
page: { from: "older", next: null, through: null, held: size + 2, total: size + 2 },
|
|
83
|
+
};
|
|
84
|
+
rows.visits = 0;
|
|
85
|
+
const [after] = engine.update(model, {
|
|
86
|
+
type: "pulled",
|
|
87
|
+
since: 1,
|
|
88
|
+
patch: reply,
|
|
89
|
+
receipts: [],
|
|
90
|
+
now: 1,
|
|
91
|
+
});
|
|
92
|
+
const cost = rows.visits;
|
|
93
|
+
expect(after.replica.tables.rows.size).toBe(size + 2);
|
|
94
|
+
expect(after.replica.tables.rows.get("new-a")).toStrictEqual(row("new-a", -1, 2));
|
|
95
|
+
expect(after.replica.tables.rows.get("new-b")).toStrictEqual(row("new-b", -2, 2));
|
|
96
|
+
expect(before.rows.has("new-a")).toBe(false);
|
|
97
|
+
expect(before.rows.get("0")).toStrictEqual(row("0", 0, 1));
|
|
98
|
+
return cost;
|
|
99
|
+
});
|
|
100
|
+
expect(costs[1]).toBe(costs[0]);
|
|
101
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Effect, Model, Patch, Replica } from "../sync.ts";
|
|
2
|
+
import type { Command, Outcome, PatchRows, Row, Server, Tables } from "./fixtures.ts";
|
|
3
|
+
/** Wire and checkpoint vocabulary shared by the conformance inputs. */
|
|
4
|
+
export type Page = {
|
|
5
|
+
from: string | null;
|
|
6
|
+
next: string | null;
|
|
7
|
+
through: string | null;
|
|
8
|
+
held: number;
|
|
9
|
+
total: number;
|
|
10
|
+
};
|
|
11
|
+
export type History = {
|
|
12
|
+
kind: "complete";
|
|
13
|
+
} | {
|
|
14
|
+
kind: "partial";
|
|
15
|
+
next: string;
|
|
16
|
+
through: string | null;
|
|
17
|
+
held: number;
|
|
18
|
+
total: number;
|
|
19
|
+
};
|
|
20
|
+
export type PagedCopy = Replica<Tables, Server> & {
|
|
21
|
+
history: History;
|
|
22
|
+
};
|
|
23
|
+
export type PagedPatch = Patch<PatchRows, Server> & {
|
|
24
|
+
page: Page;
|
|
25
|
+
};
|
|
26
|
+
export type M = Model<Tables, Server, Command, Outcome>;
|
|
27
|
+
export type E = Effect<Tables, Server, Command, Outcome>;
|
|
28
|
+
export declare const complete: {
|
|
29
|
+
readonly kind: "complete";
|
|
30
|
+
};
|
|
31
|
+
export declare const newest: {
|
|
32
|
+
deleted: boolean;
|
|
33
|
+
id: string;
|
|
34
|
+
revision: number;
|
|
35
|
+
value: number;
|
|
36
|
+
}[];
|
|
37
|
+
export declare const middle: {
|
|
38
|
+
deleted: boolean;
|
|
39
|
+
id: string;
|
|
40
|
+
revision: number;
|
|
41
|
+
value: number;
|
|
42
|
+
}[];
|
|
43
|
+
export declare const oldest: {
|
|
44
|
+
deleted: boolean;
|
|
45
|
+
id: string;
|
|
46
|
+
revision: number;
|
|
47
|
+
value: number;
|
|
48
|
+
}[];
|
|
49
|
+
export declare const all: {
|
|
50
|
+
deleted: boolean;
|
|
51
|
+
id: string;
|
|
52
|
+
revision: number;
|
|
53
|
+
value: number;
|
|
54
|
+
}[];
|
|
55
|
+
export declare const firstPage: Page;
|
|
56
|
+
export declare const secondPage: Page;
|
|
57
|
+
export declare const lastPage: Page;
|
|
58
|
+
export declare function paged(revision: number, rows: Row[], page: Page, reset?: boolean, dataset?: string): PagedPatch;
|
|
59
|
+
export declare function partialCopy(revision?: number, rows?: {
|
|
60
|
+
deleted: boolean;
|
|
61
|
+
id: string;
|
|
62
|
+
revision: number;
|
|
63
|
+
value: number;
|
|
64
|
+
}[], page?: Page): PagedCopy;
|
|
65
|
+
export declare function history(replica: Replica<Tables, Server>): History | undefined;
|
|
66
|
+
export declare function requestedPage(request: {
|
|
67
|
+
client: string;
|
|
68
|
+
since: number;
|
|
69
|
+
}): string | null | undefined;
|
|
70
|
+
export declare function loaded(replica?: Replica<Tables, Server>): M;
|
|
71
|
+
export declare function deliver(model: M, reply: PagedPatch | Patch<PatchRows, Server>, since?: number): readonly [M, readonly E[]];
|
|
72
|
+
/** A tiny independent history: only the authoritative rows, indexed by revision. No client merge or digest. */
|
|
73
|
+
export declare class Reference {
|
|
74
|
+
private readonly revisions;
|
|
75
|
+
constructor(revision: number, rows: readonly Row[]);
|
|
76
|
+
write(revision: number, changes: readonly Row[]): void;
|
|
77
|
+
at(revision: number): ReadonlyMap<string, Row>;
|
|
78
|
+
}
|
|
79
|
+
/** Every held row must be current; the declared covered range must be fully present. */
|
|
80
|
+
export declare function assertCopy(model: M, reference: Reference): void;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
import { check, literal, nullable, number, object, optional, string, variant } from "../schema.js";
|
|
3
|
+
import { copy, patch, row, sync } from "./fixtures.js";
|
|
4
|
+
export const complete = { kind: "complete" };
|
|
5
|
+
export const newest = [row("f", 60, 10), row("e", 50, 10)];
|
|
6
|
+
export const middle = [row("d", 40, 10), row("c", 30, 10)];
|
|
7
|
+
export const oldest = [row("b", 20, 10), row("a", 10, 10)];
|
|
8
|
+
export const all = [...newest, ...middle, ...oldest];
|
|
9
|
+
export const firstPage = { from: null, next: "older:e:50", through: "50", held: 2, total: 6 };
|
|
10
|
+
export const secondPage = {
|
|
11
|
+
from: "older:e:50",
|
|
12
|
+
next: "older:c:30",
|
|
13
|
+
through: "30",
|
|
14
|
+
held: 4,
|
|
15
|
+
total: 6,
|
|
16
|
+
};
|
|
17
|
+
export const lastPage = { from: "older:c:30", next: null, through: null, held: 6, total: 6 };
|
|
18
|
+
export function paged(revision, rows, page, reset = false, dataset = "dataset-a") {
|
|
19
|
+
return { ...patch(revision, rows, reset, dataset), page };
|
|
20
|
+
}
|
|
21
|
+
export function partialCopy(revision = 10, rows = newest, page = firstPage) {
|
|
22
|
+
if (page.next === null) {
|
|
23
|
+
return { ...copy(revision, rows), history: complete };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
...copy(revision, rows),
|
|
27
|
+
history: {
|
|
28
|
+
kind: "partial",
|
|
29
|
+
next: page.next,
|
|
30
|
+
through: page.through,
|
|
31
|
+
held: page.held,
|
|
32
|
+
total: page.total,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const historySchema = variant("kind", {
|
|
37
|
+
complete: object({ kind: literal("complete") }),
|
|
38
|
+
partial: object({
|
|
39
|
+
kind: literal("partial"),
|
|
40
|
+
next: string(),
|
|
41
|
+
through: nullable(string()),
|
|
42
|
+
held: number({ integer: true, min: 0 }),
|
|
43
|
+
total: number({ integer: true, min: 0 }),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
export function history(replica) {
|
|
47
|
+
if (!("history" in replica)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const parsed = check(historySchema, replica.history);
|
|
51
|
+
if (!parsed.ok) {
|
|
52
|
+
throw new Error("invalid history shape");
|
|
53
|
+
}
|
|
54
|
+
return parsed.value;
|
|
55
|
+
}
|
|
56
|
+
export function requestedPage(request) {
|
|
57
|
+
const parsed = check(optional(nullable(string())), "page" in request ? request.page : undefined);
|
|
58
|
+
if (!parsed.ok) {
|
|
59
|
+
throw new Error("invalid request page");
|
|
60
|
+
}
|
|
61
|
+
return parsed.value;
|
|
62
|
+
}
|
|
63
|
+
const emptyCopy = { ...copy(0, []), history: complete };
|
|
64
|
+
export function loaded(replica = emptyCopy) {
|
|
65
|
+
return sync.update(sync.initial("page-a", 0), {
|
|
66
|
+
type: "loaded",
|
|
67
|
+
replica,
|
|
68
|
+
commands: [],
|
|
69
|
+
unreadable: 0,
|
|
70
|
+
now: 0,
|
|
71
|
+
})[0];
|
|
72
|
+
}
|
|
73
|
+
export function deliver(model, reply, since = model.replica.revision) {
|
|
74
|
+
return sync.update(model, {
|
|
75
|
+
type: "pulled",
|
|
76
|
+
since,
|
|
77
|
+
patch: reply,
|
|
78
|
+
receipts: [],
|
|
79
|
+
now: model.now + 1,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** A tiny independent history: only the authoritative rows, indexed by revision. No client merge or digest. */
|
|
83
|
+
export class Reference {
|
|
84
|
+
revisions = new Map();
|
|
85
|
+
constructor(revision, rows) {
|
|
86
|
+
this.revisions.set(revision, new Map(rows.flatMap((item) => (item.deleted ? [] : [[item.id, item]]))));
|
|
87
|
+
}
|
|
88
|
+
write(revision, changes) {
|
|
89
|
+
const previous = [...this.revisions.values()].at(-1);
|
|
90
|
+
const next = new Map(previous);
|
|
91
|
+
for (const item of changes) {
|
|
92
|
+
if (item.deleted) {
|
|
93
|
+
next.delete(item.id);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
next.set(item.id, item);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.revisions.set(revision, next);
|
|
100
|
+
}
|
|
101
|
+
at(revision) {
|
|
102
|
+
const rows = this.revisions.get(revision);
|
|
103
|
+
if (rows === undefined) {
|
|
104
|
+
throw new Error(`no reference revision ${revision}`);
|
|
105
|
+
}
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Every held row must be current; the declared covered range must be fully present. */
|
|
110
|
+
export function assertCopy(model, reference) {
|
|
111
|
+
const expected = reference.at(model.replica.revision);
|
|
112
|
+
for (const [id, item] of model.replica.tables.rows) {
|
|
113
|
+
expect(item, `held row ${id} at ${model.replica.revision}`).toStrictEqual(expected.get(String(id)));
|
|
114
|
+
}
|
|
115
|
+
const claim = history(model.replica);
|
|
116
|
+
expect(claim).toBeDefined();
|
|
117
|
+
if (claim === undefined) {
|
|
118
|
+
throw new Error("missing history");
|
|
119
|
+
}
|
|
120
|
+
const kind = claim.kind;
|
|
121
|
+
const through = claim.kind === "partial" ? claim.through : null;
|
|
122
|
+
const covered = [...expected.values()].filter((item) => kind === "complete" || (through !== null && item.value >= Number(through)));
|
|
123
|
+
for (const item of covered) {
|
|
124
|
+
expect(model.replica.tables.rows.get(item.id)).toStrictEqual(item);
|
|
125
|
+
}
|
|
126
|
+
if (kind === "complete") {
|
|
127
|
+
expect(model.replica.tables.rows.size).toBe(expected.size);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
expect(kind).toBe("partial");
|
|
131
|
+
expect(claim.held).toBeGreaterThanOrEqual(covered.length);
|
|
132
|
+
expect(claim.held).toBeLessThanOrEqual(expected.size);
|
|
133
|
+
expect(claim.total).toBe(expected.size);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createClient } from "../client.js";
|
|
3
|
+
import { Network as SeededNetwork } from "../test/network.js";
|
|
4
|
+
import { copy, database, deferred, patch, row, sync, values } from "./fixtures.js";
|
|
5
|
+
import { all, assertCopy, complete, firstPage, history, lastPage, middle, newest, paged, Reference, requestedPage, secondPage, } from "./paging-fixtures.js";
|
|
6
|
+
// Named histories replay fixed server answers through the actual executor and seeded overlapping transport.
|
|
7
|
+
const histories = [
|
|
8
|
+
{ name: "lost-first-copy", seed: 73, drop: 0.2, duplicate: 0.3 },
|
|
9
|
+
{ name: "overlapping-retries", seed: 991, drop: 0.1, duplicate: 0.7 },
|
|
10
|
+
];
|
|
11
|
+
describe("paged first copy over the seeded network", () => {
|
|
12
|
+
test.each(histories)("1, 2, 5, 12: $name converges across dropped and duplicate requests and a reload", async (scenario) => {
|
|
13
|
+
const transport = new SeededNetwork(scenario.seed);
|
|
14
|
+
transport.faults = { drop: scenario.drop, duplicate: scenario.duplicate, maxDelay: 200 };
|
|
15
|
+
const stored = database();
|
|
16
|
+
const reloadStep = 50 + Math.floor(transport.random() * 650);
|
|
17
|
+
const reference = new Reference(10, all);
|
|
18
|
+
const changes = [
|
|
19
|
+
row("f", 65, 11),
|
|
20
|
+
row("a", 5, 11),
|
|
21
|
+
row("new", 70, 11),
|
|
22
|
+
row("b", 20, 11, true),
|
|
23
|
+
];
|
|
24
|
+
reference.write(11, changes);
|
|
25
|
+
const snapshots = [];
|
|
26
|
+
const writes = new Set();
|
|
27
|
+
const requests = [];
|
|
28
|
+
const network = {
|
|
29
|
+
pull: async (request) => {
|
|
30
|
+
requests.push(request);
|
|
31
|
+
const response = await transport.request(async () => {
|
|
32
|
+
const cursor = requestedPage(request);
|
|
33
|
+
const reply = request.since === 0
|
|
34
|
+
? paged(10, newest, firstPage, true)
|
|
35
|
+
: cursor === firstPage.next
|
|
36
|
+
? paged(11, [...changes, ...middle], { ...secondPage, held: 5, total: 6 })
|
|
37
|
+
: cursor === secondPage.next
|
|
38
|
+
? paged(11, [row("a", 5, 11)], { ...lastPage, held: 6, total: 6 })
|
|
39
|
+
: patch(11, request.since === 10 ? changes : []);
|
|
40
|
+
return { patch: reply, receipts: [] };
|
|
41
|
+
}, 1000);
|
|
42
|
+
return response.ok
|
|
43
|
+
? { ok: true, value: response.value }
|
|
44
|
+
: { ok: false, failure: { kind: "offline", message: "seeded delivery lost" } };
|
|
45
|
+
},
|
|
46
|
+
push: async () => ({ ok: false, failure: { kind: "offline", message: "no commands" } }),
|
|
47
|
+
};
|
|
48
|
+
function page(name) {
|
|
49
|
+
const booted = deferred();
|
|
50
|
+
const original = stored.port.commit;
|
|
51
|
+
const app = createClient({
|
|
52
|
+
sync,
|
|
53
|
+
client: name,
|
|
54
|
+
render: (model) => {
|
|
55
|
+
snapshots.push(model);
|
|
56
|
+
if (model.boot.kind === "ready") {
|
|
57
|
+
booted.resolve();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
ports: {
|
|
61
|
+
storage: {
|
|
62
|
+
...stored.port,
|
|
63
|
+
commit: (value) => {
|
|
64
|
+
const work = original(value);
|
|
65
|
+
writes.add(work);
|
|
66
|
+
void work
|
|
67
|
+
.finally(() => {
|
|
68
|
+
writes.delete(work);
|
|
69
|
+
})
|
|
70
|
+
.catch(() => undefined);
|
|
71
|
+
return work;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
network,
|
|
75
|
+
now: () => transport.now,
|
|
76
|
+
fingerprint: async () => "fixture-fingerprint",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return { app, booted };
|
|
80
|
+
}
|
|
81
|
+
let { app, booted } = page("page-a");
|
|
82
|
+
app.start();
|
|
83
|
+
await booted.promise;
|
|
84
|
+
const abandoned = [];
|
|
85
|
+
for (let step = 0; step < 3500; step++) {
|
|
86
|
+
if (step === 750) {
|
|
87
|
+
transport.faults = { drop: 0, duplicate: 0, maxDelay: 10 };
|
|
88
|
+
}
|
|
89
|
+
if (step === reloadStep) {
|
|
90
|
+
abandoned.push(app);
|
|
91
|
+
// The old page is no longer visible; already-sent answers may still land.
|
|
92
|
+
app.dispatch({ type: "visible", visible: false, now: transport.now });
|
|
93
|
+
({ app, booted } = page("page-b"));
|
|
94
|
+
app.start();
|
|
95
|
+
await booted.promise;
|
|
96
|
+
}
|
|
97
|
+
app.dispatch({ type: "tick", now: transport.now });
|
|
98
|
+
await transport.advance(20);
|
|
99
|
+
await Promise.allSettled(writes);
|
|
100
|
+
}
|
|
101
|
+
// No more ticks: finish any already-started request and commit, not a wall-clock delay.
|
|
102
|
+
await transport.advance(2000);
|
|
103
|
+
await Promise.allSettled(writes);
|
|
104
|
+
await Promise.all([app, ...abandoned].map((client) => client.settle()));
|
|
105
|
+
expect(transport.errors).toStrictEqual([]);
|
|
106
|
+
for (const model of snapshots) {
|
|
107
|
+
if (model.replica.dataset !== null && model.replica.revision > 0) {
|
|
108
|
+
assertCopy(model, reference);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
expect(values(app.model().replica)).toStrictEqual(values(copy(11, [
|
|
112
|
+
...newest.filter((item) => item.id !== "f"),
|
|
113
|
+
row("f", 65, 11),
|
|
114
|
+
...middle,
|
|
115
|
+
row("a", 5, 11),
|
|
116
|
+
row("new", 70, 11),
|
|
117
|
+
])));
|
|
118
|
+
expect(history(app.model().replica)).toStrictEqual(complete);
|
|
119
|
+
expect(history((await stored.port.load()).replica)).toStrictEqual(complete);
|
|
120
|
+
expect(requests.some((request) => requestedPage(request) === firstPage.next)).toBe(true);
|
|
121
|
+
expect(app.faults).toStrictEqual([]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { MEANINGS, states } from "../states.js";
|
|
3
|
+
import * as syncExports from "../sync.js";
|
|
4
|
+
import { STORAGE_DEADLINE_MS } from "../sync.js";
|
|
5
|
+
import { copy } from "./fixtures.js";
|
|
6
|
+
import { all, complete, loaded, newest, partialCopy } from "./paging-fixtures.js";
|
|
7
|
+
const working = {
|
|
8
|
+
meaning: "working",
|
|
9
|
+
where: "main",
|
|
10
|
+
claim: false,
|
|
11
|
+
why: "the copy is loading",
|
|
12
|
+
text: () => "loading copy",
|
|
13
|
+
action: null,
|
|
14
|
+
};
|
|
15
|
+
const warning = {
|
|
16
|
+
meaning: "ours",
|
|
17
|
+
where: "main",
|
|
18
|
+
claim: true,
|
|
19
|
+
why: "the durable copy needs attention",
|
|
20
|
+
text: () => "copy needs attention",
|
|
21
|
+
action: null,
|
|
22
|
+
};
|
|
23
|
+
const historyTable = {
|
|
24
|
+
loading: working,
|
|
25
|
+
none: {
|
|
26
|
+
meaning: "none",
|
|
27
|
+
where: "main",
|
|
28
|
+
claim: false,
|
|
29
|
+
why: "no server copy is held",
|
|
30
|
+
text: () => "no copy yet",
|
|
31
|
+
action: null,
|
|
32
|
+
},
|
|
33
|
+
complete: {
|
|
34
|
+
meaning: "ok",
|
|
35
|
+
where: "panel",
|
|
36
|
+
claim: false,
|
|
37
|
+
why: "every server row is held",
|
|
38
|
+
text: () => "complete history",
|
|
39
|
+
action: null,
|
|
40
|
+
},
|
|
41
|
+
partial: {
|
|
42
|
+
meaning: "partial",
|
|
43
|
+
where: "main",
|
|
44
|
+
claim: true,
|
|
45
|
+
why: "the numbers cover only a fully loaded range",
|
|
46
|
+
text: (state) => state.through === null
|
|
47
|
+
? `loading history · ${state.held} of ${state.total}`
|
|
48
|
+
: `history from ${state.through}, loading older · ${state.held} of ${state.total}`,
|
|
49
|
+
action: null,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const durabilityTable = {
|
|
53
|
+
loading: working,
|
|
54
|
+
unsaved: { ...warning, text: () => "not saved yet" },
|
|
55
|
+
lost: { ...warning, text: (state) => `not saved: ${state.reason}` },
|
|
56
|
+
failing: { ...warning, text: (state) => `storage failing: ${state.reason}` },
|
|
57
|
+
replaced: { ...warning, text: () => "reload this copy" },
|
|
58
|
+
unreadable: { ...warning, text: (state) => `${state.count} unreadable commands kept` },
|
|
59
|
+
ok: {
|
|
60
|
+
meaning: "ok",
|
|
61
|
+
where: "panel",
|
|
62
|
+
claim: false,
|
|
63
|
+
why: "the observed copy is kept",
|
|
64
|
+
text: () => "saved",
|
|
65
|
+
action: null,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
function typeContract() {
|
|
69
|
+
const { partial, ...withoutPartial } = historyTable;
|
|
70
|
+
// @ts-expect-error the public history union requires partial to be placed
|
|
71
|
+
const incomplete = withoutPartial;
|
|
72
|
+
const { lost, ...withoutLost } = durabilityTable;
|
|
73
|
+
// @ts-expect-error the independent durability union requires lost to be placed
|
|
74
|
+
const missingStorage = withoutLost;
|
|
75
|
+
void partial;
|
|
76
|
+
void lost;
|
|
77
|
+
void incomplete;
|
|
78
|
+
void missingStorage;
|
|
79
|
+
}
|
|
80
|
+
void typeContract;
|
|
81
|
+
function api() {
|
|
82
|
+
const exported = syncExports;
|
|
83
|
+
return exported;
|
|
84
|
+
}
|
|
85
|
+
function deriveHistory(model) {
|
|
86
|
+
const derive = api().deriveHistoryState;
|
|
87
|
+
expect(derive, "sync exports deriveHistoryState(model)").toBeTypeOf("function");
|
|
88
|
+
if (derive === undefined) {
|
|
89
|
+
throw new Error("missing deriveHistoryState");
|
|
90
|
+
}
|
|
91
|
+
return derive(model);
|
|
92
|
+
}
|
|
93
|
+
function deriveDurability(model) {
|
|
94
|
+
const derive = api().deriveDurabilityState;
|
|
95
|
+
expect(derive, "sync exports deriveDurabilityState(model)").toBeTypeOf("function");
|
|
96
|
+
if (derive === undefined) {
|
|
97
|
+
throw new Error("missing deriveDurabilityState");
|
|
98
|
+
}
|
|
99
|
+
return derive(model);
|
|
100
|
+
}
|
|
101
|
+
test("6, 10: history is its own closed subject, including no fully covered order group yet", () => {
|
|
102
|
+
const model = loaded(partialCopy());
|
|
103
|
+
const state = deriveHistory(model);
|
|
104
|
+
expect(state).toStrictEqual({ kind: "partial", through: "50", held: 2, total: 6 });
|
|
105
|
+
const display = states({ order: MEANINGS });
|
|
106
|
+
const subject = display.subject("history", historyTable);
|
|
107
|
+
const line = subject.line(state);
|
|
108
|
+
expect(line).toMatchObject({
|
|
109
|
+
where: "main",
|
|
110
|
+
claim: true,
|
|
111
|
+
glyph: "◑",
|
|
112
|
+
text: "history from 50, loading older · 2 of 6",
|
|
113
|
+
});
|
|
114
|
+
const unbounded = deriveHistory(loaded(partialCopy(10, newest, { from: null, next: "tie", through: null, held: 2, total: 6 })));
|
|
115
|
+
expect(subject.line(unbounded).text).toBe("loading history · 2 of 6");
|
|
116
|
+
const whole = { ...copy(10, all), history: complete };
|
|
117
|
+
expect(deriveHistory(loaded(whole))).toStrictEqual({ kind: "complete" });
|
|
118
|
+
expect(deriveHistory({ ...model, boot: { kind: "loading", since: 0 } })).toStrictEqual({
|
|
119
|
+
kind: "loading",
|
|
120
|
+
});
|
|
121
|
+
expect(deriveHistory({ ...model, replica: { ...whole, dataset: null } })).toStrictEqual({
|
|
122
|
+
kind: "none",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
const trouble = (base) => [
|
|
126
|
+
{ model: { ...base, storage: { kind: "replaced" } }, state: { kind: "replaced" } },
|
|
127
|
+
{
|
|
128
|
+
model: { ...base, storage: { kind: "failed", reason: "quota" } },
|
|
129
|
+
state: { kind: "failing", reason: "quota" },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
model: {
|
|
133
|
+
...base,
|
|
134
|
+
storage: { kind: "failed", reason: "quota" },
|
|
135
|
+
saved: { ...base.saved, behindSince: 0 },
|
|
136
|
+
},
|
|
137
|
+
state: { kind: "lost", reason: "quota" },
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
model: { ...base, now: STORAGE_DEADLINE_MS, saved: { ...base.saved, behindSince: 0 } },
|
|
141
|
+
state: { kind: "unsaved" },
|
|
142
|
+
},
|
|
143
|
+
{ model: { ...base, unreadable: 2 }, state: { kind: "unreadable", count: 2 } },
|
|
144
|
+
];
|
|
145
|
+
test("durability retains the storage catalog independently of completeness", () => {
|
|
146
|
+
const whole = { ...copy(10, all), history: complete };
|
|
147
|
+
const base = loaded(whole);
|
|
148
|
+
for (const item of trouble(base)) {
|
|
149
|
+
expect(deriveDurability(item.model)).toStrictEqual(item.state);
|
|
150
|
+
}
|
|
151
|
+
expect(deriveDurability({ ...base, boot: { kind: "loading", since: 0 } })).toStrictEqual({
|
|
152
|
+
kind: "loading",
|
|
153
|
+
});
|
|
154
|
+
expect(deriveDurability(base)).toStrictEqual({ kind: "ok" });
|
|
155
|
+
});
|
|
156
|
+
test("partial history and every storage problem survive composition as independent claims", () => {
|
|
157
|
+
const display = states({ order: MEANINGS });
|
|
158
|
+
const historySubject = display.subject("history", historyTable);
|
|
159
|
+
const durabilitySubject = display.subject("durability", durabilityTable);
|
|
160
|
+
const base = loaded(partialCopy());
|
|
161
|
+
for (const item of trouble(base)) {
|
|
162
|
+
const historyState = deriveHistory(item.model);
|
|
163
|
+
const durability = deriveDurability(item.model);
|
|
164
|
+
expect(historyState).toStrictEqual({ kind: "partial", through: "50", held: 2, total: 6 });
|
|
165
|
+
expect(durability).toStrictEqual(item.state);
|
|
166
|
+
const historyLine = historySubject.line(historyState);
|
|
167
|
+
const storageLine = durabilitySubject.line(durability);
|
|
168
|
+
for (const parts of [
|
|
169
|
+
[historyLine, storageLine],
|
|
170
|
+
[storageLine, historyLine],
|
|
171
|
+
]) {
|
|
172
|
+
const screen = display.compose(parts, "synced");
|
|
173
|
+
expect(screen.ok).toBe(false);
|
|
174
|
+
expect(screen.claims).toContain(historyLine);
|
|
175
|
+
expect(screen.claims).toContain(storageLine);
|
|
176
|
+
expect(screen.more).not.toContain(historyLine);
|
|
177
|
+
expect(screen.more).not.toContain(storageLine);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|