@anodb/core 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/PROTOCOL.md +197 -0
  3. package/README.md +269 -2
  4. package/dist/anodb.d.ts +69 -0
  5. package/dist/anodb.js +393 -0
  6. package/dist/anodb.js.map +1 -0
  7. package/dist/blob.d.ts +76 -0
  8. package/dist/blob.js +227 -0
  9. package/dist/blob.js.map +1 -0
  10. package/dist/capture.d.ts +22 -0
  11. package/dist/capture.js +88 -0
  12. package/dist/capture.js.map +1 -0
  13. package/dist/cli/i18n.d.ts +57 -0
  14. package/dist/cli/i18n.js +110 -0
  15. package/dist/cli/i18n.js.map +1 -0
  16. package/dist/cli/init.d.ts +77 -0
  17. package/dist/cli/init.js +541 -0
  18. package/dist/cli/init.js.map +1 -0
  19. package/dist/cli/main.d.ts +2 -0
  20. package/dist/cli/main.js +116 -0
  21. package/dist/cli/main.js.map +1 -0
  22. package/dist/crypto.d.ts +32 -0
  23. package/dist/crypto.js +112 -0
  24. package/dist/crypto.js.map +1 -0
  25. package/dist/db.d.ts +228 -0
  26. package/dist/db.js +721 -0
  27. package/dist/db.js.map +1 -0
  28. package/dist/defaults.d.ts +13 -0
  29. package/dist/defaults.js +19 -0
  30. package/dist/defaults.js.map +1 -0
  31. package/dist/drivers/browser.d.ts +29 -0
  32. package/dist/drivers/browser.js +76 -0
  33. package/dist/drivers/browser.js.map +1 -0
  34. package/dist/drivers/expo.d.ts +10 -0
  35. package/dist/drivers/expo.js +10 -0
  36. package/dist/drivers/expo.js.map +1 -0
  37. package/dist/drivers/sqljs-idb.d.ts +16 -0
  38. package/dist/drivers/sqljs-idb.js +53 -0
  39. package/dist/drivers/sqljs-idb.js.map +1 -0
  40. package/dist/drivers/sqljs.d.ts +18 -0
  41. package/dist/drivers/sqljs.js +26 -0
  42. package/dist/drivers/sqljs.js.map +1 -0
  43. package/dist/hlc.d.ts +20 -0
  44. package/dist/hlc.js +79 -0
  45. package/dist/hlc.js.map +1 -0
  46. package/dist/identity.d.ts +23 -0
  47. package/dist/identity.js +89 -0
  48. package/dist/identity.js.map +1 -0
  49. package/dist/index.d.ts +38 -0
  50. package/dist/index.js +21 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/keystore/adapters.d.ts +23 -0
  53. package/dist/keystore/adapters.js +37 -0
  54. package/dist/keystore/adapters.js.map +1 -0
  55. package/dist/keystore/browser.d.ts +10 -0
  56. package/dist/keystore/browser.js +53 -0
  57. package/dist/keystore/browser.js.map +1 -0
  58. package/dist/keystore/env.d.ts +8 -0
  59. package/dist/keystore/env.js +49 -0
  60. package/dist/keystore/env.js.map +1 -0
  61. package/dist/keystore/memory.d.ts +3 -0
  62. package/dist/keystore/memory.js +15 -0
  63. package/dist/keystore/memory.js.map +1 -0
  64. package/dist/keystore/types.d.ts +14 -0
  65. package/dist/keystore/types.js +2 -0
  66. package/dist/keystore/types.js.map +1 -0
  67. package/dist/kysely.d.ts +36 -0
  68. package/dist/kysely.js +79 -0
  69. package/dist/kysely.js.map +1 -0
  70. package/dist/membership.d.ts +72 -0
  71. package/dist/membership.js +216 -0
  72. package/dist/membership.js.map +1 -0
  73. package/dist/node.d.ts +3 -0
  74. package/dist/node.js +26 -0
  75. package/dist/node.js.map +1 -0
  76. package/dist/p2p.d.ts +74 -0
  77. package/dist/p2p.js +175 -0
  78. package/dist/p2p.js.map +1 -0
  79. package/dist/protocol.d.ts +55 -0
  80. package/dist/protocol.js +93 -0
  81. package/dist/protocol.js.map +1 -0
  82. package/dist/public-tables.d.ts +31 -0
  83. package/dist/public-tables.js +186 -0
  84. package/dist/public-tables.js.map +1 -0
  85. package/dist/public.d.ts +75 -0
  86. package/dist/public.js +164 -0
  87. package/dist/public.js.map +1 -0
  88. package/dist/react.d.ts +40 -0
  89. package/dist/react.js +76 -0
  90. package/dist/react.js.map +1 -0
  91. package/dist/relay-pool.d.ts +88 -0
  92. package/dist/relay-pool.js +402 -0
  93. package/dist/relay-pool.js.map +1 -0
  94. package/dist/schema.d.ts +20 -0
  95. package/dist/schema.js +107 -0
  96. package/dist/schema.js.map +1 -0
  97. package/dist/sharing.d.ts +115 -0
  98. package/dist/sharing.js +672 -0
  99. package/dist/sharing.js.map +1 -0
  100. package/dist/signer.d.ts +42 -0
  101. package/dist/signer.js +78 -0
  102. package/dist/signer.js.map +1 -0
  103. package/dist/store.d.ts +150 -0
  104. package/dist/store.js +481 -0
  105. package/dist/store.js.map +1 -0
  106. package/dist/sync/negentropy.d.ts +17 -0
  107. package/dist/sync/negentropy.js +274 -0
  108. package/dist/sync/negentropy.js.map +1 -0
  109. package/dist/sync.d.ts +34 -0
  110. package/dist/sync.js +237 -0
  111. package/dist/sync.js.map +1 -0
  112. package/dist/types.d.ts +45 -0
  113. package/dist/types.js +2 -0
  114. package/dist/types.js.map +1 -0
  115. package/docs/DECISIONS.md +29 -0
  116. package/docs/DESIGN.md +254 -0
  117. 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,270 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="assets/anodb-logo.svg" alt="anoDB — Absolutely No DB" width="720" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ 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.
10
+
11
+ 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.
12
+
13
+ > **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.
14
+
15
+ ## Features
16
+
17
+ - 📴 **Offline-first** — Writes commit immediately to local SQLite. The network catches up later.
18
+ - 🧾 **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.
19
+ - 👥 **Sharing** — Spaces with owner / admin / writer / reader roles, invitations by npub or one-time links, removal with cut-offs, key rotation.
20
+ - 🔐 **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.
21
+ - 🔀 **Deterministic merging** — Cell-level Last-Writer-Wins with Hybrid Logical Clocks; tolerant of out-of-order delivery, duplicates and retries.
22
+ - ⚡ **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.
23
+ - 🔁 **4-layer sync** — NIP-77 Negentropy, time-based fallback, sequence-gap detection, snapshots. A durable outbox retries until `minAcks` relays acknowledge.
24
+ - 🌐 **Nostr tables** — Profiles, notes, follows, reactions and long-form articles as SQL tables, readable and writable by any Nostr client.
25
+ - 📎 **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.
26
+ - ⚛️ **React hooks** — `useQuery`, `useSyncStatus`.
27
+
28
+ ## Get started
29
+
30
+ In an existing app (Next.js, Vite, SvelteKit, Tauri, Electron, Expo or Node):
31
+
32
+ ```sh
33
+ npx @anodb/cli init
34
+ ```
35
+
36
+ One interactive command does the whole setup:
37
+
38
+ 1. Asks the language (English / 日本語) and detects your environment and framework
39
+ 2. Installs `@anodb/core` (plus `sql.js` for web apps)
40
+ 3. Generates `anodb/schema`, `anodb/db` and a React `<DBProvider>`
41
+ 4. Web apps: copies the SQLite WASM files to `public/anodb/` and adds `"postinstall": "anodb assets"` to keep them in sync
42
+ 5. Wraps your app in `<DBProvider>` (Next.js `app/layout` or Vite `src/main`)
43
+ 6. Optionally checks that the relays store and serve events
44
+
45
+ Then use it from any component:
46
+
47
+ ```tsx
48
+ const db = useAnoDB()
49
+ const todos = useQuery((db) => db.selectFrom('todo').selectAll(), [])
50
+ await db.insertInto('todo').values({ title: 'Milk', done: false }).execute()
51
+ ```
52
+
53
+ No bundler configuration, no server, no account. Keys are generated on each user's device on first
54
+ launch. Only one tab per origin opens the database; other tabs get `AnoDBError('tab-locked')`.
55
+
56
+ Manual setup in a browser:
57
+
58
+ ```ts
59
+ import { browserDriver, createAnoDB } from '@anodb/core'
60
+ const db = await createAnoDB({ appId, schema, driver: await browserDriver({ name: appId }) }) // sql.js from /anodb
61
+ ```
62
+
63
+ Runs in browsers / Tauri (sql.js persisted to IndexedDB), Electron and Node.js 22.13+ (`node:sqlite`), and React Native / Expo (`expo-sqlite`).
64
+
65
+ ## Usage
66
+
67
+ ```ts
68
+ import { createAnoDB, defineSchema } from '@anodb/core'
69
+
70
+ const schema = defineSchema({
71
+ todo: { title: 'text', done: 'boolean', priority: 'integer', meta: 'json' },
72
+ tag: { name: 'text', todoId: 'text' },
73
+ })
74
+
75
+ const db = await createAnoDB({ appId: 'com.example.todo', schema, driver })
76
+
77
+ // SQL-like and type-safe, JOINs included
78
+ await db.insertInto('todo').values({ title: 'Buy groceries', done: false }).execute()
79
+ await db.updateTable('todo').set({ done: true }).where('title', 'like', '%groceries%').execute()
80
+ const rows = await db
81
+ .selectFrom('todo')
82
+ .leftJoin('tag', 'tag.todoId', 'todo.id')
83
+ .select(['todo.title', 'tag.name'])
84
+ .execute()
85
+
86
+ // Raw SQL works too; writes are captured by triggers, encrypted and synced
87
+ await db.execute('UPDATE todo SET done = 1 WHERE priority > ?', [2])
88
+
89
+ // Transactions (atomic locally, one sync unit)
90
+ await db.transaction().execute(async (tx) => {
91
+ await tx.insertInto('todo').values({ title: 'A' }).execute()
92
+ })
93
+
94
+ // Live queries: re-run on local or remote changes
95
+ const stop = db.subscribe(db.selectFrom('todo').selectAll(), (rows) => render(rows))
96
+ ```
97
+
98
+ - Every table gets `id`, `space`, `createdAt` and `updatedAt` (`space` defaults to your personal space).
99
+ - Following SQLite semantics, `boolean` reads as `0 | 1` and `json` reads as JSON text.
100
+
101
+ ### Keys (most secure defaults)
102
+
103
+ | Environment | Default key store |
104
+ |---|---|
105
+ | Browser / Tauri | IndexedDB, sealed with a non-extractable WebCrypto key |
106
+ | Node | Environment variables (`npx @anodb/cli init` writes only the device key and delegation to `.env`, never the identity key) |
107
+ | Electron / Expo | `electronKeyStore(safeStorage)` / `secureStorageKeyStore(expo-secure-store)` |
108
+
109
+ - Every write is signed by a **device key**; the identity key is used once to register the device.
110
+ - With `identity: 'recovery'`, the identity key is handed to `onRecoveryKey` once and never stored on the device.
111
+ - External signers: `signer: nip07Signer() | nip46Signer(uri) | nip55Signer(bridge)`.
112
+ - Relays see only throwaway pubkeys, per-epoch tags, times and sizes (`metadataProtection: 'none'` turns this off for allow-listed relays).
113
+
114
+ ## Sharing (spaces)
115
+
116
+ ```ts
117
+ const family = await db.createSpace('Family')
118
+ await db.insertInto('todo').values({ title: 'Milk', space: family }).execute()
119
+
120
+ // Invite by npub (the invitee publishes a key package first: db.publishKeyPackage())
121
+ await db.invite(family, 'npub1alice…', 'writer') // owner / admin / writer / reader
122
+ // Invitee: nothing syncs until they accept
123
+ alice.onInvitation((i) => …); await alice.acceptInvitation(i.space)
124
+
125
+ // One-time, expiring invite link; an admin must approve each request
126
+ const link = await db.createInviteLink(family, { role: 'reader', expiresInMs: 86_400_000 })
127
+ db.onJoinRequest((req) => db.approveJoinRequest(req.id))
128
+
129
+ await db.setRole(family, who, 'reader')
130
+ await db.removeMember(family, who)
131
+ ```
132
+
133
+ - New members receive **only a snapshot of the current state** — no edit history and no deleted content.
134
+ - Group keys rotate on member removal, device revocation, member addition and every 30 days.
135
+ - After removal, writes beyond each device's sequence-number cut-off are rejected (backdating does not help).
136
+
137
+ ### Adding and revoking devices
138
+
139
+ ```ts
140
+ // New device: show a QR code
141
+ const offer = await newDevice.startPairing() // render offer.uri as QR, show offer.code
142
+ await offer.done; await newDevice.close() // reopen: it now has the same identity
143
+ // Existing device: scan, compare the code, approve (the identity key is never copied)
144
+ const req = db.parsePairing(uri); /* check req.code */ await req.approve('Laptop')
145
+
146
+ await db.revokeDevice(lostDevicePubkey) // lost or stolen device
147
+ ```
148
+
149
+ > ⚠ 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.
150
+
151
+ ## Public Nostr data (tables)
152
+
153
+ ```ts
154
+ import { defineSchema, publicTable } from '@anodb/core'
155
+
156
+ const schema = defineSchema({
157
+ todo: { title: 'text' }, // private (encrypted in a space)
158
+ posts: publicTable('note'), // kind 1
159
+ profiles: publicTable('profile'), // kind 0
160
+ follows: publicTable('follows'), // kind 3
161
+ reactions: publicTable('reaction'), // kind 7
162
+ articles: publicTable('longForm'), // kind 30023
163
+ })
164
+
165
+ await db.insertInto('posts').values({ content: 'hello nostr', tags: [['t', 'anodb']] }).execute() // signed & published
166
+ await db.syncPublic('posts', { authors: [alice] }, { live: true }) // fetch & follow
167
+ await db.selectFrom('posts').innerJoin('profiles', 'profiles.author', 'posts.author')
168
+ .select(['posts.content', 'profiles.name']).execute() // JOIN
169
+ ```
170
+
171
+ Public tables are **not encrypted**. Notes and reactions cannot be edited (deleting publishes a NIP-09 request); profiles, follow lists and articles are replaceable.
172
+
173
+ ## Direct sync over WebRTC
174
+
175
+ Where WebRTC is available (browsers, or Node with `node-datachannel`), **your own devices connect
176
+ directly** and exchange changes peer-to-peer (signaling goes through the same Nostr relays).
177
+
178
+ ```ts
179
+ createAnoDB({ ..., p2p: { mode: 'own-devices' } }) // default where WebRTC exists
180
+ createAnoDB({ ..., p2p: { mode: 'members' } }) // also connect to other members (they see your IP)
181
+ createAnoDB({ ..., p2p: { mode: 'off' } }) // relays only
182
+ // Node: p2p: { rtc: { RTCPeerConnection } } from 'node-datachannel/polyfill'
183
+
184
+ db.p2pPeers() // [{ space, device, transport: 'direct' }]
185
+ ```
186
+
187
+ - Rooms are derived from each space's epoch key, so only members can join and removed members drop out when keys rotate.
188
+ - Every peer proves which device it is with a signature; envelopes received directly go through the same verification as relay ones.
189
+ - 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.
190
+ - `members` mode is opt-in because direct connections reveal IP addresses to other members.
191
+
192
+ ## Files (Blossom)
193
+
194
+ Values are limited to ~44 KB, so files go to Blossom servers (Nostr's blob storage). They are
195
+ encrypted on the device with a random key; the database only stores a small reference, which is
196
+ synced end-to-end encrypted like any other value — so only members of the space can open the file.
197
+
198
+ ```ts
199
+ const schema = defineSchema({ notes: { text: 'text', photo: 'json' } })
200
+
201
+ const ref = await db.blobs.put(file, { type: file.type, name: file.name }) // encrypt + upload (mirrored)
202
+ await db.insertInto('notes').values({ text: 'receipt', photo: ref }).execute()
203
+
204
+ const [row] = await db.selectFrom('notes').select('photo').execute()
205
+ const blob = await db.blobs.getBlob(row.photo) // download, verify hash, decrypt
206
+ img.src = URL.createObjectURL(blob)
207
+
208
+ await db.blobs.delete(ref) // only from the device that uploaded it
209
+ ```
210
+
211
+ - Servers see only ciphertext, its size, upload time and your IP. Each upload is signed by a throwaway key.
212
+ - Downloads are verified against the sha256, so a server cannot alter a file; other servers are tried on failure.
213
+ - 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.
214
+
215
+ ## Sync health
216
+
217
+ ```ts
218
+ db.status // { state: 'offline' | 'syncing' | 'synced', connectedRelays, pendingEvents }
219
+ db.syncHealth() // { gaps: [{ space, device, missing: [7, 8] }], nip77: { 'wss://…': true } }
220
+ await db.repair() // deep re-fetch from every relay
221
+ ```
222
+
223
+ 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.
224
+
225
+ ## React
226
+
227
+ ```tsx
228
+ import { AnoDBProvider, useQuery, useSyncStatus } from '@anodb/core/react'
229
+
230
+ <AnoDBProvider db={db}><App /></AnoDBProvider>
231
+ const todos = useQuery((db) => db.selectFrom('todo').selectAll().where('done', '=', 0), [])
232
+ const status = useSyncStatus()
233
+ ```
234
+
235
+ ## Column types
236
+
237
+ | Type | Write (TS) | Read (TS) | SQLite |
238
+ |---|---|---|---|
239
+ | `text` | `string` | `string` | TEXT |
240
+ | `integer` | `number` (safe integer) | `number` | INTEGER |
241
+ | `real` | `number` | `number` | REAL |
242
+ | `boolean` | `boolean \| 0 \| 1` | `0 \| 1` | INTEGER |
243
+ | `json` | any JSON value | `string` (JSON text) | TEXT |
244
+
245
+ All columns are nullable.
246
+
247
+ ## Limitations
248
+
249
+ - **Public relays do not guarantee retention.** Multiple relays, `minAcks` and repair improve durability but cannot guarantee it.
250
+ - **Consistency across devices is eventual.** Not suitable for global inventory counts, balances or uniqueness constraints.
251
+ - **SQL queries only cover locally available data.**
252
+ - **Deletion uses tombstones.** Copies held by relays or other people are not erased; removed members keep what they already read.
253
+ - **Losing every device loses your personal space** (by design, for security).
254
+ - 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.
255
+ - Files depend on Blossom servers' retention policies (none are guaranteed).
256
+ - The local SQLite file itself is not encrypted; rely on disk encryption.
257
+
258
+ See [docs/DESIGN.md](docs/DESIGN.md) for the threat model and open issues.
259
+
260
+ ## Development
261
+
262
+ ```sh
263
+ npm test # unit + integration tests against a mock relay (NIP-77, fault injection)
264
+ npm run typecheck
265
+ npm run smoke:public # checks against real public relays (network required)
266
+ ```
267
+
268
+ ## License
269
+
270
+ MIT
@@ -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>>;