slopkit 0.2.0 → 0.4.0-rc.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 +5 -3
- package/README.md +103 -1
- package/RULES.md +26 -6
- package/dist/api.d.ts +93 -0
- package/dist/api.js +111 -0
- package/dist/client.d.ts +21 -4
- package/dist/client.js +21 -2
- package/dist/conformance/agent-access.test.d.ts +1 -0
- package/dist/conformance/agent-access.test.js +74 -0
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +1 -0
- package/dist/conformance/fixtures.js +5 -1
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/paging-state.test.js +1 -0
- package/dist/conformance/push-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/conformance/states.test.js +19 -8
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/auth.d.ts +4 -3
- package/dist/server/auth.js +14 -6
- package/dist/server/cli.d.ts +117 -0
- package/dist/server/cli.js +1534 -0
- package/dist/server/config.d.ts +4 -1
- package/dist/server/config.js +2 -2
- package/dist/server/keys.d.ts +17 -0
- package/dist/server/keys.js +127 -0
- package/dist/server/pull.js +158 -67
- package/dist/server/store.d.ts +101 -0
- package/dist/server/store.js +347 -0
- package/dist/server/sync.d.ts +158 -0
- package/dist/server/sync.js +293 -0
- package/dist/server/web.d.ts +62 -9
- package/dist/server/web.js +260 -35
- package/dist/states.d.ts +52 -4
- package/dist/states.js +20 -0
- package/dist/sync.d.ts +86 -2
- package/dist/sync.js +277 -59
- package/dist/test/agents.d.ts +19 -0
- package/dist/test/agents.js +62 -0
- package/dist/test/page.d.ts +91 -0
- package/dist/test/page.js +763 -0
- package/dist/test/states.d.ts +44 -7
- package/dist/test/states.js +152 -0
- package/dist/web/replica.d.ts +7 -0
- package/dist/web/replica.js +260 -47
- package/dist/web/runtime.d.ts +102 -0
- package/dist/web/runtime.js +335 -0
- package/package.json +39 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's ports for the sync engine (`slopkit/client`), and nothing that decides: IndexedDB for the replica and the
|
|
3
|
+
* outbox, fetch for pull and push through the app's sync contract (`slopkit/api`), Web Crypto for the fingerprint, the
|
|
4
|
+
* poke socket with its backoff, a 1 s tick, visibility, lasting storage and telemetry. The engine is the same one a
|
|
5
|
+
* headless simulation runs; every browser global is a port (`env`), so this runs headless too.
|
|
6
|
+
*
|
|
7
|
+
* - **The socket only pokes:** what it hears is checked and becomes `heard`; it reconnects with backoff, and a page
|
|
8
|
+
* pulls whatever the socket says.
|
|
9
|
+
* - **Telemetry is bounded:** the newest 100 events wait for a flush (every 30 s and when the page hides); a flush the
|
|
10
|
+
* network lost puts back the newest 50.
|
|
11
|
+
* - **Updated under the page:** a reconciled pull from another server version reloads the page, once.
|
|
12
|
+
*/
|
|
13
|
+
import type { PullReply, SyncApi } from "../api.ts";
|
|
14
|
+
import type { Schema } from "../schema.ts";
|
|
15
|
+
import type { createSync, Lens, Model, Pending } from "../sync.ts";
|
|
16
|
+
type Attributes = Record<string, string | number | boolean | null>;
|
|
17
|
+
/** What the runtime needs of a WebSocket: it sets the handlers; a message carries `data`. */
|
|
18
|
+
export type SocketLike = {
|
|
19
|
+
onopen: (() => void) | null;
|
|
20
|
+
onmessage: ((event: {
|
|
21
|
+
data: unknown;
|
|
22
|
+
}) => void) | null;
|
|
23
|
+
onclose: (() => void) | null;
|
|
24
|
+
close: () => void;
|
|
25
|
+
};
|
|
26
|
+
/** The browser, as ports: each defaults to the page's own global. */
|
|
27
|
+
export type RuntimeEnv = {
|
|
28
|
+
indexedDB: IDBFactory;
|
|
29
|
+
fetch: typeof fetch;
|
|
30
|
+
socket: (url: string) => SocketLike;
|
|
31
|
+
location: {
|
|
32
|
+
protocol: string;
|
|
33
|
+
host: string;
|
|
34
|
+
reload: () => void;
|
|
35
|
+
};
|
|
36
|
+
document: {
|
|
37
|
+
readonly visibilityState: string;
|
|
38
|
+
addEventListener: (type: "visibilitychange", listener: () => void) => void;
|
|
39
|
+
removeEventListener: (type: "visibilitychange", listener: () => void) => void;
|
|
40
|
+
};
|
|
41
|
+
/** `navigator.storage.persist`: whether the browser grants this copy lasting storage. */
|
|
42
|
+
persist: () => Promise<boolean>;
|
|
43
|
+
now: () => number;
|
|
44
|
+
/** `performance.now`: for durations. */
|
|
45
|
+
monotonic: () => number;
|
|
46
|
+
/** Run once after `ms`; the returned function cancels it. */
|
|
47
|
+
setTimeout: (run: () => void, ms: number) => () => void;
|
|
48
|
+
/** Run every `ms`; the returned function stops it. */
|
|
49
|
+
setInterval: (run: () => void, ms: number) => () => void;
|
|
50
|
+
random: (count: number) => Uint8Array;
|
|
51
|
+
};
|
|
52
|
+
export declare function createRuntime<T, R extends Record<string, {
|
|
53
|
+
id: string | number;
|
|
54
|
+
}>, S extends {
|
|
55
|
+
version: string;
|
|
56
|
+
}, C, O>(options: {
|
|
57
|
+
/** The IndexedDB database's name. */
|
|
58
|
+
name: string;
|
|
59
|
+
sync: ReturnType<typeof createSync<T, PullReply<R, S, O>, S, C, O>>;
|
|
60
|
+
api: SyncApi<R, S, C, O>;
|
|
61
|
+
replica: {
|
|
62
|
+
version: number;
|
|
63
|
+
tables: readonly Lens<T, PullReply<R, S, O>>[];
|
|
64
|
+
empty: T;
|
|
65
|
+
/** The outbox's command schema; default the api's. */
|
|
66
|
+
pending?: Schema<Pending<C, O>>;
|
|
67
|
+
legacy?: {
|
|
68
|
+
store: string;
|
|
69
|
+
convert: (value: unknown) => Pending<C, O> | undefined;
|
|
70
|
+
};
|
|
71
|
+
/** By table: an earlier release's row as this one reads it, so an upgrade keeps the offline copy (`web/replica`). */
|
|
72
|
+
lifts?: Readonly<Record<string, (row: unknown) => unknown>>;
|
|
73
|
+
};
|
|
74
|
+
/** How long a push may take before it counts as lost (default 30 s). */
|
|
75
|
+
pushTimeoutMs?: number;
|
|
76
|
+
/** The app's hook when a command is answered (to open or close a provider's window, say). */
|
|
77
|
+
answered?: (pending: Pending<C, O>) => void;
|
|
78
|
+
/** The attributes of the first model that counts as painted, else undefined. Default: once the copy loaded. */
|
|
79
|
+
painted?: (model: Model<T, S, C, O>) => Attributes | undefined;
|
|
80
|
+
env?: Partial<RuntimeEnv>;
|
|
81
|
+
}): {
|
|
82
|
+
client: string;
|
|
83
|
+
model: () => Model<T, S, C, O>;
|
|
84
|
+
subscribe: (listener: (model: Model<T, S, C, O>) => void) => (() => void);
|
|
85
|
+
/** Load the copy (it paints first; the engine pulls once loaded), open the socket, start the clocks. */
|
|
86
|
+
start: () => void;
|
|
87
|
+
/** Close the socket for good, clear every timer and listener: nothing dispatches on its own after it. */
|
|
88
|
+
stop: () => void;
|
|
89
|
+
command: (command: C) => void;
|
|
90
|
+
/** The person is done with a finished command: it leaves the page and this device's outbox. */
|
|
91
|
+
dismiss: (command: {
|
|
92
|
+
client: string;
|
|
93
|
+
id: number;
|
|
94
|
+
}) => void;
|
|
95
|
+
/** Drop this device's copy (the server keeps everything) and start over. */
|
|
96
|
+
forget: () => Promise<void>;
|
|
97
|
+
record: (name: string, attributes?: Attributes) => void;
|
|
98
|
+
flush: () => Promise<void>;
|
|
99
|
+
/** Resolves once no effect is running and no forget is under way. */
|
|
100
|
+
settle: () => Promise<void>;
|
|
101
|
+
};
|
|
102
|
+
export {};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createClient } from "../client.js";
|
|
2
|
+
import { call } from "../contract.js";
|
|
3
|
+
import { digest } from "../digest.js";
|
|
4
|
+
import { check } from "../schema.js";
|
|
5
|
+
import { indexedReplica } from "./replica.js";
|
|
6
|
+
/** The browser's WebSocket as a `SocketLike`. */
|
|
7
|
+
function webSocket(url) {
|
|
8
|
+
const socket = new WebSocket(url);
|
|
9
|
+
const like = {
|
|
10
|
+
onopen: null,
|
|
11
|
+
onmessage: null,
|
|
12
|
+
onclose: null,
|
|
13
|
+
close: () => socket.close(),
|
|
14
|
+
};
|
|
15
|
+
socket.addEventListener("open", () => like.onopen?.());
|
|
16
|
+
socket.addEventListener("message", (event) => like.onmessage?.({ data: event.data }));
|
|
17
|
+
socket.addEventListener("close", () => like.onclose?.());
|
|
18
|
+
return like;
|
|
19
|
+
}
|
|
20
|
+
function browser() {
|
|
21
|
+
return {
|
|
22
|
+
indexedDB: globalThis.indexedDB,
|
|
23
|
+
fetch: (input, init) => globalThis.fetch(input, init),
|
|
24
|
+
socket: webSocket,
|
|
25
|
+
location: globalThis.location,
|
|
26
|
+
document: globalThis.document,
|
|
27
|
+
persist: async () => (await globalThis.navigator.storage?.persist?.()) ?? false,
|
|
28
|
+
now: () => Date.now(),
|
|
29
|
+
monotonic: () => performance.now(),
|
|
30
|
+
setTimeout: (run, ms) => {
|
|
31
|
+
const handle = globalThis.setTimeout(run, ms);
|
|
32
|
+
return () => globalThis.clearTimeout(handle);
|
|
33
|
+
},
|
|
34
|
+
setInterval: (run, ms) => {
|
|
35
|
+
const handle = globalThis.setInterval(run, ms);
|
|
36
|
+
return () => globalThis.clearInterval(handle);
|
|
37
|
+
},
|
|
38
|
+
random: (count) => crypto.getRandomValues(new Uint8Array(count)),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const base64url = (bytes) => btoa(String.fromCodePoint(...bytes))
|
|
42
|
+
.replaceAll("+", "-")
|
|
43
|
+
.replaceAll("/", "_")
|
|
44
|
+
.replaceAll("=", "");
|
|
45
|
+
/** The longest the socket waits before trying again, and the base it doubles from. */
|
|
46
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
47
|
+
const BACKOFF_MS = 1000;
|
|
48
|
+
const EVENTS_KEPT = 100;
|
|
49
|
+
const EVENTS_PUT_BACK = 50;
|
|
50
|
+
const FLUSH_MS = 30_000;
|
|
51
|
+
const TICK_MS = 1000;
|
|
52
|
+
export function createRuntime(options) {
|
|
53
|
+
const env = { ...browser(), ...options.env };
|
|
54
|
+
const { api } = options;
|
|
55
|
+
const created = env.monotonic();
|
|
56
|
+
const client = base64url(env.random(18));
|
|
57
|
+
const origin = `${env.location.protocol}//${env.location.host}`;
|
|
58
|
+
const lenses = api.rows.map((key) => {
|
|
59
|
+
const lens = options.replica.tables.find((each) => each.name === key);
|
|
60
|
+
if (lens === undefined) {
|
|
61
|
+
throw new Error(`createRuntime: no table for the row key ${key}`);
|
|
62
|
+
}
|
|
63
|
+
return lens;
|
|
64
|
+
});
|
|
65
|
+
const storage = indexedReplica({
|
|
66
|
+
factory: env.indexedDB,
|
|
67
|
+
name: options.name,
|
|
68
|
+
version: options.replica.version,
|
|
69
|
+
tables: options.replica.tables,
|
|
70
|
+
empty: options.replica.empty,
|
|
71
|
+
server: api.server,
|
|
72
|
+
pending: options.replica.pending ?? api.pending,
|
|
73
|
+
...(options.replica.legacy === undefined ? {} : { legacy: options.replica.legacy }),
|
|
74
|
+
...(options.replica.lifts === undefined ? {} : { lifts: options.replica.lifts }),
|
|
75
|
+
});
|
|
76
|
+
const events = [];
|
|
77
|
+
function record(name, attributes) {
|
|
78
|
+
events.push({
|
|
79
|
+
name,
|
|
80
|
+
at: new Date(env.now()).toISOString(),
|
|
81
|
+
...(attributes === undefined ? {} : { attributes }),
|
|
82
|
+
});
|
|
83
|
+
if (events.length > EVENTS_KEPT) {
|
|
84
|
+
events.splice(0, events.length - EVENTS_KEPT);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function flush() {
|
|
88
|
+
if (events.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const batch = events.splice(0);
|
|
92
|
+
const sent = await call(api.telemetry, { client, events: batch }, { fetch: env.fetch, base: origin });
|
|
93
|
+
// Kept for the next try unless the server took them or refused them for good.
|
|
94
|
+
if (!sent.ok && (sent.failure.kind === "offline" || sent.failure.kind === "unavailable")) {
|
|
95
|
+
events.unshift(...batch.slice(-EVENTS_PUT_BACK));
|
|
96
|
+
// Still the newest 100 at most: one call carries them all.
|
|
97
|
+
events.splice(0, Math.max(0, events.length - EVENTS_KEPT));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the browser grants this copy lasting storage, asked once at creation. The copy never waits for it: a
|
|
102
|
+
* browser that asks the person may not have answered by the time the copy loads.
|
|
103
|
+
*/
|
|
104
|
+
const persisted = (async () => {
|
|
105
|
+
let granted = false;
|
|
106
|
+
try {
|
|
107
|
+
granted = await env.persist();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not granted: the copy may be cleared, and the page says so.
|
|
111
|
+
}
|
|
112
|
+
record("storage.persist", { granted });
|
|
113
|
+
return granted;
|
|
114
|
+
})();
|
|
115
|
+
const listeners = new Set();
|
|
116
|
+
/** Stopped for good: nothing more leaves the page. */
|
|
117
|
+
let stopped = false;
|
|
118
|
+
/** The version this page first reconciled with: another means the server was updated under it. */
|
|
119
|
+
let pulledVersion;
|
|
120
|
+
let reloaded = false;
|
|
121
|
+
let painted = false;
|
|
122
|
+
const engine = createClient({
|
|
123
|
+
sync: options.sync,
|
|
124
|
+
client,
|
|
125
|
+
ports: {
|
|
126
|
+
storage: {
|
|
127
|
+
...storage,
|
|
128
|
+
load: async () => {
|
|
129
|
+
const loaded = await storage.load();
|
|
130
|
+
// Its answer if it came while the copy loaded, else no grant yet.
|
|
131
|
+
const persistent = await Promise.race([persisted, Promise.resolve(false)]);
|
|
132
|
+
return { ...loaded, persistent };
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
network: {
|
|
136
|
+
pull: async (request) => {
|
|
137
|
+
// Stopped: nothing more leaves the page, whatever was under way when it stopped.
|
|
138
|
+
if (stopped) {
|
|
139
|
+
return { ok: false, failure: { kind: "offline", message: "the runtime stopped" } };
|
|
140
|
+
}
|
|
141
|
+
const result = await call(api.pull, {
|
|
142
|
+
client: request.client,
|
|
143
|
+
dataset: request.dataset,
|
|
144
|
+
since: request.since,
|
|
145
|
+
...(request.verify ? { verify: true } : {}),
|
|
146
|
+
// Pages (and the commands it waits on) only when the engine asks for them, else an old server's whole copy.
|
|
147
|
+
...(request.page === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { page: request.page, commands: request.commands ?? [] }),
|
|
150
|
+
}, { fetch: env.fetch, base: origin });
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
const reply = result.value;
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
value: {
|
|
158
|
+
patch: {
|
|
159
|
+
dataset: reply.dataset,
|
|
160
|
+
revision: reply.revision,
|
|
161
|
+
reset: reply.reset,
|
|
162
|
+
rows: reply,
|
|
163
|
+
server: reply.server,
|
|
164
|
+
...(reply.checksum === undefined ? {} : { checksum: reply.checksum }),
|
|
165
|
+
...(reply.page === undefined ? {} : { page: reply.page }),
|
|
166
|
+
},
|
|
167
|
+
receipts: reply.commands,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
push: async (request) => {
|
|
172
|
+
if (stopped) {
|
|
173
|
+
return { ok: false, failure: { kind: "offline", message: "the runtime stopped" } };
|
|
174
|
+
}
|
|
175
|
+
const result = await call(api.push, request, {
|
|
176
|
+
fetch: env.fetch,
|
|
177
|
+
base: origin,
|
|
178
|
+
timeoutMs: options.pushTimeoutMs ?? 30_000,
|
|
179
|
+
});
|
|
180
|
+
return result.ok
|
|
181
|
+
? {
|
|
182
|
+
ok: true,
|
|
183
|
+
value: { receipts: result.value.results, revision: result.value.revision },
|
|
184
|
+
}
|
|
185
|
+
: result;
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
fingerprint: (replica) => digest(lenses.map((lens) => lens.snapshot(replica.tables).flatMap((op) =>
|
|
189
|
+
// SAFETY: a table's rows are contract rows, each with its `id` (R's rows extend { id }).
|
|
190
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion
|
|
191
|
+
"row" in op ? [op.row] : []))),
|
|
192
|
+
now: env.now,
|
|
193
|
+
...(options.answered === undefined ? {} : { answered: options.answered }),
|
|
194
|
+
telemetry: record,
|
|
195
|
+
},
|
|
196
|
+
render: (next) => {
|
|
197
|
+
for (const listener of listeners) {
|
|
198
|
+
listener(next);
|
|
199
|
+
}
|
|
200
|
+
if (!painted) {
|
|
201
|
+
const attributes = options.painted
|
|
202
|
+
? options.painted(next)
|
|
203
|
+
: next.boot.kind === "loading"
|
|
204
|
+
? undefined
|
|
205
|
+
: {};
|
|
206
|
+
if (attributes !== undefined) {
|
|
207
|
+
painted = true;
|
|
208
|
+
record("page.painted", { ...attributes, ms: Math.round(env.monotonic() - created) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const version = next.reconciledAt === null ? undefined : next.replica.server?.version;
|
|
212
|
+
if (version === undefined) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// The server was updated under this page: load its client (the service worker fetches the shell first).
|
|
216
|
+
if (pulledVersion !== undefined && pulledVersion !== version && !reloaded) {
|
|
217
|
+
reloaded = true;
|
|
218
|
+
env.location.reload();
|
|
219
|
+
}
|
|
220
|
+
pulledVersion ??= version;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
/** Forgets under way: `settle` waits for them too. */
|
|
224
|
+
const pending = new Set();
|
|
225
|
+
let started = false;
|
|
226
|
+
let socket;
|
|
227
|
+
let retry;
|
|
228
|
+
const intervals = [];
|
|
229
|
+
const visibility = () => {
|
|
230
|
+
const visible = env.document.visibilityState === "visible";
|
|
231
|
+
engine.dispatch({ type: "visible", visible, now: env.now() });
|
|
232
|
+
if (!visible) {
|
|
233
|
+
void flush().catch(() => undefined);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
/** The socket: a hint channel, reopened with backoff after consecutive tries that never opened. */
|
|
237
|
+
function connect(attempt) {
|
|
238
|
+
if (stopped) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const protocol = env.location.protocol === "https:" ? "wss" : "ws";
|
|
242
|
+
const opening = env.socket(`${protocol}://${env.location.host}${api.socket.path}`);
|
|
243
|
+
socket = opening;
|
|
244
|
+
let opened = false;
|
|
245
|
+
opening.onopen = () => {
|
|
246
|
+
opened = true;
|
|
247
|
+
engine.dispatch({ type: "socket", open: true, now: env.now() });
|
|
248
|
+
};
|
|
249
|
+
opening.onmessage = (event) => {
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = JSON.parse(String(event.data));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const message = check(api.socket.message, parsed);
|
|
258
|
+
if (message.ok) {
|
|
259
|
+
engine.dispatch({ type: "heard", revision: message.value.revision, now: env.now() });
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
opening.onclose = () => {
|
|
263
|
+
if (socket !== opening) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
socket = undefined;
|
|
267
|
+
engine.dispatch({ type: "socket", open: false, now: env.now() });
|
|
268
|
+
if (stopped) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const next = opened ? 0 : attempt + 1;
|
|
272
|
+
retry = env.setTimeout(() => {
|
|
273
|
+
retry = undefined;
|
|
274
|
+
connect(next);
|
|
275
|
+
}, Math.min(BACKOFF_MAX_MS, BACKOFF_MS * 2 ** Math.min(next, 5)));
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
client,
|
|
280
|
+
model: engine.model,
|
|
281
|
+
subscribe: (listener) => {
|
|
282
|
+
listeners.add(listener);
|
|
283
|
+
return () => {
|
|
284
|
+
listeners.delete(listener);
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
/** Load the copy (it paints first; the engine pulls once loaded), open the socket, start the clocks. */
|
|
288
|
+
start: () => {
|
|
289
|
+
if (started) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
started = true;
|
|
293
|
+
engine.start();
|
|
294
|
+
connect(0);
|
|
295
|
+
intervals.push(env.setInterval(() => engine.dispatch({ type: "tick", now: env.now() }), TICK_MS), env.setInterval(() => {
|
|
296
|
+
void flush().catch(() => undefined);
|
|
297
|
+
}, FLUSH_MS));
|
|
298
|
+
env.document.addEventListener("visibilitychange", visibility);
|
|
299
|
+
},
|
|
300
|
+
/** Close the socket for good, clear every timer and listener: nothing dispatches on its own after it. */
|
|
301
|
+
stop: () => {
|
|
302
|
+
stopped = true;
|
|
303
|
+
for (const cancel of intervals.splice(0)) {
|
|
304
|
+
cancel();
|
|
305
|
+
}
|
|
306
|
+
retry?.();
|
|
307
|
+
retry = undefined;
|
|
308
|
+
env.document.removeEventListener("visibilitychange", visibility);
|
|
309
|
+
const open = socket;
|
|
310
|
+
socket = undefined;
|
|
311
|
+
open?.close();
|
|
312
|
+
},
|
|
313
|
+
command: (command) => engine.command(command),
|
|
314
|
+
/** The person is done with a finished command: it leaves the page and this device's outbox. */
|
|
315
|
+
dismiss: (command) => engine.dispatch({ type: "dismiss", client: command.client, id: command.id, now: env.now() }),
|
|
316
|
+
/** Drop this device's copy (the server keeps everything) and start over. */
|
|
317
|
+
forget: () => {
|
|
318
|
+
const forgetting = storage.forget().then(() => env.location.reload());
|
|
319
|
+
pending.add(forgetting);
|
|
320
|
+
void forgetting
|
|
321
|
+
.finally(() => {
|
|
322
|
+
pending.delete(forgetting);
|
|
323
|
+
})
|
|
324
|
+
.catch(() => undefined);
|
|
325
|
+
return forgetting;
|
|
326
|
+
},
|
|
327
|
+
record,
|
|
328
|
+
flush,
|
|
329
|
+
/** Resolves once no effect is running and no forget is under way. */
|
|
330
|
+
settle: async () => {
|
|
331
|
+
await engine.settle();
|
|
332
|
+
await Promise.allSettled(pending);
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slopkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-rc.0",
|
|
4
4
|
"description": "Opinionated standards and libraries for fast, local-first personal software: state machines, exhaustive state tables, sync, a command ledger, jobs, a guarded server, and the conformance suite that proves them",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"conformance",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/contract.d.ts",
|
|
35
35
|
"default": "./dist/contract.js"
|
|
36
36
|
},
|
|
37
|
+
"./api": {
|
|
38
|
+
"types": "./dist/api.d.ts",
|
|
39
|
+
"default": "./dist/api.js"
|
|
40
|
+
},
|
|
37
41
|
"./sync": {
|
|
38
42
|
"types": "./dist/sync.d.ts",
|
|
39
43
|
"default": "./dist/sync.js"
|
|
@@ -74,6 +78,10 @@
|
|
|
74
78
|
"types": "./dist/web/idb.d.ts",
|
|
75
79
|
"default": "./dist/web/idb.js"
|
|
76
80
|
},
|
|
81
|
+
"./web/runtime": {
|
|
82
|
+
"types": "./dist/web/runtime.d.ts",
|
|
83
|
+
"default": "./dist/web/runtime.js"
|
|
84
|
+
},
|
|
77
85
|
"./server/ledger": {
|
|
78
86
|
"types": "./dist/server/ledger.d.ts",
|
|
79
87
|
"default": "./dist/server/ledger.js"
|
|
@@ -86,6 +94,14 @@
|
|
|
86
94
|
"types": "./dist/server/pull.d.ts",
|
|
87
95
|
"default": "./dist/server/pull.js"
|
|
88
96
|
},
|
|
97
|
+
"./server/store": {
|
|
98
|
+
"types": "./dist/server/store.d.ts",
|
|
99
|
+
"default": "./dist/server/store.js"
|
|
100
|
+
},
|
|
101
|
+
"./server/sync": {
|
|
102
|
+
"types": "./dist/server/sync.d.ts",
|
|
103
|
+
"default": "./dist/server/sync.js"
|
|
104
|
+
},
|
|
89
105
|
"./server/web": {
|
|
90
106
|
"types": "./dist/server/web.d.ts",
|
|
91
107
|
"default": "./dist/server/web.js"
|
|
@@ -94,6 +110,10 @@
|
|
|
94
110
|
"types": "./dist/server/auth.d.ts",
|
|
95
111
|
"default": "./dist/server/auth.js"
|
|
96
112
|
},
|
|
113
|
+
"./server/cli": {
|
|
114
|
+
"types": "./dist/server/cli.d.ts",
|
|
115
|
+
"default": "./dist/server/cli.js"
|
|
116
|
+
},
|
|
97
117
|
"./server/config": {
|
|
98
118
|
"types": "./dist/server/config.d.ts",
|
|
99
119
|
"default": "./dist/server/config.js"
|
|
@@ -102,10 +122,18 @@
|
|
|
102
122
|
"types": "./dist/server/log.d.ts",
|
|
103
123
|
"default": "./dist/server/log.js"
|
|
104
124
|
},
|
|
125
|
+
"./server/keys": {
|
|
126
|
+
"types": "./dist/server/keys.d.ts",
|
|
127
|
+
"default": "./dist/server/keys.js"
|
|
128
|
+
},
|
|
105
129
|
"./server/secrets": {
|
|
106
130
|
"types": "./dist/server/secrets.d.ts",
|
|
107
131
|
"default": "./dist/server/secrets.js"
|
|
108
132
|
},
|
|
133
|
+
"./test/agents": {
|
|
134
|
+
"types": "./dist/test/agents.d.ts",
|
|
135
|
+
"default": "./dist/test/agents.js"
|
|
136
|
+
},
|
|
109
137
|
"./test/network": {
|
|
110
138
|
"types": "./dist/test/network.d.ts",
|
|
111
139
|
"default": "./dist/test/network.js"
|
|
@@ -118,6 +146,10 @@
|
|
|
118
146
|
"types": "./dist/test/states.d.ts",
|
|
119
147
|
"default": "./dist/test/states.js"
|
|
120
148
|
},
|
|
149
|
+
"./test/page": {
|
|
150
|
+
"types": "./dist/test/page.d.ts",
|
|
151
|
+
"default": "./dist/test/page.js"
|
|
152
|
+
},
|
|
121
153
|
"./conformance/fixtures": {
|
|
122
154
|
"types": "./dist/conformance/fixtures.d.ts",
|
|
123
155
|
"default": "./dist/conformance/fixtures.js"
|
|
@@ -145,22 +177,28 @@
|
|
|
145
177
|
"@types/ws": "8.18.2",
|
|
146
178
|
"esbuild": "0.28.2",
|
|
147
179
|
"fake-indexeddb": "6.2.6",
|
|
180
|
+
"happy-dom": "21.0.4",
|
|
148
181
|
"knip": "6.42.1",
|
|
149
182
|
"oxfmt": "0.71.0",
|
|
150
183
|
"oxlint": "1.86.0",
|
|
151
184
|
"oxlint-tsgolint": "7.0.2004",
|
|
185
|
+
"preact": "11.0.1",
|
|
152
186
|
"typescript": "^7.0.2",
|
|
153
187
|
"ultracite": "8.0.1",
|
|
154
188
|
"vitest": "4.1.11"
|
|
155
189
|
},
|
|
156
190
|
"peerDependencies": {
|
|
157
191
|
"fake-indexeddb": "^6.2.6",
|
|
192
|
+
"happy-dom": "^21.0.4",
|
|
158
193
|
"vitest": "^4.1.11"
|
|
159
194
|
},
|
|
160
195
|
"peerDependenciesMeta": {
|
|
161
196
|
"fake-indexeddb": {
|
|
162
197
|
"optional": true
|
|
163
198
|
},
|
|
199
|
+
"happy-dom": {
|
|
200
|
+
"optional": true
|
|
201
|
+
},
|
|
164
202
|
"vitest": {
|
|
165
203
|
"optional": true
|
|
166
204
|
}
|