From 8859123c49e67550fba1c05f2f5c8fb9c96cd8ba Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Fri, 23 Oct 2026 23:02:27 +0300 Subject: [PATCH] feat: compare with RxDB feature by feature; fix what the comparison found Docs: - /guide/vs-rxdb: 300 rows in eight sections, syncom against RxDB 17.6.0, decided from its code at the tag, runs of the published package and all 271 pages of rxdb.info; rendered from docs/comparison-rxdb.json by scripts/comparison.js (a group's `section` becomes its own table), with the benchmark charts filtered to the two (`only`). - docs/comparison.json: RxDB and syncom cells corrected from the deeper read. Fixes found while deciding the syncom column, each with a test: - scopes: an op takes the scope of its record (an update's delta named none); a host refuses a pushed op whose record is outside the peer's grant (applyRemote `bound`, `outOfScope`) - a dialer keeps its own ops until its registered nodes hold them (OpLog.retain, node.acked); UNDELIVERABLE when a host lacks ones the log no longer has - REWOUND: a node restored from an older directory moves past the host's seqs and bootstraps back what it lost - logged is committed: a record file that fails after the append is held in memory and replayed at the next open (UNPERSISTED) - a torn record file is INTEGRITY; entity names the storage answers to are refused; collection methods are scoped to their collection; one() leaves the cursor alone; index keys are canonical; merge()/diff() skip __proto__; ESM named imports of the root work - docs drift: log levels, event tables, gaps, read-only tabs, nested objects Browser budget 41 -> 42 KB (entry 41.9 KB). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 58 + CLAUDE.md | 35 +- README.md | 8 +- bench/report/model.mjs | 18 +- bench/results/size.json | 6 +- docs/.vitepress/config.mts | 1 + .../theme/components/BenchCompare.vue | 6 +- .../theme/components/BenchFigure.vue | 7 +- docs/.vitepress/theme/custom.css | 12 + docs/comparison-rxdb.json | 4850 +++++++++++++++++ docs/comparison.json | 66 +- docs/guide/backends.md | 10 +- docs/guide/benchmarks.md | 2 +- docs/guide/comparison.md | 178 +- docs/guide/hooks.md | 17 +- docs/guide/query.md | 2 +- docs/guide/scaling.md | 8 +- docs/guide/storage.md | 22 +- docs/guide/sync.md | 59 +- docs/guide/telemetry.md | 7 +- docs/guide/vs-rxdb.md | 757 +++ docs/guide/why.md | 6 +- docs/index.md | 4 +- docs/public/benchmarks/size.svg | 10 +- docs/reference/api.md | 33 +- index.d.ts | 32 +- scripts/comparison.js | 105 +- scripts/size.js | 2 +- src/apply.js | 55 +- src/bootstrap.js | 3 +- src/catalog.js | 13 +- src/commit.js | 50 +- src/crdt.js | 10 +- src/entries.js | 32 +- src/hosting.js | 11 +- src/index.browser.js | 75 +- src/index.js | 75 +- src/log.js | 26 + src/query.js | 18 +- src/record.js | 19 + src/storage.js | 33 +- src/sync.js | 117 +- src/telemetry.js | 2 +- tests/crdt.test.js | 13 + tests/index.test-d.ts | 7 + tests/package/esm.test.js | 28 + tests/storage.fsi.test.js | 91 + tests/storage.ops.test.js | 17 +- tests/storage.test.js | 48 + tests/suites/hooks.js | 2 + tests/suites/query.js | 31 + tests/suites/schema.js | 2 + tests/suites/signatures.js | 2 +- tests/sync.retention.test.js | 151 + tests/sync.scopes.test.js | 112 + tests/telemetry.test.js | 6 +- 56 files changed, 7046 insertions(+), 324 deletions(-) create mode 100644 docs/comparison-rxdb.json create mode 100644 docs/guide/vs-rxdb.md create mode 100644 tests/package/esm.test.js create mode 100644 tests/sync.retention.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 97506e0..f26be7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -343,6 +343,17 @@ There is no released version section until then; everything below lands in it. ### Security +- **Scopes bound what a peer writes, not only what it declares.** An + `update()` took the scope of its delta, so with a field-based `sync.scope` + (`data.$type`, as the docs show) it was minted global and reached every + peer; an op now takes the scope of the record as it leaves it, read after + validation. And a host took the scope an op declared on trust, so a peer + granted `Other` could overwrite a `Doc` record by sending an op with no + scope: a host's `push` now passes the grant to `applyRemote` as `bound`, + and an op whose record is outside it — before or after the op, as the host + scopes it — is refused (`outOfScope` in the answer and the apply result, + `refused` with `reason: 'scope'`). Found while comparing with RxDB. + - **Record ids are checked at every entrance.** An id is `[A-Za-z0-9_@-][A-Za-z0-9._@-]{0,127}`: the local writers reject anything else with `TypeError` `code: 'INVALID_ID'` (naming `getId` when it minted @@ -384,6 +395,48 @@ There is no released version section until then; everything below lands in it. ### Fixed +- **Offline writes past retention reach the host.** A node that wrote more + ops than `retention.ops` before reaching a host had the oldest compacted, + and the host took none of them — nor any later one of that stream — + without a word. A node that dials now keeps its own ops until every + enabled registered node holds them (`listNodes()` shows `acked`, + `OpLog.retain(keep)` is the hook), and a host that lacks ones the log no + longer holds is reported once per hello as `UNDELIVERABLE`. +- **A restored directory no longer forks its node in silence.** A node + restored from an older copy minted again seqs its peers already held, and + they dropped its new writes as duplicates. At hello it now reports + `REWOUND`, moves its streams past what the host holds, and bootstraps from + the host to get back what the lost ops wrote. +- **Logged is committed.** A record file (or chain block) that failed after + the log append rejected the write while its op stayed logged and + replicated, and the writer read the record as missing until a reopen. The + write now stands: the entry is held in memory, the batch is replayed onto + the files at the next open, and the storage reports `UNPERSISTED`. A + peer's batch is held the same way. +- **A torn record file is `INTEGRITY`**, not a bare `SyntaxError` from + `get()`/`has()`; `repair(id)` rebuilds it. +- **A collection answers for its own records.** `collection.get(id)` + returned a record of another collection, and `update`, `delete` and + `record` acted on one; `get` is `null` there now, the others reject with + `NOT_FOUND`. An entity named like a storage member (`get`, `close`, `log`…) + replaced it, or broke `open()` with a bare `TypeError`; it is refused at + `open()`. +- **`cursor.one()` leaves the cursor as it was** instead of a `limit(1)` the + next `fetch()` kept. +- **Indexed objects match whatever their key order.** `$eq`/`$in` through + an index, and unique keys, compared JSON text; they compare canonical keys + now, as the predicate does. +- **`merge()` and `diff()` walk own keys and never take `__proto__`** as a + field (a JSON-parsed delta set the result's prototype). +- **`import { open } from '@alexify/syncom'` works in Node.js ESM.** The root + re-exported through spreads of locals, which Node's CommonJS lexer cannot + read; both barrels spell their exports out. +- **Docs that disagreed with the code:** failing listeners and watch runs + log at `warn`, not `debug`; an op that skips ahead is dropped as a gap, not + set aside; there is no read-only open for a second tab; a host relays an + op its validator or `beforeApply` skipped; a nested object is one + register; the event tables list `log`, `refused` and every `error` code. + - **The debt that had no decision attached.** The `Emitter`, the logger and `loadKeys` lived twice (once per platform half); they are `src/emitter.js`, `src/logger.js` and `src/keys.js` now, shared, and @@ -563,6 +616,11 @@ There is no released version section until then; everything below lands in it. ### Changed +- **Browser budget 41 → 42 KB.** The fixes above — scopes checked on the + record, own ops kept until delivered, `REWOUND`/`UNDELIVERABLE`, + `UNPERSISTED`, scoped collections, canonical index keys — add about 1.2 KB + min+gzip; the browser entry is 41.9 KB. + - **A write no longer costs more as the store grows.** The record catalog is a whole file, and it was saved every `catalog.flushEvery` (64) puts whatever its size, so every write paid for a sixty-fourth of every row: inserts into diff --git a/CLAUDE.md b/CLAUDE.md index 8fbb20b..61a2d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,8 +121,15 @@ docs/ VitePress site counted, not logged, not relayed. Payload shape is `applyOp`'s business. - **Scopes are enforced by the host.** `unit().push` drops out-of-scope ops before `applyRemote` (`refused` event, `syncom.ops.rejected{scope}`); - `snapshot`/`changes` serve `grant(asked, granted)`. `applyRemote`'s - `scopes` option only gates `advance` adoption, and adoption needs + `snapshot`/`changes` serve `grant(asked, granted)`. The scope an op declares + is the peer's word: `push` also passes the grant as `bound`, and + `applyRemote` refuses an op whose RECORD — before the op and after it, as + this node's `sync.scope` reads it — is outside it (`outOfScope`, not logged, + not relayed: a gap, like a forged op; relaying it as skipped would hand the + write to every peer of the declared scope). A local op takes the scope of + the record as it leaves it (`scopeData` in `src/commit.js`, re-read after + validation), never of a `set`'s delta alone. `applyRemote`'s `scopes` + option only gates `advance` adoption, and adoption needs `adoptFrom === op.origin` — the host a feed is read from. Never adopt on the push path. - **`ops.js` walks own keys only.** `Object.keys` + `isKey` (`__proto__` is @@ -183,7 +190,17 @@ docs/ VitePress site The storage opens its log with `sealApplied: true` and every write path calls `log.applied(from, to)` after `EntryStore.write`/`#persistBatch`; a roll then seals nothing from the oldest batch not yet applied onwards. A new path - that appends must mark what it applied, or the tail grows forever. + that appends must mark what it applied, or the tail grows forever. The + append is the commit point: a chain block or record file that fails after + it is never thrown through — `core.persist` (`Storage#land`) and + `persistLocal` hold the entries in memory (`EntryStore.hold`, never + evicted), leave the batch unapplied for the next open to replay, and + report `UNPERSISTED`. A node that dials keeps its own ops past retention + until every enabled registered node in their scope holds them + (`OpLog.retain(keep)`, `node.acked` — the own keys of a peer's vector, + replaced at hello, raised by push answers); a host behind what the log + retains is `UNDELIVERABLE`, and a host ahead of the node's own stream at + hello is `REWOUND` (adopt the host's own keys, then bootstrap from it). - **A failed page is a break in the stream.** `#receive` runs pages one after another per peer (`peer.inbound`), numbered by `peer.feed`; a rejection goes to `#pageFailed`, which closes the feed (pages of the old feed are dropped), @@ -229,9 +246,11 @@ docs/ VitePress site exists to declare that difference. Do not "fix" it by making the Node half async. The one async function of the Node half is `verifyAsync` — the engine's verify, `crypto.verify` with a callback, on libuv's thread pool — - and it is not on the package root: both barrels drop it (`const { - verifyAsync, ...crypto }`), and `src/signing.js` is its only caller. In the - browser it is `verify` itself. + and it is not on the package root: both barrels leave it out by name, and + `src/signing.js` is its only caller. In the browser it is `verify` itself. + The barrels spell every export out — a required module spread whole, the + rest by name, never a spread of a local — because that is the shape Node's + cjs-module-lexer reads for an ESM importer (`tests/package/esm.test.js`). - **Signed ops: the id is the key, the signature is the header plus a salted digest.** `nodeId` = the Ed25519 public key (`isKeyId`: 43 base64url chars, canonical last char — a non-canonical one is the same key under another @@ -296,7 +315,9 @@ docs/ VitePress site when the cursor is ordered and limited (the rows come back sorted, `fetch` must not sort again) or an `Every` otherwise; `Catalog.candidates` narrows through `$and`/`$or`, and `ids()`/`indexed()` hand out cached, shared - arrays and sets — never edit what they return. Unique keys are checked after validation and + arrays and sets — never edit what they return. Indexed and unique values are + keyed canonically (`keyOf`: object keys sorted at every depth), as the + predicate compares them. Unique keys are checked after validation and the fold, before `#log.append` (local: throw) and after `applyOp` on the apply path (remote: `conflict` event, never a skip — both records stay). - **`$text` is the catalog's.** An entity's `search` fields are joined into diff --git a/README.md b/README.md index 1d84209..634b6dd 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ that fails the build when exceeded. | Entry | raw | min | min+gzip | budget | | ----- | ---:| ---:| --------:| ------:| -| main entry — browser (@alexify/syncom) | 284.2 KB | 118.2 KB | 40.7 KB | 41.0 KB | -| main entry — node (@alexify/syncom) | 268.3 KB | 117.0 KB | 40.2 KB | — | +| main entry — browser (@alexify/syncom) | 296.0 KB | 122.3 KB | 41.9 KB | 42.0 KB | +| main entry — node (@alexify/syncom) | 279.9 KB | 121.3 KB | 41.6 KB | — | | wrpc adapter — browser (@alexify/syncom/wrpc) | 3.4 KB | 1.4 KB | 0.7 KB | 2.0 KB | | contracts — browser (@alexify/syncom/contracts) | 3.7 KB | 1.6 KB | 0.8 KB | 1.0 KB | | idb backend — browser (@alexify/syncom/idb) | 3.7 KB | 1.7 KB | 0.8 KB | 2.0 KB | @@ -169,6 +169,8 @@ nineteen in one table of forty features, each cell decided from the published so syncom is a database that merges field by field, not a text CRDT. It syncs with no service to run, its hosts validate and scope what peers send, and its log is verifiable and purgeable. It has no text or list CRDT and no UI bindings, and it is a pre-release, not on npm yet. +RxDB, the closest of them, is compared [feature by feature](https://syncomjs.vercel.app/guide/vs-rxdb) +in 300 rows, from its code and every page of its docs. ## Benchmarks @@ -266,7 +268,7 @@ _Apple M3 Max · 16 cores · Node v24.14.1 · median of 5 × 1 s samples (5 roun | TinyBase
tinybase@10.0.1 | MergeableStore + IndexedDB persister + custom synchronizer | 56.4 KB | 23.8 KB | | | Yjs
yjs@13.6.33, y-indexeddb@9.0.12, y-protocols@1.0.7 | yjs + y-indexeddb + y-protocols/sync | 80.2 KB | 24.5 KB | | | PouchDB
pouchdb-browser@9.0.0 | pouchdb-browser | 117.8 KB | 40.4 KB | pouchdb-browser carries its HTTP adapter (the CouchDB client) with it | -| **syncom**
@alexify/syncom@0.0.1 | open() over OPFS + the wrpc link adapter | 119.9 KB | 41.3 KB | storage, schema, queries, the op log and the sync protocol are one entry | +| **syncom**
@alexify/syncom@0.0.1 | open() over OPFS + the wrpc link adapter | 124.1 KB | 42.6 KB | storage, schema, queries, the op log and the sync protocol are one entry | | RxDB
rxdb@17.6.0, dexie@4.4.6, rxjs@7.8.2 | rxdb + Dexie storage + replication | 282.3 KB | 90.1 KB | | | Fireproof
@fireproof/core@0.24.19 | @fireproof/core | 784.2 KB | 204.0 KB | | | OrbitDB
@orbitdb/core@4.0.0, helia@6.1.4, libp2p@3.5.1, @libp2p/gossipsub@15.0.23, blockstore-idb@4.0.1 | @orbitdb/core + helia + libp2p, IndexedDB blockstore | 1185.6 KB | 353.7 KB | createHelia brings Helia's default libp2p configuration with it | diff --git a/bench/report/model.mjs b/bench/report/model.mjs index 3ce0bfd..04372ae 100644 --- a/bench/report/model.mjs +++ b/bench/report/model.mjs @@ -135,9 +135,13 @@ export const compareKinds = (compare) => { const PER_WHAT = { 'ops/s': 'per operation', 'records/s': 'per record' }; -export const compareModel = (compare, scenarioId, scale = 'log') => { +// The libraries a chart shows: all of them, or the ids of `only` (a page about one alternative). +const pick = (libraries, only) => (only ? libraries.filter((library) => only.includes(library.id)) : libraries); + +export const compareModel = (compare, scenarioId, scale = 'log', only = null) => { const scenario = compare.scenarios.find((candidate) => candidate.id === scenarioId) ?? compare.scenarios[0]; - const rows = compare.libraries + const libraries = pick(compare.libraries, only); + const rows = libraries .filter((library) => scenario.results[library.id]) .map((library) => { const result = scenario.results[library.id]; @@ -151,7 +155,7 @@ export const compareModel = (compare, scenarioId, scale = 'log') => { }); }) .sort(byValueDesc); - const missing = compare.libraries.filter((library) => !scenario.results[library.id]); + const missing = libraries.filter((library) => !scenario.results[library.id]); const reasons = missing.map((library) => { const failed = scenario.failed?.[library.id]; const why = failed ? `failed: ${failed.replace(/^\w*Error:\s*/, '')}` : scenario.unsupported?.[library.id]; @@ -170,8 +174,8 @@ export const compareModel = (compare, scenarioId, scale = 'log') => { }; }; -export const coldStartModel = (compare) => { - const rows = compare.libraries +export const coldStartModel = (compare, only = null) => { + const rows = pick(compare.libraries, only) .filter((library) => compare.coldStart?.[library.id]) .map((library) => { const phases = compare.coldStart[library.id]; @@ -197,9 +201,9 @@ export const coldStartModel = (compare) => { }; }; -export const sizeModel = (size) => { +export const sizeModel = (size, only = null) => { const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`; - const rows = size.libraries + const rows = pick(size.libraries, only) .map((library) => ({ id: library.id, label: library.name, diff --git a/bench/results/size.json b/bench/results/size.json index 247e72e..cc350be 100644 --- a/bench/results/size.json +++ b/bench/results/size.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-22T19:01:27.716Z", + "generatedAt": "2026-10-23T19:12:12.497Z", "esbuild": "0.28.2", "libraries": [ { @@ -9,8 +9,8 @@ "packages": [ "@alexify/syncom@0.0.1" ], - "min": 122785, - "gzip": 42313, + "min": 127030, + "gzip": 43609, "note": "storage, schema, queries, the op log and the sync protocol are one entry" }, { diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5c89e12..8624606 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -147,6 +147,7 @@ export default withMermaid({ items: [ { text: 'Why syncom?', link: '/guide/why' }, { text: 'Compared with alternatives', link: '/guide/comparison' }, + { text: 'Compared with RxDB', link: '/guide/vs-rxdb' }, { text: 'Benchmarks', link: '/guide/benchmarks' }, { text: 'Getting Started', link: '/guide/getting-started' }, { text: 'Architecture', link: '/guide/architecture' }, diff --git a/docs/.vitepress/theme/components/BenchCompare.vue b/docs/.vitepress/theme/components/BenchCompare.vue index d9d1807..a3945c0 100644 --- a/docs/.vitepress/theme/components/BenchCompare.vue +++ b/docs/.vitepress/theme/components/BenchCompare.vue @@ -5,9 +5,11 @@ import { compareCaption, compareKinds, compareModel } from '../../../../bench/re import BenchBars from './BenchBars.vue'; import Segmented from './Segmented.vue'; -const props = withDefaults(defineProps<{ kind?: string; line?: string }>(), { +// `only`: the library ids to show (a page about one alternative); all of them when absent. +const props = withDefaults(defineProps<{ kind?: string; line?: string; only?: string[] }>(), { kind: 'insert', line: 'disk', + only: undefined, }); const kinds = compareKinds(compare); @@ -27,7 +29,7 @@ const scales = [ { id: 'log', title: 'Log' }, { id: 'linear', title: 'Linear' }, ]; -const model = computed(() => compareModel(compare, scenario.value, scale.value)); +const model = computed(() => compareModel(compare, scenario.value, scale.value, props.only ?? null)); const caption = compareCaption(compare); diff --git a/docs/.vitepress/theme/components/BenchFigure.vue b/docs/.vitepress/theme/components/BenchFigure.vue index f303d0d..4885d45 100644 --- a/docs/.vitepress/theme/components/BenchFigure.vue +++ b/docs/.vitepress/theme/components/BenchFigure.vue @@ -5,12 +5,13 @@ import size from '../../../../bench/results/size.json'; import { coldStartModel, machineCaption, sizeModel } from '../../../../bench/report/model.mjs'; import BenchBars from './BenchBars.vue'; -const props = defineProps<{ chart: 'coldstart' | 'size' }>(); +// `only`: the library ids to show (a page about one alternative); all of them when absent. +const props = defineProps<{ chart: 'coldstart' | 'size'; only?: string[] }>(); const figure = computed(() => props.chart === 'coldstart' - ? { model: coldStartModel(compare), caption: machineCaption(compare.machine) } - : { model: sizeModel(size), caption: `esbuild ${size.esbuild} · ${size.generatedAt.slice(0, 10)}` }, + ? { model: coldStartModel(compare, props.only ?? null), caption: machineCaption(compare.machine) } + : { model: sizeModel(size, props.only ?? null), caption: `esbuild ${size.esbuild} · ${size.generatedAt.slice(0, 10)}` }, ); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index b1fb396..2894deb 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -165,6 +165,18 @@ left: 9px; } +/* A page about one alternative (/guide/vs-rxdb): two libraries share the + * width and wrap their longer cells instead of scrolling sideways. */ +.vp-doc .sc-matrix-duel th, +.vp-doc .sc-matrix-duel td { + font-size: 13px; +} +.vp-doc .sc-matrix-duel th:not(:first-child), +.vp-doc .sc-matrix-duel td:not(:first-child) { + width: 50%; + min-width: 15rem; +} + /* The page of the matrix uses the whole width next to the sidebar instead of * the reading column, and drops the outline on the right. */ @media (min-width: 960px) { diff --git a/docs/comparison-rxdb.json b/docs/comparison-rxdb.json new file mode 100644 index 0000000..c08f321 --- /dev/null +++ b/docs/comparison-rxdb.json @@ -0,0 +1,4850 @@ +{ + "className": "sc-matrix sc-matrix-duel", + "legend": "✅ built in, or in an official package of the same project (named) · ⚠️ partial, opt-in, Premium (paid), with a caveat, or through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. The RxDB column is rxdb 17.6.0 (npm `latest`, October 2026), decided from its source code at that tag, from runs of the published package and from every page of rxdb.info; Premium plugins are closed source, so their cells follow the docs. The syncom column is this repository.", + "groups": [ + { + "section": "Package and platform", + "title": "Package and license", + "rows": [ + { + "id": "version", + "title": "Version compared" + }, + { + "id": "released", + "title": "Release status" + }, + { + "id": "license", + "title": "License of the core" + }, + { + "id": "serverLicense", + "title": "License of the server part" + }, + { + "id": "price", + "title": "Paid tiers and price" + }, + { + "id": "premiumSplit", + "title": "What only Premium has" + }, + { + "id": "premiumTerms", + "title": "Premium license terms" + }, + { + "id": "collectionLimit", + "title": "Open collections limit" + }, + { + "id": "phoneHome", + "title": "Calls to the vendor" + } + ] + }, + { + "section": "Package and platform", + "title": "Dependencies, build and size", + "rows": [ + { + "id": "deps", + "title": "Runtime dependencies" + }, + { + "id": "impl", + "title": "Language and module format" + }, + { + "id": "entryPoints", + "title": "Entry points" + }, + { + "id": "types", + "title": "TypeScript types" + }, + { + "id": "size", + "title": "Browser bundle, minimal store" + }, + { + "id": "sizeSync", + "title": "Browser bundle with sync" + }, + { + "id": "sizeGate", + "title": "Size budget in CI" + } + ] + }, + { + "section": "Package and platform", + "title": "Runtimes", + "rows": [ + { + "id": "node", + "title": "Node.js" + }, + { + "id": "deno", + "title": "Deno" + }, + { + "id": "bun", + "title": "Bun" + }, + { + "id": "browser", + "title": "Browser persistence" + }, + { + "id": "browserReq", + "title": "Browser requirements" + }, + { + "id": "worker", + "title": "Web Worker and SharedWorker" + }, + { + "id": "reactNative", + "title": "React Native" + }, + { + "id": "expo", + "title": "Expo" + }, + { + "id": "capacitor", + "title": "Capacitor and Ionic" + }, + { + "id": "electron", + "title": "Electron" + }, + { + "id": "tauri", + "title": "Tauri" + }, + { + "id": "flutter", + "title": "Flutter" + } + ] + }, + { + "section": "Package and platform", + "title": "Tabs and instances", + "rows": [ + { + "id": "multiTab", + "title": "Several tabs on one database" + }, + { + "id": "leader", + "title": "Leader election" + } + ] + }, + { + "section": "Storage", + "title": "Backends", + "rows": [ + { + "id": "storageContract", + "title": "Storage contract" + }, + { + "id": "customBackend", + "title": "Writing your own backend" + }, + { + "id": "memoryBackend", + "title": "In-memory" + }, + { + "id": "indexeddb", + "title": "IndexedDB" + }, + { + "id": "opfs", + "title": "OPFS" + }, + { + "id": "localStorage", + "title": "localStorage" + }, + { + "id": "nodeFs", + "title": "Node.js filesystem" + }, + { + "id": "sqlite", + "title": "SQLite" + }, + { + "id": "expoFs", + "title": "Expo / React Native files" + }, + { + "id": "mongodbStorage", + "title": "MongoDB as storage" + }, + { + "id": "foundationdb", + "title": "FoundationDB" + }, + { + "id": "denokv", + "title": "Deno KV" + }, + { + "id": "legacyStorages", + "title": "LokiJS / PouchDB storages" + }, + { + "id": "storageTrialLimits", + "title": "Free-tier limits on storage" + } + ] + }, + { + "section": "Storage", + "title": "Wrappers and offloading", + "rows": [ + { + "id": "storageWrappers", + "title": "Stacking storage wrappers" + }, + { + "id": "remoteStorage", + "title": "Remote storage (IPC, WebSocket)" + }, + { + "id": "sharding", + "title": "Sharding" + }, + { + "id": "memoryMapped", + "title": "In-memory layer over a disk store" + }, + { + "id": "openCost", + "title": "What opening loads" + } + ] + }, + { + "section": "Storage", + "title": "Durability and recovery", + "rows": [ + { + "id": "durability", + "title": "Write durability" + }, + { + "id": "crashRecovery", + "title": "Crash recovery on reopen" + }, + { + "id": "exclusiveOpen", + "title": "Exclusive open" + }, + { + "id": "integrityRepair", + "title": "Integrity check and repair" + }, + { + "id": "quotas", + "title": "Quota and eviction handling" + } + ] + }, + { + "section": "Storage", + "title": "Data on disk", + "rows": [ + { + "id": "onDiskFormat", + "title": "On-disk format" + }, + { + "id": "writeFootprint", + "title": "Files written per change" + }, + { + "id": "compression", + "title": "Compression" + }, + { + "id": "docCache", + "title": "In-memory document cache" + }, + { + "id": "attachments", + "title": "Attachments / binary data" + }, + { + "id": "localDocs", + "title": "Local (non-replicated) documents" + }, + { + "id": "tombstoneCleanup", + "title": "Tombstone cleanup" + }, + { + "id": "deleteDatabase", + "title": "Delete a whole database" + } + ] + }, + { + "section": "Storage", + "title": "Moving data", + "rows": [ + { + "id": "storageMigration", + "title": "Migrate between backends" + }, + { + "id": "backup", + "title": "Backup to files" + }, + { + "id": "jsonDump", + "title": "JSON export / import" + } + ] + }, + { + "section": "Data model and writes", + "title": "Documents and collections", + "rows": [ + { + "id": "model", + "title": "Data model" + }, + { + "id": "schemaless", + "title": "Records without a schema" + }, + { + "id": "collectionNames", + "title": "Collection names" + }, + { + "id": "fieldNames", + "title": "Field names" + } + ] + }, + { + "section": "Data model and writes", + "title": "Identity", + "rows": [ + { + "id": "primaryKey", + "title": "Primary key" + }, + { + "id": "compositeKey", + "title": "Composite primary key" + }, + { + "id": "idGeneration", + "title": "Id generation" + }, + { + "id": "idRules", + "title": "Id format rules" + }, + { + "id": "immutableFields", + "title": "Immutable fields" + } + ] + }, + { + "section": "Data model and writes", + "title": "Schema and validation", + "rows": [ + { + "id": "schemaFormat", + "title": "Schema format" + }, + { + "id": "validators", + "title": "Validators" + }, + { + "id": "validationDefault", + "title": "Validation on by default" + }, + { + "id": "validatorOutput", + "title": "Validator can transform data" + }, + { + "id": "checkOnly", + "title": "Validate without writing" + }, + { + "id": "tsInference", + "title": "TypeScript types of the data" + }, + { + "id": "defaults", + "title": "Default values" + }, + { + "id": "required", + "title": "Required fields" + }, + { + "id": "nested", + "title": "Nested objects" + } + ] + }, + { + "section": "Data model and writes", + "title": "Schema versions and migration", + "rows": [ + { + "id": "schemaVersion", + "title": "Schema versions" + }, + { + "id": "migration", + "title": "Migrating stored data" + }, + { + "id": "readOld", + "title": "Old data before migrating" + }, + { + "id": "peerVersions", + "title": "A peer on another schema version" + } + ] + }, + { + "section": "Data model and writes", + "title": "Writes", + "rows": [ + { + "id": "bulk", + "title": "Bulk writes" + }, + { + "id": "upsert", + "title": "Upsert" + }, + { + "id": "insertIfAbsent", + "title": "Insert only if absent" + }, + { + "id": "patch", + "title": "Update some fields" + }, + { + "id": "unsetField", + "title": "Remove one field" + }, + { + "id": "modifyFn", + "title": "Update by function" + }, + { + "id": "operators", + "title": "Update operators" + }, + { + "id": "byQuery", + "title": "Update or delete by query" + }, + { + "id": "cas", + "title": "Compare-and-swap" + }, + { + "id": "localConflicts", + "title": "Concurrent writes on one node" + }, + { + "id": "transactions", + "title": "Multi-record transactions" + }, + { + "id": "changeAndDelete", + "title": "Change and delete in one write" + } + ] + }, + { + "section": "Data model and writes", + "title": "Counters, sets and lists", + "rows": [ + { + "id": "counters", + "title": "Counters that converge" + }, + { + "id": "sets", + "title": "Sets" + }, + { + "id": "lists", + "title": "Ordered lists that merge" + }, + { + "id": "text", + "title": "Collaborative text" + } + ] + }, + { + "section": "Data model and writes", + "title": "Merge model", + "rows": [ + { + "id": "crdtModel", + "title": "Conflict-free model" + }, + { + "id": "mergeGranularity", + "title": "Concurrent edits to different fields" + }, + { + "id": "registerWinner", + "title": "Who wins a concurrent overwrite" + }, + { + "id": "mergeHistory", + "title": "Where the merge history lives" + }, + { + "id": "mergeBypass", + "title": "Writes that bypass the merge model" + }, + { + "id": "conditionalOps", + "title": "Conditional operations" + }, + { + "id": "deltaHelpers", + "title": "Delta helpers" + } + ] + }, + { + "section": "Data model and writes", + "title": "Revisions, deletes and document objects", + "rows": [ + { + "id": "meta", + "title": "Per-record metadata" + }, + { + "id": "delete", + "title": "Delete" + }, + { + "id": "documentObjects", + "title": "Document objects" + }, + { + "id": "identity", + "title": "Same object for the same record" + }, + { + "id": "returnedData", + "title": "Editing returned data" + } + ] + }, + { + "section": "Data model and writes", + "title": "Relations and higher-level APIs", + "rows": [ + { + "id": "orm", + "title": "ORM methods" + }, + { + "id": "relations", + "title": "References between records" + }, + { + "id": "rxState", + "title": "Persistent app state" + }, + { + "id": "pipeline", + "title": "Derived collections" + }, + { + "id": "contracts", + "title": "Stored procedures" + } + ] + }, + { + "section": "Queries and reactivity", + "title": "Query language and operators", + "rows": [ + { + "id": "queryLanguage", + "title": "Query language" + }, + { + "id": "queryBuilder", + "title": "Chained query builder" + }, + { + "id": "opComparison", + "title": "`$eq` `$ne` `$gt` `$gte` `$lt` `$lte`" + }, + { + "id": "opSet", + "title": "`$in` `$nin` `$all`" + }, + { + "id": "opExistence", + "title": "`$exists` `$type`" + }, + { + "id": "opArray", + "title": "`$elemMatch` `$size`" + }, + { + "id": "opRegex", + "title": "`$regex` `$options`" + }, + { + "id": "opLogical", + "title": "`$and` `$or` `$nor` `$not`" + }, + { + "id": "opMod", + "title": "`$mod`" + }, + { + "id": "jsPredicate", + "title": "Arbitrary JS predicate" + }, + { + "id": "dottedPaths", + "title": "Nested fields (dotted paths)" + }, + { + "id": "queryValidation", + "title": "Malformed query detection" + }, + { + "id": "typedQueries", + "title": "Typed queries and rows" + }, + { + "id": "immutableQueries", + "title": "Immutable query objects" + }, + { + "id": "docMatch", + "title": "Test a document against a query" + } + ] + }, + { + "section": "Queries and reactivity", + "title": "Reading results", + "rows": [ + { + "id": "sort", + "title": "Sort" + }, + { + "id": "sortIndex", + "title": "Sort served by an index" + }, + { + "id": "topK", + "title": "Top-k for an unindexed order" + }, + { + "id": "skipLimit", + "title": "Skip and limit" + }, + { + "id": "streaming", + "title": "Streaming cursor / keyset paging" + }, + { + "id": "count", + "title": "Count queries" + }, + { + "id": "findById", + "title": "Find by id" + }, + { + "id": "findByIds", + "title": "Find many by id" + }, + { + "id": "findOne", + "title": "First match" + }, + { + "id": "projection", + "title": "Projection (select fields)" + }, + { + "id": "resultObjects", + "title": "What a query returns" + }, + { + "id": "aggregation", + "title": "Aggregation and group-by" + }, + { + "id": "joins", + "title": "Joins and cross-collection queries" + }, + { + "id": "encryptedFields", + "title": "Query on encrypted data" + } + ] + }, + { + "section": "Queries and reactivity", + "title": "Indexes and planning", + "rows": [ + { + "id": "secondaryIndexes", + "title": "Secondary indexes" + }, + { + "id": "indexKind", + "title": "Index kind" + }, + { + "id": "compoundIndexes", + "title": "Compound indexes" + }, + { + "id": "arrayIndexes", + "title": "Array (multi-entry) indexes" + }, + { + "id": "indexValues", + "title": "Indexable values" + }, + { + "id": "indexStorage", + "title": "Where indexes live" + }, + { + "id": "indexChange", + "title": "Adding or changing an index" + }, + { + "id": "planner", + "title": "Query planner" + }, + { + "id": "indexCombination", + "title": "Several indexes in one query" + }, + { + "id": "coveredQueries", + "title": "Index-only (covered) queries" + }, + { + "id": "explain", + "title": "Plan inspection" + }, + { + "id": "queryOptimizer", + "title": "Index advisor" + }, + { + "id": "queryCache", + "title": "Query cache" + }, + { + "id": "unique", + "title": "Unique constraints" + } + ] + }, + { + "section": "Queries and reactivity", + "title": "Search", + "rows": [ + { + "id": "fulltext", + "title": "Full-text search" + }, + { + "id": "fulltextInQuery", + "title": "Text search inside a query" + }, + { + "id": "vector", + "title": "Vector / similarity search" + } + ] + }, + { + "section": "Queries and reactivity", + "title": "Reactivity", + "rows": [ + { + "id": "liveQueries", + "title": "Live queries" + }, + { + "id": "liveIncremental", + "title": "Live update without re-querying" + }, + { + "id": "liveDistinct", + "title": "Emits only when the result changed" + }, + { + "id": "liveCount", + "title": "Live count" + }, + { + "id": "collectionStream", + "title": "Collection change stream" + }, + { + "id": "documentObservable", + "title": "Document observable" + }, + { + "id": "reactivityPrimitive", + "title": "Reactivity primitive" + }, + { + "id": "liveRemote", + "title": "Peer writes in live results" + } + ] + }, + { + "section": "Replication", + "title": "Model and protocol", + "rows": [ + { + "id": "replModel", + "title": "Replication model" + }, + { + "id": "wire", + "title": "What crosses the wire" + }, + { + "id": "replHistory", + "title": "Intermediate writes replicated" + }, + { + "id": "protocolVersion", + "title": "Protocol version check" + }, + { + "id": "schemaHandshake", + "title": "Schema check at connect" + }, + { + "id": "foreignBackend", + "title": "Sync with an existing backend" + }, + { + "id": "customTransport", + "title": "Writing your own transport" + }, + { + "id": "roundTrips", + "title": "Round trips to catch up" + }, + { + "id": "collectionsPerLink", + "title": "Collections in one connection" + }, + { + "id": "replMeta", + "title": "Sync state kept on the client" + } + ] + }, + { + "section": "Replication", + "title": "Topology", + "rows": [ + { + "id": "topology", + "title": "Topology" + }, + { + "id": "multiMaster", + "title": "Peers without a central server" + }, + { + "id": "relay", + "title": "Multi-hop relay" + }, + { + "id": "browserHosts", + "title": "A browser serves data" + }, + { + "id": "scaleOut", + "title": "Scaling the server out" + } + ] + }, + { + "section": "Replication", + "title": "Peer-to-peer (WebRTC)", + "rows": [ + { + "id": "webrtc", + "title": "Browser to browser (WebRTC)" + }, + { + "id": "signaling", + "title": "Signaling" + }, + { + "id": "p2pRoom", + "title": "Peers in a room" + }, + { + "id": "ice", + "title": "STUN / TURN servers" + } + ] + }, + { + "section": "Replication", + "title": "Transports and backends", + "rows": [ + { + "id": "websocket", + "title": "WebSocket" + }, + { + "id": "http", + "title": "HTTP / Server-Sent Events" + }, + { + "id": "graphql", + "title": "GraphQL" + }, + { + "id": "couchdb", + "title": "CouchDB" + }, + { + "id": "firestore", + "title": "Firestore" + }, + { + "id": "supabase", + "title": "Supabase" + }, + { + "id": "appwrite", + "title": "Appwrite" + }, + { + "id": "mongodb", + "title": "MongoDB" + }, + { + "id": "nats", + "title": "NATS JetStream" + }, + { + "id": "gdrive", + "title": "Google Drive" + }, + { + "id": "onedrive", + "title": "Microsoft OneDrive" + } + ] + }, + { + "section": "Replication", + "title": "Conflicts and clocks", + "rows": [ + { + "id": "conflictDetect", + "title": "Conflict detection" + }, + { + "id": "conflictResolve", + "title": "Conflict resolution" + }, + { + "id": "customMerge", + "title": "Custom merge logic" + }, + { + "id": "conflictWhere", + "title": "Where conflicts are settled" + }, + { + "id": "conflictEvents", + "title": "Conflicts reported" + }, + { + "id": "serverCompare", + "title": "Atomic compare on the server" + }, + { + "id": "causality", + "title": "Causality tracking" + }, + { + "id": "clocks", + "title": "Role of peer clocks" + } + ] + }, + { + "section": "Replication", + "title": "Delivery", + "rows": [ + { + "id": "live", + "title": "Realtime stream" + }, + { + "id": "resume", + "title": "Resume after a disconnect" + }, + { + "id": "restart", + "title": "Resume after an app restart" + }, + { + "id": "reconnect", + "title": "Reconnect and backoff" + }, + { + "id": "retry", + "title": "Failed requests" + }, + { + "id": "dup", + "title": "Duplicate and out-of-order delivery" + }, + { + "id": "offline", + "title": "Offline writes" + }, + { + "id": "bootstrap", + "title": "Bootstrap a new replica" + }, + { + "id": "longOffline", + "title": "Back after a long outage" + }, + { + "id": "batch", + "title": "Batch and page sizes" + }, + { + "id": "flow", + "title": "Memory bounds" + } + ] + }, + { + "section": "Replication", + "title": "Partial replication", + "rows": [ + { + "id": "partial", + "title": "Partial replication" + }, + { + "id": "partialLive", + "title": "Scoped realtime stream" + }, + { + "id": "revoke", + "title": "Access lost after sync" + }, + { + "id": "oneWay", + "title": "Pull-only or push-only" + } + ] + }, + { + "section": "Replication", + "title": "Deletes and attachments", + "rows": [ + { + "id": "deletes", + "title": "Deletes over replication" + }, + { + "id": "replAttachments", + "title": "Attachments over replication" + } + ] + }, + { + "section": "Replication", + "title": "Control and observability", + "rows": [ + { + "id": "lifecycle", + "title": "Start, pause, stop" + }, + { + "id": "awaitSync", + "title": "Waiting until in sync" + }, + { + "id": "resync", + "title": "Forcing a re-sync" + }, + { + "id": "status", + "title": "Replication status and events" + }, + { + "id": "replMultiTab", + "title": "Replication with several tabs" + } + ] + }, + { + "section": "Replication", + "title": "Server product", + "rows": [ + { + "id": "serverProduct", + "title": "Server product" + }, + { + "id": "serverRuntime", + "title": "Where the server runs" + }, + { + "id": "adapters", + "title": "HTTP framework adapters" + }, + { + "id": "endpoints", + "title": "What the server serves" + }, + { + "id": "remoteQuery", + "title": "Queries for thin clients" + }, + { + "id": "serverStorage", + "title": "Server-side storage" + } + ] + }, + { + "section": "Security and integrity", + "title": "Encryption", + "rows": [ + { + "id": "atRest", + "title": "Encryption at rest" + }, + { + "id": "encScope", + "title": "What is encrypted" + }, + { + "id": "cipher", + "title": "Cipher and integrity" + }, + { + "id": "keyOffDisk", + "title": "Key kept off the disk" + }, + { + "id": "keyRotation", + "title": "Changing the key or password" + }, + { + "id": "encAttachments", + "title": "Encrypted attachments" + }, + { + "id": "encExport", + "title": "Encrypted data in backups and dumps" + }, + { + "id": "encSync", + "title": "Encrypted data and replication" + }, + { + "id": "e2ee", + "title": "End-to-end encryption" + } + ] + }, + { + "section": "Security and integrity", + "title": "Signatures, identity and integrity", + "rows": [ + { + "id": "signing", + "title": "Signed changes" + }, + { + "id": "nodeIdentity", + "title": "Node identity" + }, + { + "id": "tamper", + "title": "Tamper-evident history" + } + ] + }, + { + "section": "Security and integrity", + "title": "Access control on the serving side", + "rows": [ + { + "id": "authn", + "title": "Authentication where data is served" + }, + { + "id": "peerValidation", + "title": "Peer validation (P2P)" + }, + { + "id": "access", + "title": "Per-client read filtering" + }, + { + "id": "writeRules", + "title": "Per-client write rules" + }, + { + "id": "serverValidation", + "title": "Server re-validates a client's write" + }, + { + "id": "serverAuth", + "title": "A server can reject a write before it spreads" + }, + { + "id": "limits", + "title": "Rate and size limits" + } + ] + }, + { + "section": "Security and integrity", + "title": "Erasure, history and audit", + "rows": [ + { + "id": "purge", + "title": "Purge: the payload leaves history" + }, + { + "id": "purgeReplicas", + "title": "Erasure across replicas" + }, + { + "id": "history", + "title": "History / time travel" + }, + { + "id": "audit", + "title": "Who made a change" + } + ] + }, + { + "section": "Security and integrity", + "title": "Input hardening", + "rows": [ + { + "id": "proto", + "title": "Prototype pollution" + }, + { + "id": "malformed", + "title": "Malformed data from a peer or backend" + }, + { + "id": "skew", + "title": "Clock-skew guard" + } + ] + }, + { + "section": "Extensibility and operations", + "title": "Hooks and events", + "rows": [ + { + "id": "hooks", + "title": "Write hooks / middleware" + }, + { + "id": "replHooks", + "title": "Hooks on replicated changes" + }, + { + "id": "outHook", + "title": "Filter outgoing changes" + }, + { + "id": "readHooks", + "title": "Read hooks" + }, + { + "id": "errorEvents", + "title": "Refusal and error events" + } + ] + }, + { + "section": "Extensibility and operations", + "title": "Extensibility and operations", + "rows": [ + { + "id": "plugins", + "title": "Plugin system" + }, + { + "id": "errors", + "title": "Typed errors" + }, + { + "id": "logging", + "title": "Logging" + }, + { + "id": "otel", + "title": "OpenTelemetry" + }, + { + "id": "stats", + "title": "Health and stats" + }, + { + "id": "diagPII", + "title": "Personal data in diagnostics" + } + ] + }, + { + "section": "Extensibility and operations", + "title": "UI bindings", + "rows": [ + { + "id": "react", + "title": "React" + }, + { + "id": "vue", + "title": "Vue" + }, + { + "id": "angular", + "title": "Angular" + }, + { + "id": "svelte", + "title": "Svelte" + }, + { + "id": "signals", + "title": "Signals and custom reactivity" + }, + { + "id": "tanstack", + "title": "TanStack DB" + } + ] + }, + { + "section": "Extensibility and operations", + "title": "Developer tooling", + "rows": [ + { + "id": "devMode", + "title": "Development mode" + }, + { + "id": "testing", + "title": "Test helpers" + }, + { + "id": "ci", + "title": "Own test suite and CI" + }, + { + "id": "docs", + "title": "Documentation" + }, + { + "id": "examples", + "title": "Example apps" + } + ] + }, + { + "section": "Project and ecosystem", + "title": "Releases and maintenance", + "rows": [ + { + "id": "breakingMajors", + "title": "Breaking major releases" + }, + { + "id": "releaseCadence", + "title": "Minor release cadence" + }, + { + "id": "freeTierHistory", + "title": "Free-tier changes over releases" + }, + { + "id": "productionUsers", + "title": "Named production users" + } + ] + }, + { + "section": "Project and ecosystem", + "title": "Support and ecosystem", + "rows": [ + { + "id": "commercialSupport", + "title": "Commercial support" + }, + { + "id": "communityHelp", + "title": "Community help" + }, + { + "id": "thirdPartyPlugins", + "title": "Third-party plugins" + } + ] + }, + { + "section": "Project and ecosystem", + "title": "Adoption and trust", + "rows": [ + { + "id": "community", + "title": "Adoption signals" + }, + { + "id": "importFromOtherDbs", + "title": "Import from other databases" + }, + { + "id": "benchmarks", + "title": "Published benchmarks" + } + ] + }, + { + "section": "Project and ecosystem", + "title": "AI and coding agents", + "rows": [ + { + "id": "webmcp", + "title": "WebMCP agent tools" + }, + { + "id": "agentDocs", + "title": "Docs for coding agents" + } + ] + } + ], + "libraries": [ + { + "id": "syncom", + "name": "syncom", + "url": "https://github.com/Alexis-Technologies/syncom", + "direction": "sync engine", + "package": "@alexify/syncom", + "version": "0.0.1", + "cells": { + "version": { + "mark": "", + "text": "0.0.1 (`@alexify/syncom`)", + "source": "package.json:3" + }, + "released": { + "mark": "", + "text": "pre-release, not on npm yet", + "source": "CHANGELOG.md:8; registry.npmjs.org/@alexify%2fsyncom → 404 (2026-10-23)" + }, + "license": { + "mark": "", + "text": "MIT, the whole package", + "source": "LICENSE; package.json:106" + }, + "serverLicense": { + "mark": "", + "text": "MIT: hosting (`sync.unit()`) is in the same package", + "source": "src/hosting.js; LICENSE" + }, + "price": { + "mark": "—", + "text": "none", + "source": "package.json; LICENSE" + }, + "premiumSplit": { + "mark": "—", + "text": "no split: every feature is in the MIT package", + "source": "package.json#exports" + }, + "premiumTerms": { + "mark": "—", + "text": "MIT: no term, token or usage limit", + "source": "LICENSE" + }, + "collectionLimit": { + "mark": "✅", + "text": "none", + "source": "src/schema.js (no cap); scratch check: open() with 40 entities over createMemoryFsi()" + }, + "phoneHome": { + "mark": "", + "text": "none: no network code beyond the injected transport", + "source": "src/ (no fetch, iframe or beacon); src/wrpc.js" + }, + "deps": { + "mark": "✅", + "text": "0: devDependencies only", + "source": "package.json (no `dependencies`); scripts/size.js:109-121 (fails on any bare import in a browser bundle)" + }, + "impl": { + "mark": "", + "text": "JavaScript, CommonJS, no build step; `src/` ships as is", + "source": "src/; index.js; package.json#exports", + "detail": "Checked with Node 24.14.1: the root entry exposes only `default` to ESM, while /wrpc, /idb, /search and /contracts expose their names. The docs' `import { open }` examples are all in bundler contexts (docs/guide/browser.md, wrpc.md#in-the-browser)." + }, + "entryPoints": { + "mark": "", + "text": "root plus 4 subpaths: `/wrpc`, `/idb`, `/search`, `/contracts`", + "source": "package.json:14-40" + }, + "types": { + "mark": "⚠️", + "text": "hand-written `.d.ts`, tsd-tested; collection names typed, record data `unknown`", + "source": "index.d.ts:827-831, 1185-1188, 1341-1354; tests/*.test-d.ts" + }, + "size": { + "mark": "", + "text": "41.9 KB min+gzip: the browser entry, the whole engine", + "source": "pnpm size (scripts/size.js, esbuild), browser.js", + "detail": "With the IndexedDB backend 41.5 KB. CommonJS is not tree-shaken, so storage, schema, queries, the op log, signing and the sync protocol all come with `open()`. No validator library is included in either column (injected / wrapped separately)." + }, + "sizeSync": { + "mark": "", + "text": "42.6 KB min+gzip with the wrpc link adapter", + "source": "bench/results/size.json (pnpm bench:size)" + }, + "sizeGate": { + "mark": "✅", + "text": "`pnpm size` fails CI when the browser entry passes 42 KB", + "source": "scripts/size.js:86; .github/workflows/ci.yml:20-24" + }, + "node": { + "mark": "✅", + "text": "≥22; persists with `node:fs`; CI on Node 22 and 24", + "source": "package.json:134-136; src/fsi.js; .github/workflows/ci.yml:33-34" + }, + "deno": { + "mark": "❌", + "text": "not tested; `engines` names Node.js only", + "source": "package.json:134-136; .github/workflows/ci.yml" + }, + "bun": { + "mark": "❌", + "text": "not tested", + "source": ".github/workflows/ci.yml" + }, + "browser": { + "mark": "✅", + "text": "OPFS (default), IndexedDB (`/idb`), memory", + "source": "src/fsi.browser.js; src/idb.js; src/backend.js:40; docs/guide/browser.md" + }, + "browserReq": { + "mark": "", + "text": "secure context, OPFS, Web Crypto with Ed25519 and RSA-OAEP", + "source": "src/crypto.browser.js:12-14; src/fsi.browser.js:10, 50; docs/guide/browser.md#requirements", + "detail": "docs/guide/browser.md names OPFS and Web Crypto but not Ed25519 specifically; which browser versions expose Ed25519 in Web Crypto was not checked here. OPFS writes use `createWritable()`." + }, + "worker": { + "mark": "🔜", + "text": "a storage served from a worker to its tabs is the planned follow-up", + "source": "docs/guide/scaling.md#several-tabs", + "detail": "The browser half has no `window` dependency (src/lock.js:87 treats `self` as a browser), but running inside a worker is not tested. wrpc's `WrpcClientProxy` can already share one connection from a SharedWorker." + }, + "reactNative": { + "mark": "❌", + "text": "not a target: no React Native backend or Web Crypto shim", + "source": "docs/guide/why.md:76; docs/guide/backends.md#your-own" + }, + "expo": { + "mark": "❌", + "text": "not a target", + "source": "docs/guide/why.md:76" + }, + "capacitor": { + "mark": "❌", + "text": "not tested; a Capacitor `fsi` adapter is yours to write", + "source": "docs/guide/backends.md#your-own" + }, + "electron": { + "mark": "❌", + "text": "not documented or tested; no IPC bridge", + "source": "docs/guide/ (no Electron page)", + "detail": "The main process is Node.js and a renderer is a browser, so either half may run there; nothing checks it." + }, + "tauri": { + "mark": "❌", + "text": "not documented or tested", + "source": "docs/guide/" + }, + "flutter": { + "mark": "❌", + "text": "none", + "source": "docs/guide/" + }, + "multiTab": { + "mark": "⚠️", + "text": "one tab per storage: `open()` holds a Web Lock, a second tab gets `LOCKED`", + "source": "src/lock.js:58-95; docs/guide/scaling.md#several-tabs; docs/guide/storage.md#one-instance-per-directory", + "note": "There are no cross-tab change events and no read-only open: the other tabs wait for the lock (`lock: 'wait'`) or route reads and writes to the owner through a `BroadcastChannel`.", + "detail": "Two storages over one directory would mint the same sequence numbers, which is why the lock exists (src/lock.js:4-9)." + }, + "leader": { + "mark": "⚠️", + "text": "no election API; `lock: 'wait'` hands the storage to the next tab", + "source": "src/lock.js:58-70; docs/guide/storage.md#one-instance-per-directory" + }, + "storageContract": { + "mark": "✅", + "text": "`fsi`: five file functions — `read`, `write`, `exists`, `remove`, `list`", + "source": "src/backend.js:7-31; index.d.ts:37-60; docs/guide/backends.md", + "detail": "Optional `sync` (fsync, used by `durable: true`) and `create` (exclusive create, used by the lock). Everything above bytes — records, log, chain, catalog, indexes, queries — is the engine's and identical on every backend." + }, + "customBackend": { + "mark": "✅", + "text": "five functions; `tests/suites/fsi.js` is the contract as a test", + "source": "docs/guide/backends.md#your-own; tests/suites/fsi.js; src/backend.js:35-82 (the memory adapter, ~45 lines)", + "detail": "The contract suite lives in the repository, not in the npm package (`files` excludes tests/). An adapter must make `write` whole-file and visible to the next `read`, and `list` return file names, sorted." + }, + "memoryBackend": { + "mark": "✅", + "text": "`createMemoryFsi()`: a `Map` of files; Node and browser", + "source": "src/backend.js:35-82; docs/guide/backends.md#the-four-that-ship", + "detail": "Files are kept as JSON strings, so reads still parse; two storages over one memory fsi with different paths form a mesh in one process (how the sync-engine suite runs)." + }, + "indexeddb": { + "mark": "✅", + "text": "`@alexify/syncom/idb` `createIdbFsi()`: each file a row keyed `[dir, name]`", + "source": "src/idb.js:8-37; docs/guide/browser.md#opfs-or-indexeddb", + "detail": "One IndexedDB transaction per file write, resolved on `complete`; `durable: true` is a no-op there." + }, + "opfs": { + "mark": "✅", + "text": "the default browser backend; async `createWritable()`, main thread or worker", + "source": "src/fsi.browser.js:47-53; docs/guide/browser.md", + "detail": "One file per record, per chain block and per log batch, written through the async API, not sync access handles. RxDB's own storage article notes that one file per document is the slow way to write to OPFS (https://rxdb.info/articles/localstorage-indexeddb-cookies-opfs-sqlite-wasm.html)." + }, + "localStorage": { + "mark": "❌", + "text": "none shipped; an `fsi` over it would be five functions", + "source": "docs/guide/backends.md#the-four-that-ship" + }, + "nodeFs": { + "mark": "✅", + "text": "the default under Node: `node:fs/promises`, plain JSON files", + "source": "src/fsi.js; docs/guide/backends.md#the-four-that-ship" + }, + "sqlite": { + "mark": "❌", + "text": "none", + "source": "src/ (no SQLite adapter)" + }, + "expoFs": { + "mark": "❌", + "text": "none; not a target, an `fsi` is injectable", + "source": "docs/guide/backends.md#your-own" + }, + "mongodbStorage": { + "mark": "❌", + "text": "none", + "source": "src/" + }, + "foundationdb": { + "mark": "❌", + "text": "none", + "source": "src/" + }, + "denokv": { + "mark": "❌", + "text": "none", + "source": "src/" + }, + "legacyStorages": { + "mark": "—", + "text": "no legacy backends", + "source": "src/" + }, + "storageTrialLimits": { + "mark": "✅", + "text": "none: every backend is in the MIT package", + "source": "package.json; LICENSE" + }, + "storageWrappers": { + "mark": "⚠️", + "text": "an `fsi` can wrap an `fsi` (bytes only); document transforms are hooks", + "source": "src/backend.js:84-105 (withSync); docs/guide/hooks.md" + }, + "remoteStorage": { + "mark": "❌", + "text": "none; a peer replicates a copy instead", + "source": "src/sync.js; docs/guide/sync.md" + }, + "sharding": { + "mark": "❌", + "text": "none", + "source": "src/" + }, + "memoryMapped": { + "mark": "❌", + "text": "none; `cache.max` entries and the catalog stay in memory", + "source": "docs/guide/storage.md#the-catalog-and-the-cache" + }, + "openCost": { + "mark": "", + "text": "the catalog (every id, indexed values) and the retained log, into memory", + "source": "src/storage.js:224-263,317-345; src/log.js:33,92-131", + "detail": "A missing or unresumable catalog is rebuilt by reading every file in data/. The log keeps 10,000 ops by default." + }, + "durability": { + "mark": "✅", + "text": "log batch first, then the record; `durable: true` fsyncs each file (node:fs)", + "note": "Files are rewritten in place, with no temp file and rename: on node:fs a crash mid-write can tear a record file, which then fails to parse until `repair(id)`.", + "source": "src/fsi.js:16-31,64-73; src/storage.js:155-161; docs/guide/backends.md#durability", + "detail": "`durable` fsyncs the file, not its directory. OPFS writables apply on close(); IndexedDB writes resolve on transaction complete. Without `durable`, the order in which the log file and the record file reach the disk is the OS's." + }, + "crashRecovery": { + "mark": "✅", + "text": "replays the log's unsealed tail; a torn newest batch is dropped", + "source": "src/storage.js:538-590; src/log.js:154-173,270-283,613", + "detail": "Checked with scratch scripts on the memory fsi: a record file missing after its log append is rebuilt on reopen; a torn newest log batch is dropped with a `log.open.unreadable` warning. A batch whose record write failed stays unsealed and is replayed at every open until it lands." + }, + "exclusiveOpen": { + "mark": "✅", + "text": "Web Locks or `lock.json` (pid); `'fail'` → `LOCKED`, `'wait'`, `false`", + "note": "Two browser tabs are two instances: the second sees `LOCKED`, so one tab or a worker must own the storage.", + "source": "src/lock.js:83-131; docs/guide/storage.md#one-instance-per-directory", + "detail": "A lockfile is stale when its pid is gone, or after 30 minutes when that cannot be told; per machine only." + }, + "integrityRepair": { + "mark": "✅", + "text": "cold reads re-verified against the chain, torn files too (`INTEGRITY`); `repair(id)` replays the log", + "note": "`repair(id)` rebuilds a record while the log keeps a whole-record op for it; otherwise it is `UNRECOVERABLE`, and a peer's snapshot is the way back.", + "source": "src/entries.js (load); src/storage.js (repair); tests/storage.fsi.test.js", + "detail": "`integrity: 'lazy'` or `chain: false` turn the per-read check off. `repair` needs a whole-record op still in the log, otherwise `UNRECOVERABLE`." + }, + "quotas": { + "mark": "❌", + "text": "none; past the log append a failed record file is held in memory and reported (`UNPERSISTED`)", + "source": "src/storage.js (#land, #unpersisted); src/commit.js (persistLocal); tests/storage.fsi.test.js", + "detail": "No `navigator.storage.persist()` request; the docs note the browser may evict OPFS." + }, + "onDiskFormat": { + "mark": "✅", + "text": "plain JSON files: `data/.json`, log batches, chain blocks, keys", + "source": "docs/guide/storage.md#on-disk; docs/guide/architecture.md#on-disk" + }, + "writeFootprint": { + "mark": "", + "text": "three per local write: log batch, chain block, record (two with `chain: false`)", + "note": "The chain is never compacted: one block file per local write and per applied batch, kept for good.", + "source": "docs/guide/chain.md#through-storage; docs/guide/architecture.md#a-local-write", + "detail": "Measured: a record holding 45 bytes of data is a 509-byte file, its per-field clocks each carrying the 43-character node id." + }, + "compression": { + "mark": "❌", + "text": "none", + "source": "src/" + }, + "docCache": { + "mark": "✅", + "text": "`cache: { max: 5000 }` entries; a live `Record` pins its own (WeakRef)", + "source": "src/entries.js:62-73; src/options.js:9,53-58; docs/guide/storage.md#the-catalog-and-the-cache" + }, + "attachments": { + "mark": "❌", + "text": "none: JSON records only, no binary type", + "source": "src/shape.js; docs/guide/storage.md" + }, + "localDocs": { + "mark": "⚠️", + "text": "only encrypted records stay local; no plain local documents", + "source": "docs/guide/storage.md#encryption-at-rest" + }, + "tombstoneCleanup": { + "mark": "❌", + "text": "none: a tombstone keeps its file (data emptied) and catalog row", + "source": "src/catalog.js:12-14; docs/guide/storage.md#on-disk", + "detail": "Checked: a deleted record's file is `{ data: {}, meta: { deleted } }`, a purged one adds `purged`. The op log is bounded by retention; tombstone files and chain blocks are not." + }, + "deleteDatabase": { + "mark": "❌", + "text": "no API; remove the directory yourself", + "source": "src/storage.js; src/backend.js (no directory removal in the contract)" + }, + "storageMigration": { + "mark": "❌", + "text": "no tool; the same JSON files on every backend", + "source": "docs/guide/backends.md", + "detail": "Copying every file from one fsi to another opens fine (checked memory to memory), but `list` names files only, so a copier must know the directory layout." + }, + "backup": { + "mark": "❌", + "text": "no API; the Node directory is plain JSON, copy it closed", + "note": "Restoring an older copy of a node's directory is caught at the next hello (`REWOUND`): the node moves past the seqs its host holds and bootstraps the lost state back; writes made between the restore and that hello are lost to the host.", + "source": "docs/guide/sync.md#the-log; src/sync.js (#rewound, #rewind); tests/sync.retention.test.js" + }, + "jsonDump": { + "mark": "⚠️", + "text": "`snapshot()` pages out records with meta, tombstones too; `mergeSnapshot()` joins them", + "source": "docs/reference/api.md (snapshot, mergeSnapshot); src/bootstrap.js", + "detail": "This is the bootstrap API: no file format or schema stamp; a join is a CRDT merge recorded as `merge` ops, not an overwrite; encrypted records are left out (checked)." + }, + "model": { + "mark": "", + "text": "JSON records addressed by id; collections optional (`$type`)", + "source": "docs/guide/storage.md#collections; src/record.js:160-215", + "detail": "`Collection.get(id)` is `storage.get(id)` with no `$type` filter: `storage.Post.get(authorId)` returned the Author record (checked by running)." + }, + "schemaless": { + "mark": "✅", + "text": "no schema needed; `{}` is a bare collection", + "source": "docs/guide/storage.md#collections; src/schema.js:331" + }, + "collectionNames": { + "mark": "✅", + "text": "any non-empty string the storage does not answer to (`get`, `close`… refused at `open()`)", + "source": "src/storage.js (#defineSchema); tests/storage.test.js" + }, + "fieldNames": { + "mark": "✅", + "text": "any JSON key; `__proto__` ignored, `$type` reserved", + "source": "src/ops.js:151-152; src/commit.js:31-38; src/record.js:76-90", + "detail": "A field named like a `Record` member (`save`, `data`, `delete`) stays in the data but is not a property of the live Record (warned as `record.shadowed`)." + }, + "primaryKey": { + "mark": "✅", + "text": "the record id, outside the data; the validator sees it under `idField`", + "source": "src/schema.js:386-404; docs/guide/storage.md#validation", + "detail": "An `id` passed inside the data is not the record id: with a validator it is dropped from what is written, without one it is stored as a plain field (checked by running). Choose the id with `set(id, data)`." + }, + "compositeKey": { + "mark": "❌", + "text": "none; a composite `unique` key instead", + "source": "src/schema.js (explicitUnique); docs/guide/query.md#unique-keys" + }, + "idGeneration": { + "mark": "✅", + "text": "`getId`: UUID by default; sync or async generator of your own", + "source": "src/storage.js:118-120,861-871; docs/guide/storage.md#custom-identifiers" + }, + "idRules": { + "mark": "✅", + "text": "`[A-Za-z0-9_@-][A-Za-z0-9._@-]{0,127}`, checked on every write and peer op", + "source": "src/shape.js:21-30; docs/guide/storage.md#custom-identifiers" + }, + "immutableFields": { + "mark": "⚠️", + "text": "only `$type` (the collection) and the id", + "source": "src/commit.js:26-38; docs/guide/storage.md#collections" + }, + "schemaFormat": { + "mark": "✅", + "text": "none of its own: a Standard Schema, a function, or `validator.compile()`", + "source": "src/schema.js:89-125; docs/guide/schema.md#other-validators", + "detail": "metaschema is the first-class fit (indexes, unique keys and the id field read off it); zod, valibot, arktype through `~standard`; JSON Schema or TypeBox through an injected Ajv." + }, + "validators": { + "mark": "✅", + "text": "whatever you inject: metaschema, zod, valibot, arktype, ajv, TypeBox; none bundled", + "source": "src/schema.js:1-14,89-125; docs/guide/schema.md" + }, + "validationDefault": { + "mark": "✅", + "text": "on for every entity with a `schema`; `validate: false` opts out", + "source": "src/commit.js:153-202; docs/guide/storage.md#validation", + "detail": "A record written without `$type` (outside any collection) has no validator: `storage.set(id, …)` bypasses a collection's schema (checked by running)." + }, + "validatorOutput": { + "mark": "✅", + "text": "its value is written: defaults, aliases, stripped keys, decoders", + "source": "src/commit.js:153-202; src/schema.js:393-404; docs/guide/schema.md#how-a-write-is-validated" + }, + "checkOnly": { + "mark": "✅", + "text": "`storage.check(entity, data)` returns the validated value", + "source": "src/storage.js:661-669; docs/guide/schema.md#the-reference" + }, + "tsInference": { + "mark": "❌", + "text": "records are `unknown`; only collection names are typed", + "source": "index.d.ts:827-831,1185-1188,1341-1355" + }, + "defaults": { + "mark": "✅", + "text": "from the validator (metaschema `default`, zod `.default()`), folded into the op", + "source": "docs/guide/schema.md#how-a-write-is-validated; src/commit.js:153-202" + }, + "required": { + "mark": "✅", + "text": "as the validator declares them", + "source": "docs/guide/storage.md#validation" + }, + "nested": { + "mark": "⚠️", + "text": "stored and validated; merged as one top-level register", + "note": "Concurrent edits to two keys of one nested object keep only one side, and `update({ 'a.b': 1 })` writes a top-level field named `a.b` (both documented in the Storage and Sync guides).", + "source": "src/ops.js:320-345 (applyFields over top-level keys); docs/guide/sync.md#registers-counters-and-sets" + }, + "schemaVersion": { + "mark": "✅", + "text": "`schema.version` on every op (`v`) and record (`meta.v`); snapshot hash", + "source": "docs/guide/schema.md#versions; src/commit.js:110-129; src/ops.js:23-27" + }, + "migration": { + "mark": "✅", + "text": "lens on read (migrations, then the validator); `migrate()` rewrites, idempotent", + "source": "src/schema.js:348-378; src/storage.js:453-505; docs/guide/schema.md#rewriting-migrate", + "detail": "`migrate()` re-adds counters and sets after the whole-record write (checked by running: `{ migrated: 1, counters: 1 }`, then `{ migrated: 0 }`)." + }, + "readOld": { + "mark": "✅", + "text": "read through the lens; writes allowed", + "source": "docs/guide/schema.md#reading-an-older-record-the-lens; src/storage.js:453-464" + }, + "peerVersions": { + "mark": "✅", + "text": "the op carries `v`; lens for older, `ahead: 'apply' | 'hold'` for newer", + "source": "docs/guide/schema.md#a-newer-peer-ahead; docs/guide/sync.md#schemas" + }, + "bulk": { + "mark": "❌", + "text": "none; one op per call (a batch is `Promise.all` of `insert()`)", + "source": "index.d.ts:1176-1247; bench/compare/adapters/syncom.js:40" + }, + "upsert": { + "mark": "✅", + "text": "`set(id, data)` replaces the record wholesale; `Collection.set()`", + "source": "src/storage.js:906-909; src/record.js:194-197", + "detail": "A whole-record write resets the record's counters and sets (docs/guide/sync.md#whole-record-writes-and-deletes)." + }, + "insertIfAbsent": { + "mark": "❌", + "text": "none; `has()` then `set()` is two calls", + "source": "index.d.ts:1176-1247" + }, + "patch": { + "mark": "✅", + "text": "`update(id, delta)`; `record.field = v` then `save()`", + "source": "src/storage.js:927-931; src/record.js:131-136" + }, + "unsetField": { + "mark": "❌", + "text": "only by a whole-record `set()`; `undefined` in a delta is dropped", + "source": "src/commit.js:218; src/shape.js:14", + "detail": "Checked by running: `update(id, { a: undefined })` left `a` as it was; `null` stores `null`." + }, + "modifyFn": { + "mark": "❌", + "text": "no `modify(fn)`; `get()` then `swap()` in a retry loop", + "source": "index.d.ts:1176-1247" + }, + "operators": { + "mark": "⚠️", + "text": "typed ops only: `update`, `inc`, `add`/`remove`; no `$push`, `$min`, `$rename`", + "source": "docs/guide/sync.md#operations" + }, + "byQuery": { + "mark": "❌", + "text": "none; `select().fetch()`, then one write per row", + "source": "index.d.ts (Cursor); src/query.js" + }, + "cas": { + "mark": "✅", + "text": "`swap(id, changes, prev)` compares field values inside the write queue", + "source": "src/storage.js:993-1003; src/commit.js:53-61", + "detail": "Local only: a peer's concurrent write is merged, not refused." + }, + "localConflicts": { + "mark": "✅", + "text": "one mutation queue; no local conflict errors", + "source": "src/storage.js:738-762; src/commit.js:209-245" + }, + "transactions": { + "mark": "❌", + "text": "none; every write is one op on one record", + "source": "docs/guide/sync.md#operations" + }, + "changeAndDelete": { + "mark": "❌", + "text": "`delete()` carries no data; two ops", + "source": "src/storage.js:911-914" + }, + "counters": { + "mark": "✅", + "text": "`inc()`: one running sum per origin", + "note": "A counter that one node resets while another increments it does not converge to the intuitive value; see Guarantees and limits in the Sync guide.", + "source": "src/ops.js:17-20,96-105; docs/guide/sync.md#registers-counters-and-sets" + }, + "sets": { + "mark": "✅", + "text": "`add()`/`remove()`: observed-remove set, read sorted", + "source": "src/ops.js:21-26,115-140; docs/guide/sync.md#registers-counters-and-sets" + }, + "lists": { + "mark": "❌", + "text": "no list type; an array is a register or a set", + "source": "src/ops.js" + }, + "text": { + "mark": "❌", + "text": "none", + "source": "src/ops.js" + }, + "crdtModel": { + "mark": "✅", + "text": "built into every write: register, counter or OR-set per field", + "source": "src/ops.js:1-50; docs/guide/sync.md#registers-counters-and-sets" + }, + "mergeGranularity": { + "mark": "✅", + "text": "both kept: last writer wins per top-level field", + "source": "src/ops.js:320-345", + "detail": "Checked by running on two storages exchanging ops. Overlaps the replication area's conflict row." + }, + "registerWinner": { + "mark": "", + "text": "the later stamp `(ts, origin)`; a node's clock never falls behind what it saw", + "source": "docs/guide/sync.md#clocks; src/ops.js:320-345" + }, + "mergeHistory": { + "mark": "✅", + "text": "op log beside the records, with retention; records keep per-field stamps", + "source": "src/log.js; src/ops.js:9-33; docs/guide/sync.md#the-log" + }, + "mergeBypass": { + "mark": "✅", + "text": "none: every write is an op; mixing field kinds throws `FIELD_KIND`", + "source": "src/commit.js:39-50; docs/guide/sync.md#registers-counters-and-sets" + }, + "conditionalOps": { + "mark": "❌", + "text": "none that replicate; `swap()` is a local check", + "source": "src/storage.js:993-1003" + }, + "deltaHelpers": { + "mark": "✅", + "text": "`diff`, `merge`, `apply`: pure functions; numbers add, arrays union", + "note": "Storage does not replicate through them, and `merge({ hits: 10 }, diff({ hits: 10 }, { hits: 3 }))` is 13: a delta is not a plain patch.", + "source": "src/crdt.js; docs/guide/crdt.md" + }, + "meta": { + "mark": "", + "text": "`meta`: per-field `[ts, origin]`, reset stamp, tombstone, counters, sets, `v`", + "source": "src/ops.js:9-33" + }, + "delete": { + "mark": "✅", + "text": "tombstone with empty data; a newer write revives the record", + "source": "src/ops.js:475-480; docs/guide/sync.md#whole-record-writes-and-deletes", + "detail": "The op log keeps earlier payloads until retention drops them or `purge()` scrubs them (checked by running)." + }, + "documentObjects": { + "mark": "✅", + "text": "`record(id)`: one live, editable `Record` per id, refreshed on any change", + "source": "src/record.js:28-158; src/storage.js:1005-1013; docs/guide/storage.md#records" + }, + "identity": { + "mark": "✅", + "text": "one `Record` per id while referenced (WeakRef); `get()` returns new copies", + "source": "src/storage.js:141-145,370-376" + }, + "returnedData": { + "mark": "✅", + "text": "JSON copies in and out; editing them changes nothing", + "source": "src/shape.js:9-14; src/storage.js:883-892", + "detail": "`getCachedData()` and `snapshot()` hand out the raw objects on purpose." + }, + "orm": { + "mark": "❌", + "text": "none", + "source": "src/record.js" + }, + "relations": { + "mark": "⚠️", + "text": "a `Record` turns any string equal to a known id into a `Reference`", + "note": "Nothing declares the relation, so an unrelated string that matches an id becomes a `Reference` too (checked by running).", + "source": "src/record.js:14-27,55-70; docs/guide/storage.md#references" + }, + "rxState": { + "mark": "❌", + "text": "none", + "source": "index.d.ts" + }, + "pipeline": { + "mark": "❌", + "text": "none; `afterWrite`, `change` and `watch()` are not checkpointed", + "source": "docs/guide/hooks.md; src/query.js" + }, + "contracts": { + "mark": "⚠️", + "text": "`@alexify/syncom/contracts`: arrow functions stored in the chain, run by `new Function`", + "note": "Load contracts only from a chain you control; the parser takes a single arrow function, and the result is written with `saveData()`.", + "source": "src/contract.js; docs/guide/contracts.md" + }, + "queryLanguage": { + "mark": "✅", + "text": "Mango-shaped selector: `collection.select(query)`, then cursor builders", + "source": "src/query.js:106-133; docs/guide/query.md#the-query" + }, + "queryBuilder": { + "mark": "⚠️", + "text": "cursor chains `order()`, `desc()`, `limit()`, `offset()`, `filter()`, `projection()`; the predicate is JSON only", + "source": "src/query.js:231-269; index.d.ts:1296-1311" + }, + "opComparison": { + "mark": "✅", + "text": "all six; `$gt`…`$lte` hold only within one kind (number, string, boolean)", + "source": "src/query.js:43-54,77-83", + "detail": "`{ priority: { $gt: '2' } }` over numbers matches nothing (verified). On an array field, any element may match." + }, + "opSet": { + "mark": "⚠️", + "text": "`$in`, `$nin`; no `$all` (an `$and` of equalities instead)", + "source": "src/query.js:79-82,91", + "detail": "`{ tags: { $all: [...] } }` is `TypeError: select(): unknown operator $all` (verified)." + }, + "opExistence": { + "mark": "⚠️", + "text": "`$exists` only (`null` counts as present); no `$type`", + "source": "src/query.js:84-86; docs/guide/query.md#the-query" + }, + "opArray": { + "mark": "⚠️", + "text": "a scalar matches any element; no `$elemMatch`, no `$size` (`filter()` instead)", + "source": "src/query.js:37-41,91; docs/guide/query.md#the-query" + }, + "opRegex": { + "mark": "✅", + "text": "`RegExp` or source string, `$options` flags; 1000 characters at most; `g`/`y` dropped", + "source": "src/query.js:56-68,87-90" + }, + "opLogical": { + "mark": "✅", + "text": "`$and`, `$or`, and `$not` over a whole query; no `$nor`, no field-level `$not`", + "source": "src/query.js:118-124", + "detail": "`$nor: [a, b]` is written `$not: { $or: [a, b] }`; `{ f: { $not: { $gt: 5 } } }` is `$not: { f: { $gt: 5 } }`. A field-level `$not` is `unknown operator` (verified)." + }, + "opMod": { + "mark": "❌", + "text": "not supported (`filter()` instead)", + "source": "src/query.js:91" + }, + "jsPredicate": { + "mark": "✅", + "text": "`filter(fn)` over the loaded row, applied before `limit()`", + "source": "src/query.js:255-260,298-300" + }, + "dottedPaths": { + "mark": "✅", + "text": "in queries, `order()`, `projection()`, `index` and `search`; `items.0.n` reaches into arrays", + "source": "src/catalog.js:29-40; src/query.js:146-152,328-333" + }, + "queryValidation": { + "mark": "✅", + "text": "unknown operator or bad argument: `TypeError` at `select()`; field names are not checked", + "source": "src/query.js:70-133; docs/guide/query.md#the-query", + "detail": "A misspelled field matches nothing, silently (verified)." + }, + "typedQueries": { + "mark": "⚠️", + "text": "`MangoQuery` is untyped; rows are `QueryRow` (`Record`)", + "source": "index.d.ts:1284-1311; index.d.ts:827-831" + }, + "immutableQueries": { + "mark": "❌", + "text": "builders change the cursor in place (`one()` leaves it as it was)", + "source": "src/query.js:231-253,349-352" + }, + "docMatch": { + "mark": "❌", + "text": "the compiled predicate is internal (`compileQuery` is not exported)", + "source": "src/query.js:398; src/index.js" + }, + "sort": { + "mark": "✅", + "text": "`order()`/`desc()`: several keys, any field, dotted; stable", + "source": "src/query.js:17-35,146-152; docs/guide/query.md#the-cursor", + "detail": "Order: absent/null, booleans, numbers, strings; arrays and objects keep their place. With no `order()`, rows come in id order, or `$text` rank." + }, + "sortIndex": { + "mark": "❌", + "text": "no sorted index: every candidate is read, then ordered", + "source": "src/query.js:291-326; docs/guide/query.md#the-plan", + "detail": "Except a covered cursor (order and projection on indexed fields), ordered from the catalog with no record read. Verified: `order('a').limit(2)` read 40 of 40 records; with `projection(['a'])`, 0." + }, + "topK": { + "mark": "✅", + "text": "`TopK` keeps the best `offset + limit` rows as candidates stream by", + "source": "src/query.js:172-203,297" + }, + "skipLimit": { + "mark": "✅", + "text": "`offset(n)`, `limit(n)`; an unordered cursor stops reading at enough matches", + "source": "src/query.js:245-253,294-296,319-322", + "detail": "Verified: an unordered `limit(2)` over 50 records read 2." + }, + "streaming": { + "mark": "❌", + "text": "`for await` yields an already fetched array; keyset paging by hand", + "source": "src/query.js:393-395" + }, + "count": { + "mark": "✅", + "text": "`count(query)` for any query; from the catalog alone when every field is indexed", + "source": "src/query.js:345-347; src/record.js:189-191", + "detail": "Verified: an indexed range count read 0 records; any other count reads the candidates. `limit`/`offset` are ignored." + }, + "findById": { + "mark": "✅", + "text": "`get(id)` (a copy), `record(id)` (a live Record); `hasRecord(id)`; a collection's `get` only its own", + "source": "src/storage.js:879-899; src/record.js:179-181,210-212" + }, + "findByIds": { + "mark": "❌", + "text": "no batch get; `select({ id: { $in } })` reads the whole collection", + "source": "src/catalog.js:294-362", + "detail": "`id` is not posted in the catalog; verified 39 of 40 records read for three ids." + }, + "findOne": { + "mark": "✅", + "text": "`cursor.one()` → the row or `null`", + "source": "src/query.js:349-352" + }, + "projection": { + "mark": "✅", + "text": "`projection(fields)`; answered from the catalog when they are indexed", + "source": "src/query.js:262-282,328-333" + }, + "resultObjects": { + "mark": "", + "text": "plain JSON copies `{ id, ...data }`, the lens applied; new objects per `fetch()`", + "source": "src/query.js:335-343; docs/guide/query.md#the-cursor", + "detail": "A cursor does not run `beforeRead`/`afterRead`; `get(id)` does." + }, + "aggregation": { + "mark": "❌", + "text": "none; compute over `fetch()` rows", + "source": "docs/guide/query.md" + }, + "joins": { + "mark": "❌", + "text": "a cursor reads one collection; `Reference.record()` follows one id", + "source": "src/record.js:184-186; index.d.ts:1314-1316" + }, + "encryptedFields": { + "mark": "❌", + "text": "an `{ encrypted: true }` record is listed, never a query row", + "source": "src/catalog.js:10-13; src/query.js:305; docs/guide/query.md#the-cursor" + }, + "secondaryIndexes": { + "mark": "✅", + "text": "entity `index: ['field', …]`, or read off a metaschema schema", + "source": "index.d.ts:484-485; docs/guide/query.md#the-plan" + }, + "indexKind": { + "mark": "⚠️", + "text": "equality posting lists; ranges, prefixes and sorts do not use them", + "source": "src/catalog.js:82-91,321-362; docs/guide/query.md#the-plan", + "note": "A range or an `order()` on an indexed field is evaluated over every candidate: the whole collection unless an indexed equality or `$in` narrows it. Checked on 200 records: `$gt` read 200, with an indexed equality 20, a covered cursor 0." + }, + "compoundIndexes": { + "mark": "⚠️", + "text": "only for `unique` keys; queries intersect single-field postings", + "source": "src/catalog.js:115-121,171-178,321-362" + }, + "arrayIndexes": { + "mark": "✅", + "text": "an indexed array is posted under each element", + "source": "src/catalog.js:82-91; tests/suites/query.js:151" + }, + "indexValues": { + "mark": "✅", + "text": "any JSON value, keyed canonically (object keys in any order)", + "source": "src/catalog.js (keyOf); tests/suites/query.js", + "detail": "Objects are keyed by key order: `{ meta: { $eq: { b: 2, a: 1 } } }` on an indexed field finds 0 of a record stored as `{ a: 1, b: 2 }`, where the unindexed path finds it (verified)." + }, + "indexStorage": { + "mark": "", + "text": "in memory (the catalog), saved to `index/catalog.json`, caught up from the log at open", + "source": "src/catalog.js:1-20; src/storage.js:312-342; docs/guide/storage.md#the-catalog-and-the-cache", + "detail": "Every live id, its indexed values and its search text must fit in memory; a first open, a stamp the log cannot resume from, or other indexes rebuild it from one scan of data/." + }, + "indexChange": { + "mark": "✅", + "text": "edit `index`; the catalog rebuilds once at the next open", + "source": "src/catalog.js:115-124,401-407" + }, + "planner": { + "mark": "✅", + "text": "indexed equalities narrow the candidates before any record is read", + "source": "src/catalog.js:294-362; docs/guide/query.md#the-plan" + }, + "indexCombination": { + "mark": "✅", + "text": "postings intersect over `$and`, union over an `$or` whose branches are all indexed", + "source": "src/catalog.js:321-362", + "detail": "Verified: `$or: [{ a }, { b }]` read 9 of 40 records; an `$or` with an unindexed branch reads all." + }, + "coveredQueries": { + "mark": "✅", + "text": "query, order and projection all indexed: answered from the catalog, no record read", + "source": "src/query.js:271-282,301-309" + }, + "explain": { + "mark": "❌", + "text": "no explain API; the plan is private to the cursor", + "source": "src/query.js:273-282" + }, + "queryOptimizer": { + "mark": "❌", + "text": "none", + "source": "docs/guide/query.md#the-plan" + }, + "queryCache": { + "mark": "❌", + "text": "none; each `fetch()` re-runs, records come from the bounded entry cache", + "source": "src/query.js:338-343; docs/guide/storage.md#the-catalog-and-the-cache" + }, + "unique": { + "mark": "✅", + "text": "`unique` fields and composites; a local write is refused, a peer's clash is a `conflict` event", + "source": "docs/guide/query.md#unique-keys; src/catalog.js:368-386", + "detail": "Verified: a duplicate `slug` and a duplicate `[org, name]` are `VALIDATION_FAILED`. Across peers both records are kept." + }, + "fulltext": { + "mark": "✅", + "text": "`$text` with `createSearch()` (`@alexify/syncom/search`): trigram TF-IDF, typo-tolerant", + "source": "src/search.js; docs/guide/query.md#full-text-search; tests/suites/search.js", + "detail": "Diacritics and case folded; ranked best first; postings in memory, rebuilt at open from the text kept in catalog.json; no phrases or boolean text operators; a custom `TextIndex` can replace it." + }, + "fulltextInQuery": { + "mark": "✅", + "text": "`$text` combines with indexed equalities, `order()`, `limit()`, `count()`, `watch()`", + "source": "src/catalog.js:302-319; docs/guide/query.md#full-text-search" + }, + "vector": { + "mark": "❌", + "text": "none", + "source": "src/query.js" + }, + "liveQueries": { + "mark": "✅", + "text": "`cursor.watch(listener)`: runs now, then after every change to the collection", + "source": "src/query.js:354-391; docs/guide/query.md#watch", + "detail": "Changes that land while a run is in flight coalesce into one more run; a peer's batch is one run. No time-based throttle." + }, + "liveIncremental": { + "mark": "❌", + "text": "every wake re-runs the whole cursor", + "source": "src/query.js:359-391" + }, + "liveDistinct": { + "mark": "❌", + "text": "the listener runs after any change to the collection, same rows or not", + "source": "src/query.js:365-372", + "detail": "Verified: updating a record outside the result called the listener with the same rows." + }, + "liveCount": { + "mark": "❌", + "text": "no; `watch()` hands rows (`rows.length`)", + "source": "src/query.js:345-391" + }, + "collectionStream": { + "mark": "⚠️", + "text": "storage-wide `change` event `(op, via)`; no per-collection stream", + "source": "index.d.ts:1095-1097; docs/guide/hooks.md#storage", + "detail": "A `set`, `inc` or `delete` op names the record id, not its collection; the listener looks the record up." + }, + "documentObservable": { + "mark": "✅", + "text": "a live `Record` emits `update (data, delta)`, `null` once deleted", + "source": "src/record.js:99-120; index.d.ts:1139-1142" + }, + "reactivityPrimitive": { + "mark": "⚠️", + "text": "callbacks and events; no observable or signal adapter", + "source": "src/query.js:359; src/emitter.js" + }, + "liveRemote": { + "mark": "✅", + "text": "a peer's ops and snapshot pages wake `watch()`; a batch is one run", + "source": "tests/suites/query.js:271-293; src/catalog.js:207-231" + }, + "replModel": { + "mark": "", + "text": "operation log; version vectors per origin and scope; pages of ops", + "source": "src/log.js; src/vector.js:32-65; docs/guide/sync.md#the-log" + }, + "wire": { + "mark": "", + "text": "ops as positional `packOps` arrays, signed; a `set` carries only its delta", + "source": "src/ops.js (packOps); src/hosting.js:205-236; docs/guide/sync.md#operations", + "detail": "About 190 bytes of signature fields per op (docs/guide/sync.md#signatures). A bootstrap snapshot is the exception: whole records with their merge metadata." + }, + "replHistory": { + "mark": "✅", + "text": "every retained op travels, intermediate writes included", + "source": "src/sync.js:503-517; src/log.js:558-586", + "detail": "Past `retention.ops` (default 10 000) the oldest sealed segments go; a peer the log no longer covers bootstraps from a snapshot." + }, + "protocolVersion": { + "mark": "✅", + "text": "`PROTOCOL` 1 in `hello`; another version is refused with 400", + "source": "src/protocol.js:8; src/hosting.js:167-169", + "detail": "The 17 `packOps` slots are appended, never reordered; a reader of fewer slots ignores the rest (CLAUDE.md)." + }, + "schemaHandshake": { + "mark": "✅", + "text": "`hello` swaps `{ version, hash }`; same version, other hash emits `schema` drift", + "source": "src/sync.js:380-397; src/hosting.js:183-201; docs/guide/sync.md#schemas", + "detail": "A different version is not refused: every op carries `v`, older records are lensed, newer ops applied or held (`ahead`)." + }, + "foreignBackend": { + "mark": "❌", + "text": "none: the far end must be a syncom node hosting `unit()`", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "customTransport": { + "mark": "✅", + "text": "`SyncLink`: `call`, `subscribe` with `lastEventId`, `on`/`off`, `close`; optional `stream`", + "source": "src/link.js:7-13; docs/guide/sync.md#the-link-contract; tests/utils/memory-link.js", + "detail": "The link only carries the four procedures; the far end still runs syncom." + }, + "roundTrips": { + "mark": "", + "text": "`hello`, then one `push` per 500 ops, then one `changes` subscription", + "source": "src/sync.js:409-447, 552-569, 571-608", + "detail": "Live, ops landing in one tick go as one push and the host streams pages on the subscription. A bootstrap adds one `snapshot` call per 500 records." + }, + "collectionsPerLink": { + "mark": "✅", + "text": "one link carries every collection of the storage, filtered by scope", + "source": "src/sync.js:503-517; src/hosting.js:251-296" + }, + "replMeta": { + "mark": "", + "text": "a cursor per peer, the version vector, the op log within retention", + "source": "src/sync.js:54-57, 150-158; src/log.js:15-17; docs/guide/sync.md#the-registry" + }, + "topology": { + "mark": "", + "text": "mesh: any node dials and hosts; hubs dial each other", + "source": "docs/guide/sync.md#protocol; docs/guide/scaling.md#hubs; tests/hub.mesh.test.js" + }, + "multiMaster": { + "mark": "✅", + "text": "any node hosts `unit()`; peers dial each other directly", + "source": "src/hosting.js; docs/guide/webrtc.md#a-mesh-of-a-room" + }, + "relay": { + "mark": "✅", + "text": "ops relayed hop by hop; the version vector drops what a node has", + "source": "src/sync.js:785-807; tests/suites/sync-engine.js:52-90", + "detail": "An op reaches a node at most once per link (docs/guide/sync.md#protocol)." + }, + "browserHosts": { + "mark": "✅", + "text": "`unit()` on a wrpc `PeerHost` over a data channel", + "source": "docs/guide/webrtc.md#hosting-in-a-browser; tests/wrpc.integration.test.js:323-410" + }, + "scaleOut": { + "mark": "⚠️", + "text": "hubs dial each other; a client stays on one hub's log", + "source": "docs/guide/scaling.md#hubs; tests/hub.mesh.test.js", + "detail": "A client moved to another hub bootstraps from it. A log shared by hub processes on a broker is reserved: `open({ sync: { sharedLog } })` throws in this version (src/sync.js:98-101)." + }, + "webrtc": { + "mark": "✅", + "text": "wrpc `WrpcPeer`/`PeerLink` and `PeerHost` (`@alexify/wrpc/webrtc`)", + "source": "docs/guide/webrtc.md; tests/wrpc.integration.test.js:225-410", + "detail": "The tests run over a fake data-channel pair at the raw-channel level; a full `WrpcPeer` needs an RTC adapter and is checked by hand (CLAUDE.md). wrpc fragments every message to 16 KiB, or to the negotiated `maxMessageSize` (@alexify/wrpc@2.0.1 src/webrtc/framing.js)." + }, + "signaling": { + "mark": "✅", + "text": "wrpc's `createSignalingUnit` on your hub, or a channel you negotiated", + "source": "docs/guide/webrtc.md#signaling-on-the-hub; docs/guide/webrtc.md#without-signaling-a-raw-data-channel" + }, + "p2pRoom": { + "mark": "", + "text": "one link per pair you add (`peer.join(room)` + `addNode`); ops relay any path", + "source": "docs/guide/webrtc.md#a-mesh-of-a-room" + }, + "ice": { + "mark": "", + "text": "your `iceServers`; wrpc sets none", + "source": "@alexify/wrpc@2.0.1 src/webrtc/peer.js:138; docs/guide/webrtc.md#the-shape" + }, + "websocket": { + "mark": "✅", + "text": "a wrpc `WrpcClient` through `createWrpcLink` (`@alexify/syncom/wrpc`)", + "source": "src/wrpc.js:49-90; tests/wrpc.integration.test.js:59-160" + }, + "http": { + "mark": "⚠️", + "text": "no request–response sync; wrpc's SSE transport untested in the repo", + "source": "src/wrpc.js; scratchpad/replication/verify/syncom-over-sse.js", + "note": "In a one-off check over wrpc 2.0.1, `transport: 'sse'` synced both ways with a live update; plain `http` cannot (the subscription is refused, then every call answers 428)." + }, + "graphql": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "couchdb": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "firestore": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "supabase": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "appwrite": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "mongodb": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "nats": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342", + "detail": "wrpc has a NATS broker transport; syncom does not use it (the shared-log hub mode is reserved)." + }, + "gdrive": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "onedrive": { + "mark": "❌", + "text": "none", + "source": "docs/guide/sync.md#protocol; src/hosting.js:304-342" + }, + "conflictDetect": { + "mark": "—", + "text": "no compare step: every op merges by its field's kind", + "source": "src/ops.js; src/apply.js:234-283" + }, + "conflictResolve": { + "mark": "", + "text": "built in: per-field LWW by `(ts, origin)`, per-origin counter sums, OR-sets", + "source": "src/ops.js; docs/guide/sync.md#registers-counters-and-sets" + }, + "customMerge": { + "mark": "❌", + "text": "none: the rules are fixed; `beforeApply` can only skip an op", + "source": "src/apply.js:253-258; docs/guide/hooks.md (Skipping a peer's op)" + }, + "conflictWhere": { + "mark": "", + "text": "on every node, by the same deterministic rules", + "source": "docs/guide/sync.md#clocks" + }, + "conflictEvents": { + "mark": "⚠️", + "text": "unique-key clashes only (`conflict`); a lost concurrent write is silent", + "source": "src/apply.js:213-231; docs/guide/hooks.md" + }, + "serverCompare": { + "mark": "—", + "text": "nothing to compare: ops commute", + "source": "src/ops.js; docs/guide/sync.md" + }, + "causality": { + "mark": "", + "text": "version vectors for delivery; per-field stamps; no causal context per record", + "source": "src/vector.js:32-65; docs/guide/sync.md#whole-record-writes-and-deletes", + "detail": "Hence the documented counter-reset case that does not converge as expected." + }, + "clocks": { + "mark": "", + "text": "decide concurrent register writes; a clock never falls behind what it saw", + "source": "src/vector.js:78-94; docs/guide/sync.md#clocks", + "detail": "Skew changes who wins, never whether replicas agree; `maxSkew` (24 h) refuses stamps far ahead." + }, + "live": { + "mark": "✅", + "text": "built in: the host's `changes` subscription pushes ops as they land", + "source": "src/hosting.js:251-296; src/sync.js:571-608" + }, + "resume": { + "mark": "✅", + "text": "the link resumes the subscription from its last event id; push by vector", + "source": "src/wrpc.js:63-78; src/sync.js:333-347; docs/guide/sync.md#protocol" + }, + "restart": { + "mark": "✅", + "text": "cursor, remote identity and vector are on disk; no bootstrap", + "source": "src/sync.js:108-130; tests/suites/sync-engine.js:370-397" + }, + "reconnect": { + "mark": "✅", + "text": "the link's: wrpc reconnects with backoff and a heartbeat; syncom greets again", + "source": "docs/guide/wrpc.md#dialing-a-node; src/sync.js:341-347" + }, + "retry": { + "mark": "✅", + "text": "a failed push leaves the peer `behind`, caught up later; backoff 1 s to 60 s", + "source": "src/sync.js:465-493; tests/sync.recovery.test.js", + "detail": "A page that fails to apply is retried from the saved cursor; three failures in a row leave the peer `stuck` (src/sync.js:616-634)." + }, + "dup": { + "mark": "✅", + "text": "duplicates dropped by the vector; an op ahead of its stream dropped as a gap", + "source": "src/apply.js:182-189; tests/storage.ops.test.js:218-275", + "detail": "docs/guide/sync.md:173-174 says such an op \"is set aside until the ones before it arrive\"; the code drops it, counts `gaps`, and it lands only when sent again." + }, + "offline": { + "mark": "✅", + "text": "own ops kept until the registered nodes hold them; pushed when a peer is back", + "source": "src/sync.js (#undelivered, #lost); src/log.js (retain); tests/sync.retention.test.js", + "note": "Ops written before any node was registered, and compacted since, cannot reach a host; it is reported once per hello (`UNDELIVERABLE`).", + "detail": "scratchpad/replication/verify/syncom-offline-retention.js: retention 50, 300 offline inserts → the host holds 0 records, and a later write does not land either; the dialer reports no error." + }, + "bootstrap": { + "mark": "✅", + "text": "replays the host's log, or past its retention a streamed snapshot", + "source": "src/sync.js:686-756; src/bootstrap.js:37-61; docs/guide/sync.md#bootstrap", + "detail": "Snapshot records carry their merge metadata, arrive as NDJSON over a wrpc binary stream, and join as `merge` ops, so a bootstrap is transitive." + }, + "longOffline": { + "mark": "✅", + "text": "a snapshot of its scopes once the host's log no longer covers it", + "source": "src/log.js:558-575; src/hosting.js:264-268; docs/guide/sync.md#the-log" + }, + "batch": { + "mark": "", + "text": "`pageSize` 500 ops per push and per page; 500 records per snapshot page", + "source": "src/protocol.js:9; src/hosting.js:22, 304-312; docs/guide/sync.md#options", + "detail": "`pageBytes` (4 MiB): a snapshot page above it is sent inline rather than streamed." + }, + "flow": { + "mark": "✅", + "text": "per-peer outbox capped (`maxOutbox` 10 000); pages applied one at a time", + "source": "src/sync.js:39-41, 581-585, 785-807", + "detail": "Beyond the cap the backlog is dropped and the peer recovers from the vector. Snapshot pages stream with wrpc backpressure." + }, + "partial": { + "mark": "✅", + "text": "per-record scopes; the host bounds a peer's reads and writes by the record's scope", + "source": "src/commit.js (scopeData); src/apply.js (outOfBounds); src/hosting.js; tests/sync.scopes.test.js", + "detail": "src/commit.js:110-115 computes the scope from `payload.data` (the delta of a `set`). scratchpad/replication/verify/syncom-scope-of-set2.js: a peer granted only `Other` receives `{ title: \"secret v2\" }` of a `Doc` record. scratchpad/replication/verify/syncom-scope-write.js: the same peer overwrites the host's `Doc` record. tests/suites/sync-engine.js:327-350 covers inserts only." + }, + "partialLive": { + "mark": "✅", + "text": "one subscription serves the granted scopes; other scopes' ops are skipped", + "source": "src/hosting.js:251-296; tests/sync.scopes.test.js:97" + }, + "revoke": { + "mark": "❌", + "text": "no revocation: the peer keeps what it received", + "source": "docs/guide/sync.md#scopes" + }, + "oneWay": { + "mark": "❌", + "text": "every link pushes and subscribes; scopes bound both directions alike", + "source": "src/sync.js:409-447; src/hosting.js:205-236" + }, + "deletes": { + "mark": "✅", + "text": "a `delete` op leaves a tombstone on every node; a `purge` op erases", + "source": "docs/guide/sync.md#whole-record-writes-and-deletes; src/ops.js", + "detail": "Tombstones are never cleaned up, so a long-offline peer cannot resurrect a record by absence; a newer write revives it by stamp." + }, + "replAttachments": { + "mark": "❌", + "text": "none: records are JSON, with no binary type", + "source": "docs/guide/storage.md" + }, + "lifecycle": { + "mark": "✅", + "text": "`sync.start()`/`stop()` for all peers; `enableNode(url, false)` per peer", + "source": "src/sync.js:178-186, 218-235" + }, + "awaitSync": { + "mark": "⚠️", + "text": "`sync(url?)` resolves once pushed; nothing awaits the inbound side", + "source": "src/sync.js:237-248; index.d.ts (SyncManager.sync)", + "detail": "`storage.sync.toPromise('sync')` resolves on the next push or received page." + }, + "resync": { + "mark": "✅", + "text": "`sync()` pushes what peers lack; `enableNode` off and on greets again", + "source": "src/sync.js:178-186, 237-248" + }, + "status": { + "mark": "✅", + "text": "`peers()`: connected, cursor, pending, behind, stuck, lastError; `hosted()`; events", + "source": "src/sync.js:257-276; docs/guide/sync.md#lifecycle-and-events", + "detail": "Events `peer`, `sync`, `bootstrap`, `error`, `refused`, `hello`, `node`; storage `change` and `apply`; OpenTelemetry spans and a replication-lag metric." + }, + "replMultiTab": { + "mark": "⚠️", + "text": "one tab owns the storage and its sync; others get `LOCKED` or wait", + "source": "docs/guide/scaling.md#several-tabs; src/lock.js", + "detail": "A SharedWorker can share wrpc's connection (`WrpcClientProxy`), not the storage; a storage served from a worker is the planned follow-up." + }, + "serverProduct": { + "mark": "✅", + "text": "`sync.unit()`: four procedures for any wrpc router, in the same MIT package", + "source": "src/hosting.js:304-342; docs/guide/wrpc.md#hosting-a-node" + }, + "serverRuntime": { + "mark": "✅", + "text": "wherever a wrpc router runs: Node servers, a browser `PeerHost`", + "source": "docs/guide/webrtc.md#hosting-in-a-browser; tests/wrpc.integration.test.js" + }, + "adapters": { + "mark": "✅", + "text": "any wrpc host (MIT): its own `Server`, `uws`, `fastify`, `express`", + "source": "@alexify/wrpc@2.0.1 package.json (exports), express.d.ts, fastify.d.ts", + "detail": "syncom's own tests run on wrpc's `Server` only (tests/wrpc.integration.test.js:37-44)." + }, + "endpoints": { + "mark": "", + "text": "`hello`, `push`, `snapshot`, `changes` (a resumable subscription)", + "source": "src/hosting.js:336-341; docs/guide/sync.md#protocol" + }, + "remoteQuery": { + "mark": "❌", + "text": "none: a client replicates its scopes and queries locally", + "source": "src/hosting.js" + }, + "serverStorage": { + "mark": "", + "text": "a full syncom node: its own log and records over its `fsi`", + "source": "docs/guide/scaling.md#hubs" + }, + "atRest": { + "mark": "⚠️", + "text": "per record (`{ encrypted: true }`) or the whole storage; such records never sync", + "note": "The RSA key pair that unseals the records sits unprotected in `/keys/`, beside the data: whoever can read the directory can read the records.", + "source": "src/commit.js:74-104; src/options.js:116-118; docs/guide/storage.md#encryption-at-rest" + }, + "encScope": { + "mark": "", + "text": "the record's whole data; its id, collection and timestamps stay readable", + "source": "src/entries.js:135-142; src/crypto.js:34-49", + "detail": "Checked by running: the record file is `{ data: , meta: null, encrypted: true, timestamp, block }`; the file name is the id, the catalog row keeps `$type`, the chain block keeps the ciphertext's hash." + }, + "cipher": { + "mark": "✅", + "text": "AES-256-GCM with a fresh key per record, wrapped by RSA-OAEP-256", + "source": "src/crypto.js:27-61; src/crypto.browser.js:102-139", + "detail": "The same envelope on Node.js and in the browser, so ciphertext and keys move between them; an unknown envelope version is `BAD_ENVELOPE`." + }, + "keyOffDisk": { + "mark": "❌", + "text": "the RSA private key is a file in `/keys/`, beside the data", + "note": "Mode 0600 where the backend has permissions; in OPFS or IndexedDB a plain entry. No passphrase, KMS or injected key.", + "source": "src/keys.js:25-37; src/storage.js:226-227; docs/guide/storage.md#encryption-at-rest" + }, + "keyRotation": { + "mark": "❌", + "text": "no rotation API; a replaced key pair cannot open sealed records", + "source": "src/keys.js:20-37; docs/guide/storage.md#encryption-at-rest" + }, + "encAttachments": { + "mark": "—", + "text": "no attachments", + "source": "docs/reference/api.md (no attachment API)" + }, + "encExport": { + "mark": "—", + "text": "no backup or export API; `snapshot()` leaves encrypted records out", + "source": "src/entries.js:100-102; docs/guide/sync.md#bootstrap", + "detail": "A copy of the directory keeps the ciphertext, and `keys/` with it." + }, + "encSync": { + "mark": "", + "text": "encrypted records stay local: no op, no snapshot, no peer copy", + "source": "src/commit.js:74-104; src/apply.js:248-252; docs/guide/sync.md#guarantees-and-limits" + }, + "e2ee": { + "mark": "⚠️", + "text": "recipe: seal fields in `beforeWrite`, open in `afterRead` (wrpc's HPKE sealer)", + "note": "One paragraph, with no example or test in the repository; a sealed field cannot be indexed, queried, counted or kept unique.", + "source": "docs/guide/scaling.md#end-to-end-encryption" + }, + "signing": { + "mark": "✅", + "text": "Ed25519 per op: the header plus a salted payload digest", + "note": "The default `'verify'` takes an unsigned op, so a relay can strip signatures; only `sync.signatures: 'require'` refuses them.", + "source": "src/signing.js:71-119; src/apply.js:60-114; docs/guide/sync.md#signatures" + }, + "nodeIdentity": { + "mark": "✅", + "text": "the node id is its Ed25519 public key; a swapped key is `IDENTITY_MISMATCH`", + "source": "src/storage.js:515-536; src/shape.js:40-45; src/crypto.js:65-78" + }, + "tamper": { + "mark": "✅", + "text": "hash-linked chain of what this node applied, unsigned (optional)", + "note": "Whoever can write the directory can rebuild it: a read checks a record against the block its file names, not that the block is on the chain, so a planted block passes and `validate()` still answers true (checked). The ops in the log are what is signed.", + "source": "src/chain.js:45-87; src/storage.js:833-866; src/entries.js:86-89; docs/guide/chain.md", + "detail": "Script: change a record's data, write a block `{ prev: '0', data: { id, hash } }` under its own hash, point the record at it: `get()` returns the forged data. `chain: false` keeps no chain." + }, + "authn": { + "mark": "✅", + "text": "`unit({ authorize })` gates `hello`; `access: 'session'` refuses everyone by default", + "note": "`hello` does not prove the peer holds its key, and the dialer's `token` sits in plain text in `/remote/`.", + "source": "src/hosting.js:164-181,304-311; src/sync.js:143-160; docs/guide/wrpc.md#hosting-a-node" + }, + "peerValidation": { + "mark": "⚠️", + "text": "host `authorize`; dialer `beforeConnect`, `beforeBootstrap`; key possession not proven", + "source": "src/hosting.js:173-181; docs/guide/hooks.md#sync-hooks; docs/guide/sync.md#signatures", + "detail": "Under `'require'` a host refuses a `hello` whose node id is no key (403 `UNSIGNED`); a peer greeting with someone else's key then fails every push as `forged`." + }, + "access": { + "mark": "✅", + "text": "per-record scopes (`sync.scope`); a peer reads only the scopes `hello` granted", + "note": "The peer names its scopes, and naming none means everything: `authorize` must check `hello.scopes`.", + "source": "src/hosting.js; src/apply.js (outOfBounds); docs/guide/sync.md#scopes" + }, + "writeRules": { + "mark": "⚠️", + "text": "ops outside granted scopes refused, by the op's scope and its record's; `beforeApply({ op, peer })` can skip one", + "note": "A finer rule than scopes lives in `beforeApply`: a skip is local (the op is still logged and relayed), a throw refuses the whole page.", + "source": "src/hosting.js; src/apply.js; tests/sync.scopes.test.js" + }, + "serverValidation": { + "mark": "✅", + "text": "the host runs the entity validators on every peer op (`OP_INVALID`)", + "source": "src/apply.js:265-272; docs/guide/schema.md#how-a-write-is-validated", + "detail": "A peer's op is never rewritten by the validator: the verdict alone decides, and an invalid op is skipped and reported (see the next row for where it goes)." + }, + "serverAuth": { + "mark": "⚠️", + "text": "out-of-scope (by the op or its record), unsigned and malformed ops stop at the host; one its validator or `beforeApply` skips is relayed", + "note": "Checked with three nodes: an op the middle node's validator or `beforeApply` skipped still reached the node behind it, because a withheld op would leave a gap in its origin's stream; every node re-checks it. A throwing `beforeApply` refuses the whole page instead, and the sender offers it again later.", + "source": "src/apply.js:116-129,253-272; src/hosting.js:215-224; docs/guide/hooks.md#skipping-a-peers-op" + }, + "limits": { + "mark": "✅", + "text": "`unit({ limits })`: `opsPerPush`, `bytesPerPush` (413), `opsPerSecond` (429)", + "source": "src/hosting.js:36-112; docs/guide/sync.md#hosting" + }, + "purge": { + "mark": "✅", + "text": "`purge(id)`: a `purge` op that wins at any stamp, then a scrub of the op log", + "note": "The chain keeps an unsalted SHA-256 of every version this node wrote or applied, so whoever holds the directory can confirm a guessed record; until the next catalog save, `index/catalog.json` may still hold its indexed fields.", + "source": "src/storage.js:921-925; src/log.js:485; src/ops.js:427-458; docs/guide/storage.md#purge", + "detail": "A scrub rewrites log files in place: a crash mid-scrub tears the file, and `purge()` is idempotent. The scrubbed op keeps origin, seq, stamp, `actor`, `sig` and the salted digest." + }, + "purgeReplicas": { + "mark": "✅", + "text": "the purge op replicates; every peer that takes it scrubs its own log", + "note": "A peer that never comes back keeps what it had.", + "source": "src/apply.js:276-278,299; docs/guide/storage.md#purge" + }, + "history": { + "mark": "⚠️", + "text": "op log within retention (`storage.log.entriesOf(id)`); no read-at-version", + "source": "src/log.js:33-34; docs/reference/api.md#oplog; docs/guide/sync.md#the-log", + "detail": "`retention.ops` defaults to 10 000; the chain keeps hashes only." + }, + "audit": { + "mark": "✅", + "text": "every op names its origin key and an `actor`, kept in the log", + "note": "`actor` is the writer's signed claim; a host holds it to what `authorize` established in `beforeApply`.", + "source": "src/commit.js:110-130; src/options.js:78-82; docs/guide/sync.md#guarantees-and-limits" + }, + "proto": { + "mark": "✅", + "text": "own keys only; `__proto__` is never a field", + "source": "src/ops.js:147-152; src/signing.js:26-29", + "detail": "Checked by running: `insert`, `update` and `applyRemote` with own `__proto__` keys left `Object.prototype` clean; the key is dropped." + }, + "malformed": { + "mark": "✅", + "text": "`wellFormed` before anything is read; counted `malformed`, never logged or relayed", + "source": "src/shape.js:60-77; src/apply.js:159-172", + "detail": "Checked by running: an origin with `@`, an id with `../` and a stamp three days ahead were all counted malformed; payloads are `applyOp`'s and taken as no-ops when garbage." + }, + "skew": { + "mark": "✅", + "text": "stamps over `sync.maxSkew` (24 h) ahead refused (`SKEW`), never observed", + "source": "src/options.js:11-14,90-96; src/apply.js:173-181" + }, + "hooks": { + "mark": "✅", + "text": "`beforeWrite` replaces data or refuses, `afterWrite`; one function per name", + "source": "src/hooks.js:21-49; src/commit.js:62-72,195-207; docs/guide/hooks.md#storage-hooks", + "detail": "Run inside the mutation queue for every local write (`write`, `set`, `inc`, `add`, `remove`, `delete`, `purge`, `merge`); a write issued from a hook is refused (`IN_HOOK`); an unknown hook name is a `TypeError` at `open()`." + }, + "replHooks": { + "mark": "✅", + "text": "`beforeApply` skips or aborts per op, `afterApply`, `beforeBootstrap`", + "source": "src/apply.js:253-258,302-313; docs/guide/hooks.md#sync-hooks", + "detail": "A peer's op is judged, never rewritten." + }, + "outHook": { + "mark": "❌", + "text": "none, by design: a withheld op is a gap; scopes decide what leaves", + "source": "docs/guide/hooks.md#sync-hooks" + }, + "readHooks": { + "mark": "⚠️", + "text": "`beforeRead` / `afterRead` wrap `get()`; queries and snapshots read raw", + "note": "An `afterRead` redaction does not reach `select()` rows, so it is not a read access control.", + "source": "src/storage.js:887-898; docs/guide/hooks.md#reads-stay-raw-where-it-matters" + }, + "errorEvents": { + "mark": "✅", + "text": "`error` with a code per refused peer op; `conflict`; host `refused`, `hello`", + "source": "src/apply.js:98-138,159-181; src/hosting.js:149-162; docs/reference/api.md#storage", + "detail": "Listeners are never awaited; a throwing one is logged (`listener.error`) and an `error` with no listener is silent." + }, + "plugins": { + "mark": "❌", + "text": "none; per-instance injection: `fsi`, `validator`, `search`, `telemetry`, `logger`, `connect`", + "source": "src/options.js:47-123; docs/reference/api.md#open-options", + "detail": "Subpaths (`/wrpc`, `/contracts`, `/idb`, `/search`) are separate modules, not plugins; nothing is global to the process." + }, + "errors": { + "mark": "✅", + "text": "`SyncomError.code`: 30 codes, plus 3 on `TypeError`s; full messages", + "source": "src/errors.js:20-32; index.d.ts:158-188; docs/reference/api.md#syncomerror", + "detail": "A host's wire refusal is a plain error with wrpc's numeric code (400–429) and a `reason` constant." + }, + "logging": { + "mark": "✅", + "text": "`open({ logger })`: a Console, a pino-shaped logger or `false`; named events", + "source": "src/logger.js:9-14; docs/guide/telemetry.md#log-events" + }, + "otel": { + "mark": "✅", + "text": "spans and metrics through an injected API; no OpenTelemetry dependency", + "source": "src/telemetry.js; docs/guide/telemetry.md" + }, + "stats": { + "mark": "✅", + "text": "`stats()`, `peers()`, `hosted()`, observable gauges", + "source": "src/storage.js:713-735; docs/guide/storage.md#health" + }, + "diagPII": { + "mark": "✅", + "text": "no id, origin, scope or key as a metric label; `includeIdentity: false` for spans", + "source": "src/telemetry.js:28-31,93-122; docs/guide/telemetry.md#metrics", + "detail": "Log lines name record ids and origins, never payloads; `error` events carry the op as a field." + }, + "react": { + "mark": "❌", + "text": "none", + "source": "package.json; docs/guide/why.md:76" + }, + "vue": { + "mark": "❌", + "text": "none", + "source": "package.json" + }, + "angular": { + "mark": "❌", + "text": "none", + "source": "package.json" + }, + "svelte": { + "mark": "❌", + "text": "none", + "source": "package.json" + }, + "signals": { + "mark": "❌", + "text": "none", + "source": "package.json" + }, + "tanstack": { + "mark": "❌", + "text": "none", + "source": "package.json" + }, + "devMode": { + "mark": "—", + "text": "no separate mode: argument checks and full error messages always on", + "source": "src/errors.js; docs/reference/api.md (error codes table)" + }, + "testing": { + "mark": "⚠️", + "text": "`createMemoryFsi()` for in-memory stores; contract suites stay in the repo", + "source": "src/backend.js:10, 40; package.json#files (no tests/); tests/suites/fsi.js; docs/guide/backends.md#your-own", + "detail": "tests/utils/memory-link.js (an in-process link) and tests/utils/mesh.js (N storages, `settle()`) are repo-only." + }, + "ci": { + "mark": "", + "text": "722 `node:test` tests; coverage gate 95/95/90/95; CI on Node 22 and 24, Linux", + "source": "pnpm test (2026-10-23: 722 tests, 721 pass, 1 skipped); package.json scripts.test:coverage; .github/workflows/ci.yml", + "detail": "Browser halves run under Node with an OPFS mock and fake-indexeddb; no real browser in CI." + }, + "docs": { + "mark": "", + "text": "21 pages, about 41,500 words, with an API reference", + "source": "docs/ (VitePress): 19 guide pages, docs/reference/api.md, docs/index.md", + "detail": "The word count includes the generated comparison table." + }, + "examples": { + "mark": "⚠️", + "text": "two Node.js scripts (`examples/main.js`, `domain.js`), linted but not run", + "source": "examples/; .github/workflows/ci.yml (lint only)" + }, + "breakingMajors": { + "mark": "", + "text": "none yet: 0.0.1, never published", + "source": "CHANGELOG.md; package.json", + "detail": "The wire carries `PROTOCOL = 1` and a host refuses another version with 400 `PROTOCOL` (src/protocol.js:8; src/hosting.js:167-168). `packOps` slots are append-only and a new op type is a PROTOCOL bump (CLAUDE.md). The catalog is derived state: a catalog.json of an older format (now 3) is rebuilt once at open (src/catalog.js:25,403; CHANGELOG.md)." + }, + "releaseCadence": { + "mark": "", + "text": "no release yet; cut by hand from a checklist", + "source": "CONTRIBUTING.md#release-checklist; CHANGELOG.md" + }, + "freeTierHistory": { + "mark": "—", + "text": "no paid tier (MIT)", + "source": "LICENSE; package.json" + }, + "productionUsers": { + "mark": "", + "text": "none yet (unreleased)", + "source": "CHANGELOG.md" + }, + "commercialSupport": { + "mark": "❌", + "text": "none offered", + "source": "README.md; SECURITY.md", + "detail": "SECURITY.md promises to acknowledge a vulnerability report within 7 days and a fix or plan within 30." + }, + "communityHelp": { + "mark": "", + "text": "GitHub repository; no chat or forum", + "source": "README.md; docs/.vitepress/config.mts:200" + }, + "thirdPartyPlugins": { + "mark": "", + "text": "none (pre-release)", + "source": "README.md" + }, + "community": { + "mark": "", + "text": "pre-release, not on npm yet", + "source": "api.github.com/repos/Alexis-Technologies/syncom (2026-10-23)" + }, + "importFromOtherDbs": { + "mark": "❌", + "text": "no importer and no JSON import; write records with `insert()`", + "source": "index.d.ts:1185; docs/guide/storage.md" + }, + "benchmarks": { + "mark": "✅", + "text": "nine libraries, six scenarios, memory and disk; machine and date stated", + "source": "docs/guide/benchmarks.md; bench/compare/; bench/results/compare.json" + }, + "webmcp": { + "mark": "❌", + "text": "none", + "source": "src/" + }, + "agentDocs": { + "mark": "✅", + "text": "`llms.txt` on the docs site; `CLAUDE.md` in the repository", + "source": "docs/public/llms.txt; CLAUDE.md" + } + } + }, + { + "id": "rxdb", + "name": "RxDB", + "url": "https://github.com/pubkey/rxdb", + "direction": "local-first database", + "package": "rxdb", + "version": "17.6.0", + "cells": { + "version": { + "mark": "", + "text": "17.6.0 (`rxdb`), npm `latest` since 2026-10-05", + "source": "npm view rxdb dist-tags time (2026-10-23); rxdb@17.6.0 package.json:4; CHANGELOG.md:11" + }, + "released": { + "mark": "", + "text": "stable; on npm since 2016, 652 versions", + "source": "npm view rxdb time (created 2016-11-16, 2026-10-23)" + }, + "license": { + "mark": "", + "text": "Apache-2.0", + "source": "rxdb@17.6.0 package.json:87; LICENSE.txt" + }, + "serverLicense": { + "mark": "⚠️", + "text": "RxServer (`rxdb-server`) is SSPL; its Fastify and Koa adapters are Premium (Pro Plus)", + "source": "rxdb-server package.json:5 and LICENSE (SSPL v1); npm view rxdb-server license; https://rxdb.info/premium/", + "detail": "The Express adapter (rxdb-server src/plugins/adapter-express) is in the SSPL repository; Fastify/Koa adapters are in the Pro Plus tier." + }, + "price": { + "mark": "", + "text": "Premium: Pro from $99/month, Pro Plus from $239/month, billed yearly; Enterprise custom", + "source": "https://rxdb.info/premium/; rxdb docs-src/src/constants.ts:22-23; docs-src/src/pages/premium.tsx:169-202", + "detail": "Prices are per organization with unlimited developers; no free trial and no monthly plan (premium FAQ). Upgrades are credited. Enterprise adds a custom MSA, named engineer, SLA and source-code access." + }, + "premiumSplit": { + "mark": "⚠️", + "text": "Pro: OPFS, IndexedDB, SQLite, filesystem storages, WebCrypto encryption, fulltext search; Pro Plus: worker, sharding, logger…", + "source": "https://rxdb.info/premium/; docs-src/src/pages/premium.tsx:144-230; https://rxdb.info/install.html (👑 imports from `rxdb-premium/plugins/*`)", + "detail": "Pro Plus adds the Worker and SharedWorker storages, Sharding, Memory-Mapped, the Localstorage Meta Optimizer, the Query Optimizer, the RxServer Fastify/Koa adapters and the logger plugin. The free tier's list strikes through 'Premium storages (OPFS, SQLite, Filesystem)' and 'Performance plugins (Sharding, Memory Mapped)'; the extracted page text loses the strike-through. The same list still names a free LokiJS storage, removed in 16.0.0. 17.0.0 moved reactivity-vue and reactivity-preact-signals from Premium into the core." + }, + "premiumTerms": { + "mark": "⚠️", + "text": "yearly term license: use must stop when it lapses; not inside a library", + "source": "https://rxdb.info/license-preview/ (same text in rxdb@17.6.0 docs-src/src/pages/license-preview.tsx:63,71,111,120-126,159-165); npm view rxdb-premium scripts", + "note": "The agreement also requires shipping Premium code minified without source maps, gives no technical support, and says installing it (an access-token `postinstall` downloads and decrypts it) sends the package name, dependency list, git user name and email, and IP address to the vendor.", + "detail": "Continued use of an already deployed release after expiry is a paid add-on option; the license auto-renews yearly; governed by German law (Stuttgart). The public `rxdb-premium` npm package is 3 files (11.9 KB) whose postinstall runs scripts/installer.js." + }, + "collectionLimit": { + "mark": "⚠️", + "text": "free core: 13 open collections per JavaScript process; Premium lifts it", + "source": "rxdb@17.6.0 src/plugins/utils/utils-premium.ts:6; src/rx-collection.ts:246-285; src/plugins/dev-mode/error-messages.ts:458; https://rxdb.info/rx-collection.html#faq", + "note": "Measured: the 14th collection opens after a wait of about 1.86 s and the 15th throws `COL23`. The count spans every database in the process; 17.0.0 lowered it from 16 to 13.", + "detail": "The wait is a retry loop (60 × 30 ms) meant for test suites that close databases without awaiting; the throw only fires when more than 13 are open after it. Removing it needs `setPremiumFlag()` from rxdb-premium (a SHA-256-checked flag); the source comment says forking to remove it is allowed." + }, + "phoneHome": { + "mark": "⚠️", + "text": "dev-mode loads a hidden rxdb.info iframe; WebRTC defaults to `signaling.rxdb.info`; Premium install reports to the vendor", + "source": "rxdb@17.6.0 src/plugins/dev-mode/dev-mode-tracking.ts:55-121; src/plugins/dev-mode/index.ts:68-72; https://rxdb.info/dev-mode.html#disable-the-tracking-iframe; https://rxdb.info/license-preview/", + "note": "The docs say the dev-mode iframe is added on localhost only, but the `isLocalHost()` check is commented out (`dev-mode-tracking.ts:67`), so it loads on any host unless the Premium flag is set. The production core makes no such call.", + "detail": "The iframe is https://rxdb.info/html/dev-mode-iframe.html?version=, 1×1 px, 'to track the results of marketing efforts'. The free Dexie and SQLite-trial storages also print a Premium advert with console.warn on their first write, in production too (rx-storage-instance-dexie.ts:83-100; sqlite-storage-instance.ts:138-155)." + }, + "deps": { + "mark": "", + "text": "24 direct plus the required peer `rxjs`: 74 packages installed", + "source": "rxdb@17.6.0 package.json:536-593; bench/compare/pnpm-lock.yaml (snapshot rxdb@17.6.0)", + "detail": "Counted from the lockfile: 72 packages are reached from rxdb's 24 dependencies, 74 with rxjs and its tslib. They include graphql, graphql-ws, simple-peer, nostr-tools, ws, dexie, crypto-js and three schema validators (ajv, z-schema, is-my-json-valid), installed whether or not the plugins are used; `as-typed`, `@types/ws` and `@types/simple-peer` (pulling `@types/node`) are runtime dependencies used only for types. Optional peers: react, vue, @angular/core, @preact/signals-core, firebase, mongodb, nats." + }, + "impl": { + "mark": "", + "text": "TypeScript, built by Babel into CommonJS and ESM; `sideEffects: false`", + "source": "rxdb@17.6.0 package.json:88-98; package.json scripts build:cjs, build:esm, build:types; babel.config.js", + "detail": "The CJS build lowers classes (ES5 target); the ESM build keeps them. The npm package also ships `src/` (TypeScript), CLAUDE.md, AGENTS.md and ERROR-MESSAGES.md." + }, + "entryPoints": { + "mark": "", + "text": "54 subpath exports, one per plugin (`rxdb/plugins/*`)", + "source": "rxdb@17.6.0 package.json:92-412" + }, + "types": { + "mark": "✅", + "text": "generated from the source; document type inferred from the schema", + "source": "rxdb@17.6.0 src/rx-schema.ts:323 (`toTypedRxJsonSchema`); src/types/rx-schema.d.ts:360 (`ExtractDocumentTypeFromTypedRxJsonSchema`, via `as-typed`); https://rxdb.info/tutorials/typescript.html", + "detail": "17.0.0 typed the `$$` signal accessors with the document type (HKT-based ReactivityLambda)." + }, + "size": { + "mark": "", + "text": "80.4 KB min+gzip: `createRxDatabase` with Dexie; 44.9 KB with localStorage", + "source": "esbuild 0.28.2, minified browser ESM, from bench/compare/node_modules (scratch size.js, size2.js); rxdb master config/bundle-size/limits.json", + "detail": "Core + memory 45.2 KB; core + Dexie 78.7 KB with the `es2015` condition for rxjs (https://rxdb.info/install.html); + `wrappedValidateAjvStorage` (needed by dev-mode) 121.2 KB. Master's new size check caps its todo-dexie app at 84,671 B gzip, consistent with these numbers." + }, + "sizeSync": { + "mark": "", + "text": "90.1 KB min+gzip with Dexie and `replicateRxCollection`", + "source": "bench/results/size.json (92,303 B); bench/compare/size.js:91-101", + "detail": "With localStorage instead of Dexie: 57.9 KB (scratch size2.js). size.json labels the bundle dexie@4.4.6, but esbuild resolved rxdb's own pinned dexie@4.4.2 (size.js:137-139 reads the version from the top-level node_modules)." + }, + "sizeGate": { + "mark": "⚠️", + "text": "CI prints a webpack build size; no limit in 17.6.0", + "source": "rxdb@17.6.0 .github/workflows/main.yml:447-448; config/bundle-size.js", + "detail": "Master adds an esbuild check with limits (orga/changelog/add-bundle-size-ci-check.json; config/bundle-size/limits.json), unreleased." + }, + "node": { + "mark": "⚠️", + "text": "≥20; free disk persistence only via MongoDB or FoundationDB servers, or the capped SQLite trial", + "source": "rxdb@17.6.0 package.json:84-86; src/plugins/storage-sqlite/sqlite-storage-instance.ts:57-58, 115-118; https://rxdb.info/nodejs-database.html; https://rxdb.info/premium/", + "note": "The Filesystem and SQLite storages are Premium (Pro); the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance.", + "detail": "CI runs Node 26.10.0 (.nvmrc) on Linux and Windows; the `engines` floor of 20 is not exercised in CI. The memory storage is free." + }, + "deno": { + "mark": "✅", + "text": "`rxdb/plugins/storage-denokv`; CI runs the Dexie and DenoKV suites on Deno 2.6.5", + "source": "rxdb@17.6.0 package.json:183; .github/workflows/main.yml:1120-1167" + }, + "bun": { + "mark": "⚠️", + "text": "CI runs the memory-storage suite on Bun 1.4.2; no Bun-specific storage", + "source": "rxdb@17.6.0 .github/workflows/main.yml:1114-1118; package.json scripts test:bun:memory" + }, + "browser": { + "mark": "✅", + "text": "free: Dexie (IndexedDB), localStorage, memory; native IndexedDB and OPFS Premium (Pro)", + "source": "rxdb@17.6.0 src/plugins/storage-dexie, storage-localstorage, storage-memory; https://rxdb.info/install.html#storages; https://rxdb.info/premium/" + }, + "browserReq": { + "mark": "", + "text": "`crypto.subtle` (secure context) or your own `hashFunction`; some bundlers need a `global` shim", + "source": "https://rxdb.info/rx-database.html#hashfunction; https://rxdb.info/install.html#polyfill-the-global-variable; https://rxdb.info/releases/16.0.0.html (ohash fallback removed)" + }, + "worker": { + "mark": "⚠️", + "text": "Worker and SharedWorker storages are Premium (Pro Plus); the free core runs inside a worker", + "source": "https://rxdb.info/rx-storage-worker.html; https://rxdb.info/rx-storage-shared-worker.html; https://rxdb.info/premium/", + "detail": "The free `rxdb/plugins/storage-remote` with a custom `messageChannelCreator` is the building block the Premium worker storage uses (https://rxdb.info/rx-storage-remote.html)." + }, + "reactNative": { + "mark": "⚠️", + "text": "Premium SQLite or Expo Filesystem storage; the free SQLite trial is capped", + "source": "https://rxdb.info/react-native-database.html; rxdb@17.6.0 src/plugins/storage-sqlite/sqlite-basics-helpers.ts:295,339,409; examples/react-native/storage.js:7-19", + "note": "The page offers SQLite as 'a free solution' but its sample imports `rxdb-premium/plugins/storage-sqlite`, and the sample's `ignoreDuplicate: true` throws `DB9` without dev-mode (checked).", + "detail": "The CI-tested Expo example uses the SQLite trial and shims `crypto.subtle` with expo-crypto (examples/react-native/initializeDb.js:27-29). The React hooks work unchanged in React Native (https://rxdb.info/react.html#react-native-compatibility)." + }, + "expo": { + "mark": "⚠️", + "text": "Expo Filesystem storage is Premium (Pro); free: expo-sqlite through the SQLite trial", + "source": "https://rxdb.info/rx-storage-filesystem-expo.html; https://rxdb.info/install.html#storages; rxdb@17.6.0 src/plugins/storage-sqlite/sqlite-basics-helpers.ts:339,409" + }, + "capacitor": { + "mark": "⚠️", + "text": "Premium SQLite via `@capacitor-community/sqlite`; free: localStorage or Dexie in the WebView", + "source": "https://rxdb.info/capacitor-database.html; rxdb@17.6.0 src/plugins/storage-sqlite/sqlite-basics-helpers.ts:224; examples/ionic (CI, localStorage storage)" + }, + "electron": { + "mark": "✅", + "text": "`rxdb/plugins/electron`: storage in the main process, databases in renderers over IPC", + "source": "rxdb@17.6.0 src/plugins/electron/rx-storage-ipc-main.ts:17; src/plugins/electron/rx-storage-ipc-renderer.ts:25; https://rxdb.info/electron.html; examples/electron (CI)", + "detail": "The docs recommend the Premium SQLite storage in the main process and require `nodeIntegration`; the CI example uses the SQLite trial." + }, + "tauri": { + "mark": "⚠️", + "text": "SQLite storage with `getSQLiteBasicsTauri()`: Premium, or the capped free trial", + "source": "rxdb@17.6.0 src/plugins/storage-sqlite/sqlite-basics-helpers.ts:620; examples/tauri/src/main.js:1-24 (CI); https://rxdb.info/overview.html" + }, + "flutter": { + "mark": "⚠️", + "text": "`rxdb/plugins/flutter` runs RxDB in QuickJS; its recipe needs the removed LokiJS storage", + "source": "rxdb@17.6.0 src/plugins/flutter/index.ts; src/plugins/flutter/dart/pubspec.yaml; https://rxdb.info/articles/flutter-database.html", + "note": "The article and examples/flutter import `rxdb/plugins/storage-lokijs`, removed in 16.0.0; the Flutter CI job is commented out, and the Dart package on pub.dev is 15.0.0-beta.31 from 2023-11-21.", + "detail": "Sources: examples/flutter/javascript/src/index.js:5-6; .github/workflows/main.yml:899-945; https://rxdb.info/releases/16.0.0.html; pub.dev/api/packages/rxdb (2026-10-23). The article also says the Dart package is not on pub.dev." + }, + "multiTab": { + "mark": "✅", + "text": "`multiInstance: true` (default): every tab opens it; changes cross over BroadcastChannel", + "source": "rxdb@17.6.0 src/rx-database.ts:816; src/rx-storage-multiinstance.ts:73, 98; src/plugins/storage-localstorage/rx-storage-instance-localstorage.ts:82; broadcast-channel@7.4.0 src/method-chooser.js:9-13", + "detail": "Dexie, SQLite and DenoKV storages broadcast through the broadcast-channel package (native BroadcastChannel, else IndexedDB or localStorage; Unix sockets between Node.js processes); the localStorage storage listens to the `storage` event; the memory storage is per tab. Replications start only in the leader tab by default (`waitForLeadership: true`, src/plugins/replication/index.ts:794, 873)." + }, + "leader": { + "mark": "✅", + "text": "`rxdb/plugins/leader-election`: `waitForLeadership()`, `isLeader()`; Web Locks when available", + "source": "rxdb@17.6.0 src/plugins/leader-election/index.ts:84-120; package.json:321; broadcast-channel src/leader-election.js:316-318; https://rxdb.info/leader-election.html" + }, + "storageContract": { + "mark": "✅", + "text": "`RxStorage` → `RxStorageInstance`: `bulkWrite`, Mango `query`, `count`, `changeStream`, `cleanup`…", + "source": "rxdb@17.6.0 src/types/rx-storage.interface.d.ts:42-64,116-315; https://rxdb.info/rx-storage.html", + "detail": "The storage owns persistence, indexes, query execution, revision-conflict detection (`previous` vs stored `_rev`), attachments and tombstone cleanup. Each document write is atomic; there is no transaction around a batch (rx-storage.interface.d.ts:155-163)." + }, + "customBackend": { + "mark": "✅", + "text": "implement `RxStorageInstance`; the storage suite runs from a clone of the repo", + "source": "rxdb@17.6.0 test/unit/custom-storage.ts:1-10; test/unit/rx-storage-implementations.test.ts; src/rx-storage-helper.ts:205,870", + "detail": "No docs page explains writing a storage. custom-storage.ts says to clone the repo, export an `RxTestStorage` and run the unit suite with DEFAULT_STORAGE=custom (rx-storage-implementations.test.ts is 3,869 lines, rx-storage-query-correctness.test.ts 2,539). Helpers ship: `categorizeBulkWriteRows`, `getQueryMatcher`, `getSortComparator`, `addRxStorageMultiInstanceSupport`." + }, + "memoryBackend": { + "mark": "✅", + "text": "`storage-memory` (free): plain objects, binary-search indexes; Node and browser", + "source": "rxdb@17.6.0 src/plugins/storage-memory/index.ts:14-45; https://rxdb.info/rx-storage-memory.html", + "detail": "State lives in a module-level Map that survives close() for the life of the process, so two databases of one name share it (tests simulate tabs this way). Supports attachments." + }, + "indexeddb": { + "mark": "✅", + "text": "`storage-dexie` (free, Dexie.js); native `storage-indexeddb` is Premium (Pro)", + "note": "The free Dexie storage prints a console warning advertising Premium once per process, when a database is created, unless the Premium package's `setPremiumFlag()` is called; it also refuses indexes on fields that are not required (`DXE1`).", + "source": "rxdb@17.6.0 src/plugins/storage-dexie/rx-storage-instance-dexie.ts:83-100; src/plugins/storage-dexie/rx-storage-dexie.ts:44; https://rxdb.info/rx-storage-dexie.html; https://rxdb.info/rx-storage-indexeddb.html; https://rxdb.info/premium/", + "detail": "Browser; in Node only through fake-indexeddb (in memory). The Dexie page says Premium IndexedDB stores attachments as binary instead of base64, but in 17.6.0 the Dexie storage also stores attachment Blobs (rx-storage-instance-dexie.ts:185-201). Premium IndexedDB's batched cursor, custom index strings, WAL-like mode and Storage Buckets support are from the docs (closed source, not checked)." + }, + "opfs": { + "mark": "⚠️", + "text": "Premium (Pro) `storage-opfs`: in a worker (sync handles) or `getRxStorageOPFSMainThread()`", + "source": "https://rxdb.info/rx-storage-opfs.html; https://rxdb.info/premium/; rxdb@17.6.0 src/plugins (no storage-opfs)", + "detail": "The worker variant is opened through `getRxStorageWorker` and a prebuilt opfs.worker.js; the premium page lists the Worker storage under Pro Plus and does not say whether Pro covers this use. rx-storage.html imports `getRxStorageOPFSMainThread` from storage-worker, rx-storage-opfs.html from storage-opfs. No OPFS code or trial in the open-source package." + }, + "localStorage": { + "mark": "✅", + "text": "`storage-localstorage` (free): a key per document, a JSON array per index; browser", + "source": "rxdb@17.6.0 src/plugins/storage-localstorage/rx-storage-instance-localstorage.ts:130-133,168-185,195-200; https://rxdb.info/rx-storage-localstorage.html", + "detail": "The docs' recommended starting storage. ~5 MB per origin; not available in workers; in Node only with `getLocalStorageMock()` (in memory). No transactions: documents are written before the index keys, each index array rewritten whole on every write. Attachments are stored as base64 data URLs." + }, + "nodeFs": { + "mark": "⚠️", + "text": "Premium (Pro) `storage-filesystem-node`; Node and Electron", + "source": "https://rxdb.info/rx-storage-filesystem-node.html; https://rxdb.info/premium/", + "detail": "Per the docs: plain JSON/txt files, several processes on one folder with `multiInstance: true`. Closed source, not checked. The free persistent options in Node are the MongoDB and FoundationDB servers and the SQLite trial." + }, + "sqlite": { + "mark": "⚠️", + "text": "Premium (Pro) `storage-sqlite`; the free `getRxStorageSQLiteTrial()` is capped", + "note": "The trial throws `SQL3` after 500 SQL calls per storage instance: a write runs one whole-table read plus one insert per new document, so the 251st serial insert fails (measured). `SQL2` stops it at 500 documents. It has no indexes or attachments.", + "source": "rxdb@17.6.0 src/plugins/storage-sqlite/sqlite-storage-instance.ts:57-58,115-118,242-244,311-336,442; src/plugins/storage-sqlite/index.ts:43-64; https://rxdb.info/rx-storage-sqlite.html", + "detail": "Measured with rxdb 17.6.0 and node:sqlite (scratch script): one bulkInsert of 400 documents passes, 499 throws SQL3. `setPremiumFlag()` only silences the trial's log; the caps are not gated on it (lines 138-157 vs 116, 242). Premium `sqliteBasics` adapters: sqlite3, node:sqlite, wa-sqlite (browser), react-native-quick-sqlite, expo-sqlite, Capacitor, Tauri." + }, + "expoFs": { + "mark": "⚠️", + "text": "Premium (Pro) `storage-filesystem-expo`, beta since 17.0.0; needs `expo-opfs`", + "source": "https://rxdb.info/rx-storage-filesystem-expo.html; https://rxdb.info/premium/" + }, + "mongodbStorage": { + "mark": "✅", + "text": "`storage-mongodb` (free), Node; no attachments", + "note": "One Node.js process per MongoDB database, per the docs; nothing in the storage enforces it (no change-stream listening, a TODO in the code).", + "source": "rxdb@17.6.0 src/plugins/storage-mongodb/rx-storage-instance-mongodb.ts:124-129,418; https://rxdb.info/rx-storage-mongodb.html", + "detail": "Writes from outside RxDB are not supported. master fixes `findDocumentsById` when `_id` is the primary key (orga/changelog/fix-mongodb-find-by-id-with-_id-primary-key.json)." + }, + "foundationdb": { + "mark": "✅", + "text": "`storage-foundationdb` (free), Node with the native client; one process per cluster", + "source": "rxdb@17.6.0 src/plugins/storage-foundationdb/rx-storage-instance-foundationdb.ts:257; https://rxdb.info/rx-storage-foundationdb.html" + }, + "denokv": { + "mark": "✅", + "text": "`storage-denokv` (free), Deno only", + "note": "Attachments are not implemented (`getAttachmentData()` throws \"Method not implemented.\"); the DenoKV page does not say so.", + "source": "rxdb@17.6.0 src/plugins/storage-denokv/rx-storage-instance-denokv.ts:261-263; https://rxdb.info/rx-storage-denokv.html" + }, + "legacyStorages": { + "mark": "❌", + "text": "removed: PouchDB in 14.0.0; LokiJS and memory-synced in 16.0.0", + "note": "The premium page still lists LokiJS among the free default storages.", + "source": "https://rxdb.info/releases/14.0.0.html; https://rxdb.info/releases/16.0.0.html; https://rxdb.info/rx-storage-lokijs.html; rxdb master docs-src/src/pages/premium.tsx:148" + }, + "storageTrialLimits": { + "mark": "⚠️", + "text": "SQLite trial capped; Dexie prints a Premium notice; no trial of other Premium storages", + "source": "rxdb@17.6.0 src/plugins/storage-sqlite/sqlite-storage-instance.ts:57-58; src/plugins/storage-dexie/rx-storage-instance-dexie.ts:83-100; https://rxdb.info/premium/ (FAQ: no free trial)", + "detail": "The 13-open-collections limit of the open-source core is the platform area's row." + }, + "storageWrappers": { + "mark": "✅", + "text": "`wrapRxStorageInstance`: validation, key compression, encryption, attachment compression stack", + "source": "rxdb@17.6.0 src/plugin-helpers.ts:155; src/rx-storage-helper.ts:870-884; https://rxdb.info/rx-storage.html", + "detail": "A base storage refuses a schema with `keyCompression`, encryption or attachment compression unless the matching wrapper sits above it (`UT5`–`UT7`)." + }, + "remoteStorage": { + "mark": "✅", + "text": "`storage-remote` over any message channel; `storage-remote-websocket`; Electron IPC", + "source": "rxdb@17.6.0 src/plugins/storage-remote/rx-storage-remote.ts:293; src/plugins/storage-remote/remote.ts:27; src/plugins/storage-remote-websocket/index.ts:93,177; src/plugins/electron/rx-storage-ipc-renderer.ts:25; https://rxdb.info/rx-storage-remote.html; https://rxdb.info/electron.html", + "detail": "All free. Both ends must run the same RxDB version (`RM1`)." + }, + "sharding": { + "mark": "⚠️", + "text": "Premium (Pro Plus) `storage-sharding`; schema `sharding: { shards, mode }`", + "source": "https://rxdb.info/rx-storage-sharding.html; rxdb@17.6.0 src/types/rx-schema.d.ts:329", + "detail": "Changing `shards` on existing data loses access to it, per the docs." + }, + "memoryMapped": { + "mark": "⚠️", + "text": "Premium (Pro Plus) `storage-memory-mapped`; memory-synced removed in 16", + "note": "By default a write resolves before it reaches the persistent storage (`awaitWritePersistence: true` waits); no attachments; one JavaScript process per store, a SharedWorker for tabs.", + "source": "https://rxdb.info/rx-storage-memory-mapped.html; https://rxdb.info/rx-storage-memory-synced.html; https://rxdb.info/releases/16.0.0.html" + }, + "openCost": { + "mark": "", + "text": "lazy per collection; Premium (Pro Plus) meta optimizer keeps internal metadata in localStorage", + "source": "https://rxdb.info/rx-storage-localstorage-meta-optimizer.html; https://rxdb.info/rx-storage-performance.html", + "detail": "The docs claim about 200 ms saved on first load with more than four collections; browser only." + }, + "durability": { + "mark": "⚠️", + "text": "the storage's job; contract: each document atomic, no batch transaction", + "source": "rxdb@17.6.0 src/types/rx-storage.interface.d.ts:155-163; src/plugins/storage-dexie/rx-storage-instance-dexie.ts:144; src/plugins/storage-localstorage/rx-storage-instance-localstorage.ts:195-200; https://rxdb.info/rx-storage-memory-mapped.html", + "detail": "Dexie runs a bulkWrite in one IndexedDB readwrite transaction; localStorage has none (the code says so); memory-mapped acknowledges before persisting unless `awaitWritePersistence`; Premium IndexedDB is 'WAL-like' per its docs." + }, + "crashRecovery": { + "mark": "⚠️", + "text": "the storage engine's own (IndexedDB, SQLite transactions); no replay in the core", + "source": "rxdb@17.6.0 src/types/rx-storage.interface.d.ts:116-315; https://rxdb.info/rx-storage-memory-mapped.html; https://rxdb.info/rx-storage-performance.html", + "detail": "Semi-persistent storages (memory-mapped) can lose writes not yet persisted when the process dies." + }, + "exclusiveOpen": { + "mark": "⚠️", + "text": "no lock: tabs and processes share a store (`multiInstance`); single-process storages unguarded", + "source": "rxdb@17.6.0 src/rx-storage-multiinstance.ts:1-15; src/rx-database.ts:717-718; https://rxdb.info/rx-database.html; https://rxdb.info/rx-storage-mongodb.html; https://rxdb.info/rx-storage-foundationdb.html", + "detail": "Change events cross instances over BroadcastChannel and concurrent writes conflict on `_rev`; a second database of the same name in one process throws `DB8` (unless `ignoreDuplicate`/`closeDuplicates`). MongoDB, FoundationDB and memory-mapped need a single process, per their docs." + }, + "integrityRepair": { + "mark": "❌", + "text": "none; attachment digests serve change detection only", + "source": "rxdb@17.6.0 src/plugins/attachments-compression/index.ts:170-176 (\"RxDB does not use digests for integrity verification\")" + }, + "quotas": { + "mark": "❌", + "text": "none in the core; the storage's `QuotaExceededError` reaches the caller", + "source": "rxdb@17.6.0 src (no QuotaExceeded handling); https://rxdb.info/articles/indexeddb-max-storage-limit.html; https://rxdb.info/rx-storage-indexeddb.html", + "detail": "The IndexedDB-limits article tells the app to catch QuotaExceededError and call navigator.storage.persist(); compression plugins reduce size; Premium IndexedDB accepts Storage Buckets." + }, + "onDiskFormat": { + "mark": "⚠️", + "text": "per storage: JSON documents with `_rev`, `_meta` (Dexie, localStorage, SQLite, MongoDB); Premium formats undocumented", + "source": "rxdb@17.6.0 src/plugins/storage-localstorage/rx-storage-instance-localstorage.ts:130-133; src/plugins/storage-sqlite/sqlite-storage-instance.ts:540-547; https://rxdb.info/rx-storage-memory-mapped.html", + "detail": "Memory-mapped writes 'blockchain-like' blocks, so moving to it needs `migrateStorage()`; the filesystem-node docs say plain JSON/txt files. Data is laid out per schema version (table or key `-`)." + }, + "writeFootprint": { + "mark": "", + "text": "one document row per write, plus the storage's index entries", + "source": "rxdb@17.6.0 src/plugins/storage-localstorage/rx-storage-instance-localstorage.ts:230-290", + "detail": "Measured with storage-localstorage: the same record is a 144-byte document; each index is one JSON array rewritten on every write." + }, + "compression": { + "mark": "✅", + "text": "`key-compression` wrapper (schema-derived short keys); `attachments-compression` (gzip/deflate)", + "source": "rxdb@17.6.0 src/plugins/key-compression/index.ts:135; src/plugins/attachments-compression/index.ts:98-139; https://rxdb.info/key-compression.html; jsonschema-key-compression README", + "detail": "Key compression needs `keyCompression: true` in the schema plus the wrapper; jsonschema-key-compression (ISC, 1.7.0) reports ~40% on its example. Attachment compression is MIME-aware and uses CompressionStream." + }, + "docCache": { + "mark": "✅", + "text": "`DocumentCache` per collection: RxDocuments by revision, held by WeakRef", + "source": "rxdb@17.6.0 src/doc-cache.ts:19-48,87", + "detail": "The query cache belongs to the query area." + }, + "attachments": { + "mark": "✅", + "text": "`attachments` plugin: Blobs per document, `putAttachment()`, `getData()`", + "note": "Not supported by the SQLite trial (`SQL1`), MongoDB, DenoKV or memory-mapped storages.", + "source": "rxdb@17.6.0 src/plugins/attachments/index.ts:182-277; src/plugins/storage-sqlite/sqlite-storage-instance.ts:442,527; src/plugins/storage-mongodb/rx-storage-instance-mongodb.ts:418; src/plugins/storage-denokv/rx-storage-instance-denokv.ts:262; https://rxdb.info/rx-attachment.html" + }, + "localDocs": { + "mark": "✅", + "text": "`local-documents`: `insertLocal()`, `getLocal$()`; not replicated, no schema", + "source": "rxdb@17.6.0 src/plugins/local-documents/local-documents-helper.ts:39-48,84-92; https://rxdb.info/rx-local-document.html", + "detail": "Opt-in per database or collection (`localDocuments: true`); ids up to 128 characters; no attachments." + }, + "tombstoneCleanup": { + "mark": "✅", + "text": "`cleanup` plugin: purges tombstones after 31 days, once replications are in sync", + "source": "rxdb@17.6.0 src/plugins/cleanup/cleanup-helper.ts:5-11; src/plugins/cleanup/cleanup.ts:50-100; https://rxdb.info/cleanup.html", + "detail": "Without the plugin tombstones stay; runs on the leader only by default; `collection.cleanup(0)` purges now." + }, + "deleteDatabase": { + "mark": "✅", + "text": "`db.remove()`, `removeRxDatabase(name, storage)`, `collection.remove()`", + "source": "rxdb@17.6.0 src/rx-database.ts:681,943; src/rx-collection.ts:1148" + }, + "storageMigration": { + "mark": "✅", + "text": "`migrateStorage()` (free): moves live documents batch by batch into a new database", + "note": "It deletes each migrated batch from the old storage and does not check write errors on the new one (a TODO in the code); deleted documents are dropped.", + "source": "rxdb@17.6.0 src/plugins/migration-storage/index.ts:51-240 (185: TODO, 237: cleanup(0)); https://rxdb.info/migration-storage.html" + }, + "backup": { + "mark": "⚠️", + "text": "`backup` plugin: Node only, one-time or live, a `document.json` per document; no restore", + "source": "rxdb@17.6.0 src/plugins/backup/file-util.ts:1; src/plugins/backup/index.ts:57,118-160; https://rxdb.info/backup.html" + }, + "jsonDump": { + "mark": "✅", + "text": "`json-dump`: `exportJSON()` / `importJSON()`, schema-hash checked, attachments optional", + "note": "A dump has no deleted documents and no revisions, holds encrypted fields in plain text, and is built in memory.", + "source": "rxdb@17.6.0 src/plugins/json-dump/index.ts:113-200; https://rxdb.info/json-import-export.html" + }, + "model": { + "mark": "", + "text": "JSON documents; every collection has a JSON Schema", + "source": "https://rxdb.info/rx-schema.html; https://rxdb.info/rx-collection.html" + }, + "schemaless": { + "mark": "⚠️", + "text": "schema required; an open `type: 'object'` sub-field, RxState or local docs", + "source": "https://rxdb.info/rx-schema.html (FAQ: How to store schemaless data?); rxdb@17.6.0 src/rx-schema-helper.ts:204" + }, + "collectionNames": { + "mark": "✅", + "text": "never shadows database members; regex checked in dev-mode", + "source": "rxdb@17.6.0 src/rx-database.ts:511-521; src/plugins/dev-mode/unallowed-properties.ts:11-20,53", + "detail": "The docs give `^[a-z][a-z0-9]*$`; the dev-mode check is `^[a-z][_$a-zA-Z0-9\\-]*$` plus DB5 for names of RxDatabase members. Without the Premium flag at most 13 (14 after a delay) collections are open per process — see the license row." + }, + "fieldNames": { + "mark": "⚠️", + "text": "letters, digits, `_`; no leading `_`; no RxDocument member names", + "source": "rxdb@17.6.0 src/plugins/dev-mode/check-schema.ts:33-60,164-176,349-354; https://rxdb.info/rx-schema.html#non-allowed-properties", + "detail": "Checked by the dev-mode plugin at schema creation; `_id` and `_deleted` are the allowed underscore names." + }, + "primaryKey": { + "mark": "✅", + "text": "a top-level string field named by `primaryKey` (`_id` allowed)", + "source": "rxdb@17.6.0 src/types/rx-schema.d.ts:242-247; src/plugins/dev-mode/check-schema.ts:47-53,207-261" + }, + "compositeKey": { + "mark": "✅", + "text": "`primaryKey: { key, fields, separator }`, filled on insert", + "source": "rxdb@17.6.0 src/rx-schema-helper.ts:91-124,147-163; https://rxdb.info/rx-schema.html#composite-primary-key" + }, + "idGeneration": { + "mark": "❌", + "text": "the caller supplies the key; a composite key is derived", + "source": "rxdb@17.6.0 src/rx-collection-helper.ts:34-66; https://rxdb.info/releases/10.0.0.html", + "detail": "Since 10.0 the primary key is mandatory and never generated; the docs' examples use `crypto.randomUUID()`." + }, + "idRules": { + "mark": "⚠️", + "text": "string with `maxLength`; values checked only by a validator and dev-mode", + "note": "Without dev-mode and a validator, an empty, over-long or missing key is accepted; on the memory storage an insert without the key was stored under `undefined` (IndexedDB rejects that one).", + "source": "rxdb@17.6.0 src/rx-schema.ts:61-63 (SC39); src/plugins/dev-mode/check-document.ts:24-64 (DOC20-DOC23)", + "detail": "Dev-mode refuses an empty key, leading or trailing whitespace, `\\r`, `\\n` and `\"`; there is no character class otherwise." + }, + "immutableFields": { + "mark": "⚠️", + "text": "`final: true`; the primary key is final — enforced in dev-mode only", + "note": "Without dev-mode, `patch()` overwrote a `final` field, and patching the primary key wrote a copy under the new key beside the original (checked by running); dev-mode refuses both (`DOC9`).", + "source": "rxdb@17.6.0 src/rx-schema.ts:153-164; src/rx-document.ts:507-510; src/rx-schema-helper.ts:340-357", + "detail": "The docs say final fields are always required; the code does not add them to `required`, so a final field left out at insert can never be set (DOC9). Patching the primary key in production left the old document and wrote a second one under the new key." + }, + "schemaFormat": { + "mark": "✅", + "text": "JSON Schema (draft-04 subset) plus `primaryKey`, `indexes`, `final`, `ref`", + "source": "rxdb@17.6.0 src/types/rx-schema.d.ts:35-154,211-354; https://rxdb.info/rx-schema.html", + "detail": "Top-level `additionalProperties` is forced to `false` (src/rx-schema-helper.ts:204); `$ref` is refused in dev-mode (SC40)." + }, + "validators": { + "mark": "✅", + "text": "`validate-ajv`, `validate-z-schema`; `validate-is-my-json-valid` unsupported", + "note": "The `validate-ajv-standalone` plugin documented on rxdb.info is not in 17.6.0; it is on master, unreleased.", + "source": "rxdb@17.6.0 src/plugins/validate-ajv/index.ts:58; src/plugins/validate-z-schema; https://rxdb.info/schema-validation.html", + "detail": "rxdb master orga/changelog/add-validate-ajv-standalone-plugin.json. ajv, ajv-formats and z-schema are runtime dependencies of the rxdb package." + }, + "validationDefault": { + "mark": "⚠️", + "text": "off until the storage is wrapped; dev-mode refuses to start without it", + "note": "Without the wrapper, production accepts unknown top-level fields, wrong types and missing required fields (checked by running).", + "source": "rxdb@17.6.0 src/plugin-helpers.ts:55-135; src/plugins/dev-mode/index.ts:131-137 (DVM1); https://rxdb.info/schema-validation.html" + }, + "validatorOutput": { + "mark": "❌", + "text": "validators return errors only; the data is written as given", + "source": "rxdb@17.6.0 src/plugins/validate-ajv/index.ts:42-54; src/plugin-helpers.ts:113-129" + }, + "checkOnly": { + "mark": "⚠️", + "text": "`getValidator(schema)` from the validate plugin; no collection method", + "source": "rxdb@17.6.0 src/plugins/validate-ajv/index.ts:42-54; src/rx-schema.ts (no validate method)" + }, + "tsInference": { + "mark": "✅", + "text": "`ExtractDocumentTypeFromTypedRxJsonSchema` from an `as const` schema; typed collections", + "source": "rxdb@17.6.0 src/types/rx-schema.d.ts:356-360; src/rx-schema.ts:286-325; https://rxdb.info/tutorials/typescript.html" + }, + "defaults": { + "mark": "⚠️", + "text": "`default` on top-level fields, filled by insert and upsert", + "source": "rxdb@17.6.0 src/rx-schema.ts:72-83; src/rx-schema-helper.ts:363-377; src/plugins/dev-mode/check-schema.ts:142-149", + "detail": "Nested defaults are refused in dev-mode (SC7) and ignored otherwise; a migration does not fill them (https://rxdb.info/migration-schema.html, Default values are not auto-applied)." + }, + "required": { + "mark": "✅", + "text": "JSON Schema `required`; the primary key always", + "source": "rxdb@17.6.0 src/rx-schema-helper.ts:239-253", + "detail": "Enforced only when a validator plugin wraps the storage." + }, + "nested": { + "mark": "✅", + "text": "nested schemas; dotted paths in update operators and queries", + "source": "https://rxdb.info/rx-schema.html; rxdb@17.6.0 src/plugins/update/mingo-updater.ts", + "detail": "Across replicas the unit is still the document, unless the `crdt` plugin replays `$set` on dotted paths." + }, + "schemaVersion": { + "mark": "✅", + "text": "`version` per collection; a changed schema without a bump throws `DB6`", + "source": "rxdb@17.6.0 src/rx-database.ts:463-484; https://rxdb.info/rx-schema.html#version" + }, + "migration": { + "mark": "✅", + "text": "`migrationStrategies` per version, at open; returning `null` drops a document", + "source": "rxdb@17.6.0 src/plugins/migration-schema/rx-migration-state.ts:600-622; https://rxdb.info/migration-schema.html", + "detail": "Async strategies; `autoMigrate: false` with `startMigration()`/`migratePromise()`; a state observable; one tab runs it (leader election); the replication meta is migrated too." + }, + "readOld": { + "mark": "❌", + "text": "invisible until migrated; writes throw `COL25`", + "source": "rxdb@17.6.0 src/plugins/migration-schema/index.ts:47-63; src/rx-collection-helper.ts:277-298; https://rxdb.info/migration-schema.html#automigrate", + "detail": "Checked by running with `autoMigrate: false`: `find()` returned `[]` and `insert()` threw COL25 until `migratePromise()` finished. By default `addCollections()` waits for the migration." + }, + "peerVersions": { + "mark": "⚠️", + "text": "no version on documents; run the same migration on server and clients", + "source": "https://rxdb.info/migration-schema.html#migration-and-replication", + "detail": "The pull checkpoint is not migrated, so the backend must accept checkpoints of older versions. Overlaps the replication area's schema-versions row." + }, + "bulk": { + "mark": "✅", + "text": "`bulkInsert`, `bulkUpsert`, `bulkRemove` with per-document success and error lists", + "source": "rxdb@17.6.0 src/rx-collection.ts:418-567,569-660,665-755" + }, + "upsert": { + "mark": "✅", + "text": "`upsert()`, `bulkUpsert()`, `incrementalUpsert()`", + "source": "rxdb@17.6.0 src/rx-collection.ts:665-832", + "detail": "The docs say parallel `upsert()` calls throw 409; in 17.6.0 an existing document goes through the incremental write queue, and 20 parallel upserts all succeeded (checked by running)." + }, + "insertIfAbsent": { + "mark": "✅", + "text": "`insertIfNotExists()` returns the existing document; `insert()` throws 409", + "source": "rxdb@17.6.0 src/rx-collection.ts:389-416" + }, + "patch": { + "mark": "✅", + "text": "`patch()`, `incrementalPatch()`", + "source": "rxdb@17.6.0 src/rx-document.ts:335-364" + }, + "unsetField": { + "mark": "✅", + "text": "`patch({ field: undefined })`, `$unset`", + "source": "https://rxdb.info/rx-document.html#patch; rxdb@17.6.0 src/types/plugins/update.d.ts:17" + }, + "modifyFn": { + "mark": "✅", + "text": "`modify(fn)`; `incrementalModify(fn)` re-runs on the latest state", + "source": "rxdb@17.6.0 src/rx-document.ts:307-333; src/incremental-write.ts:48-61" + }, + "operators": { + "mark": "✅", + "text": "`update` plugin (mingo): `$set`, `$inc`, `$push`, `$addToSet`, `$pullAll`, `$min`…", + "source": "rxdb@17.6.0 src/plugins/update/index.ts:15-32; src/types/plugins/update.d.ts:12-23; https://rxdb.info/rx-document.html#update", + "detail": "The typed list is `$min $max $inc $set $unset $push $addToSet $pop $pullAll $rename`; mingo also ran `$pull` (checked by running)." + }, + "byQuery": { + "mark": "✅", + "text": "`find().update()`, `.patch()`, `.modify()`, `.remove()`", + "source": "rxdb@17.6.0 src/rx-query.ts:502-583; src/rx-query-helper.ts:258-285", + "detail": "One write per matched document, not atomic." + }, + "cas": { + "mark": "✅", + "text": "every non-incremental write checks `_rev`; a stale one throws 409 `CONFLICT`", + "source": "rxdb@17.6.0 src/rx-document.ts:370-403; https://rxdb.info/transactions-conflicts-revisions.html" + }, + "localConflicts": { + "mark": "⚠️", + "text": "a stale `patch`/`modify`/`update` throws 409; the `incremental*` methods retry", + "source": "rxdb@17.6.0 src/rx-document.ts:307-364; https://rxdb.info/rx-document.html (Prevent conflicts with the incremental methods)", + "detail": "Checked by running: `patch()` on an outdated RxDocument threw CONFLICT, `incrementalPatch()` on the same instance succeeded." + }, + "transactions": { + "mark": "❌", + "text": "none; one document write is the atomic unit", + "source": "https://rxdb.info/transactions-conflicts-revisions.html; https://rxdb.info/rx-collection.html#bulkinsert", + "detail": "Checked by running: a `bulkInsert` with one duplicate id wrote the other two documents and returned the 409 in `error`." + }, + "changeAndDelete": { + "mark": "✅", + "text": "set `_deleted: true` inside `modify()` or `update()`", + "source": "https://rxdb.info/rx-document.html (Remove and update in a single atomic operation)" + }, + "counters": { + "mark": "⚠️", + "text": "`$inc` is local; it converges only under the `crdt` plugin", + "source": "rxdb@17.6.0 src/plugins/update/index.ts:15-23; src/plugins/crdt/index.ts:243-302", + "detail": "Without the CRDT plugin a replication conflict keeps one side's document (the default handler keeps the master state). Checked by running: two databases each `$inc` by 1, the CRDT merge gave 2." + }, + "sets": { + "mark": "⚠️", + "text": "`$addToSet`/`$pullAll`; `crdt` replays them in creator order, not observed-remove", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:133-135,283-302", + "detail": "Checked by running: a remove concurrent with a re-add of the same element kept or dropped it depending on which of the two databases' random storage tokens sorts last." + }, + "lists": { + "mark": "⚠️", + "text": "`crdt` plugin replays `$push`; no positional insert", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:133-168,243-302; https://rxdb.info/crdt.html", + "detail": "Concurrent pushes both survive, ordered by creator id (checked by running)." + }, + "text": { + "mark": "❌", + "text": "none", + "source": "rxdb@17.6.0 src (no text CRDT); https://rxdb.info/crdt.html (Why not automerge.js or yjs?)" + }, + "crdtModel": { + "mark": "⚠️", + "text": "opt-in `crdt` plugin per schema; otherwise a conflict handler", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:435-451; https://rxdb.info/crdt.html" + }, + "mergeGranularity": { + "mark": "⚠️", + "text": "the default handler keeps the master's whole document; merging needs a handler or `crdt`", + "source": "rxdb@17.6.0 src/replication-protocol/default-conflict-handler.ts:8; https://rxdb.info/transactions-conflicts-revisions.html" + }, + "registerWinner": { + "mark": "", + "text": "`crdt`: op position, then storage-token order, not time; otherwise the master", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:56-79,133-135,243-302", + "detail": "Checked by running: two databases each `$set` the same field once; both merge directions kept the value of the database whose random storage token sorts higher." + }, + "mergeHistory": { + "mark": "⚠️", + "text": "`crdt`: every op stored inside the document, never compacted", + "note": "The document grows with every write: 200 `$inc` left 201 op rows, about 18 KB (checked by running), and every replica stores and transfers them.", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:56-79,283-302" + }, + "mergeBypass": { + "mark": "⚠️", + "text": "`modify()` is refused, but `upsert()`/`incrementalUpsert()` replace the op history", + "note": "Checked by running: `upsert()` over a CRDT document reset its op list to one entry, `incrementalUpsert()` dropped the CRDT field, and with the `update` plugin added after `crdt`, `update()` wrote outside the ops. Dev-mode stops the last two, not `upsert()`.", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:342-427,467-511; src/rx-collection.ts:716-751,773-832; src/plugin.ts:87-93" + }, + "conditionalOps": { + "mark": "✅", + "text": "`crdt`: `selector` with `ifMatch`/`ifNotMatch`, re-evaluated on every replay", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:138-168; https://rxdb.info/crdt.html#conditional-crdt-operations" + }, + "deltaHelpers": { + "mark": "❌", + "text": "none; `mergeCRDTFields` merges the plugin's own field only", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:243-281" + }, + "meta": { + "mark": "", + "text": "`_rev` (height-token, local), `_meta.lwt`, `_deleted`, `_attachments`", + "source": "rxdb@17.6.0 src/rx-schema-helper.ts:220-246; src/plugins/utils/utils-revision.ts:59-65; https://rxdb.info/rx-document.html", + "detail": "The revision token is the writing database's token, not a content hash; replication strips `_rev`." + }, + "delete": { + "mark": "✅", + "text": "`_deleted: true` with the full data until `cleanup`; re-insert revives", + "source": "rxdb@17.6.0 src/rx-document.ts:405-439; src/plugins/cleanup/cleanup-helper.ts:5-11; https://rxdb.info/rx-document.html", + "detail": "Checked by running: the removed document kept every field with `_deleted: true`; inserting the id again wrote revision 3." + }, + "documentObjects": { + "mark": "✅", + "text": "`RxDocument`: read-only snapshot per revision; `getLatest()`, `$`", + "source": "rxdb@17.6.0 src/rx-document.ts:95-130; https://rxdb.info/rx-document.html (Document Lifetime and Immutability)" + }, + "identity": { + "mark": "✅", + "text": "one `RxDocument` per revision while referenced (WeakRef cache)", + "source": "rxdb@17.6.0 src/doc-cache.ts:20-140", + "detail": "Checked by running: two `findOne().exec()` calls returned the same instance." + }, + "returnedData": { + "mark": "⚠️", + "text": "deep-frozen in dev-mode only; production `toJSON()` shares nested objects", + "note": "Checked by running without dev-mode: editing `toJSON().nested` changed the cached document (and the memory storage's copy), though the docs call `toJSON()` immutable.", + "source": "rxdb@17.6.0 src/rx-document.ts:261-275,551-559; src/overwritable.ts:26" + }, + "orm": { + "mark": "✅", + "text": "`statics`, `methods`, `attachments` per collection", + "source": "rxdb@17.6.0 src/rx-document-prototype-merge.ts:28-134; https://rxdb.info/orm.html" + }, + "relations": { + "mark": "✅", + "text": "schema `ref` with `populate()` or the `field_` getter; one or many", + "source": "rxdb@17.6.0 src/rx-document.ts:192-251; https://rxdb.info/population.html", + "detail": "No referential integrity: a removed target populates as `null` (checked by running)." + }, + "rxState": { + "mark": "✅", + "text": "`state` plugin (RxState): schemaless JSON, modifier writes, replicable", + "source": "rxdb@17.6.0 src/plugins/state/rx-state.ts:128-213; https://rxdb.info/rx-state.html", + "detail": "Each write stores the resulting values as an op document under the next sequential id; two replicas writing the same id meet the collection's conflict handler." + }, + "pipeline": { + "mark": "✅", + "text": "`pipeline` plugin: leader-run handler, checkpointed, blocks the destination", + "source": "rxdb@17.6.0 src/plugins/pipeline/rx-pipeline.ts:38-185; https://rxdb.info/rx-pipeline.html" + }, + "contracts": { + "mark": "❌", + "text": "none", + "source": "rxdb@17.6.0 src/plugins (no stored-procedure plugin)" + }, + "queryLanguage": { + "mark": "✅", + "text": "Mango query `{ selector, sort, skip, limit, index }`, matched by mingo", + "source": "rxdb@17.6.0 src/rx-query-mingo.ts:52-84; https://rxdb.info/mango-query.html", + "detail": "mingo 7.2.4 is a runtime dependency; RxDB registers 18 of its operators and has no runtime hook to add more (https://rxdb.info/mango-query.html#adding-query-operators)." + }, + "queryBuilder": { + "mark": "✅", + "text": "`query-builder` plugin: `where('f').gt(5).sort('-f').limit(n)`", + "source": "rxdb@17.6.0 src/plugins/query-builder/index.ts:54-81; https://rxdb.info/query-builder.html", + "detail": "17.6.0 still exposes `all()` (nosql-query-builder.ts:473-475), which emits `$all` and always fails with `unknown query operator $all` (verified); master removes it (orga/changelog/remove-query-builder-all.json)." + }, + "opComparison": { + "mark": "✅", + "text": "all six (mingo)", + "source": "rxdb@17.6.0 src/rx-query-mingo.ts:13-32; https://rxdb.info/mango-query.html#comparison-operators" + }, + "opSet": { + "mark": "⚠️", + "text": "`$in`, `$nin`; no `$all` (an `$and` of equalities instead)", + "source": "rxdb@17.6.0 src/rx-query-mingo.ts:13-32; https://rxdb.info/mango-query.html#array-fields", + "detail": "`$all` throws `unknown query operator $all`, with or without dev-mode (verified)." + }, + "opExistence": { + "mark": "✅", + "text": "`$exists`; `$type` (`null`, `boolean`, `number`, `string`, `array`, `object`)", + "source": "rxdb@17.6.0 src/types/rx-query.d.ts:43-60; https://rxdb.info/mango-query.html#element-operators" + }, + "opArray": { + "mark": "✅", + "text": "element match, `$elemMatch` (objects or scalars), `$size`", + "source": "rxdb@17.6.0 src/rx-query-mingo.ts:13-32; https://rxdb.info/mango-query.html#array-fields" + }, + "opRegex": { + "mark": "✅", + "text": "string with `$options`; a `RegExp` instance is refused in dev-mode (QU16)", + "source": "rxdb@17.6.0 src/plugins/dev-mode/check-query.ts:191-209; https://rxdb.info/mango-query.html#regex-queries", + "note": "Without dev-mode, a `RegExp` instance is normalized to `{}` and the query matches every document (verified on 17.6.0)." + }, + "opLogical": { + "mark": "✅", + "text": "`$and`, `$or`, `$nor`; `$not` on a field only", + "source": "rxdb@17.6.0 src/rx-query-mingo.ts:13-32; https://rxdb.info/mango-query.html#logical-operators", + "detail": "A top-level `$not` throws `unknown top level operator: $not` (verified)." + }, + "opMod": { + "mark": "✅", + "text": "`$mod: [divisor, remainder]`", + "source": "https://rxdb.info/mango-query.html#mod" + }, + "jsPredicate": { + "mark": "❌", + "text": "no `$where` or `$expr`; filter the results in app code", + "source": "rxdb@17.6.0 src/rx-query-mingo.ts:13-32; https://rxdb.info/mango-query.html#adding-query-operators", + "detail": "`$where` and `$expr` throw `unknown query operator` (verified). Filtering after `exec()` runs after `limit`, so a page can come back short." + }, + "dottedPaths": { + "mark": "✅", + "text": "dot notation in selectors, `sort` (`tags.0`) and indexes", + "source": "https://rxdb.info/mango-query.html#nested-fields; https://rxdb.info/mango-query.html#sorting", + "detail": "The docs say a bracket sort path (`tags[0]`) throws QU20 in dev-mode; in 17.6.0 QU20 does not exist: a top-level one throws QU13 in dev-mode, a nested one (`a.b[0]`) and any one outside dev-mode is ignored and rows come back in primary-key order (verified). QU20 is master-only (orga/changelog/dev-mode-throw-on-bracket-sort-path.json)." + }, + "queryValidation": { + "mark": "⚠️", + "text": "unknown operators throw; field names, `RegExp`, `undefined` and extra keys only in dev-mode", + "source": "rxdb@17.6.0 src/plugins/dev-mode/check-query.ts:22-173", + "detail": "Dev-mode codes QU11 (unknown key), QU13 (field not in schema, top level only), QU16 (RegExp), QU19 (undefined). Without dev-mode a misspelled field returns [] and a RegExp matches everything (verified)." + }, + "typedQueries": { + "mark": "✅", + "text": "`MangoQuery` checks paths and values; results are typed `RxDocument`s", + "source": "rxdb@17.6.0 src/types/rx-query.d.ts:62-68" + }, + "immutableQueries": { + "mark": "✅", + "text": "every builder returns a new `RxQuery`", + "source": "rxdb@17.6.0 src/plugins/query-builder/index.ts:15-35; https://rxdb.info/rx-query.html#rxquery-instances-are-immutable" + }, + "docMatch": { + "mark": "✅", + "text": "`query.doesDocumentDataMatch(doc)`", + "source": "rxdb@17.6.0 src/rx-query.ts:489-496; https://rxdb.info/rx-query.html#doesdocumentdatamatch" + }, + "sort": { + "mark": "✅", + "text": "`sort: [{ f: 'asc' | 'desc' }]`: several keys, nested; primary key appended", + "source": "rxdb@17.6.0 src/rx-query-helper.ts:171-178; https://rxdb.info/mango-query.html#sorting", + "detail": "Without `sort`, RxDB sorts by the best-matching index, then the primary key (rx-query-helper.ts:101-170). A non-indexed sort field works on the free memory storage (verified) and on Dexie, which sorts in memory (dexie-query.ts:181-184)." + }, + "sortIndex": { + "mark": "✅", + "text": "an ascending sort that matches the index walks it and stops at `skip + limit`", + "source": "rxdb@17.6.0 src/query-planner.ts:44-48,208; src/plugins/storage-dexie/dexie-query.ts:155-168", + "note": "Any `desc` sort key forces a re-sort of every match, since most storages have no descending index (query-planner.ts:44-48)." + }, + "topK": { + "mark": "⚠️", + "text": "sorts every match, then slices", + "source": "rxdb@17.6.0 src/plugins/storage-memory/rx-storage-instance-memory.ts:393-396; src/plugins/storage-dexie/dexie-query.ts:181-187", + "detail": "Unreleased on master: a bounded heap (`createTopKDocumentsCollector()`) for memory, localstorage, dexie, denokv, foundationdb and sqlite (orga/changelog/top-k-query-sort.json)." + }, + "skipLimit": { + "mark": "✅", + "text": "`skip`, `limit`; `findOne()` refuses a `limit` (QU6)", + "source": "rxdb@17.6.0 src/rx-collection.ts:884-890; https://rxdb.info/mango-query.html#pagination-with-skip-and-limit" + }, + "streaming": { + "mark": "❌", + "text": "`exec()` returns the whole page; keyset paging by hand (`$gt` on the sort key)", + "source": "rxdb@17.6.0 src/rx-query.ts:371-388" + }, + "count": { + "mark": "⚠️", + "text": "a selector no index satisfies throws `QU14` on Dexie (every storage in dev-mode) unless `allowSlowCount: true`", + "source": "rxdb@17.6.0 src/rx-query.ts:320-333; src/plugins/dev-mode/check-query.ts:137-150; https://rxdb.info/rx-query.html#count", + "detail": "Enforced by dev-mode (QU14) and by storages that report a slow count (Dexie, rx-storage-instance-dexie.ts:251-266); the memory storage counts anyway without dev-mode (verified). `skip`/`limit` are QU15 in dev-mode, ignored otherwise." + }, + "findById": { + "mark": "✅", + "text": "`findOne(id)`: document cache first, then `findDocumentsById()`", + "source": "rxdb@17.6.0 src/rx-query.ts:845-915; src/rx-collection.ts:856-898" + }, + "findByIds": { + "mark": "✅", + "text": "`findByIds(ids)` → `Map`, observable like any query", + "source": "rxdb@17.6.0 src/rx-collection.ts:918-935; src/rx-query.ts:293-319" + }, + "findOne": { + "mark": "✅", + "text": "`findOne(query)`; `exec(true)` throws when nothing matches", + "source": "rxdb@17.6.0 src/rx-query.ts:371-388; https://rxdb.info/rx-query.html#findone" + }, + "projection": { + "mark": "❌", + "text": "always whole documents, by design; a `fields` key is QU11 in dev-mode", + "source": "https://rxdb.info/rx-query.html#faq; rxdb@17.6.0 src/plugins/dev-mode/check-query.ts:32-51" + }, + "resultObjects": { + "mark": "", + "text": "`RxDocument` instances, one per revision, shared across queries", + "source": "https://rxdb.info/rx-query.html#query-the-storage-directly", + "detail": "`storageInstance.query(query.getPreparedQuery())` returns plain data with `_rev`/`_meta`, not reactive, not cached." + }, + "aggregation": { + "mark": "❌", + "text": "none, by design", + "source": "https://rxdb.info/rx-query.html#faq", + "detail": "RxPipeline can maintain aggregates in another collection (https://rxdb.info/rx-pipeline.html); TanStack DB's `@tanstack/rxdb-db-collection` adds in-memory `groupBy` (https://rxdb.info/articles/tanstack-db/what-is-tanstack-db.html)." + }, + "joins": { + "mark": "❌", + "text": "one collection per query; `populate()` follows one ref", + "source": "https://rxdb.info/rx-query.html#faq", + "detail": "The IndexedDB article lists 'Joins across stores ✅ populate()' (https://rxdb.info/articles/indexeddb/indexeddb-query.html); populate resolves references, it is not a join. TanStack DB on top of RxDB adds in-memory joins." + }, + "encryptedFields": { + "mark": "❌", + "text": "encrypted fields cannot be queried; an `$eq` on one silently matches nothing", + "source": "https://rxdb.info/encryption.html#querying-encrypted-data", + "detail": "Verified with `encryption-crypto-js`: `$eq` and `$regex` 0 rows, `$exists` still 1. The docs suggest a memory-mapped (Premium) unencrypted copy." + }, + "secondaryIndexes": { + "mark": "✅", + "text": "schema `indexes: ['f', ['a', 'b']]`", + "source": "https://rxdb.info/rx-schema.html#indexes" + }, + "indexKind": { + "mark": "✅", + "text": "sorted keys: `$eq`, `$gt`…`$lte` and `$in` (min to max) bound the scan", + "source": "rxdb@17.6.0 src/query-planner.ts:101-133,239,370-406" + }, + "compoundIndexes": { + "mark": "✅", + "text": "any index is a field list; `_deleted` and the primary key are added", + "source": "rxdb@17.6.0 src/rx-schema-helper.ts:259-283" + }, + "arrayIndexes": { + "mark": "❌", + "text": "refused in dev-mode (`SC36`); an RxPipeline into a mapping collection instead", + "source": "rxdb@17.6.0 src/plugins/dev-mode/check-schema.ts:536-548; https://rxdb.info/rx-pipeline.html", + "note": "Without dev-mode the memory storage fails at `addCollections()` with an uncoded `TypeError`, and Dexie accepts the index on a required field but queries through it find nothing (checked by running)." + }, + "indexValues": { + "mark": "⚠️", + "text": "strings with `maxLength`, numbers with `minimum`/`maximum`/`multipleOf`, required booleans", + "source": "rxdb@17.6.0 src/plugins/dev-mode/check-schema.ts:386-480; https://rxdb.info/rx-schema.html#indexes" + }, + "indexStorage": { + "mark": "", + "text": "in the storage: IndexedDB indexes on Dexie (disk), sorted arrays on memory", + "source": "rxdb@17.6.0 src/plugins/storage-dexie/dexie-query.ts:133-144; src/plugins/storage-memory/memory-indexes.ts:7-33" + }, + "indexChange": { + "mark": "⚠️", + "text": "a schema change: bump `version` and migrate, or `addCollections()` throws DB6", + "source": "rxdb@17.6.0 src/rx-database.ts:463-484", + "detail": "Verified: adding `indexes: ['n']` at the same version is DB6." + }, + "planner": { + "mark": "✅", + "text": "rates one key range per index, takes the best; `index` forces one", + "source": "rxdb@17.6.0 src/query-planner.ts:34-237,450-483; https://rxdb.info/rx-query.html#setting-a-specific-index" + }, + "indexCombination": { + "mark": "❌", + "text": "one index per query; an `$or` scans a whole index", + "source": "rxdb@17.6.0 src/query-planner.ts:254-256", + "detail": "Verified: the plan for `$or: [{ a }, { b }]` over indexes `a` and `b` has open start and end keys. The docs advise adding a restrictive top-level operator (https://rxdb.info/nosql-performance-tips.html)." + }, + "coveredQueries": { + "mark": "⚠️", + "text": "`count()` on an index-satisfied selector only; `find()` always reads documents", + "source": "rxdb@17.6.0 src/plugins/storage-dexie/dexie-query.ts:211-257" + }, + "explain": { + "mark": "✅", + "text": "`query.getPreparedQuery().queryPlan`: index, key range, `sortSatisfiedByIndex`", + "source": "rxdb@17.6.0 src/rx-query-helper.ts:345-368; https://rxdb.info/rx-query.html#query-the-storage-directly" + }, + "queryOptimizer": { + "mark": "⚠️", + "text": "Premium (Pro Plus): `findBestIndex()`, run at build time on test data", + "source": "https://rxdb.info/query-optimizer.html; https://rxdb.info/premium" + }, + "queryCache": { + "mark": "✅", + "text": "`QueryCache`: equal queries share one `RxQuery`; about 100 unobserved ones kept", + "source": "rxdb@17.6.0 src/query-cache.ts:24-110; https://rxdb.info/query-cache.html", + "detail": "Verified: two `find()` calls with the same selector return the same instance. A custom `cacheReplacementPolicy` per collection." + }, + "unique": { + "mark": "❌", + "text": "primary key only", + "source": "rxdb@17.6.0 src/plugins/dev-mode/check-schema.ts:327-332", + "detail": "A `unique: true` keyword on another field is ignored (memory storage inserted the duplicate, verified) and ajv strict mode rejects it as an unknown keyword." + }, + "fulltext": { + "mark": "⚠️", + "text": "Premium (Pro): FlexSearch plugin, `addFulltextSearch()` then `flexSearch.find(text)`", + "source": "https://rxdb.info/fulltext-search.html; https://rxdb.info/premium", + "detail": "Closed source, not verified. The docs say the index is persisted in the collection, built through RxPipeline by the leader tab, and encrypted under an encrypted storage. Third-party alternative: rxdb-flexsearch (docs-src/docs/third-party-plugins.md)." + }, + "fulltextInQuery": { + "mark": "❌", + "text": "`flexSearch.find()` is a separate call; no `$text` operator", + "source": "https://rxdb.info/fulltext-search.html", + "detail": "`$text` in a selector throws `unknown top level operator: $text` (verified)." + }, + "vector": { + "mark": "⚠️", + "text": "`rxdb/plugins/vector`: four distance functions; nearest-neighbour search is app code", + "source": "rxdb@17.6.0 src/plugins/vector/vector-distance.ts:1-34; https://rxdb.info/articles/javascript-vector-database.html", + "detail": "The article builds the search by hand: embeddings via transformers.js in an RxPipeline, then a full scan or a 'distance to samples' index over regular string indexes." + }, + "liveQueries": { + "mark": "✅", + "text": "`query.$`: the current result on subscribe, then each new result", + "source": "rxdb@17.6.0 src/rx-query.ts:150-213; https://rxdb.info/rx-query.html#observe-", + "detail": "`liveQueryUpdateThrottleTime` (beta since 17.1.0) groups write bursts with `auditTime`." + }, + "liveIncremental": { + "mark": "✅", + "text": "EventReduce derives the new result from the change events", + "source": "rxdb@17.6.0 src/event-reduce.ts:101-168; src/rx-query.ts:726-775", + "detail": "Verified on memory: an unlimited query absorbed four writes with 0 re-runs; a `limit: 3` query re-ran once, when a row had to enter the window. `eventReduce: false` turns it off." + }, + "liveDistinct": { + "mark": "✅", + "text": "an unchanged result is not emitted", + "source": "rxdb@17.6.0 src/rx-query.ts:183-190" + }, + "liveCount": { + "mark": "✅", + "text": "`count().$`, adjusted from each change event without a re-count", + "source": "rxdb@17.6.0 src/rx-query.ts:737-755", + "detail": "Verified: 0 storage re-counts over four writes, with event-reduce on or off." + }, + "collectionStream": { + "mark": "✅", + "text": "`collection.$`, `insert$`, `update$`, `remove$`; `database.$`", + "source": "rxdb@17.6.0 src/rx-collection.ts:182-196; src/rx-database.ts:198; https://rxdb.info/rx-collection.html" + }, + "documentObservable": { + "mark": "✅", + "text": "`doc.$`, `doc.get$(path)` / `doc.field$`, `doc.deleted$`", + "source": "rxdb@17.6.0 src/rx-document.ts:68-140" + }, + "reactivityPrimitive": { + "mark": "✅", + "text": "RxJS observables (`$`); a `reactivity` factory turns them into signals or refs (`$$`)", + "source": "rxdb@17.6.0 src/rx-query.ts:215-222; https://rxdb.info/reactivity.html", + "detail": "Factories ship as reactivity-angular, reactivity-vue, reactivity-preact-signals; the framework list is the platform area's." + }, + "liveRemote": { + "mark": "✅", + "text": "replicated writes go through the collection's event stream, so `$` updates", + "source": "rxdb@17.6.0 src/rx-query.ts:150-158; https://rxdb.info/articles/realtime-database.html", + "detail": "Other tabs' writes too, through BroadcastChannel with `multiInstance` (platform area)." + }, + "replModel": { + "mark": "", + "text": "document state: pull by checkpoint, push with `assumedMasterState` (fork and master)", + "source": "https://rxdb.info/replication.html; rxdb@17.6.0 src/replication-protocol/downstream.ts:174-216, upstream.ts:279-430" + }, + "wire": { + "mark": "", + "text": "whole documents with `_deleted`; a push row carries two whole states", + "source": "rxdb@17.6.0 src/types/replication-protocol.d.ts:57-75; src/replication-protocol/helper.ts:53-68", + "detail": "`_meta` and `_rev` are stripped; with attachments, full attachment data rides along (src/replication-protocol/index.ts:200-258). Every plugin ships states; none ships operations. The WebRTC page FAQ says it streams \"CRDT operations\", which the code does not." + }, + "replHistory": { + "mark": "❌", + "text": "latest state per document; intermediate writes collapse before a push", + "source": "rxdb@17.6.0 src/replication-protocol/upstream.ts:288-292, downstream.ts:264-268", + "detail": "The `crdt` plugin keeps its operations inside the document, so they travel as part of its state." + }, + "protocolVersion": { + "mark": "❌", + "text": "none in the protocol; checkpoint and payload are the backend's", + "source": "rxdb@17.6.0 src/types/replication-protocol.d.ts:94-108", + "detail": "RxServer pins the schema version in the endpoint path instead (see Schema check at connect)." + }, + "schemaHandshake": { + "mark": "⚠️", + "text": "RxServer only: schema version in the URL; older clients get 426 (`outdatedClient$`)", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:59-62, 279-308; src/plugins/replication-server/helpers.ts:4-37", + "note": "RxServer compares the integer version only, with no schema hash. Elsewhere every replica must run the same migration, and the backend must accept checkpoints of older versions.", + "detail": "https://rxdb.info/migration-schema.html (Migration and Replication): the pull checkpoint is not migrated." + }, + "foreignBackend": { + "mark": "✅", + "text": "any backend: pull, push and stream handlers over your own database", + "source": "https://rxdb.info/replication.html (Design Decisions; transfer level)" + }, + "customTransport": { + "mark": "✅", + "text": "`replicateRxCollection`: `pull.handler`, `push.handler`, `pull.stream$` with `RESYNC`", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:785-862; src/types/plugins/replication.d.ts:27-121", + "detail": "The backend implements the checkpoint query and the `assumedMasterState` compare. The docs' HTTP recipe does not run as written: the server reads `batchSize` where the client sends `limit`, registers push as GET, and emits on an undeclared stream (https://rxdb.info/replication-http.html)." + }, + "roundTrips": { + "mark": "", + "text": "pulls until a short page; one push per 100 documents; a re-push per conflict", + "source": "rxdb@17.6.0 src/replication-protocol/downstream.ts:185-214; upstream.ts:198-201, 406-430", + "detail": "Every reconnect re-runs the checkpoint iteration (`RESYNC`): at least one pull request." + }, + "collectionsPerLink": { + "mark": "⚠️", + "text": "one replication per collection, each with its own requests and stream", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:119, 785-797; rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:43-68", + "detail": "GraphQL shares one graphql-ws socket per URL across replications (src/plugins/replication-graphql/graphql-websocket.ts:21-50); `replicateWithWebsocketServer` opens a socket per call; RxServer mounts one endpoint per collection." + }, + "replMeta": { + "mark": "", + "text": "per replication: two checkpoints and the last master state of every document", + "source": "rxdb@17.6.0 src/replication-protocol/meta-instance.ts:25-99, 147-195; src/plugins/replication/index.ts:129-132", + "detail": "A pull-only replication skips the per-document copies (`skipStoringPullMeta`, src/plugins/replication/index.ts:264)." + }, + "topology": { + "mark": "", + "text": "a star of fork–master pairs; WebRTC peers pair up in a mesh", + "source": "https://rxdb.info/replication.html; rxdb@17.6.0 src/plugins/replication-webrtc/index.ts:156-268" + }, + "multiMaster": { + "mark": "⚠️", + "text": "WebRTC only; each pair has a hash-chosen master and a fork", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc/webrtc-helper.ts:23-33; index.ts:192-266", + "note": "The WebRTC page says there is \"no master-slave concept\"; the code makes one peer of every pair the master, and only the other side runs a replication.", + "detail": "The Google Drive and OneDrive plugins need no server of yours either, but the user's drive is then the master." + }, + "relay": { + "mark": "✅", + "text": "through the local store: a second replication pushes what the first pulled", + "source": "rxdb@17.6.0 src/replication-protocol/upstream.ts:247-257; https://rxdb.info/partial-sync.html (FAQ)", + "detail": "Only states move; echoes stop when a document equals its assumed master state. With several replications on one collection the docs advise a `push.modifier` so that one replication owns each document." + }, + "browserHosts": { + "mark": "⚠️", + "text": "only as the master of a WebRTC pair; other servers are Node", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc/index.ts:121-154, 294-298; src/plugins/replication-websocket/websocket-server.ts:7-11" + }, + "scaleOut": { + "mark": "⚠️", + "text": "processes share one storage; branch servers replicate to a root", + "source": "https://rxdb.info/rx-server-scaling.html; rxdb@17.6.0 src/rx-storage-multiinstance.ts:98-160", + "note": "On the MongoDB and FoundationDB storages a process sees only its own writes (the change-stream relay is a commented-out TODO), so a live stream misses writes made through another process until its next `RESYNC`.", + "detail": "Code reading: rxdb@17.6.0 src/plugins/storage-mongodb/rx-storage-instance-mongodb.ts:124-156; storage-foundationdb/rx-storage-instance-foundationdb.ts:209, 266-268. The scaling page's sample calls `startRxServer()`, which does not exist (`createRxServer` with an `adapter`)." + }, + "webrtc": { + "mark": "✅", + "text": "`replication-webrtc` (simple-peer), free; tests re-enabled in 17.6.0", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc; CHANGELOG.md:29-38", + "detail": "Before 17.6.0 the tests were disabled as flaky. Messages are JSON, split above 16 KiB of text with no reassembly cap (connection-handler-simple-peer.ts:164, 570-597). Node needs node-datachannel through `createSimplePeerWrtc()`; browsers need a `process.nextTick` polyfill (:636-643)." + }, + "signaling": { + "mark": "✅", + "text": "self-hosted `ws` server, a public demo server (default), or Nostr relays", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc/signaling-server.ts:24-32; connection-handler-simple-peer.ts:89-93; connection-handler-nostr.ts; CHANGELOG.md:14", + "note": "Without `signalingServerUrl` the handler uses `wss://signaling.rxdb.info/`, which the code warns is for demos only; the official quickstart app uses it.", + "detail": "Nostr signaling arrived in 17.6.0 (relays required, no defaults). The p2pcf and WebTorrent handlers are commented out." + }, + "p2pRoom": { + "mark": "", + "text": "full mesh: one replication per pair, N(N−1)/2 in all, no cap", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc/connection-handler-simple-peer.ts:485-493; index.ts:217-265", + "detail": "Each pair keeps its own checkpoint (`collection||topic||peerToken`); the master side of a pair has no replication state to observe (webrtc-types.ts:97-105)." + }, + "ice": { + "mark": "", + "text": "simple-peer's Google and Twilio STUN by default; no TURN", + "source": "simple-peer@9.11.1 index.js:46, 1038-1048; rxdb@17.6.0 src/plugins/replication-webrtc/connection-handler-simple-peer.ts" + }, + "websocket": { + "mark": "⚠️", + "text": "`replication-websocket`: RxDB to RxDB, a Node server, no authentication", + "source": "rxdb@17.6.0 src/plugins/replication-websocket/websocket-server.ts:87-135; https://rxdb.info/replication-websocket.html", + "note": "The server ignores the client's `auth` message, and one message naming an unknown collection crashes the Node process (both checked by running).", + "detail": "The docs position it for server-to-server use. Every tab opens the socket before leader election (websocket-client.ts:100, 161-183)." + }, + "http": { + "mark": "✅", + "text": "your endpoints under `replicateRxCollection`; RxServer serves pull, push and SSE", + "source": "https://rxdb.info/replication-http.html; rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:85-274" + }, + "graphql": { + "mark": "✅", + "text": "`replication-graphql`; live through graphql-ws; schema and query generators", + "source": "rxdb@17.6.0 src/plugins/replication-graphql/index.ts:90-249; graphql-schema-from-rx-schema.ts", + "note": "With `pull.batchSize` unset the query asks for 20 documents while the core stops at a page under 100, so a cycle pulls only 20 (checked by running)." + }, + "couchdb": { + "mark": "✅", + "text": "`replication-couchdb`: `_changes` long-poll, `_bulk_docs` with `_rev`", + "source": "rxdb@17.6.0 src/plugins/replication-couchdb/index.ts:110-336; https://rxdb.info/replication-couchdb.html", + "detail": "Not the CouchDB replication protocol: only winning revisions cross, no attachments, one database per collection. The url must end with `/`, which the docs' examples lack (index.ts:85-97)." + }, + "firestore": { + "mark": "✅", + "text": "`replication-firestore` (`firebase` peer); `pull.filter`; live via `onSnapshot`", + "source": "rxdb@17.6.0 src/plugins/replication-firestore/index.ts:100-381", + "note": "The push compare runs inside `runTransaction` without using the transaction, so a write landing between its read and commit is overwritten (code reading)." + }, + "supabase": { + "mark": "✅", + "text": "`replication-supabase`: PostgREST compare-and-set; Realtime for live rows", + "source": "rxdb@17.6.0 src/plugins/replication-supabase/index.ts:106-317", + "detail": "The Realtime channel is not filtered by `pull.queryBuilder`, hard DELETEs never reach clients, and object or array fields cannot be updated by push (code reading)." + }, + "appwrite": { + "mark": "⚠️", + "text": "`replication-appwrite`: browser only; updates read then write, not atomic", + "source": "rxdb@17.6.0 src/plugins/replication-appwrite/index.ts:118-264; https://rxdb.info/replication-appwrite.html", + "detail": "The non-atomic update is a TODO in the code (index.ts:188-193). `appwrite` is imported at runtime but declared nowhere in rxdb's package.json." + }, + "mongodb": { + "mark": "✅", + "text": "`replication-mongodb` (Node): change streams; push in a transaction; replica set", + "source": "rxdb@17.6.0 src/plugins/replication-mongodb/index.ts:106-271; mongodb-checkpoint.ts:27-103", + "detail": "Deletes are real `deleteOne`s, read back from the change stream's pre-image. A replicator offline past the oplog window cannot resume its token." + }, + "nats": { + "mark": "✅", + "text": "`replication-nats`: a subject per document; updates checked by sequence", + "source": "rxdb@17.6.0 src/plugins/replication-nats/index.ts:76-283", + "detail": "Inserts publish with no sequence expectation, so two concurrent first writes of one id are not detected; the stream keeps every version unless limited (code reading)." + }, + "gdrive": { + "mark": "⚠️", + "text": "`replication-google-drive`, beta: JSON files and a lock file in the user's Drive", + "source": "rxdb@17.6.0 src/plugins/replication-google-drive; https://rxdb.info/replication-google-drive.html", + "detail": "Live pulls come only from WebRTC pings between devices already connected; the lock file is not released on error (60 s timeout in code, 10 s in the docs). Beta is stated on the page only." + }, + "onedrive": { + "mark": "⚠️", + "text": "`replication-microsoft-onedrive`, beta: the same file-and-lock scheme over Graph", + "source": "rxdb@17.6.0 src/plugins/replication-microsoft-onedrive; https://rxdb.info/replication-microsoft-onedrive.html", + "detail": "Unlike Google Drive, document updates carry no `If-Match`, so an overlap after a stale lock is not caught (document-handling.ts:143-152)." + }, + "conflictDetect": { + "mark": "✅", + "text": "the master compares `assumedMasterState` with its own (`isEqual`, deep-equal)", + "source": "rxdb@17.6.0 src/replication-protocol/index.ts:286-305; default-conflict-handler.ts:9-26" + }, + "conflictResolve": { + "mark": "", + "text": "`conflictHandler.resolve` on the client; the default keeps the master's document", + "source": "rxdb@17.6.0 src/replication-protocol/conflicts.ts:22-53; default-conflict-handler.ts:27-33", + "note": "Some RxDB pages call the default last-write-wins; the code keeps the server's state whatever its time (https://rxdb.info/articles/zero-latency-local-first.html)." + }, + "customMerge": { + "mark": "✅", + "text": "any function per collection; the `crdt` plugin brings its own", + "source": "https://rxdb.info/transactions-conflicts-revisions.html; rxdb@17.6.0 src/plugins/crdt/index.ts:304-330, 437-450" + }, + "conflictWhere": { + "mark": "", + "text": "on the client (fork); the server only detects", + "source": "rxdb@17.6.0 src/replication-protocol/conflicts.ts:15-29; https://rxdb.info/articles/tanstack-db/tanstack-db-conflict-resolution.html (FAQ)", + "detail": "Over WebRTC the side that lost the hash comparison resolves, so every peer needs the same deterministic handler." + }, + "conflictEvents": { + "mark": "✅", + "text": "`conflict$`: each conflict the server reported, with the handler's output", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:89-97, 439-442" + }, + "serverCompare": { + "mark": "⚠️", + "text": "per plugin: atomic on RxDB storages, CouchDB, Supabase, MongoDB; not Firestore, Appwrite", + "source": "rxdb@17.6.0 src/replication-protocol/index.ts:298-330; src/plugins/replication-firestore/index.ts:250-327; src/plugins/replication-appwrite/index.ts:188-193", + "detail": "NATS checks updates by subject sequence but not inserts; Google Drive and OneDrive serialise through a lock file; GraphQL and custom HTTP leave it to your resolver. A push batch is not atomic: a non-409 error after valid rows were written throws (index.ts:307-324)." + }, + "causality": { + "mark": "", + "text": "none across replicas: a compare-and-set against the master's last state", + "source": "rxdb@17.6.0 src/replication-protocol/upstream.ts:325-381; https://rxdb.info/transactions-conflicts-revisions.html", + "detail": "`_rev` (a height plus the instance token) is local and stripped before a push; the docs liken revisions to Lamport clocks." + }, + "clocks": { + "mark": "", + "text": "unused by the protocol; backend checkpoints and LWW handlers bring their own", + "source": "https://rxdb.info/replication.html (Security); rxdb-quickstart src/database.ts", + "detail": "The docs say client clocks can never be trusted and ask the backend to overwrite `updatedAt`; the quickstart's LWW handler compares `Date.now()` stamps from clients." + }, + "live": { + "mark": "✅", + "text": "`pull.stream$`, yours or a plugin's; `RESYNC` when events may be missed", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:452-469; https://rxdb.info/replication.html (Event observation)", + "detail": "Without `stream$` a live replication pulls only at start and on `reSync()`. Firestore, MongoDB and the Drive plugins emit only `RESYNC`, never documents." + }, + "resume": { + "mark": "✅", + "text": "a checkpoint pull (`RESYNC`) after each reconnect; the stream is not resumed", + "source": "https://rxdb.info/replication.html (Checkpoint iteration); rxdb@17.6.0 src/replication-protocol/downstream.ts:104-121" + }, + "restart": { + "mark": "✅", + "text": "checkpoints stored per `replicationIdentifier`; a new state with the same id continues", + "source": "rxdb@17.6.0 src/replication-protocol/checkpoint.ts:18-143; https://rxdb.info/replication.html (cancel)" + }, + "reconnect": { + "mark": "⚠️", + "text": "each plugin's own; a custom `stream$` must reconnect and emit `RESYNC`", + "source": "https://rxdb.info/replication.html (pull stream example); rxdb@17.6.0 src/plugins/replication-websocket/reconnecting-websocket.ts:53-60" + }, + "retry": { + "mark": "✅", + "text": "each pull or push error goes to `error$`, retried after `retryTime` (5 s), forever", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:300-316, 384-412; replication-helper.ts:70-98", + "detail": "A fixed delay, cut short by the browser `online` event; no exponential backoff in the core." + }, + "dup": { + "mark": "✅", + "text": "states, not ops: pages ordered by checkpoint, repeats harmless", + "source": "rxdb@17.6.0 src/replication-protocol/downstream.ts:254-268; https://rxdb.info/replication.html (Error handling)", + "detail": "A push re-sent after it already landed must be tolerated by the backend; with RxDB's own handlers it returns as a conflict that resolves to the same state." + }, + "offline": { + "mark": "✅", + "text": "local writes; on reconnect the latest state of each changed document is pushed", + "source": "rxdb@17.6.0 src/replication-protocol/upstream.ts:141-208" + }, + "bootstrap": { + "mark": "✅", + "text": "a checkpoint iteration from scratch over the backend's current documents", + "source": "https://rxdb.info/replication.html (Checkpoint iteration); rxdb@17.6.0 src/replication-protocol/downstream.ts:174-216" + }, + "longOffline": { + "mark": "✅", + "text": "the same checkpoint pull; the backend must keep soft-deleted documents", + "source": "https://rxdb.info/replication.html (Data layout on the server)" + }, + "batch": { + "mark": "", + "text": "`pull.batchSize`, `push.batchSize`, 100 each; plugin defaults differ", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:255-256; src/plugins/replication-webrtc/index.ts:62-71", + "detail": "WebRTC defaults to 20; GraphQL asks for 20 while the core expects 100 (see GraphQL)." + }, + "flow": { + "mark": "⚠️", + "text": "no explicit caps; four push batches in flight; queued changes coalesce per document", + "source": "rxdb@17.6.0 src/replication-protocol/upstream.ts:156-173, 288-292; downstream.ts:82-133" + }, + "partial": { + "mark": "⚠️", + "text": "one replication per scope, filtered by your backend; RxServer `queryModifier`", + "source": "https://rxdb.info/partial-sync.html; rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:66-71, 98-106", + "detail": "Firestore takes `pull.filter`; Supabase `pull.queryBuilder` (its live stream is not filtered); the other plugins replicate whole collections." + }, + "partialLive": { + "mark": "⚠️", + "text": "one stream per replication, scoped by your backend", + "source": "https://rxdb.info/partial-sync.html (Real Time Updates with a Pull Stream)" + }, + "revoke": { + "mark": "❌", + "text": "no signal: the client keeps its copy; RxServer then refuses its pushes", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:98-106, 166-170; https://rxdb.info/partial-sync.html (FAQ)", + "detail": "On that 403 the client emits `forbidden$` and cancels the whole replication (src/plugins/replication-server/helpers.ts:21-26)." + }, + "oneWay": { + "mark": "✅", + "text": "omit `push` or `pull`; pull-only stores no per-document meta", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:264, 287-292, 335-337; https://rxdb.info/replication.html (Pull-Only Replication)" + }, + "deletes": { + "mark": "✅", + "text": "`_deleted: true` with the whole document; the backend must keep it", + "source": "https://rxdb.info/replication.html (Data layout on the server); rxdb@17.6.0 src/plugins/replication/replication-helper.ts:33-63", + "detail": "`deletedField` renames the flag (Firestore and NATS hard-code `_deleted` despite their docs); MongoDB maps real deletes through pre-images; Supabase drops hard DELETEs. The `cleanup` plugin purges local tombstones after 31 days." + }, + "replAttachments": { + "mark": "⚠️", + "text": "in the protocol; only Google Drive and OneDrive carry them intact", + "source": "https://rxdb.info/replication.html (Attachment replication); rxdb@17.6.0 src/replication-protocol/index.ts:207-219; helper.ts:103-166", + "note": "Over the WebSocket and WebRTC plugins attachment data is silently corrupted, since Blobs pass through `JSON.stringify` (checked by running); RxServer, GraphQL, Appwrite and MongoDB drop it." + }, + "lifecycle": { + "mark": "✅", + "text": "per replication `start()`, `pause()`, `cancel()`, `remove()`; `live: false` runs once", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:173-192, 483-494, 693-781", + "detail": "In 17.6.0 `start()` after `cancel()` silently does nothing; the live docs describe master's `RC_START_CANCELED` (orga/changelog/replication-start-after-cancel-throws.json)." + }, + "awaitSync": { + "mark": "✅", + "text": "`awaitInitialReplication()`, `awaitInSync()`, `awaitDocumentPushed(doc)`", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:508-683", + "detail": "In a tab that is not the leader `awaitInSync()` does not resolve while another tab replicates (https://rxdb.info/replication.html)." + }, + "resync": { + "mark": "✅", + "text": "`reSync()` runs a checkpoint pull; `emitEvent()` feeds the stream by hand", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:685-690" + }, + "status": { + "mark": "✅", + "text": "`received$`, `sent$`, `error$`, `active$`, `canceled$`, `conflict$` per replication", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:83-97" + }, + "replMultiTab": { + "mark": "✅", + "text": "only the elected leader tab replicates (`waitForLeadership: true`); others see its writes", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:794, 865-884; https://rxdb.info/leader-election.html", + "note": "A visible tab that is not the leader also starts replicating (`toggleOnDocumentVisible`, on by default), so two tabs can replicate at once (code reading).", + "detail": "src/plugins/replication/index.ts:828-857. broadcast-channel prefers Web Locks; on Node 24 these did not exclude a second process in a local test, so separate Node processes may each lead." + }, + "serverProduct": { + "mark": "⚠️", + "text": "RxServer: the separate `rxdb-server` package (SSPL), over an RxDatabase", + "source": "rxdb-server@17.6.0 package.json:5; LICENSE.txt; https://rxdb.info/rx-server.html", + "detail": "Free of charge, but SSPL section 13 obliges anyone offering it as a service to publish their service's source." + }, + "serverRuntime": { + "mark": "⚠️", + "text": "Node, through the Express adapter; Deno, Bun and Electron claimed, untested", + "source": "rxdb-server@17.6.0 src/plugins/adapter-express/index.ts:1-115; .github/workflows/main.yml; https://rxdb.info/rx-server.html" + }, + "adapters": { + "mark": "⚠️", + "text": "Express free (SSPL); Fastify and Koa are Premium (Pro Plus)", + "source": "rxdb-server@17.6.0 src/plugins/adapter-express; https://rxdb.info/premium.html; https://rxdb.info/rx-server.html" + }, + "endpoints": { + "mark": "", + "text": "per collection `pull`, `push`, `pullStream` (SSE); REST `query`, `get`, `set`, `delete`", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:85-274; endpoint-rest.ts:90-369" + }, + "remoteQuery": { + "mark": "✅", + "text": "RxServer REST endpoint and `client-rest`; live results over SSE", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-rest.ts; src/plugins/client-rest/index.ts:7-104" + }, + "serverStorage": { + "mark": "", + "text": "any RxStorage of the server's RxDatabase: memory, MongoDB, FoundationDB…", + "source": "rxdb-server@17.6.0 test/unit/config.ts:6-22; https://rxdb.info/replication-mongodb.html" + }, + "atRest": { + "mark": "⚠️", + "text": "per field (schema `encrypted: [...]`): free `encryption-crypto-js`; WebCrypto is Premium (Pro)", + "source": "rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts:62-206; https://rxdb.info/encryption.html; https://rxdb.info/premium/", + "detail": "Both are wrappers around an RxStorage (`wrappedKeyEncryptionCryptoJsStorage`, `wrappedKeyEncryptionWebCryptoStorage`); a schema with `encrypted` fields and no password throws `EN3`." + }, + "encScope": { + "mark": "", + "text": "the listed fields only; primary key, other fields, `_meta`, `_rev` stay plain", + "source": "rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts:106-143; src/plugins/dev-mode/error-messages.ts:972-977 (SC15: primary cannot be encrypted)", + "detail": "Checked by running: `{\"id\":\"u1\",\"name\":\"alice\",\"ssn\":\"U2FsdGVkX1…\"}` on disk. A nested path encrypts the whole object at it as one string. The replication meta copy (`docData`) is encrypted whole when the collection encrypts (src/replication-protocol/meta-instance.ts:94-95)." + }, + "cipher": { + "mark": "⚠️", + "text": "free: AES-256-CBC, MD5 key derivation, no MAC; AES-GCM selectable in Premium", + "note": "crypto-js's passphrase mode derives key and IV with EVP_BytesToKey (MD5, one iteration) and authenticates nothing, and crypto-js is discontinued; the page calls the plugin 'tamper proof'.", + "source": "rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts:34-37; crypto-js@4.2.0 cipher-core.js:453,791, evpkdf.js:39-40, README.md; https://rxdb.info/encryption.html#using-the-webcrypto-api", + "detail": "Checked by running: every encrypted value starts with OpenSSL's `Salted__` header. The Premium WebCrypto plugin takes `{ algorithm: 'AES-CTR' | 'AES-CBC' | 'AES-GCM', password }`; its key derivation is closed source." + }, + "keyOffDisk": { + "mark": "⚠️", + "text": "`password` given at `createRxDatabase`, kept in memory; an unsalted SHA-256 of it is stored", + "note": "The stored hash lets anyone with the files test password guesses offline at SHA-256 speed (checked: it equals `sha256(JSON.stringify(password))`).", + "source": "rxdb@17.6.0 src/rx-database-internal-store.ts:162-164,230-238; src/rx-database.ts:147-158; https://rxdb.info/encryption.html#password-handling", + "detail": "Minimum length 8 (`EN2`). A wrong password fails with `DB1` at `addCollections()`, not at `createRxDatabase()` (checked). The password property is non-enumerable." + }, + "keyRotation": { + "mark": "❌", + "text": "not possible; migrate into a new database, or wrap a meta-password", + "source": "https://rxdb.info/encryption.html#changing-the-password" + }, + "encAttachments": { + "mark": "✅", + "text": "schema `attachments: { encrypted: true }`", + "source": "rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts:145-164,182-194; https://rxdb.info/encryption.html#encrypted-attachments" + }, + "encExport": { + "mark": "⚠️", + "text": "the `backup` plugin and `exportJSON()` write encrypted fields decrypted", + "source": "rxdb@17.6.0 src/plugins/backup/index.ts:146-174; https://rxdb.info/encryption.html#querying-encrypted-data; https://rxdb.info/backup.html", + "detail": "Both read through the collection, above the encryption wrapper; the docs say a dump holds encrypted fields in plain text." + }, + "encSync": { + "mark": "", + "text": "encrypted fields replicate decrypted; the backend receives plaintext", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:261", + "detail": "Checked by running: a push handler received `{\"ssn\":\"123-45-6789\",\"card\":{\"num\":\"4111\"}}` from a collection that stores both encrypted." + }, + "e2ee": { + "mark": "❌", + "text": "none; replication reads the decrypted documents", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:261; src/plugins/encryption-crypto-js/index.ts:196-202", + "detail": "`push.modifier` and `pull.modifier` (src/plugins/replication/index.ts:219-220,324-352) could encrypt on the wire, and https://rxdb.info/articles/case-studies/venice-ai.html sketches such a design with a backend that compares a plaintext hash; nothing ships, and push conflicts do not pass through `pull.modifier` (index.ts:423)." + }, + "signing": { + "mark": "❌", + "text": "none; Nostr keys sign WebRTC signaling, not documents", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc/connection-handler-nostr.ts:120-127,492-498; https://rxdb.info/replication-webrtc.html#signaling-over-nostr-relays" + }, + "nodeIdentity": { + "mark": "⚠️", + "text": "random tokens (`storageToken`, instance token); a key only through the Nostr handler", + "source": "rxdb@17.6.0 src/rx-database-internal-store.ts:160-182; src/plugins/replication-webrtc/connection-handler-nostr.ts:157", + "detail": "The instance token is the suffix of every `_rev` and the `creator` of `crdt` ops." + }, + "tamper": { + "mark": "❌", + "text": "none; `_rev` is a height plus the writer's instance token", + "source": "rxdb@17.6.0 src/plugins/utils/utils-revision.ts:59-65; https://rxdb.info/transactions-conflicts-revisions.html" + }, + "authn": { + "mark": "✅", + "text": "RxServer `authHandler(headers)`, re-run before each streamed event", + "note": "Defaults are open: with no `authHandler` every request is admitted, and the Express adapter's default CORS reflects any request origin with credentials. The documented `validUntil` is never read. The server listens on localhost unless told otherwise. The `replication-websocket` server has no auth at all.", + "source": "rxdb-server@17.6.0 src/plugins/server/index.ts:17-22, helper.ts:36-55, endpoint-replication.ts:218-238, rx-server.ts:29, adapter-express/index.ts:18-33; rxdb@17.6.0 src/plugins/replication-websocket/websocket-server.ts:102-104; https://rxdb.info/rx-server.html#auth-handler; https://rxdb.info/replication-websocket.html", + "detail": "`validUntil` appears only in types.ts:61 and the default handler. RxServer is a separate package, `rxdb-server`, under SSPL." + }, + "peerValidation": { + "mark": "✅", + "text": "`isPeerValid(peer)`; the Nostr handler verifies each peer's public key", + "note": "Once valid, a peer reads and writes every document of the collection.", + "source": "rxdb@17.6.0 src/plugins/replication-webrtc/index.ts:131-137,160-165,305-317; https://rxdb.info/replication-webrtc.html#peer-validation", + "detail": "The result is cached per peer; a throwing `isPeerValid` counts as false. With the simple-peer signaling server peers are not identified." + }, + "access": { + "mark": "✅", + "text": "RxServer `queryModifier(authData, query)`; `serverOnlyFields` hidden from clients", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:66-118,218-265; https://rxdb.info/rx-server.html#query-modifier", + "detail": "Only RxServer; with the other replication plugins your backend filters. The matcher also guards the stored state a push targets, so a conflict answer cannot leak a foreign document (endpoint-replication.ts:155-170)." + }, + "writeRules": { + "mark": "✅", + "text": "RxServer `changeValidator(authData, change)`: one refused row is 403 for the push", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:120-199; https://rxdb.info/rx-server.html#change-validator", + "detail": "Writes must also match `queryModifier`, before and after. The validator must be synchronous; the docs' readonly-field example throws instead of returning false." + }, + "serverValidation": { + "mark": "⚠️", + "text": "only if the server's storage is wrapped in a `validate-*` plugin", + "note": "Collection hooks do not run on replicated writes, and a 422 throws after the valid rows of the batch were written.", + "source": "rxdb@17.6.0 src/plugin-helpers.ts:79-136; src/replication-protocol/index.ts:307-324; rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:201" + }, + "serverAuth": { + "mark": "✅", + "text": "RxServer is the master: a refused push reaches no client", + "note": "Over WebRTC there is no master: each peer's storage decides alone.", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:136-199; https://rxdb.info/replication.html" + }, + "limits": { + "mark": "❌", + "text": "none in RxServer; `$regex` queries refused against ReDoS", + "source": "rxdb-server@17.6.0 src/plugins/server/endpoint-replication.ts:66-71,89-92, adapter-express/index.ts:15; https://rxdb.info/rx-server.html#regex-queries-not-allowed", + "detail": "A pull's `limit` comes from the query string uncapped; express.json()'s default 100 kB body is the only bound on a push." + }, + "purge": { + "mark": "⚠️", + "text": "`cleanup` plugin drops tombstones after `minimumDeletedTime` (31 days)", + "note": "A removed document keeps all its data in the tombstone until then, and forever without the plugin; `cleanup(0)` forces it once replications are in sync.", + "source": "rxdb@17.6.0 src/rx-collection.ts:369-372,614-618; src/plugins/cleanup/cleanup-helper.ts:5-11; https://rxdb.info/cleanup.html", + "detail": "With the `crdt` plugin the tombstone also carries every past value in its op list (checked by running)." + }, + "purgeReplicas": { + "mark": "❌", + "text": "cleanup is local; a delete replicates as a tombstone with the full data", + "source": "rxdb@17.6.0 src/plugins/cleanup/cleanup.ts:60-100; https://rxdb.info/cleanup.html", + "detail": "Checked by running: the push handler received `{\"id\":\"a\",\"email\":\"bob@example.com\",\"_deleted\":true}` for a removed document." + }, + "history": { + "mark": "⚠️", + "text": "latest revision only; the `crdt` plugin keeps every op in the document", + "note": "Op compaction is not implemented, so a `crdt` document carries every past value, in its tombstone too.", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:56-79; src/types/plugins/crdt.d.ts:26-37; https://rxdb.info/transactions-conflicts-revisions.html" + }, + "audit": { + "mark": "❌", + "text": "none; `crdt` ops carry the writer's storage token and a time", + "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:56-62" + }, + "proto": { + "mark": "✅", + "text": "mingo and dot-prop refuse `__proto__` paths", + "source": "rxdb@17.6.0 src/plugins/utils/utils-object-dot-prop.ts:13-17; src/plugins/query-builder/mquery/mquery-utils.ts:10", + "detail": "Checked by running: `insert`, `update({ $set: { '__proto__.x': 1 } })` (refused), `incrementalPatch` and a selector left `Object.prototype` clean." + }, + "malformed": { + "mark": "⚠️", + "text": "only with a `validate-*` wrapper: the invalid document is dropped (`RC_PULL`)", + "note": "Without a wrapper a pulled document with wrong types and extra fields is stored as sent (checked by running).", + "source": "rxdb@17.6.0 src/plugin-helpers.ts:96-136; src/replication-protocol/downstream.ts:483-517; https://rxdb.info/schema-validation.html", + "detail": "With ajv the valid documents of the batch landed, the invalid one raised `RC_PULL` (422) on `error$` and was not pulled again." + }, + "skew": { + "mark": "—", + "text": "no peer clocks: conflicts compare document states", + "source": "rxdb@17.6.0 src/replication-protocol/default-conflict-handler.ts:1-30" + }, + "hooks": { + "mark": "✅", + "text": "pre/post `insert`/`save`/`remove`, plus `postCreate`; many, series or parallel", + "source": "rxdb@17.6.0 src/rx-collection.ts:118-121,971-1076; src/rx-document.ts:490-511; https://rxdb.info/middleware.html", + "detail": "Checked by running: a hook mutates the plain data and throws to abort; `incrementalPatch`, `update` and query `update` run `preSave`; `upsert` of an existing document runs `preInsert` then `preSave`. The page's claim that validation runs per changed field is outdated: the `validate-*` wrappers check whole documents." + }, + "replHooks": { + "mark": "⚠️", + "text": "`pull.modifier` per replication; collection hooks skip pulled documents", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:219-220,324-328; src/rx-storage-helper.ts:710-714", + "detail": "Checked by running: pulled documents ran no `preInsert`/`preSave`. The modifier transforms but cannot drop a document; the global plugin hooks `preStorageWrite` (synchronous) and `preReplicationMasterWrite` see replicated rows too." + }, + "outHook": { + "mark": "✅", + "text": "`push.modifier` transforms a document or drops it (`null`)", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:345-352" + }, + "readHooks": { + "mark": "❌", + "text": "none; `postCreate` runs when a document object is built", + "source": "https://rxdb.info/middleware.html#postcreate; rxdb@17.6.0 src/rx-collection.ts:118-121" + }, + "errorEvents": { + "mark": "✅", + "text": "replication `error$` (`RC_PULL`, `RC_PUSH`), `conflict$`; rejected local writes throw", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:92-97,307-314,398-410; src/replication-protocol/downstream.ts:498-516" + }, + "plugins": { + "mark": "✅", + "text": "`addRxPlugin()`: prototypes, overwritables and 24 plugin hooks", + "note": "Plugins are process-wide and cannot be removed.", + "source": "rxdb@17.6.0 src/plugin.ts:35-120; src/hooks.ts:5-103; https://rxdb.info/plugins.html" + }, + "errors": { + "mark": "✅", + "text": "`RxError.code`: 215 codes; the message text only with `dev-mode`", + "source": "rxdb@17.6.0 src/rx-error.ts:51-66; src/overwritable.ts:33-40; src/plugins/dev-mode/error-messages.ts", + "detail": "Counted from `ERROR_MESSAGES` in the installed 17.6.0; https://rxdb.info/errors.html (master) lists 218." + }, + "logging": { + "mark": "⚠️", + "text": "Premium `logger` storage wrapper (Pro Plus); the core logs nothing", + "source": "https://rxdb.info/logger.html; https://rxdb.info/premium/" + }, + "otel": { + "mark": "❌", + "text": "none", + "source": "rxdb@17.6.0 src; rxdb-server@17.6.0 src (no tracer or meter)" + }, + "stats": { + "mark": "❌", + "text": "no stats API; replication states have `active$` and `awaitInSync()`", + "source": "rxdb@17.6.0 src/plugins/replication/index.ts:92-97" + }, + "diagPII": { + "mark": "⚠️", + "text": "error messages embed their parameters: documents, pushed rows, password hashes", + "note": "Checked by running: a wrong password's `DB1` carries both the given and the stored password hash.", + "source": "rxdb@17.6.0 src/rx-error.ts:15-37; src/rx-database-internal-store.ts:233-237; src/plugins/replication/index.ts:397,406" + }, + "react": { + "mark": "✅", + "text": "`rxdb/plugins/react`: `RxDatabaseProvider`, `useRxQuery`, `useLiveRxQuery`, `useRxDocument`…", + "source": "rxdb@17.6.0 package.json:381; src/plugins/react/hooks/index.ts; https://rxdb.info/react.html", + "detail": "Also `useRxCollection`, `useRxDatabase` and `useReplicationStatus`; added in 17.0.0." + }, + "vue": { + "mark": "✅", + "text": "`rxdb/plugins/reactivity-vue`: shallow refs through the `$$` accessors", + "source": "rxdb@17.6.0 package.json:387; src/plugins/reactivity-vue/index.ts; https://rxdb.info/reactivity.html", + "detail": "Moved from Premium into the core in 17.0.0." + }, + "angular": { + "mark": "✅", + "text": "`rxdb/plugins/reactivity-angular` (signals); observables fit the `async` pipe", + "source": "rxdb@17.6.0 package.json:393; src/plugins/reactivity-angular/index.ts; https://rxdb.info/articles/angular-database.html; examples/angular (CI, with SSR)" + }, + "svelte": { + "mark": "⚠️", + "text": "no plugin; a Svelte 5 example app runs in CI", + "source": "rxdb@17.6.0 src/plugins (no Svelte plugin); examples/svelte/src/store.svelte.js; .github/workflows/main.yml:764-796" + }, + "signals": { + "mark": "✅", + "text": "`reactivity` option: Preact signals plugin, or your own factory; no Solid adapter", + "source": "rxdb@17.6.0 package.json:399; src/plugins/reactivity-preact-signals/index.ts; src/rx-database.ts:825; https://rxdb.info/reactivity.html", + "detail": "Moved from Premium into the core in 17.0.0. `rxdb-solid` on the third-party page is about Solid pods, not SolidJS (https://rxdb.info/third-party-plugins.html)." + }, + "tanstack": { + "mark": "⚠️", + "text": "`@tanstack/rxdb-db-collection`, published by TanStack (0.1.111)", + "source": "https://rxdb.info/articles/tanstack-db/rxdb-collection-for-tanstack-db.html; npm view @tanstack/rxdb-db-collection (2026-10-23)", + "detail": "Third-party to RxDB: MIT, in the TanStack/db repository. The page says it needs rxdb ≥16.17.2; npm's 0.1.111 declares the peer `rxdb >=17.0.0`. rxdb.info has 19 TanStack DB articles (articles/tanstack-db/*)." + }, + "devMode": { + "mark": "✅", + "text": "`rxdb/plugins/dev-mode`: readable errors, deep-freeze, schema and query checks", + "source": "rxdb@17.6.0 package.json:129; src/plugins/dev-mode/index.ts:68-140; https://rxdb.info/dev-mode.html", + "note": "Without it an error carries only its code. With it, the storage must be wrapped in a schema validator (`DVM1`), and in browsers it adds a tracking iframe (see Calls to the vendor).", + "detail": "DVM1: src/plugins/dev-mode/error-messages.ts:1140-1145, thrown at src/plugins/dev-mode/index.ts:133. ERROR-MESSAGES.md in the npm package lists every code." + }, + "testing": { + "mark": "✅", + "text": "memory storage; `rxdb/plugins/test-utils` (fixtures, replication test suite); `using` closes databases", + "source": "rxdb@17.6.0 package.json:405; src/plugins/test-utils/index.ts; src/plugins/test-utils/replication-base-test-suite.ts:107; https://rxdb.info/testing.html", + "note": "The testing page's multi-tab simulation needs `ignoreDuplicate: true`, which throws `DB9` unless dev-mode is on, and the 13-collection limit counts every database a test run leaves open." + }, + "ci": { + "mark": "", + "text": "74 test files per storage; Linux, Windows, browsers, Bun, Deno, 11 example apps; no coverage gate", + "source": "rxdb@17.6.0 test/; package.json scripts (no c8/nyc); .github/workflows/main.yml" + }, + "docs": { + "mark": "", + "text": "271 pages, about 463,000 words: 98 articles, 46 landing pages", + "source": "https://rxdb.info sitemap (mirrored 2026-10); rxdb@17.6.0 docs-src/docs (200 markdown files)" + }, + "examples": { + "mark": "✅", + "text": "13 example apps; 11 run in CI: Angular, React, React Native, Electron, Tauri, Vue, Svelte…", + "source": "rxdb@17.6.0 examples/; .github/workflows/main.yml:364-897; rxdb-quickstart repository", + "detail": "CI-run: angular, electron, graphql, ionic, node, react, react-native (Expo), svelte, tauri, vite-vanilla-ts, vue. Not run: flutter (job commented out), supabase." + }, + "breakingMajors": { + "mark": "", + "text": "10 majors from 8.0 (2018) to 17.0 (2026); three in 2022", + "source": "rxdb@17.6.0 CHANGELOG.md:297,445,777,1005,1261,1488,1569,1733,2000,2224; https://rxdb.info/releases/13.0.0.html; https://rxdb.info/releases/14.0.0.html; https://rxdb.info/releases/16.0.0.html; https://rxdb.info/releases/17.0.0.html", + "note": "Majors 13, 14, 16 and 17 each told users of some or all storages to move their stored data with the `migration-storage` plugin or to re-sync it from the backend.", + "detail": "8.0 2018-09-18, 9.0 2020-05-16, 10.0 2021-07-20, 11.0 2022-01-03, 12.0 2022-04-26, 13.0 2022-09-16, 14.0 2023-02-08, 15.0 2023-12-20, 16.0 2025-01-02, 17.0 2026-03-30; before that six majors (2.0 to 7.0) in 2017. 13.0: stored data of earlier versions is not compatible. 14.0: schema hashing changed, every schema needs a version bump and a migration strategy. 16.0: SQLite, Filesystem-Node and OPFS data must be migrated. 17.0: OPFS, Filesystem-Node and IndexedDB-with-attachments data must be migrated. 9.0.0 says majors bundle the breaking changes so users upgrade in one block." + }, + "releaseCadence": { + "mark": "", + "text": "about monthly: 17.0 to 17.6 between March and October 2026", + "source": "rxdb@17.6.0 CHANGELOG.md:12-297", + "detail": "Release headings in CHANGELOG.md per year: 107 in 2022, 61 in 2023, 71 in 2024, 26 in 2025, 7 in 2026 up to 17.6.0. Plugins ship as beta and leave it at a major: 17.0 took the CRDT plugin, RxPipeline, attachment replication, the Supabase, Appwrite and MongoDB replications and the MongoDB, DenoKV and Filesystem-Node storages out of beta (https://rxdb.info/releases/17.0.0.html)." + }, + "freeTierHistory": { + "mark": "⚠️", + "text": "Premium since 12.0 (2022); Worker storage moved in at 14.0; collection cap since 16.0", + "source": "https://rxdb.info/releases/12.0.0.html; https://rxdb.info/releases/14.0.0.html; https://rxdb.info/releases/15.0.0.html; https://rxdb.info/releases/16.0.0.html; https://rxdb.info/releases/17.0.0.html; rxdb@17.6.0 src/plugins/utils/utils-premium.ts:6", + "note": "The open-source cap on open collections was 16 in 16.0 and is 13 since 17.0. Two features went the other way: `migration-storage` (15.0) and the Vue and Preact signals plugins (17.0) became free.", + "detail": "Worker storage shipped free as `rxdb/plugins/worker` in 11.0 (https://rxdb.info/releases/11.0.0.html). Later Premium additions: query optimizer (12.4.1), memory-synced storage (12.6.11, removed in 16.0), localstorage meta optimizer (13.4.4), OPFS storage (14.7.0), logger (15.0), fulltext search (15.32.0) (CHANGELOG.md:1437,1373,1219,883,515). Since 15.21.0 the free Dexie storage logs a console warning that points to Premium (CHANGELOG.md:608; src/plugins/storage-dexie/rx-storage-instance-dexie.ts:84-93). The free SQLite trial went from 300 to 500 documents in 17.4.0 (CHANGELOG.md:136) and also stops after 500 operations per instance (src/plugins/storage-sqlite/sqlite-storage-instance.ts:57-58,113-117)." + }, + "productionUsers": { + "mark": "", + "text": "10 testimonials (Readwise, Nutrien, Venice.ai, …); one case study", + "source": "https://rxdb.info/; https://rxdb.info/articles/case-studies/venice-ai.html", + "detail": "The Venice.ai case study names no license, data sizes or write rates; its \"IndexedDB\" and \"SQLite\" links go to the Premium storage pages, and its end-to-end encrypted sync is planned, not built." + }, + "commercialSupport": { + "mark": "⚠️", + "text": "paid: €180 one-hour consulting; Enterprise SLA, 24-hour first response", + "source": "https://rxdb.info/consulting; https://rxdb.info/premium/", + "detail": "Enterprise also lists a named engineer, architecture review, priority fixes and source-code access; the consulting page matches clients with partner agencies for contract work. No free trial; licenses are annual (premium.html). The couchbase-alternative page calls Premium a one-time purchase, which premium.html contradicts.", + "note": "The Pro and Pro Plus agreement says 'No Technical Support'; free users get GitHub and Discord, and a bug report is expected as a pull request with a failing test." + }, + "communityHelp": { + "mark": "", + "text": "Discord, GitHub; a bug report needs a PR with a failing test", + "source": "https://rxdb.info/contribution.html; https://rxdb.info/chat-rules; https://rxdb.info/", + "detail": "The chat rules forbid direct messages to the maintainer for support; the site shows about 1,300 Discord members." + }, + "thirdPartyPlugins": { + "mark": "", + "text": "8 listed, e.g. `rxdb-hooks`, `rxdb-flexsearch`, `rxdb-supabase`", + "source": "https://rxdb.info/third-party-plugins.html", + "detail": "The list still names `loki-async-reference-adapter`, for the LokiJS storage that 16.0 removed." + }, + "community": { + "mark": "", + "text": "23,412 GitHub stars; 63,815 npm downloads in the week to 2026-10-18", + "source": "api.github.com/repos/pubkey/rxdb (2026-10-23); api.npmjs.org/downloads/point/last-week/rxdb" + }, + "importFromOtherDbs": { + "mark": "⚠️", + "text": "migration guides only; `importJSON()` takes an RxDB dump of the same schema", + "source": "https://rxdb.info/articles/realm-to-rxdb-migration.html; https://rxdb.info/articles/alternatives/pouchdb-alternative.html; rxdb@17.6.0 src/plugins/json-dump/index.ts:177-185", + "note": "No converter ships: the Realm, PouchDB, Dexie, LokiJS, NeDB, GUN and other guides read the old store and call `bulkInsert`/`bulkUpsert` by hand.", + "detail": "`importJSON()` throws JD2 when the dump's schema hash differs. `migration-storage` moves data between RxDB storages and RxDB majors, not from other databases. The SEM pages garealm1-3 target teams leaving Realm (CHANGELOG.md:150-152)." + }, + "benchmarks": { + "mark": "⚠️", + "text": "its own storages only; no machine, browser or date given", + "source": "https://rxdb.info/rx-storage-performance.html; rxdb@17.6.0 src/plugins/test-utils/performance.ts:110", + "note": "Most measured storages are Premium (OPFS, IndexedDB, SQLite, Filesystem); the free localStorage storage that most guides use is not measured.", + "detail": "The harness is open source (3000 docs, 40 runs; test/performance.test.ts). Articles also cite 2024 raw-API numbers from Chrome 128 as RxDB-vs-SQLite-WASM evidence (https://rxdb.info/articles/rxdb-vs-sqlite-wasm-vs-pglite.html), and the vector-search numbers come from a demo repository pinned to rxdb 15.32.0 (https://rxdb.info/articles/javascript-vector-database.html)." + }, + "webmcp": { + "mark": "✅", + "text": "`rxdb/plugins/webmcp`: query, count, changes, insert, upsert, delete tools per collection; `readOnly`", + "source": "rxdb@17.6.0 package.json:291; src/plugins/webmcp/webmcp-tools.ts:16-28, 112-223; https://rxdb.info/webmcp.html", + "note": "WebMCP is an experimental browser API; where `navigator.modelContext` is missing the docs point to the `@mcp-b/global` polyfill." + }, + "agentDocs": { + "mark": "✅", + "text": "`llms.txt` plus three topic files; `ERROR-MESSAGES.md`, `CLAUDE.md`, `AGENTS.md` in the package", + "source": "rxdb@17.6.0 docs-src/docusaurus.config.ts:152-178; scripts/generate-error-messages.mjs:15,32; npm rxdb@17.6.0 ERROR-MESSAGES.md; https://rxdb.info/releases/17.0.0.html", + "note": "In the 17.6.0 tarball all 215 entries of ERROR-MESSAGES.md are headed `## undefined`, and COL23 states a limit of 16 collections where the code enforces 13.", + "detail": "The 17.0.0 notes say errors carry `cause`, `fix` and `docs` properties. In the code `RxError` has `code`, `message`, `url` and `parameters`; dev-mode appends cause, fix and docs to the message text (src/rx-error.ts:51-69; src/plugins/dev-mode/index.ts:100-122)." + } + } + } + ] +} diff --git a/docs/comparison.json b/docs/comparison.json index 4fddb7b..e5d8920 100644 --- a/docs/comparison.json +++ b/docs/comparison.json @@ -253,8 +253,9 @@ }, "schema": { "mark": "✅", - "text": "metaschema, Standard Schema, ajv; on every hop", - "source": "src/schema.js; docs/guide/schema.md" + "text": "metaschema, Standard Schema, ajv; local writes and peers' ops", + "source": "src/schema.js; src/apply.js; docs/guide/schema.md", + "note": "A snapshot merge (bootstrap) and an op from a newer schema version (`ahead: 'apply'`, the default) are applied unvalidated, and a record outside any collection has no validator." }, "migrations": { "mark": "✅", @@ -268,8 +269,8 @@ }, "indexes": { "mark": "✅", - "text": "entity `index` fields, in memory", - "source": "src/catalog.js; docs/guide/query.md" + "text": "entity `index` fields, equality only, in memory", + "source": "src/catalog.js; docs/guide/query.md#the-plan" }, "unique": { "mark": "✅", @@ -314,8 +315,8 @@ }, "partial": { "mark": "✅", - "text": "scopes per peer, enforced by the host", - "source": "src/scope.js; src/hosting.js" + "text": "scopes per peer, enforced by the host on the record", + "source": "src/scope.js; src/hosting.js; src/apply.js" }, "resume": { "mark": "✅", @@ -334,18 +335,21 @@ }, "offline": { "mark": "✅", - "text": "local op log; pushed when a peer is back", - "source": "src/log.js; src/sync.js" + "text": "local op log, kept until the hosts have it", + "source": "src/log.js; src/sync.js", + "note": "Ops written before any node was registered, and compacted since, cannot reach a host (`UNDELIVERABLE`)." }, "serverAuth": { - "mark": "✅", - "text": "the host re-runs validators, hooks, scopes", - "source": "src/apply.js; src/hosting.js" + "mark": "⚠️", + "text": "out-of-scope and unsigned ops stop at the host; one its validator or `beforeApply` skips is relayed", + "source": "src/apply.js; src/hosting.js; docs/guide/hooks.md#skipping-a-peers-op", + "note": "A withheld op would leave a gap in its origin's stream, so a host that skips an op still relays it and every node re-checks it; a throwing `beforeApply` refuses the whole page instead." }, "tamper": { "mark": "✅", - "text": "hash-linked chain, re-checked on read (optional)", - "source": "src/chain.js; docs/guide/chain.md" + "text": "hash-linked chain, unsigned (optional)", + "source": "src/chain.js; docs/guide/chain.md", + "note": "Whoever can write the directory can rebuild the chain; a read checks a record against the block its file names, so a block planted beside the chain passes. The ops in the log are what is signed." }, "signing": { "mark": "✅", @@ -356,7 +360,8 @@ "atRest": { "mark": "⚠️", "text": "per record (`encrypted: true`), never synced", - "source": "docs/guide/storage.md#encryption-at-rest" + "source": "docs/guide/storage.md#encryption-at-rest", + "note": "The RSA key pair that unseals the records sits unprotected in `/keys/`, beside the data." }, "e2ee": { "mark": "⚠️", @@ -371,7 +376,8 @@ "purge": { "mark": "✅", "text": "`purge()` scrubs the op log", - "source": "src/log.js; docs/guide/storage.md#purge" + "source": "src/log.js; docs/guide/storage.md#purge", + "note": "The chain keeps an unsalted SHA-256 of every version of the record, which can confirm a guessed record." }, "history": { "mark": "⚠️", @@ -394,8 +400,8 @@ "source": "src/errors.js" }, "types": { - "mark": "✅", - "text": "hand-written `.d.ts`", + "mark": "⚠️", + "text": "hand-written `.d.ts`; record data `unknown`", "source": "index.d.ts" }, "ui": { @@ -1299,8 +1305,8 @@ }, "deps": { "mark": "", - "text": "24 direct", - "source": "rxdb@17.6.0 package.json dependencies (ajv, dexie, mingo, graphql, simple-peer, nostr-tools, ws, …)" + "text": "24 direct + required peer `rxjs`; 74 installed", + "source": "rxdb@17.6.0 package.json dependencies (ajv, dexie, mingo, graphql, simple-peer, nostr-tools, ws, …); bench/compare/pnpm-lock.yaml (74 packages with rxjs)" }, "impl": { "mark": "", @@ -1310,7 +1316,7 @@ "node": { "mark": "⚠️", "text": "≥20; free persistence via MongoDB/FoundationDB storages", - "note": "Filesystem and SQLite storages are Premium; the free SQLite trial throws past 500 documents or 500 operations.", + "note": "Filesystem and SQLite storages are Premium; the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance (the 251st serial insert fails).", "source": "rxdb@17.6.0 package.json:84-86 (engines); src/plugins/storage-mongodb, storage-foundationdb; src/plugins/storage-sqlite/sqlite-storage-instance.ts:48-58; https://rxdb.info/premium/" }, "browser": { @@ -1322,7 +1328,7 @@ "mark": "⚠️", "text": "React Native/Expo via Premium SQLite or filesystem", "source": "https://rxdb.info/react-native-database.html; rxdb@17.6.0 src/plugins/storage-sqlite/index.ts:43-60", - "detail": "The free SQLite storage is a trial capped at 500 documents." + "detail": "The free SQLite storage is a trial: 500 documents and 500 SQL calls per storage instance (one whole-table read per write plus one insert per new document, so the 251st serial insert fails, measured); no indexes, no attachments." }, "server": { "mark": "✅", @@ -1380,7 +1386,7 @@ "mark": "⚠️", "text": "`crdt` plugin `$inc`", "source": "rxdb@17.6.0 src/plugins/crdt/index.ts:243-330; https://rxdb.info/crdt.html", - "detail": "Opt-in per schema; once on, plain writes to the document are refused." + "detail": "Opt-in per schema. Once on, `patch()`, `update()` and `remove()` become CRDT ops and `modify()` is refused, but `upsert()` and `incrementalUpsert()` replace the op history, and every op stays inside the document (measured: 200 `$inc` left 201 op rows)." }, "topology": { "mark": "", @@ -1441,7 +1447,8 @@ "atRest": { "mark": "⚠️", "text": "`encryption-crypto-js` per field; WebCrypto Premium", - "source": "rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts; https://rxdb.info/encryption.html" + "source": "rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts; https://rxdb.info/encryption.html; rxdb@17.6.0 src/plugins/encryption-crypto-js/index.ts:34-37; src/rx-database-internal-store.ts:162-164", + "note": "The free plugin is AES-CBC in crypto-js's passphrase mode (MD5 key derivation, no MAC); an unsalted SHA-256 of the password is stored beside the data." }, "e2ee": { "mark": "❌", @@ -1450,9 +1457,10 @@ "source": "rxdb@17.6.0 src/plugins/replication/index.ts:261; src/plugins/encryption-crypto-js/index.ts:120-170" }, "access": { - "mark": "⚠️", - "text": "your backend; RxServer `queryModifier`", - "source": "https://rxdb.info/rx-server.html (authHandler, queryModifier, changeValidator)" + "mark": "✅", + "text": "RxServer `authHandler`, `queryModifier`, `changeValidator` (`rxdb-server`)", + "source": "rxdb-server@17.6.0 src/plugins/server/index.ts:17-22, endpoint-replication.ts:66-199, adapter-express/index.ts:18-33; https://rxdb.info/rx-server.html", + "note": "RxServer is SSPL and its defaults are open: with no `authHandler` every request is admitted, and the Express adapter's CORS reflects any origin with credentials." }, "purge": { "mark": "⚠️", @@ -1461,9 +1469,9 @@ "source": "rxdb@17.6.0 src/rx-document.ts:430-433; src/plugins/cleanup/cleanup-helper.ts:5-11" }, "history": { - "mark": "❌", - "text": "latest revision only", - "source": "https://rxdb.info/transactions-conflicts-revisions.html" + "mark": "⚠️", + "text": "latest revision; the `crdt` plugin keeps every op in the document", + "source": "https://rxdb.info/transactions-conflicts-revisions.html; rxdb@17.6.0 src/plugins/crdt/index.ts:56-79" }, "hooks": { "mark": "✅", diff --git a/docs/guide/backends.md b/docs/guide/backends.md index 6b37567..7def0ff 100644 --- a/docs/guide/backends.md +++ b/docs/guide/backends.md @@ -70,7 +70,15 @@ price is the write latency of the disk. A backend without `sync` (OPFS, IndexedDB, memory) is as durable as it is, and `durable` logs a debug line and does nothing. What a crash can tear is the newest, unannounced batch: the log is written before the record, a reopen replays its unsealed tail, -and a torn open file is dropped before anyone was told of it. +and a torn open file is dropped before anyone was told of it. A record file +torn the same way reads as `INTEGRITY` until `repair(id)` rebuilds it. + +The log append is the commit point. A backend that fails after it — a full +disk, a browser's `QuotaExceededError` on the record file or the chain block +— does not undo the write: it resolves, the op replicates, the record is +held in memory so reads see it, and the storage's `error` event carries +`code: 'UNPERSISTED'`. The batch stays unapplied in the log, and the next +open replays it onto the files. ## One instance per directory diff --git a/docs/guide/benchmarks.md b/docs/guide/benchmarks.md index 0b11288..b409d25 100644 --- a/docs/guide/benchmarks.md +++ b/docs/guide/benchmarks.md @@ -190,7 +190,7 @@ loads is counted with the JavaScript, gzipped too. | TinyBase
tinybase@10.0.1 | MergeableStore + IndexedDB persister + custom synchronizer | 56.4 KB | 23.8 KB | | | Yjs
yjs@13.6.33, y-indexeddb@9.0.12, y-protocols@1.0.7 | yjs + y-indexeddb + y-protocols/sync | 80.2 KB | 24.5 KB | | | PouchDB
pouchdb-browser@9.0.0 | pouchdb-browser | 117.8 KB | 40.4 KB | pouchdb-browser carries its HTTP adapter (the CouchDB client) with it | -| **syncom**
@alexify/syncom@0.0.1 | open() over OPFS + the wrpc link adapter | 119.9 KB | 41.3 KB | storage, schema, queries, the op log and the sync protocol are one entry | +| **syncom**
@alexify/syncom@0.0.1 | open() over OPFS + the wrpc link adapter | 124.1 KB | 42.6 KB | storage, schema, queries, the op log and the sync protocol are one entry | | RxDB
rxdb@17.6.0, dexie@4.4.6, rxjs@7.8.2 | rxdb + Dexie storage + replication | 282.3 KB | 90.1 KB | | | Fireproof
@fireproof/core@0.24.19 | @fireproof/core | 784.2 KB | 204.0 KB | | | OrbitDB
@orbitdb/core@4.0.0, helia@6.1.4, libp2p@3.5.1, @libp2p/gossipsub@15.0.23, blockstore-idb@4.0.1 | @orbitdb/core + helia + libp2p, IndexedDB blockstore | 1185.6 KB | 353.7 KB | createHelia brings Helia's default libp2p configuration with it | diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md index 069daf6..460ed60 100644 --- a/docs/guide/comparison.md +++ b/docs/guide/comparison.md @@ -11,7 +11,8 @@ than a few favourable rows. syncom is the first column. It is tinted, and on a w pinned next to the features while the table scrolls, so every row can be read against it. How fast each one is, measured on the same scenarios, is on the [Benchmarks](/guide/benchmarks) -page. +page. RxDB, the closest of them, is compared with syncom feature by feature, in 300 rows, on +[Compared with RxDB](/guide/vs-rxdb). ## What the table shows @@ -25,10 +26,11 @@ page. - **Few sync without a server of their own and still check what they receive.** Nine of the nineteen need no server from the project to sync: syncom, Yjs, Automerge, Loro, RxDB, PouchDB, TinyBase, GUN and OrbitDB. Of those nine, only syncom and RxDB validate writes - against a schema. Only syncom, RxDB and PouchDB let the node serving the data reject a write - before it spreads (RxDB through its server plugin, PouchDB through CouchDB's - `validate_doc_update`). In syncom the host runs the same validators, hooks and scopes on a - peer's op that it runs on its own writes. + against a schema. Only RxDB and PouchDB let the node serving the data reject a write before it + spreads (RxDB through its server plugin, PouchDB through CouchDB's `validate_doc_update`). A + syncom host runs the same validators, hooks and scopes on a peer's op that it runs on its own + writes, but it still relays an op its validator or hook skipped, so every node behind it judges + that op for itself; only out-of-scope and unsigned ops stop at the host. - **Counters that converge are rare.** syncom, Automerge, Loro and Dexie Cloud have them; Dexie Cloud replays the operation on its server. Everywhere else, two replicas that increment the same number at the same time lose one of the increments. @@ -105,7 +107,7 @@ page. Version compared0.0.1 (@alexify/syncom)13.6.33 (yjs)3.5.0 (@automerge/automerge); repo 2.6.0-alpha.311.16.4 (loro-crdt)0.20.19 (jazz-tools)217.6.0 (rxdb)9.0.0 (pouchdb)10.0.1 (tinybase)dexie 4.4.6 + dexie-cloud-addon 4.4.1630.24.19 (@fireproof/core, use-fireproof)0.2020.1241 (gun, SEA and RAD included)4.0.0 (@orbitdb/core)8.23.2 (@evolu/common)0.5.0 (@livestore/livestore, beta)@electric-sql/client 1.5.28; server 1.8.1@powersync/web 2.5.0 (node 1.2.0, react-native 2.3.3)1.11.0 (@rocicorp/zero)@triplit/client 1.0.50 (db 1.1.10, server 1.1.8)1.0.67 (@instantdb/core, /react, /admin) Latest releasenot yet on npm (pre-release)2026-092026-092026-092026-072026-102024-0642026-092026-10 (addon), 2026-09 (dexie)2026-042025-0752026-052026-102026-102026-092026-102026-102025-07, no release since62026-087 LicenseMITMIT (providers and server MIT too)MIT (sync server MIT too)MIT (protocol packages MIT too)MIT (jazz-run server MIT); Jazz Cloud managedApache-2.0; Premium plugins paid8Apache-2.0 (CouchDB too)MITApache-2.0 client; server commercial9AFL-2.0 per npm; Apache-2.0 file10Zlib OR MIT OR Apache-2.0MITMIT (client and relay)Apache-2.0Apache-2.0, client and serverApache-2.0 client; service FSL-1.1-ALv211Apache-2.0 (client and zero-cache)AGPL-3.0: client, db and server12Apache-2.0 (client and server) -Runtime dependencies✅ 01 — lib0 (same author)✅ 0 (automerge-repo: 8)✅ 018 — zod, cojson, ProseMirror…24 direct14 (pouchdb); 3 (pouchdb-browser)✅ 0 (integrations are optional peers)5 (dexie-cloud-addon); dexie 05 (@fireproof/core)1 (ws) + optional @peculiar/webcrypto10 (+ Helia/libp2p you supply)5 (kysely, @noble/*, @scure/bip39, random)3 (+ peer effect)1: @microsoft/fetch-event-source5 (wa-sqlite, comlink, commander, …)48 (client and zero-cache ship together)134: @triplit/db, logger, comlink, superjson3: uuid, mutative, @instantdb/version +Runtime dependencies✅ 01 — lib0 (same author)✅ 0 (automerge-repo: 8)✅ 018 — zod, cojson, ProseMirror…24 direct + required peer rxjs; 74 installed14 (pouchdb); 3 (pouchdb-browser)✅ 0 (integrations are optional peers)5 (dexie-cloud-addon); dexie 05 (@fireproof/core)1 (ws) + optional @peculiar/webcrypto10 (+ Helia/libp2p you supply)5 (kysely, @noble/*, @scure/bip39, random)3 (+ peer effect)1: @microsoft/fetch-event-source5 (wa-sqlite, comlink, commander, …)48 (client and zero-cache ship together)134: @triplit/db, logger, comlink, superjson3: uuid, mutative, @instantdb/version ImplementationJS (CommonJS), no build stepJS (ESM + CJS build), JSDoc typesRust → WASM, TS wrapperRust → WASMTS + Rust crypto core (WASM/N-API/RN)TypeScriptJavaScriptTypeScriptTS client; closed-source serverTypeScriptJS (hand-written UMD, no build)JS (ESM)TS; SQLite (WASM / native)TS on Effect; SQLite WASMElixir service + TS clientTS SDK + Rust SQLite extension; TS serviceTypeScript; zero-cache on Node + SQLiteTypeScript client + Node/Bun/Cloudflare serverTS/JS client + Clojure server on Postgres Runtimes Node.js, as a replica✅ ≥22, node:fs✅ y-leveldb + y-websocket client; Node ≥16✅ storage-nodefs + WebSocket adapter; Node ≥22.13 (repo)⚠️ runs (WASM); persistence is app code✅ workers, jazz-run sync (SQLite); Node ≥20⚠️ ≥20; free persistence via MongoDB/FoundationDB storages14✅ LevelDB via native leveldown✅ file, node:sqlite, Postgres persisters⚠️ IndexedDB shim only; not documented✅ files under ~/.fireproof✅ RAD files on disk; engines ≥0.8.4✅ ≥22; LevelDB + Helia blockstore❌ no Node client; relay only (≥24.20)✅ @livestore/adapter-node (Node, Bun, Deno)✅ client + PGlite on filesystem; no engines15✅ @powersync/node, better-sqlite3; no min declared⚠️ client in Node undocumented; memory fallback✅ client + sqlite/lmdb storage; engines ≥18❌ @instantdb/admin: online HTTP/SSE, no local store16 @@ -114,38 +116,38 @@ page. Server needed to sync✅ none — any node can host✅ none with y-webrtc; @y/websocket-server relay18✅ none required; automerge-repo-sync-server relay19✅ none required; loro-websocket SimpleServer⚠️ a sync server: Jazz Cloud or jazz-run sync✅ any backend: own endpoints, CouchDB, GraphQL, Supabase…✅ CouchDB-compatible, self-hostable; or none✅ none (BroadcastChannel) or WsServer relay⚠️ Dexie Cloud SaaS or paid on-prem⚠️ Fireproof Cloud; self-host undocumented20✅ none; any GUN peer relays (gun/lib/server)✅ none; libp2p relay for browsers⚠️ relay needed; self-host @evolu/relay / Docker21⚠️ central backend: Cloudflare DO, Electric, S2, custom⚠️ Electric service + Postgres ≥14; self-hostable⚠️ PowerSync Service + source DB + your API⚠️ zero-cache + Postgres 15+ + your API⚠️ @triplit/server required; self-host only⚠️ Instant server + Postgres; Docker self-host or Cloud Data model Data modelJSON records in collectionsshared types: Y.Map, Y.Array, Y.Text, XMLJSON-like CRDT documents, many per RepoJSON-like CRDT doc: Map, List, Text, Tree, CounterCoValues (maps, lists, feeds, text) in a graphJSON documents in collectionsJSON docs with revision treesin-memory tables + key-valuesIndexedDB tables; Y.Doc propertiesJSON docs in a Merkle CRDT ledgergraph of nodes (souls) with fieldsappend-only log; events, KV, documentsSQLite tables; LWW per columnevent log → SQLite tables (materializers)Postgres table rows, synced as shapesSQLite tables (views over JSON rows)Postgres tables (rows) with relationshipsdocuments in collections, schema-defined relationsentities in namespaces + links (triples in Postgres) -Schema validation of writes✅ metaschema, Standard Schema, ajv; on every hop❌ none❌ none — TS generics only⚠️ loro-mirror DSL; local setState only⚠️ Zod-based co.* schemas; runtime check defaults to warn22✅ JSON Schema via validate-ajv, validate-z-schema23❌ none client-side⚠️ cell types + defaults; zod etc. converted24❌ index spec only, no validation❌ none❌ none❌ none⚠️ Standard Schema types; app validates input25✅ Effect Schema per event, encoded on commit⚠️ Postgres constraints; Standard Schema via TanStack DB⚠️ Schema declares views; raw-table SQLite constraints26⚠️ mutator args via Standard Schema; schema.ts types only27✅ own DSL Schema as S; optional (schemaless mode)✅ own DSL i.schema/i.entity, enforced by server -Schema versions and migrations✅ version on every op; lens on read; migrate()❌ none❌ none❌ none⚠️ withMigration() on every load; no versions✅ version + migrationStrategies❌ none❌ no versioned schema⚠️ version().upgrade(), unsynced tables only❌ none; normalize in index functions❌ none❌ none⚠️ append-only schema; no migration functions✅ state rebuilt from the eventlog⚠️ your Postgres migrations; DDL forces shape re-sync28⚠️ none needed for JSON views; no migration API⚠️ Postgres DDL; expand/contract deploy order⚠️ schema push with compatibility checks; no migrations⚠️ instant-cli push schema diff + renames; no data migrations -Queries✅ Mango select(), order, top-k❌ none — app reads shared types❌ none — load documents by URL⚠️ JSONPath (RFC 9535) over one doc❌ none — traverse references from known ids✅ Mango (mingo); query-builder plugin✅ Mango (pouchdb-find), map/reduce✅ queries module (TinyQL: select, join, group)✅ where() on indexes + filter()✅ map-function indexes, key ranges⚠️ graph traversal; LEX key ranges via RAD⚠️ query(fn) scans the whole log29✅ typed SQL via Kysely✅ SQL + query builder (SQLite)✅ SQL where per shape; local SQL via PGlite✅ SQL (SQLite); Drizzle, Kysely drivers✅ ZQL builder; named queries resolved by your API✅ own builder: Where, Order, Include, subqueries✅ InstaQL (nested JSON: where, order, cursors) -Secondary indexes✅ entity index fields, in memory❌ none❌ none❌ none❌ none✅ schema indexes, compound✅ Mango indexes, map/reduce views✅ indexes module (slices)✅ compound, multi-entry✅ persistent prolly-tree indexes❌ none; app-built index nodes❌ none; keyvalue-indexed indexes keys only✅ indexes config (Kysely builder)✅ table indexes✅ PGlite CREATE INDEX; client has none✅ indexes in Table options✅ automatic: Postgres indexes mirrored; client in-memory❌ collection scan or id lookup only✅ .indexed() attributes, built on the server -Unique constraints✅ unique keys; a peer's clash is a conflict event❌ none❌ none❌ none⚠️ getOrCreateUnique: deterministic id per group❌ primary key only❌ _id only❌ row id only⚠️ & index per device; sync not documented❌ _id only❌ none❌ only the indexBy key❌ rejected by design⚠️ isUnique index, per client only30⚠️ Postgres UNIQUE, when your API writes⚠️ source DB on upload; raw tables locally⚠️ Postgres enforces; .unique() only declares❌ only the id key✅ .unique(), enforced by the server +Schema validation of writes✅ metaschema, Standard Schema, ajv; local writes and peers' ops22❌ none❌ none — TS generics only⚠️ loro-mirror DSL; local setState only⚠️ Zod-based co.* schemas; runtime check defaults to warn23✅ JSON Schema via validate-ajv, validate-z-schema24❌ none client-side⚠️ cell types + defaults; zod etc. converted25❌ index spec only, no validation❌ none❌ none❌ none⚠️ Standard Schema types; app validates input26✅ Effect Schema per event, encoded on commit⚠️ Postgres constraints; Standard Schema via TanStack DB⚠️ Schema declares views; raw-table SQLite constraints27⚠️ mutator args via Standard Schema; schema.ts types only28✅ own DSL Schema as S; optional (schemaless mode)✅ own DSL i.schema/i.entity, enforced by server +Schema versions and migrations✅ version on every op; lens on read; migrate()❌ none❌ none❌ none⚠️ withMigration() on every load; no versions✅ version + migrationStrategies❌ none❌ no versioned schema⚠️ version().upgrade(), unsynced tables only❌ none; normalize in index functions❌ none❌ none⚠️ append-only schema; no migration functions✅ state rebuilt from the eventlog⚠️ your Postgres migrations; DDL forces shape re-sync29⚠️ none needed for JSON views; no migration API⚠️ Postgres DDL; expand/contract deploy order⚠️ schema push with compatibility checks; no migrations⚠️ instant-cli push schema diff + renames; no data migrations +Queries✅ Mango select(), order, top-k❌ none — app reads shared types❌ none — load documents by URL⚠️ JSONPath (RFC 9535) over one doc❌ none — traverse references from known ids✅ Mango (mingo); query-builder plugin✅ Mango (pouchdb-find), map/reduce✅ queries module (TinyQL: select, join, group)✅ where() on indexes + filter()✅ map-function indexes, key ranges⚠️ graph traversal; LEX key ranges via RAD⚠️ query(fn) scans the whole log30✅ typed SQL via Kysely✅ SQL + query builder (SQLite)✅ SQL where per shape; local SQL via PGlite✅ SQL (SQLite); Drizzle, Kysely drivers✅ ZQL builder; named queries resolved by your API✅ own builder: Where, Order, Include, subqueries✅ InstaQL (nested JSON: where, order, cursors) +Secondary indexes✅ entity index fields, equality only, in memory❌ none❌ none❌ none❌ none✅ schema indexes, compound✅ Mango indexes, map/reduce views✅ indexes module (slices)✅ compound, multi-entry✅ persistent prolly-tree indexes❌ none; app-built index nodes❌ none; keyvalue-indexed indexes keys only✅ indexes config (Kysely builder)✅ table indexes✅ PGlite CREATE INDEX; client has none✅ indexes in Table options✅ automatic: Postgres indexes mirrored; client in-memory❌ collection scan or id lookup only✅ .indexed() attributes, built on the server +Unique constraints✅ unique keys; a peer's clash is a conflict event❌ none❌ none❌ none⚠️ getOrCreateUnique: deterministic id per group❌ primary key only❌ _id only❌ row id only⚠️ & index per device; sync not documented❌ _id only❌ none❌ only the indexBy key❌ rejected by design⚠️ isUnique index, per client only31⚠️ Postgres UNIQUE, when your API writes⚠️ source DB on upload; raw tables locally⚠️ Postgres enforces; .unique() only declares❌ only the id key✅ .unique(), enforced by the server Live queries / change events✅ watch(), change events✅ observe/observeDeep, doc update events✅ DocHandle change events with patches✅ subscribe, subscribeJsonpath✅ subscribe, useCoState hooks✅ $ observables (event-reduce)⚠️ changes({ live: true }) feed✅ listeners at every level; UI hooks✅ liveQuery()✅ subscribe(), useLiveQuery✅ .on() on any node or path✅ events.on('update'); no live queries✅ subscribeQuery, useQuery✅ queryDb, signals, computed✅ ShapeStream, Shape.subscribe, useShape✅ query().watch(), differentialWatch(), onChange✅ useQuery, materialize(); incremental view maintenance✅ client.subscribe, useQuery; incremental view maintenance✅ every query is live: useQuery, subscribeQuery Collaborative text❌ none✅ Y.Text (rich), Y.XmlFragment; editor bindings✅ text with marks and blocks (Peritext)✅ LoroText rich text; ProseMirror/CodeMirror bindings✅ co.plainText(), co.richText(); ProseMirror❌ none❌ none❌ none✅ Y.Doc props via y-dexie (Yjs)❌ none❌ none❌ none❌ none❌ pair with Yjs / Automerge / Loro⚠️ Yjs via @electric-sql/y-electric provider❌ none built in; Yjs-as-rows demo❌ no text CRDT❌ no text CRDT; S.String is a register❌ none; strings are last-write values -Ordered lists that merge❌ no ordered list; an array field can be an observed-remove set (add()/remove())✅ Y.Array (YATA sequence)✅ lists with insertAt/deleteAt✅ LoroList, LoroMovableList, LoroTree✅ co.list() (ordered CRDT)⚠️ crdt plugin replays $push❌ arrays are part of the doc❌ array cells are LWW⚠️ Y.Array; add() sorts plain arrays31❌ arrays are part of the doc❌ set() is unordered (random keys)⚠️ events log: append-only, merged order❌ none⚠️ fractional indexing (Rocicorp package)⚠️ only inside a Yjs doc; columns replaced whole❌ rows, or a JSON column replaced whole❌ no sequence CRDT; rows or json() columns❌ S.Set only; arrays are replaced whole❌ arrays are JSON values; links are sets -Counters that converge✅ inc(): per-origin sums32❌ no counter type33✅ Counter type✅ LoroCounter❌ no counter type⚠️ crdt plugin $inc❌ one revision wins❌ cells are LWW✅ add(n)/remove(n) replayed on server❌ one winner per _id❌ LWW fields; concurrent increments overwrite❌ no counter store in v4❌ LWW columns only⚠️ increment events + your materializer❌ not built in; DIY in your API❌ no; PATCH uploads absolute values⚠️ server re-runs mutator; no counter type34❌ numbers are last-write registers❌ no increment op; read-modify-write +Ordered lists that merge❌ no ordered list; an array field can be an observed-remove set (add()/remove())✅ Y.Array (YATA sequence)✅ lists with insertAt/deleteAt✅ LoroList, LoroMovableList, LoroTree✅ co.list() (ordered CRDT)⚠️ crdt plugin replays $push❌ arrays are part of the doc❌ array cells are LWW⚠️ Y.Array; add() sorts plain arrays32❌ arrays are part of the doc❌ set() is unordered (random keys)⚠️ events log: append-only, merged order❌ none⚠️ fractional indexing (Rocicorp package)⚠️ only inside a Yjs doc; columns replaced whole❌ rows, or a JSON column replaced whole❌ no sequence CRDT; rows or json() columns❌ S.Set only; arrays are replaced whole❌ arrays are JSON values; links are sets +Counters that converge✅ inc(): per-origin sums33❌ no counter type34✅ Counter type✅ LoroCounter❌ no counter type⚠️ crdt plugin $inc❌ one revision wins❌ cells are LWW✅ add(n)/remove(n) replayed on server❌ one winner per _id❌ LWW fields; concurrent increments overwrite❌ no counter store in v4❌ LWW columns only⚠️ increment events + your materializer❌ not built in; DIY in your API❌ no; PATCH uploads absolute values⚠️ server re-runs mutator; no counter type35❌ numbers are last-write registers❌ no increment op; read-modify-write Replication Topologymesh; hubs that dial each otherany — client–server or WebRTC meshany — client–server or peer-to-peerany — app moves bytesclient–server through sync serversclient–server; P2P over WebRTCmulti-master, any pair of DBsrelay hub, local peers, customclient–server onlyhub: cloud or custom gatewaymesh (DAM) with relay peersp2p mesh (libp2p pubsub)client–relay; several relays at onceclient–server (central backend)client–server only; CDN fan-outclient–server only (service + your backend)client–server: Postgres → zero-cache → clientsclient–server only, one sync serverclient–server only Transport✅ injected link; wrpc WebSocket/WebRTC✅ providers: WebSocket, WebRTC; y-protocols sync✅ adapters: WebSocket, MessageChannel, BroadcastChannel✅ any (bytes); loro-websocket⚠️ WebSocket (cojson-transport-ws)✅ pluggable: HTTP, WebSocket, WebRTC, GraphQL…⚠️ HTTP (CouchDB protocol); socket-pouch third-party✅ WebSocket, BroadcastChannel, custom⚠️ HTTPS + WebSocket, fixed✅ pluggable gateways; cloud over WS/HTTP✅ WebSocket, WebRTC, UDP multicast; adapters✅ libp2p transports (TCP, WS, WebRTC…)⚠️ WebSocket only✅ pluggable SyncBackend; WS / HTTP in sync-cf⚠️ HTTP only: long-poll or SSE⚠️ HTTP streaming or WebSocket; not pluggable⚠️ fixed: WebSocket to zero-cache, HTTP to your API⚠️ WebSocket; transport option undocumented⚠️ WebSocket, SSE fallback; not pluggable -Browser to browser (WebRTC)✅ wrpc PeerLink + PeerHost✅ y-webrtc (signaling server only)35⚠️ no official adapter; automerge-repo-network-peerjs (third-party)⚠️ possible; no official adapter❌ not built in✅ replication-webrtc (simple-peer)❌ no maintained plugin❌ none built in❌ none❌ none built in✅ gun/lib/webrtc (relay for signaling)⚠️ via @libp2p/webrtc + circuit relay🔜 P2P planned (Protocol docs)❌ central backend required❌ every client reads from the Electric service❌ no; all data flows through the service❌ no peer-to-peer❌ sync always goes through the server❌ no; even presence/topics relay through the server -Partial replication✅ scopes per peer, enforced by the host⚠️ per document (room); subdocuments36⚠️ per document; sharePolicy⚠️ per document (room)✅ per CoValue, loaded on demand⚠️ one replication per scope; backend filters✅ filter, doc_ids, selector❌ whole store per path✅ realms the user can access❌ whole ledger✅ on demand: only nodes you get❌ whole database log✅ per owner (useOwner, shard owners)⚠️ per store (storeId) only✅ shapes: table, where, columns, subset snapshots✅ Sync Streams (subscriptions with params, TTL)✅ per-query sync; TTL up to 10 min✅ per-query sync, filtered by read permissions✅ per query; view rules filter results +Browser to browser (WebRTC)✅ wrpc PeerLink + PeerHost✅ y-webrtc (signaling server only)36⚠️ no official adapter; automerge-repo-network-peerjs (third-party)⚠️ possible; no official adapter❌ not built in✅ replication-webrtc (simple-peer)❌ no maintained plugin❌ none built in❌ none❌ none built in✅ gun/lib/webrtc (relay for signaling)⚠️ via @libp2p/webrtc + circuit relay🔜 P2P planned (Protocol docs)❌ central backend required❌ every client reads from the Electric service❌ no; all data flows through the service❌ no peer-to-peer❌ sync always goes through the server❌ no; even presence/topics relay through the server +Partial replication✅ scopes per peer, enforced by the host on the record⚠️ per document (room); subdocuments37⚠️ per document; sharePolicy⚠️ per document (room)✅ per CoValue, loaded on demand⚠️ one replication per scope; backend filters✅ filter, doc_ids, selector❌ whole store per path✅ realms the user can access❌ whole ledger✅ on demand: only nodes you get❌ whole database log✅ per owner (useOwner, shard owners)⚠️ per store (storeId) only✅ shapes: table, where, columns, subset snapshots✅ Sync Streams (subscriptions with params, TTL)✅ per-query sync; TTL up to 10 min✅ per-query sync, filtered by read permissions✅ per query; view rules filter results Resume after a disconnect✅ from the last event id✅ state-vector exchange on reconnect✅ sync state per peer, persisted✅ export({ mode: 'update', from }); rejoin by version✅ known-state diff per CoValue✅ checkpoints✅ checkpoints in _local docs✅ hash-tree diff on reconnect✅ delta since last server revision✅ clock heads; missing CAR files fetched⚠️ re-sends live gets with value hashes✅ heads exchange; fetch only missing entries✅ range-based set reconciliation✅ pull from cursor (seqNum)✅ offset + handle per shape✅ from last op id per bucket✅ base cookie + server CVR, kept 7 days✅ per-query checkpoint: server timestamp + cached ids⚠️ queries re-sent; server returns full results -Bootstrap a new replica✅ streamed snapshot past the log's retention✅ state-vector diff of merged state✅ whole compressed doc to an empty peer37✅ snapshot; shallow snapshot trims history❌ no snapshots — full history per CoValue✅ state-based: pulls current docs✅ changes feed: leaf revisions only✅ state-based: diff by content hashes✅ server snapshot of the user's realms⚠️ compact() merges CAR files, keeps clock38✅ state-based; no history to replay❌ newcomer traverses the full log❌ fetches the full change history🔜 full eventlog replay; compaction planned (#136)⚠️ shape log starts with a snapshot; compaction opt-in39⚠️ compacted bucket history, not a snapshot✅ clients hydrate current query results, no history✅ current query results, no history replay✅ server sends current query results -Conflict resolutionLWW per field by (stamp, origin); counters addCRDT (YATA); Y.Map key: higher clientID wins40CRDT; map key LWW by op id, losers kept41CRDT; Map LWW by Lamport, then peer idLWW per key by wall-clock madeAt; CoList CRDT42custom conflictHandler; default master winsdeterministic winner; _conflicts keptLWW per cell (HLC)server-authoritative; LWW per property43Merkle clock; deterministic winner per _idHAM: LWW per field, wall-clock state44LWW per key (Lamport clock, writer id)LWW per column (HLC)45backend total order + client rebase46server authority: your API writes Postgresserver authority: your backend decides47server authority; client rebases pending mutations48attribute patches; the last to reach the server wins49server order, last write per attribute; merge for JSON50 -Offline writes✅ local op log; pushed when a peer is back✅ local edits merge on reconnect (y-indexeddb)✅ local changes merge on reconnect✅ local edits merge on import✅ local-first; merges on reconnect✅ local writes, pushed later✅ local DB, replicate later✅ MergeableStore merges later✅ local IndexedDB, synced later✅ local ledger, synced later⚠️ local write; in-memory send queue51✅ local append; heads exchanged on join✅ local SQLite; synced later✅ local eventlog; pushed after rebase⚠️ DIY patterns; @tanstack/offline-transactions (TanStack)✅ local SQLite + upload queue (uploadData)❌ rejected once disconnected (default after 60 s)52✅ durable outbox, optimistic, rebased on server data✅ queued in IndexedDB, replayed on reconnect -A server can reject a write before it spreads✅ the host re-runs validators, hooks, scopes⚠️ none in @y/websocket-server; Hocuspocus hooks (third-party)⚠️ per-document access only; no change validation⚠️ authenticate → read/write per room; no op checks⚠️ signature/role checks; content opaque to server53✅ backend is master; RxServer changeValidator✅ CouchDB validate_doc_update❌ relay forwards client to client54⚠️ permissions enforced; no custom validation❌ cloud stores blocks and meta only⚠️ relay middleware can drop; SEA checks signatures⚠️ every peer checks canAppend + signature55❌ relay is blind (E2EE)✅ onPush can reject a batch (sync-cf)✅ your API is the only write path✅ writes reach others only via your backend✅ server mutators in a Postgres transaction✅ server re-checks schema and permissions; can reject✅ server applies every tx; rules can reject +Bootstrap a new replica✅ streamed snapshot past the log's retention✅ state-vector diff of merged state✅ whole compressed doc to an empty peer38✅ snapshot; shallow snapshot trims history❌ no snapshots — full history per CoValue✅ state-based: pulls current docs✅ changes feed: leaf revisions only✅ state-based: diff by content hashes✅ server snapshot of the user's realms⚠️ compact() merges CAR files, keeps clock39✅ state-based; no history to replay❌ newcomer traverses the full log❌ fetches the full change history🔜 full eventlog replay; compaction planned (#136)⚠️ shape log starts with a snapshot; compaction opt-in40⚠️ compacted bucket history, not a snapshot✅ clients hydrate current query results, no history✅ current query results, no history replay✅ server sends current query results +Conflict resolutionLWW per field by (stamp, origin); counters addCRDT (YATA); Y.Map key: higher clientID wins41CRDT; map key LWW by op id, losers kept42CRDT; Map LWW by Lamport, then peer idLWW per key by wall-clock madeAt; CoList CRDT43custom conflictHandler; default master winsdeterministic winner; _conflicts keptLWW per cell (HLC)server-authoritative; LWW per property44Merkle clock; deterministic winner per _idHAM: LWW per field, wall-clock state45LWW per key (Lamport clock, writer id)LWW per column (HLC)46backend total order + client rebase47server authority: your API writes Postgresserver authority: your backend decides48server authority; client rebases pending mutations49attribute patches; the last to reach the server wins50server order, last write per attribute; merge for JSON51 +Offline writes✅ local op log, kept until the hosts have it52✅ local edits merge on reconnect (y-indexeddb)✅ local changes merge on reconnect✅ local edits merge on import✅ local-first; merges on reconnect✅ local writes, pushed later✅ local DB, replicate later✅ MergeableStore merges later✅ local IndexedDB, synced later✅ local ledger, synced later⚠️ local write; in-memory send queue53✅ local append; heads exchanged on join✅ local SQLite; synced later✅ local eventlog; pushed after rebase⚠️ DIY patterns; @tanstack/offline-transactions (TanStack)✅ local SQLite + upload queue (uploadData)❌ rejected once disconnected (default after 60 s)54✅ durable outbox, optimistic, rebased on server data✅ queued in IndexedDB, replayed on reconnect +A server can reject a write before it spreads⚠️ out-of-scope and unsigned ops stop at the host; one its validator or beforeApply skips is relayed55⚠️ none in @y/websocket-server; Hocuspocus hooks (third-party)⚠️ per-document access only; no change validation⚠️ authenticate → read/write per room; no op checks⚠️ signature/role checks; content opaque to server56✅ backend is master; RxServer changeValidator✅ CouchDB validate_doc_update❌ relay forwards client to client57⚠️ permissions enforced; no custom validation❌ cloud stores blocks and meta only⚠️ relay middleware can drop; SEA checks signatures⚠️ every peer checks canAppend + signature58❌ relay is blind (E2EE)✅ onPush can reject a batch (sync-cf)✅ your API is the only write path✅ writes reach others only via your backend✅ server mutators in a Postgres transaction✅ server re-checks schema and permissions; can reject✅ server applies every tx; rules can reject Integrity and security -Tamper-evident history✅ hash-linked chain, re-checked on read (optional)❌ none✅ SHA-256 change hash graph; checked on load❌ checksums detect corruption only✅ BLAKE3 hash chain per session❌ none❌ none❌ hashes serve sync diffs only❌ none✅ SHA-256 content-addressed Merkle clock⚠️ signed fields, # content-hash souls; no log✅ Merkle-DAG of CIDs (sha256, dag-cbor)⚠️ AEAD per change; no hash chain❌ none❌ not built in❌ bucket checksums catch corruption, not tampering❌ not built in❌ no hash chain❌ none -Signed changes✅ Ed25519 per op; the node id is the key; 'require' to enforce56❌ none❌ none — author is unsigned metadata❌ none✅ Ed25519 per session❌ none❌ none❌ none❌ none❌ none; cloud uses JWT auth✅ SEA signs user-space (~pubkey) writes57✅ every entry signed by its identity❌ shared owner key, no signatures❌ none❌ not built in❌ JWT per connection; changes unsigned❌ not built in; auth via cookie/token❌ JWT per connection, not per change❌ none; auth by session token -Encryption at rest⚠️ per record (encrypted: true), never synced❌ none — y-indexeddb stores raw updates❌ none❌ none — storage is the app's⚠️ stored encrypted; keys kept locally58⚠️ encryption-crypto-js per field; WebCrypto Premium⚠️ third-party crypto-pouch❌ none⚠️ dexie-encrypted (beta for Dexie 4)✅ AES-GCM by default⚠️ manual SEA.encrypt; stores plaintext otherwise⚠️ encryption module encrypts stored entries✅ SQLCipher (web, Electron, Expo)❌ not supported (issue #70)❌ not built in⚠️ opt-in encryptionKey; Node needs cipher fork⚠️ op-sqlite encryptionKey on React Native only❌ no local encryption❌ IndexedDB cache in plaintext -End-to-end encryption⚠️ recipe: wrpc's HPKE sealer in hooks, per field⚠️ y-webrtc: DTLS + room password❌ none (Keyhive pre-alpha, separate)⚠️ %ELO via EloAdaptor (experimental)59✅ XSalsa20 with group read keys❌ replication reads decrypted docs60⚠️ third-party comdb (beta)❌ none⚠️ dexie-encrypted above the sync layer61⚠️ blocks encrypted; key sent with meta62⚠️ manual SEA.encrypt / SEA.secret (ECDH)⚠️ @orbitdb/simple-encryption, unaudited63✅ XChaCha20-Poly1305, always on❌ DIY via schema transform (issue #70)❌ app-level; official example only❌ DIY: sync app-encrypted values❌ no; zero-cache runs queries on plaintext rows❌ server reads all data❌ no; server reads all data for rules -Access control where data is served✅ host scopes for reads and writes, authorize⚠️ per room, in your WebSocket upgrade handler⚠️ shareConfig.access per peer/doc (experimental)64⚠️ per-room read/write via server authenticate✅ Groups: reader/writer/admin, cryptographically enforced⚠️ your backend; RxServer queryModifier⚠️ CouchDB per-database roles65⚠️ authenticate the WS upgrade per path✅ realms, roles; table/row/property rules⚠️ per-ledger tokens via Fireproof Cloud⚠️ owner-only user space, SEA.certify; reads open⚠️ write ACLs; no read control66⚠️ owner keys; write-key token; relay isOwnerAllowed⚠️ DIY: validatePayload, onPush / onPull⚠️ your authorizing proxy; API open by default67✅ service: JWT + stream queries; backend: writes✅ your query/mutate endpoints, from server context✅ roles + per-collection filters, server-enforced✅ CEL-style rules in instant.perms.ts, server-side68 -Purge: the payload leaves history✅ purge() scrubs the op log⚠️ deleted content GC'd (gc: true default)69❌ history kept; delete() is local only⚠️ delete + shallow snapshot; redactJsonUpdates70✅ deleteCoValues (admin), erased in background⚠️ cleanup plugin purges tombstones71⚠️ purge(), indexeddb adapter only72✅ no history; a delete keeps no payload⚠️ DELETE /users/:id; records not documented❌ no API; Merkle history immutable⚠️ put(null) overwrites; opt-in lib/erase❌ immutable log; del appends a tombstone🔜 owner deletion planned; deleteOwner is a stub73❌ append-only eventlog⚠️ Postgres DELETE; shape logs keep the payload74⚠️ delete at source; compact drops old payloads✅ hard DELETE; no history kept✅ hard delete; no history to scrub✅ delete removes the triples; no history kept -History / time travel⚠️ op log within retention; repair(id); no read-at-version⚠️ Y.snapshot; needs gc: false✅ view(doc, heads), diff, getHistory✅ checkout, forkAt, revertTo✅ $jazz.getEdits(); branches (unstable)❌ latest revision only⚠️ get(id, { rev }) until compaction⚠️ checkpoints: in-memory undo/redo❌ no API⚠️ changes(since); no read-at-version❌ latest value per field only⚠️ full oplog via log.iterator()✅ evolu_history table, queryable⚠️ eventlog via store.events()❌ no; replica=full adds old_value only❌ current checkpoint only❌ not built in❌ current state only❌ no versions; nightly backups only +Tamper-evident history✅ hash-linked chain, unsigned (optional)59❌ none✅ SHA-256 change hash graph; checked on load❌ checksums detect corruption only✅ BLAKE3 hash chain per session❌ none❌ none❌ hashes serve sync diffs only❌ none✅ SHA-256 content-addressed Merkle clock⚠️ signed fields, # content-hash souls; no log✅ Merkle-DAG of CIDs (sha256, dag-cbor)⚠️ AEAD per change; no hash chain❌ none❌ not built in❌ bucket checksums catch corruption, not tampering❌ not built in❌ no hash chain❌ none +Signed changes✅ Ed25519 per op; the node id is the key; 'require' to enforce60❌ none❌ none — author is unsigned metadata❌ none✅ Ed25519 per session❌ none❌ none❌ none❌ none❌ none; cloud uses JWT auth✅ SEA signs user-space (~pubkey) writes61✅ every entry signed by its identity❌ shared owner key, no signatures❌ none❌ not built in❌ JWT per connection; changes unsigned❌ not built in; auth via cookie/token❌ JWT per connection, not per change❌ none; auth by session token +Encryption at rest⚠️ per record (encrypted: true), never synced62❌ none — y-indexeddb stores raw updates❌ none❌ none — storage is the app's⚠️ stored encrypted; keys kept locally63⚠️ encryption-crypto-js per field; WebCrypto Premium64⚠️ third-party crypto-pouch❌ none⚠️ dexie-encrypted (beta for Dexie 4)✅ AES-GCM by default⚠️ manual SEA.encrypt; stores plaintext otherwise⚠️ encryption module encrypts stored entries✅ SQLCipher (web, Electron, Expo)❌ not supported (issue #70)❌ not built in⚠️ opt-in encryptionKey; Node needs cipher fork⚠️ op-sqlite encryptionKey on React Native only❌ no local encryption❌ IndexedDB cache in plaintext +End-to-end encryption⚠️ recipe: wrpc's HPKE sealer in hooks, per field⚠️ y-webrtc: DTLS + room password❌ none (Keyhive pre-alpha, separate)⚠️ %ELO via EloAdaptor (experimental)65✅ XSalsa20 with group read keys❌ replication reads decrypted docs66⚠️ third-party comdb (beta)❌ none⚠️ dexie-encrypted above the sync layer67⚠️ blocks encrypted; key sent with meta68⚠️ manual SEA.encrypt / SEA.secret (ECDH)⚠️ @orbitdb/simple-encryption, unaudited69✅ XChaCha20-Poly1305, always on❌ DIY via schema transform (issue #70)❌ app-level; official example only❌ DIY: sync app-encrypted values❌ no; zero-cache runs queries on plaintext rows❌ server reads all data❌ no; server reads all data for rules +Access control where data is served✅ host scopes for reads and writes, authorize⚠️ per room, in your WebSocket upgrade handler⚠️ shareConfig.access per peer/doc (experimental)70⚠️ per-room read/write via server authenticate✅ Groups: reader/writer/admin, cryptographically enforced✅ RxServer authHandler, queryModifier, changeValidator (rxdb-server)71⚠️ CouchDB per-database roles72⚠️ authenticate the WS upgrade per path✅ realms, roles; table/row/property rules⚠️ per-ledger tokens via Fireproof Cloud⚠️ owner-only user space, SEA.certify; reads open⚠️ write ACLs; no read control73⚠️ owner keys; write-key token; relay isOwnerAllowed⚠️ DIY: validatePayload, onPush / onPull⚠️ your authorizing proxy; API open by default74✅ service: JWT + stream queries; backend: writes✅ your query/mutate endpoints, from server context✅ roles + per-collection filters, server-enforced✅ CEL-style rules in instant.perms.ts, server-side75 +Purge: the payload leaves history✅ purge() scrubs the op log76⚠️ deleted content GC'd (gc: true default)77❌ history kept; delete() is local only⚠️ delete + shallow snapshot; redactJsonUpdates78✅ deleteCoValues (admin), erased in background⚠️ cleanup plugin purges tombstones79⚠️ purge(), indexeddb adapter only80✅ no history; a delete keeps no payload⚠️ DELETE /users/:id; records not documented❌ no API; Merkle history immutable⚠️ put(null) overwrites; opt-in lib/erase❌ immutable log; del appends a tombstone🔜 owner deletion planned; deleteOwner is a stub81❌ append-only eventlog⚠️ Postgres DELETE; shape logs keep the payload82⚠️ delete at source; compact drops old payloads✅ hard DELETE; no history kept✅ hard delete; no history to scrub✅ delete removes the triples; no history kept +History / time travel⚠️ op log within retention; repair(id); no read-at-version⚠️ Y.snapshot; needs gc: false✅ view(doc, heads), diff, getHistory✅ checkout, forkAt, revertTo✅ $jazz.getEdits(); branches (unstable)⚠️ latest revision; the crdt plugin keeps every op in the document⚠️ get(id, { rev }) until compaction⚠️ checkpoints: in-memory undo/redo❌ no API⚠️ changes(since); no read-at-version❌ latest value per field only⚠️ full oplog via log.iterator()✅ evolu_history table, queryable⚠️ eventlog via store.events()❌ no; replica=full adds old_value only❌ current checkpoint only❌ not built in❌ current state only❌ no versions; nightly backups only Extensibility and operations Write hooks / middleware✅ open({ hooks }): writes, reads, peer batches❌ none — doc events cannot veto❌ none❌ subscribePreCommit sets message/timestamp only❌ none✅ pre/post insert/save/remove⚠️ transform-pouch (pouchdb-community)✅ middleware: willSet*/willDel* (v8+)✅ DBCore middleware, table hooks❌ none (storage gatewayInterceptor only)✅ Gun.on('opt') → put/in/out middleware⚠️ custom access controller canAppend❌ none⚠️ materializers; onPush on the backend⚠️ your API; Phoenix.Sync.Writer callbacks (Elixir)⚠️ your backend's upload handler; no client hook✅ custom mutators + handleMutateRequest handler❌ no write hooks; permissions only⚠️ no write hooks; rules reject, webhooks after commit -Observability✅ OpenTelemetry spans and metrics (injected), stats()❌ none⚠️ setLoggerFactory, doc-metrics events; no OTel❌ none (Inspector for debugging)✅ OpenTelemetry metrics (jazz.*); no spans⚠️ logger plugin (Premium)⚠️ pouchdb-debug logging only⚠️ getStats() counters; dev Inspector❌ none; syncState observable⚠️ structured logs (FP_DEBUG), tracer events⚠️ console.STAT, lib/stats.js counters❌ none⚠️ structured Console; no OpenTelemetry✅ OpenTelemetry built in; devtools✅ server: OTLP traces, Prometheus/StatsD, Sentry⚠️ service: Prometheus metrics, JSON logs; client logger75✅ zero-cache: OTLP logs, traces, metrics76⚠️ log levels, client.logs; Sentry on server⚠️ client verbose/logger; server OTel traces to Honeycomb +Observability✅ OpenTelemetry spans and metrics (injected), stats()❌ none⚠️ setLoggerFactory, doc-metrics events; no OTel❌ none (Inspector for debugging)✅ OpenTelemetry metrics (jazz.*); no spans⚠️ logger plugin (Premium)⚠️ pouchdb-debug logging only⚠️ getStats() counters; dev Inspector❌ none; syncState observable⚠️ structured logs (FP_DEBUG), tracer events⚠️ console.STAT, lib/stats.js counters❌ none⚠️ structured Console; no OpenTelemetry✅ OpenTelemetry built in; devtools✅ server: OTLP traces, Prometheus/StatsD, Sentry⚠️ service: Prometheus metrics, JSON logs; client logger83✅ zero-cache: OTLP logs, traces, metrics84⚠️ log levels, client.logs; Sentry on server⚠️ client verbose/logger; server OTel traces to Honeycomb Typed errors✅ SyncomError with a code❌ plain Errors, no codes❌ plain errors, no codes❌ plain Error messages, no codes⚠️ load states unavailable/unauthorized/deleted✅ RxError.code (e.g. COL23)✅ status + name (e.g. 409 conflict)⚠️ tinybase:<n> in messages, undocumented✅ Dexie.*Error by name; policy codes⚠️ NotFoundError; otherwise plain Error❌ ack.err strings❌ plain Error messages✅ typed errors (Result, EvoluError)✅ Effect tagged errors (_tag)⚠️ error classes, FetchError.status; no codes⚠️ service PSYNC_* codes; client errors untyped✅ ErrorKind codes, ApplicationError, StorageFailureError⚠️ TriplitError subclasses: name + HTTP status⚠️ InstantAPIError body.type on HTTP; transact untyped -TypeScript types✅ hand-written .d.ts✅ shipped .d.ts (from JSDoc)✅ TS .d.ts; Doc<T> generics✅ TS .d.ts; typed containers✅ inferred from co.* schemas✅ TS; doc type inferred from schema⚠️ @types/pouchdb (DefinitelyTyped)✅ TS; schema-typed with-schemas✅ TS; EntityTable, DexieCloudTable✅ TS; generic document types✅ shipped .d.ts❌ none shipped (JSDoc only)✅ TS, inferred from schema✅ TS, inferred from Effect Schema✅ TS; ShapeStream<Row> generic, no codegen✅ shipped; row types inferred from Schema✅ TS, inferred from schema.ts✅ TS, inferred from schema (Entity, QueryResult)✅ inferred from i.schema; instant-cli pull writes it +TypeScript types⚠️ hand-written .d.ts; record data unknown✅ shipped .d.ts (from JSDoc)✅ TS .d.ts; Doc<T> generics✅ TS .d.ts; typed containers✅ inferred from co.* schemas✅ TS; doc type inferred from schema⚠️ @types/pouchdb (DefinitelyTyped)✅ TS; schema-typed with-schemas✅ TS; EntityTable, DexieCloudTable✅ TS; generic document types✅ shipped .d.ts❌ none shipped (JSDoc only)✅ TS, inferred from schema✅ TS, inferred from Effect Schema✅ TS; ShapeStream<Row> generic, no codegen✅ shipped; row types inferred from Schema✅ TS, inferred from schema.ts✅ TS, inferred from schema (Entity, QueryResult)✅ inferred from i.schema; instant-cli pull writes it UI bindings❌ none⚠️ editor bindings official; React via react-yjs (third-party)✅ @automerge/react, Svelte store, Solid primitives✅ loro-mirror-react; editor bindings✅ React, RN/Expo, Svelte; Vue (community-jazz-vue)✅ rxdb/plugins/react; Vue, Angular, Preact signals⚠️ third-party use-pouchdb✅ ui-react, ui-solid, ui-svelte✅ dexie-react-hooks; liveQuery observables✅ use-fireproof React hooks❌ none official❌ none✅ @evolu/react, @evolu/vue, @evolu/svelte✅ @livestore/react, solid, svelte✅ React useShape (@electric-sql/react)✅ @powersync/react, @powersync/vue, TanStack✅ @rocicorp/zero/react, /solid; Vue/Svelte community✅ @triplit/react, react-native, svelte, vue, angular, solid✅ React, React Native, Svelte, Vue, SolidJS packages @@ -167,7 +169,7 @@ page. 11. The PowerSync Service (needed to sync) is source-available under the Functional Source License: competing commercial use is barred, and each version becomes Apache-2.0 two years after release; the SQLite core extension is Apache-2.0. 12. The browser client itself is AGPL-3.0-only, not just the server. @triplit/db, @triplit/server and @triplit/server-core declare no license on npm, so the AGPL-3.0 LICENSE at the repo root applies to them. 13. Most are zero-cache's (fastify, postgres, @opentelemetry/sdk-node, @rocicorp/zero-sqlite3, ws, jose…); the client modules import only a few (e.g. @rocicorp/lock, @rocicorp/logger, @badrap/valita). -14. Filesystem and SQLite storages are Premium; the free SQLite trial throws past 500 documents or 500 operations. +14. Filesystem and SQLite storages are Premium; the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance (the 251st serial insert fails). 15. @electric-sql/client alone keeps rows in memory; a persisted Node replica is PGlite (@electric-sql/pglite) fed by @electric-sql/pglite-sync. 16. @instantdb/core itself does nothing without window (no socket, no storage), so Node gets only the online admin SDK. 17. The default is storage: 'memory': data only persists when you pass indexeddb. There is no OPFS or browser SQLite option. @@ -175,61 +177,69 @@ page. 19. The official sync server calls itself 'an unsecured Express app' — authentication is yours to add. 20. toCloud() defaults to dev endpoints (fireproof-v2-cloud-dev.jchris.workers.dev, dev.connect.fireproof.direct); the backend source sits in the repo's cloud/. 21. The default transport is wss://free.evoluhq.com, a testing relay that admits anyone and may delete data. -22. Runtime validation (0.20.10+) defaults to warn: invalid local writes go through with a console warning, although the source comment says strict. -23. Documents are validated only when the storage is wrapped with a validator plugin. -24. A cell that fails the schema is dropped or defaulted silently, and schematizers keep only type, enum and default. -25. Mutations accept already-validated values: Evolu does not run the column validators on insert/update, nor on received changes. -26. The client Schema does not validate writes (mismatched types are silently CAST); validation is your backend's on upload, and a rejected write is rolled back at the next checkpoint. -27. createSchema/table().columns() only type ZQL and declare relationships: the client's insert/update store values unchecked, and Postgres checks them when the server mutator commits. -28. Adding, removing or renaming a column invalidates every shape on that table, so clients get 409/must-refetch, drop their local copy and re-sync from scratch. -29. Documents.get(key) also walks the oplog from the head, so reads cost O(log length). -30. Two clients can commit events that collide; a materializer that then fails rolls back that event, which the docs say will 'typically halt' the system. -31. add([x])/remove([x]) merge across sync but return a sorted array, so element order is not kept. -32. A counter that one node resets while another increments it does not converge to the intuitive value; see Guarantees and limits in the Sync guide. -33. A number in a Y.Map is a register: of two concurrent increments, one is lost. -34. Increments add up only if the server mutator applies them atomically (raw SQL n = n + 1, or serializable + tx.retryOn); the default isolation is Postgres read committed. -35. y-webrtc defaults to the public signaling server wss://y-webrtc-eu.fly.dev; pass your own signaling list (and a password) for anything private. -36. The docs say providers sync subdocuments, but y-websocket, y-webrtc and y-indexeddb contain no subdocument handling — you open one provider per subdocument. -37. The compressed document still carries the full change history — Automerge does not drop history. -38. Compaction copies every block the clock reaches into the new CAR, so a newcomer still downloads the history. -39. Docs still call compaction planned, but the code has it behind an undocumented experimental_compaction shape param (default false), so by default a newcomer replays the snapshot plus every change since the shape was created. -40. Not wall-clock LWW: a concurrent Y.Map set is won by the writer with the higher random clientID, whatever the times. -41. The winner is chosen by operation id (counter, then actor), not wall-clock time; getConflicts returns the losing values. -42. compareTransactions returns 0 for equal madeAt from different sessions, so a same-millisecond tie falls to arrival order. -43. A whole-object put() overwrites concurrent property updates; where().modify() is re-run on the server. -44. Order comes from each writer's machine clock (+new Date): a write stamped in the future is held in a setTimeout until then and wins; equal states break by comparing JSON strings. -45. A change stamped more than 5 minutes ahead of the receiver's clock is quarantined until system time catches up. -46. Merge-conflict handling isn't implemented yet (#253): concurrent events simply apply in the backend's order. -47. The client never merges: it holds its checkpoint while uploads are pending, then adopts the server's checkpoint exactly; plain UPDATEs in the backend give per-field LWW by arrival order. -48. No field-level merge: the server re-runs each mutator against current Postgres state, so concurrent writes to a column are last-processed-wins unless your mutator checks. -49. The FAQ still describes per-attribute HLC timestamps from 0.x. In 1.x the server stamps each incoming batch with its own clock when it arrives, so an edit made offline hours ago overwrites newer values when it finally syncs. -50. Order is server arrival, not a timestamp: an offline write replayed on reconnect overwrites newer writes made meanwhile. -51. Reconnect re-sends only queued messages held in memory and live gets; there is no persisted outbox, and src/websocket.js:5 still reads 'TODO: resync upon reconnect'. -52. Writes queue only while connecting (disconnectTimeoutMs); docs-vs-code: the shipped client also accepts writes in needs-auth, which the docs list as rejected. -53. A write by a non-writer is stored and relayed, then ignored by every reader (markInvalid); content checks need a server worker. -54. middleware on the server's own MergeableStore guards only that copy; WsServer still relays the message to the other clients. -55. A rejected incoming entry is caught and only console.error'd; the app gets no event for it. -56. The default 'verify' takes an unsigned op; sync.signatures: 'require' refuses it. A bootstrap re-signs the host's state under the joiner's key, and hello does not yet prove possession of the key. -57. Only data under a user's ~pubkey soul is signed and verified; the public graph accepts unsigned writes from anyone. -58. The browser keeps the account secret in localStorage by default, so this protects data from the server, not from someone with the device. -59. The Loro team says not to rely on it for production-grade security yet. -60. The encryption wrapper sits below collection.storageInstance, which is what the replication reads. -61. Needs the encryption middleware moved above sync by hand; indexed fields are not encrypted. -62. MetaGateway.put attaches the ledger's key material to every meta update, so the sync service holds the key although the docs call sync end-to-end. -63. In 4.0.0 the documents type destructures encrypt instead of encryption, so document stores silently stay unencrypted (issue #1253, fix PR #1257 open). -64. Peer ids are self-asserted in the WebSocket join message, so access by peer id needs authentication at the socket. -65. CouchDB has no per-document read rules; the usual pattern is a database per user. -66. Anyone who knows a database address can replicate and read it unless it is encrypted. -67. Electric serves any data its Postgres role can read to any caller; ELECTRIC_SECRET must be added by a proxy that also pins the shape's table and where. -68. A rule that is not set evaluates to true: everything is open until you write rules. -69. GC clears the document state only; update logs kept by providers or peers hold the payload until they compact (y-indexeddb merges after 500 updates). -70. A shallow snapshot only syncs with peers past its start version, and other replicas keep their full history. -71. A removed document keeps its data in the tombstone until cleanup (default 31 days, after replication is in sync). -72. Purges do not replicate; other adapters return an error. -73. deleteOwner and deleteDatabase call todo(); rows can only be soft-deleted (isDeleted) and stay in evolu_history. -74. Without compaction a deleted row's earlier entries stay in the shape log on the Electric server (and in HTTP caches) and are replayed to new clients until the shape is deleted (DELETE /v1/shape) or rotated. -75. No tracing and no OTLP push to your own collector, yet the self-hosted service pushes anonymous metrics over OTLP to pulse.journeyapps.com unless telemetry.disable_telemetry_sharing: true. -76. zero-cache also sends anonymous usage telemetry to Rocicorp by default (ZERO_ENABLE_TELEMETRY=false or DO_NOT_TRACK=1 to opt out). +22. A snapshot merge (bootstrap) and an op from a newer schema version (ahead: 'apply', the default) are applied unvalidated, and a record outside any collection has no validator. +23. Runtime validation (0.20.10+) defaults to warn: invalid local writes go through with a console warning, although the source comment says strict. +24. Documents are validated only when the storage is wrapped with a validator plugin. +25. A cell that fails the schema is dropped or defaulted silently, and schematizers keep only type, enum and default. +26. Mutations accept already-validated values: Evolu does not run the column validators on insert/update, nor on received changes. +27. The client Schema does not validate writes (mismatched types are silently CAST); validation is your backend's on upload, and a rejected write is rolled back at the next checkpoint. +28. createSchema/table().columns() only type ZQL and declare relationships: the client's insert/update store values unchecked, and Postgres checks them when the server mutator commits. +29. Adding, removing or renaming a column invalidates every shape on that table, so clients get 409/must-refetch, drop their local copy and re-sync from scratch. +30. Documents.get(key) also walks the oplog from the head, so reads cost O(log length). +31. Two clients can commit events that collide; a materializer that then fails rolls back that event, which the docs say will 'typically halt' the system. +32. add([x])/remove([x]) merge across sync but return a sorted array, so element order is not kept. +33. A counter that one node resets while another increments it does not converge to the intuitive value; see Guarantees and limits in the Sync guide. +34. A number in a Y.Map is a register: of two concurrent increments, one is lost. +35. Increments add up only if the server mutator applies them atomically (raw SQL n = n + 1, or serializable + tx.retryOn); the default isolation is Postgres read committed. +36. y-webrtc defaults to the public signaling server wss://y-webrtc-eu.fly.dev; pass your own signaling list (and a password) for anything private. +37. The docs say providers sync subdocuments, but y-websocket, y-webrtc and y-indexeddb contain no subdocument handling — you open one provider per subdocument. +38. The compressed document still carries the full change history — Automerge does not drop history. +39. Compaction copies every block the clock reaches into the new CAR, so a newcomer still downloads the history. +40. Docs still call compaction planned, but the code has it behind an undocumented experimental_compaction shape param (default false), so by default a newcomer replays the snapshot plus every change since the shape was created. +41. Not wall-clock LWW: a concurrent Y.Map set is won by the writer with the higher random clientID, whatever the times. +42. The winner is chosen by operation id (counter, then actor), not wall-clock time; getConflicts returns the losing values. +43. compareTransactions returns 0 for equal madeAt from different sessions, so a same-millisecond tie falls to arrival order. +44. A whole-object put() overwrites concurrent property updates; where().modify() is re-run on the server. +45. Order comes from each writer's machine clock (+new Date): a write stamped in the future is held in a setTimeout until then and wins; equal states break by comparing JSON strings. +46. A change stamped more than 5 minutes ahead of the receiver's clock is quarantined until system time catches up. +47. Merge-conflict handling isn't implemented yet (#253): concurrent events simply apply in the backend's order. +48. The client never merges: it holds its checkpoint while uploads are pending, then adopts the server's checkpoint exactly; plain UPDATEs in the backend give per-field LWW by arrival order. +49. No field-level merge: the server re-runs each mutator against current Postgres state, so concurrent writes to a column are last-processed-wins unless your mutator checks. +50. The FAQ still describes per-attribute HLC timestamps from 0.x. In 1.x the server stamps each incoming batch with its own clock when it arrives, so an edit made offline hours ago overwrites newer values when it finally syncs. +51. Order is server arrival, not a timestamp: an offline write replayed on reconnect overwrites newer writes made meanwhile. +52. Ops written before any node was registered, and compacted since, cannot reach a host (UNDELIVERABLE). +53. Reconnect re-sends only queued messages held in memory and live gets; there is no persisted outbox, and src/websocket.js:5 still reads 'TODO: resync upon reconnect'. +54. Writes queue only while connecting (disconnectTimeoutMs); docs-vs-code: the shipped client also accepts writes in needs-auth, which the docs list as rejected. +55. A withheld op would leave a gap in its origin's stream, so a host that skips an op still relays it and every node re-checks it; a throwing beforeApply refuses the whole page instead. +56. A write by a non-writer is stored and relayed, then ignored by every reader (markInvalid); content checks need a server worker. +57. middleware on the server's own MergeableStore guards only that copy; WsServer still relays the message to the other clients. +58. A rejected incoming entry is caught and only console.error'd; the app gets no event for it. +59. Whoever can write the directory can rebuild the chain; a read checks a record against the block its file names, so a block planted beside the chain passes. The ops in the log are what is signed. +60. The default 'verify' takes an unsigned op; sync.signatures: 'require' refuses it. A bootstrap re-signs the host's state under the joiner's key, and hello does not yet prove possession of the key. +61. Only data under a user's ~pubkey soul is signed and verified; the public graph accepts unsigned writes from anyone. +62. The RSA key pair that unseals the records sits unprotected in <path>/keys/, beside the data. +63. The browser keeps the account secret in localStorage by default, so this protects data from the server, not from someone with the device. +64. The free plugin is AES-CBC in crypto-js's passphrase mode (MD5 key derivation, no MAC); an unsalted SHA-256 of the password is stored beside the data. +65. The Loro team says not to rely on it for production-grade security yet. +66. The encryption wrapper sits below collection.storageInstance, which is what the replication reads. +67. Needs the encryption middleware moved above sync by hand; indexed fields are not encrypted. +68. MetaGateway.put attaches the ledger's key material to every meta update, so the sync service holds the key although the docs call sync end-to-end. +69. In 4.0.0 the documents type destructures encrypt instead of encryption, so document stores silently stay unencrypted (issue #1253, fix PR #1257 open). +70. Peer ids are self-asserted in the WebSocket join message, so access by peer id needs authentication at the socket. +71. RxServer is SSPL and its defaults are open: with no authHandler every request is admitted, and the Express adapter's CORS reflects any origin with credentials. +72. CouchDB has no per-document read rules; the usual pattern is a database per user. +73. Anyone who knows a database address can replicate and read it unless it is encrypted. +74. Electric serves any data its Postgres role can read to any caller; ELECTRIC_SECRET must be added by a proxy that also pins the shape's table and where. +75. A rule that is not set evaluates to true: everything is open until you write rules. +76. The chain keeps an unsalted SHA-256 of every version of the record, which can confirm a guessed record. +77. GC clears the document state only; update logs kept by providers or peers hold the payload until they compact (y-indexeddb merges after 500 updates). +78. A shallow snapshot only syncs with peers past its start version, and other replicas keep their full history. +79. A removed document keeps its data in the tombstone until cleanup (default 31 days, after replication is in sync). +80. Purges do not replicate; other adapters return an error. +81. deleteOwner and deleteDatabase call todo(); rows can only be soft-deleted (isDeleted) and stay in evolu_history. +82. Without compaction a deleted row's earlier entries stay in the shape log on the Electric server (and in HTTP caches) and are replayed to new clients until the shape is deleted (DELETE /v1/shape) or rotated. +83. No tracing and no OTLP push to your own collector, yet the self-hosted service pushes anonymous metrics over OTLP to pulse.journeyapps.com unless telemetry.disable_telemetry_sharing: true. +84. zero-cache also sends anonymous usage telemetry to Rocicorp by default (ZERO_ENABLE_TELEMETRY=false or DO_NOT_TRACK=1 to opt out). diff --git a/docs/guide/hooks.md b/docs/guide/hooks.md index 5945738..1c1c5d3 100644 --- a/docs/guide/hooks.md +++ b/docs/guide/hooks.md @@ -109,10 +109,11 @@ same path takes an op that would leave the record invalid under this node's [schema](./storage#validation): skipped, logged, relayed, and reported through the `error` event rather than a hook. -`applyRemote` reports it: `{ applied, skipped, duplicates, gaps, malformed, unsigned, forged }` — +`applyRemote` reports it: `{ applied, skipped, duplicates, gaps, malformed, unsigned, forged, outOfScope }` — `applied` counts every accepted op, skipped ones included; `applied - skipped` reached a record; `malformed`, `unsigned` and `forged` were refused before -anything read them. +anything read them, and `outOfScope` (a host's push) because the record they +write is outside the pushing peer's scopes. A throwing `beforeApply` aborts the batch before anything is durable. The sender sees a failed push (on the dialing side its `error` event fires, with @@ -166,7 +167,7 @@ const storage = await open({ ## Events Every listener is contained: it is invoked at once, never awaited, and if it -throws the failure is logged at `debug` (`{ event: 'listener.error', name }`) +throws the failure is logged at `warn` (`{ event: 'listener.error', name }`) and the operation carries on. `error` with nobody listening is silent rather than a throw. @@ -175,10 +176,11 @@ than a throw. | Event | Payload | When | | --- | --- | --- | | `change` | `(op, via)` | Every op applied here; `via` is `null` for a local write, otherwise `{ url, node }` of the peer it came from (`url` null on the hosting side) | -| `apply` | `({ via, peer, ops, applied, skipped, duplicates, gaps, malformed, unsigned, forged })` | A peer's batch is durable — `ops` are the accepted ones, skipped included | +| `apply` | `({ via, peer, ops, applied, skipped, duplicates, gaps, malformed, unsigned, forged, outOfScope })` | A peer's batch is durable — `ops` are the accepted ones, skipped included | | `conflict` | `({ kind: 'unique', entity, fields, id, other, op })` | A peer's op left a unique key shared by two records — see [Queries](./storage#queries) | | `schema` | `({ kind: 'drift', … })` / `({ kind: 'ahead', op, held })` | A peer runs this node's schema version with another snapshot hash — see [Schemas](./sync#schemas); a peer's op is from a newer version — see [Reading across versions](./storage#reading-across-versions) | -| `error` | `(error)` with `error.op`, and `error.rejected` or `error.entity` + `error.issues` | A peer's op refused by the field-kind rule, or skipped as invalid under this node's [schema](./schema#how-a-write-is-validated) | +| `error` | `(error)` with `error.code`: `error.op`, and `error.rejected` or `error.entity` + `error.issues`; `error.count` for a signature; `error.ids` for `UNPERSISTED` | A peer's op refused by the field-kind rule (`OP_REFUSED`), skipped as invalid under this node's [schema](./schema#how-a-write-is-validated) (`OP_INVALID`) or because its record's file cannot be read (`OP_UNREADABLE`), stamped beyond `sync.maxSkew` (`SKEW`); a page's ops refused for their signature (`BAD_SIGNATURE`, `UNSIGNED`); logged ops whose record files could not be written (`UNPERSISTED`) | +| `log` | `({ kind: 'roll', sealed, dropped, floor, segments })` | The op log sealed a segment, and compacted `dropped` of them — see [The log](./sync#the-log) | | `close` | — | After `close()` sealed the log | A `Record` emits `update` `(data, delta)` on a staged assignment and when the @@ -192,10 +194,11 @@ record changes in storage (`null` data once deleted). | `node` | `({ url, action })` | The registry changed: `'add'`, `'remove'`, `'enable'`, `'disable'` | | `connect` | `({ url })` | A link was accepted, before hello | | `peer` | `({ url, connected, node?, reconnected?, schema? })` | Hello answered (with the host's `node` id and `schema`), the link closed, or it came back | -| `sync` | `({ url, pushed })` / `({ url, received, applied, skipped, duplicates, gaps, malformed, unsigned, forged })` | A push went out / a page came in | +| `sync` | `({ url, pushed })` / `({ url, received, applied, skipped, duplicates, gaps, malformed, unsigned, forged, outOfScope })` | A push went out / a page came in | | `bootstrap` | `({ url, records })` | A host's whole state was joined | -| `error` | `(error)` with `error.url` | A failure on the way to a peer — a refused link, a failed push, a hook that threw | +| `error` | `(error)` with `error.url` | A failure on the way to a peer — a refused link, a failed push, a hook that threw, a page that failed (`PAGE_FAILED`, `STUCK`), a host that holds more of this node's ops than its log (`REWOUND`) or lacks ones its log no longer holds (`UNDELIVERABLE`) | | `hello` | `({ node, scopes, schema })` | Hosting side: a peer greeted this node and was authorized | +| `refused` | `({ node, reason, count })` | Hosting side: a push carried ops this node would not take — `'scope'` (outside the peer's scopes), `'forged'`, `'unsigned'` | ## `HookError` diff --git a/docs/guide/query.md b/docs/guide/query.md index 50ee014..eff2e03 100644 --- a/docs/guide/query.md +++ b/docs/guide/query.md @@ -167,7 +167,7 @@ stop(); The first run is a microtask after `watch()`; changes that land while a run is in flight coalesce into one more run. A failing run — the query, or the -listener — goes to `onError`, or to the logger at debug. Watching keys off +listener — goes to `onError`, or to the logger at warn. Watching keys off the catalog, so a cursor over `Task` never wakes for a `Tag`. ## Unique keys diff --git a/docs/guide/scaling.md b/docs/guide/scaling.md index a10f073..d1daee5 100644 --- a/docs/guide/scaling.md +++ b/docs/guide/scaling.md @@ -114,10 +114,10 @@ Two tabs are two instances, and `open()` is exclusive per directory wrpc's `WrpcClientProxy` holds one WebSocket, and every tab's `connect(url, { worker })` goes through it — but the storage is not: a proxied connection does not stop two storages from minting the same -sequence number. For v1, one tab syncs (take the lock with `lock: 'wait'` -and let the others work read-only, or route writes to it through a -`BroadcastChannel`); a storage served from a worker to its tabs is the -follow-up. +sequence number. For v1, one tab owns the storage and syncs: the others +wait for the lock (`lock: 'wait'`) or route reads and writes to it through a +`BroadcastChannel` — there is no read-only open. A storage served from a +worker to its tabs is the follow-up. ## End-to-end encryption diff --git a/docs/guide/storage.md b/docs/guide/storage.md index 821acc9..069f89c 100644 --- a/docs/guide/storage.md +++ b/docs/guide/storage.md @@ -62,8 +62,11 @@ writer wins. A field created with `inc()` is a *counter* — increments add up across nodes. A field created with `add()` is a *set* — concurrent adds both keep, and an add a remove did not see survives it; it reads sorted by element text. A field is one kind only: `update()` on a counter or a set, `inc()` on a -register or a set, `add()` on a register or a counter throw. See -[Sync](./sync#registers-counters-and-sets). +register or a set, `add()` on a register or a counter throw. The fields are +the top-level ones: a nested object is one register, replaced whole by +whichever write is later — two nodes that change different keys of it +concurrently keep one side — and `update({ 'a.b': 1 })` writes a field named +`a.b`. See [Sync](./sync#registers-counters-and-sets). ::: ### Compare-and-swap @@ -164,7 +167,9 @@ const b = await open({ path: 'data', lock: false }); // yours to coordinate Two browser tabs are two instances: with the default `'fail'` the second tab sees `LOCKED` and must work through the first (a SharedWorker that owns the -storage, say) or read-only. `close()` releases the lock; a crash releases it +storage, say), or wait for it with `lock: 'wait'`. There is no read-only +open: `lock: false` gives a second instance that writes too (its catalog, +and the log it seals on close), which is yours to coordinate. `close()` releases the lock; a crash releases it in the browser and leaves a stale file elsewhere, which the next open takes over. The lock is per machine — on a shared network filesystem, coordinate yourself and pass `lock: false`. @@ -214,7 +219,8 @@ included, and `dirty` puts since the last save), the log (`length`, `floor`, ### Repair A record file that fails its integrity check — its data no longer hashes to -what its chain block recorded — rejects every read with `code: 'INTEGRITY'`, +what its chain block recorded, or the file no longer parses (a write torn by +a crash) — rejects every read with `code: 'INTEGRITY'`, and stays that way: nothing repairs it behind your back, because a file that changed under the chain is evidence first. When you have looked, `storage.repair(id)` rebuilds the record from this node's own log: the @@ -325,7 +331,13 @@ await storage.Post.delete(post.id); ``` `entities` may be a plain object or a `Map`. Without a schema the storage has -no collections and you address records by id only. +no collections and you address records by id only. An entity named like +something the storage already answers to (`get`, `close`, `log`, `sync`…) +would replace it, so `open()` refuses it with a `TypeError`. + +A collection answers for its own records: `get(id)` of a record of another +collection is `null`, and `update()`, `delete()` and `record()` reject with +`code: 'NOT_FOUND'` — the storage's own methods address any record. An entity definition is `{ schema?, idField?, validate?, index?, unique?, fields? }`; `{}` is a bare collection. `schema` validates the records ([below](#validation)), diff --git a/docs/guide/sync.md b/docs/guide/sync.md index 5c1c720..f8be4e6 100644 --- a/docs/guide/sync.md +++ b/docs/guide/sync.md @@ -171,7 +171,8 @@ recognised as foreign rather than mistaken for a position. highest sequence number applied per origin. Sequence numbers run per origin *and scope*, so a node syncing one scope sees no holes; the key is then `origin@scope`. Ops from one origin are applied in order; an op that skips -ahead is set aside until the ones before it arrive. +ahead is a gap: it is dropped and counted (`gaps`), and comes again when the +stream is read from the last position that landed. Retention bounds the log: `retention.ops` (default 10 000) and `retention.days` drop the oldest sealed segments, and `floor` moves up. A @@ -179,6 +180,25 @@ peer whose cursor fell below the floor cannot be resumed — it is told to [bootstrap](#bootstrap) instead. Size retention for the longest outage a peer should be able to sit out. +A node's **own** ops are the exception, because nobody else can hand them to +a host. A node that dials (it has `sync.connect`) keeps every op of its own +until each enabled registered node in its scope has it — `listNodes()` shows +how far each one holds them as `acked`, saved with the registry — so a node +that writes offline for longer than retention still delivers every write +when it reconnects. Only registered nodes are waited for: ops written before +the first `addNode()` are retained like any other, and a host that lacks +ones the log no longer holds is reported once per hello with +`code: 'UNDELIVERABLE'` (those ops are not sent — every one would be a gap +there). Register the node before going offline. A node that is never +reached holds its ops back until it is removed or disabled. + +A directory restored from an older copy is noticed at the next hello: the +host holds more of the node's own stream than its log does. The node reports +`code: 'REWOUND'`, moves its streams past what the host holds — so nothing it +mints collides with an op the host already has — and bootstraps from the +host to get back what the lost ops wrote. Writes made between the restore +and that hello reused seqs the host had, and are lost to it. + ## Signatures Every op a node makes is signed with its Ed25519 key, and the node's id **is** @@ -352,6 +372,12 @@ const storage = await open({ await storage.sync.addNode({ url, scopes: ['Doc'] }); // Doc records, plus everything global ``` +An op belongs to the scope of the record as it leaves it: a `write`'s own +data, a `set` (an `update()`) merged onto the record it changes, the record's +for an `inc`, `add`, `remove`, `delete` or `purge` — and read after the +validator, so a scope field a default fills counts. An `update()` that does +not name the scoped field is still in the record's scope. + An unscoped record (scope `'*'`) reaches every peer. On a wrpc host the subscriber joins the room `syncom:` for each scope it asked for, so `server.rooms.count('syncom:Doc')` and the cluster's `fetchClients({ room })` @@ -362,7 +388,16 @@ On the hosting side the scopes a peer named in `hello` are a **boundary**, not a filter. A `push` carrying an op of another scope has that op refused — counted as `outOfScope` in the answer, announced through the `refused` event, never applied or logged; the peer's other streams are untouched, since -sequence numbers run per scope. `snapshot` and `changes` serve the +sequence numbers run per scope. The scope an op declares is only the peer's +word, so the host also scopes the record it writes, with its own +`sync.scope`, before the op and after it: a peer may write only records it may +read. An op that would touch a record outside its scopes — a `set` declared +global on a `Doc` record, a `write` that would move one into the peer's own +scope, a new record whose data puts it in another — is refused the same way +(`outOfScope`, `refused` with `reason: 'scope'`), and so is one the host's +scope function throws on. That refusal is a gap in the op's stream at this +host: the peer's later ops of that scope stop there too, which a peer that +only writes what it may read never meets. `snapshot` and `changes` serve the intersection of what is asked and what was granted: asking for `'*'` or for nothing is asking for the granted list, asking only for other scopes is refused with code **403** (`reason: 'SCOPES'`). A peer granted `'*'` is bound by nothing. @@ -533,7 +568,25 @@ the engine's own tests run on. - **Logged means applied, eventually.** The record files are written after the log; a reopen replays the unsealed tail onto them, and the log seals a batch only once its records were written — a crash between the two, or a - record write that failed, leaves the batch in the tail until it lands. + record write that failed, leaves the batch in the tail until it lands. The + append is the commit point: a write whose record file (or chain block) + fails after it still stands and replicates, its record is held in memory so + reads see it, and the storage reports `code: 'UNPERSISTED'`. +- **A node's own ops are kept for the nodes it dials**, until they have them + — see [The log](#the-log). Ops written before any node was registered, and + compacted since, cannot reach a host (`UNDELIVERABLE`). +- **A nested object is one register.** Merging is per top-level field: two + nodes that change different keys of one nested object concurrently keep + one object, the later writer's. `update({ 'a.b': 1 })` writes a top-level + field named `a.b`, not a path. Keep fields that change independently at the + top level. +- **A host checks, every node decides.** A host that skips a peer's op — its + validator fails it (`OP_INVALID`) or `beforeApply` returns `false` — still + logs and relays it: a withheld op would be a gap in its origin's stream for + every node behind the host. Each of them runs its own validators and hooks + on it. What stops at the host is an op outside the peer's scopes, a + malformed or skewed one, a signature refused, and a `beforeApply` that + throws (the whole page is refused, and offered again). - **fsync is opt-in.** Files are written with the platform's default durability; `open({ durable: true })` adds an fsync after every write on a backend that has one (`node:fs`). On OPFS there is no rename either, so a diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index 0c3c905..ec6414c 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -64,7 +64,7 @@ wrpc's spans around the actual calls. | Span | When | Attributes | | --- | --- | --- | | `syncom.commit` | one local write, from the moment it runs to after the `change` listeners | `syncom.op.type`, `syncom.record.id`†, `syncom.result` (`applied` / `noop` / `encrypted`), `syncom.scope` | -| `syncom.apply` | one batch of a peer's ops | `syncom.ops.count`, `syncom.ops.applied`, `syncom.ops.skipped`, `syncom.ops.duplicates`, `syncom.ops.gaps`, `syncom.ops.malformed`, `syncom.ops.unsigned`, `syncom.ops.forged` | +| `syncom.apply` | one batch of a peer's ops | `syncom.ops.count`, `syncom.ops.applied`, `syncom.ops.skipped`, `syncom.ops.duplicates`, `syncom.ops.gaps`, `syncom.ops.malformed`, `syncom.ops.unsigned`, `syncom.ops.forged`, `syncom.ops.out_of_scope` | | `syncom.replay` | the unsealed log tail replayed at open | `syncom.ops.count`, `syncom.records` | | `syncom.push` | one push to a peer, all its pages | `syncom.push.reason` (`handshake` / `manual` / `relay`), `syncom.ops.count`, `syncom.pages`, `syncom.peer.url`†, `syncom.peer.node`† | | `syncom.bootstrap` | the whole snapshot walk from a peer | `syncom.peer.url`†, `syncom.peer.node`†, `syncom.pages`, `syncom.records` | @@ -212,9 +212,10 @@ alert to key on: | warn | `sync.op.malformed`, `sync.op.skew` | a peer's op refused before it was read; the latter also an `error` event (`SKEW`) | | warn | `sync.op.forged`, `sync.op.unsigned` | a page with ops refused for their signature, once per page with the count; also an `error` event (`BAD_SIGNATURE`, `UNSIGNED`) | | warn | `sync.op.rejected`, `sync.op.invalid`, `sync.op.unreadable`, `sync.op.encrypted`, `sync.op.lens` | a peer's op not applied here, by reason | -| warn | `sync.op.refused`, `sync.op.outOfScope`, `sync.hello.refused` | hosting side: ops outside a peer's scopes, a call refused by code | -| warn | `sync.gaps`, `sync.error`, `sync.outbox.dropped`, `sync.record.malformed`, `sync.record.skew`, `sync.record.encrypted` | a page with gaps, a failure on the way to a peer, a dropped backlog, a snapshot record left out | +| warn | `sync.op.refused`, `sync.op.scope`, `sync.hello.refused` | hosting side: ops outside a peer's scopes (`sync.op.scope`: the scope function threw on a pushed record, and the op was refused), a call refused by code | +| warn | `sync.gaps`, `sync.push.gaps`, `sync.error`, `sync.outbox.dropped`, `sync.record.malformed`, `sync.record.skew`, `sync.record.encrypted` | a page with gaps (received, or answered by the host to a push), a failure on the way to a peer (`REWOUND` and `UNDELIVERABLE` among them), a dropped backlog, a snapshot record left out | | warn | `log.epoch.new` (when a log was there), `log.segment.unreadable`, `log.open.unreadable` | the log changed epoch or lost a file | +| warn | `storage.unpersisted` | logged ops whose record files could not be written, held in memory until the next open; also an `error` event (`UNPERSISTED`) | | warn | `catalog.skipped`, `catalog.lens`, `catalog.save`, `catalog.search`, `snapshot.skipped`, `replay.skipped`, `record.shadowed` | derived state could not follow a record (`catalog.search`: the injected text index threw); a field shadowing a method | | warn | `listener.error`, `watch.error`, `schema.drift` | an event listener or a watcher threw; two builds of one schema version | | warn | `identity.rekeyed` | a directory from before signing: `node.json` held a UUID, the node is now its signing key (once) | diff --git a/docs/guide/vs-rxdb.md b/docs/guide/vs-rxdb.md new file mode 100644 index 0000000..d80998a --- /dev/null +++ b/docs/guide/vs-rxdb.md @@ -0,0 +1,757 @@ + + +# Compared with RxDB + +[RxDB](https://rxdb.info) is the alternative closest to syncom. Both keep JSON records with a +schema on the device, query them with Mango selectors, watch them live, and replicate them without +a server of the project's own. They differ in what crosses the wire and in who decides the outcome +of a conflict. RxDB replicates the latest state of each document through checkpoints, and a +conflict is settled per document: the master's state wins by default, or your handler or its +CRDT plugin decides. syncom replicates every write as a signed operation and merges concurrent +writes field by field on every node, with counters that add up and sets that keep every +concurrent add. + +The two also stand in different places. RxDB has been on npm since 2016. It is a broad plugin +ecosystem with storages for every JavaScript runtime and replication plugins for a dozen backends, +and part of its storage layer is paid (RxDB Premium). syncom is a zero-dependency pre-release +(0.0.1, not on npm yet), MIT from end to end, and much narrower. + +This page compares the two feature by feature, every row decided from source code: 300 +rows in eight sections. [Compared with alternatives](/guide/comparison) puts both next to seventeen +other libraries, and [Benchmarks](/guide/benchmarks) measures them on the same scenarios. + +## What the table shows + +- **RxDB is the broader toolkit.** It has storages for every JavaScript runtime, replication + plugins for nine backends and handlers that sync with one you already run, a richer + write API (bulk writes, `modify()`, Mongo update operators, update by query), sorted compound + indexes, live queries that update without re-running (EventReduce), attachments, local + documents, storage migration, backups, several tabs on one database, document types inferred + from the schema, and React, Vue, Angular and signal bindings. In 51 rows RxDB has something + where syncom has nothing; the rows are not equal in weight, so read the sections that matter to + you. +- **syncom is the stricter replicator.** Every write is an operation that merges field by field + on every node, with no plugin to turn on: concurrent edits to different fields both stay, + counters add up, sets keep every concurrent add. Every op is signed by the key that is the + node's id, a hash-linked chain re-checks what is read, `repair(id)` rebuilds a record from the + log, and `purge()` erases a payload on every replica it reaches. Any node hosts, a browser + included; the host limits, validates and reports. In 20 rows syncom has something where RxDB has + nothing: signatures, a tamper-evident log, unique keys, multi-entry and combined indexes, + projection, text search inside a query, OpenTelemetry, stats among them. +- **Several RxDB guarantees depend on a switch.** Without a `validate-*` storage wrapper, + production RxDB stores documents with wrong types, unknown fields and missing required fields. + `final` fields, frozen documents and the checks on queries and ids run only in dev-mode, and + dev-mode in turn loads a hidden iframe from rxdb.info on any host that has no Premium flag. syncom has no such mode: + validation is on for every entity with a schema, and the checks and full error messages are + always on. +- **What is free.** In RxDB, disk persistence on Node.js (filesystem, SQLite), OPFS, native + IndexedDB, WebCrypto encryption, full-text search, the worker storages and the logger are + Premium, a yearly term license. The free core caps open collections at 13 per process (a 14th + waits, a 15th throws), and its SQLite trial fails at the 251st serial insert. Everything in syncom is MIT, Node.js disk + persistence and full-text search included. +- **Conflicts are settled differently.** RxDB's default handler keeps the server's document, + whatever its time (several RxDB pages call it last-write-wins); a handler of your own, or the + `crdt` plugin, merges instead. That plugin keeps every operation inside the document forever, + orders concurrent ones by a random storage token, and `upsert()` writes around it. syncom's + rules are fixed and cannot be replaced, a nested object is a single register, and a concurrent + overwrite that loses is not reported. +- **Encryption ends at the device in both.** RxDB's free plugin is AES-CBC through crypto-js + with an MD5 key derivation and no MAC, stores an unsalted hash of the password, and replicates + encrypted fields in plain text. syncom seals a record with AES-GCM under an RSA key, but that key + sits in the same directory, and a sealed record never syncs. Neither has end-to-end encryption + built in or rotates a key. +- **Where syncom's checks stop.** A host that skips a peer's op (a failed validator or + `beforeApply`) still relays it — a withheld op would be a gap in its origin's stream — so the + peers behind it decide for themselves; what stops at the host is an op outside the peer's + scopes (by the scope it declares or by its record's), a malformed or skewed one and a refused + signature. A nested object merges as one register. Writes made offline before the first node + was registered, and compacted since, cannot reach a host (it is reported). Making this page + turned up a dozen syncom bugs, among them scopes a peer could write around, offline writes past + retention that never arrived and a restored directory that forked its node in silence; they are + fixed, with tests, and the cells describe the code as it is now. +- **Neither has** collaborative text, multi-record transactions, aggregation or joins. +- **Maturity.** RxDB has been on npm since 2016, ships a minor about every month and a major + about once a year (four of the last five asked users of some storages to migrate their stored + data), and offers paid support. syncom is a pre-release with no users yet. + +## How the table was made + +- **RxDB 17.6.0, from its code.** The RxDB column is `rxdb@17.6.0`, the npm `latest` (released + 2026-10-05), read at its git tag. Every page of [rxdb.info](https://rxdb.info) was read too, all + 271 in its sitemap: the reference, the replication and storage pages, the release notes from 8.0 + to 17.0, and the articles and landing pages. So were the other repositories of the project: + `rxdb-server` (RxServer), `event-reduce`, `broadcast-channel`, `jsonschema-key-compression`, the + quickstart and example apps, and the issue tracker of the Premium plugins. +- **Checked by running it.** Where a claim was cheap to test, it was tested against the published + package (memory storage, with and without dev-mode). Those runs settled the collection limit, + the SQLite trial's caps, what production RxDB stores without a validator, and how the CRDT + plugin keeps its operations. Where the docs and the code disagree, the cell follows the code + and a footnote says so. +- **Premium is a feature, marked as paid.** RxDB Premium (Pro, Pro Plus) is closed source, so its + cells follow the docs and the Premium page. They are ⚠️, say `Premium` and name the tier. Where + the free core has a trial or a stub of a Premium plugin, the cell says what the free one does. +- **Changes after 17.6.0 are left out.** RxDB's main branch was 108 commits ahead of the tag when + this was written. Unreleased changes stay in the data as details, never in a cell's verdict. +- **The syncom column is this repository**, at the version on the page, checked against its tests + and by running it. Its gaps are in the table like RxDB's. +- **The data is in the repository.** + [`docs/comparison-rxdb.json`](https://github.com/Alexis-Technologies/syncom/blob/main/docs/comparison-rxdb.json) + keeps every cell with the file, line or page it was decided from. `pnpm docs:comparison` + renders it into this page. If a cell is wrong, open an issue or edit the data. + +## Concepts side by side + +The closest syncom counterpart of each RxDB building block. Where there is none, the table says so. + +| RxDB | syncom | +| --- | --- | +| `createRxDatabase({ name, storage })` | `open({ path, fsi })` | +| `RxStorage` (Dexie, memory, Premium OPFS…) | an `fsi`: five file functions (`node:fs`, OPFS, IndexedDB, memory) | +| `addCollections({ todos: { schema } })` | `open({ schema: { entities: { Todo: { schema } } } })` → `storage.Todo` | +| `primaryKey` field of the document | the record id, outside the data (`getId`, or `set(id, data)`) | +| `validate-ajv` storage wrapper | an entity `schema` (Standard Schema, a function, or `validator.compile()`), on by default | +| `version` + `migrationStrategies`, per collection | `schema.version` + `schema.migrations`, per storage: a lens on read, `migrate()` to rewrite | +| `RxDocument` | a live `Record` (`storage.record(id)`) or a JSON copy (`get(id)`) | +| `insert()` / `upsert()` | `insert()` (id generated) / `set(id, data)` | +| `patch()` / `incrementalPatch()` | `update(id, delta)`, or a `Record`'s fields and `save()` | +| a stale `_rev` throws 409 | `swap(id, changes, prev)`, compared inside the write queue | +| `$inc` (merges only with the `crdt` plugin) | `inc(id, field, n)`, merges always | +| `$addToSet` / `$pullAll` | `add()` / `remove()`: an observed-remove set | +| `remove()` → `_deleted`, then the `cleanup` plugin | `delete()` → a tombstone; `purge(id)` erases the payload from the log | +| `collection.find(query).exec()` | `collection.select(query).fetch()` | +| `findOne(id)` | `get(id)` / `record(id)` | +| `sort`, `skip`, `limit` | `order()` / `desc()`, `offset()`, `limit()` | +| schema `indexes` | entity `index`, and `unique` | +| `query.$` | `cursor.watch(listener)` | +| `doc.$` / `collection.$` | a `Record`'s `update` event / the storage's `change` event | +| Premium full-text search (FlexSearch) | `$text` with `createSearch()` from `@alexify/syncom/search` | +| `preInsert` / `preSave` / `preRemove` hooks | `beforeWrite(ctx)` | +| `postInsert` / `postSave` / `postRemove` hooks | `afterWrite(ctx, op)` | +| `replicateRxCollection({ pull, push })` | `storage.sync.addNode({ url })` over an injected link | +| RxServer, or your own endpoints | `storage.sync.unit()`, mounted on a wrpc server by any node | +| `conflictHandler` | none: fixed per-field rules; `beforeApply` can skip a peer's op | +| `pull.modifier` / `push.modifier` | `beforeApply` (a verdict, no rewrite) / none: scopes decide what leaves | +| RxServer `authHandler`, `queryModifier`, `changeValidator` | `unit({ authorize })`, scopes, the entity validators and `beforeApply` | +| schema `encrypted: ['field']` + `password` | `{ encrypted: true }` per record: sealed for this node, never synced | +| `multiInstance` + `leader-election` | one storage per directory (`lock`); `lock: 'wait'` hands it over | +| `RxError` with a `code` (messages in dev-mode) | `SyncomError` with a `code` (messages always) | + +## The matrix + + + + +✅ built in, or in an official package of the same project (named) · ⚠️ partial, opt-in, Premium (paid), with a caveat, or through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. The RxDB column is rxdb 17.6.0 (npm `latest`, October 2026), decided from its source code at that tag, from runs of the published package and from every page of rxdb.info; Premium plugins are closed source, so their cells follow the docs. The syncom column is this repository. + +### Package and platform + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Package and license
Version compared0.0.1 (@alexify/syncom)17.6.0 (rxdb), npm latest since 2026-10-05
Release statuspre-release, not on npm yetstable; on npm since 2016, 652 versions
License of the coreMIT, the whole packageApache-2.0
License of the server partMIT: hosting (sync.unit()) is in the same package⚠️ RxServer (rxdb-server) is SSPL; its Fastify and Koa adapters are Premium (Pro Plus)
Paid tiers and price— nonePremium: Pro from $99/month, Pro Plus from $239/month, billed yearly; Enterprise custom
What only Premium has— no split: every feature is in the MIT package⚠️ Pro: OPFS, IndexedDB, SQLite, filesystem storages, WebCrypto encryption, fulltext search; Pro Plus: worker, sharding, logger…
Premium license terms— MIT: no term, token or usage limit⚠️ yearly term license: use must stop when it lapses; not inside a library1
Open collections limit✅ none⚠️ free core: 13 open collections per JavaScript process; Premium lifts it2
Calls to the vendornone: no network code beyond the injected transport⚠️ dev-mode loads a hidden rxdb.info iframe; WebRTC defaults to signaling.rxdb.info; Premium install reports to the vendor3
Dependencies, build and size
Runtime dependencies✅ 0: devDependencies only24 direct plus the required peer rxjs: 74 packages installed
Language and module formatJavaScript, CommonJS, no build step; src/ ships as isTypeScript, built by Babel into CommonJS and ESM; sideEffects: false
Entry pointsroot plus 4 subpaths: /wrpc, /idb, /search, /contracts54 subpath exports, one per plugin (rxdb/plugins/*)
TypeScript types⚠️ hand-written .d.ts, tsd-tested; collection names typed, record data unknown✅ generated from the source; document type inferred from the schema
Browser bundle, minimal store41.9 KB min+gzip: the browser entry, the whole engine80.4 KB min+gzip: createRxDatabase with Dexie; 44.9 KB with localStorage
Browser bundle with sync42.6 KB min+gzip with the wrpc link adapter90.1 KB min+gzip with Dexie and replicateRxCollection
Size budget in CI✅ pnpm size fails CI when the browser entry passes 42 KB⚠️ CI prints a webpack build size; no limit in 17.6.0
Runtimes
Node.js✅ ≥22; persists with node:fs; CI on Node 22 and 24⚠️ ≥20; free disk persistence only via MongoDB or FoundationDB servers, or the capped SQLite trial4
Deno❌ not tested; engines names Node.js only✅ rxdb/plugins/storage-denokv; CI runs the Dexie and DenoKV suites on Deno 2.6.5
Bun❌ not tested⚠️ CI runs the memory-storage suite on Bun 1.4.2; no Bun-specific storage
Browser persistence✅ OPFS (default), IndexedDB (/idb), memory✅ free: Dexie (IndexedDB), localStorage, memory; native IndexedDB and OPFS Premium (Pro)
Browser requirementssecure context, OPFS, Web Crypto with Ed25519 and RSA-OAEPcrypto.subtle (secure context) or your own hashFunction; some bundlers need a global shim
Web Worker and SharedWorker🔜 a storage served from a worker to its tabs is the planned follow-up⚠️ Worker and SharedWorker storages are Premium (Pro Plus); the free core runs inside a worker
React Native❌ not a target: no React Native backend or Web Crypto shim⚠️ Premium SQLite or Expo Filesystem storage; the free SQLite trial is capped5
Expo❌ not a target⚠️ Expo Filesystem storage is Premium (Pro); free: expo-sqlite through the SQLite trial
Capacitor and Ionic❌ not tested; a Capacitor fsi adapter is yours to write⚠️ Premium SQLite via @capacitor-community/sqlite; free: localStorage or Dexie in the WebView
Electron❌ not documented or tested; no IPC bridge✅ rxdb/plugins/electron: storage in the main process, databases in renderers over IPC
Tauri❌ not documented or tested⚠️ SQLite storage with getSQLiteBasicsTauri(): Premium, or the capped free trial
Flutter❌ none⚠️ rxdb/plugins/flutter runs RxDB in QuickJS; its recipe needs the removed LokiJS storage6
Tabs and instances
Several tabs on one database⚠️ one tab per storage: open() holds a Web Lock, a second tab gets LOCKED7✅ multiInstance: true (default): every tab opens it; changes cross over BroadcastChannel
Leader election⚠️ no election API; lock: 'wait' hands the storage to the next tab✅ rxdb/plugins/leader-election: waitForLeadership(), isLeader(); Web Locks when available
+ +
+ +### Storage + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Backends
Storage contract✅ fsi: five file functions — read, write, exists, remove, list✅ RxStorage → RxStorageInstance: bulkWrite, Mango query, count, changeStream, cleanup…
Writing your own backend✅ five functions; tests/suites/fsi.js is the contract as a test✅ implement RxStorageInstance; the storage suite runs from a clone of the repo
In-memory✅ createMemoryFsi(): a Map of files; Node and browser✅ storage-memory (free): plain objects, binary-search indexes; Node and browser
IndexedDB✅ @alexify/syncom/idb createIdbFsi(): each file a row keyed [dir, name]✅ storage-dexie (free, Dexie.js); native storage-indexeddb is Premium (Pro)8
OPFS✅ the default browser backend; async createWritable(), main thread or worker⚠️ Premium (Pro) storage-opfs: in a worker (sync handles) or getRxStorageOPFSMainThread()
localStorage❌ none shipped; an fsi over it would be five functions✅ storage-localstorage (free): a key per document, a JSON array per index; browser
Node.js filesystem✅ the default under Node: node:fs/promises, plain JSON files⚠️ Premium (Pro) storage-filesystem-node; Node and Electron
SQLite❌ none⚠️ Premium (Pro) storage-sqlite; the free getRxStorageSQLiteTrial() is capped9
Expo / React Native files❌ none; not a target, an fsi is injectable⚠️ Premium (Pro) storage-filesystem-expo, beta since 17.0.0; needs expo-opfs
MongoDB as storage❌ none✅ storage-mongodb (free), Node; no attachments10
FoundationDB❌ none✅ storage-foundationdb (free), Node with the native client; one process per cluster
Deno KV❌ none✅ storage-denokv (free), Deno only11
LokiJS / PouchDB storages— no legacy backends❌ removed: PouchDB in 14.0.0; LokiJS and memory-synced in 16.0.012
Free-tier limits on storage✅ none: every backend is in the MIT package⚠️ SQLite trial capped; Dexie prints a Premium notice; no trial of other Premium storages
Wrappers and offloading
Stacking storage wrappers⚠️ an fsi can wrap an fsi (bytes only); document transforms are hooks✅ wrapRxStorageInstance: validation, key compression, encryption, attachment compression stack
Remote storage (IPC, WebSocket)❌ none; a peer replicates a copy instead✅ storage-remote over any message channel; storage-remote-websocket; Electron IPC
Sharding❌ none⚠️ Premium (Pro Plus) storage-sharding; schema sharding: { shards, mode }
In-memory layer over a disk store❌ none; cache.max entries and the catalog stay in memory⚠️ Premium (Pro Plus) storage-memory-mapped; memory-synced removed in 1613
What opening loadsthe catalog (every id, indexed values) and the retained log, into memorylazy per collection; Premium (Pro Plus) meta optimizer keeps internal metadata in localStorage
Durability and recovery
Write durability✅ log batch first, then the record; durable: true fsyncs each file (node:fs)14⚠️ the storage's job; contract: each document atomic, no batch transaction
Crash recovery on reopen✅ replays the log's unsealed tail; a torn newest batch is dropped⚠️ the storage engine's own (IndexedDB, SQLite transactions); no replay in the core
Exclusive open✅ Web Locks or lock.json (pid); 'fail' → LOCKED, 'wait', false15⚠️ no lock: tabs and processes share a store (multiInstance); single-process storages unguarded
Integrity check and repair✅ cold reads re-verified against the chain, torn files too (INTEGRITY); repair(id) replays the log16❌ none; attachment digests serve change detection only
Quota and eviction handling❌ none; past the log append a failed record file is held in memory and reported (UNPERSISTED)❌ none in the core; the storage's QuotaExceededError reaches the caller
Data on disk
On-disk format✅ plain JSON files: data/<id>.json, log batches, chain blocks, keys⚠️ per storage: JSON documents with _rev, _meta (Dexie, localStorage, SQLite, MongoDB); Premium formats undocumented
Files written per changethree per local write: log batch, chain block, record (two with chain: false)17one document row per write, plus the storage's index entries
Compression❌ none✅ key-compression wrapper (schema-derived short keys); attachments-compression (gzip/deflate)
In-memory document cache✅ cache: { max: 5000 } entries; a live Record pins its own (WeakRef)✅ DocumentCache per collection: RxDocuments by revision, held by WeakRef
Attachments / binary data❌ none: JSON records only, no binary type✅ attachments plugin: Blobs per document, putAttachment(), getData()18
Local (non-replicated) documents⚠️ only encrypted records stay local; no plain local documents✅ local-documents: insertLocal(), getLocal$(); not replicated, no schema
Tombstone cleanup❌ none: a tombstone keeps its file (data emptied) and catalog row✅ cleanup plugin: purges tombstones after 31 days, once replications are in sync
Delete a whole database❌ no API; remove the directory yourself✅ db.remove(), removeRxDatabase(name, storage), collection.remove()
Moving data
Migrate between backends❌ no tool; the same JSON files on every backend✅ migrateStorage() (free): moves live documents batch by batch into a new database19
Backup to files❌ no API; the Node directory is plain JSON, copy it closed20⚠️ backup plugin: Node only, one-time or live, a document.json per document; no restore
JSON export / import⚠️ snapshot() pages out records with meta, tombstones too; mergeSnapshot() joins them✅ json-dump: exportJSON() / importJSON(), schema-hash checked, attachments optional21
+ +
+ +### Data model and writes + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Documents and collections
Data modelJSON records addressed by id; collections optional ($type)JSON documents; every collection has a JSON Schema
Records without a schema✅ no schema needed; {} is a bare collection⚠️ schema required; an open type: 'object' sub-field, RxState or local docs
Collection names✅ any non-empty string the storage does not answer to (get, close… refused at open())✅ never shadows database members; regex checked in dev-mode
Field names✅ any JSON key; __proto__ ignored, $type reserved⚠️ letters, digits, _; no leading _; no RxDocument member names
Identity
Primary key✅ the record id, outside the data; the validator sees it under idField✅ a top-level string field named by primaryKey (_id allowed)
Composite primary key❌ none; a composite unique key instead✅ primaryKey: { key, fields, separator }, filled on insert
Id generation✅ getId: UUID by default; sync or async generator of your own❌ the caller supplies the key; a composite key is derived
Id format rules✅ [A-Za-z0-9_@-][A-Za-z0-9._@-]{0,127}, checked on every write and peer op⚠️ string with maxLength; values checked only by a validator and dev-mode22
Immutable fields⚠️ only $type (the collection) and the id⚠️ final: true; the primary key is final — enforced in dev-mode only23
Schema and validation
Schema format✅ none of its own: a Standard Schema, a function, or validator.compile()✅ JSON Schema (draft-04 subset) plus primaryKey, indexes, final, ref
Validators✅ whatever you inject: metaschema, zod, valibot, arktype, ajv, TypeBox; none bundled✅ validate-ajv, validate-z-schema; validate-is-my-json-valid unsupported24
Validation on by default✅ on for every entity with a schema; validate: false opts out⚠️ off until the storage is wrapped; dev-mode refuses to start without it25
Validator can transform data✅ its value is written: defaults, aliases, stripped keys, decoders❌ validators return errors only; the data is written as given
Validate without writing✅ storage.check(entity, data) returns the validated value⚠️ getValidator(schema) from the validate plugin; no collection method
TypeScript types of the data❌ records are unknown; only collection names are typed✅ ExtractDocumentTypeFromTypedRxJsonSchema from an as const schema; typed collections
Default values✅ from the validator (metaschema default, zod .default()), folded into the op⚠️ default on top-level fields, filled by insert and upsert
Required fields✅ as the validator declares them✅ JSON Schema required; the primary key always
Nested objects⚠️ stored and validated; merged as one top-level register26✅ nested schemas; dotted paths in update operators and queries
Schema versions and migration
Schema versions✅ schema.version on every op (v) and record (meta.v); snapshot hash✅ version per collection; a changed schema without a bump throws DB6
Migrating stored data✅ lens on read (migrations, then the validator); migrate() rewrites, idempotent✅ migrationStrategies per version, at open; returning null drops a document
Old data before migrating✅ read through the lens; writes allowed❌ invisible until migrated; writes throw COL25
A peer on another schema version✅ the op carries v; lens for older, ahead: 'apply' | 'hold' for newer⚠️ no version on documents; run the same migration on server and clients
Writes
Bulk writes❌ none; one op per call (a batch is Promise.all of insert())✅ bulkInsert, bulkUpsert, bulkRemove with per-document success and error lists
Upsert✅ set(id, data) replaces the record wholesale; Collection.set()✅ upsert(), bulkUpsert(), incrementalUpsert()
Insert only if absent❌ none; has() then set() is two calls✅ insertIfNotExists() returns the existing document; insert() throws 409
Update some fields✅ update(id, delta); record.field = v then save()✅ patch(), incrementalPatch()
Remove one field❌ only by a whole-record set(); undefined in a delta is dropped✅ patch({ field: undefined }), $unset
Update by function❌ no modify(fn); get() then swap() in a retry loop✅ modify(fn); incrementalModify(fn) re-runs on the latest state
Update operators⚠️ typed ops only: update, inc, add/remove; no $push, $min, $rename✅ update plugin (mingo): $set, $inc, $push, $addToSet, $pullAll, $min…
Update or delete by query❌ none; select().fetch(), then one write per row✅ find().update(), .patch(), .modify(), .remove()
Compare-and-swap✅ swap(id, changes, prev) compares field values inside the write queue✅ every non-incremental write checks _rev; a stale one throws 409 CONFLICT
Concurrent writes on one node✅ one mutation queue; no local conflict errors⚠️ a stale patch/modify/update throws 409; the incremental* methods retry
Multi-record transactions❌ none; every write is one op on one record❌ none; one document write is the atomic unit
Change and delete in one write❌ delete() carries no data; two ops✅ set _deleted: true inside modify() or update()
Counters, sets and lists
Counters that converge✅ inc(): one running sum per origin27⚠️ $inc is local; it converges only under the crdt plugin
Sets✅ add()/remove(): observed-remove set, read sorted⚠️ $addToSet/$pullAll; crdt replays them in creator order, not observed-remove
Ordered lists that merge❌ no list type; an array is a register or a set⚠️ crdt plugin replays $push; no positional insert
Collaborative text❌ none❌ none
Merge model
Conflict-free model✅ built into every write: register, counter or OR-set per field⚠️ opt-in crdt plugin per schema; otherwise a conflict handler
Concurrent edits to different fields✅ both kept: last writer wins per top-level field⚠️ the default handler keeps the master's whole document; merging needs a handler or crdt
Who wins a concurrent overwritethe later stamp (ts, origin); a node's clock never falls behind what it sawcrdt: op position, then storage-token order, not time; otherwise the master
Where the merge history lives✅ op log beside the records, with retention; records keep per-field stamps⚠️ crdt: every op stored inside the document, never compacted28
Writes that bypass the merge model✅ none: every write is an op; mixing field kinds throws FIELD_KIND⚠️ modify() is refused, but upsert()/incrementalUpsert() replace the op history29
Conditional operations❌ none that replicate; swap() is a local check✅ crdt: selector with ifMatch/ifNotMatch, re-evaluated on every replay
Delta helpers✅ diff, merge, apply: pure functions; numbers add, arrays union30❌ none; mergeCRDTFields merges the plugin's own field only
Revisions, deletes and document objects
Per-record metadatameta: per-field [ts, origin], reset stamp, tombstone, counters, sets, v_rev (height-token, local), _meta.lwt, _deleted, _attachments
Delete✅ tombstone with empty data; a newer write revives the record✅ _deleted: true with the full data until cleanup; re-insert revives
Document objects✅ record(id): one live, editable Record per id, refreshed on any change✅ RxDocument: read-only snapshot per revision; getLatest(), $
Same object for the same record✅ one Record per id while referenced (WeakRef); get() returns new copies✅ one RxDocument per revision while referenced (WeakRef cache)
Editing returned data✅ JSON copies in and out; editing them changes nothing⚠️ deep-frozen in dev-mode only; production toJSON() shares nested objects31
Relations and higher-level APIs
ORM methods❌ none✅ statics, methods, attachments per collection
References between records⚠️ a Record turns any string equal to a known id into a Reference32✅ schema ref with populate() or the field_ getter; one or many
Persistent app state❌ none✅ state plugin (RxState): schemaless JSON, modifier writes, replicable
Derived collections❌ none; afterWrite, change and watch() are not checkpointed✅ pipeline plugin: leader-run handler, checkpointed, blocks the destination
Stored procedures⚠️ @alexify/syncom/contracts: arrow functions stored in the chain, run by new Function33❌ none
+ +
+ +### Queries and reactivity + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Query language and operators
Query language✅ Mango-shaped selector: collection.select(query), then cursor builders✅ Mango query { selector, sort, skip, limit, index }, matched by mingo
Chained query builder⚠️ cursor chains order(), desc(), limit(), offset(), filter(), projection(); the predicate is JSON only✅ query-builder plugin: where('f').gt(5).sort('-f').limit(n)
$eq $ne $gt $gte $lt $lte✅ all six; $gt…$lte hold only within one kind (number, string, boolean)✅ all six (mingo)
$in $nin $all⚠️ $in, $nin; no $all (an $and of equalities instead)⚠️ $in, $nin; no $all (an $and of equalities instead)
$exists $type⚠️ $exists only (null counts as present); no $type✅ $exists; $type (null, boolean, number, string, array, object)
$elemMatch $size⚠️ a scalar matches any element; no $elemMatch, no $size (filter() instead)✅ element match, $elemMatch (objects or scalars), $size
$regex $options✅ RegExp or source string, $options flags; 1000 characters at most; g/y dropped✅ string with $options; a RegExp instance is refused in dev-mode (QU16)34
$and $or $nor $not✅ $and, $or, and $not over a whole query; no $nor, no field-level $not✅ $and, $or, $nor; $not on a field only
$mod❌ not supported (filter() instead)✅ $mod: [divisor, remainder]
Arbitrary JS predicate✅ filter(fn) over the loaded row, applied before limit()❌ no $where or $expr; filter the results in app code
Nested fields (dotted paths)✅ in queries, order(), projection(), index and search; items.0.n reaches into arrays✅ dot notation in selectors, sort (tags.0) and indexes
Malformed query detection✅ unknown operator or bad argument: TypeError at select(); field names are not checked⚠️ unknown operators throw; field names, RegExp, undefined and extra keys only in dev-mode
Typed queries and rows⚠️ MangoQuery is untyped; rows are QueryRow (Record<string, unknown>)✅ MangoQuery<RxDocType> checks paths and values; results are typed RxDocuments
Immutable query objects❌ builders change the cursor in place (one() leaves it as it was)✅ every builder returns a new RxQuery
Test a document against a query❌ the compiled predicate is internal (compileQuery is not exported)✅ query.doesDocumentDataMatch(doc)
Reading results
Sort✅ order()/desc(): several keys, any field, dotted; stable✅ sort: [{ f: 'asc' | 'desc' }]: several keys, nested; primary key appended
Sort served by an index❌ no sorted index: every candidate is read, then ordered✅ an ascending sort that matches the index walks it and stops at skip + limit35
Top-k for an unindexed order✅ TopK keeps the best offset + limit rows as candidates stream by⚠️ sorts every match, then slices
Skip and limit✅ offset(n), limit(n); an unordered cursor stops reading at enough matches✅ skip, limit; findOne() refuses a limit (QU6)
Streaming cursor / keyset paging❌ for await yields an already fetched array; keyset paging by hand❌ exec() returns the whole page; keyset paging by hand ($gt on the sort key)
Count queries✅ count(query) for any query; from the catalog alone when every field is indexed⚠️ a selector no index satisfies throws QU14 on Dexie (every storage in dev-mode) unless allowSlowCount: true
Find by id✅ get(id) (a copy), record(id) (a live Record); hasRecord(id); a collection's get only its own✅ findOne(id): document cache first, then findDocumentsById()
Find many by id❌ no batch get; select({ id: { $in } }) reads the whole collection✅ findByIds(ids) → Map, observable like any query
First match✅ cursor.one() → the row or null✅ findOne(query); exec(true) throws when nothing matches
Projection (select fields)✅ projection(fields); answered from the catalog when they are indexed❌ always whole documents, by design; a fields key is QU11 in dev-mode
What a query returnsplain JSON copies { id, ...data }, the lens applied; new objects per fetch()RxDocument instances, one per revision, shared across queries
Aggregation and group-by❌ none; compute over fetch() rows❌ none, by design
Joins and cross-collection queries❌ a cursor reads one collection; Reference.record() follows one id❌ one collection per query; populate() follows one ref
Query on encrypted data❌ an { encrypted: true } record is listed, never a query row❌ encrypted fields cannot be queried; an $eq on one silently matches nothing
Indexes and planning
Secondary indexes✅ entity index: ['field', …], or read off a metaschema schema✅ schema indexes: ['f', ['a', 'b']]
Index kind⚠️ equality posting lists; ranges, prefixes and sorts do not use them36✅ sorted keys: $eq, $gt…$lte and $in (min to max) bound the scan
Compound indexes⚠️ only for unique keys; queries intersect single-field postings✅ any index is a field list; _deleted and the primary key are added
Array (multi-entry) indexes✅ an indexed array is posted under each element❌ refused in dev-mode (SC36); an RxPipeline into a mapping collection instead37
Indexable values✅ any JSON value, keyed canonically (object keys in any order)⚠️ strings with maxLength, numbers with minimum/maximum/multipleOf, required booleans
Where indexes livein memory (the catalog), saved to index/catalog.json, caught up from the log at openin the storage: IndexedDB indexes on Dexie (disk), sorted arrays on memory
Adding or changing an index✅ edit index; the catalog rebuilds once at the next open⚠️ a schema change: bump version and migrate, or addCollections() throws DB6
Query planner✅ indexed equalities narrow the candidates before any record is read✅ rates one key range per index, takes the best; index forces one
Several indexes in one query✅ postings intersect over $and, union over an $or whose branches are all indexed❌ one index per query; an $or scans a whole index
Index-only (covered) queries✅ query, order and projection all indexed: answered from the catalog, no record read⚠️ count() on an index-satisfied selector only; find() always reads documents
Plan inspection❌ no explain API; the plan is private to the cursor✅ query.getPreparedQuery().queryPlan: index, key range, sortSatisfiedByIndex
Index advisor❌ none⚠️ Premium (Pro Plus): findBestIndex(), run at build time on test data
Query cache❌ none; each fetch() re-runs, records come from the bounded entry cache✅ QueryCache: equal queries share one RxQuery; about 100 unobserved ones kept
Unique constraints✅ unique fields and composites; a local write is refused, a peer's clash is a conflict event❌ primary key only
Search
Full-text search✅ $text with createSearch() (@alexify/syncom/search): trigram TF-IDF, typo-tolerant⚠️ Premium (Pro): FlexSearch plugin, addFulltextSearch() then flexSearch.find(text)
Text search inside a query✅ $text combines with indexed equalities, order(), limit(), count(), watch()❌ flexSearch.find() is a separate call; no $text operator
Vector / similarity search❌ none⚠️ rxdb/plugins/vector: four distance functions; nearest-neighbour search is app code
Reactivity
Live queries✅ cursor.watch(listener): runs now, then after every change to the collection✅ query.$: the current result on subscribe, then each new result
Live update without re-querying❌ every wake re-runs the whole cursor✅ EventReduce derives the new result from the change events
Emits only when the result changed❌ the listener runs after any change to the collection, same rows or not✅ an unchanged result is not emitted
Live count❌ no; watch() hands rows (rows.length)✅ count().$, adjusted from each change event without a re-count
Collection change stream⚠️ storage-wide change event (op, via); no per-collection stream✅ collection.$, insert$, update$, remove$; database.$
Document observable✅ a live Record emits update (data, delta), null once deleted✅ doc.$, doc.get$(path) / doc.field$, doc.deleted$
Reactivity primitive⚠️ callbacks and events; no observable or signal adapter✅ RxJS observables ($); a reactivity factory turns them into signals or refs ($$)
Peer writes in live results✅ a peer's ops and snapshot pages wake watch(); a batch is one run✅ replicated writes go through the collection's event stream, so $ updates
+ +
+ +### Replication + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Model and protocol
Replication modeloperation log; version vectors per origin and scope; pages of opsdocument state: pull by checkpoint, push with assumedMasterState (fork and master)
What crosses the wireops as positional packOps arrays, signed; a set carries only its deltawhole documents with _deleted; a push row carries two whole states
Intermediate writes replicated✅ every retained op travels, intermediate writes included❌ latest state per document; intermediate writes collapse before a push
Protocol version check✅ PROTOCOL 1 in hello; another version is refused with 400❌ none in the protocol; checkpoint and payload are the backend's
Schema check at connect✅ hello swaps { version, hash }; same version, other hash emits schema drift⚠️ RxServer only: schema version in the URL; older clients get 426 (outdatedClient$)38
Sync with an existing backend❌ none: the far end must be a syncom node hosting unit()✅ any backend: pull, push and stream handlers over your own database
Writing your own transport✅ SyncLink: call, subscribe with lastEventId, on/off, close; optional stream✅ replicateRxCollection: pull.handler, push.handler, pull.stream$ with RESYNC
Round trips to catch uphello, then one push per 500 ops, then one changes subscriptionpulls until a short page; one push per 100 documents; a re-push per conflict
Collections in one connection✅ one link carries every collection of the storage, filtered by scope⚠️ one replication per collection, each with its own requests and stream
Sync state kept on the clienta cursor per peer, the version vector, the op log within retentionper replication: two checkpoints and the last master state of every document
Topology
Topologymesh: any node dials and hosts; hubs dial each othera star of fork–master pairs; WebRTC peers pair up in a mesh
Peers without a central server✅ any node hosts unit(); peers dial each other directly⚠️ WebRTC only; each pair has a hash-chosen master and a fork39
Multi-hop relay✅ ops relayed hop by hop; the version vector drops what a node has✅ through the local store: a second replication pushes what the first pulled
A browser serves data✅ unit() on a wrpc PeerHost over a data channel⚠️ only as the master of a WebRTC pair; other servers are Node
Scaling the server out⚠️ hubs dial each other; a client stays on one hub's log⚠️ processes share one storage; branch servers replicate to a root40
Peer-to-peer (WebRTC)
Browser to browser (WebRTC)✅ wrpc WrpcPeer/PeerLink and PeerHost (@alexify/wrpc/webrtc)✅ replication-webrtc (simple-peer), free; tests re-enabled in 17.6.0
Signaling✅ wrpc's createSignalingUnit on your hub, or a channel you negotiated✅ self-hosted ws server, a public demo server (default), or Nostr relays41
Peers in a roomone link per pair you add (peer.join(room) + addNode); ops relay any pathfull mesh: one replication per pair, N(N−1)/2 in all, no cap
STUN / TURN serversyour iceServers; wrpc sets nonesimple-peer's Google and Twilio STUN by default; no TURN
Transports and backends
WebSocket✅ a wrpc WrpcClient through createWrpcLink (@alexify/syncom/wrpc)⚠️ replication-websocket: RxDB to RxDB, a Node server, no authentication42
HTTP / Server-Sent Events⚠️ no request–response sync; wrpc's SSE transport untested in the repo43✅ your endpoints under replicateRxCollection; RxServer serves pull, push and SSE
GraphQL❌ none✅ replication-graphql; live through graphql-ws; schema and query generators44
CouchDB❌ none✅ replication-couchdb: _changes long-poll, _bulk_docs with _rev
Firestore❌ none✅ replication-firestore (firebase peer); pull.filter; live via onSnapshot45
Supabase❌ none✅ replication-supabase: PostgREST compare-and-set; Realtime for live rows
Appwrite❌ none⚠️ replication-appwrite: browser only; updates read then write, not atomic
MongoDB❌ none✅ replication-mongodb (Node): change streams; push in a transaction; replica set
NATS JetStream❌ none✅ replication-nats: a subject per document; updates checked by sequence
Google Drive❌ none⚠️ replication-google-drive, beta: JSON files and a lock file in the user's Drive
Microsoft OneDrive❌ none⚠️ replication-microsoft-onedrive, beta: the same file-and-lock scheme over Graph
Conflicts and clocks
Conflict detection— no compare step: every op merges by its field's kind✅ the master compares assumedMasterState with its own (isEqual, deep-equal)
Conflict resolutionbuilt in: per-field LWW by (ts, origin), per-origin counter sums, OR-setsconflictHandler.resolve on the client; the default keeps the master's document46
Custom merge logic❌ none: the rules are fixed; beforeApply can only skip an op✅ any function per collection; the crdt plugin brings its own
Where conflicts are settledon every node, by the same deterministic ruleson the client (fork); the server only detects
Conflicts reported⚠️ unique-key clashes only (conflict); a lost concurrent write is silent✅ conflict$: each conflict the server reported, with the handler's output
Atomic compare on the server— nothing to compare: ops commute⚠️ per plugin: atomic on RxDB storages, CouchDB, Supabase, MongoDB; not Firestore, Appwrite
Causality trackingversion vectors for delivery; per-field stamps; no causal context per recordnone across replicas: a compare-and-set against the master's last state
Role of peer clocksdecide concurrent register writes; a clock never falls behind what it sawunused by the protocol; backend checkpoints and LWW handlers bring their own
Delivery
Realtime stream✅ built in: the host's changes subscription pushes ops as they land✅ pull.stream$, yours or a plugin's; RESYNC when events may be missed
Resume after a disconnect✅ the link resumes the subscription from its last event id; push by vector✅ a checkpoint pull (RESYNC) after each reconnect; the stream is not resumed
Resume after an app restart✅ cursor, remote identity and vector are on disk; no bootstrap✅ checkpoints stored per replicationIdentifier; a new state with the same id continues
Reconnect and backoff✅ the link's: wrpc reconnects with backoff and a heartbeat; syncom greets again⚠️ each plugin's own; a custom stream$ must reconnect and emit RESYNC
Failed requests✅ a failed push leaves the peer behind, caught up later; backoff 1 s to 60 s✅ each pull or push error goes to error$, retried after retryTime (5 s), forever
Duplicate and out-of-order delivery✅ duplicates dropped by the vector; an op ahead of its stream dropped as a gap✅ states, not ops: pages ordered by checkpoint, repeats harmless
Offline writes✅ own ops kept until the registered nodes hold them; pushed when a peer is back47✅ local writes; on reconnect the latest state of each changed document is pushed
Bootstrap a new replica✅ replays the host's log, or past its retention a streamed snapshot✅ a checkpoint iteration from scratch over the backend's current documents
Back after a long outage✅ a snapshot of its scopes once the host's log no longer covers it✅ the same checkpoint pull; the backend must keep soft-deleted documents
Batch and page sizespageSize 500 ops per push and per page; 500 records per snapshot pagepull.batchSize, push.batchSize, 100 each; plugin defaults differ
Memory bounds✅ per-peer outbox capped (maxOutbox 10 000); pages applied one at a time⚠️ no explicit caps; four push batches in flight; queued changes coalesce per document
Partial replication
Partial replication✅ per-record scopes; the host bounds a peer's reads and writes by the record's scope⚠️ one replication per scope, filtered by your backend; RxServer queryModifier
Scoped realtime stream✅ one subscription serves the granted scopes; other scopes' ops are skipped⚠️ one stream per replication, scoped by your backend
Access lost after sync❌ no revocation: the peer keeps what it received❌ no signal: the client keeps its copy; RxServer then refuses its pushes
Pull-only or push-only❌ every link pushes and subscribes; scopes bound both directions alike✅ omit push or pull; pull-only stores no per-document meta
Deletes and attachments
Deletes over replication✅ a delete op leaves a tombstone on every node; a purge op erases✅ _deleted: true with the whole document; the backend must keep it
Attachments over replication❌ none: records are JSON, with no binary type⚠️ in the protocol; only Google Drive and OneDrive carry them intact48
Control and observability
Start, pause, stop✅ sync.start()/stop() for all peers; enableNode(url, false) per peer✅ per replication start(), pause(), cancel(), remove(); live: false runs once
Waiting until in sync⚠️ sync(url?) resolves once pushed; nothing awaits the inbound side✅ awaitInitialReplication(), awaitInSync(), awaitDocumentPushed(doc)
Forcing a re-sync✅ sync() pushes what peers lack; enableNode off and on greets again✅ reSync() runs a checkpoint pull; emitEvent() feeds the stream by hand
Replication status and events✅ peers(): connected, cursor, pending, behind, stuck, lastError; hosted(); events✅ received$, sent$, error$, active$, canceled$, conflict$ per replication
Replication with several tabs⚠️ one tab owns the storage and its sync; others get LOCKED or wait✅ only the elected leader tab replicates (waitForLeadership: true); others see its writes49
Server product
Server product✅ sync.unit(): four procedures for any wrpc router, in the same MIT package⚠️ RxServer: the separate rxdb-server package (SSPL), over an RxDatabase
Where the server runs✅ wherever a wrpc router runs: Node servers, a browser PeerHost⚠️ Node, through the Express adapter; Deno, Bun and Electron claimed, untested
HTTP framework adapters✅ any wrpc host (MIT): its own Server, uws, fastify, express⚠️ Express free (SSPL); Fastify and Koa are Premium (Pro Plus)
What the server serveshello, push, snapshot, changes (a resumable subscription)per collection pull, push, pullStream (SSE); REST query, get, set, delete
Queries for thin clients❌ none: a client replicates its scopes and queries locally✅ RxServer REST endpoint and client-rest; live results over SSE
Server-side storagea full syncom node: its own log and records over its fsiany RxStorage of the server's RxDatabase: memory, MongoDB, FoundationDB…
+ +
+ +### Security and integrity + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Encryption
Encryption at rest⚠️ per record ({ encrypted: true }) or the whole storage; such records never sync50⚠️ per field (schema encrypted: [...]): free encryption-crypto-js; WebCrypto is Premium (Pro)
What is encryptedthe record's whole data; its id, collection and timestamps stay readablethe listed fields only; primary key, other fields, _meta, _rev stay plain
Cipher and integrity✅ AES-256-GCM with a fresh key per record, wrapped by RSA-OAEP-256⚠️ free: AES-256-CBC, MD5 key derivation, no MAC; AES-GCM selectable in Premium51
Key kept off the disk❌ the RSA private key is a file in <path>/keys/, beside the data52⚠️ password given at createRxDatabase, kept in memory; an unsalted SHA-256 of it is stored53
Changing the key or password❌ no rotation API; a replaced key pair cannot open sealed records❌ not possible; migrate into a new database, or wrap a meta-password
Encrypted attachments— no attachments✅ schema attachments: { encrypted: true }
Encrypted data in backups and dumps— no backup or export API; snapshot() leaves encrypted records out⚠️ the backup plugin and exportJSON() write encrypted fields decrypted
Encrypted data and replicationencrypted records stay local: no op, no snapshot, no peer copyencrypted fields replicate decrypted; the backend receives plaintext
End-to-end encryption⚠️ recipe: seal fields in beforeWrite, open in afterRead (wrpc's HPKE sealer)54❌ none; replication reads the decrypted documents
Signatures, identity and integrity
Signed changes✅ Ed25519 per op: the header plus a salted payload digest55❌ none; Nostr keys sign WebRTC signaling, not documents
Node identity✅ the node id is its Ed25519 public key; a swapped key is IDENTITY_MISMATCH⚠️ random tokens (storageToken, instance token); a key only through the Nostr handler
Tamper-evident history✅ hash-linked chain of what this node applied, unsigned (optional)56❌ none; _rev is a height plus the writer's instance token
Access control on the serving side
Authentication where data is served✅ unit({ authorize }) gates hello; access: 'session' refuses everyone by default57✅ RxServer authHandler(headers), re-run before each streamed event58
Peer validation (P2P)⚠️ host authorize; dialer beforeConnect, beforeBootstrap; key possession not proven✅ isPeerValid(peer); the Nostr handler verifies each peer's public key59
Per-client read filtering✅ per-record scopes (sync.scope); a peer reads only the scopes hello granted60✅ RxServer queryModifier(authData, query); serverOnlyFields hidden from clients
Per-client write rules⚠️ ops outside granted scopes refused, by the op's scope and its record's; beforeApply({ op, peer }) can skip one61✅ RxServer changeValidator(authData, change): one refused row is 403 for the push
Server re-validates a client's write✅ the host runs the entity validators on every peer op (OP_INVALID)⚠️ only if the server's storage is wrapped in a validate-* plugin62
A server can reject a write before it spreads⚠️ out-of-scope (by the op or its record), unsigned and malformed ops stop at the host; one its validator or beforeApply skips is relayed63✅ RxServer is the master: a refused push reaches no client64
Rate and size limits✅ unit({ limits }): opsPerPush, bytesPerPush (413), opsPerSecond (429)❌ none in RxServer; $regex queries refused against ReDoS
Erasure, history and audit
Purge: the payload leaves history✅ purge(id): a purge op that wins at any stamp, then a scrub of the op log65⚠️ cleanup plugin drops tombstones after minimumDeletedTime (31 days)66
Erasure across replicas✅ the purge op replicates; every peer that takes it scrubs its own log67❌ cleanup is local; a delete replicates as a tombstone with the full data
History / time travel⚠️ op log within retention (storage.log.entriesOf(id)); no read-at-version⚠️ latest revision only; the crdt plugin keeps every op in the document68
Who made a change✅ every op names its origin key and an actor, kept in the log69❌ none; crdt ops carry the writer's storage token and a time
Input hardening
Prototype pollution✅ own keys only; __proto__ is never a field✅ mingo and dot-prop refuse __proto__ paths
Malformed data from a peer or backend✅ wellFormed before anything is read; counted malformed, never logged or relayed⚠️ only with a validate-* wrapper: the invalid document is dropped (RC_PULL)70
Clock-skew guard✅ stamps over sync.maxSkew (24 h) ahead refused (SKEW), never observed— no peer clocks: conflicts compare document states
+ +
+ +### Extensibility and operations + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Hooks and events
Write hooks / middleware✅ beforeWrite replaces data or refuses, afterWrite; one function per name✅ pre/post insert/save/remove, plus postCreate; many, series or parallel
Hooks on replicated changes✅ beforeApply skips or aborts per op, afterApply, beforeBootstrap⚠️ pull.modifier per replication; collection hooks skip pulled documents
Filter outgoing changes❌ none, by design: a withheld op is a gap; scopes decide what leaves✅ push.modifier transforms a document or drops it (null)
Read hooks⚠️ beforeRead / afterRead wrap get(); queries and snapshots read raw71❌ none; postCreate runs when a document object is built
Refusal and error events✅ error with a code per refused peer op; conflict; host refused, hello✅ replication error$ (RC_PULL, RC_PUSH), conflict$; rejected local writes throw
Extensibility and operations
Plugin system❌ none; per-instance injection: fsi, validator, search, telemetry, logger, connect✅ addRxPlugin(): prototypes, overwritables and 24 plugin hooks72
Typed errors✅ SyncomError.code: 30 codes, plus 3 on TypeErrors; full messages✅ RxError.code: 215 codes; the message text only with dev-mode
Logging✅ open({ logger }): a Console, a pino-shaped logger or false; named events⚠️ Premium logger storage wrapper (Pro Plus); the core logs nothing
OpenTelemetry✅ spans and metrics through an injected API; no OpenTelemetry dependency❌ none
Health and stats✅ stats(), peers(), hosted(), observable gauges❌ no stats API; replication states have active$ and awaitInSync()
Personal data in diagnostics✅ no id, origin, scope or key as a metric label; includeIdentity: false for spans⚠️ error messages embed their parameters: documents, pushed rows, password hashes73
UI bindings
React❌ none✅ rxdb/plugins/react: RxDatabaseProvider, useRxQuery, useLiveRxQuery, useRxDocument…
Vue❌ none✅ rxdb/plugins/reactivity-vue: shallow refs through the $$ accessors
Angular❌ none✅ rxdb/plugins/reactivity-angular (signals); observables fit the async pipe
Svelte❌ none⚠️ no plugin; a Svelte 5 example app runs in CI
Signals and custom reactivity❌ none✅ reactivity option: Preact signals plugin, or your own factory; no Solid adapter
TanStack DB❌ none⚠️ @tanstack/rxdb-db-collection, published by TanStack (0.1.111)
Developer tooling
Development mode— no separate mode: argument checks and full error messages always on✅ rxdb/plugins/dev-mode: readable errors, deep-freeze, schema and query checks74
Test helpers⚠️ createMemoryFsi() for in-memory stores; contract suites stay in the repo✅ memory storage; rxdb/plugins/test-utils (fixtures, replication test suite); using closes databases75
Own test suite and CI722 node:test tests; coverage gate 95/95/90/95; CI on Node 22 and 24, Linux74 test files per storage; Linux, Windows, browsers, Bun, Deno, 11 example apps; no coverage gate
Documentation21 pages, about 41,500 words, with an API reference271 pages, about 463,000 words: 98 articles, 46 landing pages
Example apps⚠️ two Node.js scripts (examples/main.js, domain.js), linted but not run✅ 13 example apps; 11 run in CI: Angular, React, React Native, Electron, Tauri, Vue, Svelte…
+ +
+ +### Project and ecosystem + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresyncom
sync engine
RxDB
local-first database
Releases and maintenance
Breaking major releasesnone yet: 0.0.1, never published10 majors from 8.0 (2018) to 17.0 (2026); three in 202276
Minor release cadenceno release yet; cut by hand from a checklistabout monthly: 17.0 to 17.6 between March and October 2026
Free-tier changes over releases— no paid tier (MIT)⚠️ Premium since 12.0 (2022); Worker storage moved in at 14.0; collection cap since 16.077
Named production usersnone yet (unreleased)10 testimonials (Readwise, Nutrien, Venice.ai, …); one case study
Support and ecosystem
Commercial support❌ none offered⚠️ paid: €180 one-hour consulting; Enterprise SLA, 24-hour first response78
Community helpGitHub repository; no chat or forumDiscord, GitHub; a bug report needs a PR with a failing test
Third-party pluginsnone (pre-release)8 listed, e.g. rxdb-hooks, rxdb-flexsearch, rxdb-supabase
Adoption and trust
Adoption signalspre-release, not on npm yet23,412 GitHub stars; 63,815 npm downloads in the week to 2026-10-18
Import from other databases❌ no importer and no JSON import; write records with insert()⚠️ migration guides only; importJSON() takes an RxDB dump of the same schema79
Published benchmarks✅ nine libraries, six scenarios, memory and disk; machine and date stated⚠️ its own storages only; no machine, browser or date given80
AI and coding agents
WebMCP agent tools❌ none✅ rxdb/plugins/webmcp: query, count, changes, insert, upsert, delete tools per collection; readOnly81
Docs for coding agents✅ llms.txt on the docs site; CLAUDE.md in the repository✅ llms.txt plus three topic files; ERROR-MESSAGES.md, CLAUDE.md, AGENTS.md in the package82
+ +
+ +### Notes + +1. The agreement also requires shipping Premium code minified without source maps, gives no technical support, and says installing it (an access-token postinstall downloads and decrypts it) sends the package name, dependency list, git user name and email, and IP address to the vendor. +2. Measured: the 14th collection opens after a wait of about 1.86 s and the 15th throws COL23. The count spans every database in the process; 17.0.0 lowered it from 16 to 13. +3. The docs say the dev-mode iframe is added on localhost only, but the isLocalHost() check is commented out (dev-mode-tracking.ts:67), so it loads on any host unless the Premium flag is set. The production core makes no such call. +4. The Filesystem and SQLite storages are Premium (Pro); the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance. +5. The page offers SQLite as 'a free solution' but its sample imports rxdb-premium/plugins/storage-sqlite, and the sample's ignoreDuplicate: true throws DB9 without dev-mode (checked). +6. The article and examples/flutter import rxdb/plugins/storage-lokijs, removed in 16.0.0; the Flutter CI job is commented out, and the Dart package on pub.dev is 15.0.0-beta.31 from 2023-11-21. +7. There are no cross-tab change events and no read-only open: the other tabs wait for the lock (lock: 'wait') or route reads and writes to the owner through a BroadcastChannel. +8. The free Dexie storage prints a console warning advertising Premium once per process, when a database is created, unless the Premium package's setPremiumFlag() is called; it also refuses indexes on fields that are not required (DXE1). +9. The trial throws SQL3 after 500 SQL calls per storage instance: a write runs one whole-table read plus one insert per new document, so the 251st serial insert fails (measured). SQL2 stops it at 500 documents. It has no indexes or attachments. +10. One Node.js process per MongoDB database, per the docs; nothing in the storage enforces it (no change-stream listening, a TODO in the code). +11. Attachments are not implemented (getAttachmentData() throws "Method not implemented."); the DenoKV page does not say so. +12. The premium page still lists LokiJS among the free default storages. +13. By default a write resolves before it reaches the persistent storage (awaitWritePersistence: true waits); no attachments; one JavaScript process per store, a SharedWorker for tabs. +14. Files are rewritten in place, with no temp file and rename: on node:fs a crash mid-write can tear a record file, which then fails to parse until repair(id). +15. Two browser tabs are two instances: the second sees LOCKED, so one tab or a worker must own the storage. +16. repair(id) rebuilds a record while the log keeps a whole-record op for it; otherwise it is UNRECOVERABLE, and a peer's snapshot is the way back. +17. The chain is never compacted: one block file per local write and per applied batch, kept for good. +18. Not supported by the SQLite trial (SQL1), MongoDB, DenoKV or memory-mapped storages. +19. It deletes each migrated batch from the old storage and does not check write errors on the new one (a TODO in the code); deleted documents are dropped. +20. Restoring an older copy of a node's directory is caught at the next hello (REWOUND): the node moves past the seqs its host holds and bootstraps the lost state back; writes made between the restore and that hello are lost to the host. +21. A dump has no deleted documents and no revisions, holds encrypted fields in plain text, and is built in memory. +22. Without dev-mode and a validator, an empty, over-long or missing key is accepted; on the memory storage an insert without the key was stored under undefined (IndexedDB rejects that one). +23. Without dev-mode, patch() overwrote a final field, and patching the primary key wrote a copy under the new key beside the original (checked by running); dev-mode refuses both (DOC9). +24. The validate-ajv-standalone plugin documented on rxdb.info is not in 17.6.0; it is on master, unreleased. +25. Without the wrapper, production accepts unknown top-level fields, wrong types and missing required fields (checked by running). +26. Concurrent edits to two keys of one nested object keep only one side, and update({ 'a.b': 1 }) writes a top-level field named a.b (both documented in the Storage and Sync guides). +27. A counter that one node resets while another increments it does not converge to the intuitive value; see Guarantees and limits in the Sync guide. +28. The document grows with every write: 200 $inc left 201 op rows, about 18 KB (checked by running), and every replica stores and transfers them. +29. Checked by running: upsert() over a CRDT document reset its op list to one entry, incrementalUpsert() dropped the CRDT field, and with the update plugin added after crdt, update() wrote outside the ops. Dev-mode stops the last two, not upsert(). +30. Storage does not replicate through them, and merge({ hits: 10 }, diff({ hits: 10 }, { hits: 3 })) is 13: a delta is not a plain patch. +31. Checked by running without dev-mode: editing toJSON().nested changed the cached document (and the memory storage's copy), though the docs call toJSON() immutable. +32. Nothing declares the relation, so an unrelated string that matches an id becomes a Reference too (checked by running). +33. Load contracts only from a chain you control; the parser takes a single arrow function, and the result is written with saveData(). +34. Without dev-mode, a RegExp instance is normalized to {} and the query matches every document (verified on 17.6.0). +35. Any desc sort key forces a re-sort of every match, since most storages have no descending index (query-planner.ts:44-48). +36. A range or an order() on an indexed field is evaluated over every candidate: the whole collection unless an indexed equality or $in narrows it. Checked on 200 records: $gt read 200, with an indexed equality 20, a covered cursor 0. +37. Without dev-mode the memory storage fails at addCollections() with an uncoded TypeError, and Dexie accepts the index on a required field but queries through it find nothing (checked by running). +38. RxServer compares the integer version only, with no schema hash. Elsewhere every replica must run the same migration, and the backend must accept checkpoints of older versions. +39. The WebRTC page says there is "no master-slave concept"; the code makes one peer of every pair the master, and only the other side runs a replication. +40. On the MongoDB and FoundationDB storages a process sees only its own writes (the change-stream relay is a commented-out TODO), so a live stream misses writes made through another process until its next RESYNC. +41. Without signalingServerUrl the handler uses wss://signaling.rxdb.info/, which the code warns is for demos only; the official quickstart app uses it. +42. The server ignores the client's auth message, and one message naming an unknown collection crashes the Node process (both checked by running). +43. In a one-off check over wrpc 2.0.1, transport: 'sse' synced both ways with a live update; plain http cannot (the subscription is refused, then every call answers 428). +44. With pull.batchSize unset the query asks for 20 documents while the core stops at a page under 100, so a cycle pulls only 20 (checked by running). +45. The push compare runs inside runTransaction without using the transaction, so a write landing between its read and commit is overwritten (code reading). +46. Some RxDB pages call the default last-write-wins; the code keeps the server's state whatever its time (https://rxdb.info/articles/zero-latency-local-first.html). +47. Ops written before any node was registered, and compacted since, cannot reach a host; it is reported once per hello (UNDELIVERABLE). +48. Over the WebSocket and WebRTC plugins attachment data is silently corrupted, since Blobs pass through JSON.stringify (checked by running); RxServer, GraphQL, Appwrite and MongoDB drop it. +49. A visible tab that is not the leader also starts replicating (toggleOnDocumentVisible, on by default), so two tabs can replicate at once (code reading). +50. The RSA key pair that unseals the records sits unprotected in <path>/keys/, beside the data: whoever can read the directory can read the records. +51. crypto-js's passphrase mode derives key and IV with EVP_BytesToKey (MD5, one iteration) and authenticates nothing, and crypto-js is discontinued; the page calls the plugin 'tamper proof'. +52. Mode 0600 where the backend has permissions; in OPFS or IndexedDB a plain entry. No passphrase, KMS or injected key. +53. The stored hash lets anyone with the files test password guesses offline at SHA-256 speed (checked: it equals sha256(JSON.stringify(password))). +54. One paragraph, with no example or test in the repository; a sealed field cannot be indexed, queried, counted or kept unique. +55. The default 'verify' takes an unsigned op, so a relay can strip signatures; only sync.signatures: 'require' refuses them. +56. Whoever can write the directory can rebuild it: a read checks a record against the block its file names, not that the block is on the chain, so a planted block passes and validate() still answers true (checked). The ops in the log are what is signed. +57. hello does not prove the peer holds its key, and the dialer's token sits in plain text in <path>/remote/. +58. Defaults are open: with no authHandler every request is admitted, and the Express adapter's default CORS reflects any request origin with credentials. The documented validUntil is never read. The server listens on localhost unless told otherwise. The replication-websocket server has no auth at all. +59. Once valid, a peer reads and writes every document of the collection. +60. The peer names its scopes, and naming none means everything: authorize must check hello.scopes. +61. A finer rule than scopes lives in beforeApply: a skip is local (the op is still logged and relayed), a throw refuses the whole page. +62. Collection hooks do not run on replicated writes, and a 422 throws after the valid rows of the batch were written. +63. Checked with three nodes: an op the middle node's validator or beforeApply skipped still reached the node behind it, because a withheld op would leave a gap in its origin's stream; every node re-checks it. A throwing beforeApply refuses the whole page instead, and the sender offers it again later. +64. Over WebRTC there is no master: each peer's storage decides alone. +65. The chain keeps an unsalted SHA-256 of every version this node wrote or applied, so whoever holds the directory can confirm a guessed record; until the next catalog save, index/catalog.json may still hold its indexed fields. +66. A removed document keeps all its data in the tombstone until then, and forever without the plugin; cleanup(0) forces it once replications are in sync. +67. A peer that never comes back keeps what it had. +68. Op compaction is not implemented, so a crdt document carries every past value, in its tombstone too. +69. actor is the writer's signed claim; a host holds it to what authorize established in beforeApply. +70. Without a wrapper a pulled document with wrong types and extra fields is stored as sent (checked by running). +71. An afterRead redaction does not reach select() rows, so it is not a read access control. +72. Plugins are process-wide and cannot be removed. +73. Checked by running: a wrong password's DB1 carries both the given and the stored password hash. +74. Without it an error carries only its code. With it, the storage must be wrapped in a schema validator (DVM1), and in browsers it adds a tracking iframe (see Calls to the vendor). +75. The testing page's multi-tab simulation needs ignoreDuplicate: true, which throws DB9 unless dev-mode is on, and the 13-collection limit counts every database a test run leaves open. +76. Majors 13, 14, 16 and 17 each told users of some or all storages to move their stored data with the migration-storage plugin or to re-sync it from the backend. +77. The open-source cap on open collections was 16 in 16.0 and is 13 since 17.0. Two features went the other way: migration-storage (15.0) and the Vue and Preact signals plugins (17.0) became free. +78. The Pro and Pro Plus agreement says 'No Technical Support'; free users get GitHub and Discord, and a bug report is expected as a pull request with a failing test. +79. No converter ships: the Realm, PouchDB, Dexie, LokiJS, NeDB, GUN and other guides read the old store and call bulkInsert/bulkUpsert by hand. +80. Most measured storages are Premium (OPFS, IndexedDB, SQLite, Filesystem); the free localStorage storage that most guides use is not measured. +81. WebMCP is an experimental browser API; where navigator.modelContext is missing the docs point to the @mcp-b/global polyfill. +82. In the 17.6.0 tarball all 215 entries of ERROR-MESSAGES.md are headed ## undefined, and COL23 states a limit of 16 collections where the code enforces 13. + + + +## Speed + +Both run the same scenarios on the [Benchmarks](/guide/benchmarks) page, in a fresh process each, +on one machine. RxDB runs in memory only there, because its Node.js disk storages are Premium. + + + + + +Measured in memory, the two insert a record at about the same rate. RxDB updates a field about +three times as fast and replicates a store into an empty replica about two and a half times as +fast; syncom signs every op and verifies each one it receives (`sync.signatures: false` trades +that away). syncom reads a record by id about three and a half times as fast, from its cache. +RxDB's `bulkInsert()` is two orders of magnitude ahead, because syncom has no bulk write: each +record is its own signed, logged op. On disk there is nothing to compare in Node.js, and a browser +app that keeps records and syncs them ships about 43 KB of syncom against 90 KB of RxDB with +Dexie, min+gzip. + +## Which to choose + +- **RxDB** when the data already lives in a backend (Supabase, CouchDB, Firestore, MongoDB, + GraphQL or your own REST) and the device keeps a replica of it; when the app needs React, + Vue or Angular bindings, React Native, Expo, Capacitor or Electron, several tabs on one + database, attachments, or a rich write API; and when a mature project with paid support matters + more than the license of its storage layer. +- **syncom** when the nodes replicate among themselves, with no backend of record: servers, + browser tabs and peers in a mesh, any of which can host. When concurrent edits must merge field + by field and counters must add up, when you need to prove which node wrote what, verify what is + stored and erase it everywhere, and when zero dependencies, a 42 KB bundle and an MIT license + for everything, Node.js disk persistence included, are requirements. +- **Neither** for collaborative text: Yjs, Automerge or Loro merge text character by character, + and can live in a field of either. diff --git a/docs/guide/why.md b/docs/guide/why.md index 5275ee4..24c2695 100644 --- a/docs/guide/why.md +++ b/docs/guide/why.md @@ -33,8 +33,8 @@ engine also covers what an application keeps next to replication: converge. - **The host decides what it accepts.** A hub checks a peer's op before applying it: the op's shape and clock skew, the peer's scopes, the entity's validator and `beforeApply`. A failing op - is skipped, a unique-key clash is reported, and each peer is served only the scopes it was - granted. Of the eight alternatives that sync without a server of their own, only RxDB + is skipped there (and still relayed, so the nodes behind the hub judge it for themselves), a + unique-key clash is reported, and each peer is served only the scopes it was granted. Of the eight alternatives that sync without a server of their own, only RxDB validates writes against a schema. - **A history you can verify and still purge.** Every op is signed by the node that made it — the node id is its Ed25519 key, so any peer verifies an op through any relay — and every @@ -50,7 +50,7 @@ engine also covers what an application keeps next to replication: - **Zero dependencies, one API for Node.js and the browser.** Nothing is installed with it. `node:fs` and `node:crypto` on the server, OPFS (or IndexedDB) and Web Crypto in the browser, picked by the `browser` export condition. CommonJS without a build step, hand-written typings, - and a browser bundle of about 41 KB min+gzip that CI holds to a budget. + and a browser bundle of about 42 KB min+gzip that CI holds to a budget. The [benchmarks](/guide/benchmarks) put a number on all of this. syncom opens a store and reads a record in about 37 ms, the fastest cold start of the ten libraries measured, and serves a read diff --git a/docs/index.md b/docs/index.md index b7223f2..bda7755 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,8 +64,8 @@ features: link: /guide/telemetry linkText: OpenTelemetry - icon: 📦 - title: Zero dependencies, about 41 KB - details: "Nothing is installed with it. The same API on Node.js and in the browser, picked by the browser export condition, and about 41 KB min+gzip for the browser, held to a budget in CI." + title: Zero dependencies, about 42 KB + details: "Nothing is installed with it. The same API on Node.js and in the browser, picked by the browser export condition, and about 42 KB min+gzip for the browser, held to a budget in CI." link: /guide/browser linkText: Browser --- diff --git a/docs/public/benchmarks/size.svg b/docs/public/benchmarks/size.svg index ede7738..e3cb309 100644 --- a/docs/public/benchmarks/size.svg +++ b/docs/public/benchmarks/size.svg @@ -80,14 +80,14 @@ pouchdb-browser carries its HTTP adapter (the CouchDB client) with it 40.4 KB syncom · open() over OPFS + the wrpc link adapter -41.3 KB min+gzip -119.9 KB minified +42.6 KB min+gzip +124.1 KB minified @alexify/syncom@0.0.1 storage, schema, queries, the op log and the sync protocol are one entry syncom open() over OPFS + the wrpc link adapter - -41.3 KB + +42.6 KB RxDB · rxdb + Dexie storage + replication 90.1 KB min+gzip @@ -135,5 +135,5 @@ loro-crdt@1.16.4 1172 KB -esbuild 0.28.2 · 2026-10-22 +esbuild 0.28.2 · 2026-10-23 diff --git a/docs/reference/api.md b/docs/reference/api.md index d46b2d9..122f37d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -60,7 +60,7 @@ Returns `Promise`. Options: `path` (default `'syncom'`), `name` | `getCachedData(id)` | `unknown \| null` | Synchronous cache peek, raw (no lens) | | `hasRecord(id)` | `boolean` | Whether the id is a live record — from the catalog, no I/O | | `stats()` | `{ closed, queue, cache: { size, max, pinned }, catalog: { entries, dirty }, log: { length, floor, next, epoch, segments, latestTs }, chain: { tail } \| null, lock: { mode } \| null }` | The node's health, from memory — no I/O | -| `applyRemote(ops, { via, scopes, peer, adoptFrom }?)` | `Promise<{ applied, skipped, duplicates, gaps, malformed, unsigned, forged }>` | Applies a peer's ops, in order per origin; `peer` is `{ url, node }` for the hooks; `malformed` ops (shape, id, a stamp beyond `sync.maxSkew`) and ops refused for their signature (`forged`; `unsigned` under `sync.signatures: 'require'`) are counted and dropped; an `advance` is adopted only when its origin is `adoptFrom` | +| `applyRemote(ops, { via, scopes, peer, adoptFrom, bound }?)` | `Promise<{ applied, skipped, duplicates, gaps, malformed, unsigned, forged, outOfScope }>` | Applies a peer's ops, in order per origin; `peer` is `{ url, node }` for the hooks; `malformed` ops (shape, id, a stamp beyond `sync.maxSkew`) and ops refused for their signature (`forged`; `unsigned` under `sync.signatures: 'require'`) are counted and dropped; an `advance` is adopted only when its origin is `adoptFrom`; under `bound` (a host's push passes the peer's granted scopes) an op whose record is outside them, before or after it, is counted `outOfScope` and dropped | | `snapshot({ cursor, limit, scopes }?)` | `Promise<{ records, cursor, eventId, vector }>` | One page of state, tombstones included | | `mergeSnapshot(records, { via, from }?)` | `Promise` | Joins a page (as `merge` ops of this node) | | `adoptVector(vector, { via, from }?)` | `Promise` | Records what a joined snapshot covers (an `advance` op) | @@ -83,7 +83,7 @@ the operation (see [Hooks and events](../guide/hooks#events)): | --- | --- | --- | | `change` | `(op, via)` | Every op applied here; `via` is `null` for a local write, otherwise `{ url, node }` of the peer it came from (`url` null on the hosting side) | | `apply` | `({ via, peer, ops, applied, skipped, duplicates, gaps, malformed, unsigned, forged })` | A peer's batch is durable | -| `error` | `(error)` with `error.code` and `error.op`, plus `error.rejected` (`OP_REFUSED`) or `error.entity` + `error.issues` (`OP_INVALID`) | A peer's op refused by the field-kind rule, skipped as invalid under this node's schema, skipped because this node cannot read the record's file (`OP_UNREADABLE`), or refused for a stamp beyond `sync.maxSkew` (`SKEW`) | +| `error` | `(error)` with `error.code` and `error.op`, plus `error.rejected` (`OP_REFUSED`) or `error.entity` + `error.issues` (`OP_INVALID`); `error.ids` for `UNPERSISTED` | A peer's op refused by the field-kind rule, skipped as invalid under this node's schema, skipped because this node cannot read the record's file (`OP_UNREADABLE`), or refused for a stamp beyond `sync.maxSkew` (`SKEW`); signatures refused (`BAD_SIGNATURE`, `UNSIGNED`); logged ops whose record files could not be written (`UNPERSISTED`) | | `conflict` | `({ kind: 'unique', entity, fields, id, other, op })` | A peer's op left a unique key shared by two records; both are kept | | `schema` | `({ kind: 'drift', peer, version, hash, local })` / `({ kind: 'ahead', op, held })` | A peer runs this schema version with another snapshot hash (see [Schemas](../guide/sync#schemas)) / a peer's op is from a newer version, applied or held | | `log` | `({ kind: 'roll', sealed, dropped, floor, segments })` | The log sealed a segment and maybe dropped old ones; `floor` is where a resume is still possible | @@ -105,11 +105,15 @@ live property per field of the loaded object. ### `Collection` -Created per schema entity and exposed as `storage.`. +Created per schema entity and exposed as `storage.`; a name the +storage already answers to (`get`, `close`, `log`, `sync`…) is refused at +`open()` with a `TypeError`. -`name`, `insert(data, options?)`, `get(id)`, `set(id, data, options?)` -(stamped as the collection's), `update(id, delta, { actor }?)` (refuses a -delta naming `$type`), `delete(id, { actor }?)`, `record(id)`, +`name`, `insert(data, options?)`, `get(id)` (null for a record of another +collection), `set(id, data, options?)` (stamped as the collection's), +`update(id, delta, { actor }?)` (refuses a delta naming `$type`), +`delete(id, { actor }?)`, `record(id)` — `update`, `delete` and `record` +reject with `NOT_FOUND` for a record of another collection — `select(query?)` → a cursor, `count(query?)`. Writes through a collection with a `schema` are validated — see [Validation](../guide/storage#validation); `unique` keys are refused with a @@ -144,7 +148,7 @@ Extends `Emitter`. See [Sync](../guide/sync). | `addNode({ url, token?, scopes? })` | `Promise` | Registers a peer to dial; dials it at once when started | | `removeNode(url)` | `Promise` | Hangs up too | | `enableNode(url, enabled = true)` | `Promise` | Keep a node without dialing it | -| `listNodes()` | `Node[]` | `{ url, token, scopes, enabled, lastSync, cursor, remote }`; `remote` is `{ id, epoch, schema }` after a hello | +| `listNodes()` | `Node[]` | `{ url, token, scopes, enabled, lastSync, cursor, remote, acked }`; `remote` is `{ id, epoch, schema }` after a hello; `acked` is how far the node holds this node's own streams (vector keys → seq), what the log keeps them for | | `start()` | `Promise` | Dials every enabled node; needs `sync.connect` | | `stop()` | `Promise` | Closes every link | | `sync(url?)` | `Promise>` | Pushes what the peers lack now | @@ -163,8 +167,12 @@ with `cursor` and `attempt` for a page of the feed that could not be applied, `node` `({ url, action: 'add' | 'remove' | 'enable' | 'disable' })`, `connect` `({ url })` once a link is accepted (before hello), and on the hosting side `hello` `({ node, scopes, schema })` once a peer is authorized and `refused` -`({ node, reason: 'scope', count })` when a push carried ops outside the -scopes that hello granted (see [Scopes](../guide/sync#scopes)). +`({ node, reason, count })` when a push carried ops it would not take: `reason` +`'scope'` for ops outside the scopes that hello granted — the scope an op +declares, or the scope of the record it writes, before or after it (see +[Scopes](../guide/sync#scopes)) — `'forged'` and `'unsigned'` for signatures +refused (see [Signatures](../guide/sync#signatures)). The dialer's `error` +event also carries `'REWOUND'` and `'UNDELIVERABLE'` (see the codes below). Hooks (`open({ sync: { hooks } })`): `beforeConnect`, `beforeBootstrap` — see [Hooks and events](../guide/hooks#sync-hooks). @@ -184,10 +192,10 @@ value is data or configuration. `HookError` and `ValidationError` are | --- | --- | --- | | `CLOSED` | any write or `applyRemote()` after `close()` | — | | `IN_HOOK` | a write issued from inside a write or apply hook (the `cause` of the `HookError` the operation rejects with) | — | -| `NOT_FOUND` | `update()`, `swap()` on a record that is absent, deleted or purged | — | +| `NOT_FOUND` | `update()`, `swap()` on a record that is absent, deleted or purged; a collection's `update()`, `delete()`, `record()` on a record of another collection | `id`, `entity` (the collection's) | | `FIELD_KIND` | `update()`/`set()` on a counter or set field, `inc()` on a register or set field, `add()`/`remove()` on a register or counter field | `field`, `kind` (`'register'`, `'counter'` or `'set'`: what the field is) | | `ENCRYPTED` | `inc()`, `add()` or `remove()` on an encrypted record; `{ encrypted: true }` on a replicated one | — | -| `INTEGRITY` | a read (`get()`, `has()`, a cursor) of a record whose file fails its chain block check, under `integrity: 'verify'` | `id` | +| `INTEGRITY` | a read (`get()`, `has()`, a cursor) of a record whose file fails its chain block check, under `integrity: 'verify'`, or cannot be parsed at all (a file torn by a crash; `repair(id)` rebuilds it) | `id`, `cause` (the parse error) | | `CORRUPT` | `open()` when `node.json`, `schema.json` or `.blockchain.json` cannot be read | `cause` | | `DOWNGRADE` | `open()` with `ahead: 'hold'` and a schema version below the one the directory ran | — | | `LOCKED` | `open()` while another instance holds the directory | `path` | @@ -205,6 +213,9 @@ value is data or configuration. `HookError` and `ValidationError` are | `PAGE_FAILED`, `STUCK` | the sync `error` event: a page of the feed could not be applied; the third in a row | `url`, `cursor`, `attempt`, `cause` | | `STREAMS_UNSUPPORTED` | the sync `error` event: the host streamed a snapshot and the link cannot read streams | `url` | | `UNRECOVERABLE` | `repair()` when the log holds no whole-record op for the id | `id` | +| `UNPERSISTED` | the storage's `error` event: ops were logged — the write stands, and replicates — but their record files (or chain block) could not be written; the records are held in memory and the next open replays the batch onto the files | `ids`, `cause` | +| `REWOUND` | the sync `error` event, at a hello: the host holds more of this node's own ops than its log does — a directory restored from an older copy, or another instance with this identity. The node moves its streams past the host's and bootstraps from it; writes made since the restore and before this hello are lost to the host | `url`, `local`, `remote` (vector keys of this node → seq) | +| `UNDELIVERABLE` | the sync `error` event, once per hello: the host lacks ops of this node's own that its log no longer retains (written before any node was registered, and compacted since); they are not sent | `url`, `missing` (key → `{ host, retained }`) | | `SKEW`, `OP_UNREADABLE`, `OP_REFUSED`, `OP_INVALID` | the storage `error` event — see [`Storage`](#storage) | `op`, plus `rejected` / `entity` + `issues` | | `HOOK_FAILED` | a `HookError` | `hook`, `cause` | | `VALIDATION_FAILED` | a `ValidationError` | `entity`, `issues`, `cause` | diff --git a/index.d.ts b/index.d.ts index 2462619..8e210bd 100644 --- a/index.d.ts +++ b/index.d.ts @@ -180,6 +180,9 @@ export type SyncomErrorCode = | 'STUCK' | 'STREAMS_UNSUPPORTED' | 'UNRECOVERABLE' + | 'UNPERSISTED' + | 'REWOUND' + | 'UNDELIVERABLE' | 'SKEW' | 'OP_UNREADABLE' | 'OP_REFUSED' @@ -284,6 +287,12 @@ export interface OpLog { advance(vector: VersionVector): Promise; tail(): LogEntry[]; flush(): Promise; + /** + * Entries compaction must not drop, whatever the retention: the segment + * holding one `keep` answers true for stays. A node that dials keeps its + * own ops until every node it syncs with has them. `null` clears it. + */ + retain(keep: ((entry: LogEntry) => boolean) | null): void; close(): Promise; /** The batches within `from..to` are reflected in the records (see `sealApplied`). */ applied(from: number, to?: number): void; @@ -859,6 +868,11 @@ export interface Node { cursor: string | null; /** What the last hello learned; `null` before the first. */ remote: { id: string; epoch: string; schema: SchemaInfo | null } | null; + /** + * This node's own streams as far as the peer is known to hold them + * (`origin` / `origin@scope` → seq): what the log keeps its own ops for. + */ + acked?: VersionVector; } /** One dialed peer, as `sync.peers()` reports it. */ @@ -1089,6 +1103,12 @@ export interface ApplyRemoteResult { unsigned: number; /** Refused for a signature that does not verify against the origin, or a payload its digest does not match; neither logged nor relayed. */ forged: number; + /** + * Refused under `bound` (a host's push): the record the op writes is + * outside the pushing peer's scopes, before the op or after it, as this + * node's `sync.scope` reads it; neither logged nor relayed. + */ + outOfScope: number; } /** The events a Storage emits; listeners are contained. */ @@ -1247,11 +1267,19 @@ export interface Storage { /** * `adoptFrom`: the node id whose own `advance` ops may move this node's * vector — the host a subscription is read from. Absent, an `advance` is - * logged and relayed but grants nothing. + * logged and relayed but grants nothing. `bound`: the scopes of the peer + * that pushed the page — an op writing a record outside them is refused + * (`outOfScope`); a host's `push` passes the peer's grant. */ applyRemote( ops: readonly SyncOp[], - options?: { via?: PeerRef | null; scopes?: readonly string[] | null; peer?: PeerRef | null; adoptFrom?: string | null }, + options?: { + via?: PeerRef | null; + scopes?: readonly string[] | null; + peer?: PeerRef | null; + adoptFrom?: string | null; + bound?: readonly string[] | null; + }, ): Promise; adoptVector(vector: VersionVector, options?: { via?: PeerRef | null; from?: string }): Promise; snapshot(options?: { cursor?: string | null; limit?: number; scopes?: readonly string[] | null }): Promise; diff --git a/scripts/comparison.js b/scripts/comparison.js index ea8c35e..3b59ca9 100644 --- a/scripts/comparison.js +++ b/scripts/comparison.js @@ -1,17 +1,16 @@ /** - * The feature matrix of docs/guide/comparison.md: one table, @alexify/mailer - * and the alternatives in columns, the features in rows grouped by area, - * rendered from docs/comparison.json into the region between - * `` and ``, so the table and - * the data cannot drift (CI regenerates it and fails on a diff). A cell is - * `{ mark, text, note? }`: the mark is one of ✅ ⚠️ ❌ 🔜 —, the text a few - * words (backticks become code), and a note becomes a numbered footnote under - * the table. + * The feature matrices of the docs, rendered from data so a table and its data + * cannot drift. Each page below has a JSON file with the libraries in columns + * and the features in rows grouped by area; the table goes into the region + * between `` and `` of the + * page. A cell is `{ mark, text, note? }`: the mark is one of ✅ ⚠️ ❌ 🔜 —, + * the text a few words (backticks become code), and a note becomes a numbered + * footnote under the table. * * The first library of the data is syncom: custom.css tints the second * column of the table in the brand colour and pins it beside the features. * Every cell of the alternatives was decided by reading the source of the - * published package and its official docs (see the page for the method); a + * published package and its official docs (see each page for the method); a * cell's `source` (and, for a footnote left out of the table, its `detail`) * stays in the data for whoever checks it again. Edit the data, not the table. * @@ -23,12 +22,14 @@ const fs = require('node:fs'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); -const DATA = path.join(ROOT, 'docs', 'comparison.json'); -const TARGETS = [path.join(ROOT, 'docs', 'guide', 'comparison.md')]; +const PAGES = [ + // Every alternative at once: /guide/comparison. + { data: path.join(ROOT, 'docs', 'comparison.json'), target: path.join(ROOT, 'docs', 'guide', 'comparison.md') }, + // syncom and RxDB, feature by feature: /guide/vs-rxdb. + { data: path.join(ROOT, 'docs', 'comparison-rxdb.json'), target: path.join(ROOT, 'docs', 'guide', 'vs-rxdb.md') }, +]; const START = ''; const END = ''; -const GENERATED = - ''; const escape = (text) => String(text).replace(/&/g, '&').replace(//g, '>'); @@ -38,21 +39,15 @@ const inline = (text) => .replace(/`([^`]+)`/g, '$1') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); -const render = (data) => { - const notes = []; - const noteOf = (text) => { - let index = notes.indexOf(text); - if (index === -1) index = notes.push(text) - 1; - return `${index + 1}`; - }; - const columns = data.libraries; +// One table of the groups given; `noteOf` numbers the footnotes across all of a page's tables. +const renderTable = (columns, groups, noteOf) => { const lines = ['', '', '', '']; for (const library of columns) { const name = `${escape(library.name)}`; lines.push(``); } lines.push('', '', ''); - for (const group of data.groups) { + for (const group of groups) { lines.push( ``, ); @@ -71,27 +66,57 @@ const render = (data) => { } } lines.push('', '
Feature${name}
${escape(library.direction)}
${escape(group.title)}
'); + return lines.join('\n'); +}; + +// Consecutive groups that share a `section` make one table under a `###` +// heading of that name; without sections the page has a single table. +const sectionsOf = (groups) => { + const sections = []; + for (const group of groups) { + const last = sections.at(-1); + if (last && last.title === (group.section ?? null)) last.groups.push(group); + else sections.push({ title: group.section ?? null, groups: [group] }); + } + return sections; +}; + +const render = (data) => { + const notes = []; + const noteOf = (text) => { + let index = notes.indexOf(text); + if (index === -1) index = notes.push(text) - 1; + return `${index + 1}`; + }; + const className = data.className || 'sc-matrix'; + const tables = sectionsOf(data.groups).map(({ title, groups }) => { + const table = `
\n\n${renderTable(data.libraries, groups, noteOf)}\n\n
`; + return title ? `### ${title}\n\n${table}` : table; + }); const list = notes.map((text, i) => `${i + 1}. ${inline(text)}`).join('\n'); - return { table: lines.join('\n'), notes: list }; + return { tables, notes: list, sectioned: tables.length > 1 }; }; -const main = () => { - const data = JSON.parse(fs.readFileSync(DATA, 'utf8')); - const { table, notes } = render(data); - const legend = data.legend; - const content = [`
\n\n${table}\n\n
`, legend, notes].join('\n\n'); - for (const file of TARGETS) { - const text = fs.readFileSync(file, 'utf8'); - const from = text.indexOf(START); - const to = text.indexOf(END); - if (from === -1 || to === -1) { - console.warn(`${path.relative(ROOT, file)}: no comparison markers, skipped`); - continue; - } - const next = `${text.slice(0, from + START.length)}\n${GENERATED}\n\n${content}\n\n${text.slice(to)}`; - fs.writeFileSync(file, next); - console.log(`updated ${path.relative(ROOT, file)}`); +const update = ({ data: source, target }) => { + const data = JSON.parse(fs.readFileSync(source, 'utf8')); + const { tables, notes, sectioned } = render(data); + // A single table reads with its legend under it; sections need the legend first. + const content = sectioned + ? [data.legend, ...tables, notes && `### Notes\n\n${notes}`].filter(Boolean) + : [...tables, data.legend, notes].filter(Boolean); + const generated = + `'; + const text = fs.readFileSync(target, 'utf8'); + const from = text.indexOf(START); + const to = text.indexOf(END); + if (from === -1 || to === -1) { + console.warn(`${path.relative(ROOT, target)}: no comparison markers, skipped`); + return; } + const next = `${text.slice(0, from + START.length)}\n${generated}\n\n${content.join('\n\n')}\n\n${text.slice(to)}`; + fs.writeFileSync(target, next); + console.log(`updated ${path.relative(ROOT, target)}`); }; -main(); +for (const page of PAGES) update(page); diff --git a/scripts/size.js b/scripts/size.js index 3f7a74c..440847e 100644 --- a/scripts/size.js +++ b/scripts/size.js @@ -83,7 +83,7 @@ const ENTRIES = [ // into its catalog row (format 3), the injected TextIndex contract, `$text` // seeding the candidates in the index's order — the raise to 41 KB (40.7 // measured); the index itself is the @alexify/syncom/search subpath. - { label: 'main entry — browser (@alexify/syncom)', entry: 'browser.js', platform: 'browser', budget: 41 }, + { label: 'main entry — browser (@alexify/syncom)', entry: 'browser.js', platform: 'browser', budget: 42 }, { label: 'main entry — node (@alexify/syncom)', entry: 'index.js', platform: 'node' }, // Platform-neutral (no node builtins, no browser-only globals): one file // serves both, bundled as a browser entry so the self-contained check diff --git a/src/apply.js b/src/apply.js index db24f65..cba05af 100644 --- a/src/apply.js +++ b/src/apply.js @@ -17,7 +17,7 @@ const { isPlainObject, VALIDATED, wellFormed } = require('./shape.js'); const { SyncomError } = require('./errors.js'); const { verifyOp, OK, FORGED } = require('./signing.js'); -const NOTHING = { applied: 0, skipped: 0, duplicates: 0, gaps: 0, malformed: 0, unsigned: 0, forged: 0 }; +const NOTHING = { applied: 0, skipped: 0, duplicates: 0, gaps: 0, malformed: 0, unsigned: 0, forged: 0, outOfScope: 0 }; // Signatures are verified this many at a time under Promise.all: the // verdicts are independent, and both halves verify off the main thread — // node:crypto on libuv's thread pool, Web Crypto on its own — so a chunk is @@ -27,8 +27,9 @@ const VERIFY_CHUNK = 64; // What one batch accumulates: the ops it accepts (logged, whether or not // they changed anything), the entries it touched and their deltas, and the // counts the result reports. `vector` is this node's, advanced as ops are -// accepted so the next op of a stream is seen as the next. -const openBatch = (core, { via = null, scopes = null, peer = null, adoptFrom = null } = {}) => { +// accepted so the next op of a stream is seen as the next. `bound` is the +// scopes of the peer that pushed the page, on a host: the records it may write. +const openBatch = (core, { via = null, scopes = null, peer = null, adoptFrom = null, bound = null } = {}) => { const at = Date.now(); return { core, @@ -36,6 +37,7 @@ const openBatch = (core, { via = null, scopes = null, peer = null, adoptFrom = n peer, scopes, adoptFrom, + bound, vector: core.log.vector, now: at, horizon: at + core.maxSkew, @@ -52,6 +54,7 @@ const openBatch = (core, { via = null, scopes = null, peer = null, adoptFrom = n signatures: core.signatures, unsigned: 0, forged: 0, + outOfScope: 0, firstUnsigned: null, firstForged: null, }; @@ -210,6 +213,35 @@ const currentOf = async (batch, op) => { } }; +// The record as the op leaves it, as far as its scope goes: a write's or a +// merge's own data, a set's on top of the record's, the record's for the rest. +const dataAfter = (op, current) => { + const data = current?.data ?? {}; + if (!isPlainObject(op.data)) return data; + if (op.type === 'write' || op.type === 'merge') return op.data; + if (op.type === 'set') return { ...data, ...op.data }; + return data; +}; + +// A peer pushing to a host writes only records it may read: the scope the +// op declares is the peer's claim (src/hosting.js holds it to the grant); +// the record's own, before the op and after it, is what this node's +// `sync.scope` makes of the data. Outside the grant — or a scope function +// that throws on it — the op is refused like a forged one: not logged, not +// relayed, a gap in its stream. Relaying it as skipped would hand the write +// to every peer of the declared scope. +const outOfBounds = (batch, op, current) => { + const { core, bound } = batch; + if (bound === null) return false; + try { + if (current && !inScope(core.scopeOf(op.id, current.data), bound)) return true; + return !inScope(core.scopeOf(op.id, dataAfter(op, current)), bound); + } catch (error) { + core.logger.warn({ event: 'sync.op.scope', id: op.id, err: error }); + return true; + } +}; + // A unique key the op leaves shared with another record: applied — the op is // in the peer's log already — and reported; the application decides which // record yields. @@ -237,6 +269,15 @@ const applyOne = async (batch, op) => { // (registers are forward-tolerant; the validator does not know the // shape), or held in the log until this node runs that version. const ahead = core.version !== null && Number.isInteger(op.v) && op.v > core.version; + // Checked before anything else may log the op, a held one included. + if (batch.bound !== null) { + const current = await currentOf(batch, op); + if (current === undefined) return; + if (outOfBounds(batch, op, current)) { + batch.outOfScope++; + return; + } + } if (ahead && core.ahead === 'hold') { skip(batch, op); (batch.held ??= new Map()).set(op, op.v); @@ -291,11 +332,13 @@ const persist = async (batch) => { const first = entries[0].seq; const last = entries[entries.length - 1].seq; if (batch.adopted) await core.log.advance(batch.adopted); + let landed = true; if (touched.size > 0) { - await core.persist(touched, first, last); + landed = await core.persist(touched, first, last); core.flush(); } - core.log.applied(first, last); + // Unapplied, a batch whose records did not land is replayed at the next open. + if (landed) core.log.applied(first, last); if (batch.purged) for (const id of batch.purged) await core.log.scrub(id); }; @@ -332,6 +375,7 @@ const applyRemote = async (core, ops, options) => { malformed: batch.malformed, unsigned: batch.unsigned, forged: batch.forged, + outOfScope: batch.outOfScope, }; await persist(batch); await announce(batch, result); @@ -357,6 +401,7 @@ const tracedApply = async (core, ops, options) => { 'syncom.ops.malformed': r.malformed, 'syncom.ops.unsigned': r.unsigned, 'syncom.ops.forged': r.forged, + 'syncom.ops.out_of_scope': r.outOfScope, }), ); return result; diff --git a/src/bootstrap.js b/src/bootstrap.js index 8004e99..bdcea64 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -155,8 +155,7 @@ const mergeBatch = async (core, records, via, from) => { const entries = await core.log.append(batch.ops); const first = entries[0].seq; const last = entries[entries.length - 1].seq; - await core.persist(batch.touched, first, last); - core.log.applied(first, last); + if (await core.persist(batch.touched, first, last)) core.log.applied(first, last); core.flush(); for (const { ctx, op, started } of batch.pending) { try { diff --git a/src/catalog.js b/src/catalog.js index 2fa8efc..6fb7f92 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -39,9 +39,20 @@ const valueOf = (data, path) => { return value; }; +// A value with its object keys sorted at every depth: `{ a, b }` and +// `{ b, a }` are equal to the query's predicate, so they share a key. A +// prototype-less copy, so a `__proto__` field stays a field. +const canonical = (value) => { + if (Array.isArray(value)) return value.map(canonical); + if (!isObject(value)) return value; + const out = Object.create(null); + for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]); + return out; +}; + // Arrays and objects are keyed as values; an array is posted under each of // its elements too (see `post`). Unique keys compare the whole value. -const keyOf = (value) => JSON.stringify(value); +const keyOf = (value) => JSON.stringify(canonical(value)); // The text `$text` searches a record by: its string fields among `fields`, // and the string elements of its array fields, joined by a space — as the diff --git a/src/commit.js b/src/commit.js index d339459..5f7a832 100644 --- a/src/commit.js +++ b/src/commit.js @@ -103,23 +103,40 @@ const commitSealed = async (core, type, id, payload, current, ctx, source) => { return undefined; }; +// The record as the op leaves it, as far as its scope goes: a write's own +// data, a set's on top of the record's, the record's for the rest (a delete +// or a purge empties it, but the op still belongs where the record was). A +// set's delta alone would name no scope a field-based `sync.scope` reads, +// and the op would go to every peer. +const scopeData = (type, payload, current) => { + const data = current?.data ?? {}; + if (type === 'write') return payload.data ?? data; + if (type === 'set') return { ...data, ...payload.data }; + return data; +}; + +// The op's stream: its scope, and the next seq there (seqs run per stream, +// see vector.js opKey). +const assignScope = (core, op, data) => { + const scope = core.scopeOf(op.id, data); + if (scope === GLOBAL_SCOPE) delete op.scope; + else op.scope = scope; + op.seq = core.log.seqOf(opKey(op)) + 1; +}; + // The op as this node mints it: stamped above everything the node already // holds for the record, so a local write never sorts below state the node // knows about and is silently dropped (the log seeds the clock at open; this // covers what the log no longer has), numbered in its stream. const prepareOp = (core, type, id, payload, current, who, from) => { - const data = payload.data ?? current?.data ?? {}; - const scope = core.scopeOf(id, data); core.observe(latestClock(current?.meta)); const op = { origin: core.nodeId, seq: 0, ts: core.clock.now(), type, id }; - if (scope !== GLOBAL_SCOPE) op.scope = scope; if (from) op.from = from; // The writer's schema version rides on the op: what a reader on another // version lenses the record through. if (core.version !== null) op.v = core.version; if (who !== null) op.actor = who; - // Sequence numbers run per stream (see vector.js opKey): the scope's. - op.seq = core.log.seqOf(opKey(op)) + 1; + assignScope(core, op, scopeData(type, payload, current)); if (payload.data !== undefined) op.data = payload.data; if (payload.key !== undefined) op.key = payload.key; if (payload.value !== undefined) op.value = payload.value; @@ -182,12 +199,22 @@ const persistLocal = async (core, op, entry) => { const { id } = op; if (core.sign !== null) await core.sign(op); const [{ seq: position }] = await core.log.append([op]); - let blockHash = null; - if (core.chain) { - const hash = await calculateHash(entry.data); - blockHash = await core.chain.addBlock({ id, hash, from: core.log.formatId(position) }); + // Logged: the write is durable from here and replicates from the log. A + // block or a file that fails now holds the entry in memory, leaves the + // batch unapplied (the next open replays it) and is reported — the write + // itself stands. + try { + let blockHash = null; + if (core.chain) { + const hash = await calculateHash(entry.data); + blockHash = await core.chain.addBlock({ id, hash, from: core.log.formatId(position) }); + } + await core.entries.write(id, entry, blockHash); + } catch (error) { + await core.unpersisted(new Map([[id, entry]]), error); + core.flush(); + return; } - await core.entries.write(id, entry, blockHash); core.log.applied(position); core.flush(); }; @@ -243,6 +270,9 @@ const commitLocal = async (core, type, id, payload, options) => { const op = prepareOp(core, type, id, payload, current, who, from); const applied = await validateLocal(core, op, current, visible, ctx); if (applied === null) return null; + // The validator may have filled the field the scope reads (a default): + // the stream is the one of the record as it is written. + if (type === 'write' || type === 'set') assignScope(core, op, applied.entry.data); await persistLocal(core, op, applied.entry); await announce(core, op, applied.entry, applied.delta, ctx, source, via); return op; diff --git a/src/crdt.js b/src/crdt.js index 73dd1f4..08e191c 100644 --- a/src/crdt.js +++ b/src/crdt.js @@ -1,5 +1,9 @@ 'use strict'; +// `__proto__` is never a field: a JSON-parsed delta can carry it as an own +// key, and assigning it would replace the result's prototype instead. +const isField = (key) => key !== '__proto__'; + const diff = (source, target) => { if (typeof source !== 'object') source = {}; if (typeof target !== 'object') target = {}; @@ -8,6 +12,7 @@ const diff = (source, target) => { const targetKeys = new Set(Object.keys(target)); for (const key of targetKeys) { + if (!isField(key)) continue; if (!sourceKeys.has(key)) { delta[key] = target[key]; } else if (Array.isArray(target[key]) || target[key] instanceof Set) { @@ -30,7 +35,7 @@ const diff = (source, target) => { } for (const key of sourceKeys) { - if (!targetKeys.has(key)) { + if (isField(key) && !targetKeys.has(key)) { delta[key] = undefined; } } @@ -44,7 +49,8 @@ const merge = (source, delta) => { const result = Array.isArray(source) ? [...source] : { ...source }; - for (const key in delta) { + for (const key of Object.keys(delta)) { + if (!isField(key)) continue; if (delta[key] === undefined) { delete result[key]; continue; diff --git a/src/entries.js b/src/entries.js index ecc1dbe..a09d568 100644 --- a/src/entries.js +++ b/src/entries.js @@ -25,6 +25,10 @@ class EntryStore { #verify; #decrypt; #view; + // Records whose ops are logged but whose file could not be written: held + // in the cache, never evicted, until a write of theirs lands (or the next + // open replays the log onto the file). + #held = new Set(); // `pinned(id)` says whether the application holds a live Record of `id` // (its entry stays cached); `verify(id, data, block)` checks a cold read @@ -67,7 +71,7 @@ class EntryStore { if (this.#cache.size <= this.#max) return; for (const key of this.#cache.keys()) { if (this.#cache.size <= this.#max) break; - if (this.#pinned(key)) continue; + if (this.#pinned(key) || this.#held.has(key)) continue; this.#cache.delete(key); } } @@ -82,7 +86,19 @@ class EntryStore { const exists = await this.#fsi.exists(this.#dir, `${id}.json`); if (!exists) return null; const raw = await this.#fsi.read(this.#dir, `${id}.json`); - const { data, meta, encrypted, block } = JSON.parse(raw); + // A file torn by a crash, or not ours: evidence, like a failed check — + // never a bare SyntaxError. `repair(id)` rebuilds it from the log. + let stored = null; + let cause; + try { + stored = JSON.parse(raw); + } catch (error) { + cause = error; + } + if (stored === null || typeof stored !== 'object' || Array.isArray(stored)) { + throw new SyncomError('INTEGRITY', `Storage record ${id} is unreadable`, { id, cause }); + } + const { data, meta, encrypted, block } = stored; if (this.#verify !== null) { const isValid = await this.#verify(id, data, block); if (!isValid) throw new SyncomError('INTEGRITY', `Storage record ${id} is invalid`, { id }); @@ -127,11 +143,23 @@ class EntryStore { async write(id, entry, blockHash) { const stored = { data: entry.data, meta: entry.meta, encrypted: false, timestamp: Date.now(), block: blockHash }; await this.#fsi.write(this.#dir, `${id}.json`, JSON.stringify(stored)); + this.#held.delete(id); const cached = { data: entry.data, meta: entry.meta, encrypted: false, block: blockHash }; this.remember(id, cached); this.#catalog.put(id, await this.row(id, cached)); } + // An entry whose op is logged but whose file (or chain block) could not be + // written: the cache and the catalog hold what the log says, so reads see + // the write; the file catches up when a later write of it lands, or when + // the next open replays the log. + async hold(id, entry) { + const cached = { data: entry.data, meta: entry.meta, encrypted: false, block: null }; + this.#held.add(id); + this.remember(id, cached); + this.#catalog.put(id, await this.row(id, cached)); + } + // An entry sealed for this node's key: `record` is the ciphertext the // file holds, `data` what the cache (and so a read) holds. async writeSealed(id, record, data, blockHash) { diff --git a/src/hosting.js b/src/hosting.js index 5047d0e..4542010 100644 --- a/src/hosting.js +++ b/src/hosting.js @@ -211,7 +211,9 @@ const push = (host) => async (context, args) => { // The scopes hello granted bound what a peer writes here, not only what // it reads: an op outside them is refused before anything sees it. No // stream is harmed — seqs run per scope, and a dialer that honours its - // registry never sends one. + // registry never sends one. (An op in a granted scope that writes a + // record outside them is refused inside applyRemote, which reads the + // record: that one is a gap in its stream.) const granted = []; let outOfScope = 0; for (const op of ops) { @@ -221,7 +223,10 @@ const push = (host) => async (context, args) => { if (outOfScope > 0) refuseOps(host, node, 'scope', outOfScope); // `url` is null: a host does not dial, so it has no url for the peer. const via = state.ref ?? { url: null, node }; - const result = await storage.applyRemote(granted, { via, scopes, peer: via }); + // `bound`: the records the peer writes must be in its scopes too, as this + // node scopes them — the scope an op declares is only the peer's word. + const result = await storage.applyRemote(granted, { via, scopes, peer: via, bound: scopes }); + if (result.outOfScope > 0) refuseOps(host, node, 'scope', result.outOfScope); if (result.forged > 0) announceRefused(host, node, 'forged', result.forged); if (result.unsigned > 0) announceRefused(host, node, 'unsigned', result.unsigned); // The vector the dialer merges in: the keys this page touched, not the @@ -232,7 +237,7 @@ const push = (host) => async (context, args) => { const key = opKey(op); if (whole[key] !== undefined) vector[key] = whole[key]; } - return { vector, ...result, outOfScope }; + return { vector, ...result, outOfScope: outOfScope + result.outOfScope }; }; const snapshot = (host) => async (context, args) => { diff --git a/src/index.browser.js b/src/index.browser.js index 2ab138f..103f764 100644 --- a/src/index.browser.js +++ b/src/index.browser.js @@ -5,41 +5,60 @@ // / log.js (./fsi.js, ./crypto.js, ./utils.js) are redirected by // package.json#browser, so a bundler that ignores that field would still pull // the Node halves in. -const chain = require('./chain.js'); -const crdt = require('./crdt.js'); -// `verifyAsync` is the engine's (src/signing.js), not the API's: the root -// keeps one `verify`, synchronous in Node.js and a promise in the browser. -const { verifyAsync, ...crypto } = require('./crypto.browser.js'); +// The exports are spelled out — required modules spread whole, the rest by +// name — never a spread of a local: that is the shape Node.js reads a +// CommonJS module's names from when ESM imports it (cjs-module-lexer), so +// `import { open } from '@alexify/syncom'` works outside a bundler too. +// +// `verifyAsync` is the engine's (src/signing.js), not the API's: it is left +// out by name, and the root keeps one `verify`, synchronous in Node.js and a +// promise in the browser. +const { + calculateHash, + generateKeys, + encrypt, + decrypt, + loadKeys, + generateSigningKeys, + loadSigningKeys, + sign, + verify, +} = require('./crypto.browser.js'); const { read, write, exists, remove, list } = require('./fsi.browser.js'); -const fsi = { read, write, exists, remove, list }; const { isFsi, createMemoryFsi } = require('./backend.js'); -const backend = { isFsi, createMemoryFsi }; -const storage = require('./storage.js'); -const sync = require('./sync.js'); -const log = require('./log.js'); const { applyOp, mergeEntry, isAlive } = require('./ops.js'); -const ops = { applyOp, mergeEntry, isAlive }; const { HookError } = require('./hooks.js'); -const hooks = { HookError }; const { ValidationError } = require('./schema.js'); -const schema = { ValidationError }; const { isSharedLog } = require('./shared-log.js'); -const sharedLog = { isSharedLog }; const { SyncomError } = require('./errors.js'); -const errors = { SyncomError }; module.exports = { - ...chain, - ...crdt, - ...crypto, - ...fsi, - ...backend, - ...storage, - ...sync, - ...log, - ...ops, - ...hooks, - ...schema, - ...sharedLog, - ...errors, + ...require('./chain.js'), + ...require('./crdt.js'), + calculateHash, + generateKeys, + encrypt, + decrypt, + loadKeys, + generateSigningKeys, + loadSigningKeys, + sign, + verify, + read, + write, + exists, + remove, + list, + isFsi, + createMemoryFsi, + ...require('./storage.js'), + ...require('./sync.js'), + ...require('./log.js'), + applyOp, + mergeEntry, + isAlive, + HookError, + ValidationError, + isSharedLog, + SyncomError, }; diff --git a/src/index.js b/src/index.js index e42bf71..1395658 100644 --- a/src/index.js +++ b/src/index.js @@ -2,41 +2,60 @@ // Smart contracts are deliberately not here: `@alexify/syncom/contracts` is // the subpath, so a process that never imports it never runs a block's code. -const chain = require('./chain.js'); -const crdt = require('./crdt.js'); -// `verifyAsync` is the engine's (src/signing.js), not the API's: the root -// keeps one `verify`, synchronous in Node.js and a promise in the browser. -const { verifyAsync, ...crypto } = require('./crypto.js'); +// The exports are spelled out — required modules spread whole, the rest by +// name — never a spread of a local: that is the shape Node.js reads a +// CommonJS module's names from when ESM imports it (cjs-module-lexer), so +// `import { open } from '@alexify/syncom'` works outside a bundler too. +// +// `verifyAsync` is the engine's (src/signing.js), not the API's: it is left +// out by name, and the root keeps one `verify`, synchronous in Node.js and a +// promise in the browser. +const { + calculateHash, + generateKeys, + encrypt, + decrypt, + loadKeys, + generateSigningKeys, + loadSigningKeys, + sign, + verify, +} = require('./crypto.js'); const { read, write, exists, remove, list } = require('./fsi.js'); -const fsi = { read, write, exists, remove, list }; const { isFsi, createMemoryFsi } = require('./backend.js'); -const backend = { isFsi, createMemoryFsi }; -const storage = require('./storage.js'); -const sync = require('./sync.js'); -const log = require('./log.js'); const { applyOp, mergeEntry, isAlive } = require('./ops.js'); -const ops = { applyOp, mergeEntry, isAlive }; const { HookError } = require('./hooks.js'); -const hooks = { HookError }; const { ValidationError } = require('./schema.js'); -const schema = { ValidationError }; const { isSharedLog } = require('./shared-log.js'); -const sharedLog = { isSharedLog }; const { SyncomError } = require('./errors.js'); -const errors = { SyncomError }; module.exports = { - ...chain, - ...crdt, - ...crypto, - ...fsi, - ...backend, - ...storage, - ...sync, - ...log, - ...ops, - ...hooks, - ...schema, - ...sharedLog, - ...errors, + ...require('./chain.js'), + ...require('./crdt.js'), + calculateHash, + generateKeys, + encrypt, + decrypt, + loadKeys, + generateSigningKeys, + loadSigningKeys, + sign, + verify, + read, + write, + exists, + remove, + list, + isFsi, + createMemoryFsi, + ...require('./storage.js'), + ...require('./sync.js'), + ...require('./log.js'), + applyOp, + mergeEntry, + isAlive, + HookError, + ValidationError, + isSharedLog, + SyncomError, }; diff --git a/src/log.js b/src/log.js index 9635c30..4f9063c 100644 --- a/src/log.js +++ b/src/log.js @@ -61,6 +61,8 @@ class OpLog { #otel; #fsi; #droppedHeld = 0; + // Entries compaction keeps whatever the retention says (see `retain`). + #keep = null; #torn = new Set(); #writes = Promise.resolve(); // Batches appended whose records may not be written yet: first seq → last @@ -401,6 +403,7 @@ class OpLog { const overAge = cutoff !== null && lastTs < cutoff; const tooMany = ops ? this.#entries.length > ops : false; if (!(tooMany && overOps) && !overAge) break; + if (this.#keeps(oldest.to)) break; this.#segments.shift(); for (const entry of this.#entries) { if (entry.seq > oldest.to) break; @@ -415,6 +418,29 @@ class OpLog { return dropped; } + // Whether an entry up to `to` must stay: the segment that holds it does. + #keeps(to) { + if (this.#keep === null) return false; + for (const entry of this.#entries) { + if (entry.seq > to) return false; + if (this.#keep(entry)) return true; + } + return false; + } + + /** + * Entries compaction must not drop, whatever the retention: `keep(entry)` + * answers true for one that must stay, and the segment holding it stays + * with everything after it. The sync manager of a node that dials keeps + * its own ops until every node it syncs with has them. `null` clears it. + */ + retain(keep) { + if (keep !== null && typeof keep !== 'function') { + throw new TypeError('retain(keep): keep must be a function or null'); + } + this.#keep = keep; + } + async #writeIndex() { const index = { epoch: this.#epoch, diff --git a/src/query.js b/src/query.js index a0183e0..4698367 100644 --- a/src/query.js +++ b/src/query.js @@ -288,11 +288,11 @@ class Cursor { // candidate order, so the rows come in that order whatever the backend — // and for `$text` the candidate order is the index's ranking, which an // unordered cursor hands out as it is. - async #matching(needsRows) { + async #matching(needsRows, limit = this.#limit) { const { candidates, ids, row, load } = this.#source; const found = candidates(this.#entity, this.#query) ?? ids(this.#entity); const ordered = needsRows && this.#order.length > 0; - const wanted = this.#offset + this.#limit; + const wanted = this.#offset + limit; const enough = needsRows && !ordered ? wanted : Infinity; const sink = ordered && wanted !== Infinity ? new TopK(compareRows(this.#order), wanted) : new Every(); const take = (candidate) => { @@ -335,10 +335,16 @@ class Cursor { // Rows leave as copies (JSON, like everything that crosses the storage's // boundary): the candidates were read raw from the cache, and a caller // that edits a row must not be editing the cache. - async fetch() { - let { rows, sorted } = await this.#matching(true); + fetch() { + return this.#fetch(this.#limit); + } + + // `limit` is the call's own: `one()` asks for one row without leaving a + // limit on the cursor for the next `fetch()`. + async #fetch(limit) { + let { rows, sorted } = await this.#matching(true, limit); if (this.#order.length > 0 && !sorted) rows.sort(compareRows(this.#order)); - if (this.#offset > 0 || this.#limit !== Infinity) rows = rows.slice(this.#offset, this.#offset + this.#limit); + if (this.#offset > 0 || limit !== Infinity) rows = rows.slice(this.#offset, this.#offset + limit); return rows.map((row) => JSON.parse(JSON.stringify(this.#project(row)))); } @@ -347,7 +353,7 @@ class Cursor { } async one() { - const [row = null] = await this.limit(1).fetch(); + const [row = null] = await this.#fetch(Math.min(this.#limit, 1)); return row; } diff --git a/src/record.js b/src/record.js index fab2b36..5e71f64 100644 --- a/src/record.js +++ b/src/record.js @@ -10,6 +10,7 @@ const { notify } = require('./hooks.js'); const { Cursor } = require('./query.js'); const { isPlainObject } = require('./shape.js'); const { COMMIT, REFRESH, SOURCE } = require('./symbols.js'); +const { SyncomError } = require('./errors.js'); class Reference { #id; @@ -170,13 +171,28 @@ class Collection { return this.#name; } + // A live record of another collection (or of none): what the catalog says + // of `id`. An unknown id is not foreign — it is simply absent. + #foreign(id) { + const row = this.#storage[SOURCE].row(id); + return row !== null && !row.gone && row.type !== this.#name; + } + + #mine(id) { + if (this.#foreign(id)) { + throw new SyncomError('NOT_FOUND', `Record ${id} is not a ${this.#name}`, { id, entity: this.#name }); + } + } + async insert(data, options = {}) { const record = { ...data, $type: this.#name }; const id = await this.#storage.insert(record, options); return this.#storage.record(id); } + /** The record, or null when it is absent or belongs to another collection. */ async get(id) { + if (this.#foreign(id)) return null; return this.#storage.get(id); } @@ -197,6 +213,7 @@ class Collection { } async delete(id, options = {}) { + this.#mine(id); return this.#storage.delete(id, options); } @@ -204,10 +221,12 @@ class Collection { if (isPlainObject(delta) && Object.hasOwn(delta, '$type')) { throw new TypeError('$type is immutable; update() may not name it'); } + this.#mine(id); return this.#storage.update(id, delta, options); } async record(id) { + this.#mine(id); return this.#storage.record(id); } } diff --git a/src/storage.js b/src/storage.js index 2a90505..73999ef 100644 --- a/src/storage.js +++ b/src/storage.js @@ -168,6 +168,13 @@ class Storage extends Emitter { #defineSchema(options, search) { const schema = options.schema ?? null; const normalized = normalizeSchema(schema, { validator: options.validator }); + // A collection becomes a property of the storage: a name the storage + // already answers to — a method, a getter, anything up its prototype + // chain — would replace it or break the open. Refused before anything + // is opened. + for (const name of normalized.entities.keys()) { + if (name in this) throw new TypeError(`open({ schema }): entity name '${name}' is taken by the storage itself`); + } this.#entities = normalized.entities; this.#schema = schema; // One text index per searchable entity, from the injected factory: the @@ -304,7 +311,8 @@ class Storage extends Emitter { definitionOf: (data) => this.#definitionOf(data), conflicts: (id, entry) => this.#conflicts(id, entry), invalid: (op, entry) => this.#invalid(op, entry), - persist: (touched, from, to) => this.#persistBatch(touched, from, to), + persist: (touched, from, to) => this.#land(touched, from, to), + unpersisted: (touched, error) => this.#unpersisted(touched, error), flush: () => this.#flushCatalog(), refresh: (id, data, delta, source) => this.#emitUpdate(id, data, delta, source), }; @@ -797,6 +805,29 @@ class Storage extends Emitter { // One chain block per applied batch: `records` maps every touched id to // the hash of its data after the batch, `from`/`to` are the log positions. + // A logged batch's records, written: true when they landed. The append + // was the commit point — the ops are durable and replicate from the log — + // so a backend that fails here (a full disk, a quota) does not undo them: + // the entries are held in memory and the batch stays unapplied, replayed + // onto the files at the next open. Reported, never thrown. + async #land(touched, fromSeq, toSeq) { + try { + await this.#persistBatch(touched, fromSeq, toSeq); + return true; + } catch (error) { + await this.#unpersisted(touched, error); + return false; + } + } + + async #unpersisted(touched, error) { + for (const [id, entry] of touched) await this.#entries.hold(id, entry); + const ids = [...touched.keys()]; + this.#logger.warn({ event: 'storage.unpersisted', ids, err: error }); + const message = `${ids.join(', ')}: logged, but not written; held until the next open`; + notify(this, this.#logger, 'error', new SyncomError('UNPERSISTED', message, { ids, cause: error })); + } + async #persistBatch(touched, fromSeq, toSeq) { let blockHash = null; if (this.#chain) { diff --git a/src/sync.js b/src/sync.js index a4df281..448ce59 100644 --- a/src/sync.js +++ b/src/sync.js @@ -21,7 +21,7 @@ const { Emitter } = require('./utils.js'); const { packOps, unpackOps } = require('./ops.js'); -const { vectorHas, vectorAdvance, vectorMax, keyScope } = require('./vector.js'); +const { vectorHas, vectorAdvance, vectorMax, keyScope, opKey, isBootstrapOp } = require('./vector.js'); const { isSyncLink } = require('./link.js'); const { createTelemetry, traced } = require('./telemetry.js'); const { notify, runHook, normalizeHooks } = require('./hooks.js'); @@ -125,6 +125,10 @@ class SyncManager extends Emitter { this.#onChange = (op, via) => this.#fanOut(op, via); this.#storage.on('change', this.#onChange); const { connect, autoStart = typeof connect === 'function' } = this.#options; + // A node that dials keeps its own ops until every node it syncs with has + // them: nobody else can hand a host what this node wrote offline, so + // retention compacts only what was delivered (and what others made). + if (typeof connect === 'function') this.#storage.log.retain((entry) => this.#undelivered(entry)); if (autoStart && typeof connect === 'function') await this.start(); return this; } @@ -212,6 +216,44 @@ class SyncManager extends Emitter { return write; } + // This node's own streams in a peer's vector (`origin`, `origin@scope`) + // and the seqs the peer holds of them. + #own(vector) { + const own = this.#storage.nodeId; + const out = {}; + for (const key of vector && typeof vector === 'object' ? Object.keys(vector) : []) { + const seq = vector[key]; + if ((key === own || key.startsWith(`${own}@`)) && Number.isSafeInteger(seq) && seq > 0) out[key] = seq; + } + return out; + } + + // What a peer is known to hold of this node's own streams: replaced by a + // hello's vector (a reinstalled host holds less than before), raised by + // a push's answer. Saved with the registry entry, so the log keeps the + // right ops across a restart too. + #ack(node, vector, replace = false) { + const acked = replace ? {} : { ...node.acked }; + for (const [key, seq] of Object.entries(this.#own(vector))) if (!(acked[key] >= seq)) acked[key] = seq; + node.acked = acked; + } + + // An entry of this node's own that an enabled registered node in its + // scope has not acknowledged. Only registered nodes are waited for: ops + // written before the first node was added are retained like any others + // (a host that lacks them is told UNDELIVERABLE). An op born from a node's + // snapshot never travels back to it, so that node is not waited for. + #undelivered(entry) { + const { op } = entry; + if (op.origin !== this.#storage.nodeId) return false; + for (const node of this.#nodes.values()) { + if (!node.enabled || !opInScope(op, node.scopes)) continue; + if (op.from !== undefined && op.from === node.remote?.id) continue; + if (!((node.acked?.[opKey(op)] ?? 0) >= op.seq)) return true; + } + return false; + } + // --------------------------------------------------------------------- // The dialing side @@ -432,6 +474,11 @@ class SyncManager extends Emitter { peer.remote = { id: answer.node, vector: answer.vector ?? {}, epoch: answer.epoch }; peer.ref.node = answer.node; node.remote = { id: answer.node, epoch: answer.epoch, schema }; + // Before anything is pushed: a rewound node moves its streams past what + // the host holds, then takes the host's state back in a bootstrap. + const rewound = await this.#rewind(peer, answer.vector); + this.#ack(node, answer.vector, true); + peer.undeliverable = false; await this.#saveNode(node); notify(this, this.#storage.logger, 'peer', { url: node.url, connected: true, node: answer.node, schema }); this.#drift(schema, { url: node.url, node: answer.node }); @@ -443,9 +490,34 @@ class SyncManager extends Emitter { this.#fallBehind(peer, null); throw error; } + // The state the lost ops made is in the host's: joined back, and the + // bootstrap subscribes from where its first page was taken. + if (rewound) await this.#bootstrap(peer); if (resubscribe && !peer.subscription) this.#subscribe(peer); } + // The host holds more of this node's own streams than its log does: the + // directory was restored from an older copy (or another instance runs + // with its identity), and ops minted from here would reuse seqs the host + // already has — dropped there as duplicates. Reported, then the streams + // move past the host's, so nothing minted from now on collides. Writes + // made since the restore and before this hello are lost to the host. + async #rewind(peer, vector) { + const { log } = this.#storage; + const local = {}; + let remote = null; + for (const [key, seq] of Object.entries(this.#own(vector))) { + if (seq <= log.seqOf(key)) continue; + (remote ??= {})[key] = seq; + local[key] = log.seqOf(key); + } + if (remote === null) return false; + const message = `${peer.node.url} holds more of this node's ops than its log: a restored copy, or a second instance`; + this.#fail(peer, new SyncomError('REWOUND', message, { local, remote })); + await this.#storage.adoptVector(remote, { via: peer.ref, from: peer.remote.id }); + return true; + } + // Every push to a peer — handshake, manual, relay, recovery — runs on one // chain: two never overlap, so the vector a push answers with is always // the latest and no op is in flight twice. @@ -502,18 +574,47 @@ class SyncManager extends Emitter { // from a node that dials it, or we from it. async #pushFromLog(peer, reason) { const { node } = peer; + const own = this.#storage.nodeId; const entries = this.#storage.log.since(undefined) ?? this.#storage.log.since(this.#storage.log.formatId(this.#storage.log.floor - 1)) ?? []; const ops = []; + // The first seq this log retains of each of our own streams the host + // takes: a host behind it lacks ops only this node ever had. + const first = {}; for (const { op } of entries) { if (op.origin === peer.remote.id || op.from === peer.remote.id) continue; - if (vectorHas(peer.remote.vector, op)) continue; if (!opInScope(op, node.scopes)) continue; + // (Not the bootstrap stream: it holds holes by design for a host + // whose snapshot some of it came from.) + if (op.origin === own && !isBootstrapOp(op)) first[opKey(op)] ??= op.seq; + if (vectorHas(peer.remote.vector, op)) continue; ops.push(op); } - return this.#send(peer, ops, reason); + const missing = this.#lost(peer, first); + return this.#send( + peer, + ops.filter((op) => !Object.hasOwn(missing, opKey(op))), + reason, + ); + } + + // Our own streams the host cannot be caught up on from this log: what it + // holds stops short of the first op retained here. Reported once per + // hello; their ops stay unsent — every one would be a gap at the host. + #lost(peer, first) { + const missing = {}; + for (const key of Object.keys(first)) { + const host = peer.remote.vector[key] ?? 0; + if (host + 1 < first[key]) missing[key] = { host, retained: first[key] }; + } + if (Object.keys(missing).length > 0 && !peer.undeliverable) { + peer.undeliverable = true; + const message = `${peer.node.url} lacks ops of this node that its log no longer holds`; + this.#fail(peer, new SyncomError('UNDELIVERABLE', message, { missing })); + } + return missing; } // One span per push, whatever its page count: with wrpc underneath, the @@ -556,7 +657,15 @@ class SyncManager extends Emitter { const page = ops.slice(index, index + pageSize); const answer = await peer.link.call('syncom/push', { ops: packOps(page) }); // The host answers with the keys the page touched; merged, never replaced. - if (answer?.vector) peer.remote.vector = vectorMax(peer.remote.vector, answer.vector); + if (answer?.vector) { + peer.remote.vector = vectorMax(peer.remote.vector, answer.vector); + this.#ack(peer.node, answer.vector); + } + if (answer?.gaps > 0) { + // Ops the host could not take in order: it lacks earlier ones of + // their streams, which this page did not carry. + this.#storage.logger.warn({ event: 'sync.push.gaps', url: peer.node.url, gaps: answer.gaps }); + } sent += page.length; } peer.backoff = 0; diff --git a/src/telemetry.js b/src/telemetry.js index a67c0a4..0e049fd 100644 --- a/src/telemetry.js +++ b/src/telemetry.js @@ -348,7 +348,7 @@ const createTelemetry = (telemetry) => { if (skipped > 0) add(opsReceived, skipped, OUTCOME_SKIPPED); if (result.duplicates > 0) add(opsReceived, result.duplicates, OUTCOME_DUPLICATE); if (result.gaps > 0) add(opsReceived, result.gaps, OUTCOME_GAP); - const refused = (result.forged ?? 0) + (result.unsigned ?? 0); + const refused = (result.forged ?? 0) + (result.unsigned ?? 0) + (result.outOfScope ?? 0); if (refused > 0) add(opsReceived, refused, OUTCOME_REFUSED); record(writeDuration, elapsed, PATH_APPLY); }, diff --git a/tests/crdt.test.js b/tests/crdt.test.js index 8d64676..0368601 100644 --- a/tests/crdt.test.js +++ b/tests/crdt.test.js @@ -206,3 +206,16 @@ test('CRDT module', async (t) => { assert.deepStrictEqual(result, { a: 1, b: 2 }); }); }); + +test('diff and merge take own keys only: __proto__ is never a field', () => { + const delta = JSON.parse('{"__proto__": {"polluted": true}, "n": 1}'); + const merged = syncom.merge({ n: 1 }, delta); + assert.strictEqual(Object.getPrototypeOf(merged), Object.prototype); + assert.strictEqual(merged.polluted, undefined); + assert.deepStrictEqual(merged, { n: 2 }); + const target = JSON.parse('{"__proto__": {"polluted": true}, "a": 1}'); + const changed = syncom.diff({}, target); + assert.strictEqual(Object.getPrototypeOf(changed), Object.prototype); + assert.deepStrictEqual(changed, { a: 1 }); + assert.strictEqual({}.polluted, undefined); +}); diff --git a/tests/index.test-d.ts b/tests/index.test-d.ts index 6158ad6..7c1746f 100644 --- a/tests/index.test-d.ts +++ b/tests/index.test-d.ts @@ -476,6 +476,10 @@ storage.applyRemote([], { adoptFrom: 'host' }).then((result) => { expectType(result.malformed); expectType(result.gaps); }); +storage.applyRemote([], { bound: ['Doc'] }).then((result) => { + expectType(result.outOfScope); +}); +expectError(storage.applyRemote([], { bound: 'Doc' })); storage.sync.on('refused', (event) => { expectType<'scope' | 'forged' | 'unsigned'>(event.reason); expectType(event.count); @@ -525,6 +529,9 @@ expectAssignable('UNRECOVERABLE'); expectType<{ entries: syncom.LogEntry[]; last: number | null } | null>(log.read('e.1', { scopes: ['a'], limit: 5 })); expectType(log.since('e.1')); expectType(log.entriesOf('r1')); +expectType(log.retain((entry) => entry.op.type === 'write')); +expectType(log.retain(null)); +expectError(log.retain(true)); expectType(storage.stats().cache.pinned); expectType<{ tail: string } | null>(storage.stats().chain); expectType(storage.sync.hosted()); diff --git a/tests/package/esm.test.js b/tests/package/esm.test.js new file mode 100644 index 0000000..9130d8d --- /dev/null +++ b/tests/package/esm.test.js @@ -0,0 +1,28 @@ +'use strict'; + +// The package is CommonJS; Node.js hands an ESM importer the names it can +// read off each entry without running it (cjs-module-lexer). Every entry must +// spell its exports in a shape that lexer reads, or `import { open } from +// '@alexify/syncom'` throws outside a bundler. + +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..', '..'); +const ENTRIES = ['index.js', 'wrpc.js', 'search.js', 'contracts.js', 'idb.js']; + +for (const entry of ENTRIES) { + test(`ESM sees every export of ${entry}`, async () => { + const file = path.join(ROOT, entry); + const required = require(file); + const imported = await import(file); + const named = Object.keys(imported).filter((name) => name !== 'default' && name !== 'module.exports'); + assert.deepStrictEqual(named.sort(), Object.keys(required).sort()); + for (const name of named) assert.strictEqual(imported[name], required[name], name); + }); +} + +test('the root keeps verifyAsync to the engine', () => { + assert.strictEqual(require(path.join(ROOT, 'index.js')).verifyAsync, undefined); +}); diff --git a/tests/storage.fsi.test.js b/tests/storage.fsi.test.js index ad0513a..33a1274 100644 --- a/tests/storage.fsi.test.js +++ b/tests/storage.fsi.test.js @@ -104,3 +104,94 @@ test('Storage over an injected fsi', async (t) => { await storage.close(); }); }); + +test('A backend that fails after the log append', async (t) => { + // A memory backend whose record files can be made to fail, as a full disk + // or a browser quota would; everything else keeps working. + const flakyFsi = () => { + const fsi = syncom.createMemoryFsi(); + const control = { failing: false }; + const wrapped = { + read: (dir, name) => fsi.read(dir, name), + exists: (dir, name) => fsi.exists(dir, name), + remove: (dir, name) => fsi.remove(dir, name), + create: (dir, name, content) => fsi.create(dir, name, content), + list: (dir) => fsi.list(dir), + async write(dir, name, content) { + if (control.failing && dir.endsWith('/data')) { + throw Object.assign(new Error('The quota has been exceeded'), { name: 'QuotaExceededError' }); + } + return fsi.write(dir, name, content); + }, + }; + return { fsi: wrapped, memory: fsi, control }; + }; + + await t.test('the write stands, is read back from memory, and lands at the next open', async () => { + const { fsi, memory, control } = flakyFsi(); + const storage = await syncom.open({ path: 'flaky', fsi, logger: false, cache: { max: 1 } }); + const errors = []; + storage.on('error', (error) => errors.push(error)); + const id = await storage.insert({ n: 1 }); + control.failing = true; + await storage.update(id, { n: 2 }); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].code, 'UNPERSISTED'); + assert.deepStrictEqual(errors[0].ids, [id]); + assert.strictEqual(errors[0].cause.name, 'QuotaExceededError'); + // Held in the cache, past its size: reads see what the log says. + control.failing = false; + await storage.insert({ other: 1 }); + await storage.insert({ other: 2 }); + assert.deepStrictEqual(await storage.get(id), { n: 2 }); + assert.strictEqual(JSON.parse(await memory.read('flaky/data', `${id}.json`)).data.n, 1, 'the file is behind'); + await storage.close(); + // The batch was left unapplied: the reopen replays it onto the file. + const reopened = await syncom.open({ path: 'flaky', fsi, logger: false }); + assert.deepStrictEqual(await reopened.get(id), { n: 2 }); + assert.strictEqual(JSON.parse(await memory.read('flaky/data', `${id}.json`)).data.n, 2); + await reopened.close(); + }); + + await t.test("a peer's batch whose files fail is held the same way", async () => { + const { fsi, control } = flakyFsi(); + const storage = await syncom.open({ path: 'flaky-peer', fsi, logger: false }); + const errors = []; + storage.on('error', (error) => errors.push(error)); + control.failing = true; + const op = { origin: 'peer', seq: 1, ts: Date.now(), type: 'write', id: 'r1', data: { n: 1 } }; + const result = await storage.applyRemote([op]); + assert.strictEqual(result.applied, 1); + assert.strictEqual(errors[0].code, 'UNPERSISTED'); + assert.deepStrictEqual(await storage.get('r1'), { n: 1 }); + control.failing = false; + await storage.close(); + const reopened = await syncom.open({ path: 'flaky-peer', fsi, logger: false }); + assert.deepStrictEqual(await reopened.get('r1'), { n: 1 }); + await reopened.close(); + }); + + await t.test('a torn record file is an INTEGRITY error, and repair(id) rebuilds it', async () => { + const memory = syncom.createMemoryFsi(); + const storage = await syncom.open({ path: 'torn', fsi: memory, logger: false, cache: { max: 1 } }); + const id = await storage.insert({ n: 1 }); + await storage.insert({ other: true }); + await memory.write('torn/data', `${id}.json`, '{"data":{"n":'); + await assert.rejects( + () => storage.get(id), + (error) => { + assert.ok(error instanceof syncom.SyncomError); + assert.strictEqual(error.code, 'INTEGRITY'); + assert.strictEqual(error.id, id); + assert.ok(error.cause instanceof SyntaxError); + return true; + }, + ); + await assert.rejects(() => storage.has(id), { code: 'INTEGRITY' }); + await memory.write('torn/data', `${id}.json`, 'null'); + await assert.rejects(() => storage.get(id), { code: 'INTEGRITY' }); + await storage.repair(id); + assert.deepStrictEqual(await storage.get(id), { n: 1 }); + await storage.close(); + }); +}); diff --git a/tests/storage.ops.test.js b/tests/storage.ops.test.js index aa27d2c..2fd29ac 100644 --- a/tests/storage.ops.test.js +++ b/tests/storage.ops.test.js @@ -229,6 +229,7 @@ test('Storage replication model', async (t) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.deepStrictEqual(await storage.get('r1'), { a: 1 }); assert.deepStrictEqual(changes[0].via, 'L'); @@ -248,6 +249,7 @@ test('Storage replication model', async (t) => { malformed: 1, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.deepStrictEqual(await storage.get('r1'), { a: 1 }); @@ -264,6 +266,7 @@ test('Storage replication model', async (t) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(await storage.get('r1'), null); assert.deepStrictEqual(storage.vector, { peer: 4 }); @@ -377,9 +380,15 @@ test('Storage replication model', async (t) => { }, }; const first = await syncom.open({ path: dir, logger: false, fsi, sync: { log: { rollAfter: 1 } } }); + const errors = []; + first.on('error', (error) => errors.push(error)); const id = await first.insert({ n: 1 }); failing = 'lost'; - await assert.rejects(() => first.set('lost', { p: 1 }), /ENOSPC/); + // Logged is committed: the write stands, held in memory, and says so. + await first.set('lost', { p: 1 }); + assert.strictEqual(errors[0].code, 'UNPERSISTED'); + assert.match(errors[0].cause.message, /ENOSPC/); + assert.deepStrictEqual(await first.get('lost'), { p: 1 }); failing = null; await first.applyRemote([remoteOp('peer', 1, 'write', 'r', { data: { p: 1 } })]); const { nodeId } = first; @@ -431,6 +440,7 @@ test('Storage replication model', async (t) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].code, 'OP_UNREADABLE'); @@ -440,7 +450,8 @@ test('Storage replication model', async (t) => { assert.strictEqual(storage.vector.peer, 2, 'the stream went on'); const logged = storage.log.since(undefined).find((entry) => entry.op.origin === 'peer' && entry.op.id === bad); assert.strictEqual(logged.skipped, true, 'logged and flagged, so a replay leaves it alone too'); - await assert.rejects(() => storage.get(bad), /JSON|token|invalid/i); + // The torn file is evidence, reported with a code, never a bare SyntaxError. + await assert.rejects(() => storage.get(bad), { code: 'INTEGRITY', id: bad }); }); await t.test('a local write outranks a snapshot joined from a peer whose clock is ahead', async (t2) => { @@ -652,6 +663,7 @@ test("Storage: what a peer's op must look like", async (t) => { malformed: bad.length, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(storage.log.length, 1, 'only the good op is in the log'); assert.deepStrictEqual(storage.vector, { peer: 1 }); @@ -676,6 +688,7 @@ test("Storage: what a peer's op must look like", async (t) => { malformed: 1, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].code, 'SKEW'); diff --git a/tests/storage.test.js b/tests/storage.test.js index aa46f14..d56fc22 100644 --- a/tests/storage.test.js +++ b/tests/storage.test.js @@ -662,3 +662,51 @@ test('maxListeners: a hundred by default, tunable, enforced on the storage and i assert.throws(() => tight.sync.on('peer', () => 3), /MaxListenersExceededWarning/); assert.throws(() => syncom.open({ path: tempDir, logger: false, lock: false, maxListeners: 0 }), TypeError); }); + +test('A collection answers for its own records only', async (t) => { + const dir = await createTemp(); + t.after(() => cleanupTemp(dir)); + const storage = await syncom.open({ path: dir, logger: false, schema: { entities: { Todo: {}, Note: {} } } }); + t.after(() => storage.close().catch(() => {})); + const note = await storage.Note.insert({ text: 'mine' }); + const bare = await storage.insert({ loose: true }); + assert.strictEqual(await storage.Todo.get(note.id), null, 'a Note is not a Todo'); + assert.strictEqual(await storage.Todo.get(bare), null, 'nor is a record of no collection'); + assert.deepStrictEqual(await storage.Note.get(note.id), { text: 'mine', $type: 'Note' }); + assert.strictEqual(await storage.Todo.get('absent'), null); + for (const call of [ + () => storage.Todo.update(note.id, { text: 'theirs' }), + () => storage.Todo.delete(note.id), + () => storage.Todo.record(note.id), + ]) { + await assert.rejects(call, (error) => { + assert.ok(error instanceof syncom.SyncomError); + assert.strictEqual(error.code, 'NOT_FOUND'); + assert.strictEqual(error.id, note.id); + assert.strictEqual(error.entity, 'Todo'); + return true; + }); + } + assert.deepStrictEqual(await storage.get(note.id), { text: 'mine', $type: 'Note' }, 'untouched'); + await storage.Note.update(note.id, { text: 'edited' }); + assert.strictEqual((await storage.Note.record(note.id)).text, 'edited'); + await storage.Note.delete(note.id); + assert.strictEqual(await storage.Note.get(note.id), null); +}); + +test('An entity name the storage answers to is refused at open', async (t) => { + for (const name of ['get', 'set', 'close', 'log', 'sync', 'path', 'on', 'constructor', '__proto__']) { + const dir = await createTemp(); + t.after(() => cleanupTemp(dir)); + const entities = Object.create(null); + entities[name] = {}; + await assert.rejects( + async () => syncom.open({ path: dir, logger: false, schema: { entities } }), + (error) => error instanceof TypeError && error.message.includes(`'${name}'`), + name, + ); + // Refused before anything was opened: the directory is free. + const storage = await syncom.open({ path: dir, logger: false }); + await storage.close(); + } +}); diff --git a/tests/suites/hooks.js b/tests/suites/hooks.js index bd800fc..058dbec 100644 --- a/tests/suites/hooks.js +++ b/tests/suites/hooks.js @@ -307,6 +307,7 @@ const runHooksTests = async (t, assert, createStorage) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(await storage.get('r1'), null, 'the skipped op did not touch the record'); assert.deepStrictEqual(await storage.get('r2'), { n: 2 }); @@ -384,6 +385,7 @@ const runHooksTests = async (t, assert, createStorage) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(batches.length, 2, 'an empty batch runs the hook too'); assert.deepStrictEqual(batches[1].ops, []); diff --git a/tests/suites/query.js b/tests/suites/query.js index c51112f..1997276 100644 --- a/tests/suites/query.js +++ b/tests/suites/query.js @@ -324,6 +324,37 @@ const runQueryTests = async (t, assert, createStorage) => { assert.deepStrictEqual(errors, ['boom', 'listener']); stopThrowing(); }); + + await t.test('one() leaves the cursor as it was', async (t2) => { + const storage = await createStorage(t2, 'O', { schema }); + for (const name of ['a', 'b', 'c']) await storage.Project.insert({ orgId: 'o', name, slug: name }); + const cursor = storage.Project.select({ orgId: 'o' }).order('name'); + assert.strictEqual((await cursor.one()).name, 'a'); + assert.deepStrictEqual( + (await cursor.fetch()).map((row) => row.name), + ['a', 'b', 'c'], + 'no limit(1) left behind', + ); + const limited = storage.Project.select({ orgId: 'o' }).order('name').limit(0); + assert.strictEqual(await limited.one(), null, 'a cursor limited to none finds none'); + }); + + await t.test('an indexed object matches whatever its key order', async (t2) => { + const shapes = { + entities: { Place: { index: ['at'], unique: ['code'] } }, + }; + const storage = await createStorage(t2, 'K', { schema: shapes }); + await storage.Place.insert({ at: { x: 1, y: 2 }, code: { a: 1, b: 2 } }); + const hits = async (query) => (await storage.Place.select(query).fetch()).length; + assert.strictEqual(await hits({ at: { $eq: { y: 2, x: 1 } } }), 1, '$eq through the index'); + assert.strictEqual(await hits({ at: { $in: [{ y: 2, x: 1 }] } }), 1, '$in through the index'); + assert.strictEqual(await hits({ at: { y: 2, x: 1 } }), 1); + await assert.rejects( + () => storage.Place.insert({ at: { x: 3 }, code: { b: 2, a: 1 } }), + (error) => error.name === 'ValidationError' && error.issues[0].code === 'unique', + 'the same unique value, keys in another order', + ); + }); }; module.exports = { runQueryTests }; diff --git a/tests/suites/schema.js b/tests/suites/schema.js index 8fa98fa..fe42599 100644 --- a/tests/suites/schema.js +++ b/tests/suites/schema.js @@ -344,6 +344,7 @@ const runSchemaTests = async (t, assert, createStorage) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.strictEqual(await storage.get('r1'), null, 'the invalid op did not touch the record'); assert.deepStrictEqual(await storage.get('r2'), { n: 2, $type: 'Post' }); @@ -365,6 +366,7 @@ const runSchemaTests = async (t, assert, createStorage) => { malformed: 0, unsigned: 0, forged: 0, + outOfScope: 0, }); assert.deepStrictEqual(await storage.get('r2'), { n: 2, $type: 'Post' }); // A reopen replays the unsealed tail and leaves the skipped ops alone. diff --git a/tests/suites/signatures.js b/tests/suites/signatures.js index c1d1c0b..85ca9e1 100644 --- a/tests/suites/signatures.js +++ b/tests/suites/signatures.js @@ -8,7 +8,7 @@ const { settle, waitUntil } = require('../utils/mesh.js'); const { createSigner, forge, tamper } = require('../utils/signed.js'); -const nothing = { applied: 0, skipped: 0, duplicates: 0, gaps: 0, malformed: 0, unsigned: 0, forged: 0 }; +const nothing = { applied: 0, skipped: 0, duplicates: 0, gaps: 0, malformed: 0, unsigned: 0, forged: 0, outOfScope: 0 }; const unsignedOp = (origin, seq, id, data) => ({ origin, seq, ts: Date.now() + seq, type: 'write', id, data }); const SIGNATURE = /^[A-Za-z0-9_-]{86}$/; diff --git a/tests/sync.retention.test.js b/tests/sync.retention.test.js new file mode 100644 index 0000000..c1a09bc --- /dev/null +++ b/tests/sync.retention.test.js @@ -0,0 +1,151 @@ +'use strict'; + +// A node's own ops are the one thing nobody else can hand a host: a dialer +// keeps them until the nodes it syncs with have them, says so when a host +// lacks ones its log no longer holds, and notices when its own directory was +// rewound under it. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs/promises'); +const syncom = require('..'); +const { createTemp, cleanupTemp } = require('./utils/temp.js'); +const { MemoryLink } = require('./utils/memory-link.js'); +const { waitUntil } = require('./utils/mesh.js'); + +const tempDir = async (t) => { + const dir = await createTemp(); + t.after(() => cleanupTemp(dir)); + return dir; +}; + +const openAt = async (t, dir, options = {}) => { + const storage = await syncom.open({ path: dir, logger: false, ...options }); + t.after(() => storage.close().catch(() => {})); + return storage; +}; + +// A dialer of `host` that keeps few ops and rolls often, so compaction runs. +const dialerOptions = (host, sync = {}) => ({ + sync: { + autoStart: false, + connect: () => new MemoryLink(host.sync.unit({ access: 'public' })), + retention: { ops: 20 }, + log: { rollAfter: 4 }, + ...sync, + }, +}); + +test('A dialer keeps its own ops until the nodes it syncs with have them', async (t) => { + await t.test('written offline past retention, delivered whole once the host is reached', async (t2) => { + const host = await openAt(t2, await tempDir(t2)); + const dialer = await openAt(t2, await tempDir(t2), dialerOptions(host)); + await dialer.sync.addNode({ url: 'mem://host' }); + for (let i = 0; i < 100; i++) await dialer.insert({ i }); + await dialer.log.flush(); + assert.strictEqual(dialer.log.floor, 1, 'nothing compacted: the host has none of them yet'); + assert.ok(dialer.log.length >= 100); + await dialer.sync.start(); + await waitUntil(async () => (await host.list()).length === 100); + assert.strictEqual(dialer.sync.listNodes()[0].acked[dialer.nodeId], 100); + // Delivered: the next roll compacts as retention says. + await dialer.insert({ i: 100 }); + await dialer.log.flush(); + assert.ok(dialer.log.floor > 1, 'compacted once acknowledged'); + }); + + await t.test('what a node acknowledged survives a restart; a disabled node is not waited for', async (t2) => { + const host = await openAt(t2, await tempDir(t2)); + const dir = await tempDir(t2); + const first = await openAt(t2, dir, dialerOptions(host)); + await first.sync.addNode({ url: 'mem://host' }); + await first.sync.start(); + await waitUntil(() => first.sync.peers()[0]?.subscribed); + await first.insert({ n: 1 }); + await first.sync.sync(); + await first.close(); + const reopened = await openAt(t2, dir, dialerOptions(host)); + assert.deepStrictEqual(reopened.sync.listNodes()[0].acked, { [reopened.nodeId]: 1 }); + // A second node that never connects would hold every op back; disabled, it holds none. + await reopened.sync.addNode({ url: 'mem://elsewhere' }); + await reopened.sync.enableNode('mem://elsewhere', false); + for (let i = 0; i < 60; i++) await reopened.insert({ i }); + await reopened.sync.start(); + await waitUntil(async () => (await host.list()).length === 61); + await reopened.log.flush(); + assert.ok(reopened.log.floor > 1); + }); + + await t.test('ops compacted before any node was registered are reported UNDELIVERABLE', async (t2) => { + const host = await openAt(t2, await tempDir(t2)); + const dialer = await openAt(t2, await tempDir(t2), dialerOptions(host)); + for (let i = 0; i < 100; i++) await dialer.insert({ i }); + await dialer.log.flush(); + assert.ok(dialer.log.floor > 1, 'compacted: no node was waiting for them'); + const errors = []; + dialer.sync.on('error', (error) => errors.push(error)); + await dialer.sync.addNode({ url: 'mem://host' }); + await dialer.sync.start(); + await waitUntil(() => errors.length > 0); + const [error] = errors; + assert.strictEqual(error.code, 'UNDELIVERABLE'); + assert.ok(error instanceof syncom.SyncomError); + const { host: held, retained } = error.missing[dialer.nodeId]; + assert.strictEqual(held, 0); + assert.ok(retained > 1); + assert.strictEqual(dialer.sync.peers()[0].lastError.code, 'UNDELIVERABLE'); + // Said once per hello, not once per push. + await dialer.insert({ late: true }); + await dialer.sync.sync(); + assert.strictEqual(errors.length, 1); + }); +}); + +test('A node restored from an older copy of its directory', async (t) => { + await t.test('is told REWOUND, mints past what the host holds and joins the lost state back', async (t2) => { + const host = await openAt(t2, await tempDir(t2)); + const dir = await tempDir(t2); + const backup = await tempDir(t2); + const options = dialerOptions(host, { retention: undefined, log: undefined }); + let node = await openAt(t2, dir, options); + await node.sync.addNode({ url: 'mem://host' }); + await node.sync.start(); + await waitUntil(() => node.sync.peers()[0]?.subscribed); + const kept = await node.insert({ n: 'before the backup' }); + await node.sync.sync(); + await node.close(); + await fs.cp(dir, backup, { recursive: true }); + node = await openAt(t2, dir, options); + await node.sync.start(); + await waitUntil(() => node.sync.peers()[0]?.subscribed); + const lost = await node.insert({ n: 'after the backup' }); + await node.sync.sync(); + const own = node.nodeId; + assert.strictEqual(host.vector[own], 2); + await node.close(); + // The directory goes back to the copy. + await fs.rm(dir, { recursive: true, force: true }); + await fs.cp(backup, dir, { recursive: true }); + node = await openAt(t2, dir, options); + assert.strictEqual(node.vector[own], 1, 'the restored log ends before the lost op'); + assert.strictEqual(await node.get(lost), null); + const errors = []; + node.sync.on('error', (error) => errors.push(error)); + const bootstraps = []; + node.sync.on('bootstrap', (event) => bootstraps.push(event)); + await node.sync.start(); + await waitUntil(() => bootstraps.length === 1); + const rewound = errors.find((error) => error.code === 'REWOUND'); + assert.ok(rewound, 'reported'); + assert.deepStrictEqual(rewound.local, { [own]: 1 }); + assert.deepStrictEqual(rewound.remote, { [own]: 2 }); + assert.ok(node.vector[own] >= 2, 'its stream moved past the host'); + assert.deepStrictEqual(await node.get(lost), { n: 'after the backup' }, 'the lost write came back'); + assert.deepStrictEqual(await node.get(kept), { n: 'before the backup' }); + // What it writes now reaches the host instead of being taken as a duplicate. + const fresh = await node.insert({ n: 'after the restore' }); + await node.sync.sync(); + await waitUntil(async () => (await host.get(fresh)) !== null); + assert.deepStrictEqual(await host.get(fresh), { n: 'after the restore' }); + }); +}); diff --git a/tests/sync.scopes.test.js b/tests/sync.scopes.test.js index 6113284..872c996 100644 --- a/tests/sync.scopes.test.js +++ b/tests/sync.scopes.test.js @@ -131,3 +131,115 @@ test('Hosting scopes', async (t) => { assert.deepStrictEqual(pages[1].ids, [a2], `B ${b} never reached an A subscriber`); }); }); + +test('Scopes bound the records an op writes', async (t) => { + await t.test('an update is minted in the scope of its record, not of its delta', async (t2) => { + const node = await openTemp(t2, { sync: { autoStart: false, scope } }); + const id = await node.insert({ $type: 'Doc', title: 'v1' }); + await node.update(id, { title: 'v2' }); + await node.inc(id, 'views', 1); + await node.add(id, 'tags', ['a']); + await node.delete(id); + const scopes = node.log.entriesOf(id).map(({ op }) => [op.type, op.scope]); + assert.deepStrictEqual(scopes, [ + ['write', 'Doc'], + ['set', 'Doc'], + ['inc', 'Doc'], + ['add', 'Doc'], + ['delete', 'Doc'], + ]); + // Each in its stream: the Doc stream counts all five. + assert.strictEqual(node.vector[`${node.nodeId}@Doc`], 5); + }); + + await t.test('the scope is read after validation: a default the validator fills counts', async (t2) => { + const tenant = (id, data) => data.tenant ?? '*'; + const node = await openTemp(t2, { + sync: { autoStart: false, scope: tenant }, + schema: { entities: { Task: { schema: (data) => ({ tenant: 't1', ...data }) } } }, + }); + const task = await node.Task.insert({ title: 'x' }); + const [{ op }] = node.log.entriesOf(task.id); + assert.strictEqual(op.scope, 't1'); + assert.strictEqual(node.vector[`${node.nodeId}@t1`], 1); + assert.strictEqual(node.vector[node.nodeId], undefined, 'nothing went to the global stream'); + }); + + await t.test('a peer that writes a record outside its scopes is refused, whatever the op declares', async (t2) => { + const host = await openTemp(t2, { sync: { scope } }); + const refused = []; + host.sync.on('refused', (event) => refused.push(event)); + const doc = await host.insert({ $type: 'Doc', title: 'secret' }); + const unit = host.sync.unit({ access: 'public' }); + const link = new MemoryLink(unit); + await link.call('syncom/hello', { node: 'tenant-o', version: 1, scopes: ['Other'] }); + const push = (op) => link.call('syncom/push', { ops: packOps([op]) }); + // A set declared global, on a Doc record: what the scope bug let through. + const set = await push(remoteOp('o1', 1, 'set', doc, { data: { title: 'stub', owner: 'mallory' } })); + // A write declared in its own scope that would move the Doc record there. + const move = await push(remoteOp('o2', 1, 'write', doc, { data: { $type: 'Other' }, scope: 'Other' })); + // A delete declared global. + const gone = await push(remoteOp('o3', 1, 'delete', doc)); + // A new record declared global whose data puts it in Doc. + const planted = await push(remoteOp('o4', 1, 'write', 'planted', { data: { $type: 'Doc', n: 1 } })); + for (const answer of [set, move, gone, planted]) { + assert.strictEqual(answer.outOfScope, 1); + assert.strictEqual(answer.applied, 0); + } + assert.deepStrictEqual(await host.get(doc), { $type: 'Doc', title: 'secret' }); + assert.strictEqual(await host.get('planted'), null); + assert.deepStrictEqual( + Object.keys(host.vector).filter((key) => key.startsWith('o')), + [], + 'neither logged nor relayed: no stream moved', + ); + assert.deepStrictEqual( + refused.map((event) => event.reason), + ['scope', 'scope', 'scope', 'scope'], + ); + // In its own scope the same peer writes as before. + const own = await push(remoteOp('o5', 1, 'write', 'mine', { data: { $type: 'Other' }, scope: 'Other' })); + assert.strictEqual(own.applied, 1); + assert.deepStrictEqual(await host.get('mine'), { $type: 'Other' }); + }); + + await t.test('an op held for a newer schema version is bound too', async (t2) => { + const host = await openTemp(t2, { sync: { scope }, schema: { version: 1, ahead: 'hold', entities: {} } }); + const doc = await host.insert({ $type: 'Doc', title: 'secret' }); + const unit = host.sync.unit({ access: 'public' }); + const link = new MemoryLink(unit); + await link.call('syncom/hello', { node: 'tenant-o', version: 1, scopes: ['Other'] }); + const op = remoteOp('o1', 1, 'set', doc, { data: { title: 'later' }, v: 9 }); + const answer = await link.call('syncom/push', { ops: packOps([op]) }); + assert.strictEqual(answer.outOfScope, 1); + assert.strictEqual(host.log.held().length, 0, 'not kept for later either'); + }); + + await t.test('a peer granted everything, and the dialing side, are bound by nothing', async (t2) => { + const host = await openTemp(t2, { sync: { scope } }); + const doc = await host.insert({ $type: 'Doc', title: 'v1' }); + const unit = host.sync.unit({ access: 'public' }); + const admin = new MemoryLink(unit); + await admin.call('syncom/hello', { node: 'admin', version: 1 }); + const ops = packOps([remoteOp('a1', 1, 'set', doc, { data: { title: 'v2' }, scope: 'Doc' })]); + assert.strictEqual((await admin.call('syncom/push', { ops })).applied, 1); + // applyRemote without `bound` (a feed the dialer reads) checks nothing. + const plain = await host.applyRemote([remoteOp('a2', 1, 'set', doc, { data: { title: 'v3' } })]); + assert.strictEqual(plain.outOfScope, 0); + assert.strictEqual((await host.get(doc)).title, 'v3'); + }); + + await t.test('a scope function that throws on a peer record refuses the op', async (t2) => { + const picky = (id, data) => { + if (data.boom) throw new Error('no scope for this'); + return data.$type ?? '*'; + }; + const host = await openTemp(t2, { sync: { scope: picky } }); + const unit = host.sync.unit({ access: 'public' }); + const link = new MemoryLink(unit); + await link.call('syncom/hello', { node: 'tenant-o', version: 1, scopes: ['Other'] }); + const op = remoteOp('o1', 1, 'write', 'r1', { data: { boom: true } }); + const answer = await link.call('syncom/push', { ops: packOps([op]) }); + assert.strictEqual(answer.outOfScope, 1); + }); +}); diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js index d959ab9..6c7bc76 100644 --- a/tests/telemetry.test.js +++ b/tests/telemetry.test.js @@ -499,6 +499,7 @@ test('Storage telemetry', async (t) => { malformed: 1, unsigned: 0, forged: 1, + outOfScope: 0, }); const [span] = named('syncom.apply'); assert.strictEqual(span.attributes['syncom.ops.count'], 6); @@ -542,7 +543,10 @@ test('Storage telemetry', async (t) => { const { dir, storage: first } = await openTemp(t2, { fsi }); const id = 'lost'; failing = true; - await assert.rejects(() => first.set(id, { n: 1 }), /ENOSPC/); + const errors = []; + first.on('error', (error) => errors.push(error)); + await first.set(id, { n: 1 }); + assert.strictEqual(errors[0].code, 'UNPERSISTED'); failing = false; await first.close(); const second = await syncom.open({ path: dir, logger: false, fsi, telemetry: { tracer, meter } });