@anodb/core 0.0.0-stage → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PROTOCOL.md +197 -0
- package/README.md +292 -2
- package/dist/anodb.d.ts +69 -0
- package/dist/anodb.js +393 -0
- package/dist/anodb.js.map +1 -0
- package/dist/blob.d.ts +76 -0
- package/dist/blob.js +227 -0
- package/dist/blob.js.map +1 -0
- package/dist/capture.d.ts +22 -0
- package/dist/capture.js +88 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli/i18n.d.ts +57 -0
- package/dist/cli/i18n.js +110 -0
- package/dist/cli/i18n.js.map +1 -0
- package/dist/cli/init.d.ts +77 -0
- package/dist/cli/init.js +541 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +116 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/crypto.d.ts +32 -0
- package/dist/crypto.js +112 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.d.ts +228 -0
- package/dist/db.js +721 -0
- package/dist/db.js.map +1 -0
- package/dist/defaults.d.ts +13 -0
- package/dist/defaults.js +19 -0
- package/dist/defaults.js.map +1 -0
- package/dist/drivers/browser.d.ts +29 -0
- package/dist/drivers/browser.js +76 -0
- package/dist/drivers/browser.js.map +1 -0
- package/dist/drivers/expo.d.ts +10 -0
- package/dist/drivers/expo.js +10 -0
- package/dist/drivers/expo.js.map +1 -0
- package/dist/drivers/sqljs-idb.d.ts +16 -0
- package/dist/drivers/sqljs-idb.js +53 -0
- package/dist/drivers/sqljs-idb.js.map +1 -0
- package/dist/drivers/sqljs.d.ts +18 -0
- package/dist/drivers/sqljs.js +26 -0
- package/dist/drivers/sqljs.js.map +1 -0
- package/dist/hlc.d.ts +20 -0
- package/dist/hlc.js +79 -0
- package/dist/hlc.js.map +1 -0
- package/dist/identity.d.ts +23 -0
- package/dist/identity.js +89 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/keystore/adapters.d.ts +23 -0
- package/dist/keystore/adapters.js +37 -0
- package/dist/keystore/adapters.js.map +1 -0
- package/dist/keystore/browser.d.ts +10 -0
- package/dist/keystore/browser.js +53 -0
- package/dist/keystore/browser.js.map +1 -0
- package/dist/keystore/env.d.ts +8 -0
- package/dist/keystore/env.js +49 -0
- package/dist/keystore/env.js.map +1 -0
- package/dist/keystore/memory.d.ts +3 -0
- package/dist/keystore/memory.js +15 -0
- package/dist/keystore/memory.js.map +1 -0
- package/dist/keystore/types.d.ts +14 -0
- package/dist/keystore/types.js +2 -0
- package/dist/keystore/types.js.map +1 -0
- package/dist/kysely.d.ts +36 -0
- package/dist/kysely.js +79 -0
- package/dist/kysely.js.map +1 -0
- package/dist/membership.d.ts +72 -0
- package/dist/membership.js +216 -0
- package/dist/membership.js.map +1 -0
- package/dist/node.d.ts +3 -0
- package/dist/node.js +26 -0
- package/dist/node.js.map +1 -0
- package/dist/p2p.d.ts +74 -0
- package/dist/p2p.js +175 -0
- package/dist/p2p.js.map +1 -0
- package/dist/protocol.d.ts +55 -0
- package/dist/protocol.js +93 -0
- package/dist/protocol.js.map +1 -0
- package/dist/public-tables.d.ts +31 -0
- package/dist/public-tables.js +186 -0
- package/dist/public-tables.js.map +1 -0
- package/dist/public.d.ts +75 -0
- package/dist/public.js +164 -0
- package/dist/public.js.map +1 -0
- package/dist/react.d.ts +40 -0
- package/dist/react.js +76 -0
- package/dist/react.js.map +1 -0
- package/dist/relay-pool.d.ts +88 -0
- package/dist/relay-pool.js +402 -0
- package/dist/relay-pool.js.map +1 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +107 -0
- package/dist/schema.js.map +1 -0
- package/dist/sharing.d.ts +115 -0
- package/dist/sharing.js +672 -0
- package/dist/sharing.js.map +1 -0
- package/dist/signer.d.ts +42 -0
- package/dist/signer.js +78 -0
- package/dist/signer.js.map +1 -0
- package/dist/store.d.ts +150 -0
- package/dist/store.js +481 -0
- package/dist/store.js.map +1 -0
- package/dist/sync/negentropy.d.ts +17 -0
- package/dist/sync/negentropy.js +274 -0
- package/dist/sync/negentropy.js.map +1 -0
- package/dist/sync.d.ts +34 -0
- package/dist/sync.js +237 -0
- package/dist/sync.js.map +1 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/DECISIONS.md +29 -0
- package/docs/DESIGN.md +254 -0
- package/package.json +86 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 show-ritz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# anoDB wire protocol — version 2 (draft)
|
|
2
|
+
|
|
3
|
+
Status: **draft**. Kind numbers and the tag name are provisional until checked against the NIP
|
|
4
|
+
registry. Implementations MUST drop anything they cannot validate. Version 1 (anoDB 0.1) is
|
|
5
|
+
obsolete and not supported.
|
|
6
|
+
|
|
7
|
+
## 1. Keys
|
|
8
|
+
|
|
9
|
+
| key | holder | used for |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **identity** (secp256k1) | the user (key store, recovery key, or a NIP-07/46/55 signer) | delegations, revocations, key packages, public Nostr events |
|
|
12
|
+
| **device** (secp256k1) | one installation | signing every inner message |
|
|
13
|
+
| **epoch key** (32 random bytes) | every active device of every member of a space | encrypting a space stream for one epoch |
|
|
14
|
+
|
|
15
|
+
### 1.1 Delegation (kind 7304)
|
|
16
|
+
|
|
17
|
+
```jsonc
|
|
18
|
+
{ "kind": 7304, "pubkey": "<identity>", "tags": [["device", "<device pubkey>"]], "content": "{\"name\":\"phone\"}" }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A revocation is the same kind with `["revoke", "<device pubkey>"]`. Delegations are not published
|
|
22
|
+
on their own; they travel inside encrypted messages (and in key packages, §6).
|
|
23
|
+
|
|
24
|
+
## 2. Envelope (kind 7303)
|
|
25
|
+
|
|
26
|
+
```jsonc
|
|
27
|
+
{
|
|
28
|
+
"kind": 7303,
|
|
29
|
+
"pubkey": "<throwaway key>", // device key if metadataProtection = 'none'
|
|
30
|
+
"created_at": <unix seconds>,
|
|
31
|
+
"tags": [["n", "<routing tag>"]],
|
|
32
|
+
"content": "<NIP-44 v2 ciphertext of the inner message JSON>"
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| routing | tag | NIP-44 conversation key |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| space stream | `hex(sha256("anodb/v2/space/" + hex(epochKey)))[0:32]` | the epoch key itself |
|
|
39
|
+
| device inbox | `hex(sha256("anodb/v2/inbox/" + devicePubkey))[0:32]` | ECDH(envelope key, device key) |
|
|
40
|
+
| invite / pairing secret | `hex(sha256("anodb/v2/secret-tag/" + secret))[0:32]` | `sha256("anodb/v2/secret-key/" + secret)` |
|
|
41
|
+
|
|
42
|
+
`keyId(epochKey) = hex(sha256("anodb/v2/key-id/" + hex(epochKey)))[0:16]`.
|
|
43
|
+
|
|
44
|
+
Relays therefore see only: a random pubkey, a tag that changes every epoch, time and size.
|
|
45
|
+
|
|
46
|
+
## 3. Inner message (kind 7305)
|
|
47
|
+
|
|
48
|
+
A NIP-01 event signed by the **device** key, never published unencrypted. `content` is the payload:
|
|
49
|
+
|
|
50
|
+
```jsonc
|
|
51
|
+
{ "v": 2, "t": "<type>", "sp": "<space id, 32 hex>", "ep": "<keyId>", "seq": <n>, "dlg": <delegation event>, ...body }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `seq` starts at 1 and increases by one per (space, device). Gaps reveal missing messages.
|
|
55
|
+
- A receiver MUST check: inner signature; `dlg` delegates to the inner pubkey; `sp`/`ep` match
|
|
56
|
+
the key that decrypted the envelope; payload shape.
|
|
57
|
+
|
|
58
|
+
| `t` | body | meaning |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `ch` | `c: [[table, rowId, column, value, hlc], …]` | cell changes |
|
|
61
|
+
| `op` | `op: {…}, par: [msgId…]` | membership operation (§5) |
|
|
62
|
+
| `snap` | `sid, i, n, body` | part `i` of `n` of a snapshot |
|
|
63
|
+
|
|
64
|
+
## 4. Cells and merge
|
|
65
|
+
|
|
66
|
+
State is `(space, table, rowId, column) → (value, hlc)`; a change wins iff its HLC is greater.
|
|
67
|
+
`hlc = base36(ms, 10) + base36(counter, 5) + "-" + nodeId(16 hex)`; string order = causal order.
|
|
68
|
+
System columns: `$deleted` (`true` = tombstone), `$createdAt` (ms). Changes are applied only if the
|
|
69
|
+
signing identity is authorized (§5) for that device and sequence number; unauthorized messages are
|
|
70
|
+
kept and re-evaluated whenever membership changes.
|
|
71
|
+
|
|
72
|
+
## 5. Spaces and membership
|
|
73
|
+
|
|
74
|
+
Every row belongs to a space; a user's personal data is a space whose only member is the user.
|
|
75
|
+
`spaceId = hex(sha256("anodb/v2/space-id/" + owner + "/" + nonce))[0:32]`, so only the owner can
|
|
76
|
+
produce the valid `create` op.
|
|
77
|
+
|
|
78
|
+
### 5.1 Ops (`t: "op"`)
|
|
79
|
+
|
|
80
|
+
`par` lists the ids of the ops the author had seen (DAG heads). An op is evaluated only once all its
|
|
81
|
+
ancestors are known; it is **valid iff its author was permitted to perform it in the state formed
|
|
82
|
+
by its valid ancestors**.
|
|
83
|
+
|
|
84
|
+
| op | fields | who may |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `create` | `owner, nonce, name` | the owner (no parents; id must match) |
|
|
87
|
+
| `add` | `identity, role` | owner: any role; admin: writer/reader |
|
|
88
|
+
| `role` | `identity, role, cut?` | owner: any non-owner; admin: writer/reader of non-admins |
|
|
89
|
+
| `remove` | `identity, cut` | owner: anyone but itself; admin: writers/readers |
|
|
90
|
+
| `leave` | `cut` | any non-owner member, for itself |
|
|
91
|
+
| `transfer` | `identity` | the owner, to a member (old owner becomes admin) |
|
|
92
|
+
| `revoke` | `revocation` (kind 7304 event), `cut` | any member, relaying a valid revocation |
|
|
93
|
+
|
|
94
|
+
Roles: `owner` (exactly one) > `admin` > `writer` > `reader`.
|
|
95
|
+
|
|
96
|
+
Conflict rules:
|
|
97
|
+
|
|
98
|
+
- a `remove` of X overrides every `add`/`role` for X that is not its descendant;
|
|
99
|
+
- `cut` maps device → highest accepted `seq`. Concurrent cut-offs for one device combine to the
|
|
100
|
+
**minimum**. A message from a device with a cut-off is applied only if `seq ≤ cut`;
|
|
101
|
+
- a later (descendant) `add` lifts the identity's cut-offs.
|
|
102
|
+
|
|
103
|
+
A `ch` or `snap` message is applied iff its author may write under the final membership state
|
|
104
|
+
(including cut-offs). Unauthorized messages are stored and re-evaluated on every membership change.
|
|
105
|
+
|
|
106
|
+
### 5.2 Keys and rotation
|
|
107
|
+
|
|
108
|
+
The current epoch key is the one with the highest `num` (ties: lowest key id). Owners/admins
|
|
109
|
+
rotate on removal, device revocation, member addition and every 30 days, by sending the new key to
|
|
110
|
+
every active device of every member through their inboxes, then posting a snapshot in the new
|
|
111
|
+
epoch.
|
|
112
|
+
|
|
113
|
+
### 5.3 Inbox messages (inner kind 7305, payload `{ v: 2, t, dlg, … }`)
|
|
114
|
+
|
|
115
|
+
| `t` | fields | accepted when |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `key` | `sp, name, personal, num, key, ops[]` | sender is admin+ of `sp` (or the same identity) under the carried + known ops, and the receiver is a member |
|
|
118
|
+
| `invite` | as `key` + `role`, optional `req` | as above; stored until the user accepts (`req = sha256(link secret)` → auto-accept for own join requests) |
|
|
119
|
+
|
|
120
|
+
`ops` are complete signed inner op events, verifiable without the old epoch keys.
|
|
121
|
+
|
|
122
|
+
### 5.4 Snapshots (`t: "snap"`)
|
|
123
|
+
|
|
124
|
+
Parts `i/n` with `body: { ops?: [inner…], cells?: [change…] }`. Rows that are deleted carry only their
|
|
125
|
+
`$deleted` cell. A complete snapshot from an authorized writer is merged like ordinary changes.
|
|
126
|
+
New members and newly paired devices receive keys for the **new** epoch only and catch up from
|
|
127
|
+
snapshots.
|
|
128
|
+
|
|
129
|
+
### 5.5 Invite links and pairing (secret routing)
|
|
130
|
+
|
|
131
|
+
- Invite link: `anodb://join?app=<appId>&s=<secret>`. The requester sends `{ t: "joinreq", devices: [delegations], name }`
|
|
132
|
+
to the secret tag. An admin must approve; the link is single-use and expires.
|
|
133
|
+
- Pairing: `anodb://pair?app=<appId>&d=<new device pubkey>&s=<secret>`; both devices display
|
|
134
|
+
`sha256("anodb/v2/pair-code/" + d + "/" + s)` as a 6-digit code. The approving device sends
|
|
135
|
+
`{ t: "pair", delegation, spaces: [grant…] }` to the secret tag. The identity key is never sent.
|
|
136
|
+
|
|
137
|
+
### 5.6 Key package (kind 10304, public, replaceable)
|
|
138
|
+
|
|
139
|
+
`content = {"v":2,"delegations":[…]}` signed by the identity, listing current device delegations so
|
|
140
|
+
others can invite by npub.
|
|
141
|
+
|
|
142
|
+
## 6. Sync (informative)
|
|
143
|
+
|
|
144
|
+
1. **NIP-77**: `NEG-OPEN` with `{"kinds":[7303],"#n":[tags…]}` over the local envelope set; fetch
|
|
145
|
+
`need` ids (repeat until complete — relays cap id lookups too), re-publish `have` envelopes.
|
|
146
|
+
A relay answering `NEG-ERR`, a `NOTICE`, or nothing is treated as unsupported.
|
|
147
|
+
2. **Time-based**: `{"kinds":[7303],"#n":[tags…],"since":cursor−600}` per relay; after `EOSE`, page
|
|
148
|
+
with `until` until nothing new; advance per-(relay, tag) cursors.
|
|
149
|
+
3. **Gaps**: per (space, device), every `seq` above the coverage of complete snapshots
|
|
150
|
+
(`heads` in part 0) must be present; missing ones trigger a deep re-fetch.
|
|
151
|
+
4. **Snapshots**: new members/devices start from them.
|
|
152
|
+
|
|
153
|
+
Outbox: retry until `minAcks` relays acknowledge; envelopes older than 300 s are re-signed (new
|
|
154
|
+
throwaway key, fresh `created_at`, same content).
|
|
155
|
+
|
|
156
|
+
Optional pruning: envelope key = `sha256("anodb/v2/prune/" + hex(deviceSk) + "/" + salt)`; after a
|
|
157
|
+
snapshot the author publishes NIP-09 kind 5 `["e", id], ["k", "7303"]` for its older envelopes.
|
|
158
|
+
|
|
159
|
+
## 7. Public tables
|
|
160
|
+
|
|
161
|
+
Standard events, unencrypted, signed by the identity key:
|
|
162
|
+
|
|
163
|
+
| preset | kind | row id | replace rule |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| note | 1 | event id | none (delete = kind 5) |
|
|
166
|
+
| profile | 0 | author | newest per author |
|
|
167
|
+
| follows | 3 | `author:target` | newest list per author |
|
|
168
|
+
| reaction | 7 | event id | none |
|
|
169
|
+
| longForm | 30023 | `author:d` | newest per address |
|
|
170
|
+
|
|
171
|
+
A client publishing a new version of a replaceable event MUST use a `created_at` greater than the
|
|
172
|
+
version it replaces.
|
|
173
|
+
|
|
174
|
+
## 8. Blob references (Blossom)
|
|
175
|
+
|
|
176
|
+
Files are stored on Blossom servers (BUD-01/02/11/12), encrypted with AES-256-GCM
|
|
177
|
+
(`blob = iv(12) || ciphertext || tag`). The reference stored in a `json` column:
|
|
178
|
+
|
|
179
|
+
```jsonc
|
|
180
|
+
{ "v": 1, "sha256": "<sha256 of the encrypted blob>", "size": <plaintext bytes>, "type": "<mime>",
|
|
181
|
+
"key": "<AES key, hex>", "servers": ["https://…"], "salt": "<hex>", "name"?: "…" }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Uploads/deletes carry `Authorization: Nostr <base64url(kind 24242 event)>` with `t`, `x`,
|
|
185
|
+
`expiration` and `server` tags, signed by `sha256("anodb/v2/blob/" + hex(deviceSk) + "/" + salt)`.
|
|
186
|
+
Clients MUST verify the sha256 of downloaded bytes before decrypting.
|
|
187
|
+
|
|
188
|
+
## 9. Direct delivery (WebRTC, via nostr-p2p)
|
|
189
|
+
|
|
190
|
+
- Room: `joinRoom("anodb:" + appId, hex(sha256("anodb/v2/p2p/" + hex(epochKey))), { transport: "direct" })`.
|
|
191
|
+
- Channel `anodb` messages:
|
|
192
|
+
- `{ t: "hi", proof }` — `proof` is an event of kind 27305 signed by the device key with
|
|
193
|
+
`content = roomId + ":" + <recipient session pubkey>`; only devices of current members are accepted.
|
|
194
|
+
- `{ t: "e", e: <envelope> }` — a regular envelope (§2), verified exactly like one from a relay.
|
|
195
|
+
- `{ t: "a", id }` — acknowledgement after successful ingestion.
|
|
196
|
+
- A sender MAY skip relays for an envelope only if every active device of every member of the space
|
|
197
|
+
acknowledged it; otherwise it MUST publish it to relays (default after 2 s).
|
package/README.md
CHANGED
|
@@ -1,3 +1,293 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/anodb-logo.svg" alt="anoDB — Absolutely No DB" width="720" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">anoDB — Absolutely No DB</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><strong>A decentralized, local-first database.</strong><br />Zero servers to run. Zero servers to trust.</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@anodb/core"><img src="https://img.shields.io/npm/v/@anodb/core?label=%40anodb%2Fcore" alt="@anodb/core on npm" /></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@anodb/cli"><img src="https://img.shields.io/npm/v/@anodb/cli?label=%40anodb%2Fcli" alt="@anodb/cli on npm" /></a>
|
|
12
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
anoDB is a decentralized, local-first database SDK that treats on-device SQLite as the source of truth and distributes changes as **end-to-end encrypted Nostr events** through existing public relays.
|
|
16
|
+
|
|
17
|
+
True to its name, it requires **absolutely no developer-operated server**: no API server, no database server, and no project setup. Offline reads and writes, multi-device sync, sharing with other people, and reading/writing public Nostr data are available through a single type-safe, SQL-like API.
|
|
18
|
+
|
|
19
|
+
> **Status: v0.7 / experimental.** The protocol ([PROTOCOL.md](PROTOCOL.md), v2) may change incompatibly. See [docs/DESIGN.md](docs/DESIGN.md) for design details and limitations, and [docs/DECISIONS.md](docs/DECISIONS.md) for the decision log.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- 📴 **Offline-first** — Writes commit immediately to local SQLite. The network catches up later.
|
|
24
|
+
- 🧾 **Type-safe SQL** — A [Kysely](https://kysely.dev) query builder with JOINs, plus raw SQL writes (`UPDATE … LIKE`, `INSERT … SELECT`, upserts) that are captured and synced.
|
|
25
|
+
- 👥 **Sharing** — Spaces with owner / admin / writer / reader roles, invitations by npub or one-time links, removal with cut-offs, key rotation.
|
|
26
|
+
- 🔐 **Secure by default** — Writes are signed by per-device keys, keys are stored encrypted, relays see only throwaway pubkeys and rotating tags, and new members never see deleted history.
|
|
27
|
+
- 🔀 **Deterministic merging** — Cell-level Last-Writer-Wins with Hybrid Logical Clocks; tolerant of out-of-order delivery, duplicates and retries.
|
|
28
|
+
- ⚡ **Direct device-to-device sync** — Your devices connect over WebRTC ([nostr-p2p](https://github.com/sho-ritz/nostr-p2p)) and exchange changes in tens of milliseconds; relays remain the store for offline devices.
|
|
29
|
+
- 🔁 **4-layer sync** — NIP-77 Negentropy, time-based fallback, sequence-gap detection, snapshots. A durable outbox retries until `minAcks` relays acknowledge.
|
|
30
|
+
- 🌐 **Nostr tables** — Profiles, notes, follows, reactions and long-form articles as SQL tables, readable and writable by any Nostr client.
|
|
31
|
+
- 📎 **Encrypted files** — Large files go to [Blossom](https://github.com/hzrd149/blossom) servers, encrypted on the device; only a reference is stored in the database.
|
|
32
|
+
- ⚛️ **React hooks** — `useQuery`, `useSyncStatus`.
|
|
33
|
+
|
|
34
|
+
## Get started
|
|
35
|
+
|
|
36
|
+
In an existing app (Next.js, Vite, SvelteKit, Tauri, Electron, Expo or Node):
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx @anodb/cli init
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
One interactive command does the whole setup:
|
|
43
|
+
|
|
44
|
+
1. Asks the language (English / 日本語) and detects your environment and framework
|
|
45
|
+
2. Installs `@anodb/core` (plus `sql.js` for web apps)
|
|
46
|
+
3. Generates `anodb/schema`, `anodb/db` and a React `<DBProvider>`
|
|
47
|
+
4. Web apps: copies the SQLite WASM files to `public/anodb/` and adds `"postinstall": "anodb assets"` to keep them in sync
|
|
48
|
+
5. Wraps your app in `<DBProvider>` (Next.js `app/layout` or Vite `src/main`)
|
|
49
|
+
6. Optionally checks that the relays store and serve events
|
|
50
|
+
|
|
51
|
+
Then use it from any component:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { useAnoDB, useQuery } from '@anodb/core/react'
|
|
55
|
+
|
|
56
|
+
const db = useAnoDB()
|
|
57
|
+
const todos = useQuery((db) => db.selectFrom('todo').selectAll(), [])
|
|
58
|
+
await db.insertInto('todo').values({ title: 'Milk', done: false }).execute()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
No bundler configuration, no server, no account. Keys are generated on each user's device on first
|
|
62
|
+
launch. Only one tab per origin opens the database; other tabs get `AnoDBError('tab-locked')`.
|
|
63
|
+
|
|
64
|
+
### Packages
|
|
65
|
+
|
|
66
|
+
The product is **anoDB**; on npm it lives under the `@anodb` scope.
|
|
67
|
+
|
|
68
|
+
| Package | What it is |
|
|
69
|
+
|---|---|
|
|
70
|
+
| [`@anodb/core`](https://www.npmjs.com/package/@anodb/core) | The database: sync, sharing, keys, files, React hooks (`@anodb/core/react`) and Node driver (`@anodb/core/node`) |
|
|
71
|
+
| [`@anodb/cli`](https://www.npmjs.com/package/@anodb/cli) | `npx @anodb/cli init` — the setup wizard (you never import it) |
|
|
72
|
+
| [`nostr-p2p`](https://www.npmjs.com/package/nostr-p2p) | WebRTC over Nostr signaling, used internally by `@anodb/core` |
|
|
73
|
+
|
|
74
|
+
### Manual setup
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm install @anodb/core sql.js # sql.js only for browsers / Tauri
|
|
78
|
+
npx anodb assets # copy the SQLite WASM files to public/anodb/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { browserDriver, createAnoDB } from '@anodb/core'
|
|
83
|
+
const db = await createAnoDB({ appId, schema, driver: await browserDriver({ name: appId }) })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Runs in browsers / Tauri (sql.js persisted to IndexedDB), Electron and Node.js 22.13+ (`node:sqlite`), and React Native / Expo (`expo-sqlite`).
|
|
87
|
+
|
|
88
|
+
## Usage
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { createAnoDB, defineSchema } from '@anodb/core'
|
|
92
|
+
|
|
93
|
+
const schema = defineSchema({
|
|
94
|
+
todo: { title: 'text', done: 'boolean', priority: 'integer', meta: 'json' },
|
|
95
|
+
tag: { name: 'text', todoId: 'text' },
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const db = await createAnoDB({ appId: 'com.example.todo', schema, driver })
|
|
99
|
+
|
|
100
|
+
// SQL-like and type-safe, JOINs included
|
|
101
|
+
await db.insertInto('todo').values({ title: 'Buy groceries', done: false }).execute()
|
|
102
|
+
await db.updateTable('todo').set({ done: true }).where('title', 'like', '%groceries%').execute()
|
|
103
|
+
const rows = await db
|
|
104
|
+
.selectFrom('todo')
|
|
105
|
+
.leftJoin('tag', 'tag.todoId', 'todo.id')
|
|
106
|
+
.select(['todo.title', 'tag.name'])
|
|
107
|
+
.execute()
|
|
108
|
+
|
|
109
|
+
// Raw SQL works too; writes are captured by triggers, encrypted and synced
|
|
110
|
+
await db.execute('UPDATE todo SET done = 1 WHERE priority > ?', [2])
|
|
111
|
+
|
|
112
|
+
// Transactions (atomic locally, one sync unit)
|
|
113
|
+
await db.transaction().execute(async (tx) => {
|
|
114
|
+
await tx.insertInto('todo').values({ title: 'A' }).execute()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Live queries: re-run on local or remote changes
|
|
118
|
+
const stop = db.subscribe(db.selectFrom('todo').selectAll(), (rows) => render(rows))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- Every table gets `id`, `space`, `createdAt` and `updatedAt` (`space` defaults to your personal space).
|
|
122
|
+
- Following SQLite semantics, `boolean` reads as `0 | 1` and `json` reads as JSON text.
|
|
123
|
+
|
|
124
|
+
### Keys (most secure defaults)
|
|
125
|
+
|
|
126
|
+
| Environment | Default key store |
|
|
127
|
+
|---|---|
|
|
128
|
+
| Browser / Tauri | IndexedDB, sealed with a non-extractable WebCrypto key |
|
|
129
|
+
| Node | Environment variables (`npx @anodb/cli init` writes only the device key and delegation to `.env`, never the identity key) |
|
|
130
|
+
| Electron / Expo | `electronKeyStore(safeStorage)` / `secureStorageKeyStore(expo-secure-store)` |
|
|
131
|
+
|
|
132
|
+
- Every write is signed by a **device key**; the identity key is used once to register the device.
|
|
133
|
+
- With `identity: 'recovery'`, the identity key is handed to `onRecoveryKey` once and never stored on the device.
|
|
134
|
+
- External signers: `signer: nip07Signer() | nip46Signer(uri) | nip55Signer(bridge)`.
|
|
135
|
+
- Relays see only throwaway pubkeys, per-epoch tags, times and sizes (`metadataProtection: 'none'` turns this off for allow-listed relays).
|
|
136
|
+
|
|
137
|
+
## Sharing (spaces)
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const family = await db.createSpace('Family')
|
|
141
|
+
await db.insertInto('todo').values({ title: 'Milk', space: family }).execute()
|
|
142
|
+
|
|
143
|
+
// Invite by npub (the invitee publishes a key package first: db.publishKeyPackage())
|
|
144
|
+
await db.invite(family, 'npub1alice…', 'writer') // owner / admin / writer / reader
|
|
145
|
+
// Invitee: nothing syncs until they accept
|
|
146
|
+
alice.onInvitation((i) => …); await alice.acceptInvitation(i.space)
|
|
147
|
+
|
|
148
|
+
// One-time, expiring invite link; an admin must approve each request
|
|
149
|
+
const link = await db.createInviteLink(family, { role: 'reader', expiresInMs: 86_400_000 })
|
|
150
|
+
db.onJoinRequest((req) => db.approveJoinRequest(req.id))
|
|
151
|
+
|
|
152
|
+
await db.setRole(family, who, 'reader')
|
|
153
|
+
await db.removeMember(family, who)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- New members receive **only a snapshot of the current state** — no edit history and no deleted content.
|
|
157
|
+
- Group keys rotate on member removal, device revocation, member addition and every 30 days.
|
|
158
|
+
- After removal, writes beyond each device's sequence-number cut-off are rejected (backdating does not help).
|
|
159
|
+
|
|
160
|
+
### Adding and revoking devices
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// New device: show a QR code
|
|
164
|
+
const offer = await newDevice.startPairing() // render offer.uri as QR, show offer.code
|
|
165
|
+
await offer.done; await newDevice.close() // reopen: it now has the same identity
|
|
166
|
+
// Existing device: scan, compare the code, approve (the identity key is never copied)
|
|
167
|
+
const req = db.parsePairing(uri); /* check req.code */ await req.approve('Laptop')
|
|
168
|
+
|
|
169
|
+
await db.revokeDevice(lostDevicePubkey) // lost or stolen device
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
> ⚠ Group keys are delivered to device keys only. **If you lose every device, your personal space cannot be recovered** (shared spaces can be restored by other members). Register at least two devices.
|
|
173
|
+
|
|
174
|
+
## Public Nostr data (tables)
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { defineSchema, publicTable } from '@anodb/core'
|
|
178
|
+
|
|
179
|
+
const schema = defineSchema({
|
|
180
|
+
todo: { title: 'text' }, // private (encrypted in a space)
|
|
181
|
+
posts: publicTable('note'), // kind 1
|
|
182
|
+
profiles: publicTable('profile'), // kind 0
|
|
183
|
+
follows: publicTable('follows'), // kind 3
|
|
184
|
+
reactions: publicTable('reaction'), // kind 7
|
|
185
|
+
articles: publicTable('longForm'), // kind 30023
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
await db.insertInto('posts').values({ content: 'hello nostr', tags: [['t', 'anodb']] }).execute() // signed & published
|
|
189
|
+
await db.syncPublic('posts', { authors: [alice] }, { live: true }) // fetch & follow
|
|
190
|
+
await db.selectFrom('posts').innerJoin('profiles', 'profiles.author', 'posts.author')
|
|
191
|
+
.select(['posts.content', 'profiles.name']).execute() // JOIN
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Public tables are **not encrypted**. Notes and reactions cannot be edited (deleting publishes a NIP-09 request); profiles, follow lists and articles are replaceable.
|
|
195
|
+
|
|
196
|
+
## Direct sync over WebRTC
|
|
197
|
+
|
|
198
|
+
Where WebRTC is available (browsers, or Node with `node-datachannel`), **your own devices connect
|
|
199
|
+
directly** and exchange changes peer-to-peer (signaling goes through the same Nostr relays).
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
createAnoDB({ ..., p2p: { mode: 'own-devices' } }) // default where WebRTC exists
|
|
203
|
+
createAnoDB({ ..., p2p: { mode: 'members' } }) // also connect to other members (they see your IP)
|
|
204
|
+
createAnoDB({ ..., p2p: { mode: 'off' } }) // relays only
|
|
205
|
+
// Node: p2p: { rtc: { RTCPeerConnection } } from 'node-datachannel/polyfill'
|
|
206
|
+
|
|
207
|
+
db.p2pPeers() // [{ space, device, transport: 'direct' }]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- Rooms are derived from each space's epoch key, so only members can join and removed members drop out when keys rotate.
|
|
211
|
+
- Every peer proves which device it is with a signature; envelopes received directly go through the same verification as relay ones.
|
|
212
|
+
- A change **skips relays entirely when every device of the space acknowledged it**; otherwise it is published to relays after 2 s, so offline devices still get it.
|
|
213
|
+
- `members` mode is opt-in because direct connections reveal IP addresses to other members.
|
|
214
|
+
|
|
215
|
+
## Files (Blossom)
|
|
216
|
+
|
|
217
|
+
Values are limited to ~44 KB, so files go to Blossom servers (Nostr's blob storage). They are
|
|
218
|
+
encrypted on the device with a random key; the database only stores a small reference, which is
|
|
219
|
+
synced end-to-end encrypted like any other value — so only members of the space can open the file.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const schema = defineSchema({ notes: { text: 'text', photo: 'json' } })
|
|
223
|
+
|
|
224
|
+
const ref = await db.blobs.put(file, { type: file.type, name: file.name }) // encrypt + upload (mirrored)
|
|
225
|
+
await db.insertInto('notes').values({ text: 'receipt', photo: ref }).execute()
|
|
226
|
+
|
|
227
|
+
const [row] = await db.selectFrom('notes').select('photo').execute()
|
|
228
|
+
const blob = await db.blobs.getBlob(row.photo) // download, verify hash, decrypt
|
|
229
|
+
img.src = URL.createObjectURL(blob)
|
|
230
|
+
|
|
231
|
+
await db.blobs.delete(ref) // only from the device that uploaded it
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- Servers see only ciphertext, its size, upload time and your IP. Each upload is signed by a throwaway key.
|
|
235
|
+
- Downloads are verified against the sha256, so a server cannot alter a file; other servers are tried on failure.
|
|
236
|
+
- Default servers: `DEFAULT_BLOB_SERVERS` (public servers that accept encrypted uploads). **Most public Blossom servers only accept images/video and reject ciphertext**, and none guarantee retention — use `blobServers` to add your own or paid servers.
|
|
237
|
+
|
|
238
|
+
## Sync health
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
db.status // { state: 'offline' | 'syncing' | 'synced', connectedRelays, pendingEvents }
|
|
242
|
+
db.syncHealth() // { gaps: [{ space, device, missing: [7, 8] }], nip77: { 'wss://…': true } }
|
|
243
|
+
await db.repair() // deep re-fetch from every relay
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
On NIP-77 relays, reconciliation is exact and lost envelopes are re-uploaded automatically. On other relays, per-device sequence numbers still **detect** anything missing.
|
|
247
|
+
|
|
248
|
+
## React
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
import { AnoDBProvider, useQuery, useSyncStatus } from '@anodb/core/react'
|
|
252
|
+
|
|
253
|
+
<AnoDBProvider db={db}><App /></AnoDBProvider>
|
|
254
|
+
const todos = useQuery((db) => db.selectFrom('todo').selectAll().where('done', '=', 0), [])
|
|
255
|
+
const status = useSyncStatus()
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Column types
|
|
259
|
+
|
|
260
|
+
| Type | Write (TS) | Read (TS) | SQLite |
|
|
261
|
+
|---|---|---|---|
|
|
262
|
+
| `text` | `string` | `string` | TEXT |
|
|
263
|
+
| `integer` | `number` (safe integer) | `number` | INTEGER |
|
|
264
|
+
| `real` | `number` | `number` | REAL |
|
|
265
|
+
| `boolean` | `boolean \| 0 \| 1` | `0 \| 1` | INTEGER |
|
|
266
|
+
| `json` | any JSON value | `string` (JSON text) | TEXT |
|
|
267
|
+
|
|
268
|
+
All columns are nullable.
|
|
269
|
+
|
|
270
|
+
## Limitations
|
|
271
|
+
|
|
272
|
+
- **Public relays do not guarantee retention.** Multiple relays, `minAcks` and repair improve durability but cannot guarantee it.
|
|
273
|
+
- **Consistency across devices is eventual.** Not suitable for global inventory counts, balances or uniqueness constraints.
|
|
274
|
+
- **SQL queries only cover locally available data.**
|
|
275
|
+
- **Deletion uses tombstones.** Copies held by relays or other people are not erased; removed members keep what they already read.
|
|
276
|
+
- **Losing every device loses your personal space** (by design, for security).
|
|
277
|
+
- With only non-NIP-77 relays, more events in a single second than a relay's result limit can be missed; `syncHealth()` reports the gap.
|
|
278
|
+
- Files depend on Blossom servers' retention policies (none are guaranteed).
|
|
279
|
+
- The local SQLite file itself is not encrypted; rely on disk encryption.
|
|
280
|
+
|
|
281
|
+
See [docs/DESIGN.md](docs/DESIGN.md) for the threat model and open issues.
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
npm test # unit + integration tests against a mock relay (NIP-77, fault injection)
|
|
287
|
+
npm run typecheck
|
|
288
|
+
npm run smoke:public # checks against real public relays (network required)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT
|
package/dist/anodb.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CapturedCell } from './capture.js';
|
|
2
|
+
import { type AnoDBOptions } from './db.js';
|
|
3
|
+
import { type P2PMode, type PeerInfo, P2PSync } from './p2p.js';
|
|
4
|
+
import { AnoDBSharing } from './sharing.js';
|
|
5
|
+
import type { NostrEvent, SchemaDefinition } from './types.js';
|
|
6
|
+
export interface SyncGap {
|
|
7
|
+
space: string;
|
|
8
|
+
device: string;
|
|
9
|
+
/** Missing sequence numbers (at most 100 listed). */
|
|
10
|
+
missing: number[];
|
|
11
|
+
}
|
|
12
|
+
export interface PublicSyncFilter {
|
|
13
|
+
authors?: string[];
|
|
14
|
+
ids?: string[];
|
|
15
|
+
since?: number;
|
|
16
|
+
until?: number;
|
|
17
|
+
limit?: number;
|
|
18
|
+
/** Tag filters without the `#`, e.g. `{ t: ['anodb'] }`. */
|
|
19
|
+
tags?: Record<string, string[]>;
|
|
20
|
+
}
|
|
21
|
+
/** anoDB database handle: SQL-like API, spaces & sharing, NIP-77 sync, public Nostr tables. */
|
|
22
|
+
export declare class AnoDB<S extends SchemaDefinition> extends AnoDBSharing<S> {
|
|
23
|
+
#private;
|
|
24
|
+
protected afterInit(): Promise<void>;
|
|
25
|
+
/** Effective P2P mode: own devices by default where WebRTC exists. */
|
|
26
|
+
get p2pMode(): P2PMode;
|
|
27
|
+
protected holdForPeers(space: string): number;
|
|
28
|
+
protected afterEnvelope(space: string, outer: NostrEvent, held: boolean): void;
|
|
29
|
+
/** Raw P2P room state (troubleshooting). */
|
|
30
|
+
p2pDiagnostics(): ReturnType<P2PSync['diagnostics']>;
|
|
31
|
+
/** Devices currently connected directly (for status UIs). */
|
|
32
|
+
p2pPeers(): PeerInfo[];
|
|
33
|
+
protected afterCatchUp(relay: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Layer 1: NIP-77 set reconciliation with one relay (fetch what we miss, upload what it misses).
|
|
36
|
+
* Layer 3: if sequence gaps remain, do a deep re-fetch from all relays (rate limited).
|
|
37
|
+
* Returns whether the relay supports NIP-77.
|
|
38
|
+
*/
|
|
39
|
+
reconcile(relay: string): Promise<boolean>;
|
|
40
|
+
/** Layer 3: messages known to exist (by per-device sequence numbers) but not received. */
|
|
41
|
+
syncHealth(): {
|
|
42
|
+
gaps: SyncGap[];
|
|
43
|
+
nip77: Record<string, boolean>;
|
|
44
|
+
};
|
|
45
|
+
/** Deep re-fetch of every stream from every connected relay (ignores cursors). */
|
|
46
|
+
repair(): Promise<void>;
|
|
47
|
+
/** Envelope keys are derived from the device key + a random salt so they can sign deletions later. */
|
|
48
|
+
protected wrap(innerJson: string, tag: string, convKey: Uint8Array, signerSk?: Uint8Array): NostrEvent;
|
|
49
|
+
protected resignEnvelope(e: NostrEvent): NostrEvent;
|
|
50
|
+
protected afterSnapshot(space: string, before: number): void;
|
|
51
|
+
/** Insert/update/delete on a public table → signed standard Nostr events (identity key). */
|
|
52
|
+
protected commitPublic(cells: CapturedCell[]): Promise<void>;
|
|
53
|
+
/** Project a verified event into a public table (replaceable semantics per preset). */
|
|
54
|
+
protected applyPublicEvent(table: string, e: NostrEvent): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Fetch standard events into a public table (verified, cached for offline use). With `live`,
|
|
57
|
+
* keeps a subscription open; call the returned `stop()` to end it.
|
|
58
|
+
*/
|
|
59
|
+
syncPublic<T extends Extract<keyof S, string>>(table: T, filter?: PublicSyncFilter, opts?: {
|
|
60
|
+
live?: boolean;
|
|
61
|
+
relays?: readonly string[];
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
}): Promise<{
|
|
64
|
+
received: number;
|
|
65
|
+
stop: () => void;
|
|
66
|
+
}>;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
export declare function createAnoDB<const S extends SchemaDefinition>(opts: AnoDBOptions<S>): Promise<AnoDB<S>>;
|