Compare commits
56 Commits
jazz-inspe
...
fix/idb-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e625f3c12 | ||
|
|
8b3686c7ce | ||
|
|
bce04ee06d | ||
|
|
f2e9115f4c | ||
|
|
ee0897d9a8 | ||
|
|
1d71ca1511 | ||
|
|
a37dc1c22f | ||
|
|
774f232390 | ||
|
|
12c19fc940 | ||
|
|
338f5421f4 | ||
|
|
e0daca300b | ||
|
|
5c76e37f14 | ||
|
|
0117d0c9b9 | ||
|
|
b90c766c05 | ||
|
|
262a36e456 | ||
|
|
cb1df65beb | ||
|
|
ea91e63ff2 | ||
|
|
8eae2eb31e | ||
|
|
c9044f5123 | ||
|
|
24340173fa | ||
|
|
53e88993a0 | ||
|
|
ece168878b | ||
|
|
cad84db52b | ||
|
|
342a385111 | ||
|
|
f87ba7d927 | ||
|
|
7c7f55b85c | ||
|
|
0e5b9f5292 | ||
|
|
2f5af3dece | ||
|
|
2c35e2ba85 | ||
|
|
0a4f79d5a4 | ||
|
|
43cb7abba7 | ||
|
|
25f76f6b02 | ||
|
|
6a56561c98 | ||
|
|
2ac31e7c51 | ||
|
|
1bbefab5a9 | ||
|
|
1143b32cf3 | ||
|
|
51ada27810 | ||
|
|
954ecb3984 | ||
|
|
05089270d9 | ||
|
|
fecc81111a | ||
|
|
4d3e7dbcd5 | ||
|
|
ee65f18fd9 | ||
|
|
bcbc4636ed | ||
|
|
8c323c4513 | ||
|
|
4103ea0c88 | ||
|
|
733ebec902 | ||
|
|
10a3834668 | ||
|
|
593c3aeb6e | ||
|
|
a55d71c28d | ||
|
|
c030c7a57e | ||
|
|
e5b4c0448a | ||
|
|
0d516a3c6a | ||
|
|
271ff3eb40 | ||
|
|
dcc836ff98 | ||
|
|
22da4ea136 | ||
|
|
80e86c92b2 |
8
.changeset/large-crabs-fry.md
Normal file
8
.changeset/large-crabs-fry.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"jazz-react-auth-clerk": patch
|
||||
"jazz-react-core": patch
|
||||
"hash-slash": patch
|
||||
"jazz-react": patch
|
||||
---
|
||||
|
||||
Add React 19 to the peer dependencies
|
||||
8
.changeset/selfish-llamas-pump.md
Normal file
8
.changeset/selfish-llamas-pump.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"cojson-storage-indexeddb": patch
|
||||
"cojson-storage-rn-sqlite": patch
|
||||
"cojson-storage-sqlite": patch
|
||||
"cojson-storage": patch
|
||||
---
|
||||
|
||||
Improve rollback on error when failing to add new content
|
||||
5
.changeset/serious-ants-smile.md
Normal file
5
.changeset/serious-ants-smile.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cojson": patch
|
||||
---
|
||||
|
||||
Ports Wasm crypto functions to use exported library `jazz-crypto-rs`
|
||||
@@ -16,15 +16,18 @@
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# General development
|
||||
git
|
||||
|
||||
# JS development
|
||||
nodejs_22
|
||||
nodePackages.pnpm
|
||||
git
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo ""
|
||||
echo "Welcome to the Jazz development environment!"
|
||||
echo "Run 'pnpm install' to install the dependencies."
|
||||
echo "Run 'pnpm install' to install dependencies."
|
||||
echo ""
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -229,7 +229,10 @@ async function readMdxContent(url) {
|
||||
const relativePath = url.replace(/^\/docs\/?/, "");
|
||||
|
||||
// Base directory for docs
|
||||
const baseDir = path.join(process.cwd(), "app/docs/[framework]/[...slug]");
|
||||
const baseDir = path.join(
|
||||
process.cwd(),
|
||||
"app/(docs)/docs/[framework]/[...slug]",
|
||||
);
|
||||
|
||||
// If it's a directory, try to read all framework variants
|
||||
const fullPath = path.join(baseDir, relativePath);
|
||||
|
||||
24
homepage/homepage/generate-docs/llms-full.test.mjs
Normal file
24
homepage/homepage/generate-docs/llms-full.test.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
test("Size test", async () => {
|
||||
const filePath = path.join(process.cwd(), "public", "llms-full.txt");
|
||||
const stats = await fs.stat(filePath);
|
||||
assert.ok(
|
||||
stats.size > 100 * 1024,
|
||||
"llms-full.txt should be larger than 100kb", // Somewhat arbitrary, but it's a good sanity check
|
||||
);
|
||||
});
|
||||
|
||||
test("Content test", async () => {
|
||||
const filePath = path.join(process.cwd(), "public", "llms-full.txt");
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
assert.ok(
|
||||
content.includes(
|
||||
'Jazz authentication is based on cryptographic keys ("Account keys").',
|
||||
),
|
||||
"Should contain authentication message", // From authentication, it's unlikely to change much
|
||||
);
|
||||
});
|
||||
@@ -13,7 +13,9 @@
|
||||
"generate:docs": "node generate-docs/typedocs.mjs --build",
|
||||
"generate:llm-docs:all": "pnpm run generate:llm-docs:concise && pnpm run generate:llm-docs:full",
|
||||
"generate:llm-docs:concise": "node generate-docs/llms.mjs",
|
||||
"generate:llm-docs:full": "node generate-docs/llms-full.mjs"
|
||||
"generate:llm-docs:full": "node generate-docs/llms-full.mjs",
|
||||
"test": "pnpm run test:llm-docs",
|
||||
"test:llm-docs": "node generate-docs/llms-full.test.mjs"
|
||||
},
|
||||
"packageManager": "pnpm@9.14.0",
|
||||
"dependencies": {
|
||||
|
||||
111
packages/cojson-storage-indexeddb/src/CoJsonIDBTransaction.ts
Normal file
111
packages/cojson-storage-indexeddb/src/CoJsonIDBTransaction.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type StoreName =
|
||||
| "coValues"
|
||||
| "sessions"
|
||||
| "transactions"
|
||||
| "signatureAfter";
|
||||
|
||||
// A access unit for the IndexedDB Jazz database
|
||||
// It's a wrapper around the IDBTransaction object that helps on batching multiple operations
|
||||
// in a single transaction.
|
||||
export class CoJsonIDBTransaction {
|
||||
db: IDBDatabase;
|
||||
tx: IDBTransaction;
|
||||
|
||||
pendingRequests: ((txEntry: this) => void)[] = [];
|
||||
rejectHandlers: (() => void)[] = [];
|
||||
|
||||
id = Math.random();
|
||||
|
||||
running = false;
|
||||
failed = false;
|
||||
done = false;
|
||||
|
||||
constructor(db: IDBDatabase) {
|
||||
this.db = db;
|
||||
|
||||
this.tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions", "signatureAfter"],
|
||||
"readwrite",
|
||||
);
|
||||
|
||||
this.tx.oncomplete = () => {
|
||||
this.done = true;
|
||||
};
|
||||
this.tx.onabort = () => {
|
||||
this.done = true;
|
||||
};
|
||||
}
|
||||
|
||||
startedAt = performance.now();
|
||||
isReusable() {
|
||||
const delta = performance.now() - this.startedAt;
|
||||
return !this.done && delta <= 20;
|
||||
}
|
||||
|
||||
getObjectStore(name: StoreName) {
|
||||
return this.tx.objectStore(name);
|
||||
}
|
||||
|
||||
private pushRequest<T>(
|
||||
handler: (txEntry: this, next: () => void) => Promise<T>,
|
||||
) {
|
||||
const next = () => {
|
||||
const next = this.pendingRequests.shift();
|
||||
|
||||
if (next) {
|
||||
next(this);
|
||||
} else {
|
||||
this.running = false;
|
||||
this.done = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (this.running) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.rejectHandlers.push(reject);
|
||||
this.pendingRequests.push(async () => {
|
||||
try {
|
||||
const result = await handler(this, next);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
return handler(this, next);
|
||||
}
|
||||
|
||||
handleRequest<T>(handler: (txEntry: this) => IDBRequest<T>) {
|
||||
return this.pushRequest<T>((txEntry, next) => {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const request = handler(txEntry);
|
||||
|
||||
request.onerror = () => {
|
||||
this.failed = true;
|
||||
this.tx.abort();
|
||||
console.error(request.error);
|
||||
reject(request.error);
|
||||
|
||||
// Don't leave any pending promise
|
||||
for (const handler of this.rejectHandlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as T);
|
||||
next();
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (!this.done) {
|
||||
this.tx.commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CojsonInternalTypes, RawCoID } from "cojson";
|
||||
import type { CojsonInternalTypes, RawCoID, SessionID } from "cojson";
|
||||
import type {
|
||||
CoValueRow,
|
||||
DBClientInterface,
|
||||
@@ -8,119 +8,60 @@ import type {
|
||||
StoredSessionRow,
|
||||
TransactionRow,
|
||||
} from "cojson-storage";
|
||||
import { SyncPromise } from "./syncPromises.js";
|
||||
import { CoJsonIDBTransaction } from "./CoJsonIDBTransaction.js";
|
||||
|
||||
export class IDBClient implements DBClientInterface {
|
||||
private db;
|
||||
|
||||
currentTx:
|
||||
| {
|
||||
id: number;
|
||||
tx: IDBTransaction;
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
startedAt: number;
|
||||
pendingRequests: ((txEntry: {
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
}) => void)[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
currentTxID = 0;
|
||||
activeTransaction: CoJsonIDBTransaction | undefined;
|
||||
autoBatchingTransaction: CoJsonIDBTransaction | undefined;
|
||||
|
||||
constructor(db: IDBDatabase) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
makeRequest<T>(
|
||||
handler: (stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
}) => IDBRequest,
|
||||
): SyncPromise<T> {
|
||||
return new SyncPromise((resolve, reject) => {
|
||||
let txEntry = this.currentTx;
|
||||
handler: (txEntry: CoJsonIDBTransaction) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
if (this.activeTransaction) {
|
||||
return this.activeTransaction.handleRequest<T>(handler);
|
||||
}
|
||||
|
||||
const requestEntry = ({
|
||||
stores,
|
||||
}: {
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
}) => {
|
||||
const request = handler(stores);
|
||||
request.onerror = () => {
|
||||
console.error("Error in request", request.error);
|
||||
this.currentTx = undefined;
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const value = request.result as T;
|
||||
resolve(value);
|
||||
if (this.autoBatchingTransaction?.isReusable()) {
|
||||
return this.autoBatchingTransaction.handleRequest<T>(handler);
|
||||
}
|
||||
|
||||
const next = txEntry?.pendingRequests.shift();
|
||||
const tx = new CoJsonIDBTransaction(this.db);
|
||||
|
||||
if (next) {
|
||||
next({ stores });
|
||||
} else {
|
||||
if (this.currentTx === txEntry) {
|
||||
this.currentTx = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
this.autoBatchingTransaction = tx;
|
||||
|
||||
// Transaction batching
|
||||
if (!txEntry || performance.now() - txEntry.startedAt > 20) {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions", "signatureAfter"],
|
||||
"readwrite",
|
||||
);
|
||||
txEntry = {
|
||||
id: this.currentTxID++,
|
||||
tx,
|
||||
stores: {
|
||||
coValues: tx.objectStore("coValues"),
|
||||
sessions: tx.objectStore("sessions"),
|
||||
transactions: tx.objectStore("transactions"),
|
||||
signatureAfter: tx.objectStore("signatureAfter"),
|
||||
},
|
||||
startedAt: performance.now(),
|
||||
pendingRequests: [],
|
||||
};
|
||||
|
||||
this.currentTx = txEntry;
|
||||
|
||||
requestEntry(txEntry);
|
||||
} else {
|
||||
txEntry.pendingRequests.push(requestEntry);
|
||||
}
|
||||
});
|
||||
return tx.handleRequest<T>(handler);
|
||||
}
|
||||
|
||||
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
|
||||
return this.makeRequest<StoredCoValueRow | undefined>(({ coValues }) =>
|
||||
coValues.index("coValuesById").get(coValueId),
|
||||
return this.makeRequest<StoredCoValueRow | undefined>((tx) =>
|
||||
tx.getObjectStore("coValues").index("coValuesById").get(coValueId),
|
||||
);
|
||||
}
|
||||
|
||||
async getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]> {
|
||||
return this.makeRequest<StoredSessionRow[]>(({ sessions }) =>
|
||||
sessions.index("sessionsByCoValue").getAll(coValueRowId),
|
||||
return this.makeRequest<StoredSessionRow[]>((tx) =>
|
||||
tx
|
||||
.getObjectStore("sessions")
|
||||
.index("sessionsByCoValue")
|
||||
.getAll(coValueRowId),
|
||||
);
|
||||
}
|
||||
|
||||
async getSingleCoValueSession(
|
||||
coValueRowId: number,
|
||||
sessionID: SessionID,
|
||||
): Promise<StoredSessionRow | undefined> {
|
||||
return this.makeRequest<StoredSessionRow>((tx) =>
|
||||
tx
|
||||
.getObjectStore("sessions")
|
||||
.index("uniqueSessions")
|
||||
.get([coValueRowId, sessionID]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,13 +69,15 @@ export class IDBClient implements DBClientInterface {
|
||||
sessionRowId: number,
|
||||
firstNewTxIdx: number,
|
||||
): Promise<TransactionRow[]> {
|
||||
return this.makeRequest<TransactionRow[]>(({ transactions }) =>
|
||||
transactions.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRowId, firstNewTxIdx],
|
||||
[sessionRowId, Number.POSITIVE_INFINITY],
|
||||
return this.makeRequest<TransactionRow[]>((tx) =>
|
||||
tx
|
||||
.getObjectStore("transactions")
|
||||
.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRowId, firstNewTxIdx],
|
||||
[sessionRowId, Number.POSITIVE_INFINITY],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,9 +85,10 @@ export class IDBClient implements DBClientInterface {
|
||||
sessionRowId: number,
|
||||
firstNewTxIdx: number,
|
||||
): Promise<SignatureAfterRow[]> {
|
||||
return this.makeRequest<SignatureAfterRow[]>(
|
||||
({ signatureAfter }: { signatureAfter: IDBObjectStore }) =>
|
||||
signatureAfter.getAll(
|
||||
return this.makeRequest<SignatureAfterRow[]>((tx) =>
|
||||
tx
|
||||
.getObjectStore("signatureAfter")
|
||||
.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRowId, firstNewTxIdx],
|
||||
[sessionRowId, Number.POSITIVE_INFINITY],
|
||||
@@ -160,8 +104,8 @@ export class IDBClient implements DBClientInterface {
|
||||
throw new Error(`Header is required, coId: ${msg.id}`);
|
||||
}
|
||||
|
||||
return (await this.makeRequest<IDBValidKey>(({ coValues }) =>
|
||||
coValues.put({
|
||||
return (await this.makeRequest<IDBValidKey>((tx) =>
|
||||
tx.getObjectStore("coValues").put({
|
||||
id: msg.id,
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO(JAZZ-561): Review
|
||||
header: msg.header!,
|
||||
@@ -176,25 +120,26 @@ export class IDBClient implements DBClientInterface {
|
||||
sessionUpdate: SessionRow;
|
||||
sessionRow?: StoredSessionRow;
|
||||
}): Promise<number> {
|
||||
return this.makeRequest<number>(({ sessions }) =>
|
||||
sessions.put(
|
||||
sessionRow?.rowID
|
||||
? {
|
||||
rowID: sessionRow.rowID,
|
||||
...sessionUpdate,
|
||||
}
|
||||
: sessionUpdate,
|
||||
),
|
||||
return this.makeRequest<number>(
|
||||
(tx) =>
|
||||
tx.getObjectStore("sessions").put(
|
||||
sessionRow?.rowID
|
||||
? {
|
||||
rowID: sessionRow.rowID,
|
||||
...sessionUpdate,
|
||||
}
|
||||
: sessionUpdate,
|
||||
) as IDBRequest<number>,
|
||||
);
|
||||
}
|
||||
|
||||
addTransaction(
|
||||
async addTransaction(
|
||||
sessionRowID: number,
|
||||
idx: number,
|
||||
newTransaction: CojsonInternalTypes.Transaction,
|
||||
) {
|
||||
return this.makeRequest(({ transactions }) =>
|
||||
transactions.add({
|
||||
await this.makeRequest((tx) =>
|
||||
tx.getObjectStore("transactions").add({
|
||||
ses: sessionRowID,
|
||||
idx,
|
||||
tx: newTransaction,
|
||||
@@ -211,8 +156,8 @@ export class IDBClient implements DBClientInterface {
|
||||
idx: number;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
}) {
|
||||
return this.makeRequest(({ signatureAfter }) =>
|
||||
signatureAfter.put({
|
||||
return this.makeRequest((tx) =>
|
||||
tx.getObjectStore("signatureAfter").put({
|
||||
ses: sessionRowID,
|
||||
idx,
|
||||
signature,
|
||||
@@ -220,7 +165,24 @@ export class IDBClient implements DBClientInterface {
|
||||
);
|
||||
}
|
||||
|
||||
async unitOfWork(operationsCallback: () => unknown[]) {
|
||||
return Promise.all(operationsCallback());
|
||||
closeTransaction(tx: CoJsonIDBTransaction) {
|
||||
tx.commit();
|
||||
|
||||
if (tx === this.activeTransaction) {
|
||||
this.activeTransaction = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async transaction(operationsCallback: () => unknown) {
|
||||
const tx = new CoJsonIDBTransaction(this.db);
|
||||
|
||||
this.activeTransaction = tx;
|
||||
|
||||
try {
|
||||
await operationsCallback();
|
||||
tx.commit(); // Tells the browser to not wait for another possible request and commit the transaction immediately
|
||||
} finally {
|
||||
this.activeTransaction = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,18 +33,7 @@ export class IDBNode {
|
||||
}
|
||||
await this.syncManager.handleSyncMessage(msg);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
new Error(
|
||||
`Error reading from localNode, handling msg\n\n${JSON.stringify(
|
||||
msg,
|
||||
(k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? `${v.slice(0, 20)}...`
|
||||
: v,
|
||||
)}`,
|
||||
{ cause: e },
|
||||
),
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
const isFunction = (func: any) => typeof func === "function";
|
||||
|
||||
const isObject = (supposedObject: any) =>
|
||||
typeof supposedObject === "object" &&
|
||||
supposedObject !== null &&
|
||||
!Array.isArray(supposedObject);
|
||||
|
||||
const isThenable = (obj: any) => isObject(obj) && isFunction(obj.then);
|
||||
|
||||
const identity = (co: any) => co;
|
||||
|
||||
export { identity, isFunction, isObject, isThenable };
|
||||
|
||||
enum States {
|
||||
PENDING = "PENDING",
|
||||
RESOLVED = "RESOLVED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
interface Handler<T, U> {
|
||||
onSuccess: HandlerOnSuccess<T, U>;
|
||||
onFail: HandlerOnFail<U>;
|
||||
}
|
||||
|
||||
type HandlerOnSuccess<T, U = any> = (value: T) => U | Thenable<U>;
|
||||
type HandlerOnFail<U = any> = (reason: any) => U | Thenable<U>;
|
||||
type Finally<U> = () => U | Thenable<U>;
|
||||
|
||||
interface Thenable<T> {
|
||||
then<U>(
|
||||
onSuccess?: HandlerOnSuccess<T, U>,
|
||||
onFail?: HandlerOnFail<U>,
|
||||
): Thenable<U>;
|
||||
then<U>(
|
||||
onSuccess?: HandlerOnSuccess<T, U>,
|
||||
onFail?: (reason: any) => void,
|
||||
): Thenable<U>;
|
||||
}
|
||||
|
||||
type Resolve<R> = (value?: R | Thenable<R>) => void;
|
||||
type Reject = (value?: any) => void;
|
||||
|
||||
export class SyncPromise<T> {
|
||||
private state: States = States.PENDING;
|
||||
private handlers: Handler<T, any>[] = [];
|
||||
private value: T | any;
|
||||
|
||||
public constructor(callback: (resolve: Resolve<T>, reject: Reject) => void) {
|
||||
try {
|
||||
callback(this.resolve as Resolve<T>, this.reject);
|
||||
} catch (e) {
|
||||
this.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
private resolve = (value: T) => {
|
||||
return this.setResult(value, States.RESOLVED);
|
||||
};
|
||||
|
||||
private reject = (reason: any) => {
|
||||
return this.setResult(reason, States.REJECTED);
|
||||
};
|
||||
|
||||
private setResult = (value: T | any, state: States) => {
|
||||
const set = () => {
|
||||
if (this.state !== States.PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isThenable(value)) {
|
||||
return (value as Thenable<T>).then(this.resolve, this.reject);
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
this.state = state;
|
||||
|
||||
return this.executeHandlers();
|
||||
};
|
||||
|
||||
void set();
|
||||
};
|
||||
|
||||
private executeHandlers = () => {
|
||||
if (this.state === States.PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const handler of this.handlers) {
|
||||
if (this.state === States.REJECTED) {
|
||||
handler.onFail(this.value);
|
||||
} else {
|
||||
handler.onSuccess(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
this.handlers = [];
|
||||
};
|
||||
|
||||
private attachHandler = (handler: Handler<T, any>) => {
|
||||
this.handlers = [...this.handlers, handler];
|
||||
|
||||
this.executeHandlers();
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noThenProperty: TODO(JAZZ-561): Review
|
||||
public then<U>(onSuccess: HandlerOnSuccess<T, U>, onFail?: HandlerOnFail<U>) {
|
||||
return new SyncPromise<U>((resolve, reject) => {
|
||||
return this.attachHandler({
|
||||
onSuccess: (result) => {
|
||||
try {
|
||||
return resolve(onSuccess(result));
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
},
|
||||
onFail: (reason) => {
|
||||
if (!onFail) {
|
||||
return reject(reason);
|
||||
}
|
||||
|
||||
try {
|
||||
return resolve(onFail(reason));
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public catch<U>(onFail: HandlerOnFail<U>) {
|
||||
return this.then<U>(identity, onFail);
|
||||
}
|
||||
|
||||
// methods
|
||||
|
||||
public toString() {
|
||||
return "[object SyncPromise]";
|
||||
}
|
||||
|
||||
public finally<U>(cb: Finally<U>) {
|
||||
return new SyncPromise<U>((resolve, reject) => {
|
||||
let co: U | any;
|
||||
let isRejected: boolean;
|
||||
|
||||
return this.then(
|
||||
(value) => {
|
||||
isRejected = false;
|
||||
co = value;
|
||||
return cb();
|
||||
},
|
||||
(reason) => {
|
||||
isRejected = true;
|
||||
co = reason;
|
||||
return cb();
|
||||
},
|
||||
).then(() => {
|
||||
if (isRejected) {
|
||||
return reject(co);
|
||||
}
|
||||
|
||||
return resolve(co);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public spread<U>(handler: (...args: any[]) => U) {
|
||||
return this.then<U>((collection) => {
|
||||
if (Array.isArray(collection)) {
|
||||
return handler(...collection);
|
||||
}
|
||||
|
||||
return handler(collection);
|
||||
});
|
||||
}
|
||||
|
||||
// static
|
||||
|
||||
public static resolve<U = any>(value?: U | Thenable<U>) {
|
||||
return new SyncPromise<U>((resolve) => {
|
||||
return resolve(value);
|
||||
});
|
||||
}
|
||||
|
||||
public static reject<U>(reason?: any) {
|
||||
return new SyncPromise<U>((_resolve, reject) => {
|
||||
return reject(reason);
|
||||
});
|
||||
}
|
||||
|
||||
public static all<U = any>(collection: (U | Thenable<U>)[]) {
|
||||
return new SyncPromise<U[]>((resolve, reject) => {
|
||||
if (!Array.isArray(collection)) {
|
||||
return reject(new TypeError("An array must be provided."));
|
||||
}
|
||||
|
||||
if (collection.length === 0) {
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
let counter = collection.length;
|
||||
const resolvedCollection: U[] = [];
|
||||
|
||||
const tryResolve = (value: U, index: number) => {
|
||||
counter -= 1;
|
||||
resolvedCollection[index] = value;
|
||||
|
||||
if (counter !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolve(resolvedCollection);
|
||||
};
|
||||
|
||||
return collection.forEach((item, index) => {
|
||||
return SyncPromise.resolve(item)
|
||||
.then((value) => {
|
||||
return tryResolve(value, index);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { CoJsonIDBTransaction } from "../CoJsonIDBTransaction";
|
||||
|
||||
const TEST_DB_NAME = "test-cojson-idb-transaction";
|
||||
|
||||
describe("CoJsonIDBTransaction", () => {
|
||||
let db: IDBDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test database
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(TEST_DB_NAME, 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
// Create test stores
|
||||
db.createObjectStore("coValues", { keyPath: "id" });
|
||||
const sessions = db.createObjectStore("sessions", { keyPath: "id" });
|
||||
sessions.createIndex("uniqueSessions", ["coValue", "sessionID"], {
|
||||
unique: true,
|
||||
});
|
||||
db.createObjectStore("transactions", { keyPath: "id" });
|
||||
db.createObjectStore("signatureAfter", { keyPath: "id" });
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Close and delete test database
|
||||
db.close();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(TEST_DB_NAME);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles successful write and read operations", async () => {
|
||||
const tx = new CoJsonIDBTransaction(db);
|
||||
|
||||
// Write test
|
||||
await tx.handleRequest((tx) =>
|
||||
tx.getObjectStore("coValues").put({
|
||||
id: "test1",
|
||||
value: "hello",
|
||||
}),
|
||||
);
|
||||
|
||||
// Read test
|
||||
const readTx = new CoJsonIDBTransaction(db);
|
||||
const result = await readTx.handleRequest((tx) =>
|
||||
tx.getObjectStore("coValues").get("test1"),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "test1",
|
||||
value: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles multiple operations in single transaction", async () => {
|
||||
const tx = new CoJsonIDBTransaction(db);
|
||||
|
||||
// Multiple writes
|
||||
await Promise.all([
|
||||
tx.handleRequest((tx) =>
|
||||
tx.getObjectStore("coValues").put({
|
||||
id: "test1",
|
||||
value: "hello",
|
||||
}),
|
||||
),
|
||||
tx.handleRequest((tx) =>
|
||||
tx.getObjectStore("coValues").put({
|
||||
id: "test2",
|
||||
value: "world",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// Read results
|
||||
const readTx = new CoJsonIDBTransaction(db);
|
||||
const [result1, result2] = await Promise.all([
|
||||
readTx.handleRequest((tx) => tx.getObjectStore("coValues").get("test1")),
|
||||
readTx.handleRequest((tx) => tx.getObjectStore("coValues").get("test2")),
|
||||
]);
|
||||
|
||||
expect(result1).toEqual({
|
||||
id: "test1",
|
||||
value: "hello",
|
||||
});
|
||||
expect(result2).toEqual({
|
||||
id: "test2",
|
||||
value: "world",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles transaction across multiple stores", async () => {
|
||||
const tx = new CoJsonIDBTransaction(db);
|
||||
|
||||
await Promise.all([
|
||||
tx.handleRequest((tx) =>
|
||||
tx.getObjectStore("coValues").put({
|
||||
id: "value1",
|
||||
data: "value data",
|
||||
}),
|
||||
),
|
||||
tx.handleRequest((tx) =>
|
||||
tx.getObjectStore("sessions").put({
|
||||
id: "session1",
|
||||
data: "session data",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const readTx = new CoJsonIDBTransaction(db);
|
||||
const [valueResult, sessionResult] = await Promise.all([
|
||||
readTx.handleRequest((tx) => tx.getObjectStore("coValues").get("value1")),
|
||||
readTx.handleRequest((tx) =>
|
||||
tx.getObjectStore("sessions").get("session1"),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(valueResult).toEqual({
|
||||
id: "value1",
|
||||
data: "value data",
|
||||
});
|
||||
expect(sessionResult).toEqual({
|
||||
id: "session1",
|
||||
data: "session data",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles failed transactions", async () => {
|
||||
const tx = new CoJsonIDBTransaction(db);
|
||||
|
||||
await expect(
|
||||
tx.handleRequest((tx) =>
|
||||
tx.getObjectStore("sessions").put({
|
||||
id: 1,
|
||||
coValue: "value1",
|
||||
sessionID: "session1",
|
||||
data: "session data",
|
||||
}),
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(tx.failed).toBe(false);
|
||||
|
||||
const badTx = new CoJsonIDBTransaction(db);
|
||||
await expect(
|
||||
badTx.handleRequest((tx) =>
|
||||
tx.getObjectStore("sessions").put({
|
||||
id: 2,
|
||||
coValue: "value1",
|
||||
sessionID: "session1",
|
||||
data: "session data",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(badTx.failed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { type DB as DatabaseT } from "@op-engineering/op-sqlite";
|
||||
import { CojsonInternalTypes, type OutgoingSyncQueue, RawCoID } from "cojson";
|
||||
import {
|
||||
CojsonInternalTypes,
|
||||
type OutgoingSyncQueue,
|
||||
RawCoID,
|
||||
SessionID,
|
||||
} from "cojson";
|
||||
import type {
|
||||
DBClientInterface,
|
||||
SessionRow,
|
||||
@@ -48,6 +53,17 @@ export class SQLiteClient implements DBClientInterface {
|
||||
return rows as StoredSessionRow[];
|
||||
}
|
||||
|
||||
async getSingleCoValueSession(
|
||||
coValueRowId: number,
|
||||
sessionID: SessionID,
|
||||
): Promise<StoredSessionRow | undefined> {
|
||||
const { rows } = await this.db.execute(
|
||||
"SELECT * FROM sessions WHERE coValue = ? AND sessionID = ?",
|
||||
[coValueRowId, sessionID],
|
||||
);
|
||||
return rows[0] as StoredSessionRow | undefined;
|
||||
}
|
||||
|
||||
async getNewTransactionInSession(
|
||||
sessionRowId: number,
|
||||
firstNewTxIdx: number,
|
||||
@@ -142,12 +158,10 @@ export class SQLiteClient implements DBClientInterface {
|
||||
);
|
||||
}
|
||||
|
||||
async unitOfWork(
|
||||
operationsCallback: () => Promise<unknown>[],
|
||||
): Promise<void> {
|
||||
async transaction(operationsCallback: () => unknown) {
|
||||
try {
|
||||
await this.db.transaction(async () => {
|
||||
await Promise.all(operationsCallback());
|
||||
await operationsCallback();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Transaction failed:", e);
|
||||
|
||||
@@ -71,6 +71,17 @@ export class SQLiteClient implements DBClientInterface {
|
||||
.all(coValueRowId) as StoredSessionRow[];
|
||||
}
|
||||
|
||||
getSingleCoValueSession(
|
||||
coValueRowId: number,
|
||||
sessionID: SessionID,
|
||||
): StoredSessionRow | undefined {
|
||||
return this.db
|
||||
.prepare<[number, string]>(
|
||||
`SELECT * FROM sessions WHERE coValue = ? AND sessionID = ?`,
|
||||
)
|
||||
.get(coValueRowId, sessionID) as StoredSessionRow | undefined;
|
||||
}
|
||||
|
||||
getNewTransactionInSession(
|
||||
sessionRowId: number,
|
||||
firstNewTxIdx: number,
|
||||
@@ -159,7 +170,7 @@ export class SQLiteClient implements DBClientInterface {
|
||||
.run(sessionRowID, idx, signature);
|
||||
}
|
||||
|
||||
unitOfWork(operationsCallback: () => any[]) {
|
||||
transaction(operationsCallback: () => unknown) {
|
||||
this.db.transaction(operationsCallback)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,15 +200,6 @@ export class SyncManager {
|
||||
? coValueRow.rowID
|
||||
: await this.dbClient.addCoValue(msg);
|
||||
|
||||
const allOurSessionsEntries =
|
||||
await this.dbClient.getCoValueSessions(storedCoValueRowID);
|
||||
|
||||
const allOurSessions: {
|
||||
[sessionID: SessionID]: StoredSessionRow;
|
||||
} = Object.fromEntries(
|
||||
allOurSessionsEntries.map((row) => [row.sessionID, row]),
|
||||
);
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: msg.id,
|
||||
header: true,
|
||||
@@ -217,9 +208,13 @@ export class SyncManager {
|
||||
|
||||
let invalidAssumptions = false;
|
||||
|
||||
await this.dbClient.unitOfWork(() =>
|
||||
(Object.keys(msg.new) as SessionID[]).map((sessionID) => {
|
||||
const sessionRow = allOurSessions[sessionID];
|
||||
for (const sessionID of Object.keys(msg.new) as SessionID[]) {
|
||||
await this.dbClient.transaction(async () => {
|
||||
const sessionRow = await this.dbClient.getSingleCoValueSession(
|
||||
storedCoValueRowID,
|
||||
sessionID,
|
||||
);
|
||||
|
||||
if (sessionRow) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
}
|
||||
@@ -229,8 +224,8 @@ export class SyncManager {
|
||||
} else {
|
||||
return this.putNewTxs(msg, sessionID, sessionRow, storedCoValueRowID);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (invalidAssumptions) {
|
||||
this.sendStateMessage({
|
||||
|
||||
@@ -45,9 +45,11 @@ describe("DB sync manager", () => {
|
||||
const DBClient = vi.fn();
|
||||
DBClient.prototype.getCoValue = vi.fn();
|
||||
DBClient.prototype.getCoValueSessions = vi.fn();
|
||||
DBClient.prototype.getSingleCoValueSession = vi.fn();
|
||||
DBClient.prototype.getNewTransactionInSession = vi.fn();
|
||||
DBClient.prototype.addSessionUpdate = vi.fn();
|
||||
DBClient.prototype.addTransaction = vi.fn();
|
||||
DBClient.prototype.unitOfWork = vi.fn((callback) => Promise.all(callback()));
|
||||
DBClient.prototype.transaction = vi.fn((callback) => callback());
|
||||
|
||||
beforeEach(async () => {
|
||||
const idbClient = new DBClient() as unknown as Mocked<DBClientInterface>;
|
||||
|
||||
@@ -41,6 +41,11 @@ export interface DBClientInterface {
|
||||
coValueRowId: number,
|
||||
): Promise<StoredSessionRow[]> | StoredSessionRow[];
|
||||
|
||||
getSingleCoValueSession(
|
||||
coValueRowId: number,
|
||||
sessionID: SessionID,
|
||||
): Promise<StoredSessionRow | undefined> | StoredSessionRow | undefined;
|
||||
|
||||
getNewTransactionInSession(
|
||||
sessionRowId: number,
|
||||
firstNewTxIdx: number,
|
||||
@@ -79,5 +84,5 @@ export interface DBClientInterface {
|
||||
signature: Signature;
|
||||
}): Promise<number> | void | unknown;
|
||||
|
||||
unitOfWork(operationsCallback: () => unknown[]): Promise<unknown> | void;
|
||||
transaction(callback: () => unknown): Promise<unknown> | void;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,12 @@
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hazae41/berith": "^1.2.6",
|
||||
"@noble/ciphers": "^0.1.3",
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@opentelemetry/api": "^1.0.0",
|
||||
"@scure/base": "1.2.1",
|
||||
"hash-wasm": "^4.9.0",
|
||||
"jazz-crypto-rs": "0.0.3",
|
||||
"neverthrow": "^7.0.1",
|
||||
"queueueue": "^4.1.2"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { xsalsa20, xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { base58 } from "@scure/base";
|
||||
@@ -24,21 +23,24 @@ import {
|
||||
|
||||
type Blake3State = ReturnType<typeof blake3.create>;
|
||||
|
||||
/**
|
||||
* Pure JavaScript implementation of the CryptoProvider interface using noble-curves and noble-ciphers libraries.
|
||||
* This provides a fallback implementation that doesn't require WebAssembly, offering:
|
||||
* - Signing/verifying (Ed25519)
|
||||
* - Encryption/decryption (XSalsa20)
|
||||
* - Sealing/unsealing (X25519 + XSalsa20-Poly1305)
|
||||
* - Hashing (BLAKE3)
|
||||
*/
|
||||
export class PureJSCrypto extends CryptoProvider<Blake3State> {
|
||||
static async create(): Promise<PureJSCrypto> {
|
||||
return new PureJSCrypto();
|
||||
}
|
||||
|
||||
randomBytes(length: number): Uint8Array {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
emptyBlake3State(): Blake3State {
|
||||
return blake3.create({});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
cloneBlake3State(state: any): Blake3State {
|
||||
cloneBlake3State(state: Blake3State): Blake3State {
|
||||
return state.clone();
|
||||
}
|
||||
|
||||
@@ -61,6 +63,14 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
|
||||
return state.clone().digest();
|
||||
}
|
||||
|
||||
generateNonce(input: Uint8Array): Uint8Array {
|
||||
return this.blake3HashOnce(input).slice(0, 24);
|
||||
}
|
||||
|
||||
private generateJsonNonce(material: JsonValue): Uint8Array {
|
||||
return this.generateNonce(textEncoder.encode(stableStringify(material)));
|
||||
}
|
||||
|
||||
newEd25519SigningKey(): Uint8Array {
|
||||
return ed25519.utils.randomPrivateKey();
|
||||
}
|
||||
@@ -109,9 +119,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length),
|
||||
);
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
const nOnce = this.generateJsonNonce(nOnceMaterial);
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(value));
|
||||
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
|
||||
@@ -126,9 +134,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length),
|
||||
);
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
const nOnce = this.generateJsonNonce(nOnceMaterial);
|
||||
|
||||
const ciphertext = base64URLtoBytes(
|
||||
encrypted.substring("encrypted_U".length),
|
||||
@@ -149,9 +155,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
|
||||
to: SealerID;
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID };
|
||||
}): Sealed<T> {
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
const nOnce = this.generateJsonNonce(nOnceMaterial);
|
||||
|
||||
const sealerPub = base58.decode(to.substring("sealer_z".length));
|
||||
|
||||
@@ -174,9 +178,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
|
||||
from: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID },
|
||||
): T | undefined {
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
const nOnce = this.generateJsonNonce(nOnceMaterial);
|
||||
|
||||
const sealerPriv = base58.decode(sealer.substring("sealerSecret_z".length));
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import {
|
||||
Ed25519Signature,
|
||||
Ed25519SigningKey,
|
||||
Ed25519VerifyingKey,
|
||||
Memory,
|
||||
X25519PublicKey,
|
||||
X25519StaticSecret,
|
||||
initBundledOnce,
|
||||
} from "@hazae41/berith";
|
||||
import { xsalsa20, xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
import { base58 } from "@scure/base";
|
||||
import { createBLAKE3 } from "hash-wasm";
|
||||
Blake3Hasher,
|
||||
blake3_empty_state,
|
||||
blake3_hash_once,
|
||||
blake3_hash_once_with_context,
|
||||
decrypt,
|
||||
encrypt,
|
||||
get_sealer_id,
|
||||
get_signer_id,
|
||||
new_ed25519_signing_key,
|
||||
new_x25519_private_key,
|
||||
seal,
|
||||
sign,
|
||||
unseal,
|
||||
verify,
|
||||
} from "jazz-crypto-rs";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
|
||||
import { RawCoID, TransactionID } from "../ids.js";
|
||||
import { Stringified, stableStringify } from "../jsonStringify.js";
|
||||
@@ -30,103 +33,82 @@ import {
|
||||
textEncoder,
|
||||
} from "./crypto.js";
|
||||
|
||||
export class WasmCrypto extends CryptoProvider<Uint8Array> {
|
||||
private constructor(
|
||||
public blake3Instance: Awaited<ReturnType<typeof createBLAKE3>>,
|
||||
) {
|
||||
type Blake3State = Blake3Hasher;
|
||||
|
||||
/**
|
||||
* WebAssembly implementation of the CryptoProvider interface using jazz-crypto-rs.
|
||||
* This provides the primary implementation using WebAssembly for optimal performance, offering:
|
||||
* - Signing/verifying (Ed25519)
|
||||
* - Encryption/decryption (XSalsa20)
|
||||
* - Sealing/unsealing (X25519 + XSalsa20-Poly1305)
|
||||
* - Hashing (BLAKE3)
|
||||
*/
|
||||
export class WasmCrypto extends CryptoProvider<Blake3State> {
|
||||
private constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
static async create(): Promise<WasmCrypto> {
|
||||
return Promise.all([createBLAKE3(), initBundledOnce()]).then(
|
||||
([blake3instance]) => new WasmCrypto(blake3instance),
|
||||
);
|
||||
return new WasmCrypto();
|
||||
}
|
||||
|
||||
randomBytes(length: number): Uint8Array {
|
||||
return randomBytes(length);
|
||||
emptyBlake3State(): Blake3State {
|
||||
return blake3_empty_state();
|
||||
}
|
||||
|
||||
emptyBlake3State(): Uint8Array {
|
||||
return this.blake3Instance.init().save();
|
||||
}
|
||||
|
||||
cloneBlake3State(state: Uint8Array): Uint8Array {
|
||||
return this.blake3Instance.load(state).save();
|
||||
cloneBlake3State(state: Blake3State): Blake3State {
|
||||
return state.clone();
|
||||
}
|
||||
|
||||
blake3HashOnce(data: Uint8Array) {
|
||||
return this.blake3Instance.init().update(data).digest("binary");
|
||||
return blake3_hash_once(data);
|
||||
}
|
||||
|
||||
blake3HashOnceWithContext(
|
||||
data: Uint8Array,
|
||||
{ context }: { context: Uint8Array },
|
||||
) {
|
||||
return this.blake3Instance
|
||||
.init()
|
||||
.update(context)
|
||||
.update(data)
|
||||
.digest("binary");
|
||||
return blake3_hash_once_with_context(data, context);
|
||||
}
|
||||
|
||||
blake3IncrementalUpdate(state: Uint8Array, data: Uint8Array): Uint8Array {
|
||||
return this.blake3Instance.load(state).update(data).save();
|
||||
blake3IncrementalUpdate(state: Blake3State, data: Uint8Array): Blake3State {
|
||||
state.update(data);
|
||||
return state;
|
||||
}
|
||||
|
||||
blake3DigestForState(state: Uint8Array): Uint8Array {
|
||||
return this.blake3Instance.load(state).digest("binary");
|
||||
blake3DigestForState(state: Blake3State): Uint8Array {
|
||||
return state.finalize();
|
||||
}
|
||||
|
||||
newEd25519SigningKey(): Uint8Array {
|
||||
return new Ed25519SigningKey().to_bytes().copyAndDispose();
|
||||
return new_ed25519_signing_key();
|
||||
}
|
||||
|
||||
getSignerID(secret: SignerSecret): SignerID {
|
||||
return `signer_z${base58.encode(
|
||||
Ed25519SigningKey.from_bytes(
|
||||
new Memory(base58.decode(secret.substring("signerSecret_z".length))),
|
||||
)
|
||||
.public()
|
||||
.to_bytes()
|
||||
.copyAndDispose(),
|
||||
)}`;
|
||||
return get_signer_id(textEncoder.encode(secret)) as SignerID;
|
||||
}
|
||||
|
||||
sign(secret: SignerSecret, message: JsonValue): Signature {
|
||||
const signature = Ed25519SigningKey.from_bytes(
|
||||
new Memory(base58.decode(secret.substring("signerSecret_z".length))),
|
||||
)
|
||||
.sign(new Memory(textEncoder.encode(stableStringify(message))))
|
||||
.to_bytes()
|
||||
.copyAndDispose();
|
||||
return `signature_z${base58.encode(signature)}`;
|
||||
return sign(
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
textEncoder.encode(secret),
|
||||
) as Signature;
|
||||
}
|
||||
|
||||
verify(signature: Signature, message: JsonValue, id: SignerID): boolean {
|
||||
return new Ed25519VerifyingKey(
|
||||
new Memory(base58.decode(id.substring("signer_z".length))),
|
||||
).verify(
|
||||
new Memory(textEncoder.encode(stableStringify(message))),
|
||||
new Ed25519Signature(
|
||||
new Memory(base58.decode(signature.substring("signature_z".length))),
|
||||
),
|
||||
return verify(
|
||||
textEncoder.encode(signature),
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
textEncoder.encode(id),
|
||||
);
|
||||
}
|
||||
|
||||
newX25519StaticSecret(): Uint8Array {
|
||||
return new X25519StaticSecret().to_bytes().copyAndDispose();
|
||||
return new_x25519_private_key();
|
||||
}
|
||||
|
||||
getSealerID(secret: SealerSecret): SealerID {
|
||||
return `sealer_z${base58.encode(
|
||||
X25519StaticSecret.from_bytes(
|
||||
new Memory(base58.decode(secret.substring("sealerSecret_z".length))),
|
||||
)
|
||||
.to_public()
|
||||
.to_bytes()
|
||||
.copyAndDispose(),
|
||||
)}`;
|
||||
return get_sealer_id(textEncoder.encode(secret)) as SealerID;
|
||||
}
|
||||
|
||||
encrypt<T extends JsonValue, N extends JsonValue>(
|
||||
@@ -134,16 +116,13 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: N,
|
||||
): Encrypted<T, N> {
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length),
|
||||
);
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(value));
|
||||
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
|
||||
return `encrypted_U${bytesToBase64url(ciphertext)}` as Encrypted<T, N>;
|
||||
return `encrypted_U${bytesToBase64url(
|
||||
encrypt(
|
||||
textEncoder.encode(stableStringify(value)),
|
||||
keySecret,
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
),
|
||||
)}` as Encrypted<T, N>;
|
||||
}
|
||||
|
||||
decryptRaw<T extends JsonValue, N extends JsonValue>(
|
||||
@@ -151,19 +130,13 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: N,
|
||||
): Stringified<T> {
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length),
|
||||
);
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
|
||||
const ciphertext = base64URLtoBytes(
|
||||
encrypted.substring("encrypted_U".length),
|
||||
);
|
||||
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
|
||||
|
||||
return textDecoder.decode(plaintext) as Stringified<T>;
|
||||
return textDecoder.decode(
|
||||
decrypt(
|
||||
base64URLtoBytes(encrypted.substring("encrypted_U".length)),
|
||||
keySecret,
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
),
|
||||
) as Stringified<T>;
|
||||
}
|
||||
|
||||
seal<T extends JsonValue>({
|
||||
@@ -177,26 +150,14 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
|
||||
to: SealerID;
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID };
|
||||
}): Sealed<T> {
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
|
||||
const sealerPub = base58.decode(to.substring("sealer_z".length));
|
||||
|
||||
const senderPriv = base58.decode(from.substring("sealerSecret_z".length));
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(message));
|
||||
|
||||
const sharedSecret = X25519StaticSecret.from_bytes(new Memory(senderPriv))
|
||||
.diffie_hellman(X25519PublicKey.from_bytes(new Memory(sealerPub)))
|
||||
.to_bytes()
|
||||
.copyAndDispose();
|
||||
|
||||
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
|
||||
plaintext,
|
||||
);
|
||||
|
||||
return `sealed_U${bytesToBase64url(sealedBytes)}` as Sealed<T>;
|
||||
return `sealed_U${bytesToBase64url(
|
||||
seal(
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
from,
|
||||
to,
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
),
|
||||
)}` as Sealed<T>;
|
||||
}
|
||||
|
||||
unseal<T extends JsonValue>(
|
||||
@@ -205,31 +166,21 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
|
||||
from: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID },
|
||||
): T | undefined {
|
||||
const nOnce = this.blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
|
||||
const sealerPriv = base58.decode(sealer.substring("sealerSecret_z".length));
|
||||
|
||||
const senderPub = base58.decode(from.substring("sealer_z".length));
|
||||
|
||||
const sealedBytes = base64URLtoBytes(sealed.substring("sealed_U".length));
|
||||
|
||||
const sharedSecret = X25519StaticSecret.from_bytes(new Memory(sealerPriv))
|
||||
.diffie_hellman(X25519PublicKey.from_bytes(new Memory(senderPub)))
|
||||
.to_bytes()
|
||||
.copyAndDispose();
|
||||
|
||||
const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(
|
||||
sealedBytes,
|
||||
const plaintext = textDecoder.decode(
|
||||
unseal(
|
||||
base64URLtoBytes(sealed.substring("sealed_U".length)),
|
||||
sealer,
|
||||
from,
|
||||
textEncoder.encode(stableStringify(nOnceMaterial)),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(plaintext));
|
||||
return JSON.parse(plaintext) as T;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
"Failed to decrypt/parse sealed message: " + (e as Error)?.message,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
import { base58 } from "@scure/base";
|
||||
import { RawAccountID } from "../coValues/account.js";
|
||||
import { AgentID, RawCoID, TransactionID } from "../ids.js";
|
||||
@@ -21,7 +22,9 @@ export const textDecoder = new TextDecoder();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export abstract class CryptoProvider<Blake3State = any> {
|
||||
abstract randomBytes(length: number): Uint8Array;
|
||||
randomBytes(length: number): Uint8Array {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
abstract newEd25519SigningKey(): Uint8Array;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { base58, base64url } from "@scure/base";
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { PureJSCrypto } from "../crypto/PureJSCrypto.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { SessionID } from "../ids.js";
|
||||
@@ -186,4 +186,46 @@ const pureJSCrypto = await PureJSCrypto.create();
|
||||
|
||||
expect(decrypted).toBeUndefined();
|
||||
});
|
||||
|
||||
test(`Unsealing malformed JSON logs error [${name}]`, () => {
|
||||
const data = "not valid json";
|
||||
const sender = crypto.newRandomSealer();
|
||||
const sealer = crypto.newRandomSealer();
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const nOnceMaterial = {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
} as const;
|
||||
|
||||
// Create a sealed message with invalid JSON
|
||||
const nOnce = blake3(
|
||||
new TextEncoder().encode(stableStringify(nOnceMaterial)),
|
||||
).slice(0, 24);
|
||||
|
||||
const senderPriv = base58.decode(sender.substring("sealerSecret_z".length));
|
||||
const sealerPub = base58.decode(
|
||||
crypto.getSealerID(sealer).substring("sealer_z".length),
|
||||
);
|
||||
|
||||
const plaintext = new TextEncoder().encode(data);
|
||||
const sharedSecret = x25519.getSharedSecret(senderPriv, sealerPub);
|
||||
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
|
||||
plaintext,
|
||||
);
|
||||
const sealed = `sealed_U${base64url.encode(sealedBytes)}`;
|
||||
|
||||
const result = crypto.unseal(
|
||||
sealed as any,
|
||||
sealer,
|
||||
crypto.getSealerID(sender),
|
||||
nOnceMaterial,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleSpy.mock.lastCall?.[0]).toContain(
|
||||
"Failed to decrypt/parse sealed message",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SessionID } from "../ids.js";
|
||||
describe.each([
|
||||
{ impl: await WasmCrypto.create(), name: "Wasm" },
|
||||
{ impl: await PureJSCrypto.create(), name: "PureJS" },
|
||||
])("Crypto $name", ({ impl }) => {
|
||||
])("$name implementation", ({ impl, name }) => {
|
||||
test("randomBytes", () => {
|
||||
expect(impl.randomBytes(32).length).toEqual(32);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "17 - 18"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --sourceMap --outDir dist",
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "./dist/app.js",
|
||||
"types": "./dist/app.d.ts",
|
||||
"files": [
|
||||
"dist/**",
|
||||
"src"
|
||||
],
|
||||
"files": ["dist/**", "src"],
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"jazz-tools": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"format-and-lint": "biome check .",
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --sourceMap --outDir dist",
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --sourceMap --outDir dist",
|
||||
|
||||
@@ -199,7 +199,6 @@ describe("ContextManager", () => {
|
||||
test("the migration should be applied correctly on existing accounts ", async () => {
|
||||
class AccountRoot extends CoMap {
|
||||
value = co.string;
|
||||
transferredRoot = co.optional.ref(AccountRoot);
|
||||
}
|
||||
|
||||
let lastRootId: string | undefined;
|
||||
@@ -236,6 +235,48 @@ describe("ContextManager", () => {
|
||||
expect(me.root.id).toBe(lastRootId);
|
||||
});
|
||||
|
||||
test("the migration should be applied correctly on existing accounts (2)", async () => {
|
||||
class AccountRoot extends CoMap {
|
||||
value = co.number;
|
||||
}
|
||||
|
||||
class CustomAccount extends Account {
|
||||
root = co.ref(AccountRoot);
|
||||
|
||||
async migrate(this: CustomAccount) {
|
||||
if (this.root === undefined) {
|
||||
this.root = AccountRoot.create({
|
||||
value: 1,
|
||||
});
|
||||
} else {
|
||||
const { root } = await this.ensureLoaded({ root: {} });
|
||||
|
||||
root.value = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
const customManager = new TestJazzContextManager<CustomAccount>();
|
||||
|
||||
// Create initial anonymous context
|
||||
await customManager.createContext({
|
||||
AccountSchema: CustomAccount,
|
||||
});
|
||||
|
||||
const account = (
|
||||
customManager.getCurrentValue() as JazzAuthContext<CustomAccount>
|
||||
).me;
|
||||
|
||||
await customManager.authenticate({
|
||||
accountID: account.id,
|
||||
accountSecret: account._raw.core.node.account.agentSecret,
|
||||
provider: "test",
|
||||
});
|
||||
|
||||
const me = await CustomAccount.getMe().ensureLoaded({ root: {} });
|
||||
|
||||
expect(me.root.value).toBe(2);
|
||||
});
|
||||
|
||||
test("onAnonymousAccountDiscarded should work on transfering data between accounts", async () => {
|
||||
class AccountRoot extends CoMap {
|
||||
value = co.string;
|
||||
|
||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@@ -1413,9 +1413,6 @@ importers:
|
||||
|
||||
packages/cojson:
|
||||
dependencies:
|
||||
'@hazae41/berith':
|
||||
specifier: ^1.2.6
|
||||
version: 1.2.6
|
||||
'@noble/ciphers':
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.4
|
||||
@@ -1431,9 +1428,9 @@ importers:
|
||||
'@scure/base':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
hash-wasm:
|
||||
specifier: ^4.9.0
|
||||
version: 4.12.0
|
||||
jazz-crypto-rs:
|
||||
specifier: 0.0.3
|
||||
version: 0.0.3
|
||||
neverthrow:
|
||||
specifier: ^7.0.1
|
||||
version: 7.2.0
|
||||
@@ -3637,18 +3634,6 @@ packages:
|
||||
'@formkit/auto-animate@0.8.2':
|
||||
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
|
||||
|
||||
'@hazae41/berith@1.2.6':
|
||||
resolution: {integrity: sha512-TQLkisoolGD2kdSQVtTVXNwmsotA9dLmUgXcpHTNzssmN11Mp+vHVf4Myn3+Q4U18Na3UzGi6DZqogZFqjPPvg==}
|
||||
|
||||
'@hazae41/box@1.0.14':
|
||||
resolution: {integrity: sha512-RevIMZBVRmp0BDkpBpL77sr3Rk7XdqzBZcw+UsfUT4JAly/sryM/NDzJa80sMyO6aERJkjHUQaxH5DYDznPhkw==}
|
||||
|
||||
'@hazae41/option@1.1.4':
|
||||
resolution: {integrity: sha512-ZGAVkOJJw2YIeihG8PaWs4R3KDloCgWGNDPqbATvT3kTtoyU+dJmya/UCM2P+ZwCfWYpE7nGiYJbBTlpp929Qg==}
|
||||
|
||||
'@hazae41/result@1.3.2':
|
||||
resolution: {integrity: sha512-X2RdjCC4DypMZFN3aZ7P5Zh8kEMN6SEqcMARRa/CraEU7Es/hj0yBh1Pt5eAYrRkpnWiE9+ohKSU4s7nL5L1gg==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -7475,9 +7460,6 @@ packages:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hash-wasm@4.12.0:
|
||||
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7876,6 +7858,9 @@ packages:
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jazz-crypto-rs@0.0.3:
|
||||
resolution: {integrity: sha512-b8yDOUQCRoOdH1KrIQsAIdhZqXcOgpCLGY93/NZEWqimFeGGKAQY7KRXNQtXndKN0jC1y/TH8a8Vxsf0T0+yqQ==}
|
||||
|
||||
jest-changed-files@29.7.0:
|
||||
resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -13440,22 +13425,6 @@ snapshots:
|
||||
|
||||
'@formkit/auto-animate@0.8.2': {}
|
||||
|
||||
'@hazae41/berith@1.2.6':
|
||||
dependencies:
|
||||
'@hazae41/box': 1.0.14
|
||||
|
||||
'@hazae41/box@1.0.14':
|
||||
dependencies:
|
||||
'@hazae41/result': 1.3.2
|
||||
|
||||
'@hazae41/option@1.1.4':
|
||||
dependencies:
|
||||
'@hazae41/result': 1.3.2
|
||||
|
||||
'@hazae41/result@1.3.2':
|
||||
dependencies:
|
||||
'@hazae41/option': 1.1.4
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.6':
|
||||
@@ -17834,8 +17803,6 @@ snapshots:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hash-wasm@4.12.0: {}
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -18208,6 +18175,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jazz-crypto-rs@0.0.3: {}
|
||||
|
||||
jest-changed-files@29.7.0:
|
||||
dependencies:
|
||||
execa: 5.1.1
|
||||
@@ -21777,16 +21746,15 @@ snapshots:
|
||||
dependencies:
|
||||
vite: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
|
||||
vite-node@3.0.5(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1):
|
||||
vite-node@3.0.5(@types/node@22.10.2)(lightningcss@1.29.1)(terser@5.37.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.0
|
||||
es-module-lexer: 1.6.0
|
||||
pathe: 2.0.2
|
||||
vite: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
vite: 5.4.11(@types/node@22.10.2)(lightningcss@1.29.1)(terser@5.37.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
@@ -21795,8 +21763,6 @@ snapshots:
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-plugin-dts@4.4.0(@types/node@22.10.2)(rollup@4.28.1)(typescript@5.6.3)(vite@6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)):
|
||||
dependencies:
|
||||
@@ -21922,7 +21888,7 @@ snapshots:
|
||||
tinypool: 1.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
vite-node: 3.0.5(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
vite-node: 3.0.5(@types/node@22.10.2)(lightningcss@1.29.1)(terser@5.37.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.10.2
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Link, RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import { AuthAndJazz } from "./jazz";
|
||||
import { ConcurrentChanges } from "./pages/ConcurrentChanges";
|
||||
import { FileStreamTest } from "./pages/FileStream";
|
||||
import { InboxPage } from "./pages/Inbox";
|
||||
import { ResumeSyncState } from "./pages/ResumeSyncState";
|
||||
@@ -31,6 +32,9 @@ function Index() {
|
||||
<li>
|
||||
<Link to="/write-only">Write Only</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/concurrent-changes">Concurrent Changes</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/inbox">Inbox</Link>
|
||||
</li>
|
||||
@@ -67,6 +71,10 @@ const router = createBrowserRouter([
|
||||
path: "/inbox",
|
||||
element: <InboxPage />,
|
||||
},
|
||||
{
|
||||
path: "/concurrent-changes",
|
||||
element: <ConcurrentChanges />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <Index />,
|
||||
|
||||
75
tests/e2e/src/pages/ConcurrentChanges.tsx
Normal file
75
tests/e2e/src/pages/ConcurrentChanges.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useAccount, useCoState } from "jazz-react";
|
||||
import { CoFeed, Group, ID, co } from "jazz-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export class Counter extends CoFeed.Of(co.json<{ value: number }>()) {}
|
||||
|
||||
function getIdParam() {
|
||||
const url = new URL(window.location.href);
|
||||
return (url.searchParams.get("id") as ID<Counter>) ?? undefined;
|
||||
}
|
||||
|
||||
export function ConcurrentChanges() {
|
||||
const [id, setId] = useState(getIdParam);
|
||||
const counter = useCoState(Counter, id, []);
|
||||
const { me } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("id", id);
|
||||
history.pushState({}, "", url.toString());
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (counter?.byMe) {
|
||||
count(counter);
|
||||
}
|
||||
}, [counter?.byMe?.value !== undefined]);
|
||||
|
||||
const createCounter = () => {
|
||||
if (!me) return;
|
||||
|
||||
const group = Group.create();
|
||||
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const id = Counter.create([{ value: 0 }], group).id;
|
||||
|
||||
setId(id);
|
||||
|
||||
window.open(`?id=${id}`, "_blank");
|
||||
};
|
||||
|
||||
const done = Object.entries(counter?.perSession ?? {}).every(
|
||||
([_, entry]) => entry.value.value === 300,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Concurrent Changes</h1>
|
||||
<p>
|
||||
{Object.entries(counter?.perSession ?? {}).map(([sessionId, entry]) => (
|
||||
<div key={sessionId}>
|
||||
<p>{sessionId}</p>
|
||||
<p data-testid="value">{entry.value.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</p>
|
||||
<button onClick={createCounter}>Create a new value!</button>
|
||||
{done && <p data-testid="done">Done!</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function count(counter: Counter) {
|
||||
if (!counter.byMe) return;
|
||||
|
||||
let value = counter.byMe.value?.value ?? 0;
|
||||
|
||||
while (value < 300) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
counter.push({ value: ++value });
|
||||
}
|
||||
}
|
||||
35
tests/e2e/tests/ConcurrentChanges.test.ts
Normal file
35
tests/e2e/tests/ConcurrentChanges.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Concurrent Changes", () => {
|
||||
test("should complete the task without incurring on InvalidSignature errors", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto("/concurrent-changes");
|
||||
const newPage = await context.newPage();
|
||||
|
||||
await page.getByRole("button", { name: "Create a new value!" }).click();
|
||||
|
||||
await newPage.goto(page.url());
|
||||
|
||||
await page.getByTestId("done").waitFor();
|
||||
|
||||
await newPage.close();
|
||||
|
||||
const errorLogs: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
errorLogs.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByTestId("done")).toBeVisible();
|
||||
|
||||
expect(
|
||||
errorLogs.find((log) => log.includes("InvalidSignature")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user