@openlfcp/storage-node 0.1.2 → 0.1.4
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 +2 -2
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +185 -0
- package/dist/secrets.d.ts +32 -0
- package/dist/secrets.js +106 -0
- package/dist/sqlite.d.ts +31 -0
- package/dist/sqlite.js +534 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -18,9 +18,9 @@ on it. The Obsidian plugin does not use it: Obsidian has its own adapter
|
|
|
18
18
|
|
|
19
19
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
20
20
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
21
|
-
at `mvp-0.1-baseline.
|
|
21
|
+
at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
|
|
22
22
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
23
|
-
(`0.1.
|
|
23
|
+
(`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
24
24
|
|
|
25
25
|
## SQLite
|
|
26
26
|
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable Node.js storage for LFCP clients (LFCP-035): LfcpStorage on
|
|
3
|
+
* SQLite and a file SecretStore. Node only: for headless Node, the CLI,
|
|
4
|
+
* examples and tests. Obsidian uses its own adapter (LFCP-059), which runs
|
|
5
|
+
* the same contract suite.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PACKAGE = "@openlfcp/storage-node";
|
|
8
|
+
export { MIGRATIONS, migrate, SCHEMA_VERSION, schemaVersion } from "./schema.js";
|
|
9
|
+
export { FileSecretStore } from "./secrets.js";
|
|
10
|
+
export { SqliteLfcpStorage, type SqliteStorageOptions } from "./sqlite.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable Node.js storage for LFCP clients (LFCP-035): LfcpStorage on
|
|
3
|
+
* SQLite and a file SecretStore. Node only: for headless Node, the CLI,
|
|
4
|
+
* examples and tests. Obsidian uses its own adapter (LFCP-059), which runs
|
|
5
|
+
* the same contract suite.
|
|
6
|
+
*/
|
|
7
|
+
export const PACKAGE = "@openlfcp/storage-node";
|
|
8
|
+
export { MIGRATIONS, migrate, SCHEMA_VERSION, schemaVersion } from "./schema.js";
|
|
9
|
+
export { FileSecretStore } from "./secrets.js";
|
|
10
|
+
export { SqliteLfcpStorage } from "./sqlite.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
/**
|
|
3
|
+
* The SQLite schema and its migrations. Version 1 is the LFCP-034 storage
|
|
4
|
+
* model; version 2 adds outbound retry state and sync state (LFCP-036);
|
|
5
|
+
* version 3 adds local marks (the crash-loop breaker's apply markers).
|
|
6
|
+
* A database at a newer version than this code knows is refused:
|
|
7
|
+
* it is never downgraded or "repaired".
|
|
8
|
+
*
|
|
9
|
+
* Conventions: IDs and exact object bytes are BLOBs (memcmp order is byte
|
|
10
|
+
* order); uint64 values (sequences, epochs, route versions) are 20-digit
|
|
11
|
+
* zero-padded decimal TEXT, so they sort numerically and never overflow
|
|
12
|
+
* SQLite's signed 64-bit INTEGER.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MIGRATIONS: readonly (readonly [version: number, sql: string])[];
|
|
15
|
+
export declare const SCHEMA_VERSION: number;
|
|
16
|
+
/** The schema version of an open database (0 for a fresh one). */
|
|
17
|
+
export declare function schemaVersion(db: Database.Database): number;
|
|
18
|
+
/** Brings the database to SCHEMA_VERSION, one migration per transaction. */
|
|
19
|
+
export declare function migrate(db: Database.Database): number;
|
|
20
|
+
//# sourceMappingURL=schema.d.ts.map
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { LfcpError } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* The SQLite schema and its migrations. Version 1 is the LFCP-034 storage
|
|
4
|
+
* model; version 2 adds outbound retry state and sync state (LFCP-036);
|
|
5
|
+
* version 3 adds local marks (the crash-loop breaker's apply markers).
|
|
6
|
+
* A database at a newer version than this code knows is refused:
|
|
7
|
+
* it is never downgraded or "repaired".
|
|
8
|
+
*
|
|
9
|
+
* Conventions: IDs and exact object bytes are BLOBs (memcmp order is byte
|
|
10
|
+
* order); uint64 values (sequences, epochs, route versions) are 20-digit
|
|
11
|
+
* zero-padded decimal TEXT, so they sort numerically and never overflow
|
|
12
|
+
* SQLite's signed 64-bit INTEGER.
|
|
13
|
+
*/
|
|
14
|
+
export const MIGRATIONS = [
|
|
15
|
+
[
|
|
16
|
+
1,
|
|
17
|
+
`
|
|
18
|
+
CREATE TABLE control_records (
|
|
19
|
+
record_id BLOB PRIMARY KEY,
|
|
20
|
+
resource_id BLOB NOT NULL,
|
|
21
|
+
control_seq TEXT NOT NULL,
|
|
22
|
+
prev_id BLOB,
|
|
23
|
+
bytes BLOB NOT NULL
|
|
24
|
+
) WITHOUT ROWID;
|
|
25
|
+
CREATE INDEX control_records_by_resource ON control_records (resource_id, control_seq, record_id);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE control_heads (
|
|
28
|
+
resource_id BLOB PRIMARY KEY,
|
|
29
|
+
head BLOB NOT NULL,
|
|
30
|
+
control_seq TEXT NOT NULL
|
|
31
|
+
) WITHOUT ROWID;
|
|
32
|
+
|
|
33
|
+
CREATE TABLE control_conflicts (
|
|
34
|
+
resource_id BLOB PRIMARY KEY,
|
|
35
|
+
heads BLOB NOT NULL
|
|
36
|
+
) WITHOUT ROWID;
|
|
37
|
+
|
|
38
|
+
CREATE TABLE epochs (
|
|
39
|
+
resource_id BLOB NOT NULL,
|
|
40
|
+
epoch TEXT NOT NULL,
|
|
41
|
+
dek_commitment BLOB NOT NULL,
|
|
42
|
+
opened_by BLOB NOT NULL,
|
|
43
|
+
closed_by BLOB,
|
|
44
|
+
dek_ref TEXT,
|
|
45
|
+
PRIMARY KEY (resource_id, epoch)
|
|
46
|
+
) WITHOUT ROWID;
|
|
47
|
+
|
|
48
|
+
CREATE TABLE data_units (
|
|
49
|
+
unit_id BLOB PRIMARY KEY,
|
|
50
|
+
resource_id BLOB NOT NULL,
|
|
51
|
+
data_epoch TEXT NOT NULL,
|
|
52
|
+
actor BLOB NOT NULL,
|
|
53
|
+
actor_seq TEXT NOT NULL,
|
|
54
|
+
prev_id BLOB,
|
|
55
|
+
control_head BLOB NOT NULL,
|
|
56
|
+
bytes BLOB NOT NULL,
|
|
57
|
+
status TEXT NOT NULL,
|
|
58
|
+
detail TEXT,
|
|
59
|
+
accepted INTEGER NOT NULL
|
|
60
|
+
) WITHOUT ROWID;
|
|
61
|
+
CREATE INDEX data_units_by_tuple ON data_units (resource_id, actor, actor_seq, unit_id);
|
|
62
|
+
CREATE INDEX data_units_by_status ON data_units (resource_id, status);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE key_packages (
|
|
65
|
+
package_id BLOB PRIMARY KEY,
|
|
66
|
+
resource_id BLOB NOT NULL,
|
|
67
|
+
data_epoch TEXT NOT NULL,
|
|
68
|
+
recipient BLOB NOT NULL,
|
|
69
|
+
sender BLOB NOT NULL,
|
|
70
|
+
bytes BLOB NOT NULL
|
|
71
|
+
) WITHOUT ROWID;
|
|
72
|
+
CREATE INDEX key_packages_by_recipient ON key_packages (resource_id, data_epoch, recipient);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE snapshots (
|
|
75
|
+
snapshot_id BLOB PRIMARY KEY,
|
|
76
|
+
resource_id BLOB NOT NULL,
|
|
77
|
+
data_epoch TEXT NOT NULL,
|
|
78
|
+
publisher BLOB NOT NULL,
|
|
79
|
+
snapshot_seq TEXT NOT NULL,
|
|
80
|
+
frontier BLOB NOT NULL,
|
|
81
|
+
bytes BLOB NOT NULL
|
|
82
|
+
) WITHOUT ROWID;
|
|
83
|
+
CREATE INDEX snapshots_by_epoch ON snapshots (resource_id, data_epoch, publisher, snapshot_seq);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE resources (
|
|
86
|
+
resource_id BLOB PRIMARY KEY,
|
|
87
|
+
data_profile TEXT NOT NULL,
|
|
88
|
+
local_principal BLOB,
|
|
89
|
+
signing_ref TEXT,
|
|
90
|
+
agreement_ref TEXT,
|
|
91
|
+
labels TEXT NOT NULL
|
|
92
|
+
) WITHOUT ROWID;
|
|
93
|
+
|
|
94
|
+
CREATE TABLE routes (
|
|
95
|
+
resource_id BLOB PRIMARY KEY,
|
|
96
|
+
route_version TEXT NOT NULL,
|
|
97
|
+
endpoints TEXT NOT NULL,
|
|
98
|
+
coordinator_url TEXT NOT NULL,
|
|
99
|
+
source BLOB NOT NULL
|
|
100
|
+
) WITHOUT ROWID;
|
|
101
|
+
|
|
102
|
+
CREATE TABLE outbound (
|
|
103
|
+
position INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
item_id BLOB NOT NULL UNIQUE,
|
|
105
|
+
resource_id BLOB NOT NULL,
|
|
106
|
+
kind TEXT NOT NULL,
|
|
107
|
+
bytes BLOB NOT NULL,
|
|
108
|
+
attempts INTEGER NOT NULL,
|
|
109
|
+
last_attempt TEXT
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE profile_checkpoints (
|
|
113
|
+
resource_id BLOB PRIMARY KEY,
|
|
114
|
+
data_profile TEXT NOT NULL,
|
|
115
|
+
state BLOB NOT NULL,
|
|
116
|
+
actor_seq INTEGER NOT NULL,
|
|
117
|
+
units TEXT NOT NULL
|
|
118
|
+
) WITHOUT ROWID;
|
|
119
|
+
|
|
120
|
+
CREATE TABLE actor_sequences (
|
|
121
|
+
resource_id BLOB NOT NULL,
|
|
122
|
+
principal BLOB NOT NULL,
|
|
123
|
+
last TEXT NOT NULL,
|
|
124
|
+
PRIMARY KEY (resource_id, principal)
|
|
125
|
+
) WITHOUT ROWID;
|
|
126
|
+
|
|
127
|
+
CREATE TABLE snapshot_sequences (
|
|
128
|
+
resource_id BLOB NOT NULL,
|
|
129
|
+
epoch TEXT NOT NULL,
|
|
130
|
+
publisher BLOB NOT NULL,
|
|
131
|
+
last TEXT NOT NULL,
|
|
132
|
+
PRIMARY KEY (resource_id, epoch, publisher)
|
|
133
|
+
) WITHOUT ROWID;
|
|
134
|
+
`,
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
// LFCP-036: retry scheduling and blocking of outbound items; per-Resource sync state.
|
|
138
|
+
2,
|
|
139
|
+
`
|
|
140
|
+
ALTER TABLE outbound ADD COLUMN next_attempt TEXT;
|
|
141
|
+
ALTER TABLE outbound ADD COLUMN blocked_reason TEXT;
|
|
142
|
+
ALTER TABLE outbound ADD COLUMN blocked_detail TEXT;
|
|
143
|
+
|
|
144
|
+
CREATE TABLE sync_state (
|
|
145
|
+
resource_id BLOB PRIMARY KEY,
|
|
146
|
+
recently_acked BLOB NOT NULL,
|
|
147
|
+
acked_durability TEXT
|
|
148
|
+
) WITHOUT ROWID;
|
|
149
|
+
`,
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
// Local marks: small device-local processing records (crash-loop breaker).
|
|
153
|
+
3,
|
|
154
|
+
`
|
|
155
|
+
CREATE TABLE local_marks (
|
|
156
|
+
key TEXT PRIMARY KEY,
|
|
157
|
+
value TEXT NOT NULL
|
|
158
|
+
) WITHOUT ROWID;
|
|
159
|
+
`,
|
|
160
|
+
],
|
|
161
|
+
];
|
|
162
|
+
export const SCHEMA_VERSION = MIGRATIONS.at(-1)?.[0] ?? 0;
|
|
163
|
+
/** The schema version of an open database (0 for a fresh one). */
|
|
164
|
+
export function schemaVersion(db) {
|
|
165
|
+
db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)");
|
|
166
|
+
const row = db.prepare("SELECT version FROM schema_version").get();
|
|
167
|
+
return row?.version ?? 0;
|
|
168
|
+
}
|
|
169
|
+
/** Brings the database to SCHEMA_VERSION, one migration per transaction. */
|
|
170
|
+
export function migrate(db) {
|
|
171
|
+
const current = schemaVersion(db);
|
|
172
|
+
if (current > SCHEMA_VERSION)
|
|
173
|
+
throw new LfcpError("UNSUPPORTED_VALUE", `the database is at schema version ${current}, newer than this code (${SCHEMA_VERSION})`);
|
|
174
|
+
for (const [version, sql] of MIGRATIONS) {
|
|
175
|
+
if (version <= current)
|
|
176
|
+
continue;
|
|
177
|
+
db.transaction(() => {
|
|
178
|
+
db.exec(sql);
|
|
179
|
+
db.exec("DELETE FROM schema_version");
|
|
180
|
+
db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(version);
|
|
181
|
+
}).immediate();
|
|
182
|
+
}
|
|
183
|
+
return SCHEMA_VERSION;
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type SecretRef, type SecretStore } from "@openlfcp/storage";
|
|
2
|
+
/**
|
|
3
|
+
* A SecretStore in a local directory (LFCP-035), for headless Node, the
|
|
4
|
+
* CLI, examples and tests.
|
|
5
|
+
*
|
|
6
|
+
* MVP LIMITATION: secrets are stored IN PLAINTEXT, protected only by file
|
|
7
|
+
* permissions: the directory is 0700 and every file 0600, owned by the
|
|
8
|
+
* running user. Anyone who can read the files as that user (or as root, or
|
|
9
|
+
* from a backup) has the keys. OS keychain integration or passphrase
|
|
10
|
+
* encryption is a follow-up.
|
|
11
|
+
*
|
|
12
|
+
* One file per SecretRef, named by the hex of the reference (references
|
|
13
|
+
* are public names, never secret material). A write goes to a temporary
|
|
14
|
+
* file that is fsynced, renamed over the target and followed by an fsync
|
|
15
|
+
* of the directory, so a crash leaves the old value or the new one, never
|
|
16
|
+
* a torn file. There is no list operation, and the store renders as
|
|
17
|
+
* "[FileSecretStore]" so values cannot leak into logs.
|
|
18
|
+
*/
|
|
19
|
+
declare const INSPECT: unique symbol;
|
|
20
|
+
export declare class FileSecretStore implements SecretStore {
|
|
21
|
+
#private;
|
|
22
|
+
/** Creates the directory (0700) if needed and tightens its mode. */
|
|
23
|
+
constructor(dir: string);
|
|
24
|
+
put(ref: SecretRef, value: Uint8Array): Promise<void>;
|
|
25
|
+
get(ref: SecretRef): Promise<Uint8Array | undefined>;
|
|
26
|
+
delete(ref: SecretRef): Promise<void>;
|
|
27
|
+
toJSON(): string;
|
|
28
|
+
toString(): string;
|
|
29
|
+
[INSPECT](): string;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { LfcpError } from "@openlfcp/core";
|
|
4
|
+
import { isSecretRef } from "@openlfcp/storage";
|
|
5
|
+
/**
|
|
6
|
+
* A SecretStore in a local directory (LFCP-035), for headless Node, the
|
|
7
|
+
* CLI, examples and tests.
|
|
8
|
+
*
|
|
9
|
+
* MVP LIMITATION: secrets are stored IN PLAINTEXT, protected only by file
|
|
10
|
+
* permissions: the directory is 0700 and every file 0600, owned by the
|
|
11
|
+
* running user. Anyone who can read the files as that user (or as root, or
|
|
12
|
+
* from a backup) has the keys. OS keychain integration or passphrase
|
|
13
|
+
* encryption is a follow-up.
|
|
14
|
+
*
|
|
15
|
+
* One file per SecretRef, named by the hex of the reference (references
|
|
16
|
+
* are public names, never secret material). A write goes to a temporary
|
|
17
|
+
* file that is fsynced, renamed over the target and followed by an fsync
|
|
18
|
+
* of the directory, so a crash leaves the old value or the new one, never
|
|
19
|
+
* a torn file. There is no list operation, and the store renders as
|
|
20
|
+
* "[FileSecretStore]" so values cannot leak into logs.
|
|
21
|
+
*/
|
|
22
|
+
const INSPECT = Symbol.for("nodejs.util.inspect.custom");
|
|
23
|
+
const fileName = (ref) => `${Buffer.from(ref, "utf8").toString("hex")}.secret`;
|
|
24
|
+
export class FileSecretStore {
|
|
25
|
+
#dir;
|
|
26
|
+
#counter = 0;
|
|
27
|
+
/** Creates the directory (0700) if needed and tightens its mode. */
|
|
28
|
+
constructor(dir) {
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
chmodSync(dir, 0o700);
|
|
31
|
+
if (!statSync(dir).isDirectory())
|
|
32
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "the secret store location is not a directory");
|
|
33
|
+
this.#dir = dir;
|
|
34
|
+
}
|
|
35
|
+
#check(ref) {
|
|
36
|
+
if (!isSecretRef(ref))
|
|
37
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "not a secret reference");
|
|
38
|
+
}
|
|
39
|
+
#syncDir() {
|
|
40
|
+
const fd = openSync(this.#dir, "r");
|
|
41
|
+
try {
|
|
42
|
+
fsyncSync(fd);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
put(ref, value) {
|
|
49
|
+
try {
|
|
50
|
+
this.#check(ref);
|
|
51
|
+
const target = join(this.#dir, fileName(ref));
|
|
52
|
+
this.#counter += 1;
|
|
53
|
+
const temp = join(this.#dir, `.tmp-${process.pid}-${this.#counter}-${fileName(ref)}`);
|
|
54
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
55
|
+
try {
|
|
56
|
+
writeSync(fd, value);
|
|
57
|
+
fsyncSync(fd);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
rmSync(temp, { force: true });
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
renameSync(temp, target);
|
|
66
|
+
this.#syncDir();
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return Promise.reject(e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
get(ref) {
|
|
74
|
+
try {
|
|
75
|
+
this.#check(ref);
|
|
76
|
+
return Promise.resolve(Uint8Array.from(readFileSync(join(this.#dir, fileName(ref)))));
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e.code === "ENOENT")
|
|
80
|
+
return Promise.resolve(undefined);
|
|
81
|
+
return Promise.reject(e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
delete(ref) {
|
|
85
|
+
try {
|
|
86
|
+
this.#check(ref);
|
|
87
|
+
unlinkSync(join(this.#dir, fileName(ref)));
|
|
88
|
+
this.#syncDir();
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e.code !== "ENOENT")
|
|
92
|
+
return Promise.reject(e);
|
|
93
|
+
}
|
|
94
|
+
return Promise.resolve();
|
|
95
|
+
}
|
|
96
|
+
toJSON() {
|
|
97
|
+
return "[FileSecretStore]";
|
|
98
|
+
}
|
|
99
|
+
toString() {
|
|
100
|
+
return "[FileSecretStore]";
|
|
101
|
+
}
|
|
102
|
+
[INSPECT]() {
|
|
103
|
+
return "[FileSecretStore]";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=secrets.js.map
|
package/dist/sqlite.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ActorSequenceReservation, type CommitResult, type ControlReader, type DataUnitReader, type KeyPackageReader, type LfcpStorage, type LocalMarkReader, type OutboundReader, type ProfileStateReader, type ResourceReader, type SnapshotReader, type SnapshotSequenceReservation, type StorageWrite, type SyncStateReader } from "@openlfcp/storage";
|
|
2
|
+
export interface SqliteStorageOptions {
|
|
3
|
+
/** Milliseconds to wait for another process's write lock (default 5000). */
|
|
4
|
+
readonly busyTimeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class SqliteLfcpStorage implements LfcpStorage {
|
|
7
|
+
#private;
|
|
8
|
+
/** The schema version after migration. */
|
|
9
|
+
readonly schemaVersion: number;
|
|
10
|
+
private constructor();
|
|
11
|
+
/**
|
|
12
|
+
* Opens (creating if needed) the database at `path`, migrates it to the
|
|
13
|
+
* current schema and sets the durability pragmas.
|
|
14
|
+
*/
|
|
15
|
+
static open(path: string, options?: SqliteStorageOptions): SqliteLfcpStorage;
|
|
16
|
+
close(): void;
|
|
17
|
+
commit(writes: readonly StorageWrite[]): Promise<CommitResult>;
|
|
18
|
+
readonly control: ControlReader;
|
|
19
|
+
readonly dataUnits: DataUnitReader;
|
|
20
|
+
readonly keyPackages: KeyPackageReader;
|
|
21
|
+
readonly snapshots: SnapshotReader;
|
|
22
|
+
readonly resources: ResourceReader;
|
|
23
|
+
readonly outbound: OutboundReader;
|
|
24
|
+
readonly profileState: ProfileStateReader;
|
|
25
|
+
readonly syncState: SyncStateReader;
|
|
26
|
+
readonly localMarks: LocalMarkReader;
|
|
27
|
+
/** Durable before it resolves: the new value is committed (WAL, synchronous=FULL) first. */
|
|
28
|
+
readonly actorSequences: ActorSequenceReservation;
|
|
29
|
+
readonly snapshotSequences: SnapshotSequenceReservation;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=sqlite.d.ts.map
|
package/dist/sqlite.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { actorSequence, bytesEqual, dataEpoch, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
|
|
2
|
+
import { nextActorSequence, } from "@openlfcp/storage";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { migrate } from "./schema.js";
|
|
5
|
+
/** uint64 as 20-digit zero-padded TEXT (sorts numerically, never overflows). */
|
|
6
|
+
const u64 = (n) => n.toString().padStart(20, "0");
|
|
7
|
+
const fromU64 = (s) => BigInt(s);
|
|
8
|
+
const blob = (b) => Buffer.from(b);
|
|
9
|
+
/** A copy as a plain Uint8Array: nothing returned aliases SQLite's buffers. */
|
|
10
|
+
const bytes = (v) => Uint8Array.from(v);
|
|
11
|
+
const as = (v) => bytes(v);
|
|
12
|
+
const maybeAs = (v) => (v === null ? null : as(v));
|
|
13
|
+
const refused = (what, id) => {
|
|
14
|
+
throw new LfcpError("INVALID_STRUCTURE", `${what} ${toHex(id)} is stored with other bytes`);
|
|
15
|
+
};
|
|
16
|
+
const IDS = 32;
|
|
17
|
+
const joinIds = (ids) => Buffer.concat(ids.map((i) => Buffer.from(i)));
|
|
18
|
+
const splitIds = (v) => {
|
|
19
|
+
const all = bytes(v);
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i < all.length; i += IDS)
|
|
22
|
+
out.push(all.slice(i, i + IDS));
|
|
23
|
+
return out;
|
|
24
|
+
};
|
|
25
|
+
const controlRecord = (r) => ({
|
|
26
|
+
recordId: as(r.record_id),
|
|
27
|
+
resourceId: as(r.resource_id),
|
|
28
|
+
controlSeq: fromU64(r.control_seq),
|
|
29
|
+
prevControlId: maybeAs(r.prev_id),
|
|
30
|
+
bytes: bytes(r.bytes),
|
|
31
|
+
});
|
|
32
|
+
const epochRow = (r) => ({
|
|
33
|
+
epoch: dataEpoch(fromU64(r.epoch)),
|
|
34
|
+
dekCommitment: as(r.dek_commitment),
|
|
35
|
+
openedBy: as(r.opened_by),
|
|
36
|
+
closedBy: maybeAs(r.closed_by),
|
|
37
|
+
dekRef: r.dek_ref ?? null,
|
|
38
|
+
});
|
|
39
|
+
const storedUnit = (r) => ({
|
|
40
|
+
unitId: as(r.unit_id),
|
|
41
|
+
resourceId: as(r.resource_id),
|
|
42
|
+
dataEpoch: dataEpoch(fromU64(r.data_epoch)),
|
|
43
|
+
actor: as(r.actor),
|
|
44
|
+
actorSeq: actorSequence(fromU64(r.actor_seq)),
|
|
45
|
+
prevDataUnitId: maybeAs(r.prev_id),
|
|
46
|
+
controlHead: as(r.control_head),
|
|
47
|
+
bytes: bytes(r.bytes),
|
|
48
|
+
status: r.status,
|
|
49
|
+
detail: r.detail ?? null,
|
|
50
|
+
accepted: r.accepted === 1,
|
|
51
|
+
});
|
|
52
|
+
const keyPackage = (r) => ({
|
|
53
|
+
packageId: as(r.package_id),
|
|
54
|
+
resourceId: as(r.resource_id),
|
|
55
|
+
dataEpoch: dataEpoch(fromU64(r.data_epoch)),
|
|
56
|
+
recipient: as(r.recipient),
|
|
57
|
+
sender: as(r.sender),
|
|
58
|
+
bytes: bytes(r.bytes),
|
|
59
|
+
});
|
|
60
|
+
const snapshot = (r) => ({
|
|
61
|
+
snapshotId: as(r.snapshot_id),
|
|
62
|
+
resourceId: as(r.resource_id),
|
|
63
|
+
dataEpoch: dataEpoch(fromU64(r.data_epoch)),
|
|
64
|
+
publisher: as(r.publisher),
|
|
65
|
+
snapshotSeq: fromU64(r.snapshot_seq),
|
|
66
|
+
frontier: bytes(r.frontier),
|
|
67
|
+
bytes: bytes(r.bytes),
|
|
68
|
+
});
|
|
69
|
+
const resource = (r) => ({
|
|
70
|
+
resourceId: as(r.resource_id),
|
|
71
|
+
dataProfile: r.data_profile,
|
|
72
|
+
localPrincipal: r.local_principal === null
|
|
73
|
+
? null
|
|
74
|
+
: {
|
|
75
|
+
principalId: as(r.local_principal),
|
|
76
|
+
signingKeyRef: r.signing_ref,
|
|
77
|
+
agreementKeyRef: r.agreement_ref,
|
|
78
|
+
},
|
|
79
|
+
labels: JSON.parse(r.labels),
|
|
80
|
+
});
|
|
81
|
+
const route = (r) => ({
|
|
82
|
+
routeVersion: fromU64(r.route_version),
|
|
83
|
+
endpoints: JSON.parse(r.endpoints).map((e) => ({
|
|
84
|
+
url: e.url,
|
|
85
|
+
priority: BigInt(e.priority),
|
|
86
|
+
...(e.flags === undefined ? {} : { flags: BigInt(e.flags) }),
|
|
87
|
+
})),
|
|
88
|
+
coordinatorUrl: r.coordinator_url,
|
|
89
|
+
source: as(r.source),
|
|
90
|
+
});
|
|
91
|
+
const outboundItem = (r) => ({
|
|
92
|
+
itemId: as(r.item_id),
|
|
93
|
+
resourceId: as(r.resource_id),
|
|
94
|
+
kind: r.kind,
|
|
95
|
+
bytes: bytes(r.bytes),
|
|
96
|
+
attempts: r.attempts,
|
|
97
|
+
lastAttempt: r.last_attempt ?? null,
|
|
98
|
+
nextAttempt: r.next_attempt ?? null,
|
|
99
|
+
blocked: r.blocked_reason === null
|
|
100
|
+
? null
|
|
101
|
+
: {
|
|
102
|
+
reason: r.blocked_reason,
|
|
103
|
+
detail: r.blocked_detail ?? null,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const syncStateRow = (r) => ({
|
|
107
|
+
resourceId: as(r.resource_id),
|
|
108
|
+
recentlyAcked: splitIds(r.recently_acked),
|
|
109
|
+
ackedDurability: r.acked_durability === null ? null : BigInt(r.acked_durability),
|
|
110
|
+
});
|
|
111
|
+
const checkpointRow = (r) => ({
|
|
112
|
+
resourceId: as(r.resource_id),
|
|
113
|
+
dataProfile: r.data_profile,
|
|
114
|
+
state: bytes(r.state),
|
|
115
|
+
actorSeq: r.actor_seq,
|
|
116
|
+
units: JSON.parse(r.units).map(([unitId, ref]) => ({
|
|
117
|
+
unitId: Uint8Array.from(Buffer.from(unitId, "hex")),
|
|
118
|
+
ref,
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
export class SqliteLfcpStorage {
|
|
122
|
+
#db;
|
|
123
|
+
/** The schema version after migration. */
|
|
124
|
+
schemaVersion;
|
|
125
|
+
constructor(db, version) {
|
|
126
|
+
this.#db = db;
|
|
127
|
+
this.schemaVersion = version;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Opens (creating if needed) the database at `path`, migrates it to the
|
|
131
|
+
* current schema and sets the durability pragmas.
|
|
132
|
+
*/
|
|
133
|
+
static open(path, options = {}) {
|
|
134
|
+
const db = new Database(path);
|
|
135
|
+
try {
|
|
136
|
+
db.pragma("journal_mode = WAL");
|
|
137
|
+
db.pragma("synchronous = FULL");
|
|
138
|
+
db.pragma(`busy_timeout = ${Math.trunc(options.busyTimeoutMs ?? 5000)}`);
|
|
139
|
+
const version = migrate(db);
|
|
140
|
+
return new SqliteLfcpStorage(db, version);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
db.close();
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
close() {
|
|
148
|
+
if (this.#db.open)
|
|
149
|
+
this.#db.close();
|
|
150
|
+
}
|
|
151
|
+
#all(sql, ...params) {
|
|
152
|
+
return this.#db.prepare(sql).all(...params);
|
|
153
|
+
}
|
|
154
|
+
#get(sql, ...params) {
|
|
155
|
+
return this.#db.prepare(sql).get(...params);
|
|
156
|
+
}
|
|
157
|
+
/** Runs `fn` synchronously in one BEGIN IMMEDIATE transaction and settles with its result. */
|
|
158
|
+
#tx(fn) {
|
|
159
|
+
try {
|
|
160
|
+
return Promise.resolve(this.#db.transaction(fn).immediate());
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return Promise.reject(e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
#read(fn) {
|
|
167
|
+
try {
|
|
168
|
+
return Promise.resolve(fn());
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return Promise.reject(e);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
commit(writes) {
|
|
175
|
+
return this.#tx(() => {
|
|
176
|
+
for (const w of writes) {
|
|
177
|
+
if (w.op !== "set-control-head")
|
|
178
|
+
continue;
|
|
179
|
+
const row = this.#get("SELECT head FROM control_heads WHERE resource_id = ?", blob(w.resourceId));
|
|
180
|
+
const current = row === undefined ? null : bytes(row.head);
|
|
181
|
+
const matches = current === null
|
|
182
|
+
? w.expected === null
|
|
183
|
+
: w.expected !== null && bytesEqual(current, w.expected);
|
|
184
|
+
if (!matches)
|
|
185
|
+
return Object.freeze({
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: "CONTROL_HEAD_MISMATCH",
|
|
188
|
+
resourceId: Uint8Array.from(w.resourceId),
|
|
189
|
+
current: current,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
for (const w of writes) {
|
|
193
|
+
if (w.op !== "expect-previous-unit")
|
|
194
|
+
continue;
|
|
195
|
+
const row = this.#get("SELECT unit_id FROM data_units WHERE resource_id = ? AND actor = ? AND accepted = 1 ORDER BY actor_seq DESC LIMIT 1", blob(w.resourceId), blob(w.actor));
|
|
196
|
+
const current = row === undefined ? null : bytes(row.unit_id);
|
|
197
|
+
const matches = current === null
|
|
198
|
+
? w.previous === null
|
|
199
|
+
: w.previous !== null && bytesEqual(current, w.previous);
|
|
200
|
+
if (!matches)
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: "PREVIOUS_UNIT_MISMATCH",
|
|
204
|
+
resourceId: Uint8Array.from(w.resourceId),
|
|
205
|
+
actor: Uint8Array.from(w.actor),
|
|
206
|
+
current: current,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
for (const w of writes)
|
|
210
|
+
this.#apply(w);
|
|
211
|
+
return Object.freeze({ ok: true });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/** Inserts an immutable object; the same ID with other bytes throws (rolling the batch back). */
|
|
215
|
+
#immutable(table, idColumn, id, row, what) {
|
|
216
|
+
const columns = Object.keys(row);
|
|
217
|
+
const result = this.#db
|
|
218
|
+
.prepare(`INSERT INTO ${table} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")}) ON CONFLICT (${idColumn}) DO NOTHING`)
|
|
219
|
+
.run(...Object.values(row));
|
|
220
|
+
if (result.changes === 0) {
|
|
221
|
+
const old = this.#get(`SELECT bytes FROM ${table} WHERE ${idColumn} = ?`, blob(id));
|
|
222
|
+
if (old === undefined || !bytesEqual(bytes(old.bytes), row.bytes))
|
|
223
|
+
refused(what, id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
#apply(w) {
|
|
227
|
+
const db = this.#db;
|
|
228
|
+
switch (w.op) {
|
|
229
|
+
case "put-control-records":
|
|
230
|
+
for (const r of w.records)
|
|
231
|
+
this.#immutable("control_records", "record_id", r.recordId, {
|
|
232
|
+
record_id: blob(r.recordId),
|
|
233
|
+
resource_id: blob(r.resourceId),
|
|
234
|
+
control_seq: u64(r.controlSeq),
|
|
235
|
+
prev_id: r.prevControlId === null ? null : blob(r.prevControlId),
|
|
236
|
+
bytes: blob(r.bytes),
|
|
237
|
+
}, "Control Record");
|
|
238
|
+
return;
|
|
239
|
+
case "set-control-head":
|
|
240
|
+
db.prepare("INSERT INTO control_heads (resource_id, head, control_seq) VALUES (?, ?, ?) ON CONFLICT (resource_id) DO UPDATE SET head = excluded.head, control_seq = excluded.control_seq").run(blob(w.resourceId), blob(w.head.head), u64(w.head.controlSeq));
|
|
241
|
+
return;
|
|
242
|
+
case "set-control-conflict":
|
|
243
|
+
if (w.conflict === null)
|
|
244
|
+
db.prepare("DELETE FROM control_conflicts WHERE resource_id = ?").run(blob(w.resourceId));
|
|
245
|
+
else
|
|
246
|
+
db.prepare("INSERT INTO control_conflicts (resource_id, heads) VALUES (?, ?) ON CONFLICT (resource_id) DO UPDATE SET heads = excluded.heads").run(blob(w.resourceId), joinIds(w.conflict.heads));
|
|
247
|
+
return;
|
|
248
|
+
case "expect-previous-unit":
|
|
249
|
+
return; // a precondition, checked by commit()
|
|
250
|
+
case "put-epoch": {
|
|
251
|
+
const e = w.epoch;
|
|
252
|
+
db.prepare(`INSERT INTO epochs (resource_id, epoch, dek_commitment, opened_by, closed_by, dek_ref) VALUES (?, ?, ?, ?, ?, ?)
|
|
253
|
+
ON CONFLICT (resource_id, epoch) DO UPDATE SET dek_commitment = excluded.dek_commitment, opened_by = excluded.opened_by, closed_by = COALESCE(excluded.closed_by, epochs.closed_by), dek_ref = COALESCE(excluded.dek_ref, epochs.dek_ref)`).run(blob(w.resourceId), u64(BigInt(e.epoch)), blob(e.dekCommitment), blob(e.openedBy), e.closedBy === null ? null : blob(e.closedBy), e.dekRef);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case "put-data-unit": {
|
|
257
|
+
const u = w.unit;
|
|
258
|
+
const old = this.#get("SELECT bytes, accepted FROM data_units WHERE unit_id = ?", blob(u.unitId));
|
|
259
|
+
if (old === undefined) {
|
|
260
|
+
this.#insertUnit(u, w.status, w.detail ?? null, w.accepted ?? false);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
if (!bytesEqual(bytes(old.bytes), u.bytes))
|
|
264
|
+
refused("Data Unit", u.unitId);
|
|
265
|
+
const accepted = w.accepted ?? old.accepted === 1;
|
|
266
|
+
db.prepare("UPDATE data_units SET status = ?, detail = ?, accepted = ? WHERE unit_id = ?").run(w.status, w.detail ?? null, accepted ? 1 : 0, blob(u.unitId));
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case "set-data-unit-status":
|
|
271
|
+
this.#mustChange(db
|
|
272
|
+
.prepare("UPDATE data_units SET status = ?, detail = ? WHERE unit_id = ?")
|
|
273
|
+
.run(w.status, w.detail ?? null, blob(w.unitId)).changes, w.unitId);
|
|
274
|
+
return;
|
|
275
|
+
case "set-accepted":
|
|
276
|
+
this.#mustChange(db
|
|
277
|
+
.prepare("UPDATE data_units SET accepted = ? WHERE unit_id = ?")
|
|
278
|
+
.run(w.accepted ? 1 : 0, blob(w.unitId)).changes, w.unitId);
|
|
279
|
+
return;
|
|
280
|
+
case "put-key-package": {
|
|
281
|
+
const k = w.row;
|
|
282
|
+
this.#immutable("key_packages", "package_id", k.packageId, {
|
|
283
|
+
package_id: blob(k.packageId),
|
|
284
|
+
resource_id: blob(k.resourceId),
|
|
285
|
+
data_epoch: u64(BigInt(k.dataEpoch)),
|
|
286
|
+
recipient: blob(k.recipient),
|
|
287
|
+
sender: blob(k.sender),
|
|
288
|
+
bytes: blob(k.bytes),
|
|
289
|
+
}, "Key Package");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case "delete-snapshot":
|
|
293
|
+
db.prepare("DELETE FROM snapshots WHERE snapshot_id = ?").run(blob(w.snapshotId));
|
|
294
|
+
return;
|
|
295
|
+
case "put-snapshot": {
|
|
296
|
+
const s = w.row;
|
|
297
|
+
this.#immutable("snapshots", "snapshot_id", s.snapshotId, {
|
|
298
|
+
snapshot_id: blob(s.snapshotId),
|
|
299
|
+
resource_id: blob(s.resourceId),
|
|
300
|
+
data_epoch: u64(BigInt(s.dataEpoch)),
|
|
301
|
+
publisher: blob(s.publisher),
|
|
302
|
+
snapshot_seq: u64(s.snapshotSeq),
|
|
303
|
+
frontier: blob(s.frontier),
|
|
304
|
+
bytes: blob(s.bytes),
|
|
305
|
+
}, "Snapshot");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case "put-resource": {
|
|
309
|
+
const r = w.row;
|
|
310
|
+
db.prepare(`INSERT INTO resources (resource_id, data_profile, local_principal, signing_ref, agreement_ref, labels) VALUES (?, ?, ?, ?, ?, ?)
|
|
311
|
+
ON CONFLICT (resource_id) DO UPDATE SET data_profile = excluded.data_profile, local_principal = excluded.local_principal, signing_ref = excluded.signing_ref, agreement_ref = excluded.agreement_ref, labels = excluded.labels`).run(blob(r.resourceId), r.dataProfile, r.localPrincipal === null ? null : blob(r.localPrincipal.principalId), r.localPrincipal?.signingKeyRef ?? null, r.localPrincipal?.agreementKeyRef ?? null, JSON.stringify(r.labels));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
case "put-route": {
|
|
315
|
+
const r = w.route;
|
|
316
|
+
const endpoints = r.endpoints.map((e) => ({
|
|
317
|
+
url: e.url,
|
|
318
|
+
priority: e.priority.toString(),
|
|
319
|
+
...(e.flags === undefined ? {} : { flags: e.flags.toString() }),
|
|
320
|
+
}));
|
|
321
|
+
db.prepare(`INSERT INTO routes (resource_id, route_version, endpoints, coordinator_url, source) VALUES (?, ?, ?, ?, ?)
|
|
322
|
+
ON CONFLICT (resource_id) DO UPDATE SET route_version = excluded.route_version, endpoints = excluded.endpoints, coordinator_url = excluded.coordinator_url, source = excluded.source`).run(blob(w.resourceId), u64(r.routeVersion), JSON.stringify(endpoints), r.coordinatorUrl, blob(r.source));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
case "enqueue": {
|
|
326
|
+
const o = w.item;
|
|
327
|
+
const old = this.#get("SELECT bytes FROM outbound WHERE item_id = ?", blob(o.itemId));
|
|
328
|
+
if (old !== undefined) {
|
|
329
|
+
if (!bytesEqual(bytes(old.bytes), o.bytes))
|
|
330
|
+
refused("outbound item", o.itemId);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
db.prepare(`INSERT INTO outbound (item_id, resource_id, kind, bytes, attempts, last_attempt, next_attempt, blocked_reason, blocked_detail)
|
|
334
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(blob(o.itemId), blob(o.resourceId), o.kind, blob(o.bytes), o.attempts, o.lastAttempt, o.nextAttempt, o.blocked?.reason ?? null, o.blocked?.detail ?? null);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
case "update-outbound": {
|
|
338
|
+
const sets = [];
|
|
339
|
+
const params = [];
|
|
340
|
+
if (w.attempts !== undefined) {
|
|
341
|
+
sets.push("attempts = ?");
|
|
342
|
+
params.push(w.attempts);
|
|
343
|
+
}
|
|
344
|
+
if (w.lastAttempt !== undefined) {
|
|
345
|
+
sets.push("last_attempt = ?");
|
|
346
|
+
params.push(w.lastAttempt);
|
|
347
|
+
}
|
|
348
|
+
if (w.nextAttempt !== undefined) {
|
|
349
|
+
sets.push("next_attempt = ?");
|
|
350
|
+
params.push(w.nextAttempt);
|
|
351
|
+
}
|
|
352
|
+
if (w.blocked !== undefined) {
|
|
353
|
+
sets.push("blocked_reason = ?", "blocked_detail = ?");
|
|
354
|
+
params.push(w.blocked?.reason ?? null, w.blocked?.detail ?? null);
|
|
355
|
+
}
|
|
356
|
+
const exists = this.#get("SELECT 1 AS x FROM outbound WHERE item_id = ?", blob(w.itemId));
|
|
357
|
+
if (exists === undefined)
|
|
358
|
+
throw new LfcpError("INVALID_STRUCTURE", `no outbound item ${toHex(w.itemId)}`);
|
|
359
|
+
if (sets.length > 0)
|
|
360
|
+
db.prepare(`UPDATE outbound SET ${sets.join(", ")} WHERE item_id = ?`).run(...params, blob(w.itemId));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
case "put-sync-state":
|
|
364
|
+
db.prepare(`INSERT INTO sync_state (resource_id, recently_acked, acked_durability) VALUES (?, ?, ?)
|
|
365
|
+
ON CONFLICT (resource_id) DO UPDATE SET recently_acked = excluded.recently_acked, acked_durability = excluded.acked_durability`).run(blob(w.row.resourceId), joinIds(w.row.recentlyAcked), w.row.ackedDurability === null ? null : w.row.ackedDurability.toString());
|
|
366
|
+
return;
|
|
367
|
+
case "dequeue":
|
|
368
|
+
db.prepare("DELETE FROM outbound WHERE item_id = ?").run(blob(w.itemId));
|
|
369
|
+
return;
|
|
370
|
+
case "put-local-mark":
|
|
371
|
+
if (w.value === null)
|
|
372
|
+
db.prepare("DELETE FROM local_marks WHERE key = ?").run(w.key);
|
|
373
|
+
else
|
|
374
|
+
db.prepare("INSERT INTO local_marks (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(w.key, w.value);
|
|
375
|
+
return;
|
|
376
|
+
case "put-profile-checkpoint": {
|
|
377
|
+
const c = w.checkpoint;
|
|
378
|
+
db.prepare(`INSERT INTO profile_checkpoints (resource_id, data_profile, state, actor_seq, units) VALUES (?, ?, ?, ?, ?)
|
|
379
|
+
ON CONFLICT (resource_id) DO UPDATE SET data_profile = excluded.data_profile, state = excluded.state, actor_seq = excluded.actor_seq, units = excluded.units`).run(blob(c.resourceId), c.dataProfile, blob(c.state), c.actorSeq, JSON.stringify(c.units.map((u) => [toHex(u.unitId), u.ref])));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
#mustChange(changes, unitId) {
|
|
385
|
+
if (changes === 0)
|
|
386
|
+
throw new LfcpError("INVALID_STRUCTURE", `no Data Unit ${toHex(unitId)}`);
|
|
387
|
+
}
|
|
388
|
+
#insertUnit(u, status, detail, accepted) {
|
|
389
|
+
this.#db
|
|
390
|
+
.prepare(`INSERT INTO data_units (unit_id, resource_id, data_epoch, actor, actor_seq, prev_id, control_head, bytes, status, detail, accepted)
|
|
391
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
392
|
+
.run(blob(u.unitId), blob(u.resourceId), u64(BigInt(u.dataEpoch)), blob(u.actor), u64(BigInt(u.actorSeq)), u.prevDataUnitId === null ? null : blob(u.prevDataUnitId), blob(u.controlHead), blob(u.bytes), status, detail, accepted ? 1 : 0);
|
|
393
|
+
}
|
|
394
|
+
control = {
|
|
395
|
+
record: (id) => this.#read(() => {
|
|
396
|
+
const r = this.#get("SELECT * FROM control_records WHERE record_id = ?", blob(id));
|
|
397
|
+
return r === undefined ? undefined : controlRecord(r);
|
|
398
|
+
}),
|
|
399
|
+
records: (res) => this.#read(() => this.#all("SELECT * FROM control_records WHERE resource_id = ? ORDER BY control_seq, record_id", blob(res)).map(controlRecord)),
|
|
400
|
+
head: (res) => this.#read(() => {
|
|
401
|
+
const r = this.#get("SELECT head, control_seq FROM control_heads WHERE resource_id = ?", blob(res));
|
|
402
|
+
return r === undefined
|
|
403
|
+
? undefined
|
|
404
|
+
: { head: as(r.head), controlSeq: fromU64(r.control_seq) };
|
|
405
|
+
}),
|
|
406
|
+
conflict: (res) => this.#read(() => {
|
|
407
|
+
const r = this.#get("SELECT heads FROM control_conflicts WHERE resource_id = ?", blob(res));
|
|
408
|
+
return r === undefined ? undefined : { heads: splitIds(r.heads) };
|
|
409
|
+
}),
|
|
410
|
+
epochs: (res) => this.#read(() => this.#all("SELECT * FROM epochs WHERE resource_id = ? ORDER BY epoch", blob(res)).map(epochRow)),
|
|
411
|
+
};
|
|
412
|
+
#at(res, actor, seq) {
|
|
413
|
+
return this.#all("SELECT * FROM data_units WHERE resource_id = ? AND actor = ? AND actor_seq = ? ORDER BY unit_id", blob(res), blob(actor), u64(BigInt(seq))).map(storedUnit);
|
|
414
|
+
}
|
|
415
|
+
dataUnits = {
|
|
416
|
+
get: (id) => this.#read(() => {
|
|
417
|
+
const r = this.#get("SELECT * FROM data_units WHERE unit_id = ?", blob(id));
|
|
418
|
+
return r === undefined ? undefined : storedUnit(r);
|
|
419
|
+
}),
|
|
420
|
+
at: (res, actor, seq) => this.#read(() => this.#at(res, actor, seq)),
|
|
421
|
+
range: (res, actor, from, to) => this.#read(() => this.#all("SELECT * FROM data_units WHERE resource_id = ? AND actor = ? AND actor_seq BETWEEN ? AND ? ORDER BY actor_seq, unit_id", blob(res), blob(actor), u64(BigInt(from)), u64(BigInt(to))).map(storedUnit)),
|
|
422
|
+
withStatus: (res, status) => this.#read(() => this.#all("SELECT * FROM data_units WHERE resource_id = ? AND status = ? ORDER BY actor, actor_seq, unit_id", blob(res), status).map(storedUnit)),
|
|
423
|
+
acceptedAt: (res, actor, seq) => this.#read(() => this.#at(res, actor, seq).find((u) => u.accepted)?.unitId),
|
|
424
|
+
recordSeen: (unit) => this.#tx(() => {
|
|
425
|
+
const old = this.#get("SELECT bytes FROM data_units WHERE unit_id = ?", blob(unit.unitId));
|
|
426
|
+
if (old !== undefined && !bytesEqual(bytes(old.bytes), unit.bytes))
|
|
427
|
+
refused("Data Unit", unit.unitId);
|
|
428
|
+
if (old === undefined)
|
|
429
|
+
this.#insertUnit(unit, "seen", null, false);
|
|
430
|
+
const unitIds = this.#at(unit.resourceId, unit.actor, unit.actorSeq).map((u) => u.unitId);
|
|
431
|
+
return Object.freeze({ unitIds: Object.freeze(unitIds), firstSeen: old === undefined });
|
|
432
|
+
}),
|
|
433
|
+
};
|
|
434
|
+
keyPackages = {
|
|
435
|
+
get: (id) => this.#read(() => {
|
|
436
|
+
const r = this.#get("SELECT * FROM key_packages WHERE package_id = ?", blob(id));
|
|
437
|
+
return r === undefined ? undefined : keyPackage(r);
|
|
438
|
+
}),
|
|
439
|
+
list: (res, filter = {}) => this.#read(() => {
|
|
440
|
+
const where = ["resource_id = ?"];
|
|
441
|
+
const params = [blob(res)];
|
|
442
|
+
if (filter.epoch !== undefined) {
|
|
443
|
+
where.push("data_epoch = ?");
|
|
444
|
+
params.push(u64(BigInt(filter.epoch)));
|
|
445
|
+
}
|
|
446
|
+
if (filter.recipient !== undefined) {
|
|
447
|
+
where.push("recipient = ?");
|
|
448
|
+
params.push(blob(filter.recipient));
|
|
449
|
+
}
|
|
450
|
+
return this.#all(`SELECT * FROM key_packages WHERE ${where.join(" AND ")} ORDER BY data_epoch, package_id`, ...params).map(keyPackage);
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
snapshots = {
|
|
454
|
+
get: (id) => this.#read(() => {
|
|
455
|
+
const r = this.#get("SELECT * FROM snapshots WHERE snapshot_id = ?", blob(id));
|
|
456
|
+
return r === undefined ? undefined : snapshot(r);
|
|
457
|
+
}),
|
|
458
|
+
list: (res, filter = {}) => this.#read(() => filter.epoch === undefined
|
|
459
|
+
? this.#all("SELECT * FROM snapshots WHERE resource_id = ? ORDER BY data_epoch, publisher, snapshot_seq", blob(res)).map(snapshot)
|
|
460
|
+
: this.#all("SELECT * FROM snapshots WHERE resource_id = ? AND data_epoch = ? ORDER BY data_epoch, publisher, snapshot_seq", blob(res), u64(BigInt(filter.epoch))).map(snapshot)),
|
|
461
|
+
};
|
|
462
|
+
resources = {
|
|
463
|
+
get: (res) => this.#read(() => {
|
|
464
|
+
const r = this.#get("SELECT * FROM resources WHERE resource_id = ?", blob(res));
|
|
465
|
+
return r === undefined ? undefined : resource(r);
|
|
466
|
+
}),
|
|
467
|
+
list: () => this.#read(() => this.#all("SELECT * FROM resources ORDER BY resource_id").map(resource)),
|
|
468
|
+
route: (res) => this.#read(() => {
|
|
469
|
+
const r = this.#get("SELECT * FROM routes WHERE resource_id = ?", blob(res));
|
|
470
|
+
return r === undefined ? undefined : route(r);
|
|
471
|
+
}),
|
|
472
|
+
};
|
|
473
|
+
outbound = {
|
|
474
|
+
list: (res) => this.#read(() => (res === undefined
|
|
475
|
+
? this.#all("SELECT * FROM outbound ORDER BY position")
|
|
476
|
+
: this.#all("SELECT * FROM outbound WHERE resource_id = ? ORDER BY position", blob(res))).map(outboundItem)),
|
|
477
|
+
get: (id) => this.#read(() => {
|
|
478
|
+
const r = this.#get("SELECT * FROM outbound WHERE item_id = ?", blob(id));
|
|
479
|
+
return r === undefined ? undefined : outboundItem(r);
|
|
480
|
+
}),
|
|
481
|
+
};
|
|
482
|
+
profileState = {
|
|
483
|
+
checkpoint: (res) => this.#read(() => {
|
|
484
|
+
const r = this.#get("SELECT * FROM profile_checkpoints WHERE resource_id = ?", blob(res));
|
|
485
|
+
return r === undefined ? undefined : checkpointRow(r);
|
|
486
|
+
}),
|
|
487
|
+
};
|
|
488
|
+
syncState = {
|
|
489
|
+
get: (res) => this.#read(() => {
|
|
490
|
+
const r = this.#get("SELECT * FROM sync_state WHERE resource_id = ?", blob(res));
|
|
491
|
+
return r === undefined ? undefined : syncStateRow(r);
|
|
492
|
+
}),
|
|
493
|
+
};
|
|
494
|
+
localMarks = {
|
|
495
|
+
get: (key) => this.#read(() => this.#get("SELECT value FROM local_marks WHERE key = ?", key)?.value),
|
|
496
|
+
list: (prefix) => this.#read(() => this.#all("SELECT key, value FROM local_marks ORDER BY key")
|
|
497
|
+
.filter((r) => r.key.startsWith(prefix))
|
|
498
|
+
.map((r) => ({ key: r.key, value: r.value }))),
|
|
499
|
+
};
|
|
500
|
+
/** Durable before it resolves: the new value is committed (WAL, synchronous=FULL) first. */
|
|
501
|
+
actorSequences = {
|
|
502
|
+
reserveNext: (res, principal) => this.#tx(() => {
|
|
503
|
+
const key = [blob(res), blob(principal)];
|
|
504
|
+
const row = this.#get("SELECT last FROM actor_sequences WHERE resource_id = ? AND principal = ?", ...key);
|
|
505
|
+
const next = nextActorSequence(row === undefined ? undefined : actorSequence(fromU64(row.last)));
|
|
506
|
+
// Fail closed (§9): a counter behind this Principal's own stored units
|
|
507
|
+
// means lost or damaged sequence state; continuing could reuse a nonce.
|
|
508
|
+
const stored = this.#get("SELECT MAX(actor_seq) AS m FROM data_units WHERE resource_id = ? AND actor = ?", ...key);
|
|
509
|
+
if (stored?.m != null && next <= fromU64(stored.m))
|
|
510
|
+
throw new LfcpError("SEQUENCE_REUSE", `the actor sequence state (${next - 1n}) is behind the stored units of this Principal (${fromU64(stored.m)}); refusing to reserve (§9)`);
|
|
511
|
+
this.#db
|
|
512
|
+
.prepare("INSERT INTO actor_sequences (resource_id, principal, last) VALUES (?, ?, ?) ON CONFLICT (resource_id, principal) DO UPDATE SET last = excluded.last")
|
|
513
|
+
.run(...key, u64(next));
|
|
514
|
+
return next;
|
|
515
|
+
}),
|
|
516
|
+
};
|
|
517
|
+
snapshotSequences = {
|
|
518
|
+
reserveNext: (res, epoch, publisher) => this.#tx(() => {
|
|
519
|
+
const key = [blob(res), u64(BigInt(epoch)), blob(publisher)];
|
|
520
|
+
const row = this.#get("SELECT last FROM snapshot_sequences WHERE resource_id = ? AND epoch = ? AND publisher = ?", ...key);
|
|
521
|
+
const last = row === undefined ? 0n : fromU64(row.last);
|
|
522
|
+
const stored = this.#get("SELECT MAX(snapshot_seq) AS m FROM snapshots WHERE resource_id = ? AND data_epoch = ? AND publisher = ?", ...key);
|
|
523
|
+
if (stored?.m != null && last + 1n <= fromU64(stored.m))
|
|
524
|
+
throw new LfcpError("SEQUENCE_REUSE", `the Snapshot Sequence state (${last}) is behind the stored Snapshots of this publisher (${fromU64(stored.m)}); refusing to reserve (§29)`);
|
|
525
|
+
if (last >= UINT64_MAX)
|
|
526
|
+
throw new LfcpError("OUT_OF_RANGE", "the Snapshot Sequence space is exhausted (§29)");
|
|
527
|
+
this.#db
|
|
528
|
+
.prepare("INSERT INTO snapshot_sequences (resource_id, epoch, publisher, last) VALUES (?, ?, ?, ?) ON CONFLICT (resource_id, epoch, publisher) DO UPDATE SET last = excluded.last")
|
|
529
|
+
.run(...key, u64(last + 1n));
|
|
530
|
+
return last + 1n;
|
|
531
|
+
}),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
//# sourceMappingURL=sqlite.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/storage-node",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Durable Node.js storage for LFCP clients: SQLite (better-sqlite3) and a file secret store. Node only.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"better-sqlite3": "13.0.3",
|
|
46
|
-
"@openlfcp/storage": "^0.1.
|
|
47
|
-
"@openlfcp/core": "^0.1.
|
|
46
|
+
"@openlfcp/storage": "^0.1.4",
|
|
47
|
+
"@openlfcp/core": "^0.1.4"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/better-sqlite3": "9.6.0",
|