Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
555 changes: 159 additions & 396 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

36 changes: 34 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,45 @@

## Unreleased

### Added

- _Nothing yet._

## 0.5.0

### Changed (breaking)

- **`responseTopicPrefix` default moved off `$SYS`.** Default changed from `$SYS/responses` to `$DB/clients`. The previous default published responses under `$SYS`, which the MQTT 5 spec reserves for broker-internal use (§4.7.2) — production brokers (EMQX, HiveMQ, AWS IoT Core) reject client publishes under `$SYS` by default ACL, so the old default only worked against permissive or custom-configured brokers. Per-request response topic shape is unchanged: `{prefix}/{clientId}/{requestId}`. Deployments overriding `responseTopicPrefix` explicitly are unaffected. Deployments relying on the old default must either set `responseTopicPrefix: '$SYS/responses'` to preserve current behavior or update broker ACLs to accept the new `$DB/clients/...` topic.
- **Store backend replaced by `@laboverwire/stitch-wasm`.** The entire TypeScript store — memory cache, IndexedDB persistence, MQTT sync, offline queue — has been removed. `@laboverwire/stitch` is now a thin, framework-agnostic binding layer: `src/store.ts` is a small adapter that wraps the Rust/WASM `Store` from `@laboverwire/stitch-wasm` (`^0.2.1`), and the package ships the same React and Vue bindings. All store/sync/persistence/offline-queue/MQTT logic now lives inside the wasm (compiled from the sibling `stitch-rs` repo).
- **`StoreOptions.remote` shape changed.** `remote` is now `{ url, clientId?, ticket?, username?, password? }` instead of `{ serverUrl, getTicket }`. `url` is a `ws://`|`wss://` MQTT endpoint; `ticket` is a JWT for MQTT v5 enhanced auth; `username`/`password` drive classic MQTT password auth. `persistence` is `{ dbName, passphrase? }` — supplying `passphrase` enables AES-GCM encryption.
- **`responseTopicPrefix` default moved off `$SYS`.** Default changed from `$SYS/responses` to `$DB/clients`. The previous default published responses under `$SYS`, which the MQTT 5 spec reserves for broker-internal use (§4.7.2) — production brokers (EMQX, HiveMQ, AWS IoT Core) reject client publishes under `$SYS` by default ACL, so the old default only worked against permissive or custom-configured brokers. Per-request response topic shape is unchanged: `{prefix}/{clientId}/{requestId}`. The `responseTopicPrefix` config field still exists on `StoreConfig` and is forwarded to the wasm; only the implementation moved. Deployments overriding `responseTopicPrefix` explicitly are unaffected. Deployments relying on the old default must either set `responseTopicPrefix: '$SYS/responses'` to preserve current behavior or update broker ACLs to accept the new `$DB/clients/...` topic.
- **Subscription callbacks are now asynchronous.** `subscribeToEntity` and `subscribeToScope` callbacks fire one tick after the mutating call resolves, not synchronously within it. Callers that relied on synchronous delivery must not assume the callback has run by the time `create`/`update`/`delete` returns.
- **`connectionStatus` values are lowercase** — `'connected' | 'connecting' | 'disconnected' | 'error' | 'offline'`.

### Added

- **`@laboverwire/stitch-wasm` dependency** (`^0.2.1`) — the Rust/WASM store, MQTT client, and IndexedDB persistence, bundled as a single wasm-bindgen module.
- **Pre-initialize tolerance in the adapter.** The wasm store requires `initialize()` before use, but the TS adapter tolerates pre-init access: synchronous reads return empties (`[]` / `{}` / `null` / `0` / `'offline'`) before init, `subscribe*` defer wiring until `initialize()` resolves and then trigger a re-read, and async methods await init. React and Vue hooks are therefore safe to mount before the provider finishes initializing.
- **`Store.getVersion(scopeId, entity): number`** — reactivity token used by the memory snapshot cache.
- **`Store.pendingMutationCount(scopeId): Promise<number>`** — offline-queue depth for a scope.

### Removed

- **In-package store internals.** `src/memory-store.ts`, `src/persistence-layer.ts`, `src/remote-sync-layer.ts`, `src/sync-engine.ts`, `src/offline-queue.ts`, `src/internal-utils.ts`, and `src/internal-wasm-error.ts` deleted — their logic now lives in `@laboverwire/stitch-wasm`.
- **`mqdb-wasm` and `mqtt5-wasm` direct dependencies.** Both are now bundled inside `@laboverwire/stitch-wasm`.
- **Internal factory exports.** `createMemoryStore`, `createSyncEngine`, `createPersistenceLayer`, `createRemoteSyncLayer`, `createPersistentOfflineQueue`, `createInMemoryOfflineQueue` removed. The only value export is now `createStore`.
- **`OwnershipError` and `MqdbError` classes** removed — error handling for the wasm store no longer surfaces these TS types.
- **Session/auth-cache `Store` methods removed.** `getCachedUser`, `setCachedUser`, `clearCachedUser`, `hasPendingLogout`, `setPendingLogout`, and `flushPendingLogout` are no longer on `Store`. They were thin `sessionStorage` wrappers; apps that relied on them must reimplement the caching in application code.
- **Internal-layer interface types.** `SyncEngine`, `PersistenceLayer`, `RemoteSyncLayer`, `OfflineQueue`, `MutationSender`, `LocalAccessor`, `PendingMutation`, `ConsolidatedMutation`, `ScopeBundle`, `ScopeState`, `SyncMutation`, `MutationEvent` no longer exported. The public type surface is now `StoreConfig`, `EntityDefinition`, `SchemaField`, `ForeignKeyDefinition`, `ConnectionStatus`, `SortField`, `SortDirection`, `ListFilter`, `Store`, `StoreOptions`, `PersistenceConfig`, `RemoteConfig`, `MemoryStore`, `EntitySchema`, `DefaultSchema`, `EntityKey`, and `OriginTag`. The `MemoryStore` type still exists but is trimmed to `{ getSnapshot, getSnapshotAsMap, subscribeToScope }`.
- **Consumer Vite `server.fs.allow` / `optimizeDeps.exclude` guidance obsolete.** `stitch-wasm` ships a wasm-bindgen bundler-target ESM module (ESM wasm import proposal), so a consuming Vite app must instead add `vite-plugin-wasm` and `vite-plugin-top-level-await`, and set `build.target: 'esnext'` for production builds (the wasm module and the top-level-await plugin emit top-level `await`). The old `mqdb-wasm`/`mqtt5-wasm` filesystem-allow and dep-exclude instructions no longer apply and must be removed. This repo's `vitest.config.ts` now uses those two plugins and the browser test suite loads the real wasm.

### Fixed

- **Brittle response-topic regex.** `handleResponseMessage` in `src/sync-engine.ts` built its match regex by prepending `\\$` to `responseTopicPrefix`, which only worked because the default started with `$`. Any prefix not beginning with `$` (e.g. a custom override like `responses`) produced an invalid regex (`\r` was interpreted as carriage return). Replaced with a `String.prototype.startsWith`-based check that works for any prefix value. As a side effect, the handler now binds to the live `clientId` rather than the previous `[^/]+` wildcard segment — defense in depth, since the response subscription is already scoped per client.
- **React/Vue init-order safety.** Because the adapter tolerates pre-init access (see Added), hooks like `useEntitySnapshot`, `useScopedEntities`, and `useRootEntityList` no longer read stale or throwing state when a component mounts before the provider's `initialize()` resolves — `subscribe*` wires up and re-reads once init completes.
- **Brittle response-topic parsing** now lives in `@laboverwire/stitch-wasm`. The former `src/sync-engine.ts` regex that split `{prefix}/{clientId}/{requestId}` on a fixed segment count is gone along with the file; the wasm handles response-topic matching against the configured `responseTopicPrefix`.

### Unchanged

- **React bindings** — `StoreProvider`, `AuthProvider`, `useEntitySnapshot`, `useScopedEntities`, `useRootEntityList`, `useTopLevelEntities`, `useChildCounts`, `useConnectionStatus`, `useSyncScope`, `useStore` — and **Vue bindings** — `StoreRoot`, `StitchAuth`, matching composables, and `useStore` — keep the same API. `Store.reconnect(serverUrl, getTicket?)` also keeps its signature: the adapter resolves the ticket via `getTicket` and runs any reconnect validator.

## 0.4.3

Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @laboverwire/stitch

Reactive state synchronization library. Bridges an in-memory store, IndexedDB persistence, and MQTT-based remote sync into a single `Store` interface. Optional React and Vue 3 bindings.
Reactive state synchronization for React and Vue. A thin, framework-agnostic binding layer over [`@laboverwire/stitch-wasm`](https://www.npmjs.com/package/@laboverwire/stitch-wasm) — a Rust/WASM package that owns the in-memory store, IndexedDB persistence, and MQTT-based remote sync. This package wraps that WASM `Store` in a small adapter and ships React and Vue 3 bindings on top; all store, sync, persistence, and offline-queue logic lives inside the WASM.

```bash
npm install @laboverwire/stitch
Expand All @@ -11,7 +11,7 @@ import { createStore } from '@laboverwire/stitch';

const store = createStore<Schema>(config, {
persistence: { dbName: 'my-app' },
remote: { serverUrl: 'wss://mqtt.example.com', getTicket: () => fetchAuthTicket() },
remote: { url: 'wss://mqtt.example.com', ticket: await fetchAuthTicket() },
});

await store.initialize();
Expand All @@ -20,15 +20,18 @@ await store.replaceScope('project-abc');
const tasks = store.getSnapshot('task', 'project-abc');
```

`remote.url` is a `ws://`/`wss://` MQTT endpoint. Pass `ticket` (a JWT) for MQTT v5 enhanced auth, or `username`/`password` for classic password auth. A `persistence.passphrase` enables AES-GCM encryption of the IndexedDB store.

## Documentation

- [Configuration](./docs/configuration.md) — `StoreConfig`, `StoreOptions`, scope model, typed schemas
- [Store API](./docs/api.md) — full method reference and error handling
- [Store API](./docs/api.md) — full method reference
- [React bindings](./docs/react.md) — providers, hooks, runnable example
- [Vue 3 bindings](./docs/vue.md) — providers, composables, runnable example
- [Concepts](./docs/concepts.md) — origin tags, offline queue, reconciliation, connection resilience
- [Vite consumer guide](./docs/vite-consumer.md) — using the package via source alias from a monorepo
- [Architecture](./ARCHITECTURE.md) — internal layer composition, data flow, invariants
- [Vite consumer guide](./docs/vite-consumer.md) — consuming apps must add `vite-plugin-wasm` (and `vite-plugin-top-level-await`) so the bundler can load the WASM module
- [Architecture](./ARCHITECTURE.md) — layer composition, data flow, invariants
- [Upgrading to 0.5.0](./docs/upgrading-to-0.5.md) — breaking changes and step-by-step upgrade from 0.4.x
- [Changelog](./CHANGELOG.md)
- [Releasing](./RELEASING.md)

Expand Down
99 changes: 54 additions & 45 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,64 @@
# Store API

`@laboverwire/stitch` is a thin binding layer over the `@laboverwire/stitch-wasm`
store. `createStore(config, options?)` returns a `Store`; all methods below are on
that instance.

## Lifecycle

```ts
store.initialize() // idempotent: concurrent callers share one init promise
store.destroy() // tear down all layers
store.initialize() // must be awaited before use; returns Promise<void>
store.destroy() // tear down the store; returns Promise<void>
store.ready // boolean, true after initialization
```

## Pre-init tolerance

The adapter tolerates access before `initialize()` resolves, so React/Vue hooks are
safe to mount before the provider finishes initializing:

- Synchronous reads return empties: `read` → `null`, `getSnapshot` → `[]`,
`getSnapshotAsMap` → `{}`, `getChildCount` → `0`, `getVersion` → `0`,
`connectionStatus` → `'offline'`, `isReconnecting` → `false`.
- `subscribe*` calls defer wiring until `initialize()` resolves, then poke a
re-read so `useSyncExternalStore`-style consumers pick up the first snapshot.
- Async methods await initialization before running.

## CRUD

```ts
store.create(entity, scopeId, data, tag?) // returns Promise<id>
store.update(entity, id, fields, tag?) // partial update
store.delete(entity, id, tag?) // delete by id
store.update(entity, id, fields, tag?) // partial update, Promise<void>
store.delete(entity, id, tag?) // delete by id, Promise<void>
```

`tag` is an `OriginTag` (`'remote' | 'load' | 'clear'`).

## Queries

```ts
store.read(entity, id) // single record or null (sync, from memory)
store.getSnapshot(entity, scopeId) // all records for entity in scope (sync)
store.getSnapshotAsMap(entity, scopeId) // same as map keyed by id (sync)
store.list(entity, filter?) // filtered list from persistence (async)
store.list(entity, filter?) // filtered list (async)
store.listRootEntities(sort?) // all root entities (async)
store.getChildCount(entity, scopeId) // count children in scope (async)
store.getChildCount(entity, scopeId) // count children in scope (sync, returns number)
store.getVersion(scopeId, entity) // reactivity token for the scope+entity (sync, returns number)
```

`getVersion` returns an opaque reactivity token the memory snapshot cache compares
for equality to decide when a snapshot must be re-read. It changes whenever the
scope+entity's data changes; treat it as opaque, not a sequential count.

## Subscriptions

```ts
store.subscribeToScope(scopeId, entity, cb) // fires when the given scope+entity changes; callback: () => void
store.subscribeToEntity(entity, cb) // fires on every create/update/delete; callback: (data | null, op) => void
```

Both return an unsubscribe function.
Both return an unsubscribe function. Callbacks are delivered **asynchronously** —
one tick after the mutating call resolves, not synchronously.

## Batch operations

Expand All @@ -46,70 +70,55 @@ store.endBatch() // flush batch and notify subscribers
## Scope management

```ts
store.replaceScope(scopeId) // subscribe MQTT + fetch + reconcile + load (rebuilds memory DB)
store.closeScope(scopeId) // unsubscribe + clear in-memory data
store.loadScope(scopeId, data) // manually load scope data (no network)
store.clearScope(scopeId) // clear in-memory scope data
store.replaceScope(scopeId) // subscribe + fetch + reconcile + load, rebuilds memory DB (async)
store.closeScope(scopeId) // unsubscribe + clear in-memory data (async)
store.loadScope(scopeId, data) // manually load scope data, no network (async)
store.clearScope(scopeId) // clear in-memory scope data (async)
```

`loadScope` takes `data` as `Record<string, Record<string, unknown>[]>` (entity
name → array of records). All four return `Promise<void>`.

## Connection

```ts
store.connectionStatus // current ConnectionStatus
store.connectionStatus // current ConnectionStatus (sync)
store.isReconnecting // boolean
store.subscribeToConnectionStatus(cb) // returns unsubscribe
store.disconnect() // close MQTT connection
store.reconnect(serverUrl, getTicket?) // reconnect with new credentials
store.disconnect() // close connection, Promise<void>
store.reconnect(serverUrl, getTicket?) // reconnect with new credentials, Promise<void>
```

`ConnectionStatus` is one of the lowercase values `'connected' | 'connecting' |
'disconnected' | 'error' | 'offline'`. `reconnect` runs any configured reconnect
validator, resolves the ticket via `getTicket`, and reconnects.

## Authentication & session

```ts
store.setAuthenticatedUser(userId)
store.setSessionInvalidHandler(handler)
store.setReconnectValidator(validator)
store.resetForLogout()

store.getCachedUser() // from sessionStorage (15min TTL)
store.setCachedUser(user)
store.clearCachedUser()

store.hasPendingLogout()
store.setPendingLogout(pending)
store.flushPendingLogout(logoutFn)
store.resetForLogout() // Promise<void>
```

## Local state
## Local state & pending sync

```ts
store.readLocalState(entity, id) // read from local-only entities
store.updateLocalState(entity, id, fields) // write to local-only entities
store.readLocalState(entity, id) // read from local-only entities (async)
store.updateLocalState(entity, id, fields) // write to local-only entities (async)
store.pendingMutationCount(scopeId) // pending offline mutations for scope, Promise<number>
```

## Advanced

```ts
store.request(topic, payload) // raw MQTT request-response
store.request(topic, payload) // raw MQTT request-response (async)
store.hasPersistence // boolean
store.hasRemote // boolean
store.memory // underlying MemoryStore
store.memory // underlying MemoryStore view
store.config // StoreConfig
```

## Errors

All throws from the underlying WASM layer are coerced into `MqdbError` so consumers get a real `Error` object with a `.stack`, a method-qualified message, and the original exception preserved on `.cause`:

```ts
import { MqdbError } from '@laboverwire/stitch';

try {
await store.list('task', { sort: [{ field: 'bogus', direction: 'asc' }] });
} catch (err) {
if (err instanceof MqdbError) {
console.error(err.method); // "list:task"
console.error(err.message); // "mqdb.list:task: unknown field: 'bogus'"
console.error(err.cause); // the raw WASM throw
}
}
```
`store.memory` is a read-only `MemoryStore` view exposing `getSnapshot`,
`getSnapshotAsMap`, and `subscribeToScope`.
4 changes: 3 additions & 1 deletion docs/concepts.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Key concepts

Sync, persistence, offline queueing, and reconciliation are implemented in the `@laboverwire/stitch-wasm` store. `@laboverwire/stitch` is a thin binding layer that wraps that store — exposing it through `createStore` plus the React and Vue bindings. The concepts below describe how the wasm store behaves; the `StoreConfig` and origin tags you pass through this package drive it.

## Origin tags

Mutations carry an `originTag` controlling propagation:
Expand All @@ -11,7 +13,7 @@ Mutations carry an `originTag` controlling propagation:

## Offline queue

Local mutations are queued in `pending_sync` and flushed when connected. Before flushing, mutations are consolidated:
Local mutations are queued in `pending_sync` and flushed when connected. `pendingMutationCount(scopeId)` reports how many are outstanding. Before flushing, mutations are consolidated:

- Multiple updates to same entity → merged (last write wins per field)
- Insert + updates → single insert with merged data
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@

| Field | Type | Description |
|---|---|---|
| `persistence` | `{ dbName: string }` | Enable IndexedDB persistence |
| `remote` | `{ serverUrl: string, getTicket?: () => Promise<string> }` | Enable MQTT sync with optional JWT auth |
| `persistence` | `{ dbName: string, passphrase?: string }` | Enable IndexedDB persistence. A `passphrase` turns on AES-GCM encryption at rest |
| `remote` | `{ url: string, clientId?: string, ticket?: string, username?: string, password?: string }` | Enable MQTT sync. `url` is a `ws://`/`wss://` MQTT endpoint; `ticket` is a JWT for MQTT v5 enhanced auth; `username`/`password` drive classic MQTT password auth |

## Scope model

Expand Down
Loading