Skip to content

Commit

Permalink
configure: basic mutations
Browse files Browse the repository at this point in the history
  • Loading branch information
oscartbeaumont committed Aug 8, 2024
1 parent 4bd8662 commit e02fac6
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 16 deletions.
10 changes: 6 additions & 4 deletions apps/configure/src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ export interface Database extends DBSchema {
key: string;
value: {
id: string;
// type: "create" | "update" | "delete";
// table: TableName;
// data: any;
type: string;
applied: boolean;
data: any;
};
};
// Stored views
Expand Down Expand Up @@ -317,7 +317,9 @@ export const openAndInitDb = (name: string, createIfNotFound = false) =>

db.createObjectStore("_kv");
db.createObjectStore("_meta");
db.createObjectStore("_mutations");
db.createObjectStore("_mutations", {
keyPath: "id",
});
db.createObjectStore("views", {
keyPath: "id",
});
Expand Down
15 changes: 12 additions & 3 deletions apps/configure/src/lib/sync/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createContextProvider } from "@solid-primitives/context";
import type { IDBPDatabase } from "idb";
import { onCleanup } from "solid-js";
import { toast } from "solid-sonner";
import type { Database } from "../db";
import { deleteKey, getKey } from "../kv";
import { applyMigrations } from "./mutation";
import { didLastSyncCompleteSuccessfully } from "./operation";
import * as schema from "./schema";

Expand All @@ -14,8 +16,12 @@ export const [SyncProvider, useSync] = createContextProvider(
);

export function initSync(db: IDBPDatabase<Database>) {
const abort = new AbortController();
onCleanup(() => abort.abort());

return {
db,
abort,
async syncAll(abort: AbortController): Promise<string | undefined> {
if (abort.signal.aborted) return;
if (!navigator.onLine) {
Expand Down Expand Up @@ -53,9 +59,12 @@ export function initSync(db: IDBPDatabase<Database>) {
const start = performance.now();
let wasError = false;
try {
await Promise.all(
Object.values(schema).map((sync) => sync(db, abort, accessToken)),
);
await Promise.all([
...Object.values(schema).map((sync) =>
sync(db, abort, accessToken),
),
applyMigrations(db, abort, accessToken),
]);
// We done care about the result if the sync was cancelled.
if (abort.signal.aborted) return;
} catch (err) {
Expand Down
89 changes: 89 additions & 0 deletions apps/configure/src/lib/sync/mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { IDBPDatabase } from "idb";
import type { useSync } from ".";
import type { Database } from "../db";
import * as allMutations from "./mutations";

// TODO
export function defineMutation<M>(
name: string,
options: {
// commit the mutation to the remote API
commit: (data: M, accessToken: string) => Promise<void> | void;
// apply the mutation against the local database
// We rely on the upsert behaviour of sync to revert this
apply: (db: IDBPDatabase<Database>, data: M) => Promise<void> | void;
},
) {
const callback = async (sync: ReturnType<typeof useSync>, data: M) => {
const id = crypto.randomUUID();

// We lock to ensure we don't try and double commit if a sync were to spawn in the middle of this mutation
await navigator.locks.request("mutations", async (lock) => {
if (!lock) return;

await sync.db.add("_mutations", {
id,
type: name,
data,
applied: false,
});

try {
await options.apply(sync.db, data);

await sync.db.put("_mutations", {
id,
type: name,
data,
applied: true,
});
} catch (err) {
console.error(`Failed to apply mutation ${id} of type ${name}: ${err}`);
}
});
// We trigger a background sync which will push the mutation to the server
sync.syncAll(sync.abort);
};

return Object.assign(callback, { mutation: { name, options } });
}

const mutations = Object.fromEntries(
Object.values(allMutations).map((mutation) => [
mutation.mutation.name,
mutation,
]),
);

// TODO: Handle `applied: false` mutations on load

export async function applyMigrations(
db: IDBPDatabase<Database>,
abort: AbortController,
accessToken: string,
) {
await navigator.locks.request("mutations", async (lock) => {
if (!lock) return;

const queued = await db.getAll("_mutations");

for (const mutation of queued) {
if (abort.signal.aborted) return;

const def = mutations[mutation.type];
if (!def)
throw new Error(
`Attempted to apply unknown mutation type: ${mutation.type}`,
);

try {
await def.mutation.options.commit(mutation.data, accessToken);
await db.delete("_mutations", mutation.id);
} catch (err) {
console.error(
`Failed to commit mutation ${mutation.id} of type ${mutation.type}: ${err}`,
);
}
}
});
}
38 changes: 38 additions & 0 deletions apps/configure/src/lib/sync/mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { defineMutation } from "./mutation";

export const updateUser = defineMutation<{
id: string;
name: string;
}>("updateUser", {
commit: async (data, accessToken) => {
// TODO: Use Microsoft batching API
const response = await fetch(
`https://graph.microsoft.com/beta/users/${data.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: accessToken, // TODO: Get access token
},
body: JSON.stringify({
displayName: data.name,
}),
},
);

// TODO: Handle Microsoft unauthorised error

// TODO: Ensure response is reported as valid
},
apply: async (db, data) => {
const tx = db.transaction("users", "readwrite");
const d = await tx.store.get(data.id);
if (!d)
throw new Error(`User ${data.id} not found. Failed to apply update.`);
await tx.store.put({
...d,
name: data.name,
});
await tx.done;
},
});
17 changes: 8 additions & 9 deletions apps/configure/src/routes/(dash).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,34 +92,33 @@ export default function Layout(props: ParentProps) {
const sync = initSync(db);
createCrossTabListener(db);

const abort = new AbortController();
onMount(() => sync.syncAll(abort));
onMount(() => sync.syncAll(sync.abort));
onCleanup(() => {
abort.abort();
// db.close(); // TODO
});

makeEventListener(document, "visibilitychange", () =>
sync.syncAll(abort),
sync.syncAll(sync.abort),
);

const [isPrimaryTab, setIsPrimaryTab] = createSignal(false);
createEffect(async () => {
while (!abort.signal.aborted) {
while (!sync.abort.signal.aborted) {
await navigator.locks
.request(
"primarytab",
{
signal: abort.signal,
signal: sync.abort.signal,
},
async (lock) => {
if (!lock) return;

setIsPrimaryTab(true);
// We hold the lock for the tab's life
await new Promise((resolve) =>
abort.signal.addEventListener("abort", () =>
resolve(undefined),
sync.abort.signal.addEventListener(
"abort",
() => resolve(undefined),
),
);
},
Expand All @@ -130,7 +129,7 @@ export default function Layout(props: ParentProps) {
});
createTimer2(
async () => {
if (isPrimaryTab()) await sync.syncAll(abort);
if (isPrimaryTab()) await sync.syncAll(sync.abort);
},
() => 60_000,
);
Expand Down
2 changes: 2 additions & 0 deletions apps/configure/src/routes/(dash)/devices/[deviceId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ export default function Page() {
/>

<Field label="Device Category" value={data()?.deviceCategory} />

{/* // TODO: Enrolled by, Management name vs hostname, serial number, last check-in time, primary user, notes */}
</div>
</Card>
</PageLayout>
Expand Down
31 changes: 31 additions & 0 deletions apps/configure/src/routes/(dash)/users/[userId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
buttonVariants,
} from "@mattrax/ui";
import { createWritableMemo } from "@solid-primitives/memo";
import clsx from "clsx";
import { For, type JSX, Show, Suspense } from "solid-js";
import { z } from "zod";
import { determineDeviceImage } from "~/assets";
import { PageLayout, PageLayoutHeading } from "~/components/PageLayout";
import { getKey } from "~/lib/kv";
import { createDbQuery } from "~/lib/query";
import { useSync } from "~/lib/sync";
import { updateUser } from "~/lib/sync/mutations";
import { useZodParams } from "~/lib/useZodParams";

export default function Page() {
Expand Down Expand Up @@ -134,6 +138,8 @@ export default function Page() {
</div>
}
>
<Debug userId={params.userId} />

<Card>
<div class="p-8 grid grid-cols-4 gap-y-8 gap-x-4">
<Field label="Identifier" value={data()?.id} />
Expand Down Expand Up @@ -271,6 +277,31 @@ export default function Page() {
);
}

function Debug(props: { userId: string }) {
const sync = useSync();

const data = createDbQuery((db) => db.get("users", props.userId));

const [name, setName] = createWritableMemo(() => data()?.name || "");

return (
<>
<Input value={name()} onChange={(e) => setName(e.currentTarget.value)} />
<Button
onClick={() => {
console.log("save", name());
updateUser(sync, {
id: props.userId,
name: name(),
});
}}
>
Save
</Button>
</>
);
}

// TODO: Break out somewhere else

export const renderDate = (d: string | undefined) =>
Expand Down

0 comments on commit e02fac6

Please sign in to comment.