@openlfcp/storage-node 0.1.0-rc.1

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,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @openlfcp/storage-node
2
+
3
+ Durable Node.js storage for LFCP clients (LFCP-035):
4
+
5
+ - `SqliteLfcpStorage`, the `LfcpStorage` interface of `@openlfcp/storage` on
6
+ SQLite through [better-sqlite3](https://github.com/WiseLibs/better-sqlite3)
7
+ 13.0.3;
8
+ - `FileSecretStore`, a `SecretStore` in a local directory.
9
+
10
+ **This package is Node only.** It is meant for headless Node clients, the CLI,
11
+ the examples and tests. It is the only sdk-ts package allowed to use `node:*`
12
+ modules (`scripts/check-boundaries.mjs`), and no portable package may depend
13
+ on it. The Obsidian plugin does not use it: Obsidian has its own adapter
14
+ (LFCP-059), which runs the same contract suite (`runStorageContract` from
15
+ `@openlfcp/storage/contract`).
16
+
17
+ ## Scope
18
+
19
+ Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
20
+ subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
21
+ at `mvp-0.1-baseline.8`, not every deferred WIRE-01 feature. It does not
22
+ claim full LFCP-WIRE-01 conformance. This is a release candidate
23
+ (`0.1.0-rc.1`, npm dist-tag `next`): APIs may still change.
24
+
25
+ ## SQLite
26
+
27
+ ```ts
28
+ const storage = SqliteLfcpStorage.open("/path/to/lfcp.sqlite");
29
+ // ... storage.commit([...]), storage.dataUnits.get(id), ...
30
+ storage.close();
31
+ ```
32
+
33
+ - **Durability.** WAL journal with `synchronous=FULL`: a transaction is on disk
34
+ when it commits, and every promise resolves after its transaction committed.
35
+ These are SQLite's guarantees on a local filesystem whose `fsync` works,
36
+ nothing more.
37
+ - **Atomicity.** One `commit()` is one `BEGIN IMMEDIATE` transaction: every
38
+ write or none, with the Control Head compare-and-set checked inside it.
39
+ - **Sequences.** Actor and Snapshot sequence reservations read and advance
40
+ their counter inside one `BEGIN IMMEDIATE` transaction, which holds the write
41
+ lock across processes. No two callers ever get the same value, in this
42
+ process or another. A crash after a reservation committed skips that value;
43
+ it never reuses it.
44
+ - **Exact bytes.** Control Records, Data Units, Key Packages and Snapshots are
45
+ stored as their exact bytes; the other columns are indexes. Storing an ID
46
+ again with other bytes fails the whole batch. Bytes are copied out, so callers
47
+ never share SQLite's buffers.
48
+ - **Indexes.** Data Units are indexed by `(resource, actor, sequence)`, which
49
+ serves equivocation checks, held-unit retries and anti-entropy ranges, and by
50
+ `(resource, status)`.
51
+ - **Schema.** A `schema_version` table and ordered migrations, from version 1.
52
+ A database from a newer schema is refused, never downgraded.
53
+ - uint64 values (sequences, epochs, route versions) are stored as 20-digit
54
+ zero-padded text, so they sort numerically and never overflow SQLite's
55
+ signed 64-bit integers.
56
+
57
+ The driver ships prebuilt Node-API binaries for macOS, Linux (glibc and musl)
58
+ and Windows on x64 and arm64. Installing needs no compiler and no install
59
+ script, and the same binary loads in Electron's main process.
60
+
61
+ ## Secrets
62
+
63
+ ```ts
64
+ const secrets = new FileSecretStore("/path/to/secrets");
65
+ await secrets.put(dekSecretRef(resource, epoch), dekBytes);
66
+ ```
67
+
68
+ > **MVP limitation: secrets are stored in plaintext on disk.** They are
69
+ > protected only by file permissions: the directory is `0700` and every file
70
+ > `0600`, owned by the user running the client. Anyone who can read those files
71
+ > as that user, as root, or from a backup has the keys. OS keychain integration
72
+ > or passphrase-based encryption is a follow-up.
73
+
74
+ - One file per `SecretRef`, named by the hex of the reference (references are
75
+ public names, never secret material).
76
+ - Writes go to a temporary file that is fsynced, renamed over the target and
77
+ followed by a directory fsync. A crash leaves the old value or the new one,
78
+ never a torn file.
79
+ - There is no list operation, and the store prints as `[FileSecretStore]`, so
80
+ values cannot leak into logs.
81
+ - Write a secret before committing the row that references it (see
82
+ `@openlfcp/storage`).
83
+
84
+ ## Tests
85
+
86
+ `test/contract.test.ts` runs the shared storage contract, including a reopen,
87
+ against SQLite and the file store. `test/crash.test.ts` kills a writing child
88
+ process with `SIGKILL` and checks the reopened database: no sequence reuse, no
89
+ torn batch, every committed batch present. Every test uses its own temporary
90
+ directory and deletes it.
91
+
92
+ ## Links
93
+
94
+ - Specification: [openlfcp/spec](https://github.com/openlfcp/spec)
95
+ - Source and the other SDK packages: [openlfcp/sdk-ts](https://github.com/openlfcp/sdk-ts)
96
+ - Issues: [openlfcp/sdk-ts/issues](https://github.com/openlfcp/sdk-ts/issues)
97
+
98
+ ## License
99
+
100
+ Apache-2.0. See [LICENSE](LICENSE).
@@ -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
@@ -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
@@ -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
@@ -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 ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@openlfcp/storage-node",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Durable Node.js storage for LFCP clients: SQLite (better-sqlite3) and a file secret store. Node only.",
5
+ "keywords": [
6
+ "openlfcp",
7
+ "lfcp",
8
+ "local-first",
9
+ "end-to-end-encryption",
10
+ "storage",
11
+ "sqlite",
12
+ "node"
13
+ ],
14
+ "license": "Apache-2.0",
15
+ "homepage": "https://github.com/openlfcp/sdk-ts/tree/main/packages/storage-node#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/openlfcp/sdk-ts/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/openlfcp/sdk-ts.git",
22
+ "directory": "packages/storage-node"
23
+ },
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=24"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "!dist/**/*.map",
38
+ "!dist/**/*.tsbuildinfo"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "tag": "next"
43
+ },
44
+ "dependencies": {
45
+ "better-sqlite3": "13.0.3",
46
+ "@openlfcp/core": "^0.1.0-rc.1",
47
+ "@openlfcp/storage": "^0.1.0-rc.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/better-sqlite3": "9.6.0",
51
+ "@types/node": "24.19.1"
52
+ }
53
+ }