@hitslop/svelte 0.1.2 → 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 +104 -5
- package/dist/ExportTarget.svelte +22 -0
- package/dist/ExportTarget.svelte.d.ts +7 -0
- package/dist/IconTarget.svelte +24 -0
- package/dist/IconTarget.svelte.d.ts +7 -0
- package/dist/file-store.svelte.d.ts +3 -14
- package/dist/file-store.svelte.js +4 -72
- package/dist/image-store.svelte.d.ts +3 -14
- package/dist/image-store.svelte.js +4 -75
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/json-store.svelte.d.ts +20 -27
- package/dist/json-store.svelte.js +91 -23
- package/dist/media-store.svelte.d.ts +25 -0
- package/dist/media-store.svelte.js +80 -0
- package/dist/sqlite-query.svelte.d.ts +1 -0
- package/dist/sqlite-query.svelte.js +6 -10
- package/package.json +31 -13
package/README.md
CHANGED
|
@@ -1,29 +1,128 @@
|
|
|
1
1
|
# @hitslop/svelte
|
|
2
2
|
|
|
3
|
+
Packaging uses this workspace's TypeScript 6 compiler API; checks use TypeScript
|
|
4
|
+
7 through `@typescript/native`, as does the root toolchain. The Bun patch for
|
|
5
|
+
`@sveltejs/package` resolves TypeScript from the project being packaged instead
|
|
6
|
+
of the packager's install directory. Keep the packager version pinned while the
|
|
7
|
+
patch is needed, and recheck this resolution when upgrading it.
|
|
8
|
+
|
|
3
9
|
Svelte 5 state adapters for JSON, SQLite, images, and named files in a hitSlop
|
|
4
10
|
document.
|
|
5
11
|
|
|
6
12
|
```svelte
|
|
7
13
|
<script lang="ts">
|
|
8
14
|
import { jsonStore } from "@hitslop/svelte";
|
|
15
|
+
import { ready } from "@hitslop/runtime";
|
|
16
|
+
import { onDestroy } from "svelte";
|
|
9
17
|
import counterSchema from "../schema";
|
|
10
18
|
|
|
11
19
|
const document = jsonStore({
|
|
12
20
|
schema: counterSchema,
|
|
13
21
|
initial: { count: 0 },
|
|
14
22
|
});
|
|
23
|
+
$effect(() => { if (document.isReady) ready(); });
|
|
24
|
+
onDestroy(() => document.destroy());
|
|
15
25
|
</script>
|
|
16
26
|
|
|
17
|
-
<button onclick={() => document.current.count += 1}>
|
|
27
|
+
<button disabled={!document.isReady || document.isLoading} onclick={() => document.current.count += 1}>
|
|
18
28
|
{document.current.count}
|
|
19
29
|
</button>
|
|
20
30
|
```
|
|
21
31
|
|
|
22
32
|
Exports: `jsonStore`, `sqliteQuery`, `imageStore`, `fileStore`, and their
|
|
23
|
-
class/type counterparts. JSON stores
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
class/type counterparts. JSON stores accept the default export of root TypeBox
|
|
34
|
+
`schema.ts` directly, with data types inferred from that schema. No preparation
|
|
35
|
+
or generated files are needed for editor types or typechecking. The adapter
|
|
36
|
+
validates initial, loaded, externally changed, and outgoing values using the
|
|
37
|
+
TypeBox interpreter; it does not coerce, insert defaults, strip unknown fields,
|
|
38
|
+
or write browser storage.
|
|
39
|
+
|
|
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.
|
|
26
66
|
|
|
27
|
-
See the [Svelte authoring examples](https://github.com/hitslop/hitslop/tree/
|
|
67
|
+
See the [Svelte authoring examples](https://github.com/hitslop/hitslop/tree/master/examples/slops).
|
|
28
68
|
|
|
29
69
|
MIT © 2026 hitSlop contributors.
|
|
70
|
+
|
|
71
|
+
## Capture views
|
|
72
|
+
|
|
73
|
+
Svelte is the supported authoring integration. Keep the interactive editor in
|
|
74
|
+
`App.svelte`; optionally provide `Icon.svelte` and `Export.svelte` as ordinary
|
|
75
|
+
presentation components. They share data and styles with the editor, not another
|
|
76
|
+
store or persistence model:
|
|
77
|
+
|
|
78
|
+
```svelte
|
|
79
|
+
<script lang="ts">
|
|
80
|
+
import { IconTarget, ExportTarget } from "@hitslop/svelte";
|
|
81
|
+
import Icon from "./Icon.svelte";
|
|
82
|
+
import Export from "./Export.svelte";
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<IconTarget><Icon completed={finished} total={tasks.length} /></IconTarget>
|
|
86
|
+
<ExportTarget><Export checklist={checklist.current} view={activeView} /></ExportTarget>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`IconTarget` mounts its children only in icon capture. It supplies a transparent
|
|
90
|
+
512×512 surface; supplying it opts into Finder icon refresh when a document
|
|
91
|
+
closes. The signed `QuickLook/Icon.png` is immutable; only Finder metadata changes.
|
|
92
|
+
Without an icon target, the existing static icon remains.
|
|
93
|
+
|
|
94
|
+
`ExportTarget` mounts its children for previews and PNG/PDF exports. Pass the
|
|
95
|
+
current selected view explicitly. Keep content in normal flow, with no fixed
|
|
96
|
+
viewport heights, nested scrolling, editing controls, or transient notices.
|
|
97
|
+
Share presentation components and theme variables to avoid visual drift.
|
|
98
|
+
`Export.svelte` is optional: without a target, the existing app is captured with
|
|
99
|
+
`data-slop-capture="static"` and `data-slop-export="hide"` controls omitted.
|
|
100
|
+
|
|
101
|
+
Preview stays at the manifest viewport. Export uses the current window width
|
|
102
|
+
and full content height. Dedicated exports have their own rectangular content
|
|
103
|
+
surface rather than a stretched window mask. PNG is 2×, limited to 16,384 pixels
|
|
104
|
+
per side and 24 megapixels; use PDF for longer documents. PDF is one page sized
|
|
105
|
+
to the content, with selectable text. Very long PDFs combine WebKit's pages and
|
|
106
|
+
scale uniformly to a maximum 14,400-point page dimension, preserving all content
|
|
107
|
+
and vector sharpness within common PDF reader limits. Their physical page width
|
|
108
|
+
is scaled too; no content is clipped or converted to a bitmap.
|
|
109
|
+
|
|
110
|
+
The runtime waits for target mounting, used fonts, visible image decoding, and
|
|
111
|
+
stable geometry, with a ten-second timeout for each preparation/settling stage.
|
|
112
|
+
Motion is disabled during capture. Canvas, charts, CSS background images, or
|
|
113
|
+
virtualized lists can register extra work with
|
|
114
|
+
`capture.onPrepare(async (mode, signal) => { ... })`; await required assets or
|
|
115
|
+
rendering and respect the abort signal. The returned function unregisters the
|
|
116
|
+
hook. Hooks should not modify durable data. Do not call `ready()` until initial
|
|
117
|
+
data is usable.
|
|
118
|
+
|
|
119
|
+
Use `?capture=icon` or `?capture=export` in `slop dev` or the shared gallery to
|
|
120
|
+
inspect disposable capture views. Reload to return to normal editing. Native
|
|
121
|
+
capture remains the authority for image/PDF fidelity.
|
|
122
|
+
|
|
123
|
+
Background captures operate on temporary snapshots, including a SQLite backup,
|
|
124
|
+
and cannot initialize or modify the source stores. User exports use the current
|
|
125
|
+
session to preserve view state; captures are serialized and restore the editor
|
|
126
|
+
on success or failure. Preview and icon failures are independent. Close-time
|
|
127
|
+
refreshes keep the last successful images on failure; quit gives pending jobs
|
|
128
|
+
up to five seconds after data has been saved.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { capture } from "@hitslop/runtime";
|
|
3
|
+
import { onMount, tick, type Snippet } from "svelte";
|
|
4
|
+
let { children }: { children: Snippet } = $props();
|
|
5
|
+
let visible = $state(false);
|
|
6
|
+
let element = $state<HTMLDivElement>();
|
|
7
|
+
onMount(() => {
|
|
8
|
+
|
|
9
|
+
const node = element;
|
|
10
|
+
if (!node) return;
|
|
11
|
+
document.body.append(node);
|
|
12
|
+
const unregister = capture.registerTarget("export", {
|
|
13
|
+
element: node,
|
|
14
|
+
prepare: async () => { visible = true; await tick(); },
|
|
15
|
+
restore: async () => { visible = false; await tick(); },
|
|
16
|
+
});
|
|
17
|
+
return () => { unregister(); node.remove(); };
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
<div bind:this={element} data-slop-render="export" style:display={visible ? "block" : "none"} style:width="100%">
|
|
21
|
+
{#if visible}{@render children()}{/if}
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { capture } from "@hitslop/runtime";
|
|
3
|
+
import { onMount, tick, type Snippet } from "svelte";
|
|
4
|
+
let { children }: { children: Snippet } = $props();
|
|
5
|
+
let visible = $state(false);
|
|
6
|
+
let element = $state<HTMLDivElement>();
|
|
7
|
+
onMount(() => {
|
|
8
|
+
if (!capture.isRenderer()) return;
|
|
9
|
+
const node = element;
|
|
10
|
+
if (!node) return;
|
|
11
|
+
document.body.append(node);
|
|
12
|
+
const unregister = capture.registerTarget("icon", {
|
|
13
|
+
element: node,
|
|
14
|
+
prepare: async () => { visible = true; await tick(); },
|
|
15
|
+
restore: async () => { visible = false; await tick(); },
|
|
16
|
+
});
|
|
17
|
+
return () => { unregister(); node.remove(); };
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
{#if capture.isRenderer()}
|
|
21
|
+
<div bind:this={element} data-slop-render="icon" style:display={visible ? "block" : "none"} style:width="512px" style:height="512px" style:background="transparent">
|
|
22
|
+
{#if visible}{@render children()}{/if}
|
|
23
|
+
</div>
|
|
24
|
+
{/if}
|
|
@@ -1,21 +1,10 @@
|
|
|
1
|
+
import { MediaStore } from "./media-store.svelte.js";
|
|
1
2
|
export type FileStoreOptions = {
|
|
2
3
|
accept?: string;
|
|
3
4
|
};
|
|
4
|
-
export declare class FileStore {
|
|
5
|
-
readonly name: string;
|
|
5
|
+
export declare class FileStore extends MediaStore<null> {
|
|
6
6
|
readonly options: FileStoreOptions;
|
|
7
|
-
src: string | null;
|
|
8
|
-
hasCustomFile: boolean;
|
|
9
|
-
isLoading: boolean;
|
|
10
|
-
error: string | null;
|
|
11
|
-
revision: string | null;
|
|
12
|
-
private unwatch;
|
|
13
7
|
constructor(name: string, options?: FileStoreOptions);
|
|
14
|
-
|
|
15
|
-
replace(file: File): Promise<void>;
|
|
16
|
-
remove(): Promise<void>;
|
|
17
|
-
reload(): Promise<void>;
|
|
18
|
-
destroy(): void;
|
|
19
|
-
private adopt;
|
|
8
|
+
get hasCustomFile(): boolean;
|
|
20
9
|
}
|
|
21
10
|
export declare const fileStore: (name: string, options?: FileStoreOptions) => FileStore;
|
|
@@ -1,78 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export class FileStore {
|
|
4
|
-
name;
|
|
1
|
+
import { MediaStore } from "./media-store.svelte.js";
|
|
2
|
+
export class FileStore extends MediaStore {
|
|
5
3
|
options;
|
|
6
|
-
src = $state(null);
|
|
7
|
-
hasCustomFile = $state(false);
|
|
8
|
-
isLoading = $state(true);
|
|
9
|
-
error = $state(null);
|
|
10
|
-
revision = $state(null);
|
|
11
|
-
unwatch = null;
|
|
12
4
|
constructor(name, options = {}) {
|
|
13
|
-
|
|
5
|
+
super(name, null, options.accept ?? "");
|
|
14
6
|
this.options = options;
|
|
15
|
-
safeMediaName(name, "File");
|
|
16
|
-
void this.reload();
|
|
17
|
-
this.unwatch = slop.media.onChange(() => { void this.reload(); });
|
|
18
|
-
}
|
|
19
|
-
choose() {
|
|
20
|
-
chooseLocalFile(this.options.accept ?? "", (file) => { void this.replace(file).catch(() => undefined); });
|
|
21
|
-
}
|
|
22
|
-
async replace(file) {
|
|
23
|
-
this.isLoading = true;
|
|
24
|
-
this.error = null;
|
|
25
|
-
try {
|
|
26
|
-
const data = await fileToBase64(file);
|
|
27
|
-
const result = await slop.media.write(this.name, data, file.type || "application/octet-stream");
|
|
28
|
-
this.adopt(true, result.revision);
|
|
29
|
-
}
|
|
30
|
-
catch (error) {
|
|
31
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
this.isLoading = false;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
async remove() {
|
|
39
|
-
this.isLoading = true;
|
|
40
|
-
this.error = null;
|
|
41
|
-
try {
|
|
42
|
-
await slop.media.remove(this.name);
|
|
43
|
-
this.adopt(false, null);
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
finally {
|
|
50
|
-
this.isLoading = false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
async reload() {
|
|
54
|
-
this.isLoading = true;
|
|
55
|
-
try {
|
|
56
|
-
const result = await slop.media.open(this.name);
|
|
57
|
-
this.adopt(result.exists, result.revision);
|
|
58
|
-
this.error = null;
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
62
|
-
this.adopt(false, null);
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
65
|
-
this.isLoading = false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
destroy() {
|
|
69
|
-
this.unwatch?.();
|
|
70
|
-
this.unwatch = null;
|
|
71
|
-
}
|
|
72
|
-
adopt(exists, revision) {
|
|
73
|
-
this.hasCustomFile = exists;
|
|
74
|
-
this.revision = revision;
|
|
75
|
-
this.src = exists ? mediaSourceURL(this.name, revision) : null;
|
|
76
7
|
}
|
|
8
|
+
get hasCustomFile() { return this.hasCustomMedia; }
|
|
77
9
|
}
|
|
78
10
|
export const fileStore = (name, options = {}) => new FileStore(name, options);
|
|
@@ -1,21 +1,10 @@
|
|
|
1
|
+
import { MediaStore } from "./media-store.svelte.js";
|
|
1
2
|
export type ImageStoreOptions = {
|
|
2
3
|
fallback: string;
|
|
3
4
|
};
|
|
4
|
-
export declare class ImageStore {
|
|
5
|
-
readonly name: string;
|
|
5
|
+
export declare class ImageStore extends MediaStore<string> {
|
|
6
6
|
readonly options: ImageStoreOptions;
|
|
7
|
-
src: string;
|
|
8
|
-
hasCustomImage: boolean;
|
|
9
|
-
isLoading: boolean;
|
|
10
|
-
error: string | null;
|
|
11
|
-
revision: string | null;
|
|
12
|
-
private unwatch;
|
|
13
7
|
constructor(name: string, options: ImageStoreOptions);
|
|
14
|
-
|
|
15
|
-
replace(file: File): Promise<void>;
|
|
16
|
-
remove(): Promise<void>;
|
|
17
|
-
reload(): Promise<void>;
|
|
18
|
-
destroy(): void;
|
|
19
|
-
private adopt;
|
|
8
|
+
get hasCustomImage(): boolean;
|
|
20
9
|
}
|
|
21
10
|
export declare const imageStore: (name: string, options: ImageStoreOptions) => ImageStore;
|
|
@@ -1,81 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export class ImageStore {
|
|
4
|
-
name;
|
|
1
|
+
import { MediaStore } from "./media-store.svelte.js";
|
|
2
|
+
export class ImageStore extends MediaStore {
|
|
5
3
|
options;
|
|
6
|
-
src = $state("");
|
|
7
|
-
hasCustomImage = $state(false);
|
|
8
|
-
isLoading = $state(true);
|
|
9
|
-
error = $state(null);
|
|
10
|
-
revision = $state(null);
|
|
11
|
-
unwatch = null;
|
|
12
4
|
constructor(name, options) {
|
|
13
|
-
|
|
5
|
+
super(name, options.fallback, "image/*");
|
|
14
6
|
this.options = options;
|
|
15
|
-
safeMediaName(name, "Image");
|
|
16
|
-
this.src = options.fallback;
|
|
17
|
-
void this.reload();
|
|
18
|
-
this.unwatch = slop.media.onChange(() => { void this.reload(); });
|
|
19
|
-
}
|
|
20
|
-
choose() {
|
|
21
|
-
chooseLocalFile("image/*", (file) => { void this.replace(file); });
|
|
22
|
-
}
|
|
23
|
-
async replace(file) {
|
|
24
|
-
if (!file.type.startsWith("image/")) {
|
|
25
|
-
this.error = "Choose an image file.";
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
this.isLoading = true;
|
|
29
|
-
this.error = null;
|
|
30
|
-
try {
|
|
31
|
-
const data = await fileToBase64(file, "image");
|
|
32
|
-
const result = await slop.media.write(this.name, data, file.type);
|
|
33
|
-
this.adopt(true, result.revision);
|
|
34
|
-
}
|
|
35
|
-
catch (error) {
|
|
36
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
37
|
-
}
|
|
38
|
-
finally {
|
|
39
|
-
this.isLoading = false;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
async remove() {
|
|
43
|
-
this.isLoading = true;
|
|
44
|
-
this.error = null;
|
|
45
|
-
try {
|
|
46
|
-
await slop.media.remove(this.name);
|
|
47
|
-
this.adopt(false, null);
|
|
48
|
-
}
|
|
49
|
-
catch (error) {
|
|
50
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
this.isLoading = false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
async reload() {
|
|
57
|
-
this.isLoading = true;
|
|
58
|
-
try {
|
|
59
|
-
const result = await slop.media.open(this.name);
|
|
60
|
-
this.adopt(result.exists, result.revision);
|
|
61
|
-
this.error = null;
|
|
62
|
-
}
|
|
63
|
-
catch (error) {
|
|
64
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
65
|
-
this.adopt(false, null);
|
|
66
|
-
}
|
|
67
|
-
finally {
|
|
68
|
-
this.isLoading = false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
destroy() {
|
|
72
|
-
this.unwatch?.();
|
|
73
|
-
this.unwatch = null;
|
|
74
|
-
}
|
|
75
|
-
adopt(exists, revision) {
|
|
76
|
-
this.hasCustomImage = exists;
|
|
77
|
-
this.revision = revision;
|
|
78
|
-
this.src = exists ? mediaSourceURL(this.name, revision) : this.options.fallback;
|
|
79
7
|
}
|
|
8
|
+
get hasCustomImage() { return this.hasCustomMedia; }
|
|
80
9
|
}
|
|
81
10
|
export const imageStore = (name, options) => new ImageStore(name, options);
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { jsonStore, JsonStore, type JsonStoreOptions } from "./json-store.svelte
|
|
|
2
2
|
export { imageStore, ImageStore, type ImageStoreOptions } from "./image-store.svelte.js";
|
|
3
3
|
export { fileStore, FileStore, type FileStoreOptions } from "./file-store.svelte.js";
|
|
4
4
|
export { sqliteQuery, SqliteQuery } from "./sqlite-query.svelte.js";
|
|
5
|
+
export { default as IconTarget } from "./IconTarget.svelte";
|
|
6
|
+
export { default as ExportTarget } from "./ExportTarget.svelte";
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,5 @@ export { jsonStore, JsonStore } from "./json-store.svelte.js";
|
|
|
2
2
|
export { imageStore, ImageStore } from "./image-store.svelte.js";
|
|
3
3
|
export { fileStore, FileStore } from "./file-store.svelte.js";
|
|
4
4
|
export { sqliteQuery, SqliteQuery } from "./sqlite-query.svelte.js";
|
|
5
|
+
export { default as IconTarget } from "./IconTarget.svelte";
|
|
6
|
+
export { default as ExportTarget } from "./ExportTarget.svelte";
|
|
@@ -1,39 +1,32 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export type JsonStoreOptions<
|
|
3
|
-
schema:
|
|
4
|
-
initial:
|
|
1
|
+
import type { Static, TSchema } from "typebox";
|
|
2
|
+
export type JsonStoreOptions<S extends TSchema> = {
|
|
3
|
+
schema: S;
|
|
4
|
+
initial: NoInfer<Static<S>>;
|
|
5
5
|
};
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
success: true;
|
|
9
|
-
data: T;
|
|
10
|
-
} | {
|
|
11
|
-
success: false;
|
|
12
|
-
error: {
|
|
13
|
-
issues: ReadonlyArray<{
|
|
14
|
-
path: PropertyKey[];
|
|
15
|
-
message: string;
|
|
16
|
-
}>;
|
|
17
|
-
};
|
|
18
|
-
};
|
|
19
|
-
};
|
|
20
|
-
export declare class JsonStore<T> {
|
|
21
|
-
current: T;
|
|
6
|
+
export declare class JsonStore<S extends TSchema> {
|
|
7
|
+
current: Static<S>;
|
|
22
8
|
isLoading: boolean;
|
|
9
|
+
isReady: boolean;
|
|
10
|
+
isDirty: boolean;
|
|
11
|
+
isSaving: boolean;
|
|
23
12
|
error: string | null;
|
|
13
|
+
errorCode: "invalid_request" | "unsupported" | "revision_conflict" | "validation_failed" | "storage_error" | "limit_exceeded" | "closed" | null;
|
|
24
14
|
revision: string | null;
|
|
25
15
|
lastChangeSource: string;
|
|
26
16
|
private persister;
|
|
27
|
-
readonly schema:
|
|
17
|
+
readonly schema: S;
|
|
28
18
|
private unwatch;
|
|
29
19
|
private stopEffect;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
private unregisterFlush;
|
|
21
|
+
private destroyed;
|
|
22
|
+
private finalSnapshot;
|
|
23
|
+
private finalError;
|
|
24
|
+
constructor(options: JsonStoreOptions<S>);
|
|
34
25
|
reload(): Promise<void>;
|
|
35
26
|
destroy(): void;
|
|
27
|
+
flush(): Promise<void>;
|
|
28
|
+
private getLocal;
|
|
29
|
+
private setError;
|
|
36
30
|
private parse;
|
|
37
31
|
}
|
|
38
|
-
export declare
|
|
39
|
-
export {};
|
|
32
|
+
export declare function jsonStore<S extends TSchema>(options: JsonStoreOptions<S>): JsonStore<S>;
|
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
import { slop } from "@hitslop/runtime";
|
|
2
|
-
import { JsonPersister } from "@hitslop/runtime/adapter";
|
|
1
|
+
import { slop, SlopError } from "@hitslop/runtime";
|
|
2
|
+
import { JsonPersister, registerFlush } from "@hitslop/runtime/adapter";
|
|
3
3
|
import { untrack } from "svelte";
|
|
4
|
+
import { validate } from "@hitslop/schema/validation";
|
|
5
|
+
import { assertJSON } from "@hitslop/schema/json";
|
|
4
6
|
const snapshot = (value) => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|
|
9
15
|
};
|
|
10
16
|
export class JsonStore {
|
|
11
17
|
current = $state();
|
|
12
18
|
isLoading = $state(true);
|
|
19
|
+
isReady = $state(false);
|
|
20
|
+
isDirty = $state(false);
|
|
21
|
+
isSaving = $state(false);
|
|
13
22
|
error = $state(null);
|
|
23
|
+
errorCode = $state(null);
|
|
14
24
|
revision = $state(null);
|
|
15
25
|
lastChangeSource = $state("package");
|
|
16
26
|
persister;
|
|
17
27
|
schema;
|
|
18
28
|
unwatch = null;
|
|
19
29
|
stopEffect = null;
|
|
30
|
+
unregisterFlush = null;
|
|
31
|
+
destroyed = false;
|
|
32
|
+
finalSnapshot = null;
|
|
33
|
+
finalError = null;
|
|
20
34
|
constructor(options) {
|
|
21
35
|
this.schema = options.schema;
|
|
22
36
|
const fallbackSnapshot = snapshot(this.parse(options.initial));
|
|
@@ -32,13 +46,19 @@ export class JsonStore {
|
|
|
32
46
|
const result = await slop.json.read();
|
|
33
47
|
return { ...result, value: this.parse(result.value) };
|
|
34
48
|
},
|
|
35
|
-
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);
|
|
36
57
|
},
|
|
37
|
-
getLocal: () => snapshot(this.parse(this.current)),
|
|
38
|
-
onAdopt: (value, source) => { this.current = value; this.lastChangeSource = source; },
|
|
39
58
|
onRevision: (revision) => { this.revision = revision; },
|
|
40
59
|
onSource: (source) => { this.lastChangeSource = source; },
|
|
41
|
-
onError: (
|
|
60
|
+
onError: (error) => this.setError(error),
|
|
61
|
+
onStatus: ({ isDirty, isSaving }) => { this.isDirty = isDirty; this.isSaving = isSaving; },
|
|
42
62
|
});
|
|
43
63
|
// Snapshotting reads the complete proxy tree, so one effect run observes all
|
|
44
64
|
// nested mutations. This intentionally favors small, document-sized JSON.
|
|
@@ -47,45 +67,93 @@ export class JsonStore {
|
|
|
47
67
|
$effect(() => {
|
|
48
68
|
let local;
|
|
49
69
|
try {
|
|
50
|
-
local = snapshot(this.
|
|
70
|
+
local = snapshot(this.current);
|
|
51
71
|
}
|
|
52
72
|
catch (error) {
|
|
53
|
-
untrack(() =>
|
|
73
|
+
untrack(() => this.persister.localInvalid(error));
|
|
54
74
|
return;
|
|
55
75
|
}
|
|
56
76
|
untrack(() => this.persister.localChanged(local.json, local.value));
|
|
57
77
|
});
|
|
58
78
|
});
|
|
59
79
|
void this.reload();
|
|
80
|
+
this.unregisterFlush = registerFlush(() => this.flush());
|
|
60
81
|
this.unwatch = slop.json.onChange((event) => this.persister.externalChanged(event.revision));
|
|
61
82
|
}
|
|
62
83
|
async reload() {
|
|
84
|
+
if (this.destroyed)
|
|
85
|
+
throw new SlopError("closed", "JSON store is destroyed");
|
|
63
86
|
this.isLoading = true;
|
|
64
87
|
try {
|
|
65
88
|
await this.persister.reload();
|
|
89
|
+
this.isReady = true;
|
|
66
90
|
}
|
|
67
91
|
catch (error) {
|
|
68
|
-
this.
|
|
92
|
+
this.setError(error);
|
|
69
93
|
}
|
|
70
94
|
finally {
|
|
71
95
|
this.isLoading = false;
|
|
72
96
|
}
|
|
73
97
|
}
|
|
74
98
|
destroy() {
|
|
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;
|
|
75
108
|
this.unwatch?.();
|
|
76
109
|
this.unwatch = null;
|
|
77
110
|
this.stopEffect?.();
|
|
78
111
|
this.stopEffect = null;
|
|
112
|
+
// Keep failed writes registered so the host barrier can report/retry them.
|
|
113
|
+
void this.flush().catch(() => undefined);
|
|
79
114
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
async 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;
|
|
89
145
|
}
|
|
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
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function jsonStore(options) {
|
|
158
|
+
return new JsonStore(options);
|
|
90
159
|
}
|
|
91
|
-
export const jsonStore = (options) => new JsonStore(options);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Shared lifecycle for named files and images; subclasses only supply policy. */
|
|
2
|
+
export declare class MediaStore<Fallback extends string | null> {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
private readonly fallback;
|
|
5
|
+
private readonly accept;
|
|
6
|
+
src: string | Fallback | undefined;
|
|
7
|
+
hasCustomMedia: boolean;
|
|
8
|
+
isLoading: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
revision: string | null;
|
|
11
|
+
private readonly task;
|
|
12
|
+
private readonly unwatch;
|
|
13
|
+
private mutations;
|
|
14
|
+
private disposed;
|
|
15
|
+
private failure;
|
|
16
|
+
private readonly unregisterFlush;
|
|
17
|
+
constructor(name: string, fallback: Fallback, accept: string);
|
|
18
|
+
choose(): void;
|
|
19
|
+
replace(file: File): Promise<void>;
|
|
20
|
+
remove(): Promise<void>;
|
|
21
|
+
private mutate;
|
|
22
|
+
flush(): Promise<void>;
|
|
23
|
+
reload(): Promise<void>;
|
|
24
|
+
destroy(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { slop } from "@hitslop/runtime";
|
|
2
|
+
import { chooseLocalFile, fileToBase64, LatestTask, mediaSourceURL, registerFlush, safeMediaName } from "@hitslop/runtime/adapter";
|
|
3
|
+
/** Shared lifecycle for named files and images; subclasses only supply policy. */
|
|
4
|
+
export class MediaStore {
|
|
5
|
+
name;
|
|
6
|
+
fallback;
|
|
7
|
+
accept;
|
|
8
|
+
src = $state();
|
|
9
|
+
hasCustomMedia = $state(false);
|
|
10
|
+
isLoading = $state(true);
|
|
11
|
+
error = $state(null);
|
|
12
|
+
revision = $state(null);
|
|
13
|
+
task = new LatestTask();
|
|
14
|
+
unwatch;
|
|
15
|
+
mutations = Promise.resolve();
|
|
16
|
+
disposed = false;
|
|
17
|
+
failure;
|
|
18
|
+
unregisterFlush;
|
|
19
|
+
constructor(name, fallback, accept) {
|
|
20
|
+
this.name = name;
|
|
21
|
+
this.fallback = fallback;
|
|
22
|
+
this.accept = accept;
|
|
23
|
+
safeMediaName(name);
|
|
24
|
+
this.src = fallback;
|
|
25
|
+
this.unregisterFlush = registerFlush(() => this.flush());
|
|
26
|
+
this.unwatch = slop.media.onChange((event) => { if (!event.name || event.name === name)
|
|
27
|
+
void this.reload(); });
|
|
28
|
+
void this.reload();
|
|
29
|
+
}
|
|
30
|
+
choose() { chooseLocalFile(this.accept, (file) => { void this.replace(file).catch(() => undefined); }); }
|
|
31
|
+
async replace(file) {
|
|
32
|
+
if (this.accept === "image/*" && !file.type.startsWith("image/")) {
|
|
33
|
+
this.error = "Choose an image file.";
|
|
34
|
+
throw new Error(this.error);
|
|
35
|
+
}
|
|
36
|
+
await this.mutate(async () => {
|
|
37
|
+
const data = await fileToBase64(file);
|
|
38
|
+
const result = await slop.media.write(this.name, data, file.type || "application/octet-stream");
|
|
39
|
+
return { exists: true, revision: result.revision };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async remove() {
|
|
43
|
+
await this.mutate(async () => { await slop.media.remove(this.name); return { exists: false, revision: null }; });
|
|
44
|
+
}
|
|
45
|
+
async mutate(operation) {
|
|
46
|
+
if (this.disposed)
|
|
47
|
+
throw new Error("Media store is closed.");
|
|
48
|
+
this.task.invalidate();
|
|
49
|
+
this.isLoading = true;
|
|
50
|
+
this.error = null;
|
|
51
|
+
const mutation = this.mutations.then(async () => { await operation(); this.failure = undefined; });
|
|
52
|
+
this.mutations = mutation.catch(() => undefined);
|
|
53
|
+
try {
|
|
54
|
+
await mutation;
|
|
55
|
+
await this.reload();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
this.failure = error;
|
|
59
|
+
if (!this.disposed) {
|
|
60
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
61
|
+
this.isLoading = false;
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async flush() { await this.mutations; if (this.failure)
|
|
67
|
+
throw this.failure; }
|
|
68
|
+
async reload() {
|
|
69
|
+
if (this.disposed)
|
|
70
|
+
return;
|
|
71
|
+
this.isLoading = true;
|
|
72
|
+
await this.task.run(() => slop.media.open(this.name), (result) => {
|
|
73
|
+
this.hasCustomMedia = result.exists;
|
|
74
|
+
this.revision = result.revision;
|
|
75
|
+
this.src = result.exists ? mediaSourceURL(this.name, result.revision) : this.fallback;
|
|
76
|
+
this.error = null;
|
|
77
|
+
}, (error) => { this.error = error instanceof Error ? error.message : String(error); }, () => { this.isLoading = false; });
|
|
78
|
+
}
|
|
79
|
+
destroy() { this.disposed = true; this.task.dispose(); this.unwatch(); this.unregisterFlush(); }
|
|
80
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { slop } from "@hitslop/runtime";
|
|
2
|
+
import { LatestTask } from "@hitslop/runtime/adapter";
|
|
2
3
|
export class SqliteQuery {
|
|
3
4
|
statement;
|
|
4
5
|
current = $state([]);
|
|
@@ -6,22 +7,17 @@ export class SqliteQuery {
|
|
|
6
7
|
error = $state(null);
|
|
7
8
|
lastChangeSource = $state("package");
|
|
8
9
|
unwatch = null;
|
|
10
|
+
task = new LatestTask();
|
|
9
11
|
constructor(statement) {
|
|
10
12
|
this.statement = statement;
|
|
11
13
|
void this.reload();
|
|
12
14
|
this.unwatch = slop.db.onChange((event) => { this.lastChangeSource = event.source; void this.reload(); });
|
|
13
15
|
}
|
|
14
16
|
async execute(statement) { await slop.db.execute(statement.sql, statement.parameters ?? []); }
|
|
15
|
-
async reload() {
|
|
16
|
-
this.
|
|
17
|
-
this.error = null;
|
|
17
|
+
async reload() {
|
|
18
|
+
this.isLoading = true;
|
|
19
|
+
await this.task.run(() => slop.db.query(this.statement.sql, this.statement.parameters ?? []), (rows) => { this.current = rows; this.error = null; }, (error) => { this.error = error instanceof Error ? error.message : String(error); }, () => { this.isLoading = false; });
|
|
18
20
|
}
|
|
19
|
-
|
|
20
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
21
|
-
}
|
|
22
|
-
finally {
|
|
23
|
-
this.isLoading = false;
|
|
24
|
-
} }
|
|
25
|
-
destroy() { this.unwatch?.(); this.unwatch = null; }
|
|
21
|
+
destroy() { this.task.dispose(); this.unwatch?.(); this.unwatch = null; }
|
|
26
22
|
}
|
|
27
23
|
export const sqliteQuery = (statement) => new SqliteQuery(statement);
|
package/package.json
CHANGED
|
@@ -1,37 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hitslop/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Svelte 5 state adapters for the hitSlop runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hitslop/hitslop.git",
|
|
9
|
+
"directory": "packages/svelte"
|
|
10
|
+
},
|
|
7
11
|
"homepage": "https://hitslop.com",
|
|
8
|
-
"bugs": {
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/hitslop/hitslop/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"hitslop",
|
|
17
|
+
"svelte",
|
|
18
|
+
"local-first",
|
|
19
|
+
"mini-apps"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
11
24
|
"type": "module",
|
|
12
25
|
"files": [
|
|
13
26
|
"dist"
|
|
14
27
|
],
|
|
15
28
|
"exports": {
|
|
16
|
-
".":
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"svelte": "./dist/index.js",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
17
34
|
},
|
|
18
35
|
"scripts": {
|
|
19
|
-
"build": "bun ../../scripts/clean-dist.ts &&
|
|
36
|
+
"build": "bun ../../scripts/clean-dist.ts && svelte-package -i src -o dist",
|
|
20
37
|
"check": "svelte-check --tsgo --tsconfig ./tsconfig.json",
|
|
21
38
|
"test": "bun run --cwd ../runtime build && bun test"
|
|
22
39
|
},
|
|
23
40
|
"dependencies": {
|
|
24
|
-
"@hitslop/runtime": "^0.
|
|
41
|
+
"@hitslop/runtime": "^0.3.0",
|
|
42
|
+
"@hitslop/schema": "^0.3.0",
|
|
43
|
+
"typebox": "^1.3.26"
|
|
25
44
|
},
|
|
26
45
|
"peerDependencies": {
|
|
27
|
-
"svelte": "^5.0.0"
|
|
28
|
-
"zod": "^4.0.0"
|
|
46
|
+
"svelte": "^5.0.0"
|
|
29
47
|
},
|
|
30
48
|
"devDependencies": {
|
|
49
|
+
"@sveltejs/package": "2.5.8",
|
|
50
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
31
51
|
"svelte": "^5.57.0",
|
|
32
52
|
"svelte-check": "^4.7.6",
|
|
33
|
-
"
|
|
34
|
-
"typescript": "^6.0.3",
|
|
35
|
-
"zod": "^4.5.2"
|
|
53
|
+
"typescript": "^6.0.3"
|
|
36
54
|
}
|
|
37
55
|
}
|