@trylight/app 0.2.0 → 0.3.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/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
@@ -1,3 +1,3 @@
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
- export const APP_ORIGINS = ["https://plaza.miis.run"];
3
+ export const APP_ORIGINS = ["https://trylight.ai", "https://plaza.miis.run"];
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: ["light", base],
128
- list: (options) => ["light", base, "list", options],
129
- pages: (options) => ["light", base, "pages", options],
130
- get: (id) => ["light", base, "get", id],
131
- write: ["light", base, "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}`);
@@ -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
- /** light_changes is a drizzle table (db/schema exports it), so it exists only once migrated. */
110
- const CHANGES_APPEND_ONLY = `
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(CHANGES_APPEND_ONLY);
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(`The change triggers didn't go on (${error instanceof Error ? error.message : String(error)}); the next migrate tries again.`);
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
  }
@@ -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(): null;
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
- return null;
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();
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
- }, (t) => [index("light_changes_row_idx").on(t.table_name, t.row_id)]);
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
@@ -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
- import { and, asc, desc, eq, getTableColumns, getTableName, ilike, inArray, isNull, or, sql } from "drizzle-orm";
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
- // Members-only: someone signed in; anyone with the link (and a local dev server): anyone.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@tanstack/react-query": ">=5.69",
56
+ "@tanstack/react-router": ">=1.170",
56
57
  "drizzle-orm": ">=0.45",
57
58
  "postgres": ">=3",
58
59
  "react": ">=19",
@@ -70,6 +71,7 @@
70
71
  "devDependencies": {
71
72
  "@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",
72
73
  "@tanstack/react-query": "^5.104.0",
74
+ "@tanstack/react-router": "^1.170.41",
73
75
  "@types/node": "^22.20.4",
74
76
  "@types/react": "^19.3.0",
75
77
  "@types/react-dom": "^19.3.0",