tablinum 0.2.0 → 0.5.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.
- package/README.md +126 -28
- package/dist/crud/collection-handle.d.ts +4 -1
- package/dist/db/create-tablinum.d.ts +4 -0
- package/dist/db/database-handle.d.ts +2 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +338 -112
- package/dist/schema/collection.d.ts +2 -0
- package/dist/schema/field.d.ts +7 -1
- package/dist/services/Config.d.ts +2 -0
- package/dist/storage/idb.d.ts +2 -1
- package/dist/storage/records-store.d.ts +1 -0
- package/dist/svelte/collection.svelte.d.ts +3 -1
- package/dist/svelte/index.svelte.d.ts +1 -1
- package/dist/svelte/index.svelte.js +350 -121
- package/dist/svelte/tablinum.svelte.d.ts +2 -1
- package/dist/sync/sync-service.d.ts +2 -1
- package/dist/utils/diff.d.ts +2 -0
- package/package.json +10 -7
package/README.md
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
# tablinum
|
|
2
2
|
|
|
3
|
-
A local-first
|
|
4
|
-
|
|
5
|
-
Built on [Effect](https://effect.website) and [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API).
|
|
3
|
+
A local-first database for the browser with encrypted sync and built-in collaboration.
|
|
6
4
|
|
|
7
5
|
## Features
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
- **Local-first** — all reads hit IndexedDB, no network required
|
|
11
|
-
- **Cross-device sync** — replicate data via Nostr relays using NIP-59 gift wrapping for privacy
|
|
12
|
-
- **Efficient reconciliation** — NIP-77 negentropy for minimal sync overhead
|
|
13
|
-
- **Dexie-style queries** — chainable `.where()`, `.above()`, `.below()`, `.orderBy()` API
|
|
14
|
-
- **Svelte 5 bindings** — optional reactive runes integration
|
|
7
|
+
### Local first
|
|
15
8
|
|
|
16
|
-
|
|
9
|
+
Your app works offline. All data lives on your device in the browser.
|
|
17
10
|
|
|
18
|
-
|
|
11
|
+
### Backed up and encrypted
|
|
19
12
|
|
|
20
|
-
|
|
13
|
+
Data syncs to relays so it's safe across devices. Everything stored on relays is end-to-end encrypted - relay operators cannot read your data.
|
|
21
14
|
|
|
22
|
-
###
|
|
15
|
+
### Identity and collaboration built in
|
|
23
16
|
|
|
24
|
-
Tablinum
|
|
17
|
+
Tablinum has a built-in system for sharing a database with other people, and for removing access when needed.
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
Every database has a shared secret key. When you invite someone, they get a copy of the key so they can read and write data. If someone leaves or is removed, a new key is created and shared with everyone _except_ the removed person — like changing the locks when a roommate moves out. Old data is still available to the removed person but they will not get anything new.
|
|
20
|
+
|
|
21
|
+
Invites are just links. Share one, and the other person has everything they need to join.
|
|
22
|
+
|
|
23
|
+
### Typed collections and queries
|
|
24
|
+
|
|
25
|
+
Define your data shape once with `collection()` and `field.*()` builders, and get full TypeScript inference everywhere. Query with a chainable API:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
const pending = await todos.where("done").equals(false).get();
|
|
29
|
+
const recent = await todos.orderBy("createdAt").get();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Svelte 5 bindings
|
|
33
|
+
|
|
34
|
+
Optional reactive integration using Svelte 5 async runes. No Effect knowledge needed — the API is plain async/await.
|
|
35
|
+
|
|
36
|
+
## Getting started
|
|
28
37
|
|
|
29
38
|
### Install
|
|
30
39
|
|
|
@@ -32,7 +41,7 @@ Tablinum syncs through Nostr relays, which must support [NIP-77 (Negentropy)](ht
|
|
|
32
41
|
npm install tablinum
|
|
33
42
|
```
|
|
34
43
|
|
|
35
|
-
### Quick start
|
|
44
|
+
### Quick start (Effect)
|
|
36
45
|
|
|
37
46
|
```typescript
|
|
38
47
|
import { Effect } from "effect";
|
|
@@ -72,11 +81,9 @@ const program = Effect.gen(function* () {
|
|
|
72
81
|
Effect.runPromise(Effect.scoped(program));
|
|
73
82
|
```
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Import from `tablinum/svelte` for reactive bindings that use Svelte 5 runes. No Effect knowledge needed — the API is plain async/await.
|
|
84
|
+
### Quick start (Svelte 5)
|
|
78
85
|
|
|
79
|
-
This API uses Svelte's async runes support, so enable it in your app config:
|
|
86
|
+
Import from `tablinum/svelte` for reactive bindings. This API uses Svelte's async runes support, so enable it in your app config:
|
|
80
87
|
|
|
81
88
|
```js
|
|
82
89
|
// svelte.config.js
|
|
@@ -89,9 +96,7 @@ const config = {
|
|
|
89
96
|
};
|
|
90
97
|
```
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
Create a database helper that defines your schema and initializes the database:
|
|
99
|
+
Create a database helper:
|
|
95
100
|
|
|
96
101
|
```typescript
|
|
97
102
|
// src/lib/db.ts
|
|
@@ -118,9 +123,7 @@ export const db = new Tablinum({
|
|
|
118
123
|
export const todos = db.collection("todos");
|
|
119
124
|
```
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
Async collection reads are reactive when used inside `$derived(await ...)` expressions.
|
|
126
|
+
Use it in a component:
|
|
124
127
|
|
|
125
128
|
```svelte
|
|
126
129
|
<script lang="ts">
|
|
@@ -188,13 +191,108 @@ Async collection reads are reactive when used inside `$derived(await ...)` expre
|
|
|
188
191
|
</svelte:boundary>
|
|
189
192
|
```
|
|
190
193
|
|
|
191
|
-
|
|
194
|
+
#### Key concepts
|
|
192
195
|
|
|
193
196
|
- **`new Tablinum(config)`** starts initialization immediately and exposes `db.ready`
|
|
194
197
|
- **Async queries are reactive** when used inside `$derived(await ...)`
|
|
195
198
|
- **`db.status`** tracks initialization and terminal state; **`db.syncStatus`** tracks sync activity
|
|
196
199
|
- **`createTablinum(config)`** still exists as a convenience and resolves once `db.ready` completes
|
|
197
200
|
|
|
201
|
+
## Logging
|
|
202
|
+
|
|
203
|
+
Tablinum uses [Effect's built-in logging](https://effect.website/docs/observability/logging/) under the hood. By default, logging is completely silent. Set `logLevel` in your config to enable it:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const db = yield* createTablinum({
|
|
207
|
+
schema,
|
|
208
|
+
relays: ["wss://relay.example.com"],
|
|
209
|
+
logLevel: "debug", // "debug" | "info" | "warning" | "error" | "none"
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Wire it to an environment variable for easy toggling:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// Effect API
|
|
217
|
+
createTablinum({
|
|
218
|
+
schema,
|
|
219
|
+
relays: ["wss://relay.example.com"],
|
|
220
|
+
logLevel: import.meta.env.VITE_LOG_LEVEL ?? "none",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Svelte API
|
|
224
|
+
new Tablinum({
|
|
225
|
+
schema,
|
|
226
|
+
relays: ["wss://relay.example.com"],
|
|
227
|
+
logLevel: import.meta.env.VITE_LOG_LEVEL ?? "none",
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Log levels
|
|
232
|
+
|
|
233
|
+
| Level | What you see |
|
|
234
|
+
|-------|-------------|
|
|
235
|
+
| `"none"` | Nothing (default) |
|
|
236
|
+
| `"error"` | Unrecoverable failures |
|
|
237
|
+
| `"warning"` | Recoverable issues (e.g. rejected writes from removed members) |
|
|
238
|
+
| `"info"` | Lifecycle milestones — storage opened, identity loaded, sync started/complete |
|
|
239
|
+
| `"debug"` | Everything above plus CRUD operations (with record data), relay reconciliation details, gift wrap processing |
|
|
240
|
+
|
|
241
|
+
### Log spans
|
|
242
|
+
|
|
243
|
+
Key operations include timing spans that appear automatically in log output:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
[11:50:31] INFO (#1) tablinum.init=13ms: Tablinum ready { ... }
|
|
247
|
+
[11:50:41] INFO (#1) tablinum.sync=520ms: Sync complete { changed: ["todos"] }
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Spans: `tablinum.init`, `tablinum.sync`, `tablinum.syncRelay`, `tablinum.negentropy`.
|
|
251
|
+
|
|
252
|
+
### Using Effect's LogLevel type
|
|
253
|
+
|
|
254
|
+
Power users can also pass Effect's `LogLevel` type directly:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import { LogLevel } from "tablinum";
|
|
258
|
+
createTablinum({ ..., logLevel: LogLevel.Debug });
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## How it works
|
|
262
|
+
|
|
263
|
+
Tablinum is built on [Effect](https://effect.website) and stores all data locally in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API).
|
|
264
|
+
|
|
265
|
+
Sync happens through [Nostr](https://nostr.com) relays. All data sent to relays is encrypted using [NIP-59 gift wrapping](https://github.com/nostr-protocol/nips/blob/master/59.md). Relays never see your application data. Sync uses [NIP-77 negentropy](https://github.com/nostr-protocol/nips/blob/master/77.md) to efficiently reconcile what each side has, minimizing bandwidth usage.
|
|
266
|
+
|
|
267
|
+
### Picking a relay
|
|
268
|
+
|
|
269
|
+
Tablinum syncs through Nostr relays that support [NIP-77 (Negentropy)](https://github.com/nostr-protocol/nips/blob/master/77.md). You have two options:
|
|
270
|
+
|
|
271
|
+
- **Use a public relay** — find NIP-77 compatible relays at [nostrwat.ch](https://nostrwat.ch)
|
|
272
|
+
- **Self-host a relay** — [strfry](https://github.com/hoytech/strfry) is a good choice if you want full control over where your users' data is stored
|
|
273
|
+
|
|
274
|
+
## Development
|
|
275
|
+
|
|
276
|
+
### Local relays
|
|
277
|
+
|
|
278
|
+
To run the example app or integration tests against local relays, spin up three [strfry](https://github.com/hoytech/strfry) instances with Docker:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
bun run relays # starts 3 relays on ports 7984, 7985, 7986
|
|
282
|
+
bun run relays:down # stop relays (data is preserved)
|
|
283
|
+
bun run relays:clean # stop relays and delete all data
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The first run builds strfry from source, which takes a few minutes. Subsequent starts are instant.
|
|
287
|
+
|
|
288
|
+
Then point your app at the local relays:
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
relays: ["ws://localhost:7984", "ws://localhost:7985", "ws://localhost:7986"]
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The Svelte example app (`bun run demo:svelte`) is pre-configured to use these local relays.
|
|
295
|
+
|
|
198
296
|
## License
|
|
199
297
|
|
|
200
298
|
MIT
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Option, Stream } from "effect";
|
|
2
|
+
import type { LogLevel } from "effect";
|
|
2
3
|
import type { CollectionDef, CollectionFields } from "../schema/collection.ts";
|
|
3
4
|
import type { InferRecord } from "../schema/types.ts";
|
|
4
5
|
import type { RecordValidator, PartialValidator } from "../schema/validate.ts";
|
|
@@ -6,10 +7,12 @@ import type { IDBStorageHandle, StoredEvent } from "../storage/idb.ts";
|
|
|
6
7
|
import { NotFoundError, StorageError, ValidationError } from "../errors.ts";
|
|
7
8
|
import type { WatchContext } from "./watch.ts";
|
|
8
9
|
import type { WhereClause, QueryBuilder } from "./query-builder.ts";
|
|
10
|
+
export declare function pruneEvents(storage: IDBStorageHandle, collection: string, recordId: string, retention: number): Effect.Effect<void, StorageError>;
|
|
9
11
|
export interface CollectionHandle<C extends CollectionDef<CollectionFields>> {
|
|
10
12
|
readonly add: (data: Omit<InferRecord<C>, "id">) => Effect.Effect<string, ValidationError | StorageError>;
|
|
11
13
|
readonly update: (id: string, data: Partial<Omit<InferRecord<C>, "id">>) => Effect.Effect<void, ValidationError | StorageError | NotFoundError>;
|
|
12
14
|
readonly delete: (id: string) => Effect.Effect<void, StorageError | NotFoundError>;
|
|
15
|
+
readonly undo: (id: string) => Effect.Effect<void, StorageError | NotFoundError>;
|
|
13
16
|
readonly get: (id: string) => Effect.Effect<InferRecord<C>, StorageError | NotFoundError>;
|
|
14
17
|
readonly first: () => Effect.Effect<Option.Option<InferRecord<C>>, StorageError>;
|
|
15
18
|
readonly count: () => Effect.Effect<number, StorageError>;
|
|
@@ -18,4 +21,4 @@ export interface CollectionHandle<C extends CollectionDef<CollectionFields>> {
|
|
|
18
21
|
readonly orderBy: (field: string & keyof Omit<InferRecord<C>, "id">) => QueryBuilder<InferRecord<C>>;
|
|
19
22
|
}
|
|
20
23
|
export type OnWriteCallback = (event: StoredEvent) => Effect.Effect<void, StorageError>;
|
|
21
|
-
export declare function createCollectionHandle<C extends CollectionDef<CollectionFields>>(def: C, storage: IDBStorageHandle, watchCtx: WatchContext, validator: RecordValidator<C["fields"]>, partialValidator: PartialValidator<C["fields"]>, makeEventId: () => string, onWrite?: OnWriteCallback): CollectionHandle<C>;
|
|
24
|
+
export declare function createCollectionHandle<C extends CollectionDef<CollectionFields>>(def: C, storage: IDBStorageHandle, watchCtx: WatchContext, validator: RecordValidator<C["fields"]>, partialValidator: PartialValidator<C["fields"]>, makeEventId: () => string, localAuthor?: string, onWrite?: OnWriteCallback, logLevel?: LogLevel.LogLevel): CollectionHandle<C>;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Effect, Scope } from "effect";
|
|
2
|
+
import type { LogLevel } from "effect";
|
|
2
3
|
import type { SchemaConfig } from "../schema/types.ts";
|
|
3
4
|
import type { DatabaseHandle } from "./database-handle.ts";
|
|
4
5
|
import type { EpochKeyInput } from "./epoch.ts";
|
|
5
6
|
import { CryptoError, StorageError, ValidationError } from "../errors.ts";
|
|
7
|
+
export type TablinumLogLevel = "debug" | "info" | "warning" | "error" | "none" | LogLevel.LogLevel;
|
|
8
|
+
export declare function resolveLogLevel(input: TablinumLogLevel | undefined): LogLevel.LogLevel;
|
|
6
9
|
export interface TablinumConfig<S extends SchemaConfig> {
|
|
7
10
|
readonly schema: S;
|
|
8
11
|
readonly relays: readonly string[];
|
|
9
12
|
readonly privateKey?: Uint8Array | undefined;
|
|
10
13
|
readonly epochKeys?: ReadonlyArray<EpochKeyInput> | undefined;
|
|
11
14
|
readonly dbName?: string | undefined;
|
|
15
|
+
readonly logLevel?: TablinumLogLevel | undefined;
|
|
12
16
|
readonly onSyncError?: ((error: Error) => void) | undefined;
|
|
13
17
|
readonly onRemoved?: ((info: {
|
|
14
18
|
epochId: string;
|
|
@@ -4,7 +4,7 @@ import type { CollectionHandle } from "../crud/collection-handle.ts";
|
|
|
4
4
|
import type { SchemaConfig } from "../schema/types.ts";
|
|
5
5
|
import type { StorageError, SyncError, RelayError, CryptoError, ValidationError } from "../errors.ts";
|
|
6
6
|
import type { Invite } from "./invite.ts";
|
|
7
|
-
import type { MemberRecord } from "./members.ts";
|
|
7
|
+
import type { MemberRecord, AuthorProfile } from "./members.ts";
|
|
8
8
|
import type { RelayStatus } from "../sync/relay.ts";
|
|
9
9
|
export type SyncStatus = "idle" | "syncing";
|
|
10
10
|
export interface DatabaseHandle<S extends SchemaConfig> {
|
|
@@ -25,6 +25,7 @@ export interface DatabaseHandle<S extends SchemaConfig> {
|
|
|
25
25
|
readonly addMember: (pubkey: string) => Effect.Effect<void, ValidationError | StorageError | CryptoError>;
|
|
26
26
|
readonly removeMember: (pubkey: string) => Effect.Effect<void, ValidationError | StorageError | SyncError | RelayError | CryptoError>;
|
|
27
27
|
readonly getMembers: () => Effect.Effect<ReadonlyArray<MemberRecord>, StorageError>;
|
|
28
|
+
readonly getProfile: () => Effect.Effect<AuthorProfile, StorageError>;
|
|
28
29
|
readonly setProfile: (profile: {
|
|
29
30
|
name?: string;
|
|
30
31
|
picture?: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,13 +4,14 @@ export type { CollectionDef, CollectionFields } from "./schema/collection.ts";
|
|
|
4
4
|
export type { FieldDef, FieldKind } from "./schema/field.ts";
|
|
5
5
|
export type { InferRecord, SchemaConfig } from "./schema/types.ts";
|
|
6
6
|
export { createTablinum } from "./db/create-tablinum.ts";
|
|
7
|
-
export type { TablinumConfig } from "./db/create-tablinum.ts";
|
|
7
|
+
export type { TablinumConfig, TablinumLogLevel } from "./db/create-tablinum.ts";
|
|
8
|
+
export { LogLevel } from "effect";
|
|
8
9
|
export type { DatabaseHandle, SyncStatus } from "./db/database-handle.ts";
|
|
9
10
|
export { encodeInvite, decodeInvite } from "./db/invite.ts";
|
|
10
11
|
export type { Invite } from "./db/invite.ts";
|
|
11
12
|
export { EpochId, DatabaseName } from "./brands.ts";
|
|
12
13
|
export type { EpochKey, EpochKeyInput } from "./db/epoch.ts";
|
|
13
|
-
export type { MemberRecord } from "./db/members.ts";
|
|
14
|
+
export type { MemberRecord, AuthorProfile } from "./db/members.ts";
|
|
14
15
|
export type { CollectionHandle } from "./crud/collection-handle.ts";
|
|
15
16
|
export type { CollectionOptions } from "./schema/collection.ts";
|
|
16
17
|
export type { WhereClause, QueryBuilder, OrderByBuilder } from "./crud/query-builder.ts";
|