{"_id":"@dmytromykhailiuk/data-store","_rev":"3-46c3cc0306262e421b415af9ba121507","name":"@dmytromykhailiuk/data-store","dist-tags":{"latest":"1.1.0"},"versions":{"1.0.0":{"name":"@dmytromykhailiuk/data-store","version":"1.0.0","keywords":["indexeddb","idb","datastore","data-store","database","browser-database","storage","persistence","offline-first","offline","offline-sync","local-first","sync","synchronization","sync-engine","data-sync","delta-sync","checkpoint","replication","outbox","queue","conflict-resolution","conflict","merge","optimistic-updates","optimistic-ui","transactions","migrations","filter","query","pwa","spa","blob","binary","arraybuffer","ios-safari","typescript","type-safe"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/data-store@1.0.0","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/data-store#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/data-store/issues"},"dist":{"shasum":"2959363253d5c4f0a9fe702a34e9a4b3e3b9eb07","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/data-store/-/data-store-1.0.0.tgz","fileCount":9,"integrity":"sha512-c5mzmtyBL3bEvFfdSBhAH3MyemZhsKStvn2f1qao5o4oJneOti2FsHEH3BfrbovOiRfzg4PMsensrB5Mru8a6w==","signatures":[{"sig":"MEYCIQD2J4sQS2NUSKfvvAnU4gtn5z0NH8HfgnS/enRGxijwXgIhAPaihGkCKjp+xADxUNs4b67DWCGkMPInsRs63MQygHK7","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":759893},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"35d7c68fd5aa3dbc757dfdd8001484e25ab15c42","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/data-store.git","type":"git"},"_npmVersion":"11.6.2","description":"Offline-first IndexedDB datastore with a persistent outbox, push/pull sync, conflict resolution, multi-table transactions and a typed filter DSL. Framework-agnostic, transport-agnostic.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","dependencies":{"idb":"^8.0.0","@dmytromykhailiuk/message-queue":"^1.0.0","@dmytromykhailiuk/retry-request":"^1.0.0","@dmytromykhailiuk/execution-blocker":"^1.0.0","@dmytromykhailiuk/network-connection":"^1.0.1"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","fake-indexeddb":"^6.0.0"},"_npmOperationalInternal":{"tmp":"tmp/data-store_1.0.0_1785682671480_0.3825514551154916","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@dmytromykhailiuk/data-store","version":"1.0.1","keywords":["indexeddb","idb","datastore","data-store","database","browser-database","storage","persistence","offline-first","offline","offline-sync","local-first","sync","synchronization","sync-engine","data-sync","delta-sync","checkpoint","replication","outbox","queue","conflict-resolution","conflict","merge","optimistic-updates","optimistic-ui","transactions","migrations","filter","query","pwa","spa","blob","binary","arraybuffer","ios-safari","typescript","type-safe"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/data-store@1.0.1","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://dmytromykhailiuk.github.io/data-store/","bugs":{"url":"https://github.com/dmytromykhailiuk/data-store/issues"},"dist":{"shasum":"d04a2a6fcccfa18982873f9c3f92b7d0cc45f877","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/data-store/-/data-store-1.0.1.tgz","fileCount":9,"integrity":"sha512-yDhbwGV7IXyeNv/HfFahXUCjn7nTnAcZcwGSwpD0wUSkMqlo4M31CaZGA+nv+wARif/x2Kht/qooXu/FDN6gzQ==","signatures":[{"sig":"MEYCIQDpkjUxx9wX96Zmd7c4G8w3MrLQVxFouypqMD6rsXqLHQIhAJqFpgga8OqD2zpD2d/us4yW0zvynn68nxtn+IwTZHmb","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":759886},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"d3fe3547539b1e6169907aaebde09faaa71891ed","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/data-store.git","type":"git"},"_npmVersion":"11.6.2","description":"Offline-first IndexedDB datastore with a persistent outbox, push/pull sync, conflict resolution, multi-table transactions and a typed filter DSL. Framework-agnostic, transport-agnostic.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","dependencies":{"idb":"^8.0.0","@dmytromykhailiuk/message-queue":"^1.0.0","@dmytromykhailiuk/retry-request":"^1.0.0","@dmytromykhailiuk/execution-blocker":"^1.0.0","@dmytromykhailiuk/network-connection":"^1.0.1"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","fake-indexeddb":"^6.0.0"},"_npmOperationalInternal":{"tmp":"tmp/data-store_1.0.1_1786638378634_0.8760467822587263","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@dmytromykhailiuk/data-store","version":"1.1.0","description":"Offline-first IndexedDB datastore with a persistent outbox, push/pull sync, conflict resolution, multi-table transactions and a typed filter DSL. Framework-agnostic, transport-agnostic.","type":"module","sideEffects":false,"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","keywords":["indexeddb","idb","datastore","data-store","database","browser-database","storage","persistence","offline-first","offline","offline-sync","local-first","sync","synchronization","sync-engine","data-sync","delta-sync","checkpoint","replication","outbox","queue","conflict-resolution","conflict","merge","optimistic-updates","optimistic-ui","transactions","migrations","filter","query","pwa","spa","blob","binary","arraybuffer","ios-safari","typescript","type-safe"],"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"scripts":{"build":"tsup","dev":"tsup --watch","playground":"vite --config vite.playground.config.ts","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","lint":"biome check .","lint:fix":"biome check --write .","format":"biome format --write .","prepublishOnly":"npm run build"},"engines":{"node":">=18"},"dependencies":{"@dmytromykhailiuk/execution-blocker":"^1.0.0","@dmytromykhailiuk/message-queue":"^1.0.0","@dmytromykhailiuk/network-connection":"^1.0.1","@dmytromykhailiuk/retry-request":"^1.0.0","idb":"^8.0.0"},"devDependencies":{"@biomejs/biome":"^1.9.4","@types/node":"^22.10.5","fake-indexeddb":"^6.0.0","jsdom":"^25.0.1","tsup":"^8.3.5","typescript":"^5.7.3","vite":"^5.4.11","vitest":"^2.1.8"},"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/data-store.git"},"bugs":{"url":"https://github.com/dmytromykhailiuk/data-store/issues"},"homepage":"https://dmytromykhailiuk.github.io/data-store/","gitHead":"66e8ddc7f4390171e68bf73adda3337762298f17","_id":"@dmytromykhailiuk/data-store@1.1.0","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-4Im+Nke53uoxaTYC4wOWIgmxgmnzzASbd0azhlhk4QrW/fPPL0tlE2+XmjDyJxiNNYaqg0eKSja13dPHBjfM+g==","shasum":"dd7c2e3bf78249b9b0619ca0c7acba82abc5fb3b","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/data-store/-/data-store-1.1.0.tgz","fileCount":9,"unpackedSize":766929,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIEA/lqq2abmnzNnVhN4LlagMsxvT+9nqJ/ZViptOQiMoAiBvnVdI1JX6L/r827hxy7OaAIEqfvF1cqcbKw5gzZpVmw=="}]},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"directories":{},"maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/data-store_1.1.0_1788601014776_0.2134934996397546"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-02T14:57:51.391Z","modified":"2026-09-05T09:36:55.081Z","1.0.0":"2026-08-02T14:57:51.691Z","1.0.1":"2026-08-13T16:26:18.824Z","1.1.0":"2026-09-05T09:36:54.944Z"},"bugs":{"url":"https://github.com/dmytromykhailiuk/data-store/issues"},"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","homepage":"https://dmytromykhailiuk.github.io/data-store/","keywords":["indexeddb","idb","datastore","data-store","database","browser-database","storage","persistence","offline-first","offline","offline-sync","local-first","sync","synchronization","sync-engine","data-sync","delta-sync","checkpoint","replication","outbox","queue","conflict-resolution","conflict","merge","optimistic-updates","optimistic-ui","transactions","migrations","filter","query","pwa","spa","blob","binary","arraybuffer","ios-safari","typescript","type-safe"],"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/data-store.git"},"description":"Offline-first IndexedDB datastore with a persistent outbox, push/pull sync, conflict resolution, multi-table transactions and a typed filter DSL. Framework-agnostic, transport-agnostic.","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"readme":"# @dmytromykhailiuk/data-store\n\nOffline-first **IndexedDB** datastore with a **persistent outbox**, **push/pull sync**, **conflict resolution**, **multi-table transactions** and a **typed filter DSL** — framework-agnostic, no vendor lock-in.\n\n> **Full documentation:** open [Docs](https://dmytromykhailiuk.github.io/data-store/) in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.\n\nBuilt for apps that must keep working with no connection at all — and reconcile honestly when it returns. **IndexedDB is the single source of truth**, every mutation commits atomically with its queued upload, and everything the server has not acknowledged survives reloads, crashes and going offline for a week. The transport is *yours* — plain `push`/`pull` functions per table; swap GraphQL for REST without touching store code.\n\n## Install\n\n```sh\nnpm i @dmytromykhailiuk/data-store\n```\n\nThe push/pull engines read the online state from [`@dmytromykhailiuk/network-connection`](https://www.npmjs.com/package/@dmytromykhailiuk/network-connection) — `NetworkConnection.init()` **must** run before `store.start()`, otherwise `start()` rejects immediately with a clear error.\n\n## Quick start\n\n```ts\nimport { NetworkConnection } from \"@dmytromykhailiuk/network-connection\";\nimport { ConflictError, createDataStore, defineTable } from \"@dmytromykhailiuk/data-store\";\n\n// 1. Declare tables: model type + the slice of sync params they consume.\nconst store = createDataStore<{ eventId?: string }>({\n  name: \"InspectorDataStore\",\n  schemaVersion: 1,\n  ignoreFields: [\"_version\", \"updatedAt\"], // server-managed fields\n  tables: {\n    Issue: defineTable<Issue, { eventId?: string }>({\n      primaryKey: \"id\",\n      indexes: { byEvent: { key: \"eventId\" } },\n      scope: {\n        key: (p) => p.eventId ?? null,                    // which slice to mirror\n        keep: (issue, p) => issue.eventId === p.eventId,  // what survives a switch\n      },\n      push: {\n        create: async (issue) => (await api.createIssue(issue)).data,\n        update: async (issue) => {\n          try { return (await api.updateIssue(issue)).data; }\n          catch (e) {\n            if (api.isVersionMismatch(e)) throw new ConflictError({ remote: api.remoteOf(e) });\n            throw e; // transport errors retry with backoff + offline parking\n          }\n        },\n        delete: async (issue) => { await api.deleteIssue(issue.id, issue._version); },\n      },\n      pull: {\n        fetch: async ({ params, checkpoint, signal }) => {\n          const page = await api.issuesByEvent(params.eventId!, { since: checkpoint, signal });\n          return { items: page.items, checkpoint: page.startedAt, done: !page.nextToken };\n        },\n        fetchOne: (id) => api.getIssue(id),\n      },\n      merge: (local, remote) => ({ ...remote, status: local.status }),\n    }),\n    Draft: defineTable<DraftNote>({ primaryKey: \"id\", local: true }), // local-only\n  },\n});\n\n// 2. Start: opens IndexedDB (running migrations), restores the outbox, pulls.\nawait NetworkConnection.init(\"/healthcheck.txt\");\nawait store.start({ params: { eventId: route.eventId } });\nawait store.whenSynced();\n\n// 3. Work with tables — everything survives reloads and offline.\nconst issues = store.table(\"Issue\");\nawait issues.put({ id: crypto.randomUUID(), eventId, severity: 3, status: \"OPEN\" });\nawait issues.update(id, { status: \"RESOLVED\" });\nconst open = await issues.query({ status: { eq: \"OPEN\" }, severity: { ge: 2 } });\n\n// 4. React.\nissues.subscribe({ eventId: { eq: eventId } }, ({ type, item, origin }) => render(item));\nawait store.whenUploaded(); // outbox drained — safe to log out\n```\n\n## How it works\n\n- **IndexedDB is the single source of truth** — no in-memory mirror to drift out of sync; reads are consistent snapshots.\n- **Records are stored wrapped** — `{ key, data, meta }`; your fields never mix with bookkeeping (`state`, revision counter, sync scope, last error).\n- **One write path** — every mutation takes the table's FIFO lock (`execution-blocker`) and runs one `readwrite` transaction spanning the data *and* the outbox. Locks are never held across the network.\n- **Writes queue in call order** — a write claims its place in the table's FIFO before it does any asynchronous work of its own, so blob encoding never lets a lighter payload commit ahead of a heavier one issued earlier.\n- **Read-modify-write is serial** — the functional form `update(key, current => next)` reads its origin *after* it wins the lock, so twenty concurrent updates of one record run one at a time in call order, each callback receiving the previous one's committed result and running exactly once, each producing exactly one revision. Same for `update()` inside `store.transaction()`, and on tables with `blobPaths` under active binary encoding — there the callback and the async encode run under the lock too (local work only, never the network).\n- **The outbox is data** — queued uploads commit atomically with the records they belong to and are rebuilt from disk on every start. Mutations of one record coalesce (`create`+`update`→`create`, `create`+`delete`→nothing, `delete`+`put`→`update`) without losing their FIFO position.\n- **The transport is yours** — the store understands exactly two special errors: `ConflictError` (merge → one re-push → surface) and `FatalPushError` (straight to the `error` state). Everything else is a transport failure: exponential backoff that parks while offline.\n\nRecord lifecycle: `pending → pushing → synced`, with `error` for surfaced failures (revive via `retryFailed()` / `resolveFailed()` / a newer save) and `local` for records that never push. A deleted server-known record is a hidden tombstone until the server confirms; a save mid-push can never be lost — the pipeline re-checks the revision counter after every network call.\n\nWhen a pull meets a record with unpushed changes, it **rebases** it by default: the table's `merge(local, remote)` runs and the record stays queued with the merged data — the eventual push carries fresh server-managed fields instead of a stale base (important on last-write-wins backends). Opt out per table with `pull.mergePending: false`; tombstones and `error`-state records are never rebased.\n\nData arriving outside the pull loop (WebSocket snapshots, SSR payloads) is applied imperatively with the exact same semantics — no `pull` config required:\n\n```ts\nsocket.on(\"issues\", (items) => void store.table(\"Issue\").applyRemote(items));\n```\n\n## Transactions\n\n```ts\nawait store.transaction([\"Event\", \"Issue\"], async (tx) => {\n  await tx.table(\"Event\").update(eventId, { status: \"COMPLETED\" });\n  for (const issue of resolved) await tx.table(\"Issue\").delete(issue.id);\n});\n```\n\nOne IndexedDB transaction over the listed tables **and** the outbox: everything commits or rolls back together, events fire only after the commit, locks are acquired in sorted order (no deadlocks). Don't await the outside world inside — IndexedDB auto-commits (you'll get a `TransactionInactiveError` explaining this).\n\n## Queries\n\nBoth a **typed, serializable DSL** and plain **predicates**, always over full records:\n\n```ts\nawait issues.query({\n  and: [\n    { status: { in: [\"OPEN\", \"TRIAGED\"] } },\n    { severity: { between: [2, 4] } },\n    { or: [{ title: { contains: q } }, { id: { beginsWith: q } }] },\n  ],\n}, { limit: 50 });\n\nawait issues.query((issue) => issue.tags.length > 3);\n```\n\nOperators are type-checked per field (`{ severity: { beginsWith } }` doesn't compile); invalid conditions throw a loud `SchemaError` instead of silently matching everything.\n\nQueries are purely local by default. With a `pull.query` handler declared, `{ remote: true }` fetches first, persists the result with full pull semantics, then answers locally:\n\n```ts\nawait issues.query({ status: { eq: \"OPEN\" } }, { remote: true });\n```\n\n## Conflicts\n\nA push handler throws `ConflictError({ remote? })` → the store merges (`merge(local, remote)`, default: local wins except `ignoreFields`, which come from the remote — list your version field there), re-pushes once, and surfaces a second conflict as an `error`-state record:\n\n```ts\nawait issues.resolveFailed(key, (local, remote) => {\n  if (!remote) return \"discard-local\";\n  return { ...remote, note: local.note }; // or \"keep-local\"\n});\n```\n\n## Sync scopes & params\n\n```ts\nawait store.setParams({ eventId: next });\n```\n\nTables whose `scope.key(params)` changed abort their pull, evict records failing `scope.keep` (never records with unpushed changes), and pull the new scope — resuming from its persisted checkpoint if it synced before. Untouched scopes stay marked synced: navigating back is instant.\n\n## Migrations\n\n```ts\ncreateDataStore({\n  schemaVersion: 3, // structure (tables/indexes) reconciles automatically on bump\n  migrations: {\n    3: async ({ table }) => {\n      await table(\"Issue\").updateEach((i) => (i.status === \"OPENED\" ? { ...i, status: \"OPEN\" } : i));\n    },\n  },\n  ...\n});\n```\n\nA migration that throws aborts the whole upgrade — nothing half-commits. Adding a table without bumping the version fails fast with a `SchemaError`.\n\n## Binary fields (the iOS Blob bug)\n\nSome WebKit builds throw `DataCloneError` when a `Blob` hits IndexedDB. Declare where your blobs live — on affected browsers they are transparently stored as `ArrayBuffer`s and come back as real `Blob`s on every read:\n\n```ts\ncreateDataStore({\n  binary: { mode: \"auto\" }, // feature-probe at start(); \"always\"/\"never\" to force\n  tables: {\n    Photo: defineTable<Photo>({\n      primaryKey: \"id\",\n      blobPaths: [\"preview\", \"attachment.file\", \"frames\"], // typed dot-paths, arrays ok\n    }),\n  },\n});\n```\n\nReads, events, push handlers and `merge` always see real `Blob`s (merge must treat them as opaque — pick a side whole). Inside `store.transaction()` live Blobs are rejected on every platform — pre-encode with `await table.encodeBlobs(item)`. A functional `table.update()` *may* introduce a fresh `Blob`, and stays as serial as any other write: encoding happens under the table lock, so blob-carrying writes keep their place in the queue.\n\n## Testing\n\nRuns unmodified on [`fake-indexeddb`](https://www.npmjs.com/package/fake-indexeddb); alias `@dmytromykhailiuk/network-connection` to a controllable double (inline `@dmytromykhailiuk/retry-request` so it sees the same mock). This package's own 181-test suite is written exactly that way and doubles as a cookbook.\n\n## TypeScript\n\nEverything is inferred from `defineTable<Model, ParamsSlice>()`: `store.table(\"Issue\")` is a `TableStore<Issue>`, filters are checked against `Issue`'s fields, transaction views only accept the declared tables. Errors form one hierarchy (`DataStoreError` with a stable `code`) — branch on `instanceof`, never on message text.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}