Compare commits
31 Commits
docs/llms
...
auth-state
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba81951331 | ||
|
|
af8e6e3f82 | ||
|
|
1136d9b744 | ||
|
|
92e78dc262 | ||
|
|
03897a2689 | ||
|
|
823f546028 | ||
|
|
9bc54d1939 | ||
|
|
30780c05f0 | ||
|
|
118b6294ac | ||
|
|
6dc9b9d2ec | ||
|
|
0ae2067c3c | ||
|
|
2137938ead | ||
|
|
d14bb57ff5 | ||
|
|
3f42a4ddf9 | ||
|
|
0eed228170 | ||
|
|
a519537701 | ||
|
|
43c79cac2a | ||
|
|
44dbaa00d4 | ||
|
|
a0df32e81a | ||
|
|
236d8226d8 | ||
|
|
1220fa5d97 | ||
|
|
3042627748 | ||
|
|
8cea1e96cf | ||
|
|
5cc58c8e02 | ||
|
|
9df644c578 | ||
|
|
1e625f3c12 | ||
|
|
8b3686c7ce | ||
|
|
bce04ee06d | ||
|
|
f2e9115f4c | ||
|
|
6854f9930c | ||
|
|
ee0897d9a8 |
12
.changeset/new-nails-knock.md
Normal file
12
.changeset/new-nails-knock.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"jazz-react-auth-clerk": patch
|
||||
"jazz-react-native": patch
|
||||
"jazz-auth-clerk": patch
|
||||
"jazz-react-core": patch
|
||||
"jazz-browser": patch
|
||||
"jazz-svelte": patch
|
||||
"jazz-react": patch
|
||||
"jazz-tools": patch
|
||||
---
|
||||
|
||||
Fixed isAuthenticated out-of-sync with the account state during the logOut and authenticate flows
|
||||
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
|
||||
9
.changeset/spotty-shoes-play.md
Normal file
9
.changeset/spotty-shoes-play.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"jazz-react-auth-clerk": patch
|
||||
"jazz-auth-clerk": patch
|
||||
"jazz-browser": patch
|
||||
"jazz-react": patch
|
||||
"jazz-tools": patch
|
||||
---
|
||||
|
||||
Fixes clerk auth flow
|
||||
@@ -44,4 +44,5 @@ appId: com.jazz.chatrn
|
||||
|
||||
# logout
|
||||
- tapOn: "Logout"
|
||||
- assertVisible: "Anonymous user"
|
||||
- assertVisible: "boorad"
|
||||
- assertVisible: "bro, low key, it do be like that tho"
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { ContentByFramework, CodeGroup } from '@/components/forMdx'
|
||||
import { ContentByFramework, FileDownloadLink, CodeGroup } from '@/components/forMdx'
|
||||
|
||||
# Using AI tools to build Jazz apps
|
||||
# Using AI to build Jazz apps
|
||||
|
||||
AI tools, particularly large language models (LLMs), can enhance the speed at which you build apps with Jazz.
|
||||
However, for these tools to work effectively and provide better results, they need access to the relevant documentation.
|
||||
AI tools, particularly large language models (LLMs), can accelerate your development with Jazz. Searching docs, responding to questions and even helping you write code are all things that LLMs are starting to get good at.
|
||||
|
||||
## LLM docs
|
||||
However, Jazz is a rapidly evolving framework, so sometimes AI might get things a little wrong.
|
||||
|
||||
To provide AI tools, like Cursor, with access to Jazz docs, we've compressed the docs into a single txt file that is
|
||||
more LLM-readable than a full website, which you can easily upload to your tool of choice.
|
||||
To help the LLMs, we provide the Jazz documentation in a txt file that is optimized for use with AI tools, like Cursor.
|
||||
|
||||
## Adding docs to ChatGPT and v0
|
||||
<FileDownloadLink href="/llms-full.txt">llms-full.txt</FileDownloadLink>
|
||||
|
||||
<a href="/llms-full.txt" download>Download llms-full.txt</a>
|
||||
## Setting up AI tools
|
||||
|
||||
Every tool is different, but generally, you'll need to either paste the contents of the [llms-full.txt](https://jazz.tools/llms-full.txt) file directly in your prompt, or attach the file to the tool.
|
||||
|
||||
### ChatGPT and v0
|
||||
|
||||
Upload the txt file in your prompt.
|
||||
|
||||

|
||||
|
||||
## Adding docs to Cursor
|
||||
### Cursor
|
||||
|
||||
1. Go to Settings > Cursor Settings > Features > Docs
|
||||
2. Click "Add new doc"
|
||||
@@ -30,3 +32,12 @@ https://jazz.tools/llms-full.txt
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## llms.txt convention
|
||||
|
||||
We follow the llms.txt [proposed standard](https://llmstxt.org/) for providing documentation to AI tools at inference time that helps them understand the context of the code you're writing.
|
||||
|
||||
## Limitations and considerations
|
||||
|
||||
AI is amazing, but it's not perfect. What works well this week could break next week (or be twice as good).
|
||||
|
||||
We're keen to keep up with changes in tooling to help support you building the best apps, but if you need help from humans (or you have issues getting set up), please let us know on [Discord](https://discord.gg/utDMjHYg42).
|
||||
|
||||
31
homepage/homepage/components/FileDownloadLink.tsx
Normal file
31
homepage/homepage/components/FileDownloadLink.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from "react";
|
||||
|
||||
import { Button } from "gcmp-design-system/src/app/components/atoms/Button";
|
||||
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
|
||||
|
||||
export function FileDownloadLink(
|
||||
props: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>,
|
||||
) {
|
||||
if (!props.href) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
const { children, href } = props;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center font-medium text-stone-900 rounded-md border p-3 shadow-sm dark:text-white dark:bg-stone-925 flex py-2 rounded-lg ">
|
||||
<Icon name="file" size="sm" className="mr-2" />
|
||||
{children}
|
||||
|
||||
<a href={href} download className="ml-12">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "@/components/docs/ContentByFramework";
|
||||
import { JazzLogo as JazzLogoClient } from "gcmp-design-system/src/app/components/atoms/logos/JazzLogo";
|
||||
import { CodeGroup as CodeGroupClient } from "gcmp-design-system/src/app/components/molecules/CodeGroup";
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from "react";
|
||||
import { FileDownloadLink as FileDownloadLinkClient } from "./FileDownloadLink";
|
||||
import { ComingSoon as ComingSoonClient } from "./docs/ComingSoon";
|
||||
import { IssueTrackerPreview as IssueTrackerPreviewClient } from "./docs/IssueTrackerPreview";
|
||||
|
||||
@@ -35,3 +37,12 @@ export function IssueTrackerPreview() {
|
||||
export function JazzLogo(props: { className?: string }) {
|
||||
return <JazzLogoClient {...props} />;
|
||||
}
|
||||
|
||||
export function FileDownloadLink(
|
||||
props: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>,
|
||||
) {
|
||||
return <FileDownloadLinkClient {...props} />;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ControlledAgent } from "../coValues/account.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import {
|
||||
connectTwoPeers,
|
||||
createTwoConnectedNodes,
|
||||
groupWithTwoAdmins,
|
||||
groupWithTwoAdminsHighLevel,
|
||||
@@ -2029,9 +2030,13 @@ test("Can give write permissions to 'everyone' (high-level)", async () => {
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
connectTwoPeers(group.core.node, childContent2.core.node, "server", "server");
|
||||
|
||||
// Ensure that the group is available to newAccount
|
||||
await group.core.waitForSync();
|
||||
|
||||
expect(childContent2.get("foo")).toEqual("bar");
|
||||
|
||||
console.log("Before anon set");
|
||||
childContent2.set("foo", "bar2", "private");
|
||||
expect(childContent2.get("foo")).toEqual("bar2");
|
||||
});
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { AgentSecret } from "cojson";
|
||||
import { AuthSecretStorage } from "jazz-tools";
|
||||
import {
|
||||
Account,
|
||||
AuthCredentials,
|
||||
AuthSecretStorage,
|
||||
AuthenticateAccountFunction,
|
||||
ID,
|
||||
} from "jazz-tools";
|
||||
import { getClerkUsername } from "./getClerkUsername.js";
|
||||
import { MinimalClerkClient } from "./types.js";
|
||||
|
||||
type ClerkCredentials = {
|
||||
jazzAccountID: ID<Account>;
|
||||
jazzAccountSecret: AgentSecret;
|
||||
jazzAccountSeed?: number[];
|
||||
};
|
||||
import {
|
||||
ClerkCredentials,
|
||||
MinimalClerkClient,
|
||||
isClerkCredentials,
|
||||
} from "./types.js";
|
||||
|
||||
export type { MinimalClerkClient };
|
||||
export { isClerkCredentials };
|
||||
|
||||
export class JazzClerkAuth {
|
||||
constructor(
|
||||
@@ -23,8 +20,27 @@ export class JazzClerkAuth {
|
||||
private authSecretStorage: AuthSecretStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Loads the Jazz auth data from the Clerk user and sets it in the auth secret storage.
|
||||
*/
|
||||
static loadClerkAuthData(
|
||||
credentials: ClerkCredentials,
|
||||
storage: AuthSecretStorage,
|
||||
) {
|
||||
return storage.set({
|
||||
accountID: credentials.jazzAccountID,
|
||||
accountSecret: credentials.jazzAccountSecret,
|
||||
secretSeed: credentials.jazzAccountSeed
|
||||
? Uint8Array.from(credentials.jazzAccountSeed)
|
||||
: undefined,
|
||||
provider: "clerk",
|
||||
});
|
||||
}
|
||||
|
||||
onClerkUserChange = async (clerkClient: Pick<MinimalClerkClient, "user">) => {
|
||||
if (!clerkClient.user) return;
|
||||
if (!clerkClient.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthenticated = this.authSecretStorage.isAuthenticated;
|
||||
|
||||
@@ -45,13 +61,8 @@ export class JazzClerkAuth {
|
||||
throw new Error("Not signed in on Clerk");
|
||||
}
|
||||
|
||||
const clerkCredentials = clerkClient.user
|
||||
.unsafeMetadata as ClerkCredentials;
|
||||
|
||||
if (
|
||||
!clerkCredentials.jazzAccountID ||
|
||||
!clerkCredentials.jazzAccountSecret
|
||||
) {
|
||||
const clerkCredentials = clerkClient.user.unsafeMetadata;
|
||||
if (!isClerkCredentials(clerkCredentials)) {
|
||||
throw new Error("No credentials found on Clerk");
|
||||
}
|
||||
|
||||
@@ -66,7 +77,14 @@ export class JazzClerkAuth {
|
||||
|
||||
await this.authenticate(credentials);
|
||||
|
||||
await this.authSecretStorage.set(credentials);
|
||||
await JazzClerkAuth.loadClerkAuthData(
|
||||
{
|
||||
jazzAccountID: credentials.accountID,
|
||||
jazzAccountSecret: credentials.accountSecret,
|
||||
jazzAccountSeed: clerkCredentials.jazzAccountSeed,
|
||||
},
|
||||
this.authSecretStorage,
|
||||
);
|
||||
};
|
||||
|
||||
signIn = async (clerkClient: Pick<MinimalClerkClient, "user">) => {
|
||||
@@ -76,13 +94,15 @@ export class JazzClerkAuth {
|
||||
throw new Error("No credentials found");
|
||||
}
|
||||
|
||||
const jazzAccountSeed = credentials.secretSeed
|
||||
? Array.from(credentials.secretSeed)
|
||||
: undefined;
|
||||
|
||||
await clerkClient.user?.update({
|
||||
unsafeMetadata: {
|
||||
jazzAccountID: credentials.accountID,
|
||||
jazzAccountSecret: credentials.accountSecret,
|
||||
jazzAccountSeed: credentials.secretSeed
|
||||
? Array.from(credentials.secretSeed)
|
||||
: undefined,
|
||||
jazzAccountSeed,
|
||||
} satisfies ClerkCredentials,
|
||||
});
|
||||
|
||||
@@ -96,12 +116,14 @@ export class JazzClerkAuth {
|
||||
currentAccount.profile.name = username;
|
||||
}
|
||||
|
||||
await this.authSecretStorage.set({
|
||||
accountID: credentials.accountID,
|
||||
accountSecret: credentials.accountSecret,
|
||||
secretSeed: credentials.secretSeed,
|
||||
provider: "clerk",
|
||||
});
|
||||
await JazzClerkAuth.loadClerkAuthData(
|
||||
{
|
||||
jazzAccountID: credentials.accountID,
|
||||
jazzAccountSecret: credentials.accountSecret,
|
||||
jazzAccountSeed,
|
||||
},
|
||||
this.authSecretStorage,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
49
packages/jazz-auth-clerk/src/tests/types.test.ts
Normal file
49
packages/jazz-auth-clerk/src/tests/types.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isClerkCredentials } from "../types";
|
||||
|
||||
describe("isClerkCredentials", () => {
|
||||
it.each([
|
||||
{
|
||||
metadata: {
|
||||
jazzAccountID: "123",
|
||||
jazzAccountSecret: "456",
|
||||
jazzAccountSeed: [1, 2, 3],
|
||||
},
|
||||
description: "full credentials",
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
jazzAccountID: "123",
|
||||
jazzAccountSecret: "456",
|
||||
},
|
||||
description: "missing jazzAccountSeed",
|
||||
},
|
||||
])("succeeds for valid credentials: $description", ({ metadata }) => {
|
||||
expect(isClerkCredentials(metadata)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
metadata: {},
|
||||
description: "empty object",
|
||||
},
|
||||
{
|
||||
metadata: undefined,
|
||||
description: "undefined",
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
jazzAccountSecret: "456",
|
||||
},
|
||||
description: "missing jazzAccountID",
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
jazzAccountID: "123",
|
||||
},
|
||||
description: "missing jazzAccountSecret",
|
||||
},
|
||||
])("fails for invalid credentials: $description", ({ metadata }) => {
|
||||
expect(isClerkCredentials(metadata)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { AgentSecret } from "cojson";
|
||||
import { Account, ID } from "jazz-tools";
|
||||
|
||||
export type MinimalClerkClient = {
|
||||
user:
|
||||
| {
|
||||
@@ -21,3 +24,19 @@ export type MinimalClerkClient = {
|
||||
signOut: () => Promise<void>;
|
||||
addListener: (listener: (data: unknown) => void) => void;
|
||||
};
|
||||
|
||||
export type ClerkCredentials = {
|
||||
jazzAccountID: ID<Account>;
|
||||
jazzAccountSecret: AgentSecret;
|
||||
jazzAccountSeed?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the Clerk user metadata contains the necessary credentials for Jazz auth.
|
||||
* **Note**: It does not validate the credentials, only checks if the necessary fields are present in the metadata object.
|
||||
*/
|
||||
export function isClerkCredentials(
|
||||
data: NonNullable<MinimalClerkClient["user"]>["unsafeMetadata"] | undefined,
|
||||
): data is ClerkCredentials {
|
||||
return !!data && "jazzAccountID" in data && "jazzAccountSecret" in data;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export type JazzContextManagerProps<Acc extends Account> = {
|
||||
export class JazzBrowserContextManager<
|
||||
Acc extends Account,
|
||||
> extends JazzContextManager<Acc, JazzContextManagerProps<Acc>> {
|
||||
// TODO: When the storage changes, if the user is changed, update the context
|
||||
getKvStore() {
|
||||
if (typeof window === "undefined") {
|
||||
// To handle running in SSR
|
||||
@@ -63,7 +64,7 @@ export class JazzBrowserContextManager<
|
||||
});
|
||||
}
|
||||
|
||||
this.updateContext(props, currentContext);
|
||||
await this.updateContext(props, currentContext, authProps);
|
||||
}
|
||||
|
||||
propsChanged(props: JazzContextManagerProps<Acc>) {
|
||||
|
||||
@@ -165,8 +165,6 @@ export async function createJazzBrowserContext<Acc extends Account>(
|
||||
const authSecretStorage = options.authSecretStorage;
|
||||
const credentials = options.credentials ?? (await authSecretStorage.get());
|
||||
|
||||
authSecretStorage.emitUpdate(credentials);
|
||||
|
||||
function handleAuthUpdate(isAuthenticated: boolean) {
|
||||
if (isAuthenticated) {
|
||||
toggleNetwork(true);
|
||||
@@ -176,7 +174,7 @@ export async function createJazzBrowserContext<Acc extends Account>(
|
||||
}
|
||||
|
||||
unsubscribeAuthUpdate = authSecretStorage.onUpdate(handleAuthUpdate);
|
||||
handleAuthUpdate(authSecretStorage.isAuthenticated);
|
||||
handleAuthUpdate(authSecretStorage.getIsAuthenticated(credentials));
|
||||
}
|
||||
|
||||
const context = await createJazzContext({
|
||||
|
||||
@@ -15,6 +15,8 @@ setupInspector();
|
||||
export * from "./createBrowserContext.js";
|
||||
export * from "./BrowserContextManager.js";
|
||||
|
||||
export { LocalStorageKVStore } from "./auth/LocalStorageKVStore.js";
|
||||
|
||||
/** @category Invite Links */
|
||||
export function createInviteLink<C extends CoValue>(
|
||||
value: C,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dependencies": {
|
||||
"cojson": "workspace:*",
|
||||
"jazz-auth-clerk": "workspace:*",
|
||||
"jazz-browser": "workspace:*",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { JazzClerkAuth, type MinimalClerkClient } from "jazz-auth-clerk";
|
||||
import {
|
||||
JazzClerkAuth,
|
||||
type MinimalClerkClient,
|
||||
isClerkCredentials,
|
||||
} from "jazz-auth-clerk";
|
||||
import { LocalStorageKVStore } from "jazz-browser";
|
||||
import {
|
||||
JazzProvider,
|
||||
JazzProviderProps,
|
||||
useAuthSecretStorage,
|
||||
useJazzContext,
|
||||
} from "jazz-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { AuthSecretStorage, InMemoryKVStore, KvStoreContext } from "jazz-tools";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
function useJazzClerkAuth(clerk: MinimalClerkClient) {
|
||||
const context = useJazzContext();
|
||||
@@ -39,6 +45,28 @@ function RegisterClerkAuth(props: {
|
||||
export const JazzProviderWithClerk = (
|
||||
props: { clerk: MinimalClerkClient } & JazzProviderProps,
|
||||
) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
setupKvStore();
|
||||
const secretStorage = new AuthSecretStorage();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClerkCredentials(props.clerk.user?.unsafeMetadata)) {
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
JazzClerkAuth.loadClerkAuthData(
|
||||
props.clerk.user.unsafeMetadata,
|
||||
secretStorage,
|
||||
).then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<JazzProvider {...props} onLogOut={props.clerk.signOut}>
|
||||
<RegisterClerkAuth clerk={props.clerk}>
|
||||
@@ -47,3 +75,11 @@ export const JazzProviderWithClerk = (
|
||||
</JazzProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function setupKvStore() {
|
||||
KvStoreContext.getInstance().initialize(
|
||||
typeof window === "undefined"
|
||||
? new InMemoryKVStore()
|
||||
: new LocalStorageKVStore(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import type { MinimalClerkClient } from "jazz-auth-clerk";
|
||||
import { JazzClerkAuth, type MinimalClerkClient } from "jazz-auth-clerk";
|
||||
import { AuthSecretStorage, InMemoryKVStore, KvStoreContext } from "jazz-tools";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MockInstance, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { JazzProviderWithClerk } from "../index";
|
||||
|
||||
vi.mock("jazz-react", async (importOriginal) => {
|
||||
@@ -27,12 +27,24 @@ vi.mock("jazz-react", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("jazz-auth-clerk", async (importOriginal) => {
|
||||
const { JazzClerkAuth } = await import("jazz-auth-clerk");
|
||||
|
||||
JazzClerkAuth.loadClerkAuthData = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
return {
|
||||
...(await importOriginal<typeof import("jazz-auth-clerk")>()),
|
||||
JazzClerkAuth,
|
||||
};
|
||||
});
|
||||
|
||||
const authSecretStorage = new AuthSecretStorage();
|
||||
KvStoreContext.getInstance().initialize(new InMemoryKVStore());
|
||||
|
||||
describe("JazzProviderWithClerk", () => {
|
||||
beforeEach(async () => {
|
||||
await authSecretStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const setup = (
|
||||
@@ -94,4 +106,60 @@ describe("JazzProviderWithClerk", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should load the clerk credentials when the user is authenticated", async () => {
|
||||
render(
|
||||
<JazzProviderWithClerk
|
||||
clerk={{
|
||||
addListener: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
unsafeMetadata: {
|
||||
jazzAccountID: "test",
|
||||
jazzAccountSecret: "test",
|
||||
jazzAccountSeed: "test",
|
||||
},
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
username: "test",
|
||||
fullName: "Test User",
|
||||
id: "test",
|
||||
primaryEmailAddress: {
|
||||
emailAddress: "test@test.com",
|
||||
},
|
||||
},
|
||||
}}
|
||||
sync={{ peer: "wss://test.jazz.tools" }}
|
||||
>
|
||||
<div data-testid="test-child">Test Content</div>
|
||||
</JazzProviderWithClerk>,
|
||||
);
|
||||
|
||||
expect(JazzClerkAuth.loadClerkAuthData).toHaveBeenCalledWith(
|
||||
{
|
||||
jazzAccountID: "test",
|
||||
jazzAccountSecret: "test",
|
||||
jazzAccountSeed: "test",
|
||||
},
|
||||
authSecretStorage,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not load the clerk credentials when the user is not authenticated", async () => {
|
||||
render(
|
||||
<JazzProviderWithClerk
|
||||
clerk={{
|
||||
addListener: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
user: null,
|
||||
}}
|
||||
sync={{ peer: "wss://test.jazz.tools" }}
|
||||
>
|
||||
<div data-testid="test-child">Test Content</div>
|
||||
</JazzProviderWithClerk>,
|
||||
);
|
||||
|
||||
expect(JazzClerkAuth.loadClerkAuthData).not.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DemoAuth } from "jazz-tools";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuthSecretStorage, useJazzContext } from "../hooks.js";
|
||||
import { useIsAuthenticated } from "./useIsAuthenticated.js";
|
||||
import { useIsAuthenticated } from "../hooks.js";
|
||||
|
||||
/**
|
||||
* `useDemoAuth` is a hook that provides a `JazzAuth` object for demo authentication.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PassphraseAuth } from "jazz-tools";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useAuthSecretStorage, useJazzContext } from "../hooks.js";
|
||||
import { useIsAuthenticated } from "./useIsAuthenticated.js";
|
||||
import { useIsAuthenticated } from "../hooks.js";
|
||||
|
||||
/**
|
||||
* `usePassphraseAuth` hook provides a `JazzAuth` object for passphrase authentication.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./DemoAuth.js";
|
||||
export * from "./PassphraseAuth.js";
|
||||
export * from "./useIsAuthenticated.js";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthSecretStorage } from "../hooks.js";
|
||||
|
||||
export function useIsAuthenticated() {
|
||||
const authSecretStorage = useAuthSecretStorage();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(
|
||||
authSecretStorage.isAuthenticated,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return authSecretStorage.onUpdate(setIsAuthenticated);
|
||||
}, []);
|
||||
|
||||
return isAuthenticated;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import React, { useCallback, useContext, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
Account,
|
||||
@@ -9,10 +14,13 @@ import {
|
||||
DepthsIn,
|
||||
ID,
|
||||
InboxSender,
|
||||
JazzContextManager,
|
||||
JazzContextType,
|
||||
createCoValueObservable,
|
||||
} from "jazz-tools";
|
||||
import { JazzAuthContext, JazzContext } from "./provider.js";
|
||||
import { JazzContext, JazzContextManagerContext } from "./provider.js";
|
||||
import { getCurrentAccountFromContextManager } from "./utils.js";
|
||||
import { subscribeToContextManager } from "./utils.js";
|
||||
|
||||
export function useJazzContext<Acc extends Account>() {
|
||||
const value = useContext(JazzContext) as JazzContextType<Acc>;
|
||||
@@ -26,8 +34,23 @@ export function useJazzContext<Acc extends Account>() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useJazzContextManager<Acc extends Account>() {
|
||||
const value = useContext(JazzContextManagerContext) as JazzContextManager<
|
||||
Acc,
|
||||
{}
|
||||
>;
|
||||
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
"You need to set up a JazzProvider on top of your app to use this hook.",
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useAuthSecretStorage() {
|
||||
const value = useContext(JazzAuthContext);
|
||||
const value = useContext(JazzContextManagerContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
@@ -35,7 +58,45 @@ export function useAuthSecretStorage() {
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
return value.getAuthSecretStorage();
|
||||
}
|
||||
|
||||
export function useIsAuthenticated() {
|
||||
const authSecretStorage = useAuthSecretStorage();
|
||||
|
||||
return useSyncExternalStore(
|
||||
useCallback(
|
||||
(callback) => {
|
||||
return authSecretStorage.onUpdate(callback);
|
||||
},
|
||||
[authSecretStorage],
|
||||
),
|
||||
() => authSecretStorage.isAuthenticated,
|
||||
() => authSecretStorage.isAuthenticated,
|
||||
);
|
||||
}
|
||||
|
||||
function useCoValueObservable<V extends CoValue, D>() {
|
||||
const [initialValue] = React.useState(() =>
|
||||
createCoValueObservable<V, D>({
|
||||
syncResolution: true,
|
||||
}),
|
||||
);
|
||||
const ref = useRef(initialValue);
|
||||
|
||||
return {
|
||||
getCurrentValue() {
|
||||
return ref.current.getCurrentValue();
|
||||
},
|
||||
getCurrentObservable() {
|
||||
return ref.current;
|
||||
},
|
||||
reset() {
|
||||
ref.current = createCoValueObservable<V, D>({
|
||||
syncResolution: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useCoState<V extends CoValue, D>(
|
||||
@@ -44,13 +105,9 @@ export function useCoState<V extends CoValue, D>(
|
||||
id: ID<V> | undefined,
|
||||
depth: D & DepthsIn<V> = [] as D & DepthsIn<V>,
|
||||
): DeeplyLoaded<V, D> | undefined | null {
|
||||
const context = useJazzContext();
|
||||
const contextManager = useJazzContextManager();
|
||||
|
||||
const [observable] = React.useState(() =>
|
||||
createCoValueObservable({
|
||||
syncResolution: true,
|
||||
}),
|
||||
);
|
||||
const observable = useCoValueObservable<V, D>();
|
||||
|
||||
const value = React.useSyncExternalStore<
|
||||
DeeplyLoaded<V, D> | undefined | null
|
||||
@@ -59,14 +116,20 @@ export function useCoState<V extends CoValue, D>(
|
||||
(callback) => {
|
||||
if (!id) return () => {};
|
||||
|
||||
const agent = "me" in context ? context.me : context.guest;
|
||||
// We subscribe to the context manager to react to the account updates
|
||||
// faster than the useSyncExternalStore callback update to keep the isAuthenticated state
|
||||
// up to date with the data when logging in and out.
|
||||
return subscribeToContextManager(contextManager, () => {
|
||||
const agent = getCurrentAccountFromContextManager(contextManager);
|
||||
|
||||
return observable.subscribe(Schema, id, agent, depth, callback, () => {
|
||||
console.log("unavailable");
|
||||
callback();
|
||||
observable.reset();
|
||||
|
||||
return observable
|
||||
.getCurrentObservable()
|
||||
.subscribe(Schema, id, agent, depth, callback, callback);
|
||||
});
|
||||
},
|
||||
[Schema, id, context],
|
||||
[Schema, id, contextManager],
|
||||
),
|
||||
() => observable.getCurrentValue(),
|
||||
() => observable.getCurrentValue(),
|
||||
@@ -87,6 +150,7 @@ export function createUseAccountHooks<Acc extends Account>() {
|
||||
depth?: D,
|
||||
): { me: Acc | DeeplyLoaded<Acc, D> | undefined | null; logOut: () => void } {
|
||||
const context = useJazzContext<Acc>();
|
||||
const contextManager = useJazzContextManager<Acc>();
|
||||
|
||||
if (!("me" in context)) {
|
||||
throw new Error(
|
||||
@@ -94,10 +158,42 @@ export function createUseAccountHooks<Acc extends Account>() {
|
||||
);
|
||||
}
|
||||
|
||||
const me = useCoState<Acc, D>(
|
||||
context.me.constructor as CoValueClass<Acc>,
|
||||
context.me.id,
|
||||
depth,
|
||||
const observable = useCoValueObservable<Acc, D>();
|
||||
|
||||
const me = React.useSyncExternalStore<
|
||||
DeeplyLoaded<Acc, D> | undefined | null
|
||||
>(
|
||||
React.useCallback(
|
||||
(callback) => {
|
||||
return subscribeToContextManager(contextManager, () => {
|
||||
const agent = getCurrentAccountFromContextManager(contextManager);
|
||||
|
||||
if (agent._type === "Anonymous") {
|
||||
throw new Error(
|
||||
"useAccount can't be used in a JazzProvider with auth === 'guest' - consider using useAccountOrGuest()",
|
||||
);
|
||||
}
|
||||
|
||||
observable.reset();
|
||||
|
||||
const Schema = agent.constructor as CoValueClass<Acc>;
|
||||
|
||||
return observable
|
||||
.getCurrentObservable()
|
||||
.subscribe(
|
||||
Schema,
|
||||
agent.id,
|
||||
agent,
|
||||
depth ?? ([] as D),
|
||||
callback,
|
||||
callback,
|
||||
);
|
||||
});
|
||||
},
|
||||
[contextManager],
|
||||
),
|
||||
() => observable.getCurrentValue(),
|
||||
() => observable.getCurrentValue(),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -118,14 +214,42 @@ export function createUseAccountHooks<Acc extends Account>() {
|
||||
me: Acc | DeeplyLoaded<Acc, D> | undefined | null | AnonymousJazzAgent;
|
||||
} {
|
||||
const context = useJazzContext<Acc>();
|
||||
const contextManager = useJazzContextManager<Acc>();
|
||||
|
||||
const contextMe = "me" in context ? context.me : undefined;
|
||||
const AccountSchema = contextMe?.constructor ?? Account;
|
||||
const observable = useCoValueObservable<Acc, D>();
|
||||
|
||||
const me = useCoState<Acc, D>(
|
||||
AccountSchema as CoValueClass<Acc>,
|
||||
contextMe?.id,
|
||||
depth,
|
||||
const me = React.useSyncExternalStore<
|
||||
DeeplyLoaded<Acc, D> | undefined | null
|
||||
>(
|
||||
React.useCallback(
|
||||
(callback) => {
|
||||
return subscribeToContextManager(contextManager, () => {
|
||||
const agent = getCurrentAccountFromContextManager(contextManager);
|
||||
|
||||
if (agent._type === "Anonymous") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
observable.reset();
|
||||
|
||||
const Schema = agent.constructor as CoValueClass<Acc>;
|
||||
|
||||
return observable
|
||||
.getCurrentObservable()
|
||||
.subscribe(
|
||||
Schema,
|
||||
agent.id,
|
||||
agent,
|
||||
depth ?? ([] as D),
|
||||
callback,
|
||||
callback,
|
||||
);
|
||||
});
|
||||
},
|
||||
[contextManager],
|
||||
),
|
||||
() => observable.getCurrentValue(),
|
||||
() => observable.getCurrentValue(),
|
||||
);
|
||||
|
||||
if ("me" in context) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { Account, AuthSecretStorage, JazzContextType } from "jazz-tools";
|
||||
import { Account, JazzContextManager, JazzContextType } from "jazz-tools";
|
||||
|
||||
export interface Register {}
|
||||
|
||||
@@ -12,6 +12,6 @@ export const JazzContext = React.createContext<
|
||||
JazzContextType<Account> | undefined
|
||||
>(undefined);
|
||||
|
||||
export const JazzAuthContext = React.createContext<
|
||||
AuthSecretStorage | undefined
|
||||
export const JazzContextManagerContext = React.createContext<
|
||||
JazzContextManager<Account, {}> | undefined
|
||||
>(undefined);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Account, AnonymousJazzAgent } from "jazz-tools";
|
||||
import { TestJazzContextManager } from "jazz-tools/testing";
|
||||
import { useCallback, useState, useSyncExternalStore } from "react";
|
||||
import { JazzAuthContext, JazzContext } from "./provider.js";
|
||||
import { JazzContext, JazzContextManagerContext } from "./provider.js";
|
||||
|
||||
export function JazzTestProvider<Acc extends Account>({
|
||||
children,
|
||||
@@ -26,9 +26,9 @@ export function JazzTestProvider<Acc extends Account>({
|
||||
|
||||
return (
|
||||
<JazzContext.Provider value={value}>
|
||||
<JazzAuthContext.Provider value={contextManager.getAuthSecretStorage()}>
|
||||
<JazzContextManagerContext.Provider value={contextManager}>
|
||||
{children}
|
||||
</JazzAuthContext.Provider>
|
||||
</JazzContextManagerContext.Provider>
|
||||
</JazzContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Account, CoMap, co } from "jazz-tools";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createUseAccountHooks } from "../hooks.js";
|
||||
import { createJazzTestAccount } from "../testing.js";
|
||||
import { renderHook } from "./testUtils.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createUseAccountHooks, useJazzContextManager } from "../hooks.js";
|
||||
import { useIsAuthenticated } from "../index.js";
|
||||
import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
|
||||
import { act, renderHook } from "./testUtils.js";
|
||||
|
||||
const { useAccount } = createUseAccountHooks<Account>();
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupJazzTestSync();
|
||||
});
|
||||
|
||||
describe("useAccount", () => {
|
||||
it("should return the correct value", async () => {
|
||||
const account = await createJazzTestAccount();
|
||||
@@ -50,4 +55,125 @@ describe("useAccount", () => {
|
||||
|
||||
expect(result.current?.me?.root?.value).toBe("123");
|
||||
});
|
||||
|
||||
it("should be in sync with useIsAuthenticated when logOut is called", async () => {
|
||||
const account = await createJazzTestAccount({});
|
||||
|
||||
const accounts: string[] = [];
|
||||
const updates: { isAuthenticated: boolean; accountIndex: number }[] = [];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const account = useAccount();
|
||||
|
||||
if (!accounts.includes(account.me.id)) {
|
||||
accounts.push(account.me.id);
|
||||
}
|
||||
|
||||
updates.push({
|
||||
isAuthenticated,
|
||||
accountIndex: accounts.indexOf(account.me.id),
|
||||
});
|
||||
|
||||
return { isAuthenticated, account };
|
||||
},
|
||||
{
|
||||
account,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current?.isAuthenticated).toBe(true);
|
||||
expect(result.current?.account?.me).toBeDefined();
|
||||
|
||||
const id = result.current?.account?.me?.id;
|
||||
|
||||
await act(async () => {
|
||||
await result.current?.account?.logOut();
|
||||
});
|
||||
|
||||
expect(result.current?.isAuthenticated).toBe(false);
|
||||
expect(result.current?.account?.me.id).not.toBe(id);
|
||||
|
||||
expect(updates).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"accountIndex": 0,
|
||||
"isAuthenticated": true,
|
||||
},
|
||||
{
|
||||
"accountIndex": 0,
|
||||
"isAuthenticated": true,
|
||||
},
|
||||
{
|
||||
"accountIndex": 1,
|
||||
"isAuthenticated": false,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should be in sync with useIsAuthenticated when authenticate is called", async () => {
|
||||
const accountToAuthenticate = await createJazzTestAccount({});
|
||||
const account = await createJazzTestAccount({});
|
||||
|
||||
const accounts: string[] = [];
|
||||
const updates: { isAuthenticated: boolean; accountIndex: number }[] = [];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const account = useAccount();
|
||||
const contextManager = useJazzContextManager();
|
||||
|
||||
if (!accounts.includes(account.me.id)) {
|
||||
accounts.push(account.me.id);
|
||||
}
|
||||
|
||||
updates.push({
|
||||
isAuthenticated,
|
||||
accountIndex: accounts.indexOf(account.me.id),
|
||||
});
|
||||
|
||||
return { isAuthenticated, account, contextManager };
|
||||
},
|
||||
{
|
||||
account,
|
||||
isAuthenticated: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current?.isAuthenticated).toBe(false);
|
||||
expect(result.current?.account?.me).toBeDefined();
|
||||
|
||||
const id = result.current?.account?.me?.id;
|
||||
|
||||
await act(async () => {
|
||||
await result.current?.contextManager?.authenticate({
|
||||
accountID: accountToAuthenticate.id,
|
||||
accountSecret: accountToAuthenticate._raw.core.node.account.agentSecret,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.isAuthenticated).toBe(true);
|
||||
expect(result.current?.account?.me.id).not.toBe(id);
|
||||
|
||||
expect(updates).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"accountIndex": 0,
|
||||
"isAuthenticated": false,
|
||||
},
|
||||
{
|
||||
"accountIndex": 0,
|
||||
"isAuthenticated": false,
|
||||
},
|
||||
{
|
||||
"accountIndex": 1,
|
||||
"isAuthenticated": true,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AuthSecretStorage, InMemoryKVStore, KvStoreContext } from "jazz-tools";
|
||||
import { createJazzTestAccount } from "jazz-tools/testing";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useIsAuthenticated } from "../auth/useIsAuthenticated";
|
||||
import { useIsAuthenticated } from "../hooks";
|
||||
// @vitest-environment happy-dom
|
||||
import { renderHook } from "./testUtils";
|
||||
|
||||
|
||||
28
packages/jazz-react-core/src/utils.ts
Normal file
28
packages/jazz-react-core/src/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Account, JazzContextManager } from "jazz-tools";
|
||||
|
||||
export function getCurrentAccountFromContextManager<Acc extends Account>(
|
||||
contextManager: JazzContextManager<Acc, any>,
|
||||
) {
|
||||
const context = contextManager.getCurrentValue();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("No context found");
|
||||
}
|
||||
|
||||
return "me" in context ? context.me : context.guest;
|
||||
}
|
||||
|
||||
export function subscribeToContextManager<Acc extends Account>(
|
||||
contextManager: JazzContextManager<Acc, any>,
|
||||
callback: () => () => void,
|
||||
) {
|
||||
let unsub = () => {};
|
||||
|
||||
const handler = () => {
|
||||
unsub();
|
||||
unsub = callback();
|
||||
};
|
||||
|
||||
handler();
|
||||
return contextManager.subscribe(handler);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export class ReactNativeContextManager<
|
||||
});
|
||||
}
|
||||
|
||||
this.updateContext(props, currentContext);
|
||||
await this.updateContext(props, currentContext, authProps);
|
||||
}
|
||||
|
||||
propsChanged(props: JazzContextManagerProps<Acc>) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JazzAuthContext, JazzContext } from "jazz-react-core";
|
||||
import { JazzContext, JazzContextManagerContext } from "jazz-react-core";
|
||||
import { Account, JazzContextType, KvStore } from "jazz-tools";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { JazzContextManagerProps } from "./ReactNativeContextManager.js";
|
||||
@@ -80,9 +80,9 @@ export function JazzProvider<Acc extends Account = RegisteredAccount>({
|
||||
|
||||
return (
|
||||
<JazzContext.Provider value={value}>
|
||||
<JazzAuthContext.Provider value={contextManager.getAuthSecretStorage()}>
|
||||
<JazzContextManagerContext.Provider value={contextManager}>
|
||||
{value && children}
|
||||
</JazzAuthContext.Provider>
|
||||
</JazzContextManagerContext.Provider>
|
||||
</JazzContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
JazzBrowserContextManager,
|
||||
JazzContextManagerProps,
|
||||
} from "jazz-browser";
|
||||
import { JazzAuthContext, JazzContext } from "jazz-react-core";
|
||||
import { JazzContext, JazzContextManagerContext } from "jazz-react-core";
|
||||
import { Account, JazzContextType } from "jazz-tools";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
@@ -47,7 +47,8 @@ export function JazzProvider<Acc extends Account = RegisteredAccount>({
|
||||
defaultProfileName,
|
||||
onLogOut: onLogOutRefCallback,
|
||||
onAnonymousAccountDiscarded: onAnonymousAccountDiscardedRefCallback,
|
||||
};
|
||||
} satisfies JazzContextManagerProps<Acc>;
|
||||
|
||||
if (contextManager.propsChanged(props)) {
|
||||
contextManager.createContext(props).catch((error) => {
|
||||
console.error("Error creating Jazz browser context:", error);
|
||||
@@ -74,9 +75,9 @@ export function JazzProvider<Acc extends Account = RegisteredAccount>({
|
||||
|
||||
return (
|
||||
<JazzContext.Provider value={value}>
|
||||
<JazzAuthContext.Provider value={contextManager.getAuthSecretStorage()}>
|
||||
<JazzContextManagerContext.Provider value={contextManager}>
|
||||
{value && children}
|
||||
</JazzAuthContext.Provider>
|
||||
</JazzContextManagerContext.Provider>
|
||||
</JazzContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function getAuthSecretStorage() {
|
||||
const context = getContext<AuthSecretStorage>(JAZZ_AUTH_CTX);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useJazzContext must be used within a JazzProvider');
|
||||
throw new Error('getAuthSecretStorage must be used within a JazzProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
|
||||
@@ -15,7 +15,7 @@ function setup(options: {
|
||||
context: createJazzTestContext({ account: options.account }),
|
||||
props: {
|
||||
depth: options.depth ?? [],
|
||||
setResult: (value: ReturnType<typeof useAccount>) => {
|
||||
setResult: (value) => {
|
||||
result.current = value
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ function setup(options: {
|
||||
context: createJazzTestContext({ account: options.account }),
|
||||
props: {
|
||||
depth: options.depth ?? [],
|
||||
setResult: (value: ReturnType<typeof useAccountOrGuest>) => {
|
||||
setResult: (value) => {
|
||||
result.current = value
|
||||
},
|
||||
},
|
||||
@@ -57,7 +57,6 @@ describe("useAccountOrGuest", () => {
|
||||
expect(result.current?.me).toEqual(account);
|
||||
});
|
||||
|
||||
|
||||
it("should return the guest agent if the account is a guest", async () => {
|
||||
const account = await createJazzTestGuest();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AuthSetPayload = {
|
||||
export class AuthSecretStorage {
|
||||
private listeners: Set<(isAuthenticated: boolean) => void>;
|
||||
public isAuthenticated: boolean;
|
||||
notify = false;
|
||||
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
@@ -109,7 +110,10 @@ export class AuthSecretStorage {
|
||||
provider: payload.provider,
|
||||
}),
|
||||
);
|
||||
this.emitUpdate(payload);
|
||||
|
||||
if (this.notify) {
|
||||
this.emitUpdate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
getIsAuthenticated(data: AuthCredentials | null): boolean {
|
||||
@@ -138,6 +142,9 @@ export class AuthSecretStorage {
|
||||
async clear() {
|
||||
const kvStore = KvStoreContext.getInstance().getStorage();
|
||||
await kvStore.delete(STORAGE_KEY);
|
||||
this.emitUpdate(null);
|
||||
|
||||
if (this.notify) {
|
||||
this.emitUpdate(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type JazzContextManagerAuthProps = {
|
||||
|
||||
export type JazzContextManagerBaseProps<Acc extends Account> = {
|
||||
onAnonymousAccountDiscarded?: (anonymousAccount: Acc) => Promise<void>;
|
||||
onLogOut?: () => void;
|
||||
onLogOut?: () => void | Promise<unknown>;
|
||||
};
|
||||
|
||||
type PlatformSpecificAuthContext<Acc extends Account> = {
|
||||
@@ -43,6 +43,10 @@ export class JazzContextManager<
|
||||
protected context: PlatformSpecificContext<Acc> | undefined;
|
||||
protected props: P | undefined;
|
||||
protected authSecretStorage = new AuthSecretStorage();
|
||||
protected authSecretStorageWithNotify = Object.assign(
|
||||
Object.create(this.authSecretStorage),
|
||||
{ notify: true },
|
||||
);
|
||||
protected authenticating = false;
|
||||
|
||||
constructor() {
|
||||
@@ -59,7 +63,11 @@ export class JazzContextManager<
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateContext(props: P, context: PlatformSpecificContext<Acc>) {
|
||||
async updateContext(
|
||||
props: P,
|
||||
context: PlatformSpecificContext<Acc>,
|
||||
authProps?: JazzContextManagerAuthProps,
|
||||
) {
|
||||
// When authenticating we don't want to close the previous context
|
||||
// because we might need to handle the onAnonymousAccountDiscarded callback
|
||||
if (!this.authenticating) {
|
||||
@@ -75,6 +83,12 @@ export class JazzContextManager<
|
||||
logOut: this.logOut,
|
||||
};
|
||||
|
||||
if (authProps?.credentials) {
|
||||
this.authSecretStorage.emitUpdate(authProps.credentials);
|
||||
} else {
|
||||
this.authSecretStorage.emitUpdate(await this.authSecretStorage.get());
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
@@ -88,7 +102,9 @@ export class JazzContextManager<
|
||||
}
|
||||
|
||||
getAuthSecretStorage() {
|
||||
return this.authSecretStorage;
|
||||
// External updates of the auth secret storage are notified by default (e.g. when registering a new Auth provider)
|
||||
// We skip internal notify to ensure that the isAuthenticated changes are notified along with the context updates
|
||||
return this.authSecretStorageWithNotify;
|
||||
}
|
||||
|
||||
logOut = async () => {
|
||||
@@ -96,8 +112,8 @@ export class JazzContextManager<
|
||||
return;
|
||||
}
|
||||
|
||||
await this.props.onLogOut?.();
|
||||
await this.context.logOut();
|
||||
this.props.onLogOut?.();
|
||||
return this.createContext(this.props);
|
||||
};
|
||||
|
||||
@@ -109,6 +125,9 @@ export class JazzContextManager<
|
||||
this.context.done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticates the user with the given credentials
|
||||
*/
|
||||
authenticate = async (credentials: AuthCredentials) => {
|
||||
if (!this.props) {
|
||||
throw new Error("Props required");
|
||||
|
||||
@@ -222,9 +222,6 @@ export async function createJazzContext<Acc extends Account>(options: {
|
||||
authSecretStorage.clear();
|
||||
},
|
||||
});
|
||||
|
||||
// To align the isAuthenticated state with the credentials
|
||||
authSecretStorage.emitUpdate(credentials);
|
||||
} else {
|
||||
const secretSeed = options.crypto.newRandomSecretSeed();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PureJSCrypto } from "cojson/dist/crypto/PureJSCrypto";
|
||||
import {
|
||||
Account,
|
||||
AccountClass,
|
||||
AuthCredentials,
|
||||
JazzContextManagerAuthProps,
|
||||
} from "./exports.js";
|
||||
import {
|
||||
@@ -182,13 +183,14 @@ export class TestJazzContextManager<
|
||||
const storage = context.getAuthSecretStorage();
|
||||
const node = account._raw.core.node;
|
||||
|
||||
storage.set({
|
||||
const credentials = {
|
||||
accountID: account.id,
|
||||
accountSecret: node.account.agentSecret,
|
||||
secretSeed: SecretSeedMap.get(account.id),
|
||||
provider,
|
||||
});
|
||||
storage.isAuthenticated = Boolean(props?.isAuthenticated);
|
||||
} satisfies AuthCredentials;
|
||||
|
||||
storage.set(credentials);
|
||||
|
||||
context.updateContext(
|
||||
{
|
||||
@@ -202,9 +204,13 @@ export class TestJazzContextManager<
|
||||
node.gracefulShutdown();
|
||||
},
|
||||
logOut: async () => {
|
||||
await storage.clear();
|
||||
node.gracefulShutdown();
|
||||
},
|
||||
},
|
||||
{
|
||||
credentials,
|
||||
},
|
||||
);
|
||||
|
||||
return context;
|
||||
@@ -254,16 +260,20 @@ export class TestJazzContextManager<
|
||||
AccountSchema: props.AccountSchema,
|
||||
});
|
||||
|
||||
this.updateContext(props, {
|
||||
me: context.account,
|
||||
node: context.node,
|
||||
done: () => {
|
||||
context.done();
|
||||
await this.updateContext(
|
||||
props,
|
||||
{
|
||||
me: context.account,
|
||||
node: context.node,
|
||||
done: () => {
|
||||
context.done();
|
||||
},
|
||||
logOut: () => {
|
||||
return context.logOut();
|
||||
},
|
||||
},
|
||||
logOut: () => {
|
||||
return context.logOut();
|
||||
},
|
||||
});
|
||||
authProps,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ KvStoreContext.getInstance().initialize(kvStore);
|
||||
let authSecretStorage = new AuthSecretStorage();
|
||||
|
||||
describe("AuthSecretStorage", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
it("should migrate demo auth secret", async () => {
|
||||
const demoSecret = JSON.stringify({
|
||||
accountID: "demo123",
|
||||
@@ -79,6 +79,11 @@ describe("AuthSecretStorage", () => {
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
it("should return null when no data exists", async () => {
|
||||
expect(await authSecretStorage.get()).toBeNull();
|
||||
});
|
||||
@@ -132,6 +137,11 @@ describe("AuthSecretStorage", () => {
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
it("should set credentials with secretSeed", async () => {
|
||||
const payload = {
|
||||
accountID: "test123" as ID<Account>,
|
||||
@@ -165,61 +175,14 @@ describe("AuthSecretStorage", () => {
|
||||
const stored = JSON.parse((await kvStore.get("jazz-logged-in-secret"))!);
|
||||
expect(stored).toEqual(payload);
|
||||
});
|
||||
|
||||
it("should emit update event when setting credentials", async () => {
|
||||
const handler = vi.fn();
|
||||
authSecretStorage.onUpdate(handler);
|
||||
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
provider: "passphrase",
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
it("should return false when no data exists", async () => {
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for anonymous credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "anonymous",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for non-anonymous credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "demo",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when the provider is missing", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
} as any);
|
||||
expect(authSecretStorage.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onUpdate", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
it("should add and remove event listener", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
@@ -275,6 +238,11 @@ describe("AuthSecretStorage", () => {
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
it("should remove stored credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
@@ -287,21 +255,179 @@ describe("AuthSecretStorage", () => {
|
||||
|
||||
expect(await authSecretStorage.get()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit update event when clearing", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
provider: "passphrase",
|
||||
describe("notify=true", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
authSecretStorage.notify = true;
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("should emit update event when setting credentials", async () => {
|
||||
const handler = vi.fn();
|
||||
authSecretStorage.onUpdate(handler);
|
||||
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
provider: "passphrase",
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
it("should return false when no data exists", async () => {
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
authSecretStorage.onUpdate(handler);
|
||||
it("should return false for anonymous credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "anonymous",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
await authSecretStorage.clear();
|
||||
it("should return true for non-anonymous credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "demo",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
it("should return true when the provider is missing", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
} as any);
|
||||
expect(authSecretStorage.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should emit update event when clearing", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
provider: "passphrase",
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
authSecretStorage.onUpdate(handler);
|
||||
|
||||
await authSecretStorage.clear();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify=false", () => {
|
||||
beforeEach(() => {
|
||||
kvStore.clearAll();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("should not emit update event when setting credentials", async () => {
|
||||
const handler = vi.fn();
|
||||
authSecretStorage.onUpdate(handler);
|
||||
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
provider: "passphrase",
|
||||
});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
it("should return false when no data exists", async () => {
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for anonymous credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "anonymous",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for non-anonymous credentials", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "demo",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
authSecretStorage.emitUpdate({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
provider: "demo",
|
||||
});
|
||||
expect(authSecretStorage.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when the provider is missing", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
} as any);
|
||||
expect(authSecretStorage.isAuthenticated).toBe(false);
|
||||
authSecretStorage.emitUpdate({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
secretSeed: new Uint8Array([1, 2, 3]),
|
||||
} as any);
|
||||
expect(authSecretStorage.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should not emit update event when clearing", async () => {
|
||||
await authSecretStorage.set({
|
||||
accountID: "test123" as ID<Account>,
|
||||
accountSecret:
|
||||
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
|
||||
provider: "passphrase",
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
authSecretStorage.onUpdate(handler);
|
||||
|
||||
await authSecretStorage.clear();
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
setupJazzTestSync,
|
||||
} from "../testing";
|
||||
|
||||
// @ts-ignore Typescript in VSCode doesn't like top level await
|
||||
const Crypto = await WasmCrypto.create();
|
||||
|
||||
class TestJazzContextManager<Acc extends Account> extends JazzContextManager<
|
||||
@@ -53,16 +54,20 @@ class TestJazzContextManager<Acc extends Account> extends JazzContextManager<
|
||||
AccountSchema: props.AccountSchema,
|
||||
});
|
||||
|
||||
this.updateContext(props, {
|
||||
me: context.account,
|
||||
node: context.node,
|
||||
done: () => {
|
||||
context.done();
|
||||
await this.updateContext(
|
||||
props,
|
||||
{
|
||||
me: context.account,
|
||||
node: context.node,
|
||||
done: () => {
|
||||
context.done();
|
||||
},
|
||||
logOut: async () => {
|
||||
await context.logOut();
|
||||
},
|
||||
},
|
||||
logOut: async () => {
|
||||
await context.logOut();
|
||||
},
|
||||
});
|
||||
authProps,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export type JazzAuthContext<Acc extends Account> = {
|
||||
authenticate: AuthenticateAccountFunction;
|
||||
logOut: () => Promise<void>;
|
||||
done: () => void;
|
||||
isAuthenticated?: boolean;
|
||||
};
|
||||
|
||||
export type JazzGuestContext = {
|
||||
@@ -32,6 +33,7 @@ export type JazzGuestContext = {
|
||||
authenticate: AuthenticateAccountFunction;
|
||||
logOut: () => void;
|
||||
done: () => void;
|
||||
isAuthenticated?: boolean;
|
||||
};
|
||||
|
||||
export type JazzContextType<Acc extends Account> =
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1723,6 +1723,9 @@ importers:
|
||||
jazz-auth-clerk:
|
||||
specifier: workspace:*
|
||||
version: link:../jazz-auth-clerk
|
||||
jazz-browser:
|
||||
specifier: workspace:*
|
||||
version: link:../jazz-browser
|
||||
jazz-react:
|
||||
specifier: workspace:*
|
||||
version: link:../jazz-react
|
||||
|
||||
@@ -13,9 +13,7 @@ function App() {
|
||||
<header>
|
||||
<nav className="container flex justify-between items-center py-3">
|
||||
{isAuthenticated ? (
|
||||
<span>
|
||||
You're logged in as <strong>{me?.profile?.name}</strong>
|
||||
</span>
|
||||
<span>You're logged in.</span>
|
||||
) : (
|
||||
<span>Authenticate to share the data with another device.</span>
|
||||
)}
|
||||
@@ -26,7 +24,10 @@ function App() {
|
||||
<Logo />
|
||||
|
||||
<div className="text-center">
|
||||
<h1>Welcome{me?.profile.name ? <>, {me?.profile.name}</> : ""}!</h1>
|
||||
<h1>
|
||||
Welcome{me?.profile.firstName ? <>, {me?.profile.firstName}</> : ""}
|
||||
!
|
||||
</h1>
|
||||
{!!me?.root.age && (
|
||||
<p>As of today, you are {me.root.age} years old.</p>
|
||||
)}
|
||||
|
||||
@@ -9,15 +9,15 @@ export function Form() {
|
||||
<div className="grid gap-4 border p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="firstName" className="sm:w-32">
|
||||
Name
|
||||
First name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
placeholder="Enter your name here..."
|
||||
placeholder="Enter your first name here..."
|
||||
className="border border-stone-300 rounded shadow-sm py-1 px-2 flex-1"
|
||||
value={me.profile.name || ""}
|
||||
onChange={(e) => (me.profile.name = e.target.value)}
|
||||
value={me.profile.firstName || ""}
|
||||
onChange={(e) => (me.profile.firstName = e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
test("home page loads", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Welcome, Anonymous user!")).toBeVisible();
|
||||
await expect(page.getByText("Welcome!")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Name").fill("Bob");
|
||||
await expect(page.getByText("Welcome, Bob!")).toBeVisible();
|
||||
|
||||
@@ -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