@hitslop/svelte 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
@@ -37,7 +37,34 @@ validates initial, loaded, externally changed, and outgoing values using the
37
37
  TypeBox interpreter; it does not coerce, insert defaults, strip unknown fields,
38
38
  or write browser storage.
39
39
 
40
- See the [Svelte authoring examples](https://github.com/hitslop/hitslop/tree/main/examples/slops).
40
+ Once loaded, JSON edits save after 150 ms without another change, or after one
41
+ second of continuous editing. Only one write runs at a time; edits during a write
42
+ coalesce into the newest follow-up snapshot. `await document.flush()` captures
43
+ current state immediately, bypasses the delay, and waits for pending writes.
44
+ Concurrent flushes share the same drain. Schema validation runs at I/O boundaries,
45
+ so invalid edits remain in memory and are never written.
46
+
47
+ `isDirty` stays true while changes are waiting, saving, or failed; `isSaving` is
48
+ true during a write. `error` contains the display message and `errorCode` contains
49
+ the `SlopError` code when available, including `validation_failed`,
50
+ `revision_conflict`, and `storage_error`. A failed save retains the latest edits.
51
+ Call `flush()` to retry, or make a new edit to resume automatic saving. Repeated
52
+ observations of an identical value do not retry a failed save.
53
+
54
+ External file changes load while the store is clean. While dirty, local edits
55
+ win: a revision conflict reads the new revision and retries the latest full
56
+ local snapshot once. This does not merge another writer's fields. Another
57
+ conflict stops saving and surfaces an error. After loading, explicit `reload()`
58
+ discards edits made before the call and adopts the file; edits made during the
59
+ read survive. Retrying a failed initial load also preserves local edits.
60
+
61
+ Call `destroy()` on teardown to stop observation and immediately drain a detached
62
+ final snapshot. It is synchronous and safe to call twice. Pending or failed saves
63
+ stay registered with the runtime flush barrier until a flush succeeds. For a
64
+ user-initiated teardown where errors should prevent navigation, await `flush()`
65
+ before removing the component. A destroyed store cannot reload or resume editing.
66
+
67
+ See the [Svelte authoring examples](https://github.com/hitslop/hitslop/tree/master/examples/slops).
41
68
 
42
69
  MIT © 2026 hitSlop contributors.
43
70
 
@@ -10,6 +10,7 @@ export declare class JsonStore<S extends TSchema> {
10
10
  isDirty: boolean;
11
11
  isSaving: boolean;
12
12
  error: string | null;
13
+ errorCode: "invalid_request" | "unsupported" | "revision_conflict" | "validation_failed" | "storage_error" | "limit_exceeded" | "closed" | null;
13
14
  revision: string | null;
14
15
  lastChangeSource: string;
15
16
  private persister;
@@ -17,10 +18,15 @@ export declare class JsonStore<S extends TSchema> {
17
18
  private unwatch;
18
19
  private stopEffect;
19
20
  private unregisterFlush;
21
+ private destroyed;
22
+ private finalSnapshot;
23
+ private finalError;
20
24
  constructor(options: JsonStoreOptions<S>);
21
25
  reload(): Promise<void>;
22
26
  destroy(): void;
23
27
  flush(): Promise<void>;
28
+ private getLocal;
29
+ private setError;
24
30
  private parse;
25
31
  }
26
32
  export declare function jsonStore<S extends TSchema>(options: JsonStoreOptions<S>): JsonStore<S>;
@@ -1,13 +1,17 @@
1
- import { slop } from "@hitslop/runtime";
1
+ import { slop, SlopError } from "@hitslop/runtime";
2
2
  import { JsonPersister, registerFlush } from "@hitslop/runtime/adapter";
3
3
  import { untrack } from "svelte";
4
4
  import { validate } from "@hitslop/schema/validation";
5
5
  import { assertJSON } from "@hitslop/schema/json";
6
6
  const snapshot = (value) => {
7
- const detached = $state.snapshot(value);
8
- assertJSON(detached);
9
- const json = JSON.stringify(detached);
10
- return { json, value: JSON.parse(json) };
7
+ try {
8
+ const detached = $state.snapshot(value);
9
+ assertJSON(detached);
10
+ return { json: JSON.stringify(detached), value: detached };
11
+ }
12
+ catch (error) {
13
+ throw new SlopError("validation_failed", error instanceof Error ? error.message : String(error));
14
+ }
11
15
  };
12
16
  export class JsonStore {
13
17
  current = $state();
@@ -16,6 +20,7 @@ export class JsonStore {
16
20
  isDirty = $state(false);
17
21
  isSaving = $state(false);
18
22
  error = $state(null);
23
+ errorCode = $state(null);
19
24
  revision = $state(null);
20
25
  lastChangeSource = $state("package");
21
26
  persister;
@@ -23,6 +28,9 @@ export class JsonStore {
23
28
  unwatch = null;
24
29
  stopEffect = null;
25
30
  unregisterFlush = null;
31
+ destroyed = false;
32
+ finalSnapshot = null;
33
+ finalError = null;
26
34
  constructor(options) {
27
35
  this.schema = options.schema;
28
36
  const fallbackSnapshot = snapshot(this.parse(options.initial));
@@ -38,13 +46,18 @@ export class JsonStore {
38
46
  const result = await slop.json.read();
39
47
  return { ...result, value: this.parse(result.value) };
40
48
  },
41
- write: (value, revision) => slop.json.write(this.parse(value), revision),
49
+ write: (value, revision) => slop.json.write(this.parse(value, false), revision),
50
+ },
51
+ getLocal: () => this.getLocal(),
52
+ onAdopt: (value, source) => {
53
+ this.current = value;
54
+ this.lastChangeSource = source;
55
+ if (this.destroyed)
56
+ this.finalSnapshot = snapshot(value);
42
57
  },
43
- getLocal: () => snapshot(this.parse(this.current)),
44
- onAdopt: (value, source) => { this.current = value; this.lastChangeSource = source; },
45
58
  onRevision: (revision) => { this.revision = revision; },
46
59
  onSource: (source) => { this.lastChangeSource = source; },
47
- onError: (message) => { this.error = message; },
60
+ onError: (error) => this.setError(error),
48
61
  onStatus: ({ isDirty, isSaving }) => { this.isDirty = isDirty; this.isSaving = isSaving; },
49
62
  });
50
63
  // Snapshotting reads the complete proxy tree, so one effect run observes all
@@ -54,10 +67,10 @@ export class JsonStore {
54
67
  $effect(() => {
55
68
  let local;
56
69
  try {
57
- local = snapshot(this.parse(this.current));
70
+ local = snapshot(this.current);
58
71
  }
59
72
  catch (error) {
60
- untrack(() => { this.error = error instanceof Error ? error.message : String(error); });
73
+ untrack(() => this.persister.localInvalid(error));
61
74
  return;
62
75
  }
63
76
  untrack(() => this.persister.localChanged(local.json, local.value));
@@ -68,36 +81,77 @@ export class JsonStore {
68
81
  this.unwatch = slop.json.onChange((event) => this.persister.externalChanged(event.revision));
69
82
  }
70
83
  async reload() {
84
+ if (this.destroyed)
85
+ throw new SlopError("closed", "JSON store is destroyed");
71
86
  this.isLoading = true;
72
87
  try {
73
88
  await this.persister.reload();
74
89
  this.isReady = true;
75
90
  }
76
91
  catch (error) {
77
- this.error = error instanceof Error ? error.message : String(error);
92
+ this.setError(error);
78
93
  }
79
94
  finally {
80
95
  this.isLoading = false;
81
96
  }
82
97
  }
83
98
  destroy() {
84
- if (!this.isLoading)
85
- void this.flush().catch(() => undefined);
86
- this.unregisterFlush?.();
87
- this.unregisterFlush = null;
99
+ if (this.destroyed)
100
+ return;
101
+ try {
102
+ this.finalSnapshot = snapshot(this.current);
103
+ }
104
+ catch (error) {
105
+ this.finalError = error;
106
+ }
107
+ this.destroyed = true;
88
108
  this.unwatch?.();
89
109
  this.unwatch = null;
90
110
  this.stopEffect?.();
91
111
  this.stopEffect = null;
112
+ // Keep failed writes registered so the host barrier can report/retry them.
113
+ void this.flush().catch(() => undefined);
92
114
  }
93
115
  async flush() {
94
- const local = snapshot(this.parse(this.current));
95
- this.persister.localChanged(local.json, local.value);
96
- await this.persister.flush();
116
+ try {
117
+ let local;
118
+ try {
119
+ local = this.getLocal();
120
+ }
121
+ catch (error) {
122
+ this.persister.localInvalid(error);
123
+ throw error;
124
+ }
125
+ this.persister.localChanged(local.json, local.value);
126
+ await this.persister.flush();
127
+ if (this.destroyed) {
128
+ this.unregisterFlush?.();
129
+ this.unregisterFlush = null;
130
+ }
131
+ }
132
+ catch (error) {
133
+ this.setError(error);
134
+ throw error;
135
+ }
136
+ }
137
+ getLocal() {
138
+ if (this.finalError)
139
+ throw this.finalError;
140
+ return this.finalSnapshot ?? snapshot(this.current);
141
+ }
142
+ setError(error) {
143
+ this.error = error == null ? null : error instanceof Error ? error.message : String(error);
144
+ this.errorCode = error instanceof SlopError ? error.code : null;
97
145
  }
98
- parse(value) {
99
- assertJSON(value);
100
- return validate(this.schema, value);
146
+ parse(value, checkJSON = true) {
147
+ try {
148
+ if (checkJSON)
149
+ assertJSON(value);
150
+ return validate(this.schema, value);
151
+ }
152
+ catch (error) {
153
+ throw new SlopError("validation_failed", error instanceof Error ? error.message : String(error));
154
+ }
101
155
  }
102
156
  }
103
157
  export function jsonStore(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hitslop/svelte",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Svelte 5 state adapters for the hitSlop runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  "test": "bun run --cwd ../runtime build && bun test"
39
39
  },
40
40
  "dependencies": {
41
- "@hitslop/runtime": "^0.2.0",
42
- "@hitslop/schema": "^0.2.0",
41
+ "@hitslop/runtime": "^0.3.0",
42
+ "@hitslop/schema": "^0.3.0",
43
43
  "typebox": "^1.3.26"
44
44
  },
45
45
  "peerDependencies": {