@tangentfeed/adapter-idb 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,10 @@
1
+ # @tangentfeed/adapter-idb
2
+
3
+ IndexedDB storage adapter for tangentfeed.
4
+
5
+ ```ts
6
+ import { IdbAdapter } from "@tangentfeed/adapter-idb";
7
+ const storage = await IdbAdapter.open("my-space");
8
+ ```
9
+
10
+ Part of [tangentfeed](https://github.com/sreerajta/tangentfeed). MIT licensed.
@@ -0,0 +1,57 @@
1
+ import { StorageAdapter, Op, Frontier, ClockState, DeviceKey, CompactionWrite, BatchWrite } from '@tangentfeed/core';
2
+
3
+ /**
4
+ * IndexedDB adapter — PROTOCOL.md §8 capabilities on browser storage.
5
+ *
6
+ * Layout (one database per space, name `tangentfeed:{space}`):
7
+ * ops — keyPath "id". Since op.id === op.hlc (34-char sortable string),
8
+ * primary-key order IS global HLC order for free.
9
+ * cells — key: cellKey(table,row,column) = "table\0row\0column".
10
+ * value: { key, op } where op is the current winning op.
11
+ * \u0000 sorts below every legal name char, so prefix ranges give
12
+ * us per-table and per-row scans without extra indexes.
13
+ * meta — "frontier" and "clock" singletons.
14
+ *
15
+ * Atomicity (§8.2): applyBatch issues every put inside ONE readwrite
16
+ * transaction over all three stores; IndexedDB transactions commit
17
+ * all-or-nothing. No foreign awaits happen mid-transaction (that would let
18
+ * the tx auto-commit early); all requests are queued synchronously.
19
+ *
20
+ * Known M2 simplifications (revisit in M8/compaction):
21
+ * - opsSince scans the whole ops store with a cursor and filters. Fine for
22
+ * demo scale; a ["device","hlc"] index makes it O(missing) later.
23
+ * - listTables/listRows scan the cells store by cursor.
24
+ */
25
+
26
+ declare class IdbAdapter implements StorageAdapter {
27
+ private readonly db;
28
+ private constructor();
29
+ /**
30
+ * Open (creating on first use) the database for a space.
31
+ * `factory` defaults to globalThis.indexedDB; inject fake-indexeddb in
32
+ * tests or a custom factory in exotic environments.
33
+ */
34
+ static open(space: string, factory?: IDBFactory): Promise<IdbAdapter>;
35
+ /** Delete a space's database entirely. */
36
+ static destroy(space: string, factory?: IDBFactory): Promise<void>;
37
+ close(): void;
38
+ getRow(table: string, row: string): Promise<ReadonlyMap<string, Op> | undefined>;
39
+ listRows(table: string): Promise<string[]>;
40
+ listTables(): Promise<string[]>;
41
+ hasOp(id: string): Promise<boolean>;
42
+ getWinner(table: string, row: string, column: string): Promise<Op | undefined>;
43
+ opsSince(frontier: Frontier): Promise<Op[]>;
44
+ getFrontier(): Promise<Frontier>;
45
+ getClock(): Promise<ClockState | undefined>;
46
+ getDeviceKey(): Promise<DeviceKey | undefined>;
47
+ setDeviceKey(key: DeviceKey): Promise<void>;
48
+ opCount(): Promise<number>;
49
+ allOps(): Promise<Op[]>;
50
+ getPeerFrontiers(): Promise<Record<string, Frontier>>;
51
+ setPeerFrontier(peer: string, frontier: Frontier): Promise<void>;
52
+ compact(write: CompactionWrite): Promise<void>;
53
+ applyBatch(batch: BatchWrite): Promise<void>;
54
+ private rangeScan;
55
+ }
56
+
57
+ export { IdbAdapter };
package/dist/index.js ADDED
@@ -0,0 +1,187 @@
1
+ // src/index.ts
2
+ import {
3
+ aboveFrontier,
4
+ cellKey
5
+ } from "@tangentfeed/core";
6
+ var SEP = "\0";
7
+ var HIGH = "\uFFFF";
8
+ var IdbAdapter = class _IdbAdapter {
9
+ constructor(db) {
10
+ this.db = db;
11
+ }
12
+ db;
13
+ /**
14
+ * Open (creating on first use) the database for a space.
15
+ * `factory` defaults to globalThis.indexedDB; inject fake-indexeddb in
16
+ * tests or a custom factory in exotic environments.
17
+ */
18
+ static async open(space, factory = globalThis.indexedDB) {
19
+ if (!factory) throw new Error("no IndexedDB available in this environment");
20
+ const db = await promisifyOpen(factory.open(`tangentfeed:${space}`, 1));
21
+ return new _IdbAdapter(db);
22
+ }
23
+ /** Delete a space's database entirely. */
24
+ static async destroy(space, factory = globalThis.indexedDB) {
25
+ await promisifyRequest(factory.deleteDatabase(`tangentfeed:${space}`));
26
+ }
27
+ close() {
28
+ this.db.close();
29
+ }
30
+ // ---------- reads ----------
31
+ async getRow(table, row) {
32
+ const prefix = table + SEP + row + SEP;
33
+ const entries = await this.rangeScan("cells", prefix);
34
+ if (entries.length === 0) return void 0;
35
+ const out = /* @__PURE__ */ new Map();
36
+ for (const { key, op } of entries) {
37
+ out.set(key.slice(prefix.length), op);
38
+ }
39
+ return out;
40
+ }
41
+ async listRows(table) {
42
+ const prefix = table + SEP;
43
+ const rows = /* @__PURE__ */ new Set();
44
+ for (const { key } of await this.rangeScan("cells", prefix)) {
45
+ const rest = key.slice(prefix.length);
46
+ rows.add(rest.slice(0, rest.indexOf(SEP)));
47
+ }
48
+ return [...rows];
49
+ }
50
+ async listTables() {
51
+ const tables = /* @__PURE__ */ new Set();
52
+ for (const { key } of await this.rangeScan("cells", "")) {
53
+ tables.add(key.slice(0, key.indexOf(SEP)));
54
+ }
55
+ return [...tables];
56
+ }
57
+ async hasOp(id) {
58
+ const tx = this.db.transaction("ops", "readonly");
59
+ const n = await promisifyRequest(tx.objectStore("ops").count(id));
60
+ return n > 0;
61
+ }
62
+ async getWinner(table, row, column) {
63
+ const tx = this.db.transaction("cells", "readonly");
64
+ const rec = await promisifyRequest(
65
+ tx.objectStore("cells").get(cellKey(table, row, column))
66
+ );
67
+ return rec?.op;
68
+ }
69
+ async opsSince(frontier) {
70
+ const tx = this.db.transaction("ops", "readonly");
71
+ const out = [];
72
+ await cursorEach(tx.objectStore("ops").openCursor(), (op) => {
73
+ if (aboveFrontier(op, frontier)) out.push(op);
74
+ });
75
+ return out;
76
+ }
77
+ async getFrontier() {
78
+ const tx = this.db.transaction("meta", "readonly");
79
+ return await promisifyRequest(tx.objectStore("meta").get("frontier")) ?? {};
80
+ }
81
+ async getClock() {
82
+ const tx = this.db.transaction("meta", "readonly");
83
+ return promisifyRequest(tx.objectStore("meta").get("clock"));
84
+ }
85
+ // ---------- signing identity (§12) ----------
86
+ // Stored as raw Uint8Arrays: structured clone handles typed arrays, so there
87
+ // is nothing to encode and nothing to get wrong on the way back out.
88
+ async getDeviceKey() {
89
+ const tx = this.db.transaction("meta", "readonly");
90
+ return promisifyRequest(tx.objectStore("meta").get("deviceKey"));
91
+ }
92
+ async setDeviceKey(key) {
93
+ const tx = this.db.transaction("meta", "readwrite");
94
+ tx.objectStore("meta").put({ publicKey: key.publicKey, privateKey: key.privateKey }, "deviceKey");
95
+ await txDone(tx);
96
+ }
97
+ // ---------- compaction support (§9) ----------
98
+ async opCount() {
99
+ const tx = this.db.transaction("ops", "readonly");
100
+ return promisifyRequest(tx.objectStore("ops").count());
101
+ }
102
+ async allOps() {
103
+ const tx = this.db.transaction("ops", "readonly");
104
+ const out = [];
105
+ await cursorEach(tx.objectStore("ops").openCursor(), (op) => out.push(op));
106
+ return out;
107
+ }
108
+ async getPeerFrontiers() {
109
+ const tx = this.db.transaction("meta", "readonly");
110
+ return await promisifyRequest(
111
+ tx.objectStore("meta").get("peers")
112
+ ) ?? {};
113
+ }
114
+ async setPeerFrontier(peer, frontier) {
115
+ const current = await this.getPeerFrontiers();
116
+ const tx = this.db.transaction("meta", "readwrite");
117
+ tx.objectStore("meta").put({ ...current, [peer]: frontier }, "peers");
118
+ await txDone(tx);
119
+ }
120
+ async compact(write) {
121
+ const tx = this.db.transaction(["ops", "cells"], "readwrite");
122
+ const ops = tx.objectStore("ops");
123
+ const cells = tx.objectStore("cells");
124
+ for (const id of write.opIds) ops.delete(id);
125
+ for (const key of write.cellKeys) cells.delete(key);
126
+ await txDone(tx);
127
+ }
128
+ // ---------- the one write path ----------
129
+ async applyBatch(batch) {
130
+ const tx = this.db.transaction(["ops", "cells", "meta"], "readwrite");
131
+ const ops = tx.objectStore("ops");
132
+ const cells = tx.objectStore("cells");
133
+ const meta = tx.objectStore("meta");
134
+ for (const op of batch.ops) ops.put(op);
135
+ for (const [key, op] of batch.winners) cells.put({ key, op });
136
+ meta.put(batch.frontier, "frontier");
137
+ meta.put(batch.clock, "clock");
138
+ await txDone(tx);
139
+ }
140
+ // ---------- helpers ----------
141
+ async rangeScan(store, prefix) {
142
+ const tx = this.db.transaction(store, "readonly");
143
+ const range = prefix === "" ? void 0 : IDBKeyRange.bound(prefix, prefix + HIGH, false, false);
144
+ const out = [];
145
+ await cursorEach(
146
+ tx.objectStore(store).openCursor(range ?? null),
147
+ (rec) => out.push(rec)
148
+ );
149
+ return out;
150
+ }
151
+ };
152
+ function promisifyOpen(req) {
153
+ req.onupgradeneeded = () => {
154
+ const db = req.result;
155
+ db.createObjectStore("ops", { keyPath: "id" });
156
+ db.createObjectStore("cells", { keyPath: "key" });
157
+ db.createObjectStore("meta");
158
+ };
159
+ return promisifyRequest(req);
160
+ }
161
+ function promisifyRequest(req) {
162
+ return new Promise((resolve, reject) => {
163
+ req.onsuccess = () => resolve(req.result);
164
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
165
+ });
166
+ }
167
+ function txDone(tx) {
168
+ return new Promise((resolve, reject) => {
169
+ tx.oncomplete = () => resolve();
170
+ tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
171
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
172
+ });
173
+ }
174
+ function cursorEach(req, fn) {
175
+ return new Promise((resolve, reject) => {
176
+ req.onsuccess = () => {
177
+ const cur = req.result;
178
+ if (!cur) return resolve();
179
+ fn(cur.value);
180
+ cur.continue();
181
+ };
182
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB cursor failed"));
183
+ });
184
+ }
185
+ export {
186
+ IdbAdapter
187
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@tangentfeed/adapter-idb",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "build": "tsup src/index.ts --format esm --dts --clean",
9
+ "prepack": "npm run build"
10
+ },
11
+ "dependencies": {
12
+ "@tangentfeed/core": "0.2.0"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^20.0.0",
16
+ "fake-indexeddb": "^6.0.0",
17
+ "fast-check": "^3.19.0",
18
+ "typescript": "^5.5.0",
19
+ "vitest": "^2.0.0",
20
+ "tsup": "^8.5.0"
21
+ },
22
+ "description": "IndexedDB storage adapter for tangentfeed",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/sreerajta/tangentfeed.git",
27
+ "directory": "packages/adapter-idb"
28
+ },
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }