tangentfeed 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sreeraj T A
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/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # tangentfeed
2
+
3
+ Batteries-included entry point.
4
+
5
+ ```ts
6
+ import { openSpace, broadcast } from "tangentfeed";
7
+
8
+ const db = await openSpace({ space: "kitchen-42", transports: [broadcast()] });
9
+ await db.insert("tasks", { title: "Buy oat milk", done: false });
10
+ ```
11
+
12
+ See the [project README](https://github.com/sreerajta/tangentfeed) for the full guide.
13
+
14
+ Part of [tangentfeed](https://github.com/sreerajta/tangentfeed). MIT licensed.
@@ -0,0 +1,145 @@
1
+ import { StorageAdapter, Transport, SyncEngine, Json, RowData, ChangeEvent, Frontier } from '@tangentfeed/core';
2
+ export { ChangeEvent, Frontier, Json, MemoryAdapter, Op, Replicator, RowData, StorageAdapter, SyncEngine, Transport } from '@tangentfeed/core';
3
+ import { ManualPairState, ManualPairTransport, SignalingState } from '@tangentfeed/transport-webrtc';
4
+ export { ManualPairState, ManualPairTransport, SignalingState, WebRTCTransport } from '@tangentfeed/transport-webrtc';
5
+ import { SchemaShape, TableName, InsertInput, UpdateInput, RowOf } from '@tangentfeed/schema';
6
+ export { IdbAdapter } from '@tangentfeed/adapter-idb';
7
+ export { SpaceCipher } from '@tangentfeed/crypto';
8
+ export { BroadcastTransport } from '@tangentfeed/transport-broadcast';
9
+
10
+ /**
11
+ * tangentfeed — offline-first, peer-to-peer data sync.
12
+ *
13
+ * This package is the batteries-included entry point. It assembles the pieces
14
+ * (engine, storage adapter, transports, optional encryption) that the
15
+ * @tangentfeed/* packages provide individually, so the common case is one call:
16
+ *
17
+ * const db = await openSpace({
18
+ * space: "kitchen-42",
19
+ * transports: [broadcast(), webrtc({ signaling: "wss://…" })],
20
+ * encryption: { passphrase: "correct horse battery staple" },
21
+ * });
22
+ *
23
+ * await db.insert("tasks", { title: "Buy oat milk", done: false });
24
+ * db.subscribe(() => render(db.list("tasks")));
25
+ *
26
+ * Everything remains available à la carte: import from @tangentfeed/core to build
27
+ * against the protocol directly with your own storage or transport.
28
+ */
29
+
30
+ type TransportFactory = (ctx: {
31
+ space: string;
32
+ deviceId: string;
33
+ }) => Transport | Promise<Transport>;
34
+ interface OpenSpaceOptions<S extends SchemaShape | undefined = undefined> {
35
+ /** Logical database name; peers only sync within the same space. */
36
+ space: string;
37
+ /**
38
+ * "indexeddb" (default in browsers), "memory", or any StorageAdapter.
39
+ *
40
+ * For SQLite, construct the adapter yourself and pass it here — the driver
41
+ * (better-sqlite3, node:sqlite, bun:sqlite) is your choice, and keeping it
42
+ * out of this package means browsers never bundle a native dependency:
43
+ *
44
+ * import { SqliteAdapter, betterSqliteDriver } from "@tangentfeed/adapter-sqlite";
45
+ * import Database from "better-sqlite3";
46
+ *
47
+ * const db = await openSpace({
48
+ * space: "kitchen-42",
49
+ * storage: SqliteAdapter.open(betterSqliteDriver(new Database("data.db"))),
50
+ * });
51
+ */
52
+ storage?: "indexeddb" | "memory" | StorageAdapter;
53
+ /**
54
+ * Names the local database when several replicas share one origin — two
55
+ * browser tabs syncing with each other, for instance.
56
+ *
57
+ * This is not an identity. Identity is a keypair held inside the database
58
+ * (§4.3); this only decides which database to open. Callers that supply
59
+ * their own storage adapter never need it.
60
+ */
61
+ replica?: string;
62
+ /** Zero or more transports. Omit for a purely local database. */
63
+ transports?: TransportFactory[];
64
+ /** End-to-end encryption. Every peer in the space needs the same secret. */
65
+ encryption?: {
66
+ passphrase: string;
67
+ } | {
68
+ secret: Uint8Array;
69
+ };
70
+ /** Surface protocol-level problems (clock drift, bad ops, transport errors). */
71
+ onError?: (err: unknown, ctx: {
72
+ peer?: string;
73
+ }) => void;
74
+ /**
75
+ * Optional typed schema. Supplying one types the data methods and validates
76
+ * local writes; it never inspects data arriving from peers, so a peer on a
77
+ * different schema still syncs completely.
78
+ */
79
+ schema?: S;
80
+ }
81
+ interface SyncedSpace<S extends SchemaShape | undefined = undefined> {
82
+ readonly space: string;
83
+ readonly deviceId: string;
84
+ /** Underlying engine, for protocol-level work. */
85
+ readonly engine: SyncEngine;
86
+ insert: S extends SchemaShape ? <T extends TableName<S>>(table: T, values: InsertInput<S, T>) => Promise<string> : (table: string, values: Record<string, Json>) => Promise<string>;
87
+ update: S extends SchemaShape ? <T extends TableName<S>>(table: T, row: string, values: UpdateInput<S, T>) => Promise<void> : (table: string, row: string, values: Record<string, Json>) => Promise<void>;
88
+ delete: S extends SchemaShape ? (table: TableName<S>, row: string) => Promise<void> : (table: string, row: string) => Promise<void>;
89
+ get: S extends SchemaShape ? <T extends TableName<S>>(table: T, row: string) => Promise<RowOf<S, T> | undefined> : (table: string, row: string) => Promise<RowData | undefined>;
90
+ list: S extends SchemaShape ? <T extends TableName<S>>(table: T) => Promise<RowOf<S, T>[]> : (table: string) => Promise<RowData[]>;
91
+ /** Called after every committed change, local or remote. */
92
+ subscribe(cb: (event: ChangeEvent) => void): () => void;
93
+ /** deviceIds currently reachable across all transports. */
94
+ peers(): string[];
95
+ frontier(): Promise<Frontier>;
96
+ /** Reclaim superseded ops. See PROTOCOL.md §9. */
97
+ compact(opts?: {
98
+ includeTombstones?: boolean;
99
+ dryRun?: boolean;
100
+ }): Promise<{
101
+ removed: number;
102
+ rowsReclaimed: number;
103
+ blockedBy: string[];
104
+ }>;
105
+ close(): Promise<void>;
106
+ }
107
+ /**
108
+ * Overloads, not one generic signature, so the no-schema call keeps exactly
109
+ * the types it had before this layer existed.
110
+ *
111
+ * Order is load-bearing: `Parameters<typeof openSpace>` resolves against the
112
+ * LAST overload, and existing code uses that utility type to describe options.
113
+ * A single generic signature resolves S to its constraint there, which widens
114
+ * the return to SyncedSpace<SchemaShape | undefined> and breaks assignment to
115
+ * SyncedSpace.
116
+ */
117
+ declare function openSpace<S extends SchemaShape>(opts: OpenSpaceOptions<S> & {
118
+ schema: S;
119
+ }): Promise<SyncedSpace<S>>;
120
+ declare function openSpace(opts: OpenSpaceOptions<undefined>): Promise<SyncedSpace<undefined>>;
121
+ /** Same-device sync between tabs and workers. No infrastructure. */
122
+ declare function broadcast(): TransportFactory;
123
+ /** Cross-device sync over WebRTC, brokered by a signaling server. */
124
+ declare function webrtc(opts: {
125
+ signaling: string;
126
+ iceServers?: RTCIceServer[];
127
+ onSignalingState?: (state: SignalingState) => void;
128
+ onError?: (err: unknown, ctx: {
129
+ peer?: string;
130
+ }) => void;
131
+ }): TransportFactory;
132
+ /**
133
+ * Serverless pairing: the two devices exchange offer/answer blobs by QR code
134
+ * or copy-paste. Create the transport yourself so you can drive the handshake,
135
+ * then hand it to openSpace via `existing()`.
136
+ */
137
+ declare function manualPair(opts: {
138
+ deviceId: string;
139
+ iceServers?: RTCIceServer[];
140
+ onState?: (state: ManualPairState) => void;
141
+ }): ManualPairTransport;
142
+ /** Wrap an already-constructed transport (e.g. a paired ManualPairTransport). */
143
+ declare function existing(transport: Transport): TransportFactory;
144
+
145
+ export { type OpenSpaceOptions, type SyncedSpace, type TransportFactory, broadcast, existing, manualPair, openSpace, webrtc };
package/dist/index.js ADDED
@@ -0,0 +1,156 @@
1
+ // src/index.ts
2
+ import {
3
+ Replicator,
4
+ SyncEngine
5
+ } from "@tangentfeed/core";
6
+ import { IdbAdapter } from "@tangentfeed/adapter-idb";
7
+ import { MemoryAdapter } from "@tangentfeed/core";
8
+ import { SpaceCipher } from "@tangentfeed/crypto";
9
+ import { BroadcastTransport } from "@tangentfeed/transport-broadcast";
10
+ import {
11
+ ManualPairTransport,
12
+ WebRTCTransport
13
+ } from "@tangentfeed/transport-webrtc";
14
+ import {
15
+ validateInsert,
16
+ validateUpdate
17
+ } from "@tangentfeed/schema";
18
+ import {
19
+ SyncEngine as SyncEngine2,
20
+ Replicator as Replicator2,
21
+ MemoryAdapter as MemoryAdapter2
22
+ } from "@tangentfeed/core";
23
+ import { IdbAdapter as IdbAdapter2 } from "@tangentfeed/adapter-idb";
24
+ import { SpaceCipher as SpaceCipher2 } from "@tangentfeed/crypto";
25
+ import { BroadcastTransport as BroadcastTransport2 } from "@tangentfeed/transport-broadcast";
26
+ import {
27
+ WebRTCTransport as WebRTCTransport2,
28
+ ManualPairTransport as ManualPairTransport2
29
+ } from "@tangentfeed/transport-webrtc";
30
+ async function openSpace(opts) {
31
+ const space = opts.space;
32
+ const storage = await resolveStorage(opts.storage, space, opts.replica ?? "default");
33
+ const cipher = await resolveCipher(opts.encryption, space);
34
+ const engine = await SyncEngine.open({
35
+ storage,
36
+ ...cipher ? { cipher } : {}
37
+ });
38
+ const transports = [];
39
+ const replicators = [];
40
+ for (const make of opts.transports ?? []) {
41
+ const transport = await make({ space, deviceId: engine.deviceId });
42
+ transports.push(transport);
43
+ const replicator = new Replicator({
44
+ engine,
45
+ transport,
46
+ space,
47
+ events: {
48
+ onError: (err, ctx) => opts.onError?.(err, ctx.from ? { peer: ctx.from } : {})
49
+ }
50
+ });
51
+ replicators.push(replicator);
52
+ await replicator.start();
53
+ }
54
+ return {
55
+ space,
56
+ deviceId: engine.deviceId,
57
+ engine,
58
+ // `async` is load-bearing: validation throws synchronously, and these
59
+ // methods are declared to return a Promise. Without it a SchemaError would
60
+ // escape past `.catch()` and reject-style handling.
61
+ insert: (async (table, values) => engine.insert(
62
+ table,
63
+ opts.schema ? validateInsert(opts.schema, table, values) : values
64
+ )),
65
+ update: (async (table, row, values) => engine.update(
66
+ table,
67
+ row,
68
+ opts.schema ? validateUpdate(opts.schema, table, values) : values
69
+ )),
70
+ // The read casts are where "asserted, not proven" lives: the engine still
71
+ // returns RowData and the schema layer relabels it without checking. That
72
+ // is deliberate — see parseRow for the opt-in check.
73
+ delete: ((t, r) => engine.delete(t, r)),
74
+ get: ((t, r) => engine.get(t, r)),
75
+ list: ((t) => engine.list(t)),
76
+ subscribe: (cb) => engine.subscribe(cb),
77
+ peers: () => {
78
+ const ids = /* @__PURE__ */ new Set();
79
+ for (const r of replicators) for (const id of r.peerIds) ids.add(id);
80
+ for (const t of transports) {
81
+ const connected = t.connectedPeers;
82
+ for (const id of connected ?? []) ids.add(id);
83
+ }
84
+ return [...ids];
85
+ },
86
+ frontier: () => engine.frontier(),
87
+ compact: async (o = {}) => {
88
+ const stats = await engine.compact(o);
89
+ return {
90
+ removed: stats.removed,
91
+ rowsReclaimed: stats.rowsReclaimed,
92
+ blockedBy: stats.blockedBy
93
+ };
94
+ },
95
+ close: async () => {
96
+ for (const r of replicators) r.stop();
97
+ for (const t of transports) t.close();
98
+ storage.close?.();
99
+ }
100
+ };
101
+ }
102
+ function broadcast() {
103
+ return ({ space }) => new BroadcastTransport(space);
104
+ }
105
+ function webrtc(opts) {
106
+ return ({ space, deviceId }) => new WebRTCTransport({
107
+ space,
108
+ deviceId,
109
+ signalingUrl: opts.signaling,
110
+ ...opts.iceServers ? { rtcConfig: { iceServers: opts.iceServers } } : {},
111
+ ...opts.onSignalingState ? { onSignalingState: opts.onSignalingState } : {},
112
+ ...opts.onError ? { onError: opts.onError } : {}
113
+ });
114
+ }
115
+ function manualPair(opts) {
116
+ return new ManualPairTransport({
117
+ deviceId: opts.deviceId,
118
+ ...opts.iceServers ? { rtcConfig: { iceServers: opts.iceServers } } : {},
119
+ ...opts.onState ? { onState: opts.onState } : {}
120
+ });
121
+ }
122
+ function existing(transport) {
123
+ return () => transport;
124
+ }
125
+ async function resolveStorage(storage, space, replica) {
126
+ if (storage && typeof storage !== "string") return storage;
127
+ if (storage === "memory") return new MemoryAdapter();
128
+ if (storage === "indexeddb" || storage === void 0) {
129
+ if (globalThis.indexedDB) return IdbAdapter.open(`${space}:${replica}`);
130
+ if (storage === "indexeddb") {
131
+ throw new Error("IndexedDB is not available in this environment");
132
+ }
133
+ return new MemoryAdapter();
134
+ }
135
+ throw new Error(`unknown storage option: ${String(storage)}`);
136
+ }
137
+ async function resolveCipher(enc, space) {
138
+ if (!enc) return void 0;
139
+ if ("passphrase" in enc) return SpaceCipher.fromPassphrase(enc.passphrase, space);
140
+ return new SpaceCipher(enc.secret);
141
+ }
142
+ export {
143
+ BroadcastTransport2 as BroadcastTransport,
144
+ IdbAdapter2 as IdbAdapter,
145
+ ManualPairTransport2 as ManualPairTransport,
146
+ MemoryAdapter2 as MemoryAdapter,
147
+ Replicator2 as Replicator,
148
+ SpaceCipher2 as SpaceCipher,
149
+ SyncEngine2 as SyncEngine,
150
+ WebRTCTransport2 as WebRTCTransport,
151
+ broadcast,
152
+ existing,
153
+ manualPair,
154
+ openSpace,
155
+ webrtc
156
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "tangentfeed",
3
+ "version": "0.2.0",
4
+ "description": "Offline-first, peer-to-peer data sync. Any storage, any transport, any language.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "offline-first",
23
+ "sync",
24
+ "crdt",
25
+ "p2p",
26
+ "webrtc",
27
+ "local-first",
28
+ "indexeddb"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsup src/index.ts --format esm --dts --clean",
32
+ "prepack": "npm run build",
33
+ "test": "vitest run"
34
+ },
35
+ "dependencies": {
36
+ "@tangentfeed/core": "0.2.0",
37
+ "@tangentfeed/adapter-idb": "0.2.0",
38
+ "@tangentfeed/crypto": "0.2.0",
39
+ "@tangentfeed/schema": "0.2.0",
40
+ "@tangentfeed/transport-broadcast": "0.2.0",
41
+ "@tangentfeed/transport-webrtc": "0.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^20.0.0",
45
+ "tsup": "^8.5.0",
46
+ "typescript": "^5.5.0",
47
+ "vitest": "^2.0.0"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/sreerajta/tangentfeed.git",
52
+ "directory": "packages/tangentfeed"
53
+ },
54
+ "engines": {
55
+ "node": ">=20"
56
+ }
57
+ }