@trylight/app 0.3.8 → 0.3.12

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 CHANGED
@@ -8,8 +8,8 @@ its tables and their typed client, its door, its contracts and its chat.
8
8
  | Import | What |
9
9
  |---|---|
10
10
  | `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
11
- | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useInfiniteList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
- | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
11
+ | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useInfiniteList/useCount/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
+ | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password, code?)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
13
13
  | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge, `fetchThroughRestart` (`createStart`'s `serverFns.fetch`) |
14
14
  | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
15
15
  | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
@@ -37,7 +37,9 @@ new ones load (`isSwitching`), sorts on the server, and prefetches the
37
37
  sibling views it is given once it settles. A write refetches every page a
38
38
  list has loaded, one after another, before its mutation settles; the
39
39
  change itself shows at once. `search` matches words in any
40
- text column, case-insensitively, on the server.
40
+ text column, case-insensitively, on the server. `useCount({ where, search })` is how
41
+ many rows the same list would hold, counted on the server (a paged list's tab
42
+ total), and refetched after every write like the rest.
41
43
 
42
44
  Joins and aggregates stay typed server functions over `light.db`.
43
45
 
@@ -60,8 +62,10 @@ every `POLL_MS` while the tab is visible, at once on focus and reconnect. The
60
62
  route answers from `light_changes` with the tables changed since the cursor
61
63
  (looking `CHANGES_OVERLAP` behind it, for transactions that committed after
62
64
  the last poll read) and the next cursor; never rows. Each changed table's
63
- queries are invalidated by its key (except a table this tab is writing: the
64
- write's own refetch follows it), and the route loaders reloaded. `light_changes`' `created_at` index is made
65
+ queries are invalidated: those under its key, and those that list its table in
66
+ `meta.tables` (a query that reads several tables, e.g. a server function's
67
+ join), except a table this tab is writing (the write's own refetch follows
68
+ it); the route loaders are reloaded. `light_changes`' `created_at` index is made
65
69
  after every migrate (`src/db/audit.ts`). The poll is one `ChangeFeed`; a pushed
66
70
  transport can replace it.
67
71
 
@@ -1,7 +1,7 @@
1
1
  import type { AuthContext } from "./context.js";
2
2
  import { type PlazaSession } from "./plaza.js";
3
3
  /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
- export { SESSION_COOKIE } from "../platform/contract.js";
4
+ export { APP_SESSION_COOKIE as SESSION_COOKIE } from "../brand.generated.js";
5
5
  /**
6
6
  * How often a session asks the platform whether it still stands. Someone removed from
7
7
  * the org is signed out of the app within this long; their chat stops at once,
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { checkPlazaSession, revokePlazaSession } from "./plaza.js";
3
3
  /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
- export { SESSION_COOKIE } from "../platform/contract.js";
4
+ export { APP_SESSION_COOKIE as SESSION_COOKIE } from "../brand.generated.js";
5
5
  /**
6
6
  * How often a session asks the platform whether it still stands. Someone removed from
7
7
  * the org is signed out of the app within this long; their chat stops at once,
@@ -25,7 +25,7 @@ export type PlazaSession = {
25
25
  * (the platform unreachable, the platform answering nonsense), so the route can report them.
26
26
  */
27
27
  export type SignInFailure = {
28
- readonly kind: "credentials" | "not_a_member" | "throttled";
28
+ readonly kind: "credentials" | "not_a_member" | "unverified" | "throttled" | "code_required" | "wrong_code" | "two_factor_not_set_up";
29
29
  readonly message: string;
30
30
  } | {
31
31
  readonly kind: "unavailable";
@@ -44,9 +44,10 @@ export type SignInResult = ({
44
44
  };
45
45
  /**
46
46
  * Check an email and password with the platform, and get back who the person is and
47
- * the session the platform signed for them.
47
+ * the session the platform signed for them. `code` is from their authenticator app,
48
+ * sent once the platform has asked for it.
48
49
  */
49
- export declare function signInWithPlaza(email: string, password: string): Promise<SignInResult>;
50
+ export declare function signInWithPlaza(email: string, password: string, code?: string): Promise<SignInResult>;
50
51
  export type PasswordResetResult = {
51
52
  readonly ok: true;
52
53
  } | {
@@ -1,18 +1,19 @@
1
1
  import { z } from "zod/v4";
2
2
  import { miiAuthConfig } from "./config.js";
3
- import { PLATFORM_AUTH_PATHS } from "../platform/contract.js";
3
+ import { EMAIL_NOT_VERIFIED_REFUSAL, PLATFORM_AUTH_PATHS } from "../platform/contract.js";
4
4
  /**
5
5
  * Check an email and password with the platform, and get back who the person is and
6
- * the session the platform signed for them.
6
+ * the session the platform signed for them. `code` is from their authenticator app,
7
+ * sent once the platform has asked for it.
7
8
  */
8
- export async function signInWithPlaza(email, password) {
9
+ export async function signInWithPlaza(email, password, code) {
9
10
  const { plazaUrl, appId } = miiAuthConfig();
10
11
  let response;
11
12
  try {
12
13
  response = await fetch(`${plazaUrl}${PLATFORM_AUTH_PATHS.signIn}`, {
13
14
  method: "POST",
14
15
  headers: { "Content-Type": "application/json" },
15
- body: JSON.stringify({ appId, email, password }),
16
+ body: JSON.stringify({ appId, email, password, code: typedCode.parse(code) }),
16
17
  cache: "no-store"
17
18
  });
18
19
  }
@@ -46,6 +47,12 @@ export async function signInWithPlaza(email, password) {
46
47
  failure: failureFor(response.status, await response.text())
47
48
  };
48
49
  }
50
+ /** A blank code field is no code: the platform refuses an empty one outright. */
51
+ const typedCode = z
52
+ .string()
53
+ .trim()
54
+ .transform((typed) => (typed === "" ? undefined : typed))
55
+ .optional();
49
56
  /** `returnTo` is this app's login, where the platform's reset page sends the person afterwards. */
50
57
  export async function requestPasswordResetWithPlaza(email, returnTo) {
51
58
  const { plazaUrl, appId } = miiAuthConfig();
@@ -155,11 +162,33 @@ function asSignedIn(body) {
155
162
  const { session, ...identity } = read.data;
156
163
  return { identity, session };
157
164
  }
165
+ // A 403 is also "not a member"; the refusal's code tells them apart.
166
+ const unverifiedRefusal = z.object({ error: z.literal(EMAIL_NOT_VERIFIED_REFUSAL) });
167
+ // Not every answer is JSON: a proxy's error page, say.
168
+ function jsonIn(body) {
169
+ try {
170
+ return JSON.parse(body);
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
158
176
  function failureFor(status, body) {
177
+ const answer = jsonIn(body);
178
+ const refusal = codeRefusal.safeParse(answer);
179
+ if (refusal.success) {
180
+ return { kind: refusal.data.error, message: refusal.data.detail };
181
+ }
159
182
  if (status === 401) {
160
183
  return { kind: "credentials", message: "Wrong email or password." };
161
184
  }
162
185
  if (status === 403) {
186
+ if (unverifiedRefusal.safeParse(answer).success) {
187
+ return {
188
+ kind: "unverified",
189
+ message: "Confirm your email first: we sent you a link."
190
+ };
191
+ }
163
192
  return {
164
193
  kind: "not_a_member",
165
194
  // Said plainly: they have just proved they own the account, so this is
@@ -179,3 +208,8 @@ function failureFor(status, body) {
179
208
  cause: new Error(`Platform sign-in answered ${status}: ${body.slice(0, 300)}`)
180
209
  };
181
210
  }
211
+ // The refusals the platform words itself: it knows the lockout and the setup path.
212
+ const codeRefusal = z.object({
213
+ error: z.enum(["code_required", "wrong_code", "two_factor_not_set_up", "throttled"]),
214
+ detail: z.string().min(1)
215
+ });
@@ -13,6 +13,10 @@ export const SIGN_IN_PROBLEMS = {
13
13
  title: "That account can't open this app",
14
14
  body: "The password was right, but the account isn't in this app's org. Ask whoever runs it to add you."
15
15
  },
16
+ unverified: {
17
+ title: "Confirm your email first",
18
+ body: "We emailed you a link. Open it, then sign in here again."
19
+ },
16
20
  deactivated: {
17
21
  title: "Your access here has been turned off",
18
22
  body: "Ask whoever runs this app to turn it back on."
@@ -1,2 +1,4 @@
1
1
  /** The platform app's origins, current domain first. */
2
2
  export declare const APP_ORIGINS: readonly string[];
3
+ /** The cookie holding an app's own session. */
4
+ export declare const APP_SESSION_COOKIE = "mii_session";
@@ -1,3 +1,5 @@
1
1
  // Generated from config/brand.json by scripts/write-brand.ts. Do not edit.
2
2
  /** The platform app's origins, current domain first. */
3
3
  export const APP_ORIGINS = ["https://trylight.ai", "https://plaza.miis.run"];
4
+ /** The cookie holding an app's own session. */
5
+ export const APP_SESSION_COOKIE = "mii_session";
package/dist/client.d.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  import { type QueryKey } from "@tanstack/react-query";
2
2
  import type { Contracts } from "./invoke/server.js";
3
- import { EntityError, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
3
+ import { EntityError, type CountOptions, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
4
4
  import type { Entity } from "./schema.js";
5
- export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
5
+ declare module "@tanstack/react-query" {
6
+ interface Register {
7
+ /** `tables`: the tables a query reads when it isn't keyed under one (a server function's join); a write to any refetches it. */
8
+ queryMeta: {
9
+ tables: readonly Entity[];
10
+ };
11
+ }
12
+ }
13
+ export { EntityError, LIST_LIMIT, type CountOptions, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
6
14
  export type { FieldSource, FieldSources } from "./schema.js";
7
15
  export { siteOf } from "./schema.js";
8
16
  export { imageSrc } from "./images.js";
@@ -68,10 +76,12 @@ declare function tableClient<T extends Entity>(entity: T): {
68
76
  /**
69
77
  * The query key everything this table caches sits under. A page's own
70
78
  * query over the table (a server function's totals) keyed under it is
71
- * refetched after every write too.
79
+ * refetched after every write too; one over several tables names them in
80
+ * `meta.tables` instead.
72
81
  */
73
82
  key: string[];
74
83
  list: (options?: ListOptions<Row<T>>) => Promise<Row<T>[]>;
84
+ count: (options?: CountOptions<Row<T>>) => Promise<number>;
75
85
  get: (id: string) => Promise<Row<T>>;
76
86
  create: (data: NewRow<T>) => Promise<Row<T>>;
77
87
  update: (id: string, patch: Patch<T>) => Promise<Row<T>>;
@@ -686,6 +696,14 @@ declare function tableClient<T extends Entity>(entity: T): {
686
696
  isEnabled: boolean;
687
697
  fetchStatus: import("@tanstack/react-query").FetchStatus;
688
698
  };
699
+ /**
700
+ * How many rows match, counted on the server, so a paged list's tab can
701
+ * show its total; refetched after any write to this table. New options
702
+ * keep the last count until theirs lands.
703
+ *
704
+ * const drafts = app.table(posts).useCount({ where: { status: "draft" } });
705
+ */
706
+ useCount: (options?: CountOptions<Row<T>>) => import("@tanstack/react-query").UseQueryResult<number, EntityError>;
689
707
  /** One row; `null` waits (nothing open yet). */
690
708
  useGet: (id: string | null) => import("@tanstack/react-query").UseQueryResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError>;
691
709
  useCreate: () => import("@tanstack/react-query").UseMutationResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError, NewRow<T>, unknown>;
package/dist/client.js CHANGED
@@ -13,6 +13,7 @@ import { z } from "zod/v4";
13
13
  import { ENTITIES_PATH } from "./platform/contract.js";
14
14
  import { EntityError, LIST_LIMIT, tableQueryKey } from "./entities.js";
15
15
  import { fetchThroughRestart } from "./platform/restart.js";
16
+ import { reads } from "./platform/live-data.js";
16
17
  export { EntityError, LIST_LIMIT } from "./entities.js";
17
18
  export { siteOf } from "./schema.js";
18
19
  export { imageSrc } from "./images.js";
@@ -124,16 +125,19 @@ function putBack(client, id, before) {
124
125
  }
125
126
  /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
126
127
  function tableClient(entity) {
127
- const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
128
- const all = tableQueryKey(getTableName(entity));
128
+ const table = getTableName(entity);
129
+ const base = `${ENTITIES_PATH}/${table}`;
130
+ const all = tableQueryKey(table);
129
131
  const keys = {
130
132
  all,
131
133
  list: (options) => [...all, "list", options],
132
134
  pages: (options) => [...all, "pages", options],
135
+ count: (options) => [...all, "count", options],
133
136
  get: (id) => [...all, "get", id],
134
137
  write: [...all, "write"]
135
138
  };
136
139
  const list = (options = {}) => call("GET", `${base}?q=${encodeURIComponent(JSON.stringify(options))}`);
140
+ const count = (options = {}) => call("GET", `${base}?count=${encodeURIComponent(JSON.stringify(options))}`);
137
141
  const get = (id) => call("GET", `${base}/${id}`);
138
142
  const create = (data) => call("POST", base, json(data));
139
143
  const update = (id, patch) => call("PATCH", `${base}/${id}`, json(patch));
@@ -168,7 +172,7 @@ function tableClient(entity) {
168
172
  const client = useQueryClient();
169
173
  const refetch = async () => {
170
174
  if (client.isMutating({ mutationKey: keys.write }) === 1)
171
- await client.invalidateQueries({ queryKey: keys.all });
175
+ await client.invalidateQueries({ predicate: (query) => reads(query, table) });
172
176
  };
173
177
  return (_data, error) => {
174
178
  if (error === null)
@@ -180,10 +184,12 @@ function tableClient(entity) {
180
184
  /**
181
185
  * The query key everything this table caches sits under. A page's own
182
186
  * query over the table (a server function's totals) keyed under it is
183
- * refetched after every write too.
187
+ * refetched after every write too; one over several tables names them in
188
+ * `meta.tables` instead.
184
189
  */
185
190
  key: keys.all,
186
191
  list,
192
+ count,
187
193
  get,
188
194
  create,
189
195
  update,
@@ -269,6 +275,14 @@ function tableClient(entity) {
269
275
  };
270
276
  return { ...query, ...view };
271
277
  },
278
+ /**
279
+ * How many rows match, counted on the server, so a paged list's tab can
280
+ * show its total; refetched after any write to this table. New options
281
+ * keep the last count until theirs lands.
282
+ *
283
+ * const drafts = app.table(posts).useCount({ where: { status: "draft" } });
284
+ */
285
+ useCount: (options = {}) => useQuery({ queryKey: keys.count(options), queryFn: () => count(options), placeholderData: keepPreviousData }),
272
286
  /** One row; `null` waits (nothing open yet). */
273
287
  useGet: (id) => useQuery({
274
288
  queryKey: keys.get(id),
@@ -27,6 +27,8 @@ export type ListOptions<R> = {
27
27
  limit?: number;
28
28
  offset?: number;
29
29
  };
30
+ /** What `useCount` takes: the rows a list with the same `where` and `search` would hold. */
31
+ export type CountOptions<R> = Pick<ListOptions<R>, "where" | "search">;
30
32
  /** A refused or failed entities call, with the route's own reason. */
31
33
  export declare class EntityError extends Error {
32
34
  readonly status: number;
@@ -16,6 +16,8 @@ export declare const PLATFORM_AUTH_PATHS: {
16
16
  /** "Continue with Google / Microsoft": the platform signs the person in and returns with a one-time code. */
17
17
  socialSignIn: string;
18
18
  };
19
+ /** The platform's sign-in refusing a right password: the login's address is unconfirmed (a 403, like "not a member"). */
20
+ export declare const EMAIL_NOT_VERIFIED_REFUSAL = "email_not_verified";
19
21
  /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
20
22
  export declare const APP_CALLER_HEADERS: {
21
23
  appId: string;
@@ -31,7 +33,6 @@ export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
31
33
  export declare const STARTING_HEADER = "x-mii-app-starting";
32
34
  /** Every TanStack query the SDK owns sits under this key. */
33
35
  export declare const QUERY_ROOT = "light";
34
- export declare const SESSION_COOKIE = "mii_session";
35
36
  export declare const STORAGE_KEYS: {
36
37
  /** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
37
38
  chunkRetryBuildId: string;
@@ -2,7 +2,7 @@
2
2
  // apps and the platform's pane depend on every value here: rename freely, never
3
3
  // change a value without both sides moving.
4
4
  const NAMESPACE = "light";
5
- /** The platform's former name: the pane protocol, the session cookie and two routes still carry it. */
5
+ /** The platform's former name: the pane protocol and two routes still carry it. */
6
6
  const LEGACY_NAMESPACE = "mii";
7
7
  export const ENTITIES_PATH = `/api/${NAMESPACE}/entities`;
8
8
  export const CHANGES_PATH = `${ENTITIES_PATH}/_changes`;
@@ -22,6 +22,8 @@ export const PLATFORM_AUTH_PATHS = {
22
22
  /** "Continue with Google / Microsoft": the platform signs the person in and returns with a one-time code. */
23
23
  socialSignIn: "/api/app-auth/social"
24
24
  };
25
+ /** The platform's sign-in refusing a right password: the login's address is unconfirmed (a 403, like "not a member"). */
26
+ export const EMAIL_NOT_VERIFIED_REFUSAL = "email_not_verified";
25
27
  /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
26
28
  export const APP_CALLER_HEADERS = { appId: "x-app-id", secret: "x-app-secret" };
27
29
  /** The server env that secret is provisioned in. */
@@ -34,7 +36,6 @@ export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
34
36
  export const STARTING_HEADER = `x-${LEGACY_NAMESPACE}-app-starting`;
35
37
  /** Every TanStack query the SDK owns sits under this key. */
36
38
  export const QUERY_ROOT = NAMESPACE;
37
- export const SESSION_COOKIE = `${LEGACY_NAMESPACE}_session`;
38
39
  export const STORAGE_KEYS = {
39
40
  /** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
40
41
  chunkRetryBuildId: `${NAMESPACE}:chunk-retry-build-id`,
@@ -1,4 +1,4 @@
1
- import { type QueryClient } from "@tanstack/react-query";
1
+ import { type Query, type QueryClient } from "@tanstack/react-query";
2
2
  import { z } from "zod/v4";
3
3
  declare const changes: z.ZodObject<{
4
4
  cursor: z.ZodString;
@@ -20,9 +20,12 @@ export type ChangeFeed = (onChanged: (tables: string[]) => void) => () => void;
20
20
  export declare function pollChanges(queries: QueryClient, fetchChanges: (since: string | null) => Promise<Poll>): ChangeFeed;
21
21
  export declare function fetchChanges(since: string | null): Promise<Poll>;
22
22
  /**
23
- * Each changed table's on-screen queries refetch, and the route loaders. A
24
- * table this tab is writing is left to its write, whose own refetch follows
25
- * it (client.ts's settle) and brings these changes too.
23
+ * Each changed table's on-screen queries refetch, in one go: those keyed under
24
+ * it, and those that read it among others and say so in `meta.tables`. A table
25
+ * this tab is writing is left to its write, whose own refetch follows it
26
+ * (client.ts's settle) and brings these changes too.
26
27
  */
27
28
  export declare function refreshTables(queries: QueryClient, tables: string[]): void;
29
+ /** Whether a query shows `table`'s rows: keyed under it, or listing it in `meta.tables` (typed in client.ts). */
30
+ export declare function reads(query: Query, table: string): boolean;
28
31
  export {};
@@ -4,7 +4,8 @@
4
4
  // of a change is a ChangeFeed; today a poll of `_changes` that TanStack Query
5
5
  // itself runs (refetchInterval while the tab is visible, at once on focus and
6
6
  // reconnect), later a pushed one (SSE) behind the same type.
7
- import { QueryObserver } from "@tanstack/react-query";
7
+ import { QueryObserver, partialMatchKey } from "@tanstack/react-query";
8
+ import { getTableName } from "drizzle-orm";
8
9
  import { z } from "zod/v4";
9
10
  import { EntityError, tableQueryKey } from "../entities.js";
10
11
  import { CHANGES_PATH, QUERY_ROOT } from "./contract.js";
@@ -73,14 +74,18 @@ export async function fetchChanges(since) {
73
74
  return changes.parse(await response.json());
74
75
  }
75
76
  /**
76
- * Each changed table's on-screen queries refetch, and the route loaders. A
77
- * table this tab is writing is left to its write, whose own refetch follows
78
- * it (client.ts's settle) and brings these changes too.
77
+ * Each changed table's on-screen queries refetch, in one go: those keyed under
78
+ * it, and those that read it among others and say so in `meta.tables`. A table
79
+ * this tab is writing is left to its write, whose own refetch follows it
80
+ * (client.ts's settle) and brings these changes too.
79
81
  */
80
82
  export function refreshTables(queries, tables) {
81
- for (const table of tables) {
82
- const key = tableQueryKey(table);
83
- if (queries.isMutating({ mutationKey: [...key, "write"] }) === 0)
84
- void queries.invalidateQueries({ queryKey: key });
85
- }
83
+ const quiet = tables.filter((table) => queries.isMutating({ mutationKey: [...tableQueryKey(table), "write"] }) === 0);
84
+ if (quiet.length > 0)
85
+ void queries.invalidateQueries({ predicate: (query) => quiet.some((table) => reads(query, table)) });
86
+ }
87
+ /** Whether a query shows `table`'s rows: keyed under it, or listing it in `meta.tables` (typed in client.ts). */
88
+ export function reads(query, table) {
89
+ // eslint-disable-next-line no-restricted-syntax -- TanStack's meta is optional: most queries name no tables
90
+ return partialMatchKey(query.queryKey, tableQueryKey(table)) || query.meta?.tables.some((entity) => getTableName(entity) === table) === true;
86
91
  }
@@ -1,4 +1,4 @@
1
- // The one route behind `app.table(...)`: list, get, create, update and delete
1
+ // The one route behind `app.table(...)`: list, count, 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
4
  // trigger, which records the change in light_changes (src/db/audit.ts), which
@@ -82,7 +82,10 @@ export function createEntitiesHandler(options) {
82
82
  if (method === "GET") {
83
83
  if (!mayRead(session))
84
84
  return refuse(401, "not_signed_in", "Sign in to use this app.");
85
- return id === null ? list(db, table, url) : answer(() => get(db, table, id));
85
+ if (id !== null)
86
+ return answer(() => get(db, table, id));
87
+ const counted = url.searchParams.get("count");
88
+ return counted === null ? list(db, table, url) : count(db, table, counted);
86
89
  }
87
90
  if (table.entity.$access.write === "agents") {
88
91
  return refuse(403, "agents_only", `${table.name} is written by the org's agents, not from the app.`);
@@ -133,6 +136,10 @@ function tableRules(entity) {
133
136
  .filter(([name, c]) => !(name in PLATFORM_COLUMNS) && SEARCHED_TYPES.has(c.columnType))
134
137
  .map(([, c]) => c);
135
138
  const match = Object.fromEntries(Object.entries(createSelectSchema(entity).shape).map(([key, value]) => [key, z.union([value, z.array(value)])]));
139
+ const filters = {
140
+ where: z.object(match).partial().strict().default({}),
141
+ search: z.string().trim().max(SEARCH_MAX).default("")
142
+ };
136
143
  return {
137
144
  name: getTableName(entity),
138
145
  entity,
@@ -143,13 +150,13 @@ function tableRules(entity) {
143
150
  update: writable(createUpdateSchema(entity).shape),
144
151
  list: z
145
152
  .object({
146
- where: z.object(match).partial().strict().default({}),
147
- search: z.string().trim().max(SEARCH_MAX).default(""),
153
+ ...filters,
148
154
  sort: z.literal([...names, ...names.map((name) => `-${name}`)]).default("-created_at"),
149
155
  limit: z.int().min(1).max(LIST_LIMIT).default(LIST_LIMIT),
150
156
  offset: z.int().min(0).default(0)
151
157
  })
152
- .strict()
158
+ .strict(),
159
+ count: z.object(filters).strict()
153
160
  };
154
161
  }
155
162
  async function list(db, table, url) {
@@ -159,6 +166,28 @@ async function list(db, table, url) {
159
166
  return refuse(400, "invalid", z.prettifyError(query.error));
160
167
  const { where, search, sort, limit, offset } = query.data;
161
168
  const order = sort.startsWith("-") ? desc(table.column(sort.slice(1))) : asc(table.column(sort));
169
+ const rows = await db
170
+ .select()
171
+ .from(table.entity)
172
+ .where(and(...matching(table, where, search)))
173
+ // The id breaks ties, so paging with offset never repeats or skips a row.
174
+ .orderBy(order, asc(table.id))
175
+ .limit(limit)
176
+ .offset(offset);
177
+ return Response.json(rows);
178
+ }
179
+ /** How many rows a list with the same `where` and `search` would hold, without fetching them. */
180
+ async function count(db, table, asked) {
181
+ const query = table.count.safeParse(readJson(asked));
182
+ if (!query.success)
183
+ return refuse(400, "invalid", z.prettifyError(query.error));
184
+ const rows = await db
185
+ .select({ count: sql `count(*)::int` })
186
+ .from(table.entity)
187
+ .where(and(...matching(table, query.data.where, query.data.search)));
188
+ return Response.json(exactlyOne(rows).count);
189
+ }
190
+ function matching(table, where, search) {
162
191
  const conditions = Object.entries(where).map(([name, value]) => {
163
192
  const column = table.column(name);
164
193
  if (value === null)
@@ -172,15 +201,7 @@ async function list(db, table, url) {
172
201
  const pattern = `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
173
202
  conditions.push(or(...table.searched.map((column) => ilike(column, pattern))) ?? sql `false`);
174
203
  }
175
- const rows = await db
176
- .select()
177
- .from(table.entity)
178
- .where(and(...conditions))
179
- // The id breaks ties, so paging with offset never repeats or skips a row.
180
- .orderBy(order, asc(table.id))
181
- .limit(limit)
182
- .offset(offset);
183
- return Response.json(rows);
204
+ return conditions;
184
205
  }
185
206
  /** Members-only: someone signed in; anyone with the link (and a local dev server): anyone. */
186
207
  function mayRead(session) {
@@ -13,8 +13,8 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
13
13
  session: (request: Request) => Promise<import("./session.js").SessionState>;
14
14
  /** Whether sign-in is configured (a bare local dev server has none). */
15
15
  configured: () => boolean;
16
- /** Email and password, checked by the platform; the session to set as a cookie, or why not. */
17
- signIn: (email: string, password: string) => Promise<import("./session.js").SignIn>;
16
+ /** Email and password (and the authenticator code once asked for), checked by the platform; the session to set as a cookie, or why not. */
17
+ signIn: (email: string, password: string, code?: string) => Promise<import("./session.js").SignIn>;
18
18
  requestPasswordReset: (email: string, returnTo: string) => Promise<import("./session.js").PasswordResetRequest>;
19
19
  /** The app's database, typed by its schema: `light.db.select().from(companies)`. */
20
20
  readonly db: PostgresJsDatabase<S>;
@@ -36,8 +36,8 @@ export function createLightServer(options) {
36
36
  session,
37
37
  /** Whether sign-in is configured (a bare local dev server has none). */
38
38
  configured: () => env().configured,
39
- /** Email and password, checked by the platform; the session to set as a cookie, or why not. */
40
- signIn: (email, password) => signIn(auth, email, password),
39
+ /** Email and password (and the authenticator code once asked for), checked by the platform; the session to set as a cookie, or why not. */
40
+ signIn: (email, password, code) => signIn(auth, email, password, code),
41
41
  requestPasswordReset: (email, returnTo) => requestPasswordReset(email, returnTo),
42
42
  /** The app's database, typed by its schema: `light.db.select().from(companies)`. */
43
43
  get db() {
@@ -10,12 +10,15 @@ export type SignIn = {
10
10
  ok: true;
11
11
  token: string;
12
12
  expiresAt: Date;
13
- } | {
13
+ }
14
+ /** `needsCode`: show a field for the authenticator code and send it with the password again. */
15
+ | {
14
16
  ok: false;
15
17
  message: string;
18
+ needsCode: boolean;
16
19
  };
17
20
  /** The login form's whole job: the platform checks the password, the app starts a session. */
18
- export declare function signIn(auth: MiiAuthCore, email: string, password: string): Promise<SignIn>;
21
+ export declare function signIn(auth: MiiAuthCore, email: string, password: string, code?: string): Promise<SignIn>;
19
22
  export type PasswordResetRequest = {
20
23
  ok: true;
21
24
  } | {
@@ -21,15 +21,17 @@ export async function readSession(auth, request) {
21
21
  return { user: { id, name, email, avatarUrl, role }, mode };
22
22
  }
23
23
  /** The login form's whole job: the platform checks the password, the app starts a session. */
24
- export async function signIn(auth, email, password) {
24
+ export async function signIn(auth, email, password, code) {
25
25
  if (!env().configured)
26
- return { ok: false, message: "Sign-in is not configured." };
27
- const result = await signInWithPlaza(email, password);
28
- if (!result.ok)
29
- return { ok: false, message: result.failure.message };
26
+ return { ok: false, message: "Sign-in is not configured.", needsCode: false };
27
+ const result = await signInWithPlaza(email, password, code);
28
+ if (!result.ok) {
29
+ const { kind, message } = result.failure;
30
+ return { ok: false, message, needsCode: kind === "code_required" || kind === "wrong_code" };
31
+ }
30
32
  const member = await auth.upsertMemberFromIdentity(result.identity);
31
33
  if (member.status !== "active") {
32
- return { ok: false, message: "Your account is not active in this workspace." };
34
+ return { ok: false, message: "Your account is not active in this workspace.", needsCode: false };
33
35
  }
34
36
  const session = await auth.issueSession(member.id, result.session);
35
37
  return { ok: true, ...session };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.3.8",
3
+ "version": "0.3.12",
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,6 +12,7 @@
12
12
  "url": "https://github.com/shubh9/mii.git",
13
13
  "directory": "packages/app-sdk"
14
14
  },
15
+ "packageManager": "pnpm@11.0.9",
15
16
  "files": [
16
17
  "dist",
17
18
  "bin"
@@ -41,6 +42,15 @@
41
42
  "default": "./dist/vite.js"
42
43
  }
43
44
  },
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
+ },
44
54
  "peerDependencies": {
45
55
  "@tanstack/react-query": ">=5.69",
46
56
  "@tanstack/react-router": ">=1.170",
@@ -74,13 +84,5 @@
74
84
  "typescript-eslint": "^8.70.1",
75
85
  "vite": "^8.2.0",
76
86
  "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
87
  }
86
- }
88
+ }