@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 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
- Documentation: [Architecture](https://github.com/hitslop/hitslop/blob/main/docs/architecture.md) ·
18
- [Storage](https://github.com/hitslop/hitslop/blob/main/docs/storage.md)
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.
@@ -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: (message: string | null) => void;
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 message;
69
+ private asError;
54
70
  }
55
71
  export {};
@@ -10,16 +10,31 @@ export class JsonPersister {
10
10
  draining = null;
11
11
  operations = Promise.resolve(undefined);
12
12
  failure = null;
13
- status() { this.options.onStatus?.({ isDirty: this.pending !== null || this.writing, isSaving: this.writing }); }
14
- async flush() {
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.requestDrain();
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
- const message = this.message(error);
89
- this.options.onError(message);
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.message(error));
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.options.onError(null);
146
- this.failure = null;
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 = new Error(this.message(readError));
163
- this.options.onError(this.message(readError));
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 = new Error(this.message(error));
172
- this.options.onError(this.message(error));
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
- message(error) {
193
- return error instanceof Error ? error.message : String(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.2.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.2.0" },
23
+ "dependencies": { "@hitslop/schema": "^0.3.0" },
24
24
  "devDependencies": { "typescript": "^7.0.2" }
25
25
  }