@trylight/app 0.1.8 → 0.2.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.
Files changed (71) hide show
  1. package/README.md +15 -22
  2. package/dist/auth/config.d.ts +5 -0
  3. package/dist/auth/config.js +16 -0
  4. package/dist/auth/context.d.ts +11 -0
  5. package/dist/auth/context.js +1 -0
  6. package/dist/auth/core.d.ts +64 -0
  7. package/dist/auth/core.js +50 -0
  8. package/dist/auth/db.d.ts +7 -0
  9. package/dist/auth/db.js +57 -0
  10. package/dist/auth/lookup.d.ts +47 -0
  11. package/dist/auth/lookup.js +120 -0
  12. package/dist/auth/mode.d.ts +8 -0
  13. package/dist/auth/mode.js +52 -0
  14. package/dist/auth/next-path.d.ts +1 -0
  15. package/dist/auth/next-path.js +10 -0
  16. package/dist/auth/plaza.d.ts +68 -0
  17. package/dist/auth/plaza.js +154 -0
  18. package/dist/auth/problems.d.ts +4 -0
  19. package/dist/auth/problems.js +36 -0
  20. package/dist/auth/users.d.ts +8 -0
  21. package/dist/auth/users.js +25 -0
  22. package/dist/brand.generated.d.ts +2 -0
  23. package/dist/brand.generated.js +3 -0
  24. package/dist/chat/paths.d.ts +10 -0
  25. package/dist/chat/paths.js +10 -0
  26. package/dist/chat/server.d.ts +24 -0
  27. package/dist/chat/server.js +161 -0
  28. package/dist/client.d.ts +517 -19
  29. package/dist/client.js +244 -24
  30. package/dist/db/cli.js +3 -40
  31. package/dist/db/migrations.d.ts +2 -2
  32. package/dist/db/migrations.js +2 -2
  33. package/dist/entities.d.ts +3 -1
  34. package/dist/entities.js +1 -1
  35. package/dist/invoke/contracts.d.ts +28 -0
  36. package/dist/invoke/contracts.js +1 -0
  37. package/dist/invoke/error.d.ts +11 -0
  38. package/dist/invoke/error.js +13 -0
  39. package/dist/invoke/index.d.ts +73 -0
  40. package/dist/invoke/index.js +274 -0
  41. package/dist/invoke/paths.d.ts +2 -0
  42. package/dist/invoke/paths.js +2 -0
  43. package/dist/invoke/server.d.ts +45 -0
  44. package/dist/invoke/server.js +259 -0
  45. package/dist/platform/index.d.ts +71 -0
  46. package/dist/platform/index.js +80 -0
  47. package/dist/platform/pane-bridge.d.ts +1 -0
  48. package/dist/platform/pane-bridge.js +216 -0
  49. package/dist/platform/server-fn-door.d.ts +27 -0
  50. package/dist/platform/server-fn-door.js +32 -0
  51. package/dist/platform.d.ts +2 -2
  52. package/dist/platform.js +3 -3
  53. package/dist/request-error.d.ts +10 -0
  54. package/dist/request-error.js +29 -0
  55. package/dist/schema.d.ts +1 -1
  56. package/dist/server/db-retry.d.ts +7 -0
  57. package/dist/server/db-retry.js +58 -0
  58. package/dist/server/entities.js +18 -3
  59. package/dist/server/health.d.ts +17 -0
  60. package/dist/server/health.js +42 -0
  61. package/dist/server/index.d.ts +7 -6
  62. package/dist/server/index.js +7 -6
  63. package/dist/server/sentry-tunnel.d.ts +6 -0
  64. package/dist/server/sentry-tunnel.js +52 -0
  65. package/dist/server/session.d.ts +1 -1
  66. package/dist/server/session.js +2 -2
  67. package/dist/uuid.d.ts +1 -0
  68. package/dist/uuid.js +7 -0
  69. package/package.json +4 -4
  70. package/dist/db/baseline.d.ts +0 -36
  71. package/dist/db/baseline.js +0 -439
package/dist/client.js CHANGED
@@ -5,26 +5,128 @@
5
5
  // `app.table(companies)` reads and writes that table's rows through the
6
6
  // entities route; its hooks keep TanStack Query's cache in step, so an edit
7
7
  // shows at once and never snaps back. `app.contracts` asks the org's agents.
8
- import { keepPreviousData, skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
- import { createInvoker } from "@shubh90/app-runtime/invoke";
10
- import { getTableName } from "drizzle-orm";
8
+ import { hashKey, keepPreviousData, skipToken, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useEffect, useMemo, useState } from "react";
10
+ import { createInvoker } from "./invoke/index.js";
11
+ import { getTableColumns, getTableName } from "drizzle-orm";
11
12
  import { z } from "zod/v4";
12
- import { ENTITIES_PATH, EntityError } from "./entities.js";
13
+ import { ENTITIES_PATH, EntityError, LIST_LIMIT } from "./entities.js";
13
14
  export { EntityError, LIST_LIMIT } from "./entities.js";
14
15
  export { siteOf } from "./schema.js";
15
16
  export { imageSrc } from "./images.js";
16
- export { InvocationError } from "@shubh90/app-runtime/invoke";
17
+ export { InvocationError } from "./invoke/index.js";
17
18
  export function createApp() {
18
19
  return { table: tableClient, contracts: createInvoker() };
19
20
  }
20
21
  /** Every list's rows before its first answer: one array, so they never change identity while pending. */
21
22
  const NO_ROWS = [];
23
+ /** Rows one page of `useInfiniteList` asks for; scrolling near the end asks for the next. */
24
+ export const PAGE_SIZE = 100;
25
+ /** How long after a list settles its sibling views' first pages are fetched: the page's own work goes first. */
26
+ const PREFETCH_AFTER_MS = 300;
27
+ /** A prefetched first page counts as fresh this long, so settling again doesn't fetch it again. */
28
+ const PREFETCH_FRESH_MS = 30_000;
29
+ /** A cached row as any query under a table's key holds it: an object with the row's id. */
30
+ const cachedRow = z.looseObject({ id: z.string() });
31
+ /** A cached useInfiniteList: its pages of rows. */
32
+ const cachedPages = z.looseObject({ pages: z.array(z.array(z.unknown())) });
33
+ const cachedSources = z.record(z.string(), z.unknown());
34
+ /** The rows a cached query holds: a list's, each of a useInfiniteList's pages, or useGet's one row. */
35
+ function rowsOf(data) {
36
+ if (Array.isArray(data))
37
+ return [data];
38
+ const paged = cachedPages.safeParse(data);
39
+ if (paged.success)
40
+ return paged.data.pages;
41
+ return cachedRow.safeParse(data).success ? [[data]] : [];
42
+ }
43
+ /** The same query with its rows replaced, page by page, in the shape it came in. */
44
+ function withRows(data, rows) {
45
+ if (Array.isArray(data))
46
+ return rows[0];
47
+ const paged = cachedPages.safeParse(data);
48
+ if (paged.success)
49
+ return { ...paged.data, pages: rows };
50
+ // One row (useGet): kept as it was when the change takes it out (an open panel's row stays while it closes).
51
+ const [[row = data] = []] = rows;
52
+ return row;
53
+ }
54
+ const isRow = (item, id) => {
55
+ const row = cachedRow.safeParse(item);
56
+ return row.success && row.data.id === id;
57
+ };
58
+ /**
59
+ * Every query under a table's key with one row changed (null takes it out):
60
+ * useList's, useInfiniteList's, useGet's, and a page's own server-function
61
+ * list keyed under the table, so a write shows everywhere at once. Returns
62
+ * where the row was, so a write that fails puts back just that row, never
63
+ * another write's change made since. A query holding anything else is left alone.
64
+ */
65
+ function changeRow(client, key, id, fields, change) {
66
+ const before = [];
67
+ for (const [queryKey, data] of client.getQueriesData({ queryKey: key })) {
68
+ const pages = rowsOf(data);
69
+ pages.forEach((rows, page) => {
70
+ const index = rows.findIndex((item) => isRow(item, id));
71
+ if (index >= 0)
72
+ before.push({ key: queryKey, page, index, row: rows[index], fields });
73
+ });
74
+ if (!pages.some((rows) => rows.some((item) => isRow(item, id))))
75
+ continue;
76
+ client.setQueryData(queryKey, withRows(data, pages.map((rows) => rows.flatMap((item) => {
77
+ const row = cachedRow.safeParse(item);
78
+ if (!row.success || row.data.id !== id)
79
+ return [item];
80
+ const next = change(row.data);
81
+ return next === null ? [] : [next];
82
+ }))));
83
+ }
84
+ return before;
85
+ }
86
+ /** The queries under a table's key that hold one row: the only ones a write to it touches (or cancels). */
87
+ function holding(client, key, id) {
88
+ return client
89
+ .getQueriesData({ queryKey: key })
90
+ .filter(([, data]) => rowsOf(data).some((rows) => rows.some((item) => isRow(item, id))))
91
+ .map(([queryKey]) => queryKey);
92
+ }
93
+ /** A failed write taken back: its fields of the row as they were (a delete: the row, where it was); everything else as it is now. */
94
+ function putBack(client, id, before) {
95
+ for (const { key, page, index, row, fields } of before) {
96
+ client.setQueryData(key, (data) => {
97
+ const pages = rowsOf(data);
98
+ const rows = pages[page];
99
+ if (rows === undefined)
100
+ return data;
101
+ const was = cachedRow.safeParse(row);
102
+ const there = rows.some((item) => isRow(item, id));
103
+ // An update's row gone meanwhile (deleted) stays gone; only a delete puts its row back.
104
+ if (!there && fields !== null)
105
+ return data;
106
+ const back = there
107
+ ? rows.map((item) => {
108
+ if (!isRow(item, id))
109
+ return item;
110
+ const now = cachedRow.safeParse(item);
111
+ if (fields === null || !now.success || !was.success)
112
+ return row;
113
+ // Each field as it was, and gone if it wasn't there (a slim list the patch added a column to).
114
+ const kept = Object.entries(now.data).filter(([field]) => !fields.includes(field));
115
+ const restored = fields.filter((field) => Object.hasOwn(was.data, field)).map((field) => [field, was.data[field]]);
116
+ return Object.fromEntries([...kept, ...restored]);
117
+ })
118
+ : [...rows.slice(0, index), row, ...rows.slice(index)];
119
+ return withRows(data, pages.map((each, at) => (at === page ? back : each)));
120
+ });
121
+ }
122
+ }
22
123
  /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
23
124
  function tableClient(entity) {
24
125
  const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
25
126
  const keys = {
26
127
  all: ["light", base],
27
128
  list: (options) => ["light", base, "list", options],
129
+ pages: (options) => ["light", base, "pages", options],
28
130
  get: (id) => ["light", base, "get", id],
29
131
  write: ["light", base, "write"]
30
132
  };
@@ -35,18 +137,41 @@ function tableClient(entity) {
35
137
  const remove = async (id) => {
36
138
  await call("DELETE", `${base}/${id}`);
37
139
  };
140
+ const columns = new Set(Object.keys(getTableColumns(entity)));
141
+ const isColumn = (column) => columns.has(column);
142
+ /** A header's sort as the server takes it. DataTable sorts only columns `canSort` allows, so any other is a bug. */
143
+ const sortBy = ({ column, desc }) => {
144
+ if (!isColumn(column))
145
+ throw new Error(`${getTableName(entity)} has no column ${column} to sort by.`);
146
+ return desc ? `-${column}` : column;
147
+ };
148
+ const pagesQuery = (options) => {
149
+ const { pageSize = PAGE_SIZE, ...rest } = options;
150
+ return {
151
+ queryKey: keys.pages(options),
152
+ queryFn: ({ pageParam }) => list({ ...rest, limit: pageSize, offset: pageParam }),
153
+ initialPageParam: 0,
154
+ // A full page may have more after it; a short one is the end (or one an optimistic delete shortened, until the write's refetch).
155
+ getNextPageParam: (last, all) => last.length < pageSize ? undefined : all.reduce((sum, page) => sum + page.length, 0)
156
+ };
157
+ };
38
158
  /**
39
159
  * After a write, refetch the table once the last write in flight settles.
40
- * Returning the refetch keeps the mutation pending until it lands, so an
41
- * edit never flickers back to its old value; a failed write is corrected
42
- * by the same refetch.
160
+ * A saved write stays pending until the refetch lands, so an edit never
161
+ * flickers back to its old value. A failed one is already taken back, so
162
+ * its error reaches the page (the toast saying so) without waiting.
43
163
  */
44
164
  const useSettle = () => {
45
165
  const client = useQueryClient();
46
- return async () => {
166
+ const refetch = async () => {
47
167
  if (client.isMutating({ mutationKey: keys.write }) === 1)
48
168
  await client.invalidateQueries({ queryKey: keys.all });
49
169
  };
170
+ return (_data, error) => {
171
+ if (error === null)
172
+ return refetch();
173
+ void refetch();
174
+ };
50
175
  };
51
176
  return {
52
177
  /**
@@ -69,9 +194,77 @@ function tableClient(entity) {
69
194
  useList: (options = {}) => {
70
195
  const query = useQuery({ queryKey: keys.list(options), queryFn: () => list(options) });
71
196
  const { data: rows = NO_ROWS } = query;
197
+ const view = {
198
+ rows,
199
+ isPending: query.isPending,
200
+ loadError: query.isLoadingError ? query.error : null,
201
+ refetch: query.refetch,
202
+ // Stopped at the server's limit (not one the page chose): DataTable says it shows only the first ones, never drops the rest silently.
203
+ hasMore: options.limit === undefined && rows.length >= LIST_LIMIT,
204
+ loadMore: null,
205
+ isLoadingMore: false,
206
+ moreFailed: false,
207
+ isSwitching: false,
208
+ serverSort: null
209
+ };
72
210
  // A spread reads every field, so the page re-renders on any of them (isFetching
73
211
  // too); a list page shows those states anyway, and it keeps the result plain.
74
- return { ...query, rows };
212
+ return { ...query, ...view };
213
+ },
214
+ /**
215
+ * A list of any size, a page at a time: `loadMore` (DataTable calls it as
216
+ * the end scrolls into view) fetches the next. New options (a tab, a
217
+ * filter, a search) keep the last rows on screen with `isSwitching` until
218
+ * theirs land, so a click never blanks the table. `prefetch` names the
219
+ * sibling views (the other tabs): their first pages load once this one
220
+ * settles, so switching to one is instant. Sorting is the server's, over
221
+ * every row; `sort` is where it starts.
222
+ *
223
+ * const list = app.table(leads).useInfiniteList({ where: { status: tab } }, { prefetch: TABS.map((t) => ({ where: { status: t } })) });
224
+ * <DataTable list={list} … />
225
+ */
226
+ useInfiniteList: (options = {}, { prefetch = [] } = {}) => {
227
+ const client = useQueryClient();
228
+ const [sorting, setSorting] = useState(null);
229
+ const asked = sorting === null ? options : { ...options, sort: sortBy(sorting) };
230
+ const query = useInfiniteQuery({ ...pagesQuery(asked), placeholderData: keepPreviousData });
231
+ // Pages are by offset: a row added above the fold mid-scroll shifts the next page by one, so a row can come twice. The first stays.
232
+ const rows = useMemo(() => (query.data === undefined ? NO_ROWS : firstOfEach(query.data.pages.flat(), (row) => row.id)), [query.data]);
233
+ const settled = query.isSuccess && !query.isPlaceholderData;
234
+ // The sibling views as this one is sorted, less this one; keyed by their hash, so a page passing a new array each render changes nothing.
235
+ const siblings = prefetch.map((sibling) => (asked.sort === undefined ? sibling : { ...sibling, sort: asked.sort })).filter((sibling) => hashKey([sibling]) !== hashKey([asked]));
236
+ const siblingsKey = hashKey(siblings);
237
+ useEffect(() => {
238
+ if (!settled)
239
+ return;
240
+ const later = setTimeout(() => {
241
+ for (const sibling of siblings)
242
+ void client.prefetchInfiniteQuery({ ...pagesQuery(sibling), staleTime: PREFETCH_FRESH_MS });
243
+ }, PREFETCH_AFTER_MS);
244
+ return () => clearTimeout(later);
245
+ // `siblingsKey` stands for `siblings`, by value.
246
+ }, [settled, siblingsKey, client]);
247
+ const view = {
248
+ rows,
249
+ isPending: query.isPending,
250
+ loadError: query.isLoadingError ? query.error : null,
251
+ refetch: query.refetch,
252
+ hasMore: query.hasNextPage,
253
+ loadMore: () => {
254
+ if (query.hasNextPage && !query.isFetchingNextPage && !query.isPlaceholderData)
255
+ void query.fetchNextPage();
256
+ },
257
+ isLoadingMore: query.isFetchingNextPage,
258
+ moreFailed: query.isFetchNextPageError,
259
+ isSwitching: query.isPlaceholderData,
260
+ serverSort: {
261
+ // A header's choice, or the page's own `sort` until one is clicked.
262
+ sorting: sorting === null ? sortingOf(options.sort) : sorting,
263
+ setSorting,
264
+ canSort: isColumn
265
+ }
266
+ };
267
+ return { ...query, ...view };
75
268
  },
76
269
  /** One row; `null` waits (nothing open yet). */
77
270
  useGet: (id) => useQuery({
@@ -88,7 +281,11 @@ function tableClient(entity) {
88
281
  onSettled: settle
89
282
  });
90
283
  },
91
- /** Shows the patch at once in every cached list and row. */
284
+ /**
285
+ * Shows the patch at once in every cached list, page and row under the
286
+ * table's key; a save that fails takes it back at once (and the mutation's
287
+ * error says why), then the table refetches.
288
+ */
92
289
  useUpdate: () => {
93
290
  const client = useQueryClient();
94
291
  const settle = useSettle();
@@ -96,22 +293,26 @@ function tableClient(entity) {
96
293
  mutationKey: keys.write,
97
294
  mutationFn: ({ id, patch }) => update(id, patch),
98
295
  onMutate: async ({ id, patch }) => {
99
- await client.cancelQueries({ queryKey: keys.all });
296
+ // Only the queries holding this row: a tab switching meanwhile keeps loading.
297
+ await Promise.all(holding(client, keys.all, id).map((queryKey) => client.cancelQueries({ queryKey, exact: true })));
100
298
  // A value set here loses its old source until the server answers with who set it; sources sent merge in.
101
- const patched = (row) => row.id === id
102
- ? {
103
- ...row,
104
- ...patch,
105
- field_sources: Object.assign(Object.fromEntries(Object.entries(row.field_sources).filter(([column]) => !(column in patch))), patch.field_sources)
106
- }
107
- : row;
108
- client.setQueriesData({ queryKey: [...keys.all, "list"] }, (rows) => rows && rows.map(patched));
109
- client.setQueryData(keys.get(id), (row) => row && patched(row));
299
+ // A row cached without its sources (a page's slim list) just takes the patch.
300
+ const patched = (row) => {
301
+ const sources = cachedSources.safeParse(row.field_sources);
302
+ if (!sources.success)
303
+ return { ...row, ...patch };
304
+ const kept = Object.fromEntries(Object.entries(sources.data).filter(([column]) => !(column in patch)));
305
+ return { ...row, ...patch, field_sources: Object.assign(kept, patch.field_sources) };
306
+ };
307
+ // Its sources change with its values (an edited value loses its old source), so they go back with them.
308
+ return changeRow(client, keys.all, id, [...Object.keys(patch), "field_sources"], patched);
110
309
  },
310
+ // No context when onMutate itself threw: nothing was changed to put back.
311
+ onError: (_error, { id }, before = []) => putBack(client, id, before),
111
312
  onSettled: settle
112
313
  });
113
314
  },
114
- /** Takes the row out of every cached list at once. */
315
+ /** Takes the row out of every cached list at once; a delete that fails puts it back. */
115
316
  useDelete: () => {
116
317
  const client = useQueryClient();
117
318
  const settle = useSettle();
@@ -119,14 +320,33 @@ function tableClient(entity) {
119
320
  mutationKey: keys.write,
120
321
  mutationFn: remove,
121
322
  onMutate: async (id) => {
122
- await client.cancelQueries({ queryKey: keys.all });
123
- client.setQueriesData({ queryKey: [...keys.all, "list"] }, (rows) => rows && rows.filter((row) => row.id !== id));
323
+ await Promise.all(holding(client, keys.all, id).map((queryKey) => client.cancelQueries({ queryKey, exact: true })));
324
+ return changeRow(client, keys.all, id, null, () => null);
124
325
  },
326
+ onError: (_error, id, before = []) => putBack(client, id, before),
125
327
  onSettled: settle
126
328
  });
127
329
  }
128
330
  };
129
331
  }
332
+ /** A page's `sort` ("name", "-created_at") as a header shows it. */
333
+ function sortingOf(sort) {
334
+ if (sort === undefined)
335
+ return null;
336
+ return sort.startsWith("-") ? { column: sort.slice(1), desc: true } : { column: sort, desc: false };
337
+ }
338
+ /** Each row once, where it first came. */
339
+ function firstOfEach(rows, idOf) {
340
+ const seen = new Set();
341
+ const once = [];
342
+ for (const row of rows) {
343
+ if (seen.has(idOf(row)))
344
+ continue;
345
+ seen.add(idOf(row));
346
+ once.push(row);
347
+ }
348
+ return once;
349
+ }
130
350
  function json(body) {
131
351
  return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
132
352
  }
package/dist/db/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // `light-db sync`, `light-db migrate`, `light-db check`: the
2
- // app's `pnpm db:*` scripts; `light-db baseline` moves an older app's tables
3
- // under db/schema, once. Run from the app's root.
2
+ // app's `pnpm db:*` scripts. Run from the app's root.
4
3
  import { existsSync } from "node:fs";
5
4
  import { request } from "node:http";
6
5
  import { userInfo } from "node:os";
@@ -11,7 +10,6 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
11
10
  import postgres from "postgres";
12
11
  import { z } from "zod/v4";
13
12
  import { archiveNotices, entityTables, prepareAudit, trackTables } from "./audit.js";
14
- import { BaselineRefused, baseline as writeBaseline } from "./baseline.js";
15
13
  import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
16
14
  import { env } from "../server/env.js";
17
15
  /**
@@ -127,42 +125,7 @@ async function migrateThroughPlatform() {
127
125
  process.stdout.write(reply.output);
128
126
  return reply.code;
129
127
  }
130
- /** Writes db/schema and a first migration from the live tables of an app made before db/schema. */
131
- async function baseline() {
132
- const { databaseUrl } = env();
133
- if (databaseUrl === undefined)
134
- throw new Error("DATABASE_URL is not set: baseline reads the app's database.");
135
- let written;
136
- try {
137
- written = await writeBaseline(root, databaseUrl);
138
- }
139
- catch (error) {
140
- if (!(error instanceof BaselineRefused))
141
- throw error;
142
- console.error(error.message);
143
- return 1;
144
- }
145
- console.log(`Wrote db/schema from the database, and ${MIGRATIONS_DIR}/0000_baseline.sql recording its tables as they are:`);
146
- for (const table of written.tables) {
147
- const lacks = table.lacks.length > 0 ? ` (lacks ${table.lacks.join(", ")}; the first sync adds them)` : "";
148
- console.log(` db/schema/${table.name}.ts${lacks}`);
149
- }
150
- console.log('Every entity is { read: "door", write: "door" }; make one `write: "agents"` if only agents fill it.');
151
- if (written.plain.length > 0) {
152
- console.log("Plain tables (no sync changes them; app.table can't serve them):");
153
- for (const table of written.plain)
154
- console.log(` db/schema/${table.name}.ts: ${table.why}`);
155
- }
156
- if (written.skipped.length > 0)
157
- console.log(`Skipped, the platform's own tables: ${written.skipped.join(", ")}.`);
158
- if (written.leftOut.length > 0)
159
- console.log(`Left in the database, not in db/schema: ${written.leftOut.join(", ")}.`);
160
- console.log(written.pending.length === 0
161
- ? "`pnpm db:sync` has nothing to change."
162
- : `\`pnpm db:sync\` will only add this; nothing already there changes:\n${written.pending.join("\n")}`);
163
- return 0;
164
- }
165
128
  // Last, so every const above is initialized before a command reads it.
166
- const commands = { sync, migrate: applyMigrations, check, baseline };
167
- const usage = z.enum(["sync", "migrate", "check", "baseline"], { error: "Usage: light-db sync | migrate | check | baseline" });
129
+ const commands = { sync, migrate: applyMigrations, check };
130
+ const usage = z.enum(["sync", "migrate", "check"], { error: "Usage: light-db sync | migrate | check" });
168
131
  process.exit(await commands[usage.parse(command)]());
@@ -10,7 +10,7 @@ export type Plan = {
10
10
  snapshot: Snapshot;
11
11
  };
12
12
  /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
13
- export declare const snapshotSchema: z.ZodObject<{
13
+ declare const snapshotSchema: z.ZodObject<{
14
14
  id: z.ZodString;
15
15
  tables: z.ZodRecord<z.ZodString, z.ZodObject<{
16
16
  columns: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -37,7 +37,6 @@ export type Snapshot = z.infer<typeof snapshotSchema>;
37
37
  * after the columns that used them have changed.
38
38
  */
39
39
  export declare function plan(root: string, schema: Record<string, unknown>): Promise<Plan>;
40
- export declare const EMPTY_SNAPSHOT: Snapshot;
41
40
  /**
42
41
  * Writes the plan as the next migration, in drizzle-kit's own layout (the
43
42
  * SQL, its snapshot, the journal entry), so drizzle's migrator applies it.
@@ -45,3 +44,4 @@ export declare const EMPTY_SNAPSHOT: Snapshot;
45
44
  export declare function writeMigration(root: string, planned: Plan, name?: string): string;
46
45
  /** Takes back the newest migration, one that didn't apply, so the schema's change is pending again. */
47
46
  export declare function unwriteMigration(root: string, tag: string): void;
47
+ export {};
@@ -12,7 +12,7 @@ export const SCHEMA_ENTRY = "db/schema/index.ts";
12
12
  export const MIGRATIONS_DIR = "db/migrations";
13
13
  const entries = z.record(z.string(), z.unknown());
14
14
  /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
15
- export const snapshotSchema = z.looseObject({
15
+ const snapshotSchema = z.looseObject({
16
16
  id: z.string(),
17
17
  tables: z.record(z.string(), z.looseObject({
18
18
  columns: entries,
@@ -102,7 +102,7 @@ const journalSchema = z.object({
102
102
  breakpoints: z.boolean()
103
103
  }))
104
104
  });
105
- export const EMPTY_SNAPSHOT = {
105
+ const EMPTY_SNAPSHOT = {
106
106
  version: "7",
107
107
  dialect: "postgresql",
108
108
  id: "00000000-0000-0000-0000-000000000000",
@@ -1,6 +1,6 @@
1
1
  import type { Entity, PlatformColumn } from "./schema.js";
2
2
  export declare const ENTITIES_PATH = "/api/light/entities";
3
- /** The most rows one list returns; page past it with `offset`. */
3
+ /** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
4
4
  export declare const LIST_LIMIT = 1000;
5
5
  /** A value as JSON carries it: a Date column arrives as its ISO string. */
6
6
  type Wire<T> = {
@@ -19,6 +19,8 @@ export type Where<R> = {
19
19
  export type Sort<R> = (keyof R & string) | `-${keyof R & string}`;
20
20
  export type ListOptions<R> = {
21
21
  where?: Where<R>;
22
+ /** Rows with every one of these words in some text column of their own, case-insensitively: the server searches, so a paged list finds rows not loaded yet. */
23
+ search?: string;
22
24
  sort?: Sort<R>;
23
25
  /** At most LIST_LIMIT, which is also the default. */
24
26
  limit?: number;
package/dist/entities.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const ENTITIES_PATH = "/api/light/entities";
2
- /** The most rows one list returns; page past it with `offset`. */
2
+ /** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
3
3
  export const LIST_LIMIT = 1000;
4
4
  /** A refused or failed entities call, with the route's own reason. */
5
5
  export class EntityError extends Error {
@@ -0,0 +1,28 @@
1
+ import type { z } from "zod/v4";
2
+ type ObjectSchema = z.ZodType<Record<string, unknown>, Record<string, unknown>>;
3
+ export type Contract = {
4
+ /** The id `mii contract create` printed. */
5
+ readonly id: string;
6
+ readonly input: ObjectSchema;
7
+ readonly output: ObjectSchema;
8
+ };
9
+ export type Contracts = Readonly<Record<string, Contract>>;
10
+ export type ContractInput<C extends Contracts, K extends keyof C> = z.input<C[K]["input"]>;
11
+ export type ContractOutput<C extends Contracts, K extends keyof C> = z.output<C[K]["output"]>;
12
+ export type InvocationState = "pending" | "succeeded" | "failed";
13
+ /** What the app's invoke routes answer with; `Output` is a contract's, once the app's schema has parsed it. */
14
+ export type Invocation<Output = Record<string, unknown>> = {
15
+ readonly id: string;
16
+ /** The input the run was started with: the JSON of the app's schema's parse at the start. */
17
+ readonly input: Record<string, unknown>;
18
+ readonly lastError: string | null;
19
+ readonly createdAt: string;
20
+ readonly finishedAt: string | null;
21
+ } & ({
22
+ readonly state: "pending" | "failed";
23
+ readonly result: null;
24
+ } | {
25
+ readonly state: "succeeded";
26
+ readonly result: Output;
27
+ });
28
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { RequestError } from "../request-error.js";
2
+ /**
3
+ * Why an invocation did not produce a result. `code` is the server's
4
+ * (`not_signed_in`, `invalid_input`, `unknown_contract`, `result_mismatch`, …),
5
+ * or `failed` when the agents could not produce a result matching the schema.
6
+ * `status` is 0 when no HTTP refusal is behind it: `failed`, or no answer at all.
7
+ */
8
+ export declare class InvocationError extends RequestError {
9
+ }
10
+ /** A run the agents closed without a result that matches its schema. */
11
+ export declare function failedRun(lastError: string | null): InvocationError;
@@ -0,0 +1,13 @@
1
+ import { RequestError } from "../request-error.js";
2
+ /**
3
+ * Why an invocation did not produce a result. `code` is the server's
4
+ * (`not_signed_in`, `invalid_input`, `unknown_contract`, `result_mismatch`, …),
5
+ * or `failed` when the agents could not produce a result matching the schema.
6
+ * `status` is 0 when no HTTP refusal is behind it: `failed`, or no answer at all.
7
+ */
8
+ export class InvocationError extends RequestError {
9
+ }
10
+ /** A run the agents closed without a result that matches its schema. */
11
+ export function failedRun(lastError) {
12
+ return new InvocationError(0, "failed", `The agents could not produce a valid result.${lastError === null ? "" : `\n${lastError}`}`);
13
+ }
@@ -0,0 +1,73 @@
1
+ import type { ContractInput, ContractOutput, Contracts } from "./contracts.js";
2
+ import { InvocationError } from "./error.js";
3
+ export { InvocationError };
4
+ export type { Invocation, InvocationState } from "./contracts.js";
5
+ /** How long one read asks the platform to hold a pending invocation; the platform clamps it to its own limit. */
6
+ export declare const INVOCATION_WAIT_SECONDS = 60;
7
+ /** The pause after a read that failed before asking again. */
8
+ export declare const INVOCATION_RETRY_MS = 1500;
9
+ /** How long a run started with `remember` is picked up again in this browser. */
10
+ export declare const INVOCATION_REMEMBER_MS: number;
11
+ type Result = Record<string, unknown>;
12
+ /**
13
+ * `id` and `input` are null until the platform has the invocation. `input` is the
14
+ * run's input as JSON, so a page that picked a run up can show what it is
15
+ * for. While pending, `pollError` is why the latest read failed, or null: the
16
+ * wait goes on either way, and the app decides when a server that stays down
17
+ * is worth telling the person about.
18
+ */
19
+ export type InvocationStatus<Output> = {
20
+ readonly state: "idle";
21
+ } | {
22
+ readonly state: "pending";
23
+ readonly id: string | null;
24
+ readonly input: Result | null;
25
+ readonly elapsedMs: number;
26
+ readonly pollError: InvocationError | null;
27
+ } | {
28
+ readonly state: "succeeded";
29
+ readonly id: string;
30
+ readonly input: Result;
31
+ readonly result: Output;
32
+ readonly elapsedMs: number;
33
+ } | {
34
+ readonly state: "failed";
35
+ readonly id: string | null;
36
+ readonly input: Result | null;
37
+ readonly error: InvocationError;
38
+ readonly elapsedMs: number;
39
+ };
40
+ export type UseInvocationOptions = {
41
+ /**
42
+ * A name for what this press is for (`"fill:revenue"`, `"add-candidates"`).
43
+ * A run started with it is picked up again when the page reloads or the
44
+ * tab is reopened in this browser, finished or not, until `reset()` or
45
+ * INVOCATION_REMEMBER_MS after it started.
46
+ */
47
+ readonly remember?: string | null;
48
+ };
49
+ export type Invoker<C extends Contracts> = {
50
+ invoke<K extends keyof C & string>(key: K, input: ContractInput<C, K>, signal?: AbortSignal): Promise<ContractOutput<C, K>>;
51
+ useInvocation<K extends keyof C & string>(key: K, options?: UseInvocationOptions): {
52
+ readonly status: InvocationStatus<ContractOutput<C, K>>;
53
+ run(input: ContractInput<C, K>): void;
54
+ /** Follow a run already started, such as one a server function started and the app stored. */
55
+ resume(id: string): void;
56
+ /** Back to idle; a remembered run is let go of. */
57
+ reset(): void;
58
+ };
59
+ };
60
+ export declare function createInvoker<C extends Contracts>(): Invoker<C>;
61
+ /**
62
+ * One button's invocations, outside React so it can be tested: at most one is
63
+ * live, and one that was replaced, reset or disposed of reports nothing more.
64
+ * With `remember`, `restore()` picks up the run this browser last started with it.
65
+ */
66
+ export declare function createInvocationController<Output = Result>(key: string, onStatus: (status: InvocationStatus<Output>) => void, { remember }?: UseInvocationOptions): {
67
+ run: (input: unknown) => void;
68
+ resume: (id: string) => void;
69
+ /** With `remember`: follow the run this browser noted. */
70
+ restore: () => void;
71
+ reset: () => void;
72
+ dispose: () => void;
73
+ };