@trylight/app 0.1.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 ADDED
@@ -0,0 +1,61 @@
1
+ # @trylight/app
2
+
3
+ The platform half of a v2 org app (`infra/provisioning/org-app-template-v2`):
4
+ its tables and their typed client, its door, its contracts and its chat.
5
+ Sign-in, contracts and chat are `@shubh90/app-runtime`'s, re-exported
6
+ unchanged; entities and the schema sync are new here.
7
+
8
+ | Import | What |
9
+ |---|---|
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); `fieldSource` (the `{url, evidence}` schema) |
11
+ | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed) and `app.contracts` (`useInvocation`) |
12
+ | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.invoke.handler`, `light.chat` |
13
+ | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
14
+ | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
15
+ | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
16
+
17
+ ## Entities
18
+
19
+ One route, `/api/light/entities/:table[/:id]`, serves every table exported
20
+ through `entity()`. Each request is parsed with the table's own Zod schema
21
+ (drizzle-zod; unknown or platform columns are refused, `field_sources` must be
22
+ `{url, evidence}` per real column), goes through the app's door (members-only:
23
+ a member; public: anyone reads, people signed in write; `write: "agents"`
24
+ tables refuse the app), must come from the app's own pages, and each change is
25
+ written with a `light_changes` row naming the person, in one transaction. An
26
+ edited value loses its source; sources sent are merged by column. Lists sort
27
+ by the asked column, then id. With no sign-in configured only a non-production
28
+ server asked on loopback answers (as "Local developer"); anything else gets 503.
29
+
30
+ Pages read and write through TanStack Query hooks: `useUpdate` patches every
31
+ cached list and row at once and stays pending until the table's refetch lands
32
+ (no snap-back); a failed save is corrected by that same refetch.
33
+
34
+ Joins and aggregates stay typed server functions over `light.db`.
35
+
36
+ ## Schema sync
37
+
38
+ `plan()` diffs `db/schema` against the newest migration snapshot with
39
+ drizzle-kit's API, removals first and additions second, so drizzle-kit never
40
+ has to ask whether a column was renamed (it can't without a terminal).
41
+ The change is written as a drizzle migration (`db/migrations`,
42
+ drizzle-kit's own layout) and applied, whatever it does to the rows there (a
43
+ removed column's values go with it); one the rows refuse is taken back out,
44
+ so the change stays pending rather than blocking every later migrate. Changed
45
+ indexes and constraints are dropped before the columns they cover and enums
46
+ only after, so the two halves apply in order. Syncs and migrates each hold a Postgres advisory lock (with a
47
+ lock_timeout), so the dev server's watcher and an agent's own sync never
48
+ overlap, and a process that dies releases it. `migrate` applies the journal; in an app's sandbox the agent's shell
49
+ asks the platform's migrate socket to run it. `check` (in `pnpm build`)
50
+ fails while `db/schema` has changes no migration holds.
51
+
52
+ ## Develop
53
+
54
+ ```bash
55
+ pnpm install
56
+ pnpm run check # typecheck, lint, tests
57
+ TEST_DATABASE_URL=postgres://you@127.0.0.1:5432/postgres pnpm test # with the database tests
58
+ pnpm run build # dist/, what the template installs
59
+ ```
60
+
61
+ Not published yet: the template depends on this directory (`file:`).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // The app's schema files are TypeScript; tsx lets the CLI import them.
3
+ import { register } from "tsx/esm/api";
4
+
5
+ register();
6
+ await import("../dist/db/cli.js");
@@ -0,0 +1,40 @@
1
+ import type { Contracts } from "@shubh90/app-runtime/invoke/server";
2
+ import { EntityError, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
3
+ import type { Entity } from "./schema.js";
4
+ export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
5
+ export type { FieldSource, FieldSources } from "./schema.js";
6
+ export { siteOf } from "./schema.js";
7
+ export { InvocationError, type InvocationStatus } from "@shubh90/app-runtime/invoke";
8
+ export declare function createApp<C extends Contracts = Record<never, never>>(): {
9
+ table: typeof tableClient;
10
+ contracts: import("@shubh90/app-runtime/invoke").Invoker<C>;
11
+ };
12
+ /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
13
+ declare function tableClient<T extends Entity>(entity: T): {
14
+ /**
15
+ * The query key everything this table caches sits under. A page's own
16
+ * query over the table (a server function's totals) keyed under it is
17
+ * refetched after every write too.
18
+ */
19
+ key: string[];
20
+ list: (options?: ListOptions<Row<T>>) => Promise<Row<T>[]>;
21
+ get: (id: string) => Promise<Row<T>>;
22
+ create: (data: NewRow<T>) => Promise<Row<T>>;
23
+ update: (id: string, patch: Patch<T>) => Promise<Row<T>>;
24
+ delete: (id: string) => Promise<void>;
25
+ /** The rows, cached per options; refetched after any write to this table. */
26
+ useList: (options?: ListOptions<Row<T>>) => 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>;
27
+ /** One row; `null` waits (nothing open yet). */
28
+ 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>;
29
+ 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>;
30
+ /** Shows the patch at once in every cached list and row. */
31
+ useUpdate: () => 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, {
32
+ id: string;
33
+ patch: Patch<T>;
34
+ }, unknown>;
35
+ /** Takes the row out of every cached list at once. */
36
+ useDelete: () => import("@tanstack/react-query").UseMutationResult<void, EntityError, string, unknown>;
37
+ };
38
+ export type TableClient<T extends Entity> = ReturnType<typeof tableClient<T>>;
39
+ /** To the login page, and back here after. */
40
+ export declare function redirectToLogin(): void;
package/dist/client.js ADDED
@@ -0,0 +1,135 @@
1
+ // The browser half, bound once in `src/lib/app.ts`:
2
+ //
3
+ // export const app = createApp<typeof contracts>();
4
+ //
5
+ // `app.table(companies)` reads and writes that table's rows through the
6
+ // entities route; its hooks keep TanStack Query's cache in step, so an edit
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";
11
+ import { z } from "zod/v4";
12
+ import { ENTITIES_PATH, EntityError } from "./entities.js";
13
+ export { EntityError, LIST_LIMIT } from "./entities.js";
14
+ export { siteOf } from "./schema.js";
15
+ export { InvocationError } from "@shubh90/app-runtime/invoke";
16
+ export function createApp() {
17
+ return { table: tableClient, contracts: createInvoker() };
18
+ }
19
+ /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
20
+ function tableClient(entity) {
21
+ const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
22
+ const keys = {
23
+ all: ["light", base],
24
+ list: (options) => ["light", base, "list", options],
25
+ get: (id) => ["light", base, "get", id],
26
+ write: ["light", base, "write"]
27
+ };
28
+ const list = (options = {}) => call("GET", `${base}?q=${encodeURIComponent(JSON.stringify(options))}`);
29
+ const get = (id) => call("GET", `${base}/${id}`);
30
+ const create = (data) => call("POST", base, json(data));
31
+ const update = (id, patch) => call("PATCH", `${base}/${id}`, json(patch));
32
+ const remove = async (id) => {
33
+ await call("DELETE", `${base}/${id}`);
34
+ };
35
+ /**
36
+ * After a write, refetch the table once the last write in flight settles.
37
+ * Returning the refetch keeps the mutation pending until it lands, so an
38
+ * edit never flickers back to its old value; a failed write is corrected
39
+ * by the same refetch.
40
+ */
41
+ const useSettle = () => {
42
+ const client = useQueryClient();
43
+ return async () => {
44
+ if (client.isMutating({ mutationKey: keys.write }) === 1)
45
+ await client.invalidateQueries({ queryKey: keys.all });
46
+ };
47
+ };
48
+ return {
49
+ /**
50
+ * The query key everything this table caches sits under. A page's own
51
+ * query over the table (a server function's totals) keyed under it is
52
+ * refetched after every write too.
53
+ */
54
+ key: keys.all,
55
+ list,
56
+ get,
57
+ create,
58
+ update,
59
+ delete: remove,
60
+ /** The rows, cached per options; refetched after any write to this table. */
61
+ useList: (options = {}) => useQuery({ queryKey: keys.list(options), queryFn: () => list(options) }),
62
+ /** One row; `null` waits (nothing open yet). */
63
+ useGet: (id) => useQuery({
64
+ queryKey: keys.get(id),
65
+ queryFn: id === null ? skipToken : () => get(id),
66
+ // A panel closing (id → null) keeps its row while it slides out.
67
+ placeholderData: keepPreviousData
68
+ }),
69
+ useCreate: () => {
70
+ const settle = useSettle();
71
+ return useMutation({
72
+ mutationKey: keys.write,
73
+ mutationFn: create,
74
+ onSettled: settle
75
+ });
76
+ },
77
+ /** Shows the patch at once in every cached list and row. */
78
+ useUpdate: () => {
79
+ const client = useQueryClient();
80
+ const settle = useSettle();
81
+ return useMutation({
82
+ mutationKey: keys.write,
83
+ mutationFn: ({ id, patch }) => update(id, patch),
84
+ onMutate: async ({ id, patch }) => {
85
+ await client.cancelQueries({ queryKey: keys.all });
86
+ // As the server does: a value set here loses its old source; sources sent merge in.
87
+ const patched = (row) => row.id === id
88
+ ? {
89
+ ...row,
90
+ ...patch,
91
+ field_sources: Object.assign(Object.fromEntries(Object.entries(row.field_sources).filter(([column]) => !(column in patch))), patch.field_sources)
92
+ }
93
+ : row;
94
+ client.setQueriesData({ queryKey: [...keys.all, "list"] }, (rows) => rows && rows.map(patched));
95
+ client.setQueryData(keys.get(id), (row) => row && patched(row));
96
+ },
97
+ onSettled: settle
98
+ });
99
+ },
100
+ /** Takes the row out of every cached list at once. */
101
+ useDelete: () => {
102
+ const client = useQueryClient();
103
+ const settle = useSettle();
104
+ return useMutation({
105
+ mutationKey: keys.write,
106
+ mutationFn: remove,
107
+ onMutate: async (id) => {
108
+ await client.cancelQueries({ queryKey: keys.all });
109
+ client.setQueriesData({ queryKey: [...keys.all, "list"] }, (rows) => rows && rows.filter((row) => row.id !== id));
110
+ },
111
+ onSettled: settle
112
+ });
113
+ }
114
+ };
115
+ }
116
+ function json(body) {
117
+ return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
118
+ }
119
+ const refusal = z.object({ error: z.string(), code: z.string() });
120
+ async function call(method, path, init = {}) {
121
+ const response = await fetch(path, { ...init, method, credentials: "same-origin" });
122
+ if (response.ok)
123
+ return response.json();
124
+ const read = refusal.safeParse(await response.json().catch(() => null));
125
+ if (!read.success)
126
+ throw new EntityError(`${method} ${path} failed (${response.status}).`, response.status);
127
+ // A session that ended while the tab was open: the login page, not an error.
128
+ if (read.data.code === "not_signed_in")
129
+ redirectToLogin();
130
+ throw new EntityError(read.data.error, response.status);
131
+ }
132
+ /** To the login page, and back here after. */
133
+ export function redirectToLogin() {
134
+ window.location.assign(`/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`);
135
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/db/cli.js ADDED
@@ -0,0 +1,112 @@
1
+ // `light-db sync`, `light-db migrate`, `light-db check`: the
2
+ // app's `pnpm db:*` scripts. Run from the app's root.
3
+ import { existsSync } from "node:fs";
4
+ import { request } from "node:http";
5
+ import { userInfo } from "node:os";
6
+ import { join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { drizzle } from "drizzle-orm/postgres-js";
9
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
10
+ import postgres from "postgres";
11
+ import { z } from "zod/v4";
12
+ import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
13
+ import { env } from "../server/env.js";
14
+ /**
15
+ * In an app's sandbox the agent's shell can read the database, not change
16
+ * it: the platform runs `pnpm db:migrate` for it, as root with the app's own
17
+ * credential, behind this socket.
18
+ */
19
+ const PLATFORM_MIGRATE = "/run/mii/migrate.sock";
20
+ /** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
21
+ const MIGRATE_LOCK_KEY = 4_815_162_342;
22
+ const SYNC_LOCK_KEY = 4_815_162_343;
23
+ // pg_advisory_lock waits for the one before it; a process that dies releases it.
24
+ const connect = () => postgres(databaseUrl(), { max: 1, onnotice: () => undefined });
25
+ const root = process.cwd();
26
+ const envFile = join(root, ".env.local");
27
+ if (existsSync(envFile))
28
+ process.loadEnvFile(envFile);
29
+ const [command] = process.argv.slice(2);
30
+ async function loadSchema() {
31
+ return import(pathToFileURL(join(root, SCHEMA_ENTRY)).href);
32
+ }
33
+ function databaseUrl() {
34
+ const { databaseUrl } = env();
35
+ if (databaseUrl === undefined)
36
+ throw new Error("DATABASE_URL is not set: there is no database to sync db/schema into.");
37
+ return databaseUrl;
38
+ }
39
+ /**
40
+ * Makes the database match `db/schema`, whatever the change does to the rows
41
+ * there. One sync at a time (the dev server's watcher and an agent's own)
42
+ * through a Postgres advisory lock, which a sync that dies releases.
43
+ */
44
+ async function sync() {
45
+ const client = connect();
46
+ try {
47
+ await client `select pg_advisory_lock(${SYNC_LOCK_KEY})`;
48
+ const planned = await plan(root, await loadSchema());
49
+ if (planned.statements.length === 0)
50
+ return await applyMigrations();
51
+ const tag = writeMigration(root, planned);
52
+ console.log(`Wrote ${MIGRATIONS_DIR}/${tag}.sql:\n${planned.statements.join("\n")}`);
53
+ // A migration that doesn't apply comes back out, so the change stays pending
54
+ // (and the build's check keeps failing) instead of blocking every migrate.
55
+ const applied = await applyMigrations().catch((error) => {
56
+ console.error(error instanceof Error ? error.message : String(error));
57
+ return 1;
58
+ });
59
+ if (applied !== 0) {
60
+ unwriteMigration(root, tag);
61
+ console.error(`${MIGRATIONS_DIR}/${tag}.sql didn't apply, so it was taken back; change db/schema and save again.`);
62
+ }
63
+ return applied;
64
+ }
65
+ finally {
66
+ await client.end();
67
+ }
68
+ }
69
+ /** Fails when db/schema has changes no migration holds yet (a build's check). */
70
+ async function check() {
71
+ const planned = await plan(root, await loadSchema());
72
+ if (planned.statements.length === 0)
73
+ return 0;
74
+ console.error(`db/schema has changes with no migration yet; run \`pnpm db:sync\`:\n${planned.statements.join("\n")}`);
75
+ return 1;
76
+ }
77
+ /** Applies every migration in db/migrations the database doesn't have. */
78
+ async function applyMigrations() {
79
+ // The agent (not root) in an app's sandbox: the platform migrates for it.
80
+ if (userInfo().uid !== 0 && existsSync(PLATFORM_MIGRATE))
81
+ return migrateThroughPlatform();
82
+ const client = connect();
83
+ try {
84
+ await client `select pg_advisory_lock(${MIGRATE_LOCK_KEY})`;
85
+ await migrate(drizzle(client), { migrationsFolder: join(root, MIGRATIONS_DIR) });
86
+ console.log("Database is up to date with db/migrations.");
87
+ return 0;
88
+ }
89
+ finally {
90
+ await client.end();
91
+ }
92
+ }
93
+ const platformReply = z.object({ output: z.string(), code: z.number() });
94
+ async function migrateThroughPlatform() {
95
+ const body = await new Promise((resolve, reject) => {
96
+ const req = request({ socketPath: PLATFORM_MIGRATE, method: "POST", path: "/migrate" }, (res) => {
97
+ let text = "";
98
+ res.setEncoding("utf8");
99
+ res.on("data", (chunk) => (text += chunk));
100
+ res.on("end", () => resolve(text));
101
+ });
102
+ req.on("error", reject);
103
+ req.end();
104
+ });
105
+ const reply = platformReply.parse(JSON.parse(body));
106
+ process.stdout.write(reply.output);
107
+ return reply.code;
108
+ }
109
+ // Last, so every const above is initialized before a command reads it.
110
+ const commands = { sync, migrate: applyMigrations, check };
111
+ const usage = z.enum(["sync", "migrate", "check"], { error: "Usage: light-db sync | migrate | check" });
112
+ process.exit(await commands[usage.parse(command)]());
@@ -0,0 +1,44 @@
1
+ import { z } from "zod/v4";
2
+ export declare const SCHEMA_ENTRY = "db/schema/index.ts";
3
+ export declare const MIGRATIONS_DIR = "db/migrations";
4
+ export type Plan = {
5
+ /** The new migration's statements, in order; empty when nothing changed. */
6
+ statements: string[];
7
+ snapshot: Snapshot;
8
+ };
9
+ /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
10
+ declare const snapshotSchema: z.ZodObject<{
11
+ id: z.ZodString;
12
+ tables: z.ZodRecord<z.ZodString, z.ZodObject<{
13
+ columns: z.ZodRecord<z.ZodString, z.ZodUnknown>;
14
+ indexes: z.ZodRecord<z.ZodString, z.ZodUnknown>;
15
+ foreignKeys: z.ZodRecord<z.ZodString, z.ZodUnknown>;
16
+ compositePrimaryKeys: z.ZodRecord<z.ZodString, z.ZodUnknown>;
17
+ uniqueConstraints: z.ZodRecord<z.ZodString, z.ZodUnknown>;
18
+ checkConstraints: z.ZodRecord<z.ZodString, z.ZodUnknown>;
19
+ policies: z.ZodRecord<z.ZodString, z.ZodUnknown>;
20
+ }, z.core.$loose>>;
21
+ enums: z.ZodRecord<z.ZodString, z.ZodUnknown>;
22
+ schemas: z.ZodRecord<z.ZodString, z.ZodUnknown>;
23
+ sequences: z.ZodRecord<z.ZodString, z.ZodUnknown>;
24
+ roles: z.ZodRecord<z.ZodString, z.ZodUnknown>;
25
+ policies: z.ZodRecord<z.ZodString, z.ZodUnknown>;
26
+ views: z.ZodRecord<z.ZodString, z.ZodUnknown>;
27
+ }, z.core.$loose>;
28
+ type Snapshot = z.infer<typeof snapshotSchema>;
29
+ /**
30
+ * What `db/schema` needs beyond the last migration, in three diffs that each
31
+ * only remove or only add, so drizzle-kit never has to ask whether something
32
+ * removed was renamed (it can't ask here: there is no terminal). A rename is
33
+ * therefore a drop and an add: the old column's values go. Removed enums go last,
34
+ * after the columns that used them have changed.
35
+ */
36
+ export declare function plan(root: string, schema: Record<string, unknown>): Promise<Plan>;
37
+ /**
38
+ * Writes the plan as the next migration, in drizzle-kit's own layout (the
39
+ * SQL, its snapshot, the journal entry), so drizzle's migrator applies it.
40
+ */
41
+ export declare function writeMigration(root: string, planned: Plan): string;
42
+ /** Takes back the newest migration, one that didn't apply, so the schema's change is pending again. */
43
+ export declare function unwriteMigration(root: string, tag: string): void;
44
+ export {};
@@ -0,0 +1,158 @@
1
+ // Writing a schema file is the migration: the change from the last
2
+ // migration's snapshot to `db/schema` becomes a new drizzle migration,
3
+ // written and applied at once, whatever it does to the rows there (a removed
4
+ // column's values go with it).
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { join } from "node:path";
8
+ import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
9
+ import { z } from "zod/v4";
10
+ export const SCHEMA_ENTRY = "db/schema/index.ts";
11
+ export const MIGRATIONS_DIR = "db/migrations";
12
+ const entries = z.record(z.string(), z.unknown());
13
+ /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
14
+ const snapshotSchema = z.looseObject({
15
+ id: z.string(),
16
+ tables: z.record(z.string(), z.looseObject({
17
+ columns: entries,
18
+ indexes: entries,
19
+ foreignKeys: entries,
20
+ compositePrimaryKeys: entries,
21
+ uniqueConstraints: entries,
22
+ checkConstraints: entries,
23
+ policies: entries
24
+ })),
25
+ enums: entries,
26
+ schemas: entries,
27
+ sequences: entries,
28
+ roles: entries,
29
+ policies: entries,
30
+ views: entries
31
+ });
32
+ /**
33
+ * What `db/schema` needs beyond the last migration, in three diffs that each
34
+ * only remove or only add, so drizzle-kit never has to ask whether something
35
+ * removed was renamed (it can't ask here: there is no terminal). A rename is
36
+ * therefore a drop and an add: the old column's values go. Removed enums go last,
37
+ * after the columns that used them have changed.
38
+ */
39
+ export async function plan(root, schema) {
40
+ const previous = latestSnapshot(root);
41
+ // As JSON, the way it is written: undefined fields drop, so it compares equal to what was read back.
42
+ const snapshot = snapshotSchema.parse(JSON.parse(JSON.stringify(generateDrizzleJson(schema, previous.id))));
43
+ const kept = withoutRemovals(previous, snapshot);
44
+ const withOldEnums = { ...snapshot, enums: { ...previous.enums, ...snapshot.enums } };
45
+ const statements = [
46
+ ...(await generateMigration(previous, kept)),
47
+ ...(await generateMigration(kept, withOldEnums)),
48
+ ...(await generateMigration(withOldEnums, snapshot))
49
+ ];
50
+ return { statements, snapshot };
51
+ }
52
+ function only(from, keep) {
53
+ return Object.fromEntries(Object.entries(from).filter(([key]) => key in keep));
54
+ }
55
+ /** Entries of `from` that `now` still has exactly as they were. */
56
+ function unchanged(from, now) {
57
+ return Object.fromEntries(Object.entries(from).filter(([key, value]) => isDeepStrictEqual(value, now[key])));
58
+ }
59
+ /**
60
+ * The previous snapshot less whatever the current one no longer has. Tables
61
+ * and columns go by name (a changed column is altered in the second step);
62
+ * indexes and constraints go unless unchanged, so a changed one is dropped
63
+ * before the columns it covers and made again after. Enums all stay: a type
64
+ * is dropped only in the third step, after the columns that use it change.
65
+ */
66
+ function withoutRemovals(previous, current) {
67
+ const tables = {};
68
+ for (const [key, table] of Object.entries(previous.tables)) {
69
+ const now = current.tables[key];
70
+ if (now === undefined)
71
+ continue;
72
+ tables[key] = {
73
+ ...table,
74
+ columns: only(table.columns, now.columns),
75
+ indexes: unchanged(table.indexes, now.indexes),
76
+ foreignKeys: unchanged(table.foreignKeys, now.foreignKeys),
77
+ compositePrimaryKeys: unchanged(table.compositePrimaryKeys, now.compositePrimaryKeys),
78
+ uniqueConstraints: unchanged(table.uniqueConstraints, now.uniqueConstraints),
79
+ checkConstraints: unchanged(table.checkConstraints, now.checkConstraints),
80
+ policies: unchanged(table.policies, now.policies)
81
+ };
82
+ }
83
+ return {
84
+ ...previous,
85
+ tables,
86
+ schemas: only(previous.schemas, current.schemas),
87
+ sequences: only(previous.sequences, current.sequences),
88
+ roles: only(previous.roles, current.roles),
89
+ policies: only(previous.policies, current.policies),
90
+ views: only(previous.views, current.views)
91
+ };
92
+ }
93
+ const journalSchema = z.object({
94
+ version: z.string(),
95
+ dialect: z.literal("postgresql"),
96
+ entries: z.array(z.object({
97
+ idx: z.number(),
98
+ version: z.string(),
99
+ when: z.number(),
100
+ tag: z.string(),
101
+ breakpoints: z.boolean()
102
+ }))
103
+ });
104
+ const EMPTY_SNAPSHOT = {
105
+ version: "7",
106
+ dialect: "postgresql",
107
+ id: "00000000-0000-0000-0000-000000000000",
108
+ prevId: "",
109
+ tables: {},
110
+ enums: {},
111
+ schemas: {},
112
+ policies: {},
113
+ roles: {},
114
+ sequences: {},
115
+ views: {},
116
+ _meta: { schemas: {}, tables: {}, columns: {} }
117
+ };
118
+ function readJournal(root) {
119
+ const path = join(root, MIGRATIONS_DIR, "meta", "_journal.json");
120
+ if (!existsSync(path))
121
+ return { version: "7", dialect: "postgresql", entries: [] };
122
+ return journalSchema.parse(JSON.parse(readFileSync(path, "utf8")));
123
+ }
124
+ function snapshotFile(root, idx) {
125
+ return join(root, MIGRATIONS_DIR, "meta", `${String(idx).padStart(4, "0")}_snapshot.json`);
126
+ }
127
+ function latestSnapshot(root) {
128
+ const last = readJournal(root).entries.at(-1);
129
+ if (last === undefined)
130
+ return EMPTY_SNAPSHOT;
131
+ return snapshotSchema.parse(JSON.parse(readFileSync(snapshotFile(root, last.idx), "utf8")));
132
+ }
133
+ /**
134
+ * Writes the plan as the next migration, in drizzle-kit's own layout (the
135
+ * SQL, its snapshot, the journal entry), so drizzle's migrator applies it.
136
+ */
137
+ export function writeMigration(root, planned) {
138
+ const journal = readJournal(root);
139
+ const idx = journal.entries.length;
140
+ const tag = `${String(idx).padStart(4, "0")}_schema`;
141
+ mkdirSync(join(root, MIGRATIONS_DIR, "meta"), { recursive: true });
142
+ writeFileSync(join(root, MIGRATIONS_DIR, `${tag}.sql`), planned.statements.join("\n--> statement-breakpoint\n") + "\n");
143
+ writeFileSync(snapshotFile(root, idx), JSON.stringify(planned.snapshot, null, 2) + "\n");
144
+ journal.entries.push({ idx, version: "7", when: Date.now(), tag, breakpoints: true });
145
+ writeFileSync(join(root, MIGRATIONS_DIR, "meta", "_journal.json"), JSON.stringify(journal, null, 2) + "\n");
146
+ return tag;
147
+ }
148
+ /** Takes back the newest migration, one that didn't apply, so the schema's change is pending again. */
149
+ export function unwriteMigration(root, tag) {
150
+ const journal = readJournal(root);
151
+ const last = journal.entries.at(-1);
152
+ if (last === undefined || last.tag !== tag)
153
+ throw new Error(`${tag} is not the newest migration.`);
154
+ rmSync(join(root, MIGRATIONS_DIR, `${tag}.sql`));
155
+ rmSync(snapshotFile(root, last.idx));
156
+ journal.entries.pop();
157
+ writeFileSync(join(root, MIGRATIONS_DIR, "meta", "_journal.json"), JSON.stringify(journal, null, 2) + "\n");
158
+ }
@@ -0,0 +1,32 @@
1
+ import type { Entity, PlatformColumn } from "./schema.js";
2
+ export declare const ENTITIES_PATH = "/api/light/entities";
3
+ /** The most rows one list returns; page past it with `offset`. */
4
+ export declare const LIST_LIMIT = 1000;
5
+ /** A value as JSON carries it: a Date column arrives as its ISO string. */
6
+ type Wire<T> = {
7
+ [K in keyof T]: Date extends T[K] ? Exclude<T[K], Date> | string : T[K];
8
+ };
9
+ export type Row<T extends Entity> = Wire<T["$inferSelect"]>;
10
+ /** What a create takes: every column but the ones the platform sets. */
11
+ export type NewRow<T extends Entity> = Omit<Wire<T["$inferInsert"]>, PlatformColumn>;
12
+ /** What an update takes: any of those columns. */
13
+ export type Patch<T extends Entity> = Partial<NewRow<T>>;
14
+ /** Equal to a value, or (an array) to any of several; null matches empty. */
15
+ export type Where<R> = {
16
+ [K in keyof R]?: R[K] | readonly R[K][];
17
+ };
18
+ /** A column, ascending, or `-column`, descending. */
19
+ export type Sort<R> = (keyof R & string) | `-${keyof R & string}`;
20
+ export type ListOptions<R> = {
21
+ where?: Where<R>;
22
+ sort?: Sort<R>;
23
+ /** At most LIST_LIMIT, which is also the default. */
24
+ limit?: number;
25
+ offset?: number;
26
+ };
27
+ /** A refused or failed entities call, with the route's own reason. */
28
+ export declare class EntityError extends Error {
29
+ readonly status: number;
30
+ constructor(message: string, status: number);
31
+ }
32
+ export {};
@@ -0,0 +1,12 @@
1
+ export const ENTITIES_PATH = "/api/light/entities";
2
+ /** The most rows one list returns; page past it with `offset`. */
3
+ export const LIST_LIMIT = 1000;
4
+ /** A refused or failed entities call, with the route's own reason. */
5
+ export class EntityError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ this.name = "EntityError";
11
+ }
12
+ }
@@ -0,0 +1,2 @@
1
+ export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS, type ServerFnName } from "@shubh90/app-runtime/platform";
2
+ export { MiiPaneBridge } from "@shubh90/app-runtime/react";
@@ -0,0 +1,5 @@
1
+ // The platform contract a page and the global middleware need: framing by
2
+ // Plaza's App pane, the server-function door, analytics and error policies,
3
+ // and the pane bridge. Behaviour lives in @shubh90/app-runtime.
4
+ export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS } from "@shubh90/app-runtime/platform";
5
+ export { MiiPaneBridge } from "@shubh90/app-runtime/react";