@hitslop/runtime 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/dist/json-persister.d.ts +18 -2
- package/dist/json-persister.js +87 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,7 +14,16 @@ ready();
|
|
|
14
14
|
Use `@hitslop/runtime/adapter` only when implementing a framework adapter; it
|
|
15
15
|
exports the shared JSON persister and safe media helpers.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
`JsonPersister` batches idle writes for 150 ms, with a one-second maximum wait.
|
|
18
|
+
Adapters supply detached snapshots and validate values at their I/O boundaries.
|
|
19
|
+
`flush()` bypasses scheduling and drains pending writes; errors retain their
|
|
20
|
+
original identity and code. Register adapter flushers with `registerFlush` so
|
|
21
|
+
the host can await state that has not reached the bridge yet. Keep that
|
|
22
|
+
registration until teardown's final save succeeds.
|
|
23
|
+
The persister's `onError` callback receives `Error | null`; derive display text
|
|
24
|
+
from the error's `message` rather than replacing the error object.
|
|
25
|
+
|
|
26
|
+
Documentation: [Architecture](https://github.com/hitslop/hitslop/blob/master/docs/architecture.md) ·
|
|
27
|
+
[Storage](https://github.com/hitslop/hitslop/blob/master/docs/storage.md)
|
|
19
28
|
|
|
20
29
|
MIT © 2026 hitSlop contributors.
|
package/dist/json-persister.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export type JsonSnapshot<T> = {
|
|
|
3
3
|
value: T;
|
|
4
4
|
};
|
|
5
5
|
type Source = "package" | "app" | "external";
|
|
6
|
+
type Scheduler = {
|
|
7
|
+
setTimeout: (callback: () => void, delay: number) => unknown;
|
|
8
|
+
clearTimeout: (handle: unknown) => void;
|
|
9
|
+
};
|
|
6
10
|
type Options<T> = {
|
|
7
11
|
fallback: JsonSnapshot<T>;
|
|
8
12
|
io: {
|
|
@@ -22,11 +26,12 @@ type Options<T> = {
|
|
|
22
26
|
onAdopt: (value: T, source: Source) => void;
|
|
23
27
|
onRevision: (revision: string | null) => void;
|
|
24
28
|
onSource: (source: Source) => void;
|
|
25
|
-
onError: (
|
|
29
|
+
onError: (error: Error | null) => void;
|
|
26
30
|
onStatus?: (state: {
|
|
27
31
|
isDirty: boolean;
|
|
28
32
|
isSaving: boolean;
|
|
29
33
|
}) => void;
|
|
34
|
+
scheduler?: Scheduler;
|
|
30
35
|
};
|
|
31
36
|
export declare class JsonPersister<T> {
|
|
32
37
|
private options;
|
|
@@ -40,16 +45,27 @@ export declare class JsonPersister<T> {
|
|
|
40
45
|
private draining;
|
|
41
46
|
private operations;
|
|
42
47
|
private failure;
|
|
48
|
+
private invalid;
|
|
49
|
+
private lastLocalJson;
|
|
50
|
+
private flushing;
|
|
51
|
+
private debounce;
|
|
52
|
+
private maximumWait;
|
|
53
|
+
private readonly scheduler;
|
|
43
54
|
private status;
|
|
44
55
|
flush(): Promise<void>;
|
|
56
|
+
private flushNow;
|
|
45
57
|
constructor(options: Options<T>);
|
|
46
58
|
localChanged(json: string, value: T): void;
|
|
59
|
+
/** An unrepresentable local value must block older queued snapshots too. */
|
|
60
|
+
localInvalid(error: Error): void;
|
|
47
61
|
reload(): Promise<void>;
|
|
48
62
|
externalChanged(eventRevision?: string | null): void;
|
|
63
|
+
private cancelTimers;
|
|
64
|
+
private scheduleDrain;
|
|
49
65
|
private requestDrain;
|
|
50
66
|
private drainLoop;
|
|
51
67
|
private adopt;
|
|
52
68
|
private schedule;
|
|
53
|
-
private
|
|
69
|
+
private asError;
|
|
54
70
|
}
|
|
55
71
|
export {};
|
package/dist/json-persister.js
CHANGED
|
@@ -10,16 +10,31 @@ export class JsonPersister {
|
|
|
10
10
|
draining = null;
|
|
11
11
|
operations = Promise.resolve(undefined);
|
|
12
12
|
failure = null;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
invalid = null;
|
|
14
|
+
lastLocalJson;
|
|
15
|
+
flushing = null;
|
|
16
|
+
debounce;
|
|
17
|
+
maximumWait;
|
|
18
|
+
scheduler;
|
|
19
|
+
status() { this.options.onStatus?.({ isDirty: this.pending !== null || this.writing || this.invalid !== null, isSaving: this.writing }); }
|
|
20
|
+
flush() {
|
|
21
|
+
if (this.flushing)
|
|
22
|
+
return this.flushing;
|
|
23
|
+
this.cancelTimers();
|
|
24
|
+
this.flushing = this.flushNow().finally(() => { this.flushing = null; });
|
|
25
|
+
return this.flushing;
|
|
26
|
+
}
|
|
27
|
+
async flushNow() {
|
|
15
28
|
const retry = this.stopped;
|
|
16
29
|
await this.operations;
|
|
30
|
+
if (this.invalid)
|
|
31
|
+
throw this.invalid;
|
|
17
32
|
if (this.stopped && !retry && this.failure)
|
|
18
33
|
throw this.failure;
|
|
19
34
|
if (!this.loaded) {
|
|
20
35
|
if (!this.pending)
|
|
21
36
|
return;
|
|
22
|
-
throw new Error("Document data has not loaded");
|
|
37
|
+
throw this.failure ?? new Error("Document data has not loaded");
|
|
23
38
|
}
|
|
24
39
|
this.stopped = false;
|
|
25
40
|
this.failure = null;
|
|
@@ -32,13 +47,24 @@ export class JsonPersister {
|
|
|
32
47
|
constructor(options) {
|
|
33
48
|
this.options = options;
|
|
34
49
|
this.lastPersistedJson = options.fallback.json;
|
|
50
|
+
this.lastLocalJson = options.fallback.json;
|
|
51
|
+
this.scheduler = options.scheduler ?? {
|
|
52
|
+
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
|
53
|
+
clearTimeout: handle => clearTimeout(handle),
|
|
54
|
+
};
|
|
35
55
|
}
|
|
36
56
|
localChanged(json, value) {
|
|
57
|
+
if (!this.invalid && json === this.lastLocalJson)
|
|
58
|
+
return;
|
|
37
59
|
this.localVersion += 1;
|
|
60
|
+
this.lastLocalJson = json;
|
|
61
|
+
this.invalid = null;
|
|
62
|
+
this.failure = null;
|
|
38
63
|
// A write already in flight may change what is persisted, so a reversion to
|
|
39
64
|
// the previously persisted value still has to remain queued until it lands.
|
|
40
65
|
if (!this.writing && json === this.lastPersistedJson) {
|
|
41
66
|
this.pending = null;
|
|
67
|
+
this.cancelTimers();
|
|
42
68
|
this.stopped = false;
|
|
43
69
|
this.options.onError(null);
|
|
44
70
|
this.status();
|
|
@@ -46,8 +72,20 @@ export class JsonPersister {
|
|
|
46
72
|
}
|
|
47
73
|
this.pending = { json, value };
|
|
48
74
|
this.stopped = false;
|
|
75
|
+
this.options.onError(null);
|
|
76
|
+
this.status();
|
|
77
|
+
this.scheduleDrain();
|
|
78
|
+
}
|
|
79
|
+
/** An unrepresentable local value must block older queued snapshots too. */
|
|
80
|
+
localInvalid(error) {
|
|
81
|
+
this.localVersion += 1;
|
|
82
|
+
this.invalid = error;
|
|
83
|
+
this.failure = error;
|
|
84
|
+
this.pending = null;
|
|
85
|
+
this.stopped = true;
|
|
86
|
+
this.cancelTimers();
|
|
87
|
+
this.options.onError(error);
|
|
49
88
|
this.status();
|
|
50
|
-
this.requestDrain();
|
|
51
89
|
}
|
|
52
90
|
reload() {
|
|
53
91
|
const wasLoaded = this.loaded;
|
|
@@ -57,6 +95,9 @@ export class JsonPersister {
|
|
|
57
95
|
if (wasLoaded) {
|
|
58
96
|
this.pending = null;
|
|
59
97
|
this.stopped = false;
|
|
98
|
+
this.invalid = null;
|
|
99
|
+
this.failure = null;
|
|
100
|
+
this.cancelTimers();
|
|
60
101
|
}
|
|
61
102
|
return this.schedule(async () => {
|
|
62
103
|
try {
|
|
@@ -68,25 +109,31 @@ export class JsonPersister {
|
|
|
68
109
|
this.revision = result.revision;
|
|
69
110
|
this.lastPersistedJson = persistedJson;
|
|
70
111
|
this.options.onRevision(result.revision);
|
|
71
|
-
const local = this.options.getLocal();
|
|
72
112
|
const changedDuringReload = this.localVersion !== requestedAtVersion;
|
|
113
|
+
const local = !wasLoaded || changedDuringReload ? this.options.getLocal() : { json: persistedJson, value: result.value };
|
|
73
114
|
const localWins = wasLoaded
|
|
74
115
|
? changedDuringReload && local.json !== persistedJson
|
|
75
116
|
: local.json !== this.options.fallback.json;
|
|
117
|
+
// A write that was already running can fail after reload was requested.
|
|
118
|
+
// A successful read still recovers the store from that stopped state.
|
|
119
|
+
this.stopped = false;
|
|
120
|
+
this.failure = null;
|
|
121
|
+
this.invalid = null;
|
|
76
122
|
if (localWins) {
|
|
77
123
|
this.pending = local;
|
|
78
|
-
this.
|
|
124
|
+
this.scheduleDrain();
|
|
79
125
|
}
|
|
80
126
|
else {
|
|
81
127
|
this.pending = null;
|
|
128
|
+
this.cancelTimers();
|
|
82
129
|
this.adopt(result.value, result.revision, wasLoaded ? "external" : "package");
|
|
83
130
|
}
|
|
84
131
|
this.options.onError(null);
|
|
85
132
|
this.status();
|
|
86
133
|
}
|
|
87
134
|
catch (error) {
|
|
88
|
-
|
|
89
|
-
this.options.onError(
|
|
135
|
+
this.failure = this.asError(error);
|
|
136
|
+
this.options.onError(this.failure);
|
|
90
137
|
throw error;
|
|
91
138
|
}
|
|
92
139
|
});
|
|
@@ -108,10 +155,30 @@ export class JsonPersister {
|
|
|
108
155
|
this.options.onError(null);
|
|
109
156
|
}
|
|
110
157
|
catch (error) {
|
|
111
|
-
this.options.onError(this.
|
|
158
|
+
this.options.onError(this.asError(error));
|
|
112
159
|
}
|
|
113
160
|
});
|
|
114
161
|
}
|
|
162
|
+
cancelTimers() {
|
|
163
|
+
if (this.debounce !== undefined)
|
|
164
|
+
this.scheduler.clearTimeout(this.debounce);
|
|
165
|
+
if (this.maximumWait !== undefined)
|
|
166
|
+
this.scheduler.clearTimeout(this.maximumWait);
|
|
167
|
+
this.debounce = this.maximumWait = undefined;
|
|
168
|
+
}
|
|
169
|
+
scheduleDrain() {
|
|
170
|
+
if (!this.loaded || this.stopped || !this.pending || this.draining)
|
|
171
|
+
return;
|
|
172
|
+
if (this.flushing) {
|
|
173
|
+
this.requestDrain();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const drain = () => { this.cancelTimers(); this.requestDrain(); };
|
|
177
|
+
if (this.debounce !== undefined)
|
|
178
|
+
this.scheduler.clearTimeout(this.debounce);
|
|
179
|
+
this.debounce = this.scheduler.setTimeout(drain, 150);
|
|
180
|
+
this.maximumWait ??= this.scheduler.setTimeout(drain, 1_000);
|
|
181
|
+
}
|
|
115
182
|
requestDrain() {
|
|
116
183
|
if (this.draining || !this.loaded || this.stopped || !this.pending)
|
|
117
184
|
return;
|
|
@@ -142,8 +209,10 @@ export class JsonPersister {
|
|
|
142
209
|
this.lastPersistedJson = snapshot.json;
|
|
143
210
|
this.options.onRevision(result.revision);
|
|
144
211
|
this.options.onSource("app");
|
|
145
|
-
this.
|
|
146
|
-
|
|
212
|
+
if (!this.invalid) {
|
|
213
|
+
this.options.onError(null);
|
|
214
|
+
this.failure = null;
|
|
215
|
+
}
|
|
147
216
|
conflicts = 0;
|
|
148
217
|
}
|
|
149
218
|
catch (error) {
|
|
@@ -159,8 +228,8 @@ export class JsonPersister {
|
|
|
159
228
|
catch (readError) {
|
|
160
229
|
this.pending ??= snapshot;
|
|
161
230
|
this.stopped = true;
|
|
162
|
-
this.failure =
|
|
163
|
-
this.options.onError(this.
|
|
231
|
+
this.failure = this.asError(readError);
|
|
232
|
+
this.options.onError(this.failure);
|
|
164
233
|
continue;
|
|
165
234
|
}
|
|
166
235
|
}
|
|
@@ -168,8 +237,8 @@ export class JsonPersister {
|
|
|
168
237
|
// and re-arms persistence without losing any fields.
|
|
169
238
|
this.pending ??= snapshot;
|
|
170
239
|
this.stopped = true;
|
|
171
|
-
this.failure =
|
|
172
|
-
this.options.onError(this.
|
|
240
|
+
this.failure = this.asError(error);
|
|
241
|
+
this.options.onError(this.failure);
|
|
173
242
|
}
|
|
174
243
|
}
|
|
175
244
|
}
|
|
@@ -181,6 +250,7 @@ export class JsonPersister {
|
|
|
181
250
|
adopt(value, revision, source) {
|
|
182
251
|
this.revision = revision;
|
|
183
252
|
this.lastPersistedJson = JSON.stringify(value);
|
|
253
|
+
this.lastLocalJson = this.lastPersistedJson;
|
|
184
254
|
this.options.onRevision(revision);
|
|
185
255
|
this.options.onAdopt(value, source);
|
|
186
256
|
}
|
|
@@ -189,7 +259,7 @@ export class JsonPersister {
|
|
|
189
259
|
this.operations = scheduled.catch(() => undefined);
|
|
190
260
|
return scheduled;
|
|
191
261
|
}
|
|
192
|
-
|
|
193
|
-
return error instanceof Error ? error
|
|
262
|
+
asError(error) {
|
|
263
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
194
264
|
}
|
|
195
265
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hitslop/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Framework-neutral browser bridge for hitSlop documents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/hitslop/hitslop.git", "directory": "packages/runtime" },
|
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
"check": "tsc -p tsconfig.json",
|
|
21
21
|
"test": "bun test"
|
|
22
22
|
},
|
|
23
|
-
"dependencies": { "@hitslop/schema": "^0.
|
|
23
|
+
"dependencies": { "@hitslop/schema": "^0.3.0" },
|
|
24
24
|
"devDependencies": { "typescript": "^7.0.2" }
|
|
25
25
|
}
|