@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 +201 -0
- package/README.md +100 -0
- 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 +53 -0
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).
|
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
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
|
+
}
|