@indietabletop/appkit 3.4.0 → 3.6.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ClientContext/ClientContext.tsx +25 -0
- package/lib/InfoPage/index.tsx +46 -0
- package/lib/InfoPage/pages.tsx +36 -0
- package/lib/InfoPage/style.css.ts +36 -0
- package/lib/Letterhead/index.tsx +3 -3
- package/lib/LetterheadForm/index.tsx +1 -1
- package/lib/LoginPage/LoginPage.stories.tsx +107 -0
- package/lib/LoginPage/LoginPage.tsx +204 -0
- package/lib/LoginPage/style.css.ts +17 -0
- package/lib/ModernIDB/Cursor.ts +91 -0
- package/lib/ModernIDB/ModernIDB.ts +337 -0
- package/lib/ModernIDB/ModernIDBError.ts +9 -0
- package/lib/ModernIDB/ObjectStore.ts +195 -0
- package/lib/ModernIDB/ObjectStoreIndex.ts +102 -0
- package/lib/ModernIDB/README.md +9 -0
- package/lib/ModernIDB/Transaction.ts +40 -0
- package/lib/ModernIDB/VersionChangeManager.ts +57 -0
- package/lib/ModernIDB/bindings/factory.tsx +160 -0
- package/lib/ModernIDB/bindings/index.ts +2 -0
- package/lib/ModernIDB/bindings/types.ts +56 -0
- package/lib/ModernIDB/bindings/utils.tsx +32 -0
- package/lib/ModernIDB/index.ts +10 -0
- package/lib/ModernIDB/types.ts +77 -0
- package/lib/ModernIDB/utils.ts +51 -0
- package/lib/ReleaseInfo/index.tsx +29 -0
- package/lib/RulesetResolver.ts +214 -0
- package/lib/SubscribeCard/SubscribeCard.stories.tsx +133 -0
- package/lib/SubscribeCard/SubscribeCard.tsx +107 -0
- package/lib/SubscribeCard/style.css.ts +14 -0
- package/lib/Title/index.tsx +4 -0
- package/lib/append-copy-to-text.ts +1 -1
- package/lib/async-op.ts +8 -0
- package/lib/client.ts +37 -2
- package/lib/copyrightRange.ts +6 -0
- package/lib/groupBy.ts +25 -0
- package/lib/ids.ts +6 -0
- package/lib/index.ts +13 -0
- package/lib/random.ts +12 -0
- package/lib/result/swr.ts +18 -0
- package/lib/structs.ts +10 -0
- package/lib/typeguards.ts +12 -0
- package/lib/types.ts +3 -1
- package/lib/unique.test.ts +22 -0
- package/lib/unique.ts +24 -0
- package/lib/useIsVisible.ts +27 -0
- package/package.json +16 -6
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { VersionChangeObjectStore } from "./ObjectStore.ts";
|
|
2
|
+
import { VersionChangeTransaction } from "./Transaction.ts";
|
|
3
|
+
import type { KeyPath, ModernIDBSchema } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export class VersionChangeManager<
|
|
6
|
+
Schema extends ModernIDBSchema,
|
|
7
|
+
IndexNames extends {
|
|
8
|
+
[K in keyof Schema]?: string;
|
|
9
|
+
} = never,
|
|
10
|
+
> {
|
|
11
|
+
readonly idbDatabase: IDBDatabase;
|
|
12
|
+
readonly event: IDBVersionChangeEvent;
|
|
13
|
+
readonly transaction: VersionChangeTransaction<Schema, IndexNames>;
|
|
14
|
+
|
|
15
|
+
constructor(props: {
|
|
16
|
+
idbDatabase: IDBDatabase;
|
|
17
|
+
event: IDBVersionChangeEvent;
|
|
18
|
+
idbTransaction: IDBTransaction;
|
|
19
|
+
}) {
|
|
20
|
+
this.idbDatabase = props.idbDatabase;
|
|
21
|
+
this.event = props.event;
|
|
22
|
+
this.transaction = new VersionChangeTransaction({
|
|
23
|
+
transaction: props.idbTransaction,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new object store with the given name and options and returns a new IDBObjectStore.
|
|
29
|
+
*
|
|
30
|
+
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBDatabase/createObjectStore)
|
|
31
|
+
*/
|
|
32
|
+
createObjectStore<StoreName extends string & keyof Schema>(
|
|
33
|
+
name: StoreName,
|
|
34
|
+
options?: {
|
|
35
|
+
autoIncrement?: boolean;
|
|
36
|
+
keyPath?:
|
|
37
|
+
| KeyPath<Schema[StoreName]>
|
|
38
|
+
| KeyPath<Schema[StoreName]>[]
|
|
39
|
+
| null;
|
|
40
|
+
},
|
|
41
|
+
) {
|
|
42
|
+
const objectStore = this.idbDatabase.createObjectStore(name, options);
|
|
43
|
+
return new VersionChangeObjectStore<
|
|
44
|
+
Schema[StoreName],
|
|
45
|
+
IndexNames[StoreName] extends string ? IndexNames[StoreName] : never
|
|
46
|
+
>(objectStore);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deletes the object store with the given name.
|
|
51
|
+
*
|
|
52
|
+
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBDatabase/deleteObjectStore)
|
|
53
|
+
*/
|
|
54
|
+
deleteObjectStore(name: string): void {
|
|
55
|
+
this.idbDatabase.deleteObjectStore(name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Failure, isAsyncOp, Pending, Success } from "../../async-op.ts";
|
|
9
|
+
import { caughtValueToString } from "../../caught-value.ts";
|
|
10
|
+
import { useAsyncOp } from "../../use-async-op.ts";
|
|
11
|
+
import type { AnyModernIDB } from "../types.ts";
|
|
12
|
+
import type {
|
|
13
|
+
DatabaseOpenRequestOp,
|
|
14
|
+
FlattenSuccessOps,
|
|
15
|
+
InaccessibleDatabaseError,
|
|
16
|
+
QueryOp,
|
|
17
|
+
} from "./types.ts";
|
|
18
|
+
import { toKnownError } from "./utils.tsx";
|
|
19
|
+
|
|
20
|
+
export function createDatabaseBindings<T extends AnyModernIDB>(db: T) {
|
|
21
|
+
const DatabaseContext = createContext(db);
|
|
22
|
+
|
|
23
|
+
const DatabaseOpenRequest = createContext<DatabaseOpenRequestOp<T>>(
|
|
24
|
+
new Pending(),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const cache = new Map<string, unknown>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Performs an IndexedDB query and watches for changes.
|
|
31
|
+
*
|
|
32
|
+
* Note that queries will only be executed once IndexedDB is open.
|
|
33
|
+
*
|
|
34
|
+
* If query output is an AsyncOp, it will be flattened. In other words, you
|
|
35
|
+
* will only get one 'layer' of async ops.
|
|
36
|
+
*
|
|
37
|
+
* Provide a cache key if you want to make sure that data is synchronously
|
|
38
|
+
* available during mounts/unmounts.
|
|
39
|
+
*/
|
|
40
|
+
function useQuery<Output>(
|
|
41
|
+
/**
|
|
42
|
+
* `query` must have stable identity.
|
|
43
|
+
*
|
|
44
|
+
* Make sure you use `useCallback` or a module-scoped function.
|
|
45
|
+
*/
|
|
46
|
+
query: (db: T) => Promise<Output>,
|
|
47
|
+
|
|
48
|
+
cacheKey?: string,
|
|
49
|
+
): FlattenSuccessOps<QueryOp<Output>> {
|
|
50
|
+
const openRequest = useDatabaseOpenRequest();
|
|
51
|
+
const [queryOp, setOp] = useState<QueryOp<Output>>(new Pending());
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
async function runQuery(): Promise<void> {
|
|
55
|
+
if (openRequest.isSuccess) {
|
|
56
|
+
try {
|
|
57
|
+
const data = await query(openRequest.value);
|
|
58
|
+
const success = new Success(data);
|
|
59
|
+
setOp(success);
|
|
60
|
+
|
|
61
|
+
if (cacheKey) {
|
|
62
|
+
cache.set(cacheKey, success);
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const failure = new Failure({
|
|
66
|
+
type: "QUERY_ERROR" as const,
|
|
67
|
+
error: caughtValueToString(error),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
setOp(failure);
|
|
71
|
+
if (cacheKey) {
|
|
72
|
+
cache.set(cacheKey, failure);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
db.addEventListener("readwrite", runQuery);
|
|
79
|
+
window.addEventListener("focus", runQuery);
|
|
80
|
+
void runQuery();
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
db.removeEventListener("readwrite", runQuery);
|
|
84
|
+
window.removeEventListener("focus", runQuery);
|
|
85
|
+
};
|
|
86
|
+
}, [cacheKey, openRequest, query]);
|
|
87
|
+
|
|
88
|
+
return openRequest.flatMap(() => {
|
|
89
|
+
const flattenOutput = (output: Output) => {
|
|
90
|
+
if (isAsyncOp(output)) {
|
|
91
|
+
return output as FlattenSuccessOps<QueryOp<Output>>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Success(output) as FlattenSuccessOps<QueryOp<Output>>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (cacheKey) {
|
|
98
|
+
const lastResult = cache.get(cacheKey) as QueryOp<Output> | undefined;
|
|
99
|
+
|
|
100
|
+
if (lastResult && queryOp.isPending) {
|
|
101
|
+
return lastResult.flatMap(flattenOutput);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return queryOp.flatMap(flattenOutput);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function useDatabase() {
|
|
110
|
+
return useContext(DatabaseContext);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function useDatabaseOpenRequest() {
|
|
114
|
+
return useContext(DatabaseOpenRequest);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function DatabaseProvider(props: { children: ReactNode }) {
|
|
118
|
+
const { op, setSuccess, setFailure } = useAsyncOp<
|
|
119
|
+
T,
|
|
120
|
+
InaccessibleDatabaseError
|
|
121
|
+
>();
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
db.open({
|
|
125
|
+
onBlocking() {
|
|
126
|
+
setFailure({ type: "CLOSED_FOR_UPGRADE" });
|
|
127
|
+
db.close();
|
|
128
|
+
console.info("Database closed due to version upgrade.");
|
|
129
|
+
},
|
|
130
|
+
}).then(
|
|
131
|
+
() => {
|
|
132
|
+
setSuccess(db);
|
|
133
|
+
console.info("Database open.");
|
|
134
|
+
},
|
|
135
|
+
(error: unknown) => {
|
|
136
|
+
setFailure(toKnownError(error));
|
|
137
|
+
console.warn(`Request to open database failed.`);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
db.close();
|
|
143
|
+
console.info("Database closed.");
|
|
144
|
+
};
|
|
145
|
+
}, [setFailure, setSuccess]);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<DatabaseOpenRequest.Provider value={op}>
|
|
149
|
+
{props.children}
|
|
150
|
+
</DatabaseOpenRequest.Provider>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
useQuery,
|
|
156
|
+
useDatabase,
|
|
157
|
+
useDatabaseOpenRequest,
|
|
158
|
+
DatabaseProvider,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AsyncOp, Success } from "../../async-op.ts";
|
|
2
|
+
import type { AnyModernIDB } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
export type DatabaseQueryOpFailure = QueryError | InaccessibleDatabaseError;
|
|
5
|
+
|
|
6
|
+
export type QueryOp<Output> = AsyncOp<Output, DatabaseQueryOpFailure>;
|
|
7
|
+
|
|
8
|
+
export type FlattenSuccessOps<T> = T extends Success<
|
|
9
|
+
infer S extends AsyncOp<unknown, unknown>
|
|
10
|
+
>
|
|
11
|
+
? S
|
|
12
|
+
: T;
|
|
13
|
+
|
|
14
|
+
export type QueryError = {
|
|
15
|
+
type: "QUERY_ERROR";
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The stored database version is higher than the version requested.
|
|
21
|
+
*/
|
|
22
|
+
export type VersionHigherState = {
|
|
23
|
+
type: "DB_VERSION_HIGHER_THAN_REQUESTED";
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Database could not be opened for an unrecognized reason.
|
|
29
|
+
*/
|
|
30
|
+
export type UnexpectedErrorState = {
|
|
31
|
+
type: "UNKNOWN_ERROR";
|
|
32
|
+
message: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This happens if the database needs an upgrade due to version change, but there are connections open to it from other tabs.
|
|
37
|
+
*/
|
|
38
|
+
export type UpgradeBlockedState = {
|
|
39
|
+
type: "UPGRADE_BLOCKED";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The DB has been closed to allow for version change.
|
|
44
|
+
*/
|
|
45
|
+
export type ClosedForUpgradeState = {
|
|
46
|
+
type: "CLOSED_FOR_UPGRADE";
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type InaccessibleDatabaseError =
|
|
50
|
+
| UpgradeBlockedState
|
|
51
|
+
| ClosedForUpgradeState
|
|
52
|
+
| VersionHigherState
|
|
53
|
+
| UnexpectedErrorState;
|
|
54
|
+
|
|
55
|
+
export type DatabaseOpenRequestOp<T extends AnyModernIDB = AnyModernIDB> =
|
|
56
|
+
AsyncOp<T, InaccessibleDatabaseError>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { InaccessibleDatabaseError } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function toKnownError(error: unknown): InaccessibleDatabaseError {
|
|
4
|
+
if (error instanceof Error) {
|
|
5
|
+
switch (error.name) {
|
|
6
|
+
case "OpenRequestBlockedError": {
|
|
7
|
+
return {
|
|
8
|
+
type: "UPGRADE_BLOCKED",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
case "VersionError": {
|
|
13
|
+
return {
|
|
14
|
+
type: "DB_VERSION_HIGHER_THAN_REQUESTED",
|
|
15
|
+
message: error.message,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
default: {
|
|
20
|
+
return {
|
|
21
|
+
type: "UNKNOWN_ERROR",
|
|
22
|
+
message: error.message,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
type: "UNKNOWN_ERROR",
|
|
30
|
+
message: "A non-error object was thrown.",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./bindings/index.ts";
|
|
2
|
+
export * from "./Cursor.ts";
|
|
3
|
+
export * from "./ModernIDB.ts";
|
|
4
|
+
export * from "./ModernIDBError.ts";
|
|
5
|
+
export * from "./ObjectStore.ts";
|
|
6
|
+
export * from "./ObjectStoreIndex.ts";
|
|
7
|
+
export * from "./Transaction.ts";
|
|
8
|
+
export * from "./types.ts";
|
|
9
|
+
export * from "./utils.ts";
|
|
10
|
+
export * from "./VersionChangeManager.ts";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ModernIDB } from "./ModernIDB.ts";
|
|
2
|
+
import { VersionChangeManager } from "./VersionChangeManager.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* IDB supports "readonly", "readwrite", and "versionchange" transaction modes,
|
|
6
|
+
* but "versionchange" can only be initiated automatically during DB upgrade.
|
|
7
|
+
*/
|
|
8
|
+
export type TransactionMode = "readonly" | "readwrite";
|
|
9
|
+
|
|
10
|
+
export type ModernIDBState = "open" | "opening" | "closed";
|
|
11
|
+
|
|
12
|
+
export type ModernIDBSchema = {
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type VersionChangeHandler<
|
|
17
|
+
Schema extends ModernIDBSchema,
|
|
18
|
+
IndexNames extends {
|
|
19
|
+
[K in keyof Schema]?: string;
|
|
20
|
+
},
|
|
21
|
+
> = (props: {
|
|
22
|
+
event: IDBVersionChangeEvent;
|
|
23
|
+
manager: VersionChangeManager<Schema, IndexNames>;
|
|
24
|
+
db: ModernIDB<Schema, IndexNames>;
|
|
25
|
+
}) => void;
|
|
26
|
+
|
|
27
|
+
export type BlockingHandler<
|
|
28
|
+
Schema extends ModernIDBSchema,
|
|
29
|
+
IndexNames extends {
|
|
30
|
+
[K in keyof Schema]?: string;
|
|
31
|
+
},
|
|
32
|
+
> = (props: {
|
|
33
|
+
event: IDBVersionChangeEvent;
|
|
34
|
+
db: ModernIDB<Schema, IndexNames>;
|
|
35
|
+
}) => void;
|
|
36
|
+
|
|
37
|
+
export type OpenRequestHandlers<
|
|
38
|
+
Schema extends ModernIDBSchema,
|
|
39
|
+
IndexNames extends {
|
|
40
|
+
[K in keyof Schema]?: string;
|
|
41
|
+
},
|
|
42
|
+
> = {
|
|
43
|
+
/**
|
|
44
|
+
* If error is thrown inside the `onInit` handler, the version change
|
|
45
|
+
* transaction will be aborted.
|
|
46
|
+
*/
|
|
47
|
+
onInit?: VersionChangeHandler<Schema, IndexNames>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* If error is thrown inside the `onUpgrade` handler, the version change
|
|
51
|
+
* transaction will be aborted.
|
|
52
|
+
*/
|
|
53
|
+
onUpgrade?: VersionChangeHandler<Schema, IndexNames>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* If a new connection opens which requests a higher version number than
|
|
57
|
+
* is the current version number, a `versionchange` event will be dispatched
|
|
58
|
+
* on the IDBDatabase instance. This handler can specify desired behaviour.
|
|
59
|
+
* For example, you might want to close currently connected connections
|
|
60
|
+
* to allow the version change to proceed.
|
|
61
|
+
*/
|
|
62
|
+
onBlocking?: BlockingHandler<Schema, IndexNames>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type ValidKeyValue = string | number | Date;
|
|
66
|
+
|
|
67
|
+
export type KeyPath<T> = T extends { [key: string]: unknown }
|
|
68
|
+
? {
|
|
69
|
+
[K in keyof T]: K extends string
|
|
70
|
+
? T[K] extends ValidKeyValue
|
|
71
|
+
? `${K}`
|
|
72
|
+
: `${K}.${KeyPath<T[K]>}`
|
|
73
|
+
: never;
|
|
74
|
+
}[keyof T]
|
|
75
|
+
: never;
|
|
76
|
+
|
|
77
|
+
export type AnyModernIDB = ModernIDB<any, any>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function transactionToPromise(
|
|
2
|
+
transaction: IDBTransaction,
|
|
3
|
+
): Promise<Event> {
|
|
4
|
+
return new Promise<Event>((resolve, reject) => {
|
|
5
|
+
transaction.addEventListener("complete", resolve, { once: true });
|
|
6
|
+
transaction.addEventListener("error", reject, { once: true });
|
|
7
|
+
transaction.addEventListener("abort", reject, { once: true });
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function requestToPromise<T>(request: IDBRequest<T>) {
|
|
12
|
+
return new Promise<T>((resolve, reject) => {
|
|
13
|
+
const success = () => resolve(request.result);
|
|
14
|
+
const error = () => reject(request.error);
|
|
15
|
+
request.addEventListener("success", success, { once: true });
|
|
16
|
+
request.addEventListener("error", error, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function openRequestToPromise(request: IDBOpenDBRequest) {
|
|
21
|
+
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
22
|
+
const success = () => resolve(request.result);
|
|
23
|
+
const error = () => reject(request.error);
|
|
24
|
+
const blocked = () =>
|
|
25
|
+
reject(
|
|
26
|
+
new Error(
|
|
27
|
+
"Operation blocked. An existing database connection is preventing this action.",
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
request.addEventListener("success", success, { once: true });
|
|
32
|
+
request.addEventListener("error", error, { once: true });
|
|
33
|
+
request.addEventListener("blocked", blocked, { once: true });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function* requestToAsyncGenerator(
|
|
38
|
+
request: IDBRequest<IDBCursorWithValue | null>,
|
|
39
|
+
) {
|
|
40
|
+
let cursor = await requestToPromise(request);
|
|
41
|
+
|
|
42
|
+
while (cursor) {
|
|
43
|
+
yield cursor;
|
|
44
|
+
|
|
45
|
+
if (request.readyState !== "pending") {
|
|
46
|
+
cursor.continue();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cursor = await requestToPromise(request);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function ReleaseInfo(props: {
|
|
2
|
+
branch?: string;
|
|
3
|
+
shortcode?: string;
|
|
4
|
+
backgroundColor?: string;
|
|
5
|
+
}) {
|
|
6
|
+
if (!props.branch || !props.shortcode) {
|
|
7
|
+
return <>Release [local]</>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
{`Release `}
|
|
13
|
+
<code
|
|
14
|
+
style={{
|
|
15
|
+
fontSize: "0.875em",
|
|
16
|
+
backgroundColor: props.backgroundColor,
|
|
17
|
+
paddingInline: "0.25rem",
|
|
18
|
+
paddingBlock: "0.125rem",
|
|
19
|
+
borderRadius: "0.25rem",
|
|
20
|
+
fontFamily: "monospace",
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{props.shortcode}
|
|
24
|
+
</code>
|
|
25
|
+
|
|
26
|
+
{props.branch === "main" ? "" : ` (${props.branch})`}
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import { Failure, Pending, Success } from "./async-op.ts";
|
|
3
|
+
import { isNullish } from "./typeguards.ts";
|
|
4
|
+
|
|
5
|
+
type RulesetLike = { version: string };
|
|
6
|
+
|
|
7
|
+
type GetRemoteRuleset<R extends RulesetLike, F> = (
|
|
8
|
+
version: string,
|
|
9
|
+
) => Promise<Success<R> | Failure<F>>;
|
|
10
|
+
|
|
11
|
+
type RulesetResolverProps<R extends RulesetLike, F> = {
|
|
12
|
+
/**
|
|
13
|
+
* The initial ruleset that will be available to all users immediately at
|
|
14
|
+
* app start.
|
|
15
|
+
*/
|
|
16
|
+
initialRuleset: R;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A function that should return a Result with an instance of
|
|
20
|
+
* a Ruleset for the given game.
|
|
21
|
+
*/
|
|
22
|
+
getRemoteRuleset: GetRemoteRuleset<R, F>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Encapsulates ruleset caching and retrieval logic.
|
|
27
|
+
*
|
|
28
|
+
* Usually you want to instantiate this class and immediately pass it to
|
|
29
|
+
* {@link createRulesetResolverBindings} so that you can use it from within
|
|
30
|
+
* React components with proper re-renders.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { createRulesetResolverBindings, RulesetResolver } from "@indietabletop/appkit";
|
|
35
|
+
*
|
|
36
|
+
* // Instantiate a resolver and export it for any potential use
|
|
37
|
+
* export const resolver = new RulesetResolver({
|
|
38
|
+
* initialRuleset: new Ruleset({ ... }),
|
|
39
|
+
* async getRemoteRuleset(version) {
|
|
40
|
+
* // Get ruleset from a remote location somehow
|
|
41
|
+
* return new Ruleset({ ... });
|
|
42
|
+
* },
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Generate resolver-bound hooks for use within React components
|
|
46
|
+
* export const { useResolveRuleset, useLatestRuleset } =
|
|
47
|
+
* createRulesetResolverBindings(resolver);
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export class RulesetResolver<R extends RulesetLike, F> {
|
|
51
|
+
getRemoteRuleset: GetRemoteRuleset<R, F>;
|
|
52
|
+
rulesets: Map<string, Success<R> | Failure<F>>;
|
|
53
|
+
|
|
54
|
+
constructor(props: RulesetResolverProps<R, F>) {
|
|
55
|
+
this.getRemoteRuleset = props.getRemoteRuleset;
|
|
56
|
+
this.rulesets = new Map([
|
|
57
|
+
[props.initialRuleset.version, new Success(props.initialRuleset)],
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get latest() {
|
|
62
|
+
const sortedRulesets = Array.from(this.rulesets.values())
|
|
63
|
+
.filter((result) => result.isSuccess)
|
|
64
|
+
.map((result) => result.value)
|
|
65
|
+
.sort((left, right) => left.version.localeCompare(right.version));
|
|
66
|
+
|
|
67
|
+
if (sortedRulesets.length === 0) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Could not resolve latest ruleset. Ruleset resolver doesn't include any succesfully resolved ruleset versions.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
74
|
+
return sortedRulesets.at(-1)!;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
requests = new Map<string, Promise<Success<R> | Failure<F>>>();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolves a ruleset version if it is currently in memory.
|
|
81
|
+
*
|
|
82
|
+
* Otherwise, initiates ruleset resolution from a remote source in the
|
|
83
|
+
* background and returns `null`. If you want to know when the freshly
|
|
84
|
+
* requested ruleset will be resolved, you must use the {@link subscribe}
|
|
85
|
+
* method (possibly using a hook returned from {@link createRulesetResolverBindings}).
|
|
86
|
+
*/
|
|
87
|
+
resolve(version: string) {
|
|
88
|
+
const ruleset = this.rulesets.get(version);
|
|
89
|
+
if (ruleset) {
|
|
90
|
+
return ruleset;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
void this.resolveFromRemote(version);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves a ruleset from a remote source, deduplicating requests to
|
|
99
|
+
* identical rulesets.
|
|
100
|
+
*/
|
|
101
|
+
async resolveFromRemote(version: string) {
|
|
102
|
+
const ongoingRequest = this.requests.get(version);
|
|
103
|
+
if (ongoingRequest) {
|
|
104
|
+
return await ongoingRequest;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const remoteRulesetPromise = this.getRemoteRuleset(version);
|
|
108
|
+
this.requests.set(version, remoteRulesetPromise);
|
|
109
|
+
|
|
110
|
+
const newRuleset = await remoteRulesetPromise;
|
|
111
|
+
this.rulesets.set(version, newRuleset);
|
|
112
|
+
this.notify();
|
|
113
|
+
|
|
114
|
+
// Clear requests cache to allow for potential retries
|
|
115
|
+
this.requests.delete(version);
|
|
116
|
+
|
|
117
|
+
return newRuleset;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
listeners = new Set<() => void>();
|
|
121
|
+
|
|
122
|
+
subscribe(callback: () => void) {
|
|
123
|
+
this.listeners.add(callback);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
unsubscribe(callback: () => void) {
|
|
127
|
+
this.listeners.delete(callback);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
notify() {
|
|
131
|
+
for (const notifyListener of this.listeners) {
|
|
132
|
+
notifyListener();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Given a RulesetResolver, creates bound React hooks that trigger re-renders
|
|
139
|
+
* when new rulesets are resolved by the resolver.
|
|
140
|
+
*
|
|
141
|
+
* Usually you will create these hooks in their own module and export them
|
|
142
|
+
* for further use.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* export const resolver = new RulesetResolver({ ... });
|
|
147
|
+
*
|
|
148
|
+
* export const { useResolveRuleset, useLatestRuleset } =
|
|
149
|
+
* createRulesetResolverHooks(resolver);
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function createRulesetResolverBindings<
|
|
153
|
+
Ruleset extends RulesetLike,
|
|
154
|
+
Failure,
|
|
155
|
+
>(resolver: RulesetResolver<Ruleset, Failure>) {
|
|
156
|
+
// Pending state with stable identity — necessary so that
|
|
157
|
+
// useSyncExternalStore doesn't enter an infinite loop.
|
|
158
|
+
const pending = new Pending();
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns a ruleset result compatible with the provided version.
|
|
162
|
+
*
|
|
163
|
+
* If a ruleset is not currently in memory, it will be requested in the
|
|
164
|
+
* background and the component using this hook will be re-rendered once
|
|
165
|
+
* the request is completed.
|
|
166
|
+
*
|
|
167
|
+
* Note that requests are deduplicated, so it is safe to use this hook
|
|
168
|
+
* in loops/lists where multiple components might request identical
|
|
169
|
+
* ruleset verions.
|
|
170
|
+
*/
|
|
171
|
+
function useResolveRuleset(version: string | null | undefined) {
|
|
172
|
+
const subscribe = useCallback((callback: () => void) => {
|
|
173
|
+
resolver.subscribe(callback);
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
resolver.unsubscribe(callback);
|
|
177
|
+
};
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
const getSnapshot = useCallback(() => {
|
|
181
|
+
if (!isNullish(version)) {
|
|
182
|
+
return resolver.resolve(version) ?? pending;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return pending;
|
|
186
|
+
}, [version]);
|
|
187
|
+
|
|
188
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Returns the latest ruleset available in memory.
|
|
193
|
+
*/
|
|
194
|
+
function useLatestRuleset() {
|
|
195
|
+
const subscribe = useCallback((callback: () => void) => {
|
|
196
|
+
resolver.subscribe(callback);
|
|
197
|
+
|
|
198
|
+
return () => {
|
|
199
|
+
resolver.unsubscribe(callback);
|
|
200
|
+
};
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
const getSnapshot = useCallback(() => {
|
|
204
|
+
return resolver.latest;
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
useLatestRuleset,
|
|
212
|
+
useResolveRuleset,
|
|
213
|
+
};
|
|
214
|
+
}
|