@trylight/app 0.3.6 → 0.3.8

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
@@ -91,10 +91,12 @@ drizzle-kit's own layout) and applied, whatever it does to the rows there (a
91
91
  removed column's values are archived first, below); one the rows refuse is taken back out,
92
92
  so the change stays pending rather than blocking every later migrate. Changed
93
93
  indexes and constraints are dropped before the columns they cover and enums
94
- only after, so the two halves apply in order. Syncs and migrates each hold a Postgres advisory lock (with a
95
- lock_timeout), so the dev server's watcher and an agent's own sync never
96
- overlap, and a process that dies releases it. `migrate` applies the journal; in an app's sandbox the agent's shell
97
- asks the platform's migrate socket to run it. `check` (in `pnpm build`)
94
+ only after, so the two halves apply in order. Syncs and migrates each hold a
95
+ Postgres advisory lock, so the dev server's watcher and an agent's own sync
96
+ never overlap, and a process that dies releases it. A wait for the lock is
97
+ bounded (`LOCK_WAIT_MS` in `src/db/locks.ts`), then fails naming its holder.
98
+ `migrate` applies the journal; in an app's sandbox the agent's shell asks
99
+ the platform's migrate socket to run it. `check` (in `pnpm build`)
98
100
  fails while `db/schema` has changes no migration holds.
99
101
 
100
102
  ## Recoverable deletes
@@ -103,13 +105,13 @@ fails while `db/schema` has changes no migration holds.
103
105
  that records each insert, update, delete and truncate in `light_changes` (the
104
106
  new row, `{ column: { from, to } }`, or the old row), whoever wrote it: the
105
107
  actor is `light.actor_kind/_id/_name`, which the entities route and the platform's
106
- `mii db write` set per transaction, or else the database role. A migration
108
+ `light db write` set per transaction, or else the database role. A migration
107
109
  that drops a table or column or changes a column's type first copies what it
108
110
  destroys into `light_archive` (`light_archive_values`, called at the top of
109
111
  the migration, so in its transaction) and the migrate prints "Archived N
110
112
  values from table.column". Both tables refuse UPDATE, DELETE and TRUNCATE (a guard against mistakes:
111
113
  the owner role can still drop the trigger). DDL an agent runs itself (`drop
112
- table` through `mii db write`) is not archived.
114
+ table` through `light db write`) is not archived.
113
115
  `migrate` installs the functions before applying migrations and the triggers
114
116
  after; the template's AGENTS.md has the restore SQL.
115
117
 
package/dist/db/cli.js CHANGED
@@ -10,6 +10,7 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
10
10
  import postgres from "postgres";
11
11
  import { z } from "zod/v4";
12
12
  import { archiveNotices, entityTables, prepareAudit, trackTables } from "./audit.js";
13
+ import { LockWaitError, takeLock } from "./locks.js";
13
14
  import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
14
15
  import { env } from "../server/env.js";
15
16
  /**
@@ -18,13 +19,9 @@ import { env } from "../server/env.js";
18
19
  * credential, behind this socket.
19
20
  */
20
21
  const PLATFORM_MIGRATE = "/run/mii/migrate.sock";
21
- /** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
22
- const MIGRATE_LOCK_KEY = 4_815_162_342;
23
- const SYNC_LOCK_KEY = 4_815_162_343;
24
22
  /** Exit code of a migrate whose migrations committed but whose change tracking (triggers, index) didn't go on. */
25
23
  const APPLIED_UNTRACKED = 2;
26
- // pg_advisory_lock waits for the one before it; a process that dies releases it.
27
- // Only on a direct connection: behind a transaction pooler the lock stays on a
24
+ // Session locks (./locks.ts) only on a direct connection: behind a transaction pooler the lock stays on a
28
25
  // pooled server connection after the process exits, and every later sync waits.
29
26
  const connect = (onnotice = () => undefined) => postgres(unpooledDatabaseUrl(), { max: 1, onnotice });
30
27
  const root = process.cwd();
@@ -50,7 +47,7 @@ function unpooledDatabaseUrl() {
50
47
  async function sync() {
51
48
  const client = connect();
52
49
  try {
53
- await client `select pg_advisory_lock(${SYNC_LOCK_KEY})`;
50
+ await takeLock(client, "sync");
54
51
  const planned = await plan(root, await loadSchema());
55
52
  if (planned.statements.length === 0)
56
53
  return await applyMigrations();
@@ -64,7 +61,7 @@ async function sync() {
64
61
  });
65
62
  if (applied !== 0 && applied !== APPLIED_UNTRACKED) {
66
63
  unwriteMigration(root, tag);
67
- console.error(`${MIGRATIONS_DIR}/${tag}.sql didn't apply, so it was taken back; change db/schema and save again.`);
64
+ console.error(`${MIGRATIONS_DIR}/${tag}.sql didn't apply, so it was taken back; sync again once the error above is dealt with.`);
68
65
  }
69
66
  return applied;
70
67
  }
@@ -93,7 +90,7 @@ async function applyMigrations() {
93
90
  const archived = archiveNotices();
94
91
  const client = connect(archived.onnotice);
95
92
  try {
96
- await client `select pg_advisory_lock(${MIGRATE_LOCK_KEY})`;
93
+ await takeLock(client, "migrate");
97
94
  const tables = entityTables(await loadSchema());
98
95
  await prepareAudit(client);
99
96
  await migrate(drizzle(client), { migrationsFolder: join(root, MIGRATIONS_DIR) });
@@ -128,4 +125,10 @@ async function migrateThroughPlatform() {
128
125
  // Last, so every const above is initialized before a command reads it.
129
126
  const commands = { sync, migrate: applyMigrations, check };
130
127
  const usage = z.enum(["sync", "migrate", "check"], { error: "Usage: light-db sync | migrate | check" });
131
- process.exit(await commands[usage.parse(command)]());
128
+ const exitCode = await commands[usage.parse(command)]().catch((error) => {
129
+ if (!(error instanceof LockWaitError))
130
+ throw error;
131
+ console.error(error.message);
132
+ return 1;
133
+ });
134
+ process.exit(exitCode);
@@ -0,0 +1,13 @@
1
+ import postgres from "postgres";
2
+ /** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
3
+ export declare const LOCKS: {
4
+ migrate: number;
5
+ sync: number;
6
+ };
7
+ /** How long a sync or migrate waits for the one before it, then fails naming who holds the lock. */
8
+ export declare const LOCK_WAIT_MS = 30000;
9
+ /** A sync or migrate gave up waiting for its lock; the message says who holds it. */
10
+ export declare class LockWaitError extends Error {
11
+ }
12
+ /** Takes `command`'s session lock, or throws naming its holder after `LOCK_WAIT_MS`. */
13
+ export declare function takeLock(client: postgres.Sql, command: keyof typeof LOCKS): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import postgres from "postgres";
2
+ import { z } from "zod/v4";
3
+ /** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
4
+ export const LOCKS = { migrate: 4_815_162_342, sync: 4_815_162_343 };
5
+ /** How long a sync or migrate waits for the one before it, then fails naming who holds the lock. */
6
+ export const LOCK_WAIT_MS = 30_000;
7
+ const LOCK_NOT_AVAILABLE = "55P03";
8
+ // Exclusive, so at most one backend holds it.
9
+ const holderRows = z.array(z.object({ pid: z.number() })).max(1);
10
+ /** A sync or migrate gave up waiting for its lock; the message says who holds it. */
11
+ export class LockWaitError extends Error {
12
+ }
13
+ /** Takes `command`'s session lock, or throws naming its holder after `LOCK_WAIT_MS`. */
14
+ export async function takeLock(client, command) {
15
+ const key = LOCKS[command];
16
+ try {
17
+ await client.begin(async (tx) => {
18
+ await tx `select set_config('lock_timeout', ${`${LOCK_WAIT_MS}ms`}, true)`;
19
+ await tx `select pg_advisory_lock(${key})`;
20
+ });
21
+ }
22
+ catch (error) {
23
+ if (!(error instanceof postgres.PostgresError) || error.code !== LOCK_NOT_AVAILABLE)
24
+ throw error;
25
+ const [holder] = holderRows.parse(await client `
26
+ select pid from pg_locks
27
+ where locktype = 'advisory' and granted and objsubid = 1
28
+ and database = (select oid from pg_database where datname = current_database())
29
+ and ((classid::bigint << 32) | objid::bigint) = ${key}`);
30
+ const waited = `light-db ${command}: waited ${LOCK_WAIT_MS / 1000}s for the ${command} lock`;
31
+ if (holder === undefined)
32
+ throw new LockWaitError(`${waited}; it came free just now, so run it again.`);
33
+ throw new LockWaitError(`${waited}, held by backend ${holder.pid}; if no other sync or migrate is running, the database's owner ends it with pg_terminate_backend(${holder.pid}).`);
34
+ }
35
+ }
@@ -1,7 +1,7 @@
1
1
  import type { z } from "zod/v4";
2
2
  type ObjectSchema = z.ZodType<Record<string, unknown>, Record<string, unknown>>;
3
3
  export type Contract = {
4
- /** The id `mii contract create` printed. */
4
+ /** The id `light contract create` printed. */
5
5
  readonly id: string;
6
6
  readonly input: ObjectSchema;
7
7
  readonly output: ObjectSchema;
@@ -20,7 +20,7 @@ export type MiiInvoke<C extends Contracts> = {
20
20
  readonly handler: (request: Request) => Promise<Response>;
21
21
  /**
22
22
  * Start a contract from a POST server function, as the person whose request
23
- * it is answering, and return the run's id without waiting for it: for work
23
+ * it is answering (on a public app, a visitor with no session), and return the run's id without waiting for it: for work
24
24
  * that must be recorded with its run (a job row that names it). A page
25
25
  * follows it with `resume(id)`. Refusals reject with InvocationError, with
26
26
  * the codes a button's press gets.
@@ -6,7 +6,8 @@ import { isSameOrigin, readCookie } from "../chat/server.js";
6
6
  import { errorBody } from "../request-error.js";
7
7
  import { isUuid } from "../uuid.js";
8
8
  import { failedRun, InvocationError } from "./error.js";
9
- import { INVOKE_BASE_PATH } from "../platform/contract.js";
9
+ import { APP_CALLER_HEADERS, INVOKE_BASE_PATH } from "../platform/contract.js";
10
+ import { env } from "../server/env.js";
10
11
  export { InvocationError };
11
12
  function routeOf(request) {
12
13
  const { pathname } = new URL(request.url);
@@ -21,6 +22,14 @@ function routeOf(request) {
21
22
  const [, id = ""] = read;
22
23
  return { kind: "read", id };
23
24
  }
25
+ function platformPath(route) {
26
+ switch (route.kind) {
27
+ case "start":
28
+ return "/invocations";
29
+ case "read":
30
+ return `/invocations/${route.id}`;
31
+ }
32
+ }
24
33
  const invokeBody = z.object({ key: z.string(), input: z.unknown() });
25
34
  const plazaInvocation = z.object({
26
35
  id: z.string(),
@@ -67,28 +76,40 @@ function startBody(contract, key, rawInput) {
67
76
  return JSON.stringify({ contractId: contract.id, input: input.data, outputSchema: contract.outputSchema });
68
77
  }
69
78
  /**
70
- * The person behind a request to this app: the session cookie, from one of
79
+ * The caller behind a request to this app: the session cookie, from one of
71
80
  * this app's own pages. Every org app shares one site with every other
72
81
  * (*.<domain>), so a page on another org's app can send this app's cookie along.
73
82
  */
74
- async function signedIn(auth, request) {
83
+ async function callerOf(auth, request) {
75
84
  if (!isSameOrigin(request, new URL(request.url))) {
76
85
  throw new InvocationError(403, "cross_site", "Contracts can only be invoked from this app's own pages.");
77
86
  }
78
87
  const token = readCookie(request.headers.get("cookie"), auth.SESSION_COOKIE);
79
- if (token === null)
80
- throw new InvocationError(401, "not_signed_in", "Sign in to use this.");
88
+ if (token === null) {
89
+ const { appSecret } = env();
90
+ if (appSecret === null)
91
+ throw new InvocationError(401, "not_signed_in", "Sign in to use this.");
92
+ return { kind: "visitor", appId: miiAuthConfig().appId, secret: appSecret };
93
+ }
81
94
  const session = await auth.resolveSessionWithPlaza(token);
82
95
  if (session === null)
83
96
  throw new InvocationError(401, "sign_in_again", "Sign out and back in to use it here.");
84
- return { token, plazaToken: session.plazaToken };
97
+ return { kind: "person", token, plazaToken: session.plazaToken };
98
+ }
99
+ function callerHeaders(caller) {
100
+ switch (caller.kind) {
101
+ case "person":
102
+ return { Authorization: `Bearer ${caller.plazaToken}` };
103
+ case "visitor":
104
+ return { [APP_CALLER_HEADERS.appId]: caller.appId, [APP_CALLER_HEADERS.secret]: caller.secret };
105
+ }
85
106
  }
86
- /** One call to the platform's contract API as the person; a refused token ends their session. Rejects only when the platform can't be reached. */
87
- async function callPlaza(auth, person, call) {
107
+ /** One call to the platform's contract API as the caller; a refused token ends a person's session. Rejects only when the platform can't be reached. */
108
+ async function callPlaza(auth, caller, call) {
88
109
  const answer = await fetch(call.target, {
89
110
  method: call.method,
90
111
  headers: {
91
- Authorization: `Bearer ${person.plazaToken}`,
112
+ ...callerHeaders(caller),
92
113
  ...(call.body === null ? {} : { "Content-Type": "application/json" })
93
114
  },
94
115
  body: call.body,
@@ -96,17 +117,17 @@ async function callPlaza(auth, person, call) {
96
117
  redirect: "manual",
97
118
  signal: call.signal
98
119
  });
99
- if (answer.status === 401 || answer.status === 403) {
100
- await auth.revokeSession(person.token, answer.status === 403 ? "not_a_member" : "token_rejected");
120
+ if (caller.kind === "person" && (answer.status === 401 || answer.status === 403)) {
121
+ await auth.revokeSession(caller.token, answer.status === 403 ? "not_a_member" : "token_rejected");
101
122
  }
102
123
  return answer;
103
124
  }
104
125
  const plazaTarget = (path) => new URL(`${miiAuthConfig().plazaUrl}/api/app-contracts/v1${path}`);
105
- /** One call to the platform as the person, read as JSON. A refusal, or no answer, rejects with InvocationError. */
106
- async function askPlaza(auth, person, call) {
126
+ /** One call to the platform as the caller, read as JSON. A refusal, or no answer, rejects with InvocationError. */
127
+ async function askPlaza(auth, caller, call) {
107
128
  let answer;
108
129
  try {
109
- answer = await callPlaza(auth, person, call);
130
+ answer = await callPlaza(auth, caller, call);
110
131
  }
111
132
  catch (error) {
112
133
  if (call.signal !== null && call.signal.aborted)
@@ -143,10 +164,10 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
143
164
  if (route.kind === "read" && !isUuid(route.id)) {
144
165
  return refuse(404, "not_found", "Invocation not found.");
145
166
  }
146
- let person;
167
+ let caller;
147
168
  let body = null;
148
169
  try {
149
- person = await signedIn(auth, request);
170
+ caller = await callerOf(auth, request);
150
171
  if (route.kind === "start")
151
172
  body = await readInvoke(request, declared);
152
173
  }
@@ -155,7 +176,7 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
155
176
  throw error;
156
177
  return refuse(error.status, error.code, error.message);
157
178
  }
158
- const target = plazaTarget(route.kind === "start" ? "/invocations" : `/invocations/${route.id}`);
179
+ const target = plazaTarget(platformPath(route));
159
180
  // How long the platform may hold a read of a pending invocation. This app's own
160
181
  // function limit has to outlast it (see the README).
161
182
  const wait = new URL(request.url).searchParams.get("wait");
@@ -164,7 +185,7 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
164
185
  let answer;
165
186
  try {
166
187
  // The browser giving up ends the platform's hold too.
167
- answer = await callPlaza(auth, person, { method: request.method, target, body, signal: request.signal });
188
+ answer = await callPlaza(auth, caller, { method: request.method, target, body, signal: request.signal });
168
189
  }
169
190
  catch (error) {
170
191
  if (!request.signal.aborted)
@@ -190,17 +211,17 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
190
211
  return respond(answer.status, plazaInvocation.parse(await answer.json()), declared, reportError);
191
212
  };
192
213
  const start = async ({ request, key, input, signal = null }) => {
193
- const person = await signedIn(auth, request);
214
+ const caller = await callerOf(auth, request);
194
215
  const body = startBody(declared.get(key), key, input);
195
- const answer = await askPlaza(auth, person, { method: "POST", target: plazaTarget("/invocations"), body, signal });
216
+ const answer = await askPlaza(auth, caller, { method: "POST", target: plazaTarget("/invocations"), body, signal });
196
217
  return { id: plazaInvocation.parse(answer).id };
197
218
  };
198
219
  const result = async (key, id, request) => {
199
- const person = await signedIn(auth, request);
220
+ const caller = await callerOf(auth, request);
200
221
  const contract = contracts[key];
201
222
  if (!isUuid(id))
202
223
  throw new InvocationError(404, "not_found", "Invocation not found.");
203
- const invocation = plazaInvocation.parse(await askPlaza(auth, person, { method: "GET", target: plazaTarget(`/invocations/${id}`), body: null, signal: null }));
224
+ const invocation = plazaInvocation.parse(await askPlaza(auth, caller, { method: "GET", target: plazaTarget(`/invocations/${id}`), body: null, signal: null }));
204
225
  if (invocation.contractId !== contract.id) {
205
226
  throw new InvocationError(404, "not_found", `Invocation ${id} is not a run of "${key}".`);
206
227
  }
@@ -16,6 +16,13 @@ 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
+ /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
20
+ export declare const APP_CALLER_HEADERS: {
21
+ appId: string;
22
+ secret: string;
23
+ };
24
+ /** The server env that secret is provisioned in. */
25
+ export declare const APP_SECRET_ENV = "PLATFORM_APP_SECRET";
19
26
  /** The platform's chat API: an app's chat route forwards any path beneath it, so a new widget route needs no SDK release. */
20
27
  export declare const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
21
28
  /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
@@ -22,6 +22,10 @@ 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
+ /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
26
+ export const APP_CALLER_HEADERS = { appId: "x-app-id", secret: "x-app-secret" };
27
+ /** The server env that secret is provisioned in. */
28
+ export const APP_SECRET_ENV = "PLATFORM_APP_SECRET";
25
29
  /** The platform's chat API: an app's chat route forwards any path beneath it, so a new widget route needs no SDK release. */
26
30
  export const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
27
31
  /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
package/dist/schema.d.ts CHANGED
@@ -17,7 +17,7 @@ export type FieldSources = Partial<Record<string, FieldSource>>;
17
17
  * Who may use a table through `app.table(...)`. Reads always go through the
18
18
  * app's door: members only, or anyone with the link on a public app. Writes
19
19
  * are either people signed in to the app ("door") or only the org's agents,
20
- * through `mii db write` ("agents").
20
+ * through `light db write` ("agents").
21
21
  */
22
22
  export type Access = {
23
23
  readonly read: "door";
@@ -2,6 +2,7 @@
2
2
  export declare function env(): {
3
3
  databaseUrl: string | undefined;
4
4
  unpooledDatabaseUrl: string | undefined;
5
+ appSecret: string | null;
5
6
  /** Sign-in needs the platform, this app's id in it, and a database for sessions. */
6
7
  configured: boolean;
7
8
  /**
@@ -1,6 +1,7 @@
1
1
  // The server env the SDK reads, in one schema: what the platform injects
2
2
  // (Vercel env, the sandbox's .env.local).
3
3
  import { z } from "zod/v4";
4
+ import { APP_SECRET_ENV } from "../platform/contract.js";
4
5
  const serverEnv = z
5
6
  .object({
6
7
  DATABASE_URL: z.string().min(1).optional(),
@@ -8,11 +9,14 @@ const serverEnv = z
8
9
  DATABASE_URL_UNPOOLED: z.string().min(1).optional(),
9
10
  MII_PLAZA_URL: z.url().optional(),
10
11
  MII_APP_ID: z.string().min(1).optional(),
12
+ /** What this app's server presents to call the platform as the app, for a visitor with no session. */
13
+ [APP_SECRET_ENV]: z.string().min(1).nullable().default(null),
11
14
  NODE_ENV: z.string().optional()
12
15
  })
13
16
  .transform((env) => ({
14
17
  databaseUrl: env.DATABASE_URL,
15
18
  unpooledDatabaseUrl: env.DATABASE_URL_UNPOOLED,
19
+ appSecret: env[APP_SECRET_ENV],
16
20
  /** Sign-in needs the platform, this app's id in it, and a database for sessions. */
17
21
  configured: Boolean(env.MII_PLAZA_URL && env.MII_APP_ID && env.DATABASE_URL),
18
22
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",