selfstore 1.0.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -0
  3. package/dist/advanced.d.ts +90 -0
  4. package/dist/advanced.js +41 -0
  5. package/dist/advanced.js.map +1 -0
  6. package/dist/backups.d.ts +182 -0
  7. package/dist/backups.js +360 -0
  8. package/dist/backups.js.map +1 -0
  9. package/dist/cache-De5_sALA.d.ts +129 -0
  10. package/dist/cache-lock-LZILZNLP.js +23 -0
  11. package/dist/cache-lock-LZILZNLP.js.map +1 -0
  12. package/dist/chunk-ATUPHVLG.js +441 -0
  13. package/dist/chunk-ATUPHVLG.js.map +1 -0
  14. package/dist/chunk-BJ3RUMGI.js +45 -0
  15. package/dist/chunk-BJ3RUMGI.js.map +1 -0
  16. package/dist/chunk-CUWB5EWF.js +732 -0
  17. package/dist/chunk-CUWB5EWF.js.map +1 -0
  18. package/dist/chunk-FDPAKHQS.js +230 -0
  19. package/dist/chunk-FDPAKHQS.js.map +1 -0
  20. package/dist/chunk-IBIEKK36.js +331 -0
  21. package/dist/chunk-IBIEKK36.js.map +1 -0
  22. package/dist/chunk-JB4JHM7V.js +474 -0
  23. package/dist/chunk-JB4JHM7V.js.map +1 -0
  24. package/dist/chunk-N4RPMX3Y.js +1908 -0
  25. package/dist/chunk-N4RPMX3Y.js.map +1 -0
  26. package/dist/chunk-OWLX6PNT.js +231 -0
  27. package/dist/chunk-OWLX6PNT.js.map +1 -0
  28. package/dist/chunk-QBCU7OOP.js +20 -0
  29. package/dist/chunk-QBCU7OOP.js.map +1 -0
  30. package/dist/chunk-SGISYMT5.js +46 -0
  31. package/dist/chunk-SGISYMT5.js.map +1 -0
  32. package/dist/chunk-VBFMMR7Q.js +29 -0
  33. package/dist/chunk-VBFMMR7Q.js.map +1 -0
  34. package/dist/drive-Bv94UM8b.d.ts +222 -0
  35. package/dist/flows.d.ts +306 -0
  36. package/dist/flows.js +153 -0
  37. package/dist/flows.js.map +1 -0
  38. package/dist/groups.d.ts +39 -0
  39. package/dist/groups.js +103 -0
  40. package/dist/groups.js.map +1 -0
  41. package/dist/households.d.ts +214 -0
  42. package/dist/households.js +410 -0
  43. package/dist/households.js.map +1 -0
  44. package/dist/index.d.ts +205 -0
  45. package/dist/index.js +249 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kdf-worker.js +2395 -0
  48. package/dist/kdf-worker.js.map +1 -0
  49. package/dist/local-2py_vGGy.d.ts +47 -0
  50. package/dist/local-AM22RWRR.js +7 -0
  51. package/dist/local-AM22RWRR.js.map +1 -0
  52. package/dist/machine-ALMkT3mk.d.ts +17 -0
  53. package/dist/store-ChIGEOph.d.ts +577 -0
  54. package/dist/sync.d.ts +107 -0
  55. package/dist/sync.js +3 -0
  56. package/dist/sync.js.map +1 -0
  57. package/dist/widgets.d.ts +426 -0
  58. package/dist/widgets.js +2579 -0
  59. package/dist/widgets.js.map +1 -0
  60. package/llms.txt +405 -0
  61. package/package.json +99 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florian Mousseau
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # selfstore
2
+
3
+ **Local-first storage for browser apps, batteries included.** Your data lives
4
+ on the user's device, syncs across their devices without a server, and leaves
5
+ as a portable encrypted backup they actually own.
6
+
7
+ ```ts
8
+ import { selfstore } from 'selfstore';
9
+
10
+ const store = await selfstore('todo-app');
11
+
12
+ await store.put('todos', { id: 't1', text: 'ship it' });
13
+ store.all('todos'); // [{ id: 't1', text: 'ship it' }]
14
+ store.onChange(render); // local writes AND merges from other devices
15
+ ```
16
+
17
+ That is a working app: saving to IndexedDB, debounced auto-save on every
18
+ mutation, and the browser sync moments (tab focus, network return, tab hide,
19
+ a slow interval) are already wired. No schema ceremony, no server, nothing to
20
+ host.
21
+
22
+ Docs, guides, a live demo and honest comparisons with the alternatives:
23
+ **[selfstore.dev](https://selfstore.dev)**.
24
+
25
+ ## Going further is one call each
26
+
27
+ ```ts
28
+ // Multi-device sync: connect a destination the USER owns.
29
+ await store.connectDrive(gisDriveAuth({ clientId })); // Google Drive
30
+ await store.connectFile(); // a file on disk (Chromium)
31
+ await store.connectWebdav({ url, username, password: appPassword }); // Nextcloud & friends
32
+
33
+ // End-to-end encryption of everything that leaves the device.
34
+ await store.protect('a passphrase the user chose');
35
+
36
+ // A portable encrypted .zip the user can walk away with.
37
+ await store.downloadBackup();
38
+ ```
39
+
40
+ `connectDrive` / `connectFile` / `connectWebdav` all resolve to what actually
41
+ happened: `'merged'` (the destination already held data; both sides were
42
+ folded together, nothing lost), `'started'` (it was empty; this device's data
43
+ now lives there), `'cancelled'` (the user closed the picker), or `'manual'`
44
+ (no File System Access; offer `downloadBackup()` instead). If the destination
45
+ holds an encrypted backup and no password was given, the call throws
46
+ `PASSWORD_REQUIRED` BEFORE touching anything, so you can prompt and retry.
47
+
48
+ ## The one rule
49
+
50
+ Records need a non-empty **string `id`**: it is what the multi-device merge
51
+ keys on. `put()` enforces it immediately with a clear error instead of letting
52
+ the record silently never sync. A different field name is one option away:
53
+
54
+ ```ts
55
+ const store = await selfstore('crm', { sync: { ids: { contacts: 'uuid' } } });
56
+ ```
57
+
58
+ Everything else about your records is your business: plain JSON in, plain JSON
59
+ out, no proxies, no injected fields, no ORM to fight.
60
+
61
+ ## Status and errors are headless
62
+
63
+ The store never ships UI copy. It exposes a status descriptor
64
+ (`store.status`: `severity`, optional `action`, a stable `labelKey` like
65
+ `status.synced`) and typed errors (`store.error`: `{ code, labelKey }`, keys
66
+ like `error.authExpired`). You map the keys to your own wording and language;
67
+ the raw English `message` is for logs only.
68
+
69
+ ```ts
70
+ store.subscribe(() => statusBar.textContent = t(store.status.labelKey));
71
+ ```
72
+
73
+ Transient trouble (offline, a cold-started backend, a 5xx) never raises a
74
+ scary reconnect dialog: the edit stays safe in the local cache and the next
75
+ save or sync retries. Only a GENUINE loss of access (token revoked, permission
76
+ withdrawn) surfaces as `AUTH_EXPIRED` with a `reconnect` action.
77
+
78
+ | Code | Meaning |
79
+ |---|---|
80
+ | `BAD_FORMAT` | Not a backup file, or corrupt framing. |
81
+ | `UNSUPPORTED_VERSION` | Written by a newer format generation, cipher or KDF. |
82
+ | `PASSWORD_REQUIRED` | Encrypted backup opened without a password. |
83
+ | `DECRYPT_FAILED` | Wrong password, or tampered/corrupt ciphertext. |
84
+ | `TOO_LARGE` | An archive entry exceeds the zip-bomb guard (512 MiB). |
85
+ | `AUTH_EXPIRED` | Access to the destination genuinely lost; a user gesture reconnects. |
86
+ | `TARGET_UNAVAILABLE` | Transient: offline, cold start, 5xx. Retried automatically. |
87
+ | `TARGET_WRITE_FAILED` | The destination refused or failed the write (non-auth). |
88
+ | `NOT_CONNECTED` | The target has no connected destination. |
89
+ | `ENCRYPTION_REQUIRED` | `requireEncryption` is set: a plaintext attach, backup or export is refused. |
90
+ | `WEAK_PASSWORD` | The password fails the store's `passwordPolicy`. |
91
+ | `UNEXPECTEDLY_UNENCRYPTED` | Downgrade guard: expected encrypted, found plaintext. |
92
+ | `SCHEMA_TOO_NEW` | Data written by a newer app schema; update the app to sync. |
93
+
94
+ Branch on `err.code`, never parse messages. New codes may be ADDED in a minor
95
+ release, so keep a `default` branch in exhaustive switches.
96
+
97
+ ## Backup files, on their own
98
+
99
+ The backup is a real ZIP, specified independently of this library
100
+ ([SPEC.md](./SPEC.md), with a ~120-line Python reference reader and canonical
101
+ test vectors): unencrypted it opens in any archive tool; encrypted it is still
102
+ a valid ZIP holding the AES-256-GCM ciphertext, the cleartext parameters and a
103
+ readme - never a mystery blob. Working with backup FILES needs no store:
104
+
105
+ ```ts
106
+ import { backup, restore, changePassword } from 'selfstore';
107
+
108
+ // Write: fluent and STAGED - an illegal order does not compile.
109
+ const blob = await backup({ collections: { notes }, files: [] })
110
+ .as('my-app', '1.2.0')
111
+ .encryptedWith(password) // omit for a plain, browsable ZIP
112
+ .withReadme('Import this file into MyApp with your password.')
113
+ .toBlob(); // or .toBytes(), or .toDisk('my-backup.zip')
114
+
115
+ // Read
116
+ const meta = await restore(file).meta(); // cleartext, no password needed
117
+ if (await restore(file).isEncrypted()) { /* ask the user */ }
118
+ const snap = await restore(file).withPassword(password).read();
119
+
120
+ // Rotate, add or remove the password
121
+ const rekeyed = await changePassword(blob, { from: 'old', to: 'new' });
122
+ ```
123
+
124
+ ## When you need more: the deeper layers
125
+
126
+ The package root stays small on purpose. The machinery underneath is public
127
+ too, as subpaths of the same install:
128
+
129
+ | Import | What it is | Reach for it when |
130
+ |---|---|---|
131
+ | `selfstore` | the simple store + backup files | almost always: start here |
132
+ | `selfstore/flows` | the user journeys (connect a destination, share panel, join an invitation) as headless, tested state machines | you are building the connect/share/join UI and want the ordering and failure rules - one popup per gesture, password proven before attach, merge by default - already right |
133
+ | `selfstore/widgets` | the same journeys as drop-in web components (`<selfstore-connect>`, `<selfstore-share>`, `<selfstore-join>`) - framework-free, themable via CSS custom properties and `::part()`, reworded/localized via a `labels` map | you want the screens ready-made and your app's look on top |
134
+ | `selfstore/advanced` | the pull-model store (`createLocalStore`), custom `BackupTarget`s, caches, headless status derivation, functional codec | your state lives in its own reactive model (Svelte runes, Redux), or you are writing a destination (S3, your KV...) |
135
+ | `selfstore/groups` | passwordless group encryption: per-member identities, sealed envelopes, signed membership manifests | several PEOPLE share one encrypted store without a shared password ([PEERS.md](./PEERS.md)) |
136
+ | `selfstore/sync` | the bare merge engine (HLC + per-collection strategies, no CRDT runtime) | you only want the algorithm inside your own persistence ([SYNC docs in SPEC.md](./SPEC.md)) |
137
+
138
+ `store.advanced` on the simple store IS the `selfstore/advanced` store - same
139
+ instance - so the two styles compose instead of competing: start simple, reach
140
+ down for one advanced call (`attachPeer`, `setGroup`, a custom target), keep
141
+ the rest.
142
+
143
+ ```ts
144
+ import { createLocalStore, indexedDbCache } from 'selfstore/advanced';
145
+
146
+ const store = createLocalStore({
147
+ app: 'my-app',
148
+ schemaVersion: 1,
149
+ gather: () => state.toSnapshot(), // YOUR app owns the data
150
+ apply: (snap) => state.load(snap),
151
+ cache: indexedDbCache('my-app')
152
+ });
153
+ await store.init();
154
+ onEveryChange(store.schedule);
155
+ ```
156
+
157
+ ## Local-first, demonstrably
158
+
159
+ selfstore exists so an app can *prove* its local-first claims instead of
160
+ asserting them:
161
+
162
+ - **No server anywhere in the loop.** Save, backup, restore and merge all run
163
+ in the browser. There is nothing to host and nothing that sees the data.
164
+ - **The user owns a real file.** See "Backup files" above: a genuine,
165
+ documented ZIP, readable by anything that follows the spec.
166
+ - **Encrypted the moment it leaves the device.** Cleartext at rest locally (so
167
+ you keep queries and DevTools), end-to-end encrypted as soon as it goes to a
168
+ file or the cloud: AES-256-GCM over an Argon2id-derived key, parameters
169
+ stored per file so old backups keep decrypting when defaults improve.
170
+ - **Offline is the normal case, not a fallback.** The working copy is
171
+ IndexedDB; durable homes and merges catch up whenever the device is back
172
+ online.
173
+ - **Leaving is easy.** The format is stable across generations; a file written
174
+ years ago by another app still reads.
175
+
176
+ ## Multi-device merge, no CRDT runtime
177
+
178
+ A Hybrid Logical Clock plus pluggable per-collection strategies converge two
179
+ replicas deterministically, while your objects stay plain JSON. The default -
180
+ records keyed by `id`, later edit per record wins, deletes propagate as
181
+ tombstones - fits most apps; `sync: { strategies }` tunes it per collection:
182
+
183
+ | Strategy | Semantics |
184
+ |---|---|
185
+ | `lww-set` | records keyed by id; later write per id wins; deletes tombstoned |
186
+ | `lww-map` | field-by-field merge: concurrent edits to different fields both survive; same field goes to the later clock; deletes stay record-level |
187
+ | `grow-set` | append-only, entries immutable per id; union, never a conflict |
188
+ | `lww-register` | a single value; later write wins |
189
+ | `manual` | surface concurrent same-id edits (via `MergeResult.conflicts`) instead of resolving |
190
+
191
+ **Guarantees (seeded-fuzz tested):** every two-way merge is symmetric and
192
+ idempotent; `lww-set` and `grow-set` (used within their contracts) are also
193
+ order-independent across replicas. **Honest limits:** LWW resolves a
194
+ concurrent same-record (or same-field) edit by KEEPING THE LATER CLOCK - the
195
+ losing value is surfaced in the sync journal's conflicts, not merged. In
196
+ `lww-map`, a record deleted on one replica while another edits one of its
197
+ fields resurrects with the surviving side's fields only. Change detection
198
+ hashes content with 32-bit FNV-1a: a collision (~2^-32 per edit) would miss an
199
+ edit. If concurrent-edit losslessness is your requirement, embed a real CRDT
200
+ document (Yjs/Automerge) as a binary file in the snapshot.
201
+
202
+ ## Sharing between people: peers
203
+
204
+ Share a store between several people WITHOUT anyone granting write access:
205
+ each member publishes their own copy on their own storage, shares it
206
+ read-only, and attaches the other members' links as peers
207
+ (`store.advanced.attachPeer(source)`). Every converge folds each peer's copy
208
+ in as one more replica and publishes the merged state back: one writer per
209
+ file (no write races), copies converge to the union (every member is a full
210
+ backup of the group), gossip is transitive (a star around one member is
211
+ enough). Peer problems are recorded per peer on `state.peers` and never gate
212
+ your own saves.
213
+
214
+ Group crypto, two options: ONE shared passphrase (exchanged once out of band),
215
+ or **passwordless groups** (`selfstore/groups`): a keypair per member, every
216
+ copy Ed25519-signed by its author and sealed per recipient (X25519 envelopes),
217
+ membership governed by an admin-signed manifest with rollback protection. The
218
+ full model, the failure table and the operational notes live in
219
+ [PEERS.md](./PEERS.md); the trust analysis in
220
+ [THREAT-MODEL.md](./THREAT-MODEL.md).
221
+
222
+ ## Destinations
223
+
224
+ Built in: **disk file** (File System Access, truly zero backend, Chromium),
225
+ **Google Drive** (bring a `DriveAuth`; `gisDriveAuth` is the client-only
226
+ reference - occasional re-consent; a tiny refresh-token broker buys "connected
227
+ for good"; the cloud only ever sees opaque encrypted bytes), **WebDAV**
228
+ (Nextcloud and friends; credentials sealed at rest under a non-extractable
229
+ device key), and **S3-compatible buckets** (Amazon S3, Cloudflare R2, Backblaze
230
+ B2, MinIO): the browser signs each request with SigV4 itself, no vendor SDK, the
231
+ secret sealed at rest like the WebDAV credentials.
232
+
233
+ Yours: implement `BackupTarget { kind / save / load / isReady / reconnect /
234
+ disconnect }` from `selfstore/advanced` with any `kind` string (say `'ipfs'`)
235
+ and hand it to `store.connectTarget(...)`. Signal a genuine loss of access by
236
+ throwing `AuthExpiredError`; anything else you throw is treated as transient
237
+ and retried. `examples/custom-target.ts` is a complete one in ~30 lines.
238
+
239
+ > Honest scope: a disk file needs **no backend**; Drive client-only needs no
240
+ > backend but re-consents roughly hourly (third-party cookies); a permanent
241
+ > Drive connection needs a small refresh-token broker. WebDAV and S3 need only
242
+ > a CORS rule on the server/bucket for your app origin.
243
+
244
+ For resilience, `store.addReplica(target)` keeps a SECOND destination in sync
245
+ with the same backup - Drive as the primary, an S3 bucket as the synced copy -
246
+ so losing one home never loses the data. For locked-down deployments,
247
+ `createLocalStore({ requireEncryption, passwordPolicy })` refuses a plaintext
248
+ backup and a weak password at the engine (not just the UI); offering only
249
+ `webdav` / `s3` in the connect widget, and not mounting the share/join widgets,
250
+ keeps an app on the user's own storage with no sharing surface at all.
251
+
252
+ ## Framework bindings
253
+
254
+ `store.subscribe(fn)` plus reading state is the whole contract, and the state
255
+ snapshot is referentially stable between changes - so every framework binds in
256
+ a few lines and there is deliberately NO adapter package to install:
257
+
258
+ ```ts
259
+ // React. The THIRD argument matters: without it useSyncExternalStore throws
260
+ // during SSR (Next, Remix...).
261
+ import { useSyncExternalStore } from 'react';
262
+ const s = useSyncExternalStore(store.subscribe, () => store.state, () => store.state);
263
+
264
+ // Svelte (3, 4 and 5). The store contract is structural - this object IS a
265
+ // readable, no svelte import needed; use it as $persistence in components.
266
+ const persistence = {
267
+ subscribe(run: (s: typeof store.state) => void) {
268
+ run(store.state);
269
+ return store.subscribe(() => run(store.state));
270
+ }
271
+ };
272
+
273
+ // Vue, Solid, anything else: subscribe on mount, read store.state, call the
274
+ // returned unsubscriber on unmount.
275
+ ```
276
+
277
+ ## Security model
278
+
279
+ - Confidentiality and integrity come from AES-256-GCM: a wrong password, a
280
+ flipped ciphertext byte or altered KDF/IV parameters all fail decryption.
281
+ There is no partially-valid read.
282
+ - The KDF is Argon2id (memory-hard), 46 MiB / 3 passes by default, parameters
283
+ stored per file. It runs in a Web Worker (shipped as
284
+ `selfstore/kdf-worker`), falling back to the main thread with byte-identical
285
+ results wherever workers cannot load.
286
+ - The cosmetic header fields (`app`, `appVersion`, `createdAt`) are cleartext
287
+ and NOT authenticated; never base a security decision on them.
288
+ - Passwords are JavaScript strings: the platform gives no way to zeroize them.
289
+ Derived keys are non-extractable WebCrypto keys.
290
+ - Decompression is guarded: any archive entry declaring more than 512 MiB is
291
+ refused before inflation (`TOO_LARGE`), and a backup declaring absurd
292
+ Argon2id parameters is rejected before the KDF runs.
293
+ - WebDAV Basic-auth credentials are refused over plain http (loopback aside);
294
+ S3 requests are refused over plain http as well (loopback aside).
295
+ - The local IndexedDB cache is sealed at rest: the collections snapshot and file
296
+ blobs are AES-256-GCM-encrypted under a non-extractable per-device key (only
297
+ the small sync bookkeeping stays in the clear). Defense-in-depth against
298
+ disk forensics and partial exfiltration, not against a full-profile copy or
299
+ code in the origin - see T13 in the threat model.
300
+ - Ultra-sensitive `cacheLock`: seal that cache under a key held in memory only
301
+ (a password, or an app key such as a passkey PRF), never on disk, so even a
302
+ full-profile copy cannot read it. One unlock per session - `cacheLock` on the
303
+ simple facade, or `indexedDbCache(name, { lock: true })` at the store layer.
304
+ Still bounded by the origin (unlocked, code in the page decrypts).
305
+ - Opt-in hardening for sensitive deployments: `requireEncryption` refuses to
306
+ write or export a plaintext backup, and `passwordPolicy` refuses a password
307
+ weaker than a length / character-class rule - both enforced at the store, so
308
+ the app cannot forget to check.
309
+ - The full analysis - assets, trust boundaries, threats and explicit non-goals
310
+ (XSS, rollback replay, metadata) - is in [THREAT-MODEL.md](./THREAT-MODEL.md).
311
+
312
+ ## When not to use selfstore
313
+
314
+ Honest limits: everything fits in memory (backups measured in MB, not GB;
315
+ there is no streaming path and no delta sync - every converge ships the whole
316
+ state); the merge targets one person's devices and small groups, not real-time
317
+ collaboration (use a CRDT library for that); there is no undo or history - an
318
+ LWW overwrite is final locally, so keep dated backup copies if you want time
319
+ travel (`backup(...).toDisk()` makes that a one-liner); and it is a browser
320
+ library (Node >= 20 works for tests, but the client is the point).
321
+
322
+ Other current limits, documented with open eyes: multi-tab coordination is
323
+ data-only (connections and mode switches apply to other tabs on reload;
324
+ `multiTab: false` opts out); deletion tombstones grow unless you opt in to
325
+ compaction (`tombstoneHorizonMs` on the advanced store - safe only if the
326
+ horizon exceeds the longest a device stays offline); binary files merge by id
327
+ union (no clocks: use content-addressed ids and tie file lifetime to a
328
+ record). In Vite DEV, if the KDF worker 404s under dependency pre-bundling,
329
+ add `optimizeDeps: { exclude: ['selfstore'] }`.
330
+
331
+ ## Testing your integration
332
+
333
+ The simple store already runs in plain Node/vitest with no browser and no
334
+ mocks: without IndexedDB it lands on an in-memory cache, and
335
+ `await selfstore('app', { cache: memoryCache() })` (cache from
336
+ `selfstore/advanced`) makes the sharing explicit - two stores over one
337
+ `memoryCache()` simulate a reopen; a ~15-line in-memory `BackupTarget` (a Blob
338
+ variable) exercises the full save/sync/restore loop. That is exactly how
339
+ selfstore tests itself.
340
+
341
+ ## For AI agents
342
+
343
+ `llms.txt` at the package root summarizes the API with canonical snippets, and
344
+ [RECIPES.md](./RECIPES.md) has copy-paste tasks (the `examples/` folder holds
345
+ the same mini-apps, typechecked). Rules of thumb: every record needs a
346
+ **string `id`** (the simple store enforces it; the advanced one only logs);
347
+ always branch on `err.code`, never parse error messages; treat
348
+ `DECRYPT_FAILED` as "wrong password or corrupted file" (indistinguishable by
349
+ design); an empty password means "not encrypted"; collection names starting
350
+ with `__` are reserved.
351
+
352
+ ## Install
353
+
354
+ ```sh
355
+ npm install selfstore
356
+ ```
357
+
358
+ ESM only, TypeScript types included, browser-first. `fflate` and `hash-wasm`
359
+ are loaded lazily via dynamic import, so they stay out of your critical bundle
360
+ until the first compress/encrypt.
361
+
362
+ ## License
363
+
364
+ MIT (c) Florian Mousseau
@@ -0,0 +1,90 @@
1
+ import { B as BackupTarget } from './store-ChIGEOph.js';
2
+ export { A as AuthExpiredError, a as BuiltinTargetKind, E as EncodeOptions, H as Header, K as KdfParams, L as LocalStore, b as LocalStoreOptions, c as LocalStoreState, M as MirrorState, d as Mode, P as PasswordCheck, e as PasswordPolicy, f as PasswordRequirement, g as PeerSource, h as PeerState, R as RESERVED_COLLECTION_PREFIX, i as RESERVED_STORE_MODES, j as RecipientStanza, k as ReplicaState, S as SelfstoreError, l as SelfstoreErrorCode, m as Severity, n as Snapshot, o as SnapshotFile, p as StatusAction, q as StatusDescriptor, r as StatusInput, s as StorageState, t as StoreError, u as StoreGroupConfig, v as SyncJournalEntry, w as SyncSource, T as TargetKind, x as checkPasswordPolicy, y as createLocalStore, z as deriveStatus, C as errorLabelKey, D as isAuthExpired, F as isReservedStoreMode, G as isSelfstoreError } from './store-ChIGEOph.js';
3
+ import { K as KV } from './cache-De5_sALA.js';
4
+ export { C as CachedFile, L as LocalCache, e as LockableCache, i as indexedDbCache, f as isLockableCache, m as memoryCache } from './cache-De5_sALA.js';
5
+ export { e as exportSnapshot, g as gisDriveAuth, i as importSnapshot, a as inspect, b as isEncrypted, p as pickFromDisk, s as saveToDisk } from './local-2py_vGGy.js';
6
+ import { c as connect$1, f as fromSession$1, p as preview, a as adopt, b as findOrCreateOwnFile, l as listBackups, d as createBackup, e as deleteBackup, r as renameBackup, g as connect$2, h as fromSession$2, i as connect$3, j as fromSession$3, k as peer } from './drive-Bv94UM8b.js';
7
+ export { D as DriveAuth, m as DriveBackupInfo, n as DriveOptions, S as S3Config, o as S3ConnectOptions, W as WebdavConfig, q as WebdavConnectOptions, s as WebdavPeerOptions } from './drive-Bv94UM8b.js';
8
+ import './sync.js';
9
+
10
+ /**
11
+ * Disk-file backup target (Chromium only). The user picks a backup file once via
12
+ * the File System Access API; the store then re-writes that real file on every
13
+ * change. The handle is persisted through the injected KV, so the target stays
14
+ * storage-agnostic. The browser re-confirms write permission once per session
15
+ * (a one-click reconnect).
16
+ */
17
+
18
+ interface FileConnectOptions {
19
+ /** Where the picked file handle is persisted across sessions. */
20
+ kv: KV;
21
+ /** Suggested name for the backup file the user creates/picks. */
22
+ fileName: string;
23
+ }
24
+ /** True when the browser supports picking a re-writable file (Chromium). */
25
+ declare function isSupported(): boolean;
26
+ /** Prompt the user to choose/create the backup file. Persists the handle for
27
+ * next sessions. Returns the target, or null if cancelled. */
28
+ declare function connect(opts: FileConnectOptions): Promise<BackupTarget | null>;
29
+ /** Rebuild the target from the handle persisted in a past session, or null. */
30
+ declare function fromSession(opts: {
31
+ kv: KV;
32
+ }): Promise<BackupTarget | null>;
33
+
34
+ /**
35
+ * selfstore/advanced - the pull-model store and the machinery under it.
36
+ *
37
+ * The simple `selfstore()` facade (package root) owns its data. This entry
38
+ * inverts that: with createLocalStore your app owns the data and hands the
39
+ * store a gather/apply pair - use it when state lives in your own reactive
40
+ * model (Svelte runes, Redux, signals) and the facade's collections would be
41
+ * a second copy. Everything here also backs `store.advanced` on the simple
42
+ * store: same machinery, same instance, so the two styles compose.
43
+ *
44
+ * Also here: the durable destinations (disk file, Google Drive, WebDAV) as
45
+ * connectable targets, the BackupTarget contract for writing your own, the
46
+ * caches, the headless status derivation, and the functional codec for the
47
+ * backup file format (the fluent one lives at the package root).
48
+ */
49
+
50
+ /** Disk-file destination (File System Access, Chromium). */
51
+ declare const fileTarget: {
52
+ connect: typeof connect;
53
+ fromSession: typeof fromSession;
54
+ isSupported: typeof isSupported;
55
+ };
56
+ /** WebDAV destination (Nextcloud, ownCloud, any server the user controls), plus
57
+ * `peer()`: a read-only source over another member's shared WebDAV link, so a
58
+ * group can share read-write without Google Drive (see store.attachPeer). */
59
+ declare const webdavTarget: {
60
+ connect: typeof connect$3;
61
+ fromSession: typeof fromSession$3;
62
+ peer: typeof peer;
63
+ };
64
+ /** S3-compatible destination (Amazon S3, Cloudflare R2, Backblaze B2, MinIO):
65
+ * the browser signs each request with SigV4 and writes one object on a bucket
66
+ * the user controls, no vendor broker. The bucket only ever holds the same
67
+ * end-to-end-encrypted .selfstore bytes. */
68
+ declare const s3Target: {
69
+ connect: typeof connect$2;
70
+ fromSession: typeof fromSession$2;
71
+ };
72
+ /** Google Drive destination, plus the shared-file primitives (preview a specific
73
+ * file, adopt it as this device's backup, find-or-create the user's own file)
74
+ * and the backup-management ops (list the app's files with dates, create a
75
+ * named empty backup, rename one, delete one for good) - the raw pieces a "my
76
+ * backups" panel builds on. */
77
+ declare const driveTarget: {
78
+ connect: typeof connect$1;
79
+ fromSession: typeof fromSession$1;
80
+ preview: typeof preview;
81
+ adopt: typeof adopt;
82
+ findOrCreateOwnFile: typeof findOrCreateOwnFile;
83
+ listBackups: typeof listBackups;
84
+ createBackup: typeof createBackup;
85
+ deleteBackup: typeof deleteBackup;
86
+ renameBackup: typeof renameBackup;
87
+ FILE_ID_KEY: string;
88
+ };
89
+
90
+ export { BackupTarget, type FileConnectOptions, KV, driveTarget, fileTarget, s3Target, webdavTarget };
@@ -0,0 +1,41 @@
1
+ export { RESERVED_STORE_MODES, createLocalStore, deriveStatus, gisDriveAuth, indexedDbCache, isLockableCache, isReservedStoreMode, memoryCache } from './chunk-N4RPMX3Y.js';
2
+ export { pickFromDisk, saveToDisk } from './chunk-BJ3RUMGI.js';
3
+ import './chunk-IBIEKK36.js';
4
+ import { FILE_ID_KEY, renameBackup, deleteBackup, createBackup, listBackups, findOrCreateOwnFile, adopt, preview, fromSession, connect, isSupported, fromSession2, connect2, fromSession4, connect4, peer, fromSession3, connect3 } from './chunk-CUWB5EWF.js';
5
+ export { RESERVED_COLLECTION_PREFIX, checkPasswordPolicy, exportSnapshot, importSnapshot, inspect, isEncrypted } from './chunk-JB4JHM7V.js';
6
+ import './chunk-OWLX6PNT.js';
7
+ import './chunk-QBCU7OOP.js';
8
+ import './chunk-FDPAKHQS.js';
9
+ export { AuthExpiredError, SelfstoreError, errorLabelKey, isAuthExpired, isSelfstoreError } from './chunk-VBFMMR7Q.js';
10
+
11
+ // src/entries/advanced.ts
12
+ var fileTarget = {
13
+ connect: connect2,
14
+ fromSession: fromSession2,
15
+ isSupported
16
+ };
17
+ var webdavTarget = {
18
+ connect: connect3,
19
+ fromSession: fromSession3,
20
+ peer
21
+ };
22
+ var s3Target = {
23
+ connect: connect4,
24
+ fromSession: fromSession4
25
+ };
26
+ var driveTarget = {
27
+ connect,
28
+ fromSession,
29
+ preview,
30
+ adopt,
31
+ findOrCreateOwnFile,
32
+ listBackups,
33
+ createBackup,
34
+ deleteBackup,
35
+ renameBackup,
36
+ FILE_ID_KEY
37
+ };
38
+
39
+ export { driveTarget, fileTarget, s3Target, webdavTarget };
40
+ //# sourceMappingURL=advanced.js.map
41
+ //# sourceMappingURL=advanced.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/entries/advanced.ts"],"names":["connect","fromSession"],"mappings":";;;;;;;;;;;AA8HO,IAAM,UAAA,GAAa;AAAA,EACzB,OAAA,EAASA,QAAAA;AAAA,EACT,WAAA,EAAaC,YAAAA;AAAA,EACb;AACD;AAKO,IAAM,YAAA,GAAe;AAAA,EAC3B,OAAA,EAASD,QAAAA;AAAA,EACT,WAAA,EAAaC,YAAAA;AAAA,EACb;AACD;AAMO,IAAM,QAAA,GAAW;AAAA,EACvB,OAAA,EAASD,QAAAA;AAAA,EACT,WAAA,EAAaC;AACd;AAOO,IAAM,WAAA,GAAc;AAAA,EAC1B,OAAA;AAAA,EACA,WAAA;AAAA,EACA,OAAA;AAAA,EACA,KAAA;AAAA,EACA,mBAAA;AAAA,EACA,WAAA;AAAA,EACA,YAAA;AAAA,EACA,YAAA;AAAA,EACA,YAAA;AAAA,EACA;AACD","file":"advanced.js","sourcesContent":["/**\n * selfstore/advanced - the pull-model store and the machinery under it.\n *\n * The simple `selfstore()` facade (package root) owns its data. This entry\n * inverts that: with createLocalStore your app owns the data and hands the\n * store a gather/apply pair - use it when state lives in your own reactive\n * model (Svelte runes, Redux, signals) and the facade's collections would be\n * a second copy. Everything here also backs `store.advanced` on the simple\n * store: same machinery, same instance, so the two styles compose.\n *\n * Also here: the durable destinations (disk file, Google Drive, WebDAV) as\n * connectable targets, the BackupTarget contract for writing your own, the\n * caches, the headless status derivation, and the functional codec for the\n * backup file format (the fluent one lives at the package root).\n */\n\n// --- The pull-model store: your app owns the data ---\nexport {\n\tcreateLocalStore,\n\ttype LocalStore,\n\ttype LocalStoreOptions,\n\ttype LocalStoreState,\n\ttype StoreError,\n\ttype Mode,\n\ttype TargetKind,\n\ttype SyncSource,\n\ttype SyncJournalEntry,\n\ttype PeerState,\n\ttype MirrorState,\n\ttype ReplicaState,\n\ttype StoreGroupConfig\n} from '../persistence/store';\n\n// --- On-device caches (the store's working copy) ---\nexport {\n\tindexedDbCache,\n\tmemoryCache,\n\tisLockableCache,\n\ttype LocalCache,\n\ttype LockableCache,\n\ttype CachedFile,\n\ttype KV\n} from '../persistence/cache';\n\n// --- Headless status: map store state to your UI copy ---\nexport {\n\tderiveStatus,\n\ttype StatusDescriptor,\n\ttype StatusInput,\n\ttype Severity,\n\ttype StorageState,\n\ttype StatusAction\n} from '../persistence/status';\n\n// --- The target contract (write your own destination) ---\nexport {\n\tRESERVED_STORE_MODES,\n\tisReservedStoreMode,\n\ttype BackupTarget,\n\ttype BuiltinTargetKind,\n\ttype PeerSource\n} from '../persistence/target';\n\n// --- The backup file format, functional style (fluent lives at the root) ---\nexport {\n\texportSnapshot,\n\timportSnapshot,\n\tinspect,\n\tisEncrypted,\n\tRESERVED_COLLECTION_PREFIX,\n\ttype Snapshot,\n\ttype SnapshotFile,\n\ttype Header,\n\ttype KdfParams,\n\ttype RecipientStanza,\n\ttype EncodeOptions\n} from '../selfstore';\n\n// --- Password strength policy (the store enforces it; the UI previews it) ---\nexport {\n\tcheckPasswordPolicy,\n\ttype PasswordPolicy,\n\ttype PasswordCheck,\n\ttype PasswordRequirement\n} from '../selfstore';\n\n// --- The full error contract (AuthExpiredError is what a custom target or\n// DriveAuth THROWS to signal genuine access loss; everything else it\n// throws is treated as transient and retried) ---\nexport {\n\tSelfstoreError,\n\tAuthExpiredError,\n\tisSelfstoreError,\n\tisAuthExpired,\n\terrorLabelKey,\n\ttype SelfstoreErrorCode\n} from '../selfstore';\n\n// --- Durable destinations (opt-in), each implementing BackupTarget ---\n// Explicit namespace objects: every member is deliberately public, so a future\n// internal helper in a target module can never leak into the contract.\nimport {\n\tconnect as fileConnect,\n\tfromSession as fileFromSession,\n\tisSupported as fileIsSupported\n} from '../persistence/targets/file';\nimport {\n\tconnect as webdavConnect,\n\tfromSession as webdavFromSession,\n\tpeer as webdavPeer\n} from '../persistence/targets/webdav';\nimport { connect as s3Connect, fromSession as s3FromSession } from '../persistence/targets/s3';\nimport {\n\tconnect as driveConnect,\n\tfromSession as driveFromSession,\n\tpreview as drivePreview,\n\tadopt as driveAdopt,\n\tfindOrCreateOwnFile as driveFindOrCreateOwnFile,\n\tlistBackups as driveListBackups,\n\tcreateBackup as driveCreateBackup,\n\tdeleteBackup as driveDeleteBackup,\n\trenameBackup as driveRenameBackup,\n\tFILE_ID_KEY as DRIVE_FILE_ID_KEY\n} from '../persistence/targets/drive';\n\n/** Disk-file destination (File System Access, Chromium). */\nexport const fileTarget = {\n\tconnect: fileConnect,\n\tfromSession: fileFromSession,\n\tisSupported: fileIsSupported\n};\n\n/** WebDAV destination (Nextcloud, ownCloud, any server the user controls), plus\n * `peer()`: a read-only source over another member's shared WebDAV link, so a\n * group can share read-write without Google Drive (see store.attachPeer). */\nexport const webdavTarget = {\n\tconnect: webdavConnect,\n\tfromSession: webdavFromSession,\n\tpeer: webdavPeer\n};\n\n/** S3-compatible destination (Amazon S3, Cloudflare R2, Backblaze B2, MinIO):\n * the browser signs each request with SigV4 and writes one object on a bucket\n * the user controls, no vendor broker. The bucket only ever holds the same\n * end-to-end-encrypted .selfstore bytes. */\nexport const s3Target = {\n\tconnect: s3Connect,\n\tfromSession: s3FromSession\n};\n\n/** Google Drive destination, plus the shared-file primitives (preview a specific\n * file, adopt it as this device's backup, find-or-create the user's own file)\n * and the backup-management ops (list the app's files with dates, create a\n * named empty backup, rename one, delete one for good) - the raw pieces a \"my\n * backups\" panel builds on. */\nexport const driveTarget = {\n\tconnect: driveConnect,\n\tfromSession: driveFromSession,\n\tpreview: drivePreview,\n\tadopt: driveAdopt,\n\tfindOrCreateOwnFile: driveFindOrCreateOwnFile,\n\tlistBackups: driveListBackups,\n\tcreateBackup: driveCreateBackup,\n\tdeleteBackup: driveDeleteBackup,\n\trenameBackup: driveRenameBackup,\n\tFILE_ID_KEY: DRIVE_FILE_ID_KEY\n};\n\nexport type { FileConnectOptions } from '../persistence/targets/file';\nexport type { DriveAuth, DriveOptions, DriveBackupInfo } from '../persistence/targets/drive';\nexport type {\n\tWebdavConfig,\n\tWebdavConnectOptions,\n\tWebdavPeerOptions\n} from '../persistence/targets/webdav';\nexport type { S3Config, S3ConnectOptions } from '../persistence/targets/s3';\nexport { gisDriveAuth } from '../persistence/targets/drive-auth-gis';\nexport { saveToDisk, pickFromDisk } from '../selfstore/targets/local';\n"]}