snapback4 0.0.1 → 0.0.2

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.
@@ -0,0 +1,9 @@
1
+ export type Kind = "null" | "bool" | "int" | "string" | "id" | "bytes";
2
+ /** Encode a tuple of `(kind, value)` components. */
3
+ export declare function encodeKey(components: readonly (readonly [Kind, unknown])[]): Uint8Array;
4
+ /** The exclusive upper bound of every key that extends `prefix`. */
5
+ export declare function upperBound(prefix: Uint8Array): Uint8Array;
6
+ export declare function compareKeys(a: Uint8Array, b: Uint8Array): number;
7
+ export declare function hex(bytes: Uint8Array): string;
8
+ /** The kind of an index column from its declared column type. */
9
+ export declare function kindOf(columnType: unknown): Kind;
@@ -0,0 +1,107 @@
1
+ // One order-preserving byte encoding for index keys, byte-for-byte the
2
+ // server's (`crates/snapback4-core/src/key.rs`): a device scans the same
3
+ // bytes in the same order. Values on the device are plain JSON, so the
4
+ // column kind says whether a string is text or an identity.
5
+ const T_NULL = 0x01, T_FALSE = 0x02, T_TRUE = 0x03, T_INT = 0x04, T_STRING = 0x05, T_ID = 0x06, T_BYTES = 0x07, T_END = 0xff;
6
+ const utf8 = new TextEncoder();
7
+ /** Encode a tuple of `(kind, value)` components. */
8
+ export function encodeKey(components) {
9
+ const out = [];
10
+ for (const [kind, value] of components)
11
+ encodeOne(kind, value, out);
12
+ return Uint8Array.from(out);
13
+ }
14
+ function encodeOne(kind, value, out) {
15
+ if (value === null || value === undefined) {
16
+ out.push(T_NULL);
17
+ return;
18
+ }
19
+ switch (kind) {
20
+ case "null":
21
+ out.push(T_NULL);
22
+ return;
23
+ case "bool":
24
+ out.push(value ? T_TRUE : T_FALSE);
25
+ return;
26
+ case "int": {
27
+ out.push(T_INT);
28
+ // Flip the sign bit so two's complement sorts as unsigned bytes.
29
+ const flipped = BigInt.asUintN(64, BigInt(value)) ^ (1n << 63n);
30
+ for (let shift = 56n; shift >= 0n; shift -= 8n)
31
+ out.push(Number((flipped >> shift) & 0xffn));
32
+ return;
33
+ }
34
+ case "string":
35
+ out.push(T_STRING);
36
+ escape(utf8.encode(String(value)), out);
37
+ return;
38
+ case "id":
39
+ out.push(T_ID);
40
+ escape(utf8.encode(String(value)), out);
41
+ return;
42
+ case "bytes":
43
+ out.push(T_BYTES);
44
+ escape(hexBytes(String(value)), out);
45
+ return;
46
+ }
47
+ }
48
+ /** `0x00` inside a body becomes `0x00 0xff`; the body ends with `0x00 0x00`. */
49
+ function escape(bytes, out) {
50
+ for (const byte of bytes) {
51
+ if (byte === 0)
52
+ out.push(0, 0xff);
53
+ else
54
+ out.push(byte);
55
+ }
56
+ out.push(0, 0);
57
+ }
58
+ function hexBytes(hex) {
59
+ const out = new Uint8Array(hex.length >> 1);
60
+ for (let i = 0; i < out.length; i++)
61
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
62
+ return out;
63
+ }
64
+ /** The exclusive upper bound of every key that extends `prefix`. */
65
+ export function upperBound(prefix) {
66
+ const out = new Uint8Array(prefix.length + 1);
67
+ out.set(prefix);
68
+ out[prefix.length] = T_END;
69
+ return out;
70
+ }
71
+ export function compareKeys(a, b) {
72
+ const n = Math.min(a.length, b.length);
73
+ for (let i = 0; i < n; i++) {
74
+ if (a[i] !== b[i])
75
+ return a[i] < b[i] ? -1 : 1;
76
+ }
77
+ return a.length - b.length;
78
+ }
79
+ export function hex(bytes) {
80
+ let out = "";
81
+ for (const byte of bytes)
82
+ out += byte.toString(16).padStart(2, "0");
83
+ return out;
84
+ }
85
+ /** The kind of an index column from its declared column type. */
86
+ export function kindOf(columnType) {
87
+ if (columnType === "Id" || columnType === "Principal")
88
+ return "id";
89
+ if (columnType === "Int" || columnType === "Time")
90
+ return "int";
91
+ if (columnType === "Bool")
92
+ return "bool";
93
+ if (typeof columnType === "object" && columnType !== null) {
94
+ const key = Object.keys(columnType)[0];
95
+ if (key === "Ref")
96
+ return "id";
97
+ if (key === "Text" || key === "Enum")
98
+ return "string";
99
+ if (key === "Decimal" || key === "Money")
100
+ return "int";
101
+ if (key === "Bytes")
102
+ return "bytes";
103
+ if (key === "Optional")
104
+ return kindOf(columnType.Optional);
105
+ }
106
+ return "string";
107
+ }
@@ -0,0 +1,58 @@
1
+ import type { SchemaJson, Store } from "./store.ts";
2
+ export interface StreamEvent {
3
+ seq?: number;
4
+ table: string;
5
+ kind: "put" | "del" | "scope";
6
+ id: string;
7
+ data?: unknown;
8
+ }
9
+ export interface StreamPage {
10
+ snapshot: boolean;
11
+ events: StreamEvent[];
12
+ watermark: number;
13
+ more: boolean;
14
+ generation?: number;
15
+ }
16
+ export interface Transport {
17
+ /** `GET /sync?from=W`; `from: 0` is a snapshot. */
18
+ sync(from: number, limit: number): Promise<StreamPage | {
19
+ denied: unknown;
20
+ }>;
21
+ }
22
+ export interface ReplicaState {
23
+ watermark: number;
24
+ generation: number;
25
+ }
26
+ export declare class Replica {
27
+ readonly store: Store;
28
+ schema: SchemaJson;
29
+ private transport;
30
+ generation: number;
31
+ private listeners;
32
+ private caughtUp;
33
+ private following;
34
+ private closed;
35
+ constructor(store: Store, schema: SchemaJson, transport: Transport, generation: number);
36
+ /** A new generation from the server: the next sync re-derives the partition. */
37
+ adopt(schema: SchemaJson, generation: number): void;
38
+ /** Whether the device has applied everything the server had at the last sync. */
39
+ get isCaughtUp(): boolean;
40
+ onChange(listener: (touched: Set<string>) => void): () => void;
41
+ state(): Promise<ReplicaState>;
42
+ /** One round with the server: a snapshot when the device has none or the
43
+ * generation changed, else the stream from the watermark; every page
44
+ * applies as one transaction. Returns the tables that changed. */
45
+ syncOnce(): Promise<{
46
+ touched: Set<string>;
47
+ ok: boolean;
48
+ }>;
49
+ /** Apply one page atomically: rows, tombstones and the watermark together. */
50
+ apply(page: StreamPage, touched: Set<string>): Promise<void>;
51
+ /** A group the device left: drop its rows (`until-revoked`) or keep what
52
+ * was delivered (`delivered-history`). */
53
+ private leave;
54
+ /** Follow the stream until closed: sync, wait for the transport's next
55
+ * change signal, sync again. */
56
+ follow(wait: () => Promise<void>): void;
57
+ close(): Promise<void>;
58
+ }
@@ -0,0 +1,147 @@
1
+ // The replica: the viewer's partitions on the device as one store with one
2
+ // watermark (LLP 2000.000 §7.2). A fresh device takes a snapshot, then
3
+ // follows the merged stream; every batch applies in one transaction with
4
+ // its watermark; a scope tombstone applies the table's leave policy; a
5
+ // generation change re-bootstraps. Predictions live beside the rows and are
6
+ // replaced by the server's images as they arrive.
7
+ /** The meta keys the replica keeps. */
8
+ const WATERMARK = "watermark";
9
+ const GENERATION = "generation";
10
+ const PAGE = 2000;
11
+ export class Replica {
12
+ store;
13
+ schema;
14
+ transport;
15
+ generation;
16
+ listeners = new Set();
17
+ caughtUp = false;
18
+ following = null;
19
+ closed = false;
20
+ constructor(store, schema, transport, generation) {
21
+ this.store = store;
22
+ this.schema = schema;
23
+ this.transport = transport;
24
+ this.generation = generation;
25
+ }
26
+ /** A new generation from the server: the next sync re-derives the partition. */
27
+ adopt(schema, generation) {
28
+ this.schema = schema;
29
+ this.generation = generation;
30
+ }
31
+ /** Whether the device has applied everything the server had at the last sync. */
32
+ get isCaughtUp() {
33
+ return this.caughtUp;
34
+ }
35
+ onChange(listener) {
36
+ this.listeners.add(listener);
37
+ return () => this.listeners.delete(listener);
38
+ }
39
+ async state() {
40
+ return this.store.read(async (tx) => ({ watermark: Number((await tx.getMeta(WATERMARK)) ?? 0), generation: Number((await tx.getMeta(GENERATION)) ?? 0) }));
41
+ }
42
+ /** One round with the server: a snapshot when the device has none or the
43
+ * generation changed, else the stream from the watermark; every page
44
+ * applies as one transaction. Returns the tables that changed. */
45
+ async syncOnce() {
46
+ const touched = new Set();
47
+ let { watermark, generation } = await this.state();
48
+ if (generation !== this.generation) {
49
+ // A new generation re-derives the partition: drop rows, keep the outbox.
50
+ await this.store.clearRows();
51
+ await this.store.write(async (tx) => { await tx.setMeta(WATERMARK, 0); await tx.setMeta(GENERATION, this.generation); });
52
+ watermark = 0;
53
+ }
54
+ for (let pages = 0; pages < 1_000; pages++) {
55
+ const page = await this.transport.sync(watermark, PAGE);
56
+ if ("denied" in page)
57
+ return { touched, ok: false };
58
+ if (page.watermark < watermark)
59
+ return { touched, ok: false }; // never move backwards
60
+ await this.apply(page, touched);
61
+ watermark = page.watermark;
62
+ if (!page.more)
63
+ break;
64
+ }
65
+ this.caughtUp = true;
66
+ if (touched.size > 0)
67
+ for (const listener of this.listeners)
68
+ listener(touched);
69
+ return { touched, ok: true };
70
+ }
71
+ /** Apply one page atomically: rows, tombstones and the watermark together. */
72
+ async apply(page, touched) {
73
+ if (page.snapshot) {
74
+ // A snapshot is the whole partition: clear, then load. Predicted rows
75
+ // are re-applied from the outbox by the client, not kept here.
76
+ await this.store.clearRows();
77
+ for (const table of Object.keys(this.schema.tables))
78
+ touched.add(table);
79
+ }
80
+ await this.store.write(async (tx) => {
81
+ if (page.snapshot)
82
+ await tx.setMeta(WATERMARK, 0);
83
+ for (const event of page.events) {
84
+ touched.add(event.table);
85
+ switch (event.kind) {
86
+ case "put": {
87
+ const row = event.data;
88
+ await tx.put(event.table, row);
89
+ break;
90
+ }
91
+ case "del":
92
+ await tx.delete(event.table, event.id);
93
+ break;
94
+ case "scope":
95
+ await this.leave(tx, event.table, event.data);
96
+ break;
97
+ }
98
+ }
99
+ await tx.setMeta(WATERMARK, page.watermark);
100
+ await tx.setMeta(GENERATION, this.generation);
101
+ });
102
+ }
103
+ /** A group the device left: drop its rows (`until-revoked`) or keep what
104
+ * was delivered (`delivered-history`). */
105
+ async leave(tx, table, group) {
106
+ const definition = this.schema.tables[table];
107
+ if (!definition || definition.leave === "delivered-history")
108
+ return;
109
+ const audience = definition.sync?.audience;
110
+ const prefix = audience && typeof audience === "object" && "Target" in audience ? audience.Target.prefix : audience && "Column" in audience ? [audience.Column] : [];
111
+ // The group's rows are the prefix range of any index leading with the
112
+ // audience columns; the horizon index does when there is one.
113
+ const index = definition.sync?.horizon?.by ?? Object.entries(definition.indexes).find(([, i]) => prefix.every((c, n) => i.components[n]?.Column === c))?.[0];
114
+ if (!index)
115
+ return;
116
+ for (;;) {
117
+ const rows = await tx.scan(table, index, group, {}, "asc", 500);
118
+ if (rows.length === 0)
119
+ break;
120
+ for (const { row } of rows)
121
+ await tx.delete(table, row.id);
122
+ if (rows.length < 500)
123
+ break;
124
+ }
125
+ }
126
+ /** Follow the stream until closed: sync, wait for the transport's next
127
+ * change signal, sync again. */
128
+ follow(wait) {
129
+ if (this.following)
130
+ return;
131
+ this.following = (async () => {
132
+ while (!this.closed) {
133
+ const { ok } = await this.syncOnce();
134
+ if (this.closed)
135
+ break;
136
+ if (!ok)
137
+ await new Promise((r) => setTimeout(r, 1000));
138
+ else
139
+ await wait();
140
+ }
141
+ })();
142
+ }
143
+ async close() {
144
+ this.closed = true;
145
+ await this.store.close();
146
+ }
147
+ }
@@ -0,0 +1,29 @@
1
+ import { type SchemaJson, type Store } from "./store.ts";
2
+ /** What a SQLite binding must offer. `expo-sqlite`'s async database and
3
+ * `node:sqlite`'s `DatabaseSync` both fit behind a few lines. */
4
+ export interface SqliteDriver {
5
+ run(sql: string, params?: readonly unknown[]): Promise<void>;
6
+ all(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
7
+ /** Run `body` inside one transaction; roll back when it throws. */
8
+ transaction<T>(body: () => Promise<T>): Promise<T>;
9
+ close(): Promise<void>;
10
+ }
11
+ export declare function openSqlite(driver: SqliteDriver, schema: SchemaJson): Promise<Store>;
12
+ /** `node:sqlite`'s synchronous database behind the driver, for tests and
13
+ * for Node hosts. (`expo-sqlite` fits the same way with its async calls.) */
14
+ export declare function nodeSqliteDriver(db: {
15
+ exec(sql: string): void;
16
+ prepare(sql: string): {
17
+ run(...p: unknown[]): unknown;
18
+ all(...p: unknown[]): unknown[];
19
+ };
20
+ close(): void;
21
+ }): SqliteDriver;
22
+ /** `expo-sqlite`'s async database behind the driver. */
23
+ export declare function expoSqliteDriver(db: {
24
+ runAsync(sql: string, ...p: unknown[]): Promise<unknown>;
25
+ getAllAsync(sql: string, ...p: unknown[]): Promise<unknown[]>;
26
+ withExclusiveTransactionAsync?(body: (tx: unknown) => Promise<void>): Promise<void>;
27
+ withTransactionAsync(body: () => Promise<void>): Promise<void>;
28
+ closeAsync(): Promise<void>;
29
+ }): SqliteDriver;
@@ -0,0 +1,151 @@
1
+ // The replica store on SQLite: the server's two-table layout, one BLOB key
2
+ // per index entry, for hosts where SQLite is native — Expo (`expo-sqlite`)
3
+ // and Node (`node:sqlite`). The driver is the smallest thing both offer: run
4
+ // a statement, read rows, wrap a transaction. SQLite compares BLOBs byte by
5
+ // byte, so `key.ts` orders here exactly as it does in IndexedDB and on the
6
+ // server.
7
+ import { encodeKey, upperBound } from "./key.js";
8
+ import { indexKey, indexKinds, scanRange } from "./store.js";
9
+ const SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS r (t TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (t, id)) WITHOUT ROWID;
11
+ CREATE TABLE IF NOT EXISTS i (ti TEXT NOT NULL, k BLOB NOT NULL, id TEXT NOT NULL, PRIMARY KEY (ti, k)) WITHOUT ROWID;
12
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
13
+ CREATE TABLE IF NOT EXISTS side (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (name, key)) WITHOUT ROWID;
14
+ `;
15
+ export async function openSqlite(driver, schema) {
16
+ for (const statement of SCHEMA.split(";").map((s) => s.trim()).filter(Boolean))
17
+ await driver.run(statement);
18
+ return new SqliteStore(driver, schema);
19
+ }
20
+ class SqliteStore {
21
+ db;
22
+ schema;
23
+ constructor(db, schema) {
24
+ this.db = db;
25
+ this.schema = schema;
26
+ }
27
+ tx() {
28
+ const db = this.db;
29
+ const schema = this.schema;
30
+ const enc = (table, index, values) => {
31
+ const { kinds } = indexKinds(schema, table, index);
32
+ return encodeKey(values.map((v, i) => [kinds[i] ?? "string", v]));
33
+ };
34
+ const parse = (rows) => rows.map((r) => JSON.parse(r.data));
35
+ const side = (name) => ({
36
+ async get(key) {
37
+ const rows = await db.all("SELECT value FROM side WHERE name = ? AND key = ?", [name, key]);
38
+ return rows[0] ? JSON.parse(rows[0].value) : undefined;
39
+ },
40
+ async put(key, value) { await db.run("INSERT INTO side(name, key, value) VALUES (?, ?, ?) ON CONFLICT(name, key) DO UPDATE SET value = excluded.value", [name, key, JSON.stringify(value)]); },
41
+ async delete(key) { await db.run("DELETE FROM side WHERE name = ? AND key = ?", [name, key]); },
42
+ async all() { return (await db.all("SELECT key, value FROM side WHERE name = ? ORDER BY key", [name])).map((r) => ({ key: r.key, value: JSON.parse(r.value) })); },
43
+ async clear() { await db.run("DELETE FROM side WHERE name = ?", [name]); },
44
+ });
45
+ return {
46
+ async get(table, id) {
47
+ return parse(await db.all("SELECT data FROM r WHERE t = ? AND id = ?", [table, id]))[0];
48
+ },
49
+ async lookup(table, index, key) {
50
+ const prefix = enc(table, index, key);
51
+ const rows = await db.all("SELECT r.data FROM i JOIN r ON r.t = ? AND r.id = i.id WHERE i.ti = ? AND i.k >= ? AND i.k < ? ORDER BY i.k LIMIT 1", [table, `${table}.${index}`, prefix, upperBound(prefix)]);
52
+ return parse(rows)[0];
53
+ },
54
+ async scan(table, index, prefix, bounds, dir, limit) {
55
+ const range = scanRange(schema, table, index, prefix, bounds, dir);
56
+ if (!range || limit <= 0)
57
+ return [];
58
+ const rows = await db.all(`SELECT r.data, i.k AS k FROM i JOIN r ON r.t = ? AND r.id = i.id WHERE i.ti = ? AND i.k >= ? AND i.k < ? ORDER BY i.k ${dir === "asc" ? "ASC" : "DESC"} LIMIT ?`, [table, `${table}.${index}`, range[0], range[1], limit]);
59
+ return rows.map((r) => ({ row: JSON.parse(r.data), key: new Uint8Array(r.k) }));
60
+ },
61
+ async count(table, index, prefix, limit) {
62
+ const range = scanRange(schema, table, index, prefix, {}, "asc");
63
+ if (!range)
64
+ return 0;
65
+ const rows = await db.all("SELECT COUNT(*) AS n FROM (SELECT 1 FROM i WHERE ti = ? AND k >= ? AND k < ? LIMIT ?)", [`${table}.${index}`, range[0], range[1], limit]);
66
+ return Number(rows[0]?.n ?? 0);
67
+ },
68
+ async put(table, row) {
69
+ const old = parse(await db.all("SELECT data FROM r WHERE t = ? AND id = ?", [table, row.id]))[0];
70
+ const definition = schema.tables[table];
71
+ if (!definition)
72
+ throw new Error(`unknown table ${table}`);
73
+ for (const index of Object.keys(definition.indexes)) {
74
+ const ti = `${table}.${index}`;
75
+ if (old)
76
+ await db.run("DELETE FROM i WHERE ti = ? AND k = ?", [ti, indexKey(schema, table, index, old)]);
77
+ await db.run("INSERT INTO i(ti, k, id) VALUES (?, ?, ?) ON CONFLICT(ti, k) DO UPDATE SET id = excluded.id", [ti, indexKey(schema, table, index, row), row.id]);
78
+ }
79
+ await db.run("INSERT INTO r(t, id, data) VALUES (?, ?, ?) ON CONFLICT(t, id) DO UPDATE SET data = excluded.data", [table, row.id, JSON.stringify(row)]);
80
+ },
81
+ async delete(table, id) {
82
+ const old = parse(await db.all("SELECT data FROM r WHERE t = ? AND id = ?", [table, id]))[0];
83
+ if (!old)
84
+ return;
85
+ for (const index of Object.keys(schema.tables[table]?.indexes ?? {}))
86
+ await db.run("DELETE FROM i WHERE ti = ? AND k = ?", [`${table}.${index}`, indexKey(schema, table, index, old)]);
87
+ await db.run("DELETE FROM r WHERE t = ? AND id = ?", [table, id]);
88
+ },
89
+ async getMeta(key) {
90
+ const rows = await db.all("SELECT value FROM meta WHERE key = ?", [key]);
91
+ return rows[0] ? JSON.parse(rows[0].value) : undefined;
92
+ },
93
+ async setMeta(key, value) { await db.run("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, JSON.stringify(value)]); },
94
+ side,
95
+ };
96
+ }
97
+ read(body) { return this.db.transaction(() => body(this.tx())); }
98
+ write(body) { return this.db.transaction(() => body(this.tx())); }
99
+ async clearRows() { await this.db.run("DELETE FROM r"); await this.db.run("DELETE FROM i"); }
100
+ close() { return this.db.close(); }
101
+ }
102
+ /** `node:sqlite`'s synchronous database behind the driver, for tests and
103
+ * for Node hosts. (`expo-sqlite` fits the same way with its async calls.) */
104
+ export function nodeSqliteDriver(db) {
105
+ let depth = 0;
106
+ return {
107
+ async run(sql, params = []) { db.prepare(sql).run(...params); },
108
+ async all(sql, params = []) { return db.prepare(sql).all(...params); },
109
+ async transaction(body) {
110
+ if (depth > 0)
111
+ return body();
112
+ depth++;
113
+ db.exec("BEGIN");
114
+ try {
115
+ const result = await body();
116
+ db.exec("COMMIT");
117
+ return result;
118
+ }
119
+ catch (error) {
120
+ db.exec("ROLLBACK");
121
+ throw error;
122
+ }
123
+ finally {
124
+ depth--;
125
+ }
126
+ },
127
+ async close() { db.close(); },
128
+ };
129
+ }
130
+ /** `expo-sqlite`'s async database behind the driver. */
131
+ export function expoSqliteDriver(db) {
132
+ let depth = 0;
133
+ return {
134
+ async run(sql, params = []) { await db.runAsync(sql, ...params); },
135
+ async all(sql, params = []) { return (await db.getAllAsync(sql, ...params)); },
136
+ async transaction(body) {
137
+ if (depth > 0)
138
+ return body();
139
+ depth++;
140
+ let result;
141
+ try {
142
+ await db.withTransactionAsync(async () => { result = await body(); });
143
+ }
144
+ finally {
145
+ depth--;
146
+ }
147
+ return result;
148
+ },
149
+ async close() { await db.closeAsync(); },
150
+ };
151
+ }
@@ -0,0 +1,91 @@
1
+ import { type Kind } from "./key.ts";
2
+ export type Row = Record<string, unknown> & {
3
+ id: string;
4
+ };
5
+ /** The server's schema, as `GET /schema` sends it. */
6
+ export interface SchemaJson {
7
+ tables: Record<string, TableJson>;
8
+ maintains?: unknown[];
9
+ }
10
+ export interface TableJson {
11
+ columns: Record<string, unknown>;
12
+ indexes: Record<string, {
13
+ components: ({
14
+ Column: string;
15
+ } | {
16
+ WordPrefixes: unknown;
17
+ })[];
18
+ unique: boolean;
19
+ }>;
20
+ sync?: {
21
+ audience: unknown;
22
+ horizon?: {
23
+ last: number;
24
+ by: string;
25
+ } | null;
26
+ } | null;
27
+ leave?: "until-revoked" | "delivered-history";
28
+ }
29
+ export interface Bounds {
30
+ after?: unknown[];
31
+ before?: unknown[];
32
+ gt?: unknown;
33
+ gte?: unknown;
34
+ lt?: unknown;
35
+ lte?: unknown;
36
+ }
37
+ export interface StoreTx {
38
+ get(table: string, id: string): Promise<Row | undefined>;
39
+ lookup(table: string, index: string, key: readonly unknown[]): Promise<Row | undefined>;
40
+ scan(table: string, index: string, prefix: readonly unknown[], bounds: Bounds, dir: "asc" | "desc", limit: number): Promise<{
41
+ row: Row;
42
+ key: Uint8Array;
43
+ }[]>;
44
+ count(table: string, index: string, prefix: readonly unknown[], limit: number): Promise<number>;
45
+ put(table: string, row: Row): Promise<void>;
46
+ delete(table: string, id: string): Promise<void>;
47
+ getMeta(key: string): Promise<unknown>;
48
+ setMeta(key: string, value: unknown): Promise<void>;
49
+ /** The outbox and other durable side tables share the transaction. */
50
+ side(name: string): SideTx;
51
+ }
52
+ export interface SideTx {
53
+ get(key: string): Promise<unknown>;
54
+ put(key: string, value: unknown): Promise<void>;
55
+ delete(key: string): Promise<void>;
56
+ all(): Promise<{
57
+ key: string;
58
+ value: unknown;
59
+ }[]>;
60
+ clear(): Promise<void>;
61
+ }
62
+ export interface Store {
63
+ read<T>(body: (tx: StoreTx) => Promise<T>): Promise<T>;
64
+ write<T>(body: (tx: StoreTx) => Promise<T>): Promise<T>;
65
+ /** Drop every row and index entry (a re-bootstrap); meta and side tables stay. */
66
+ clearRows(): Promise<void>;
67
+ close(): Promise<void>;
68
+ }
69
+ /** The index components' kinds for a table, from the schema. */
70
+ export declare function indexKinds(schema: SchemaJson, table: string, index: string): {
71
+ columns: string[];
72
+ kinds: Kind[];
73
+ };
74
+ /** The stored key of one index entry: the components, then the id. */
75
+ export declare function indexKey(schema: SchemaJson, table: string, index: string, row: Row): Uint8Array;
76
+ /** The byte range of a scan: `[lo, hi)`, or null when empty. */
77
+ export declare function scanRange(schema: SchemaJson, table: string, index: string, prefix: readonly unknown[], bounds: Bounds, dir: "asc" | "desc"): [Uint8Array, Uint8Array] | null;
78
+ export declare class MemoryStore implements Store {
79
+ readonly schema: SchemaJson;
80
+ private rows;
81
+ private indexes;
82
+ private meta;
83
+ private sides;
84
+ constructor(schema: SchemaJson);
85
+ private tx;
86
+ read<T>(body: (tx: StoreTx) => Promise<T>): Promise<T>;
87
+ write<T>(body: (tx: StoreTx) => Promise<T>): Promise<T>;
88
+ clearRows(): Promise<void>;
89
+ close(): Promise<void>;
90
+ }
91
+ export declare function openIndexedDb(name: string, schema: SchemaJson): Promise<Store>;