@trylight/app 0.2.1 → 0.3.1
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/README.md +14 -0
- package/dist/auth/core.d.ts +2 -2
- package/dist/auth/core.js +1 -1
- package/dist/auth/plaza.d.ts +8 -0
- package/dist/auth/plaza.js +27 -1
- package/dist/client.js +7 -6
- package/dist/db/audit.d.ts +1 -1
- package/dist/db/audit.js +10 -4
- package/dist/db/cli.js +2 -2
- package/dist/entities.d.ts +4 -0
- package/dist/entities.js +4 -0
- package/dist/platform/live-data-mount.d.ts +1 -0
- package/dist/platform/live-data-mount.js +18 -0
- package/dist/platform/live-data.d.ts +30 -0
- package/dist/platform/live-data.js +83 -0
- package/dist/platform/pane-bridge.d.ts +1 -1
- package/dist/platform/pane-bridge.js +4 -1
- package/dist/platform/server-fn-door.js +4 -3
- package/dist/schema.js +3 -1
- package/dist/server/entities.js +42 -7
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/session.d.ts +7 -0
- package/dist/server/session.js +9 -1
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -41,6 +41,20 @@ text column, case-insensitively, on the server.
|
|
|
41
41
|
|
|
42
42
|
Joins and aggregates stay typed server functions over `light.db`.
|
|
43
43
|
|
|
44
|
+
### Live data
|
|
45
|
+
|
|
46
|
+
An open page shows other people's and agents' changes without a refresh.
|
|
47
|
+
`MiiPaneBridge` (every app renders it) runs a TanStack query over
|
|
48
|
+
`/api/light/entities/_changes?since=<cursor>` (`src/platform/live-data.ts`):
|
|
49
|
+
every `POLL_MS` while the tab is visible, at once on focus and reconnect. The
|
|
50
|
+
route answers from `light_changes` with the tables changed since the cursor
|
|
51
|
+
(looking `CHANGES_OVERLAP` behind it, for transactions that committed after
|
|
52
|
+
the last poll read) and the next cursor; never rows. Each changed table's
|
|
53
|
+
queries are invalidated by its key (except a table this tab is writing: the
|
|
54
|
+
write's own refetch follows it), and the route loaders reloaded. `light_changes`' `created_at` index is made
|
|
55
|
+
after every migrate (`src/db/audit.ts`). The poll is one `ChangeFeed`; a pushed
|
|
56
|
+
transport can replace it.
|
|
57
|
+
|
|
44
58
|
## Images
|
|
45
59
|
|
|
46
60
|
`/api/light/image?src=…` serves a logo or photo a row points at from the
|
package/dist/auth/core.d.ts
CHANGED
|
@@ -5,11 +5,11 @@ import { type MiiUser, type ResolvedSession } from "./lookup.js";
|
|
|
5
5
|
import { type MiiIdentity, type PlazaSession } from "./plaza.js";
|
|
6
6
|
export type { MiiUser, ResolvedSession } from "./lookup.js";
|
|
7
7
|
export type { AuthMode } from "./mode.js";
|
|
8
|
-
export type { MiiIdentity, PlazaSession, SignedIn, SignInFailure, SignInResult } from "./plaza.js";
|
|
8
|
+
export type { MiiIdentity, PasswordResetResult, PlazaSession, SignedIn, SignInFailure, SignInResult } from "./plaza.js";
|
|
9
9
|
export { SIGN_IN_PROBLEMS } from "./problems.js";
|
|
10
10
|
export { safeNext } from "./next-path.js";
|
|
11
11
|
export { SESSION_COOKIE } from "./lookup.js";
|
|
12
|
-
export { signInWithPlaza } from "./plaza.js";
|
|
12
|
+
export { requestPasswordResetWithPlaza, signInWithPlaza } from "./plaza.js";
|
|
13
13
|
export type CreateMiiAuthCoreOptions = {
|
|
14
14
|
/** Where to report failures that are ours, not the person's. Defaults to console.error. */
|
|
15
15
|
readonly reportError?: (error: unknown, context: Record<string, string>) => void;
|
package/dist/auth/core.js
CHANGED
|
@@ -8,7 +8,7 @@ import { deactivateUser, upsertMemberFromIdentity } from "./users.js";
|
|
|
8
8
|
export { SIGN_IN_PROBLEMS } from "./problems.js";
|
|
9
9
|
export { safeNext } from "./next-path.js";
|
|
10
10
|
export { SESSION_COOKIE } from "./lookup.js";
|
|
11
|
-
export { signInWithPlaza } from "./plaza.js";
|
|
11
|
+
export { requestPasswordResetWithPlaza, signInWithPlaza } from "./plaza.js";
|
|
12
12
|
/**
|
|
13
13
|
* Cookie attributes the app must use with issueSession's token: inside the platform's App pane the app is a third party, so the
|
|
14
14
|
* cookie must be SameSite=None + Secure + partitioned or the pane signs in
|
package/dist/auth/plaza.d.ts
CHANGED
|
@@ -47,6 +47,14 @@ export type SignInResult = ({
|
|
|
47
47
|
* the session the platform signed for them.
|
|
48
48
|
*/
|
|
49
49
|
export declare function signInWithPlaza(email: string, password: string): Promise<SignInResult>;
|
|
50
|
+
export type PasswordResetResult = {
|
|
51
|
+
readonly ok: true;
|
|
52
|
+
} | {
|
|
53
|
+
readonly ok: false;
|
|
54
|
+
readonly failure: SignInFailure;
|
|
55
|
+
};
|
|
56
|
+
/** `returnTo` is this app's login, where the platform's reset page sends the person afterwards. */
|
|
57
|
+
export declare function requestPasswordResetWithPlaza(email: string, returnTo: string): Promise<PasswordResetResult>;
|
|
50
58
|
/**
|
|
51
59
|
* Spend a code for the person behind it, or null if it is spent or stale.
|
|
52
60
|
*
|
package/dist/auth/plaza.js
CHANGED
|
@@ -45,6 +45,32 @@ export async function signInWithPlaza(email, password) {
|
|
|
45
45
|
failure: failureFor(response.status, await response.text())
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
+
/** `returnTo` is this app's login, where the platform's reset page sends the person afterwards. */
|
|
49
|
+
export async function requestPasswordResetWithPlaza(email, returnTo) {
|
|
50
|
+
const { plazaUrl, appId } = miiAuthConfig();
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(`${plazaUrl}/api/app-auth/request-password-reset`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ appId, email, returnTo }),
|
|
57
|
+
cache: "no-store"
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
failure: {
|
|
64
|
+
kind: "unavailable",
|
|
65
|
+
message: "Password reset is unreachable right now. Try again in a moment.",
|
|
66
|
+
cause
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (response.ok)
|
|
71
|
+
return { ok: true };
|
|
72
|
+
return { ok: false, failure: failureFor(response.status, await response.text()) };
|
|
73
|
+
}
|
|
48
74
|
/**
|
|
49
75
|
* Spend a code for the person behind it, or null if it is spent or stale.
|
|
50
76
|
*
|
|
@@ -143,7 +169,7 @@ function failureFor(status, body) {
|
|
|
143
169
|
if (status === 429) {
|
|
144
170
|
return {
|
|
145
171
|
kind: "throttled",
|
|
146
|
-
message: "Too many
|
|
172
|
+
message: "Too many tries. Wait a minute and try again."
|
|
147
173
|
};
|
|
148
174
|
}
|
|
149
175
|
return {
|
package/dist/client.js
CHANGED
|
@@ -10,7 +10,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
10
10
|
import { createInvoker } from "./invoke/index.js";
|
|
11
11
|
import { getTableColumns, getTableName } from "drizzle-orm";
|
|
12
12
|
import { z } from "zod/v4";
|
|
13
|
-
import { ENTITIES_PATH, EntityError, LIST_LIMIT } from "./entities.js";
|
|
13
|
+
import { ENTITIES_PATH, EntityError, LIST_LIMIT, tableQueryKey } from "./entities.js";
|
|
14
14
|
export { EntityError, LIST_LIMIT } from "./entities.js";
|
|
15
15
|
export { siteOf } from "./schema.js";
|
|
16
16
|
export { imageSrc } from "./images.js";
|
|
@@ -123,12 +123,13 @@ function putBack(client, id, before) {
|
|
|
123
123
|
/** One entity table's rows: plain calls, and TanStack Query hooks over them. */
|
|
124
124
|
function tableClient(entity) {
|
|
125
125
|
const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
|
|
126
|
+
const all = tableQueryKey(getTableName(entity));
|
|
126
127
|
const keys = {
|
|
127
|
-
all
|
|
128
|
-
list: (options) => [
|
|
129
|
-
pages: (options) => [
|
|
130
|
-
get: (id) => [
|
|
131
|
-
write: [
|
|
128
|
+
all,
|
|
129
|
+
list: (options) => [...all, "list", options],
|
|
130
|
+
pages: (options) => [...all, "pages", options],
|
|
131
|
+
get: (id) => [...all, "get", id],
|
|
132
|
+
write: [...all, "write"]
|
|
132
133
|
};
|
|
133
134
|
const list = (options = {}) => call("GET", `${base}?q=${encodeURIComponent(JSON.stringify(options))}`);
|
|
134
135
|
const get = (id) => call("GET", `${base}/${id}`);
|
package/dist/db/audit.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare function entityTables(schema: Record<string, unknown>): string[];
|
|
|
10
10
|
/**
|
|
11
11
|
* Puts the change trigger on each of `tables` the database has (one a
|
|
12
12
|
* migration hasn't made yet is tracked on the migrate that makes it), and
|
|
13
|
-
* makes light_changes append-only. Runs after migrating.
|
|
13
|
+
* makes light_changes append-only and indexed by time. Runs after migrating.
|
|
14
14
|
*/
|
|
15
15
|
export declare function trackTables(sql: postgres.Sql, tables: string[]): Promise<void>;
|
|
16
16
|
/** A table, or a column of one, that a migration destroys: what it archives first. */
|
package/dist/db/audit.js
CHANGED
|
@@ -106,10 +106,16 @@ begin
|
|
|
106
106
|
archived, case when col is null then 'rows' else 'values' end, concat_ws('.', tbl, col), tag);
|
|
107
107
|
end $$;
|
|
108
108
|
`;
|
|
109
|
-
/**
|
|
110
|
-
|
|
109
|
+
/**
|
|
110
|
+
* light_changes is a drizzle table (db/schema exports it), so it exists only
|
|
111
|
+
* once migrated. Its created_at index (open pages' poll reads the last
|
|
112
|
+
* few seconds) is made here, not in the schema, so every app gets it on its
|
|
113
|
+
* next migrate without a migration of its own.
|
|
114
|
+
*/
|
|
115
|
+
const CHANGES_SETUP = `
|
|
111
116
|
create or replace trigger light_append_only before update or delete or truncate on light_changes
|
|
112
117
|
for each statement execute function light_append_only();
|
|
118
|
+
create index if not exists light_changes_created_idx on light_changes (created_at);
|
|
113
119
|
`;
|
|
114
120
|
/** Makes light_archive and the functions migrations and triggers call. Runs before migrating. */
|
|
115
121
|
export async function prepareAudit(sql) {
|
|
@@ -130,11 +136,11 @@ export function entityTables(schema) {
|
|
|
130
136
|
/**
|
|
131
137
|
* Puts the change trigger on each of `tables` the database has (one a
|
|
132
138
|
* migration hasn't made yet is tracked on the migrate that makes it), and
|
|
133
|
-
* makes light_changes append-only. Runs after migrating.
|
|
139
|
+
* makes light_changes append-only and indexed by time. Runs after migrating.
|
|
134
140
|
*/
|
|
135
141
|
export async function trackTables(sql, tables) {
|
|
136
142
|
await sql `select light_track(name) from unnest(${sql.array(tables)}::text[]) as name where to_regclass(quote_ident(name)) is not null`;
|
|
137
|
-
await sql.unsafe(
|
|
143
|
+
await sql.unsafe(CHANGES_SETUP);
|
|
138
144
|
}
|
|
139
145
|
const snapshotTable = z.looseObject({
|
|
140
146
|
name: z.string(),
|
package/dist/db/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ const PLATFORM_MIGRATE = "/run/mii/migrate.sock";
|
|
|
21
21
|
/** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
|
|
22
22
|
const MIGRATE_LOCK_KEY = 4_815_162_342;
|
|
23
23
|
const SYNC_LOCK_KEY = 4_815_162_343;
|
|
24
|
-
/** Exit code of a migrate whose migrations committed but whose change triggers didn't go on. */
|
|
24
|
+
/** Exit code of a migrate whose migrations committed but whose change tracking (triggers, index) didn't go on. */
|
|
25
25
|
const APPLIED_UNTRACKED = 2;
|
|
26
26
|
// pg_advisory_lock waits for the one before it; a process that dies releases it.
|
|
27
27
|
// Only on a direct connection: behind a transaction pooler the lock stays on a
|
|
@@ -101,7 +101,7 @@ async function applyMigrations() {
|
|
|
101
101
|
console.log("Database is up to date with db/migrations.");
|
|
102
102
|
// The migrations are committed: a failure here must not take their files back.
|
|
103
103
|
return await trackTables(client, tables).then(() => 0, (error) => {
|
|
104
|
-
console.error(`
|
|
104
|
+
console.error(`Change tracking didn't go on (${error instanceof Error ? error.message : String(error)}); the next migrate tries again.`);
|
|
105
105
|
return APPLIED_UNTRACKED;
|
|
106
106
|
});
|
|
107
107
|
}
|
package/dist/entities.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Entity, PlatformColumn } from "./schema.js";
|
|
2
2
|
export declare const ENTITIES_PATH = "/api/light/entities";
|
|
3
|
+
/** Which tables changed since a cursor (open pages' poll): `?since=<cursor>` → {cursor, changedTables}. */
|
|
4
|
+
export declare const CHANGES_PATH = "/api/light/entities/_changes";
|
|
5
|
+
/** The TanStack Query key everything cached from one table sits under; a change to the table invalidates it. */
|
|
6
|
+
export declare const tableQueryKey: (table: string) => string[];
|
|
3
7
|
/** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
|
|
4
8
|
export declare const LIST_LIMIT = 1000;
|
|
5
9
|
/** A value as JSON carries it: a Date column arrives as its ISO string. */
|
package/dist/entities.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export const ENTITIES_PATH = "/api/light/entities";
|
|
2
|
+
/** Which tables changed since a cursor (open pages' poll): `?since=<cursor>` → {cursor, changedTables}. */
|
|
3
|
+
export const CHANGES_PATH = `${ENTITIES_PATH}/_changes`;
|
|
4
|
+
/** The TanStack Query key everything cached from one table sits under; a change to the table invalidates it. */
|
|
5
|
+
export const tableQueryKey = (table) => ["light", `${ENTITIES_PATH}/${table}`];
|
|
2
6
|
/** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
|
|
3
7
|
export const LIST_LIMIT = 1000;
|
|
4
8
|
/** A refused or failed entities call, with the route's own reason. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function LiveData(): null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Mounted by MiiPaneBridge, so every app has it: the page's query client and router, given to live-data.ts.
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useRouter } from "@tanstack/react-router";
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { fetchChanges, pollChanges, refreshTables } from "./live-data.js";
|
|
7
|
+
export function LiveData() {
|
|
8
|
+
const queries = useQueryClient();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const feed = pollChanges(queries, fetchChanges);
|
|
12
|
+
return feed((tables) => {
|
|
13
|
+
refreshTables(queries, tables);
|
|
14
|
+
void router.invalidate();
|
|
15
|
+
});
|
|
16
|
+
}, [queries, router]);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
/** How often a visible tab asks what changed: an agent's write shows within about this long. */
|
|
4
|
+
export declare const POLL_MS = 3000;
|
|
5
|
+
declare const changes: z.ZodObject<{
|
|
6
|
+
cursor: z.ZodString;
|
|
7
|
+
changedTables: z.ZodArray<z.ZodString>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
/**
|
|
10
|
+
* A poll's answer; "unreachable" (offline, the server restarting: asked again
|
|
11
|
+
* next interval); or "signed-out" (the login page itself; the page's own
|
|
12
|
+
* reads send a person to sign in, never the poll).
|
|
13
|
+
*/
|
|
14
|
+
type Poll = z.infer<typeof changes> | "unreachable" | "signed-out";
|
|
15
|
+
/** Calls `onChanged` with the tables that changed, as it hears; the returned function stops it. */
|
|
16
|
+
export type ChangeFeed = (onChanged: (tables: string[]) => void) => () => void;
|
|
17
|
+
/**
|
|
18
|
+
* Polls `_changes` as a TanStack query. One the server breaks on (or answers
|
|
19
|
+
* in a shape it shouldn't) is the query's error, asked again next interval
|
|
20
|
+
* and reported once (reportError: the pane and Sentry hear it) until a poll works again.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pollChanges(queries: QueryClient, fetchChanges: (since: string | null) => Promise<Poll>): ChangeFeed;
|
|
23
|
+
export declare function fetchChanges(since: string | null): Promise<Poll>;
|
|
24
|
+
/**
|
|
25
|
+
* Each changed table's on-screen queries refetch, and the route loaders. A
|
|
26
|
+
* table this tab is writing is left to its write, whose own refetch follows
|
|
27
|
+
* it (client.ts's settle) and brings these changes too.
|
|
28
|
+
*/
|
|
29
|
+
export declare function refreshTables(queries: QueryClient, tables: string[]): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Open pages show other people's and agents' changes: what changed is told,
|
|
2
|
+
// not pushed — a changed table's on-screen queries and the page's route
|
|
3
|
+
// loaders refetch, through TanStack Query's own invalidation. How a page hears
|
|
4
|
+
// of a change is a ChangeFeed; today a poll of `_changes` that TanStack Query
|
|
5
|
+
// itself runs (refetchInterval while the tab is visible, at once on focus and
|
|
6
|
+
// reconnect), later a pushed one (SSE) behind the same type.
|
|
7
|
+
import { QueryObserver } from "@tanstack/react-query";
|
|
8
|
+
import { z } from "zod/v4";
|
|
9
|
+
import { CHANGES_PATH, EntityError, tableQueryKey } from "../entities.js";
|
|
10
|
+
/** How often a visible tab asks what changed: an agent's write shows within about this long. */
|
|
11
|
+
export const POLL_MS = 3_000;
|
|
12
|
+
const changes = z.object({ cursor: z.string(), changedTables: z.array(z.string()) });
|
|
13
|
+
/**
|
|
14
|
+
* Polls `_changes` as a TanStack query. One the server breaks on (or answers
|
|
15
|
+
* in a shape it shouldn't) is the query's error, asked again next interval
|
|
16
|
+
* and reported once (reportError: the pane and Sentry hear it) until a poll works again.
|
|
17
|
+
*/
|
|
18
|
+
export function pollChanges(queries, fetchChanges) {
|
|
19
|
+
return (onChanged) => {
|
|
20
|
+
let cursor = null;
|
|
21
|
+
let reported = false;
|
|
22
|
+
const observer = new QueryObserver(queries, {
|
|
23
|
+
queryKey: ["light", CHANGES_PATH],
|
|
24
|
+
queryFn: () => fetchChanges(cursor),
|
|
25
|
+
// Signed out: nothing to watch until the tab is shown again.
|
|
26
|
+
refetchInterval: (query) => (query.state.data === "signed-out" ? false : POLL_MS),
|
|
27
|
+
// "always", not true: an app's own default staleTime must not hold back a returning tab.
|
|
28
|
+
refetchOnWindowFocus: "always",
|
|
29
|
+
refetchOnReconnect: "always",
|
|
30
|
+
retry: false,
|
|
31
|
+
// Each answer once: not again as the next poll starts.
|
|
32
|
+
notifyOnChangeProps: ["data", "error"]
|
|
33
|
+
});
|
|
34
|
+
const stop = observer.subscribe((result) => {
|
|
35
|
+
if (result.status === "error") {
|
|
36
|
+
if (!reported)
|
|
37
|
+
reportError(result.error);
|
|
38
|
+
reported = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (result.status !== "success" || result.data === "unreachable")
|
|
42
|
+
return;
|
|
43
|
+
reported = false;
|
|
44
|
+
if (result.data === "signed-out")
|
|
45
|
+
return;
|
|
46
|
+
cursor = result.data.cursor;
|
|
47
|
+
if (result.data.changedTables.length > 0)
|
|
48
|
+
onChanged(result.data.changedTables);
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
stop();
|
|
52
|
+
queries.removeQueries({ queryKey: ["light", CHANGES_PATH], exact: true });
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export async function fetchChanges(since) {
|
|
57
|
+
const path = since === null ? CHANGES_PATH : `${CHANGES_PATH}?since=${encodeURIComponent(since)}`;
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(path, { credentials: "same-origin" });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// fetch rejects only when no answer came back at all.
|
|
64
|
+
return "unreachable";
|
|
65
|
+
}
|
|
66
|
+
if (response.status === 401)
|
|
67
|
+
return "signed-out";
|
|
68
|
+
if (!response.ok)
|
|
69
|
+
throw new EntityError(`GET ${CHANGES_PATH} failed (${response.status}).`, response.status);
|
|
70
|
+
return changes.parse(await response.json());
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Each changed table's on-screen queries refetch, and the route loaders. A
|
|
74
|
+
* table this tab is writing is left to its write, whose own refetch follows
|
|
75
|
+
* it (client.ts's settle) and brings these changes too.
|
|
76
|
+
*/
|
|
77
|
+
export function refreshTables(queries, tables) {
|
|
78
|
+
for (const table of tables) {
|
|
79
|
+
const key = tableQueryKey(table);
|
|
80
|
+
if (queries.isMutating({ mutationKey: [...key, "write"] }) === 0)
|
|
81
|
+
void queries.invalidateQueries({ queryKey: key });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function MiiPaneBridge():
|
|
1
|
+
export declare function MiiPaneBridge(): import("react").JSX.Element;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
3
|
// How an org app talks to the platform when the platform is showing it in a pane.
|
|
3
4
|
//
|
|
4
5
|
// The platform embeds the deployment in an iframe. That iframe is a different origin,
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
import { useEffect, useRef } from "react";
|
|
18
19
|
import { z } from "zod/v4";
|
|
19
20
|
import { PLAZA_FRAME_ORIGINS } from "./index.js";
|
|
21
|
+
import { LiveData } from "./live-data-mount.js";
|
|
20
22
|
const PLAZA_ORIGINS = PLAZA_FRAME_ORIGINS;
|
|
21
23
|
/** The platform → app. Anything else the platform posts is not for this bridge. */
|
|
22
24
|
const inbound = z.discriminatedUnion("type", [
|
|
@@ -177,7 +179,8 @@ export function MiiPaneBridge() {
|
|
|
177
179
|
outline.remove();
|
|
178
180
|
};
|
|
179
181
|
}, []);
|
|
180
|
-
|
|
182
|
+
// The one platform component every app renders, so every app's pages stay fresh.
|
|
183
|
+
return _jsx(LiveData, {});
|
|
181
184
|
}
|
|
182
185
|
function rectOf(element) {
|
|
183
186
|
const rect = element.getBoundingClientRect();
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
// whose functions forget their own guard serves the org's data to anyone.
|
|
4
4
|
/**
|
|
5
5
|
* The template's server functions a signed-out visitor needs to become signed
|
|
6
|
-
* in: who am I, the two ways to sign in, signing out,
|
|
7
|
-
* page renders with.
|
|
6
|
+
* in: who am I, the two ways to sign in, a forgotten password, signing out,
|
|
7
|
+
* and the config the login page renders with.
|
|
8
8
|
*/
|
|
9
9
|
const SIGN_IN_SERVER_FNS = [
|
|
10
10
|
"src/lib/auth.ts#getSessionUser",
|
|
11
11
|
"src/lib/auth.ts#redeemPaneCode",
|
|
12
12
|
"src/lib/auth.ts#loginWithPassword",
|
|
13
|
-
"src/lib/auth.ts#
|
|
13
|
+
"src/lib/auth.ts#requestPasswordReset",
|
|
14
|
+
"src/lib/auth.ts#signOut",
|
|
14
15
|
"src/routes/__root.tsx#getPlatformConfig"
|
|
15
16
|
];
|
|
16
17
|
/**
|
package/dist/schema.js
CHANGED
|
@@ -84,7 +84,9 @@ export const lightChanges = pgTable("light_changes", {
|
|
|
84
84
|
created_at: timestamp({ withTimezone: true, mode: "string" })
|
|
85
85
|
.notNull()
|
|
86
86
|
.defaultNow()
|
|
87
|
-
},
|
|
87
|
+
},
|
|
88
|
+
// Its created_at index is made by src/db/audit.ts on every migrate; declared here, its migration would fail on every app.
|
|
89
|
+
(t) => [index("light_changes_row_idx").on(t.table_name, t.row_id)]);
|
|
88
90
|
const bytea = customType({ dataType: () => "bytea" });
|
|
89
91
|
/**
|
|
90
92
|
* The app's own copy of each photo or logo its rows point at, kept on the
|
package/dist/server/entities.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// The one route behind `app.table(...)`: list, get, create, update and delete
|
|
2
2
|
// on any entity table, each request checked against the table's own Zod
|
|
3
3
|
// schema and the app's door. Each write names its person for the table's
|
|
4
|
-
// trigger, which records the change in light_changes (src/db/audit.ts)
|
|
5
|
-
|
|
4
|
+
// trigger, which records the change in light_changes (src/db/audit.ts), which
|
|
5
|
+
// `_changes` reads to tell open pages what to refetch (src/platform/live-data.ts).
|
|
6
|
+
import { and, asc, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
|
6
7
|
import { isDeepStrictEqual } from "node:util";
|
|
7
8
|
import { createSchemaFactory } from "drizzle-zod";
|
|
8
9
|
import { isSameOrigin } from "../chat/server.js";
|
|
9
10
|
import { z } from "zod/v4";
|
|
10
|
-
import { ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
|
|
11
|
-
import { PLATFORM_COLUMNS, fieldSource, isEntity } from "../schema.js";
|
|
11
|
+
import { CHANGES_PATH, ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
|
|
12
|
+
import { PLATFORM_COLUMNS, fieldSource, isEntity, lightChanges } from "../schema.js";
|
|
12
13
|
import { env } from "./env.js";
|
|
13
14
|
/** Drizzle's text column types: what a search reads. */
|
|
14
15
|
const SEARCHED_TYPES = new Set(["PgText", "PgVarchar", "PgChar"]);
|
|
@@ -35,6 +36,16 @@ const target = z.union([
|
|
|
35
36
|
z.tuple([z.string(), z.uuid()]).transform(([table, id]) => ({ table, id }))
|
|
36
37
|
]);
|
|
37
38
|
const postgresError = z.object({ cause: z.object({ code: z.string(), message: z.string() }) });
|
|
39
|
+
/**
|
|
40
|
+
* How far behind its cursor a poll looks. A change's created_at is its
|
|
41
|
+
* transaction's start, so one that commits after a poll read can sit before
|
|
42
|
+
* that poll's cursor; the next poll still sees it if the transaction took
|
|
43
|
+
* less than this. The cost: a change is reported by the poll that sees it
|
|
44
|
+
* and by the next one, so the page refetches twice.
|
|
45
|
+
*/
|
|
46
|
+
const CHANGES_OVERLAP = "5 seconds";
|
|
47
|
+
/** A cursor: the ISO time the server gave with the last answer. */
|
|
48
|
+
const cursor = z.iso.datetime({ offset: true });
|
|
38
49
|
export function createEntitiesHandler(options) {
|
|
39
50
|
const tables = new Map(Object.values(options.schema)
|
|
40
51
|
.filter(isEntity)
|
|
@@ -51,6 +62,13 @@ export function createEntitiesHandler(options) {
|
|
|
51
62
|
if (!configured && !local) {
|
|
52
63
|
return refuse(503, "auth_not_configured", "Sign-in isn't configured for this app.");
|
|
53
64
|
}
|
|
65
|
+
if (url.pathname === CHANGES_PATH) {
|
|
66
|
+
if (request.method !== "GET")
|
|
67
|
+
return refuse(405, "method", `${request.method} isn't something ${url.pathname} does.`);
|
|
68
|
+
if (!mayRead(await options.session(request)))
|
|
69
|
+
return refuse(401, "not_signed_in", "Sign in to use this app.");
|
|
70
|
+
return changes(options.db(), url);
|
|
71
|
+
}
|
|
54
72
|
const path = target.safeParse(url.pathname.slice(ENTITIES_PATH.length + 1).split("/"));
|
|
55
73
|
const table = path.success ? tables.get(path.data.table) : undefined;
|
|
56
74
|
if (!path.success || table === undefined) {
|
|
@@ -61,10 +79,8 @@ export function createEntitiesHandler(options) {
|
|
|
61
79
|
const id = path.data.id;
|
|
62
80
|
const method = request.method;
|
|
63
81
|
if (method === "GET") {
|
|
64
|
-
|
|
65
|
-
if (session.user === null && session.mode === "members") {
|
|
82
|
+
if (!mayRead(session))
|
|
66
83
|
return refuse(401, "not_signed_in", "Sign in to use this app.");
|
|
67
|
-
}
|
|
68
84
|
return id === null ? list(db, table, url) : answer(() => get(db, table, id));
|
|
69
85
|
}
|
|
70
86
|
if (table.entity.$access.write === "agents") {
|
|
@@ -165,6 +181,25 @@ async function list(db, table, url) {
|
|
|
165
181
|
.offset(offset);
|
|
166
182
|
return Response.json(rows);
|
|
167
183
|
}
|
|
184
|
+
/** Members-only: someone signed in; anyone with the link (and a local dev server): anyone. */
|
|
185
|
+
function mayRead(session) {
|
|
186
|
+
return session.user !== null || session.mode !== "members";
|
|
187
|
+
}
|
|
188
|
+
/** The tables changed since `since` (none without one: a tab's first poll only takes a cursor), and the cursor for the next poll. */
|
|
189
|
+
async function changes(db, url) {
|
|
190
|
+
const since = url.searchParams.get("since");
|
|
191
|
+
const read = since === null ? null : cursor.safeParse(since);
|
|
192
|
+
if (read !== null && !read.success)
|
|
193
|
+
return refuse(400, "invalid", "since must be the cursor of the last answer.");
|
|
194
|
+
const rows = await db
|
|
195
|
+
.select({
|
|
196
|
+
cursor: sql `to_json(clock_timestamp())#>>'{}'`,
|
|
197
|
+
changedTables: sql `coalesce(array_agg(distinct ${lightChanges.table_name}), '{}')`
|
|
198
|
+
})
|
|
199
|
+
.from(lightChanges)
|
|
200
|
+
.where(read === null ? sql `false` : gt(lightChanges.created_at, sql `${read.data}::timestamptz - ${CHANGES_OVERLAP}::interval`));
|
|
201
|
+
return Response.json(exactlyOne(rows));
|
|
202
|
+
}
|
|
168
203
|
async function get(db, table, id) {
|
|
169
204
|
return atMostOne(await db.select().from(table.entity).where(eq(table.id, id)));
|
|
170
205
|
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
2
|
import { type Contracts } from "../invoke/server.js";
|
|
3
|
-
export type { SessionState, SessionUser, SignIn } from "./session.js";
|
|
3
|
+
export type { PasswordResetRequest, SessionState, SessionUser, SignIn } from "./session.js";
|
|
4
4
|
export { cookieAttributes, safeNext, SESSION_COOKIE } from "../auth/core.js";
|
|
5
5
|
export { defineContracts, InvocationError, type Contracts } from "../invoke/server.js";
|
|
6
6
|
export { createHealthHandler } from "./health.js";
|
|
@@ -15,6 +15,7 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
|
|
|
15
15
|
configured: () => boolean;
|
|
16
16
|
/** Email and password, checked by the platform; the session to set as a cookie, or why not. */
|
|
17
17
|
signIn: (email: string, password: string) => Promise<import("./session.js").SignIn>;
|
|
18
|
+
requestPasswordReset: (email: string, returnTo: string) => Promise<import("./session.js").PasswordResetRequest>;
|
|
18
19
|
/** The app's database, typed by its schema: `light.db.select().from(companies)`. */
|
|
19
20
|
readonly db: PostgresJsDatabase<S>;
|
|
20
21
|
/** `/api/light/entities/*`: what `app.table(...)` calls. */
|
package/dist/server/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { createMiiInvoke } from "../invoke/server.js";
|
|
|
9
9
|
import { createEntitiesHandler } from "./entities.js";
|
|
10
10
|
import { env } from "./env.js";
|
|
11
11
|
import { createImageHandler } from "./images.js";
|
|
12
|
-
import { readSession, signIn } from "./session.js";
|
|
12
|
+
import { readSession, requestPasswordReset, signIn } from "./session.js";
|
|
13
13
|
export { cookieAttributes, safeNext, SESSION_COOKIE } from "../auth/core.js";
|
|
14
14
|
export { defineContracts, InvocationError } from "../invoke/server.js";
|
|
15
15
|
export { createHealthHandler } from "./health.js";
|
|
@@ -38,6 +38,7 @@ export function createLightServer(options) {
|
|
|
38
38
|
configured: () => env().configured,
|
|
39
39
|
/** Email and password, checked by the platform; the session to set as a cookie, or why not. */
|
|
40
40
|
signIn: (email, password) => signIn(auth, email, password),
|
|
41
|
+
requestPasswordReset: (email, returnTo) => requestPasswordReset(email, returnTo),
|
|
41
42
|
/** The app's database, typed by its schema: `light.db.select().from(companies)`. */
|
|
42
43
|
get db() {
|
|
43
44
|
return db();
|
package/dist/server/session.d.ts
CHANGED
|
@@ -16,3 +16,10 @@ export type SignIn = {
|
|
|
16
16
|
};
|
|
17
17
|
/** The login form's whole job: the platform checks the password, the app starts a session. */
|
|
18
18
|
export declare function signIn(auth: MiiAuthCore, email: string, password: string): Promise<SignIn>;
|
|
19
|
+
export type PasswordResetRequest = {
|
|
20
|
+
ok: true;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function requestPasswordReset(email: string, returnTo: string): Promise<PasswordResetRequest>;
|
package/dist/server/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Who is asking, as the app's door sees it, and signing in through the platform.
|
|
2
|
-
import { signInWithPlaza } from "../auth/core.js";
|
|
2
|
+
import { requestPasswordResetWithPlaza, signInWithPlaza } from "../auth/core.js";
|
|
3
3
|
import { readCookie } from "../chat/server.js";
|
|
4
4
|
import { env } from "./env.js";
|
|
5
5
|
/** The session a request's cookie names, and whether the app is members-only. */
|
|
@@ -34,3 +34,11 @@ export async function signIn(auth, email, password) {
|
|
|
34
34
|
const session = await auth.issueSession(member.id, result.session);
|
|
35
35
|
return { ok: true, ...session };
|
|
36
36
|
}
|
|
37
|
+
export async function requestPasswordReset(email, returnTo) {
|
|
38
|
+
if (!env().configured)
|
|
39
|
+
return { ok: false, message: "Sign-in is not configured." };
|
|
40
|
+
const result = await requestPasswordResetWithPlaza(email, returnTo);
|
|
41
|
+
if (!result.ok)
|
|
42
|
+
return { ok: false, message: result.failure.message };
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylight/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"url": "https://github.com/shubh9/mii.git",
|
|
13
13
|
"directory": "packages/app-sdk"
|
|
14
14
|
},
|
|
15
|
-
"packageManager": "pnpm@11.0.9",
|
|
16
15
|
"files": [
|
|
17
16
|
"dist",
|
|
18
17
|
"bin"
|
|
@@ -42,17 +41,9 @@
|
|
|
42
41
|
"default": "./dist/vite.js"
|
|
43
42
|
}
|
|
44
43
|
},
|
|
45
|
-
"scripts": {
|
|
46
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
47
|
-
"typecheck": "tsc --noEmit",
|
|
48
|
-
"lint": "eslint src test --max-warnings 0",
|
|
49
|
-
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
50
|
-
"check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
51
|
-
"prepack": "pnpm run build",
|
|
52
|
-
"brand": "tsx scripts/write-brand.ts"
|
|
53
|
-
},
|
|
54
44
|
"peerDependencies": {
|
|
55
45
|
"@tanstack/react-query": ">=5.69",
|
|
46
|
+
"@tanstack/react-router": ">=1.170",
|
|
56
47
|
"drizzle-orm": ">=0.45",
|
|
57
48
|
"postgres": ">=3",
|
|
58
49
|
"react": ">=19",
|
|
@@ -70,6 +61,7 @@
|
|
|
70
61
|
"devDependencies": {
|
|
71
62
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",
|
|
72
63
|
"@tanstack/react-query": "^5.104.0",
|
|
64
|
+
"@tanstack/react-router": "^1.170.41",
|
|
73
65
|
"@types/node": "^22.20.4",
|
|
74
66
|
"@types/react": "^19.3.0",
|
|
75
67
|
"@types/react-dom": "^19.3.0",
|
|
@@ -82,5 +74,13 @@
|
|
|
82
74
|
"typescript-eslint": "^8.70.1",
|
|
83
75
|
"vite": "^8.2.0",
|
|
84
76
|
"zod": "^4.6.5"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
80
|
+
"typecheck": "tsc --noEmit",
|
|
81
|
+
"lint": "eslint src test --max-warnings 0",
|
|
82
|
+
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
83
|
+
"check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
84
|
+
"brand": "tsx scripts/write-brand.ts"
|
|
85
85
|
}
|
|
86
|
-
}
|
|
86
|
+
}
|