@drakkar.software/starfish-client 3.0.0-alpha.10 → 3.0.0-alpha.12
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/dist/append-log.d.ts +1 -3
- package/dist/append-log.js +267 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.js +28 -20
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +30 -13
- package/dist/client.js +391 -0
- package/dist/config.js +18 -0
- package/dist/debounced-sync.js +120 -0
- package/dist/dedup.js +35 -0
- package/dist/export.js +115 -0
- package/dist/history.js +61 -0
- package/dist/index.js +30 -23
- package/dist/index.js.map +3 -3
- package/dist/logger.js +80 -0
- package/dist/migrate.js +38 -0
- package/dist/mobile-lifecycle.js +94 -0
- package/dist/multi-store.js +92 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.js +223 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.js +181 -0
- package/dist/types.d.ts +1 -9
- package/dist/types.js +18 -0
- package/dist/validate.js +28 -0
- package/package.json +2 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexedDB-based storage adapter for Zustand persistence.
|
|
3
|
+
* Implements the same interface as Zustand's StateStorage (getItem/setItem/removeItem).
|
|
4
|
+
* Supports larger data than localStorage (typically 50MB+).
|
|
5
|
+
*/
|
|
6
|
+
function openDB(dbName, storeName) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const request = indexedDB.open(dbName, 1);
|
|
9
|
+
request.onupgradeneeded = () => {
|
|
10
|
+
const db = request.result;
|
|
11
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
12
|
+
db.createObjectStore(storeName);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
request.onsuccess = () => resolve(request.result);
|
|
16
|
+
request.onerror = () => reject(request.error);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function idbRequest(request) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
request.onsuccess = () => resolve(request.result);
|
|
22
|
+
request.onerror = () => reject(request.error);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function createIndexedDBStorage(opts) {
|
|
26
|
+
const dbName = opts?.dbName ?? "starfish";
|
|
27
|
+
const storeName = opts?.storeName ?? "state";
|
|
28
|
+
let dbPromise = null;
|
|
29
|
+
function getDB() {
|
|
30
|
+
if (!dbPromise) {
|
|
31
|
+
dbPromise = openDB(dbName, storeName).catch((err) => {
|
|
32
|
+
dbPromise = null; // Reset so next call retries
|
|
33
|
+
throw err;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return dbPromise;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
async getItem(name) {
|
|
40
|
+
const db = await getDB();
|
|
41
|
+
const tx = db.transaction(storeName, "readonly");
|
|
42
|
+
const store = tx.objectStore(storeName);
|
|
43
|
+
const result = await idbRequest(store.get(name));
|
|
44
|
+
return result ?? null;
|
|
45
|
+
},
|
|
46
|
+
async setItem(name, value) {
|
|
47
|
+
const db = await getDB();
|
|
48
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
49
|
+
const store = tx.objectStore(storeName);
|
|
50
|
+
await idbRequest(store.put(value, name));
|
|
51
|
+
},
|
|
52
|
+
async removeItem(name) {
|
|
53
|
+
const db = await getDB();
|
|
54
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
55
|
+
const store = tx.objectStore(storeName);
|
|
56
|
+
await idbRequest(store.delete(name));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, deepMerge, docAuthorCanonicalInput, getBase64, } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
import { ConflictError } from "./types.js";
|
|
3
|
+
import { stripPushPrefix } from "./client.js";
|
|
4
|
+
import { ValidationError } from "./validate.js";
|
|
5
|
+
export class AbortError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("SyncManager was aborted");
|
|
8
|
+
this.name = "AbortError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class SyncManager {
|
|
12
|
+
client;
|
|
13
|
+
pullPath;
|
|
14
|
+
pushPath;
|
|
15
|
+
onConflict;
|
|
16
|
+
maxRetries;
|
|
17
|
+
encryptor;
|
|
18
|
+
signer;
|
|
19
|
+
logger;
|
|
20
|
+
loggerName;
|
|
21
|
+
validate;
|
|
22
|
+
lastHash = null;
|
|
23
|
+
lastCheckpoint = 0;
|
|
24
|
+
localData = {};
|
|
25
|
+
aborted = false;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.client = options.client;
|
|
28
|
+
this.pullPath = options.pullPath;
|
|
29
|
+
this.pushPath = options.pushPath;
|
|
30
|
+
this.onConflict = options.onConflict ?? deepMerge;
|
|
31
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
32
|
+
this.signer = options.signer;
|
|
33
|
+
this.logger = options.logger;
|
|
34
|
+
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
35
|
+
this.validate = options.validate;
|
|
36
|
+
this.encryptor = options.encryptor ?? null;
|
|
37
|
+
}
|
|
38
|
+
abort() {
|
|
39
|
+
this.aborted = true;
|
|
40
|
+
}
|
|
41
|
+
get isAborted() {
|
|
42
|
+
return this.aborted;
|
|
43
|
+
}
|
|
44
|
+
getData() {
|
|
45
|
+
return { ...this.localData };
|
|
46
|
+
}
|
|
47
|
+
getHash() {
|
|
48
|
+
return this.lastHash;
|
|
49
|
+
}
|
|
50
|
+
/** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
|
|
51
|
+
setHash(hash) {
|
|
52
|
+
this.lastHash = hash;
|
|
53
|
+
}
|
|
54
|
+
getCheckpoint() {
|
|
55
|
+
return this.lastCheckpoint;
|
|
56
|
+
}
|
|
57
|
+
async pull() {
|
|
58
|
+
if (this.aborted)
|
|
59
|
+
throw new AbortError();
|
|
60
|
+
this.logger?.pullStart(this.loggerName);
|
|
61
|
+
const start = performance.now();
|
|
62
|
+
try {
|
|
63
|
+
// NOTE: `SyncManager.pull` does NOT auto-enable `withKeyring`. Clients
|
|
64
|
+
// that drive the keyring helpers from `recipients.ts` and want to save
|
|
65
|
+
// the cold-start round-trip should call `client.pull(path, {withKeyring: true})`
|
|
66
|
+
// directly. We keep `SyncManager` keyring-agnostic so it stays usable
|
|
67
|
+
// for collections that don't use delegated encryption.
|
|
68
|
+
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
69
|
+
if (this.aborted)
|
|
70
|
+
throw new AbortError();
|
|
71
|
+
if (this.encryptor) {
|
|
72
|
+
const decrypted = await this.encryptor.decrypt(result.data);
|
|
73
|
+
if (this.aborted)
|
|
74
|
+
throw new AbortError();
|
|
75
|
+
this.localData = decrypted;
|
|
76
|
+
result.data = decrypted;
|
|
77
|
+
}
|
|
78
|
+
else if (this.lastCheckpoint > 0) {
|
|
79
|
+
this.localData = deepMerge(this.localData, result.data);
|
|
80
|
+
result.data = this.localData;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
this.localData = result.data;
|
|
84
|
+
}
|
|
85
|
+
this.lastHash = result.hash;
|
|
86
|
+
this.lastCheckpoint = result.timestamp;
|
|
87
|
+
this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async push(data) {
|
|
96
|
+
if (this.aborted)
|
|
97
|
+
throw new AbortError();
|
|
98
|
+
if (this.validate) {
|
|
99
|
+
const result = this.validate(data);
|
|
100
|
+
if (result !== true)
|
|
101
|
+
throw new ValidationError(result);
|
|
102
|
+
}
|
|
103
|
+
this.logger?.pushStart(this.loggerName);
|
|
104
|
+
const start = performance.now();
|
|
105
|
+
let attempt = 0;
|
|
106
|
+
let pendingData = data;
|
|
107
|
+
while (attempt <= this.maxRetries) {
|
|
108
|
+
try {
|
|
109
|
+
const sealed = this.encryptor
|
|
110
|
+
? await this.encryptor.encrypt(pendingData)
|
|
111
|
+
: pendingData;
|
|
112
|
+
if (this.aborted)
|
|
113
|
+
throw new AbortError();
|
|
114
|
+
// v3.0 signer path: sign the document author proof over the doc-author
|
|
115
|
+
// canonical input (domain-tagged, bound to documentKey) and pass it as
|
|
116
|
+
// top-level body siblings of `data` (NOT inside `data`), where the server
|
|
117
|
+
// verifies it and stores the raw author pubkey.
|
|
118
|
+
let author;
|
|
119
|
+
if (this.signer) {
|
|
120
|
+
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
121
|
+
if (this.aborted)
|
|
122
|
+
throw new AbortError();
|
|
123
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
124
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
125
|
+
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
126
|
+
if (this.aborted)
|
|
127
|
+
throw new AbortError();
|
|
128
|
+
author = {
|
|
129
|
+
[AUTHOR_PUBKEY_FIELD]: devEdPubHex,
|
|
130
|
+
[AUTHOR_SIGNATURE_FIELD]: getBase64().encode(sigBytes),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const result = await this.client.push(this.pushPath, sealed, this.lastHash, author);
|
|
134
|
+
if (this.aborted)
|
|
135
|
+
throw new AbortError();
|
|
136
|
+
this.lastHash = result.hash;
|
|
137
|
+
this.lastCheckpoint = result.timestamp;
|
|
138
|
+
this.localData = pendingData;
|
|
139
|
+
this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err instanceof AbortError)
|
|
144
|
+
throw err;
|
|
145
|
+
if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
|
|
146
|
+
this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
this.logger?.conflict(this.loggerName, attempt + 1);
|
|
150
|
+
try {
|
|
151
|
+
const remote = await this.client.pull(this.pullPath);
|
|
152
|
+
if (this.aborted)
|
|
153
|
+
throw new AbortError();
|
|
154
|
+
const remoteData = this.encryptor
|
|
155
|
+
? await this.encryptor.decrypt(remote.data)
|
|
156
|
+
: remote.data;
|
|
157
|
+
if (this.aborted)
|
|
158
|
+
throw new AbortError();
|
|
159
|
+
this.lastHash = remote.hash;
|
|
160
|
+
this.lastCheckpoint = remote.timestamp;
|
|
161
|
+
pendingData = this.onConflict(pendingData, remoteData);
|
|
162
|
+
}
|
|
163
|
+
catch (resolveErr) {
|
|
164
|
+
if (resolveErr instanceof AbortError)
|
|
165
|
+
throw resolveErr;
|
|
166
|
+
const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
|
|
167
|
+
this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
|
|
168
|
+
throw resolveErr;
|
|
169
|
+
}
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2000) + Math.random() * 100));
|
|
171
|
+
attempt++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new ConflictError();
|
|
175
|
+
}
|
|
176
|
+
async update(modifier) {
|
|
177
|
+
await this.pull();
|
|
178
|
+
const updated = modifier(this.localData);
|
|
179
|
+
return this.push(updated);
|
|
180
|
+
}
|
|
181
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CapCert } from "@drakkar.software/starfish-protocol";
|
|
2
2
|
/** Push conflict error (HTTP 409). */
|
|
3
3
|
export declare class ConflictError extends Error {
|
|
4
4
|
constructor();
|
|
@@ -29,19 +29,11 @@ export interface StarfishCapProvider {
|
|
|
29
29
|
* The client then sends it as `X-Starfish-Pub` so the server can verify the
|
|
30
30
|
* request signature against it and check the cap's `aud` allow-list. Omit
|
|
31
31
|
* `pubHex` for device/member caps (the server uses `cap.sub`).
|
|
32
|
-
*
|
|
33
|
-
* `presenterAlg` is the crypto suite of `devEdPrivHex` (the key that signs
|
|
34
|
-
* the request). It matters only for `audience` caps, where the presenter is
|
|
35
|
-
* an arbitrary redeemer whose suite is unrelated to the cap's `issAlg`; the
|
|
36
|
-
* client sends it as `X-Starfish-Alg`. For device/member caps the subject's
|
|
37
|
-
* suite is taken authoritatively from the verified cert, so this is ignored.
|
|
38
|
-
* Defaults to `"ed25519"` when omitted.
|
|
39
32
|
*/
|
|
40
33
|
getCap(): Promise<{
|
|
41
34
|
cap: CapCert;
|
|
42
35
|
devEdPrivHex: string;
|
|
43
36
|
pubHex?: string;
|
|
44
|
-
presenterAlg?: Alg;
|
|
45
37
|
}>;
|
|
46
38
|
}
|
|
47
39
|
/** Options for creating a StarfishClient. */
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Push conflict error (HTTP 409). */
|
|
2
|
+
export class ConflictError extends Error {
|
|
3
|
+
constructor() {
|
|
4
|
+
super("hash_mismatch");
|
|
5
|
+
this.name = "ConflictError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** HTTP error from the Starfish server. */
|
|
9
|
+
export class StarfishHttpError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
body;
|
|
12
|
+
constructor(status, body) {
|
|
13
|
+
super(`HTTP ${status}: ${body}`);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.body = body;
|
|
16
|
+
this.name = "StarfishHttpError";
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Error thrown when pre-push validation fails. */
|
|
2
|
+
export class ValidationError extends Error {
|
|
3
|
+
errors;
|
|
4
|
+
constructor(errors) {
|
|
5
|
+
super(`Validation failed: ${errors.join("; ")}`);
|
|
6
|
+
this.errors = errors;
|
|
7
|
+
this.name = "ValidationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates a validator from a JSON Schema object.
|
|
12
|
+
* Requires an Ajv-compatible validate function.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import Ajv from "ajv"
|
|
17
|
+
* const ajv = new Ajv()
|
|
18
|
+
* const validator = createSchemaValidator(ajv, mySchema)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function createSchemaValidator(ajv, schema) {
|
|
22
|
+
const validate = ajv.compile(schema);
|
|
23
|
+
return (data) => {
|
|
24
|
+
if (validate(data))
|
|
25
|
+
return true;
|
|
26
|
+
return [ajv.errorsText(validate.errors)];
|
|
27
|
+
};
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakkar.software/starfish-client",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.12",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/Drakkar-Software/starfish.git",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@drakkar.software/starfish-protocol": "3.0.0-alpha.
|
|
63
|
+
"@drakkar.software/starfish-protocol": "3.0.0-alpha.12"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@legendapp/state": "^2.0.0",
|