@trylight/app 0.1.3 → 0.1.5
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 +18 -3
- package/dist/client.d.ts +169 -2
- package/dist/client.js +15 -2
- package/dist/db/baseline.d.ts +36 -0
- package/dist/db/baseline.js +343 -0
- package/dist/db/cli.js +40 -3
- package/dist/db/migrations.d.ts +4 -4
- package/dist/db/migrations.js +4 -4
- package/dist/platform.js +1 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/server/entities.js +2 -2
- package/dist/server/env.d.ts +2 -2
- package/dist/server/env.js +2 -2
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +4 -4
- package/dist/server/session.d.ts +1 -1
- package/dist/server/session.js +4 -4
- package/package.json +12 -9
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@ unchanged; entities and the schema sync are new here.
|
|
|
8
8
|
| Import | What |
|
|
9
9
|
|---|---|
|
|
10
10
|
| `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
|
|
11
|
-
| `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
|
|
11
|
+
| `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
|
|
12
12
|
| `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
|
|
13
13
|
| `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
|
|
14
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 |
|
|
15
|
+
| `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts; `baseline`: an older app's tables into `db/schema`, once |
|
|
16
16
|
|
|
17
17
|
## Entities
|
|
18
18
|
|
|
@@ -70,7 +70,7 @@ fails while `db/schema` has changes no migration holds.
|
|
|
70
70
|
`src/db/audit.ts`, in Postgres itself. Every entity table has a row trigger
|
|
71
71
|
that records each insert, update, delete and truncate in `light_changes` (the
|
|
72
72
|
new row, `{ column: { from, to } }`, or the old row), whoever wrote it: the
|
|
73
|
-
actor is `light.actor_kind/_id/_name`, which the entities route and
|
|
73
|
+
actor is `light.actor_kind/_id/_name`, which the entities route and the platform's
|
|
74
74
|
`mii db write` set per transaction, or else the database role. A migration
|
|
75
75
|
that drops a table or column or changes a column's type first copies what it
|
|
76
76
|
destroys into `light_archive` (`light_archive_values`, called at the top of
|
|
@@ -81,6 +81,21 @@ table` through `mii db write`) is not archived.
|
|
|
81
81
|
`migrate` installs the functions before applying migrations and the triggers
|
|
82
82
|
after; the template's AGENTS.md has the restore SQL.
|
|
83
83
|
|
|
84
|
+
## Baseline
|
|
85
|
+
|
|
86
|
+
`light-db baseline` moves an app made before `db/schema` (the Vite or Next.js
|
|
87
|
+
template, tables made by `migrations/*.sql`) onto it, once: `drizzle-kit pull`
|
|
88
|
+
reads the live database, and `db/migrations/0000_baseline.sql` records the
|
|
89
|
+
tables as they are (its SQL commented out, so migrating runs nothing). A table
|
|
90
|
+
keyed by `id uuid` whose standard columns match becomes an `entity()` file;
|
|
91
|
+
any other stays the plain `pgTable` pull wrote, which no sync changes. The old
|
|
92
|
+
templates' own tables are left in the database. Before writing, it checks the
|
|
93
|
+
files say exactly what the database has; the next sync then only adds the
|
|
94
|
+
`entity()` columns a table lacks and the SDK's `light_*` tables. It refuses,
|
|
95
|
+
writing nothing, a foreign key into a table it leaves out, or an index with a
|
|
96
|
+
non-default operator class. It reads the tables' keys, which Postgres shows
|
|
97
|
+
only to a role with more than SELECT: an app's reader has REFERENCES for it.
|
|
98
|
+
|
|
84
99
|
## Develop
|
|
85
100
|
|
|
86
101
|
```bash
|
package/dist/client.d.ts
CHANGED
|
@@ -23,8 +23,175 @@ declare function tableClient<T extends Entity>(entity: T): {
|
|
|
23
23
|
create: (data: NewRow<T>) => Promise<Row<T>>;
|
|
24
24
|
update: (id: string, patch: Patch<T>) => Promise<Row<T>>;
|
|
25
25
|
delete: (id: string) => Promise<void>;
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The query (`isPending`, `isError`, `error`, `refetch`) and its `rows`,
|
|
28
|
+
* cached per options and refetched after any write to this table. `rows`
|
|
29
|
+
* is never undefined: empty until the first answer, and the same array
|
|
30
|
+
* until the rows change, so it can sit in a hook's dependencies.
|
|
31
|
+
*/
|
|
32
|
+
useList: (options?: ListOptions<Row<T>>) => {
|
|
33
|
+
rows: (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)[];
|
|
34
|
+
data: (T["$inferSelect"] extends infer T_2 ? { [K in keyof T_2]: Date extends T_2[K] ? string | Exclude<T_2[K], T_2[K] & Date> : T_2[K]; } : never)[];
|
|
35
|
+
error: EntityError;
|
|
36
|
+
isError: true;
|
|
37
|
+
isPending: false;
|
|
38
|
+
isLoading: false;
|
|
39
|
+
isLoadingError: false;
|
|
40
|
+
isRefetchError: true;
|
|
41
|
+
isSuccess: false;
|
|
42
|
+
isPlaceholderData: false;
|
|
43
|
+
status: "error";
|
|
44
|
+
dataUpdatedAt: number;
|
|
45
|
+
errorUpdatedAt: number;
|
|
46
|
+
failureCount: number;
|
|
47
|
+
failureReason: EntityError | null;
|
|
48
|
+
errorUpdateCount: number;
|
|
49
|
+
isFetched: boolean;
|
|
50
|
+
isFetchedAfterMount: boolean;
|
|
51
|
+
isFetching: boolean;
|
|
52
|
+
isInitialLoading: boolean;
|
|
53
|
+
isPaused: boolean;
|
|
54
|
+
isRefetching: boolean;
|
|
55
|
+
isStale: boolean;
|
|
56
|
+
isEnabled: boolean;
|
|
57
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[], EntityError>>;
|
|
58
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
59
|
+
} | {
|
|
60
|
+
rows: (T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[];
|
|
61
|
+
data: (T["$inferSelect"] extends infer T_4 ? { [K in keyof T_4]: Date extends T_4[K] ? string | Exclude<T_4[K], T_4[K] & Date> : T_4[K]; } : never)[];
|
|
62
|
+
error: null;
|
|
63
|
+
isError: false;
|
|
64
|
+
isPending: false;
|
|
65
|
+
isLoading: false;
|
|
66
|
+
isLoadingError: false;
|
|
67
|
+
isRefetchError: false;
|
|
68
|
+
isSuccess: true;
|
|
69
|
+
isPlaceholderData: false;
|
|
70
|
+
status: "success";
|
|
71
|
+
dataUpdatedAt: number;
|
|
72
|
+
errorUpdatedAt: number;
|
|
73
|
+
failureCount: number;
|
|
74
|
+
failureReason: EntityError | null;
|
|
75
|
+
errorUpdateCount: number;
|
|
76
|
+
isFetched: boolean;
|
|
77
|
+
isFetchedAfterMount: boolean;
|
|
78
|
+
isFetching: boolean;
|
|
79
|
+
isInitialLoading: boolean;
|
|
80
|
+
isPaused: boolean;
|
|
81
|
+
isRefetching: boolean;
|
|
82
|
+
isStale: boolean;
|
|
83
|
+
isEnabled: boolean;
|
|
84
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[], EntityError>>;
|
|
85
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
86
|
+
} | {
|
|
87
|
+
rows: (T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[];
|
|
88
|
+
data: undefined;
|
|
89
|
+
error: EntityError;
|
|
90
|
+
isError: true;
|
|
91
|
+
isPending: false;
|
|
92
|
+
isLoading: false;
|
|
93
|
+
isLoadingError: true;
|
|
94
|
+
isRefetchError: false;
|
|
95
|
+
isSuccess: false;
|
|
96
|
+
isPlaceholderData: false;
|
|
97
|
+
status: "error";
|
|
98
|
+
dataUpdatedAt: number;
|
|
99
|
+
errorUpdatedAt: number;
|
|
100
|
+
failureCount: number;
|
|
101
|
+
failureReason: EntityError | null;
|
|
102
|
+
errorUpdateCount: number;
|
|
103
|
+
isFetched: boolean;
|
|
104
|
+
isFetchedAfterMount: boolean;
|
|
105
|
+
isFetching: boolean;
|
|
106
|
+
isInitialLoading: boolean;
|
|
107
|
+
isPaused: boolean;
|
|
108
|
+
isRefetching: boolean;
|
|
109
|
+
isStale: boolean;
|
|
110
|
+
isEnabled: boolean;
|
|
111
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[], EntityError>>;
|
|
112
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
113
|
+
} | {
|
|
114
|
+
rows: (T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[];
|
|
115
|
+
data: undefined;
|
|
116
|
+
error: null;
|
|
117
|
+
isError: false;
|
|
118
|
+
isPending: true;
|
|
119
|
+
isLoading: true;
|
|
120
|
+
isLoadingError: false;
|
|
121
|
+
isRefetchError: false;
|
|
122
|
+
isSuccess: false;
|
|
123
|
+
isPlaceholderData: false;
|
|
124
|
+
status: "pending";
|
|
125
|
+
dataUpdatedAt: number;
|
|
126
|
+
errorUpdatedAt: number;
|
|
127
|
+
failureCount: number;
|
|
128
|
+
failureReason: EntityError | null;
|
|
129
|
+
errorUpdateCount: number;
|
|
130
|
+
isFetched: boolean;
|
|
131
|
+
isFetchedAfterMount: boolean;
|
|
132
|
+
isFetching: boolean;
|
|
133
|
+
isInitialLoading: boolean;
|
|
134
|
+
isPaused: boolean;
|
|
135
|
+
isRefetching: boolean;
|
|
136
|
+
isStale: boolean;
|
|
137
|
+
isEnabled: boolean;
|
|
138
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[], EntityError>>;
|
|
139
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
140
|
+
} | {
|
|
141
|
+
rows: (T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[];
|
|
142
|
+
data: undefined;
|
|
143
|
+
error: null;
|
|
144
|
+
isError: false;
|
|
145
|
+
isPending: true;
|
|
146
|
+
isLoadingError: false;
|
|
147
|
+
isRefetchError: false;
|
|
148
|
+
isSuccess: false;
|
|
149
|
+
isPlaceholderData: false;
|
|
150
|
+
status: "pending";
|
|
151
|
+
dataUpdatedAt: number;
|
|
152
|
+
errorUpdatedAt: number;
|
|
153
|
+
failureCount: number;
|
|
154
|
+
failureReason: EntityError | null;
|
|
155
|
+
errorUpdateCount: number;
|
|
156
|
+
isFetched: boolean;
|
|
157
|
+
isFetchedAfterMount: boolean;
|
|
158
|
+
isFetching: boolean;
|
|
159
|
+
isLoading: boolean;
|
|
160
|
+
isInitialLoading: boolean;
|
|
161
|
+
isPaused: boolean;
|
|
162
|
+
isRefetching: boolean;
|
|
163
|
+
isStale: boolean;
|
|
164
|
+
isEnabled: boolean;
|
|
165
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[], EntityError>>;
|
|
166
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
167
|
+
} | {
|
|
168
|
+
rows: (T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[];
|
|
169
|
+
data: (T["$inferSelect"] extends infer T_9 ? { [K in keyof T_9]: Date extends T_9[K] ? string | Exclude<T_9[K], T_9[K] & Date> : T_9[K]; } : never)[];
|
|
170
|
+
isError: false;
|
|
171
|
+
error: null;
|
|
172
|
+
isPending: false;
|
|
173
|
+
isLoading: false;
|
|
174
|
+
isLoadingError: false;
|
|
175
|
+
isRefetchError: false;
|
|
176
|
+
isSuccess: true;
|
|
177
|
+
isPlaceholderData: true;
|
|
178
|
+
status: "success";
|
|
179
|
+
dataUpdatedAt: number;
|
|
180
|
+
errorUpdatedAt: number;
|
|
181
|
+
failureCount: number;
|
|
182
|
+
failureReason: EntityError | null;
|
|
183
|
+
errorUpdateCount: number;
|
|
184
|
+
isFetched: boolean;
|
|
185
|
+
isFetchedAfterMount: boolean;
|
|
186
|
+
isFetching: boolean;
|
|
187
|
+
isInitialLoading: boolean;
|
|
188
|
+
isPaused: boolean;
|
|
189
|
+
isRefetching: boolean;
|
|
190
|
+
isStale: boolean;
|
|
191
|
+
isEnabled: boolean;
|
|
192
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_10 ? { [K in keyof T_10]: Date extends T_10[K] ? string | Exclude<T_10[K], T_10[K] & Date> : T_10[K]; } : never)[], EntityError>>;
|
|
193
|
+
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
194
|
+
};
|
|
28
195
|
/** One row; `null` waits (nothing open yet). */
|
|
29
196
|
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>;
|
|
30
197
|
useCreate: () => import("@tanstack/react-query").UseMutationResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError, NewRow<T>, unknown>;
|
package/dist/client.js
CHANGED
|
@@ -17,6 +17,8 @@ export { InvocationError } from "@shubh90/app-runtime/invoke";
|
|
|
17
17
|
export function createApp() {
|
|
18
18
|
return { table: tableClient, contracts: createInvoker() };
|
|
19
19
|
}
|
|
20
|
+
/** Every list's rows before its first answer: one array, so they never change identity while pending. */
|
|
21
|
+
const NO_ROWS = [];
|
|
20
22
|
/** One entity table's rows: plain calls, and TanStack Query hooks over them. */
|
|
21
23
|
function tableClient(entity) {
|
|
22
24
|
const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
|
|
@@ -58,8 +60,19 @@ function tableClient(entity) {
|
|
|
58
60
|
create,
|
|
59
61
|
update,
|
|
60
62
|
delete: remove,
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The query (`isPending`, `isError`, `error`, `refetch`) and its `rows`,
|
|
65
|
+
* cached per options and refetched after any write to this table. `rows`
|
|
66
|
+
* is never undefined: empty until the first answer, and the same array
|
|
67
|
+
* until the rows change, so it can sit in a hook's dependencies.
|
|
68
|
+
*/
|
|
69
|
+
useList: (options = {}) => {
|
|
70
|
+
const query = useQuery({ queryKey: keys.list(options), queryFn: () => list(options) });
|
|
71
|
+
const { data: rows = NO_ROWS } = query;
|
|
72
|
+
// A spread reads every field, so the page re-renders on any of them (isFetching
|
|
73
|
+
// too); a list page shows those states anyway, and it keeps the result plain.
|
|
74
|
+
return { ...query, rows };
|
|
75
|
+
},
|
|
63
76
|
/** One row; `null` waits (nothing open yet). */
|
|
64
77
|
useGet: (id) => useQuery({
|
|
65
78
|
queryKey: keys.get(id),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Snapshot } from "./migrations.js";
|
|
2
|
+
export type Baseline = {
|
|
3
|
+
/** Each app table written to db/schema as an entity(), with the entity() columns it lacks (the first sync adds them). */
|
|
4
|
+
tables: {
|
|
5
|
+
name: string;
|
|
6
|
+
lacks: string[];
|
|
7
|
+
}[];
|
|
8
|
+
/** App tables entity() can't key or say exactly, and why: plain tables in db/schema. */
|
|
9
|
+
plain: {
|
|
10
|
+
name: string;
|
|
11
|
+
why: string;
|
|
12
|
+
}[];
|
|
13
|
+
/** Platform tables left as they are. */
|
|
14
|
+
skipped: string[];
|
|
15
|
+
/** Other things drizzle-kit found (views, sequences…) that db/schema doesn't hold. */
|
|
16
|
+
leftOut: string[];
|
|
17
|
+
/** What the first `light-db sync` will apply. */
|
|
18
|
+
pending: string[];
|
|
19
|
+
};
|
|
20
|
+
/** Why the database can't become db/schema as it is; nothing was written. */
|
|
21
|
+
export declare class BaselineRefused extends Error {
|
|
22
|
+
}
|
|
23
|
+
export declare function baseline(root: string, databaseUrl: string, schemaModule?: string): Promise<Baseline>;
|
|
24
|
+
/** Runs drizzle-kit pull into a temporary folder: the tables as TypeScript, and drizzle's snapshot of them. */
|
|
25
|
+
export declare function pull(databaseUrl: string): {
|
|
26
|
+
source: string;
|
|
27
|
+
snapshot: Snapshot;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* What drizzle-kit pull writes wrong: an empty-string default as `.default(')`,
|
|
31
|
+
* and a column of a type it can't parse (tsvector, bytea) as `unknown(...)`
|
|
32
|
+
* under a TODO naming the type, which becomes a custom type of that name.
|
|
33
|
+
*/
|
|
34
|
+
export declare function repairPull(text: string): string;
|
|
35
|
+
/** What a sync would run to make the `live` tables (as pulled) into `recorded`'s; none when they match. */
|
|
36
|
+
export declare function driftFrom(live: Snapshot, recorded: Snapshot): Promise<string[]>;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// `light-db baseline`: an older app's live tables become db/schema. drizzle-kit
|
|
2
|
+
// pull reads the database; each app table becomes a file with its real
|
|
3
|
+
// columns, and a first migration records the tables exactly as they are (its
|
|
4
|
+
// SQL commented out: they already exist). A table entity() can key becomes an
|
|
5
|
+
// entity(), so the next sync adds only the entity() columns it lacks; any
|
|
6
|
+
// other stays the plain table pull wrote, which no sync changes.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
|
|
13
|
+
import postgres from "postgres";
|
|
14
|
+
import { z } from "zod/v4";
|
|
15
|
+
import { getTableName, is } from "drizzle-orm";
|
|
16
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
17
|
+
import * as sdkSchema from "../schema.js";
|
|
18
|
+
import { EMPTY_SNAPSHOT, SCHEMA_ENTRY, plan, snapshotSchema, writeMigration } from "./migrations.js";
|
|
19
|
+
/** The SDK's own tables (`lightChanges`, `lightImages`…): every app's db/schema exports them. */
|
|
20
|
+
const SDK_TABLES = Object.entries(sdkSchema).flatMap(([name, value]) => (is(value, PgTable) ? [{ name, table: getTableName(value) }] : []));
|
|
21
|
+
/**
|
|
22
|
+
* Tables the platform keeps rather than the app: app-runtime's sign-in, the
|
|
23
|
+
* Vite template's AI actions (record jobs, AI searches, intro bans), image
|
|
24
|
+
* copies and migration log (org-app-template-vite/migrations), the Next.js
|
|
25
|
+
* template's settings, analytics and migration log, and v2's archive and SDK
|
|
26
|
+
* tables. Baseline leaves them in the database as they are.
|
|
27
|
+
*/
|
|
28
|
+
const PLATFORM_TABLES = [
|
|
29
|
+
"mii_auth_users",
|
|
30
|
+
"mii_auth_sessions",
|
|
31
|
+
"record_jobs",
|
|
32
|
+
"ai_searches",
|
|
33
|
+
"excluded_people",
|
|
34
|
+
"mii_images",
|
|
35
|
+
"_migrations",
|
|
36
|
+
"app_migrations",
|
|
37
|
+
"app_settings",
|
|
38
|
+
"analytics_events",
|
|
39
|
+
"event_coverage",
|
|
40
|
+
"person_aliases",
|
|
41
|
+
"light_archive",
|
|
42
|
+
...SDK_TABLES.map((sdk) => sdk.table)
|
|
43
|
+
];
|
|
44
|
+
/** What every table baseline writes lets through `app.table(...)`; the agent narrows it per table. */
|
|
45
|
+
const ACCESS = '{ read: "door", write: "door" }';
|
|
46
|
+
/** Why the database can't become db/schema as it is; nothing was written. */
|
|
47
|
+
export class BaselineRefused extends Error {
|
|
48
|
+
}
|
|
49
|
+
const pulledColumn = z.looseObject({ type: z.string(), primaryKey: z.boolean() });
|
|
50
|
+
const pulledTable = z.looseObject({
|
|
51
|
+
name: z.string(),
|
|
52
|
+
schema: z.string(),
|
|
53
|
+
columns: z.record(z.string(), pulledColumn),
|
|
54
|
+
compositePrimaryKeys: z.record(z.string(), z.looseObject({ columns: z.array(z.string()) })),
|
|
55
|
+
foreignKeys: z.record(z.string(), z.looseObject({ tableTo: z.string(), columnsFrom: z.array(z.string()) }))
|
|
56
|
+
});
|
|
57
|
+
const pulledTables = z.record(z.string(), pulledTable);
|
|
58
|
+
/** `export const <name> = <kind>("<dbName>"`, the head of each declaration drizzle-kit pull writes. */
|
|
59
|
+
const DECLARATION = /^export const (\w+) = (\w+)\("([^"]+)"/;
|
|
60
|
+
const declarationParts = z.tuple([z.string(), z.string(), z.string()]);
|
|
61
|
+
export async function baseline(root, databaseUrl, schemaModule = "@trylight/app/schema") {
|
|
62
|
+
const dbDir = join(root, "db");
|
|
63
|
+
if (existsSync(dbDir)) {
|
|
64
|
+
throw new BaselineRefused("db/ already exists. Baseline writes db/schema and db/migrations from the database, once; move db/ aside first.");
|
|
65
|
+
}
|
|
66
|
+
const pulled = pull(databaseUrl);
|
|
67
|
+
const { specialOpclasses, hiddenKeys } = await catalogFacts(databaseUrl);
|
|
68
|
+
if (hiddenKeys.length > 0) {
|
|
69
|
+
throw new BaselineRefused(`The database role baseline reads with can't see the keys of ${hiddenKeys.join(", ")}: Postgres shows them only to a role with REFERENCES (or more) on the table, ` +
|
|
70
|
+
"which the platform grants an app's reader (scripts/grant-reader-references.ts). Nothing was written.");
|
|
71
|
+
}
|
|
72
|
+
const live = pulledTables.parse(pulled.snapshot.tables);
|
|
73
|
+
const declarations = pulled.source.split(/\n(?=export const )/).slice(1).map(readDeclaration);
|
|
74
|
+
const tables = declarations.filter((d) => d.kind === "pgTable" && !PLATFORM_TABLES.includes(d.dbName));
|
|
75
|
+
const enums = declarations.filter((d) => d.kind === "pgEnum");
|
|
76
|
+
const skipped = declarations.filter((d) => d.kind === "pgTable" && PLATFORM_TABLES.includes(d.dbName)).map((d) => d.dbName);
|
|
77
|
+
const leftOut = declarations.filter((d) => d.kind !== "pgTable" && d.kind !== "pgEnum").map((d) => `${d.dbName} (${d.kind})`);
|
|
78
|
+
const standard = await standardSnapshot();
|
|
79
|
+
const judged = await Promise.all(tables.map((table) => entityMisfit(liveTable(live, table.dbName), standard).then((why) => ({ name: table.dbName, why }))));
|
|
80
|
+
const plain = judged.flatMap(({ name, why }) => (why === null ? [] : [{ name, why }]));
|
|
81
|
+
const entities = new Set(judged.filter(({ why }) => why === null).map(({ name }) => name));
|
|
82
|
+
const refusals = [
|
|
83
|
+
...tables.flatMap((table) => refusalsFor(liveTable(live, table.dbName))),
|
|
84
|
+
...specialOpclasses
|
|
85
|
+
.filter((index) => tables.some((table) => table.dbName === index.table))
|
|
86
|
+
.map((index) => `- ${index.table}: index ${index.index} uses operator class ${index.opclass}, which db/schema can't carry (drizzle-kit reads index operator classes wrongly). ` +
|
|
87
|
+
"Recreate it without one in the old app's own migrations (the CLI's `app migrate` applies them), or leave it out and add it back in db/schema after the move, then run baseline again.")
|
|
88
|
+
];
|
|
89
|
+
if (refusals.length > 0) {
|
|
90
|
+
throw new BaselineRefused(`These tables can't go into db/schema as they are; nothing was written:\n${refusals.join("\n")}`);
|
|
91
|
+
}
|
|
92
|
+
const pgCore = importedNames(pulled.source);
|
|
93
|
+
mkdirSync(join(root, "db", "schema"), { recursive: true });
|
|
94
|
+
try {
|
|
95
|
+
for (const table of tables) {
|
|
96
|
+
const file = tableFile(table, liveTable(live, table.dbName), entities.has(table.dbName), { pgCore, tables, enums, schemaModule });
|
|
97
|
+
writeFileSync(join(root, "db", "schema", `${table.dbName}.ts`), file);
|
|
98
|
+
}
|
|
99
|
+
if (enums.length > 0) {
|
|
100
|
+
writeFileSync(join(root, "db", "schema", "enums.ts"), enumsFile(enums));
|
|
101
|
+
}
|
|
102
|
+
writeFileSync(join(root, SCHEMA_ENTRY), indexFile(tables, enums.length > 0, schemaModule));
|
|
103
|
+
const schema = await import(pathToFileURL(join(root, SCHEMA_ENTRY)).href);
|
|
104
|
+
const full = snapshotSchema.parse(JSON.parse(JSON.stringify(generateDrizzleJson(schema, EMPTY_SNAPSHOT.id))));
|
|
105
|
+
const recorded = asTheyAre(full, live);
|
|
106
|
+
// What entity() says must be exactly what the database has.
|
|
107
|
+
const drift = await driftFrom(pulled.snapshot, recorded);
|
|
108
|
+
if (drift.length > 0) {
|
|
109
|
+
throw new BaselineRefused(`db/schema can't say these tables exactly as the database has them; nothing was written. A sync would run:\n${drift.join("\n")}\n` +
|
|
110
|
+
"Change them in the old app's own migrations (the CLI's `app migrate` applies them) so they fit, then run baseline again.");
|
|
111
|
+
}
|
|
112
|
+
const created = await generateMigration(EMPTY_SNAPSHOT, recorded);
|
|
113
|
+
const commented = [
|
|
114
|
+
"-- light-db baseline: the app's tables as they were when it moved to db/schema.",
|
|
115
|
+
"-- They already exist, so nothing here runs; the schema's history starts from them.",
|
|
116
|
+
...created.join("\n").split("\n").map((line) => `-- ${line}`)
|
|
117
|
+
].join("\n");
|
|
118
|
+
writeMigration(root, { statements: [commented], archive: [], snapshot: recorded }, "baseline");
|
|
119
|
+
const pending = await plan(root, schema);
|
|
120
|
+
return {
|
|
121
|
+
tables: tables
|
|
122
|
+
.filter((table) => entities.has(table.dbName))
|
|
123
|
+
.map((table) => ({ name: table.dbName, lacks: lacking(liveTable(live, table.dbName)) })),
|
|
124
|
+
plain,
|
|
125
|
+
skipped,
|
|
126
|
+
leftOut,
|
|
127
|
+
pending: pending.statements
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** What pull can't tell: indexes with a non-default operator class, and tables whose keys this role can't see. */
|
|
136
|
+
async function catalogFacts(databaseUrl) {
|
|
137
|
+
const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined });
|
|
138
|
+
try {
|
|
139
|
+
const specialOpclasses = await sql `
|
|
140
|
+
select t.relname as table, i.relname as index, o.opcname as opclass
|
|
141
|
+
from pg_index x
|
|
142
|
+
join pg_class i on i.oid = x.indexrelid
|
|
143
|
+
join pg_class t on t.oid = x.indrelid
|
|
144
|
+
join pg_namespace n on n.oid = t.relnamespace
|
|
145
|
+
cross join lateral unnest(x.indclass::oid[]) as c(oid)
|
|
146
|
+
join pg_opclass o on o.oid = c.oid
|
|
147
|
+
where n.nspname = 'public' and not o.opcdefault`;
|
|
148
|
+
const hidden = await sql `
|
|
149
|
+
select c.relname as table from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
150
|
+
where n.nspname = 'public' and c.relkind in ('r', 'p') and not has_table_privilege(c.oid, 'REFERENCES')
|
|
151
|
+
order by 1`;
|
|
152
|
+
return { specialOpclasses, hiddenKeys: hidden.map((row) => row.table) };
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await sql.end();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Runs drizzle-kit pull into a temporary folder: the tables as TypeScript, and drizzle's snapshot of them. */
|
|
159
|
+
export function pull(databaseUrl) {
|
|
160
|
+
const dir = mkdtempSync(join(tmpdir(), "light-db-baseline-"));
|
|
161
|
+
try {
|
|
162
|
+
const settings = { dialect: "postgresql", out: "./out", schemaFilter: ["public"], introspect: { casing: "preserve" } };
|
|
163
|
+
const config = join(dir, "drizzle.config.mjs");
|
|
164
|
+
// The URL stays in the environment, out of the file and the process list.
|
|
165
|
+
writeFileSync(config, `export default { ...${JSON.stringify(settings)}, dbCredentials: { url: process.env.DATABASE_URL } };\n`);
|
|
166
|
+
const drizzleKit = join(dirname(fileURLToPath(import.meta.resolve("drizzle-kit"))), "bin.cjs");
|
|
167
|
+
const run = spawnSync(process.execPath, [drizzleKit, "pull", "--config", config], {
|
|
168
|
+
cwd: dir,
|
|
169
|
+
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
170
|
+
encoding: "utf8"
|
|
171
|
+
});
|
|
172
|
+
const out = join(dir, "out");
|
|
173
|
+
if (run.status !== 0 || !existsSync(join(out, "schema.ts"))) {
|
|
174
|
+
throw new Error(`drizzle-kit pull failed:\n${run.stdout}${run.stderr}`);
|
|
175
|
+
}
|
|
176
|
+
const [snapshotName] = readdirSync(join(out, "meta")).filter((file) => file.endsWith("_snapshot.json"));
|
|
177
|
+
if (snapshotName === undefined)
|
|
178
|
+
throw new Error(`drizzle-kit pull wrote no snapshot:\n${run.stdout}`);
|
|
179
|
+
return {
|
|
180
|
+
source: readFileSync(join(out, "schema.ts"), "utf8"),
|
|
181
|
+
snapshot: snapshotSchema.parse(JSON.parse(readFileSync(join(out, "meta", snapshotName), "utf8")))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function readDeclaration(text) {
|
|
189
|
+
const match = DECLARATION.exec(text);
|
|
190
|
+
if (match === null)
|
|
191
|
+
throw new Error(`drizzle-kit pull wrote a declaration baseline can't read:\n${text}`);
|
|
192
|
+
const [name, kind, dbName] = declarationParts.parse(match.slice(1));
|
|
193
|
+
return { name, kind, dbName, text: text.trimEnd() };
|
|
194
|
+
}
|
|
195
|
+
function liveTable(live, name) {
|
|
196
|
+
const table = live[`public.${name}`];
|
|
197
|
+
if (table === undefined)
|
|
198
|
+
throw new Error(`drizzle-kit pull wrote ${name} without its snapshot.`);
|
|
199
|
+
return table;
|
|
200
|
+
}
|
|
201
|
+
function refusalsFor(table) {
|
|
202
|
+
return Object.values(table.foreignKeys)
|
|
203
|
+
.filter((key) => PLATFORM_TABLES.includes(key.tableTo))
|
|
204
|
+
.map((key) => `- ${table.name}.${key.columnsFrom.join(", ")} references ${key.tableTo}, a platform table db/schema doesn't hold. ` +
|
|
205
|
+
"Drop that foreign key in the old app's own migrations (the CLI's `app migrate` applies them; the column and its values stay), then run baseline again.");
|
|
206
|
+
}
|
|
207
|
+
/** The standard columns as entity() declares them, in drizzle-kit's snapshot form. */
|
|
208
|
+
async function standardSnapshot() {
|
|
209
|
+
const probe = { probe: sdkSchema.entity("probe", {}, { read: "door", write: "door" }) };
|
|
210
|
+
const snapshot = snapshotSchema.parse(JSON.parse(JSON.stringify(generateDrizzleJson(probe, EMPTY_SNAPSHOT.id))));
|
|
211
|
+
const table = snapshot.tables["public.probe"];
|
|
212
|
+
if (table === undefined)
|
|
213
|
+
throw new Error("drizzle-kit wrote no snapshot of entity()'s columns.");
|
|
214
|
+
return table;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Why entity() can't say this table, or null when it can: it needs a lone
|
|
218
|
+
* `id uuid` key, and each standard column it already has exactly as entity()
|
|
219
|
+
* declares it (a sync would otherwise change it).
|
|
220
|
+
*/
|
|
221
|
+
async function entityMisfit(table, standard) {
|
|
222
|
+
const id = table.columns.id;
|
|
223
|
+
if (id === undefined || id.type !== "uuid" || !id.primaryKey || Object.keys(table.compositePrimaryKeys).length > 0) {
|
|
224
|
+
return "not keyed by a lone `id uuid` primary key";
|
|
225
|
+
}
|
|
226
|
+
const shared = sdkSchema.STANDARD_COLUMNS.filter((column) => column in table.columns);
|
|
227
|
+
const only = (columns) => ({
|
|
228
|
+
"public.probe": { ...standard, columns: Object.fromEntries(shared.map((column) => [column, columns[column]])) }
|
|
229
|
+
});
|
|
230
|
+
const changes = await generateMigration({ ...EMPTY_SNAPSHOT, tables: only(table.columns) }, { ...EMPTY_SNAPSHOT, tables: only(standard.columns) });
|
|
231
|
+
if (changes.length === 0)
|
|
232
|
+
return null;
|
|
233
|
+
return `entity() would change it: ${changes.map((change) => change.replaceAll('"probe"', `"${table.name}"`)).join(" ")}`;
|
|
234
|
+
}
|
|
235
|
+
/** The entity() columns a live table doesn't have yet. */
|
|
236
|
+
function lacking(table) {
|
|
237
|
+
return sdkSchema.STANDARD_COLUMNS.filter((column) => !(column in table.columns));
|
|
238
|
+
}
|
|
239
|
+
/** The names drizzle-kit pull imported from pg-core. */
|
|
240
|
+
function importedNames(source) {
|
|
241
|
+
const match = /^import \{ ([^}]+) \} from "drizzle-orm\/pg-core"/m.exec(source);
|
|
242
|
+
if (match === null)
|
|
243
|
+
throw new Error(`drizzle-kit pull wrote no pg-core import baseline can read:\n${source}`);
|
|
244
|
+
return z.string().parse(match[1]).split(",").map((name) => name.trim());
|
|
245
|
+
}
|
|
246
|
+
const uses = (text, name, after) => new RegExp(`\\b${name}${after}`).test(text);
|
|
247
|
+
/**
|
|
248
|
+
* One pulled `pgTable` as a db/schema file: an `entity()` (which declares its
|
|
249
|
+
* standard columns itself) when it fits one, else the table as pulled.
|
|
250
|
+
*/
|
|
251
|
+
function tableFile(table, live, asEntity, context) {
|
|
252
|
+
const standard = new RegExp(`^\\t(${sdkSchema.STANDARD_COLUMNS.join("|")}): `);
|
|
253
|
+
const lines = repairPull(table.text)
|
|
254
|
+
.split("\n")
|
|
255
|
+
.filter((line) => !asEntity || !standard.test(line))
|
|
256
|
+
// pull names each index column's operator class, often the wrong one; baseline refused any that isn't the default.
|
|
257
|
+
.map((line) => (line.includes('.using("btree"') ? line.replace(/\.op\("[^"]*"\)/g, "") : line));
|
|
258
|
+
let body = lines.join("\n");
|
|
259
|
+
if (asEntity) {
|
|
260
|
+
body = body.replace(`= pgTable("${table.dbName}", {`, `= entity("${table.dbName}", {`);
|
|
261
|
+
body = body.endsWith("\n});")
|
|
262
|
+
? `${body.slice(0, -"\n});".length)}\n}, ${ACCESS});`
|
|
263
|
+
: body.replace("\n}, (table) => [", `\n}, ${ACCESS}, (table) => [`);
|
|
264
|
+
if (!body.includes(ACCESS))
|
|
265
|
+
throw new Error(`drizzle-kit pull wrote ${table.dbName} in a shape baseline can't read:\n${table.text}`);
|
|
266
|
+
}
|
|
267
|
+
const pgCore = [...context.pgCore, "customType"].filter((name) => uses(body, name, "[<(]"));
|
|
268
|
+
const referenced = new Set(Object.values(live.foreignKeys).map((key) => key.tableTo));
|
|
269
|
+
const others = context.tables.filter((other) => other !== table && referenced.has(other.dbName));
|
|
270
|
+
const types = new Set(Object.values(live.columns).map((column) => column.type));
|
|
271
|
+
const enums = context.enums.filter((e) => types.has(e.dbName));
|
|
272
|
+
return [
|
|
273
|
+
...(asEntity ? [`import { entity } from "${context.schemaModule}";`] : []),
|
|
274
|
+
...(pgCore.length > 0 ? [`import { ${pgCore.join(", ")} } from "drizzle-orm/pg-core";`] : []),
|
|
275
|
+
...(uses(body, "sql", "`") ? ['import { sql } from "drizzle-orm";'] : []),
|
|
276
|
+
...(enums.length > 0 ? [`import { ${enums.map((e) => e.name).join(", ")} } from "./enums";`] : []),
|
|
277
|
+
...others.map((other) => `import { ${other.name} } from "./${other.dbName}";`),
|
|
278
|
+
"",
|
|
279
|
+
body,
|
|
280
|
+
""
|
|
281
|
+
].join("\n");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* What drizzle-kit pull writes wrong: an empty-string default as `.default(')`,
|
|
285
|
+
* and a column of a type it can't parse (tsvector, bytea) as `unknown(...)`
|
|
286
|
+
* under a TODO naming the type, which becomes a custom type of that name.
|
|
287
|
+
*/
|
|
288
|
+
export function repairPull(text) {
|
|
289
|
+
return text
|
|
290
|
+
.replaceAll(".default(')", '.default("")')
|
|
291
|
+
.replace(/\t\/\/ TODO: failed to parse database type '([^']+)'\n(\t\w+: )unknown\(/g, (_, type, column) => `${column}customType<{ data: unknown }>({ dataType: () => ${JSON.stringify(type)} })(`);
|
|
292
|
+
}
|
|
293
|
+
function enumsFile(enums) {
|
|
294
|
+
return ['import { pgEnum } from "drizzle-orm/pg-core";', "", ...enums.map((e) => e.text), ""].join("\n");
|
|
295
|
+
}
|
|
296
|
+
function indexFile(tables, hasEnums, schemaModule) {
|
|
297
|
+
return [
|
|
298
|
+
"// The app's tables: one file per table in this folder, each exported here.",
|
|
299
|
+
"// Writing a file here is the migration (see AGENTS.md).",
|
|
300
|
+
`export { ${SDK_TABLES.map((sdk) => sdk.name).join(", ")} } from "${schemaModule}";`,
|
|
301
|
+
...(hasEnums ? ['export * from "./enums";'] : []),
|
|
302
|
+
...tables.map((table) => `export * from "./${table.dbName}";`),
|
|
303
|
+
""
|
|
304
|
+
].join("\n");
|
|
305
|
+
}
|
|
306
|
+
/** The schema's snapshot as the database has it: its app tables, less the entity() columns they lack. */
|
|
307
|
+
function asTheyAre(full, live) {
|
|
308
|
+
const tables = {};
|
|
309
|
+
for (const [key, table] of Object.entries(full.tables)) {
|
|
310
|
+
const now = live[key];
|
|
311
|
+
if (now === undefined)
|
|
312
|
+
continue;
|
|
313
|
+
tables[key] = { ...table, columns: Object.fromEntries(Object.entries(table.columns).filter(([column]) => column in now.columns)) };
|
|
314
|
+
}
|
|
315
|
+
return { ...full, tables };
|
|
316
|
+
}
|
|
317
|
+
/** What a sync would run to make the `live` tables (as pulled) into `recorded`'s; none when they match. */
|
|
318
|
+
export function driftFrom(live, recorded) {
|
|
319
|
+
return generateMigration(comparable(live, recorded), comparable(recorded, recorded));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A snapshot with only what db/schema holds (`recorded`'s tables and enums),
|
|
323
|
+
* btree operator classes aside: pull misreads them, and the default is what
|
|
324
|
+
* the files say.
|
|
325
|
+
*/
|
|
326
|
+
function comparable(snapshot, recorded) {
|
|
327
|
+
const tables = {};
|
|
328
|
+
for (const key of Object.keys(recorded.tables)) {
|
|
329
|
+
const table = snapshot.tables[key];
|
|
330
|
+
if (table === undefined)
|
|
331
|
+
throw new Error(`${key} is in db/schema but not in the database drizzle-kit read.`);
|
|
332
|
+
tables[key] = { ...table, indexes: Object.fromEntries(Object.entries(table.indexes).map(([name, index]) => [name, withoutBtreeOpclass(index)])) };
|
|
333
|
+
}
|
|
334
|
+
return { ...snapshot, tables, schemas: {}, sequences: {}, roles: {}, policies: {}, views: {} };
|
|
335
|
+
}
|
|
336
|
+
const btreeIndex = z.looseObject({ method: z.literal("btree"), columns: z.array(z.record(z.string(), z.unknown())) });
|
|
337
|
+
function withoutBtreeOpclass(index) {
|
|
338
|
+
const parsed = btreeIndex.safeParse(index);
|
|
339
|
+
if (!parsed.success)
|
|
340
|
+
return index;
|
|
341
|
+
const columns = parsed.data.columns.map((column) => Object.fromEntries(Object.entries(column).filter(([key]) => key !== "opclass")));
|
|
342
|
+
return { ...parsed.data, columns };
|
|
343
|
+
}
|
package/dist/db/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `light-db sync`, `light-db migrate`, `light-db check`: the
|
|
2
|
-
// app's `pnpm db:*` scripts
|
|
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.
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
4
5
|
import { request } from "node:http";
|
|
5
6
|
import { userInfo } from "node:os";
|
|
@@ -10,6 +11,7 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
|
10
11
|
import postgres from "postgres";
|
|
11
12
|
import { z } from "zod/v4";
|
|
12
13
|
import { archiveNotices, entityTables, prepareAudit, trackTables } from "./audit.js";
|
|
14
|
+
import { BaselineRefused, baseline as writeBaseline } from "./baseline.js";
|
|
13
15
|
import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
|
|
14
16
|
import { env } from "../server/env.js";
|
|
15
17
|
/**
|
|
@@ -125,7 +127,42 @@ async function migrateThroughPlatform() {
|
|
|
125
127
|
process.stdout.write(reply.output);
|
|
126
128
|
return reply.code;
|
|
127
129
|
}
|
|
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
|
+
}
|
|
128
165
|
// Last, so every const above is initialized before a command reads it.
|
|
129
|
-
const commands = { sync, migrate: applyMigrations, check };
|
|
130
|
-
const usage = z.enum(["sync", "migrate", "check"], { error: "Usage: light-db sync | migrate | check" });
|
|
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" });
|
|
131
168
|
process.exit(await commands[usage.parse(command)]());
|
package/dist/db/migrations.d.ts
CHANGED
|
@@ -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
|
-
declare const snapshotSchema: z.ZodObject<{
|
|
13
|
+
export 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>;
|
|
@@ -28,7 +28,7 @@ declare const snapshotSchema: z.ZodObject<{
|
|
|
28
28
|
policies: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
29
29
|
views: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
30
30
|
}, z.core.$loose>;
|
|
31
|
-
type Snapshot = z.infer<typeof snapshotSchema>;
|
|
31
|
+
export type Snapshot = z.infer<typeof snapshotSchema>;
|
|
32
32
|
/**
|
|
33
33
|
* What `db/schema` needs beyond the last migration, in three diffs that each
|
|
34
34
|
* only remove or only add, so drizzle-kit never has to ask whether something
|
|
@@ -37,11 +37,11 @@ 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;
|
|
40
41
|
/**
|
|
41
42
|
* Writes the plan as the next migration, in drizzle-kit's own layout (the
|
|
42
43
|
* SQL, its snapshot, the journal entry), so drizzle's migrator applies it.
|
|
43
44
|
*/
|
|
44
|
-
export declare function writeMigration(root: string, planned: Plan): string;
|
|
45
|
+
export declare function writeMigration(root: string, planned: Plan, name?: string): string;
|
|
45
46
|
/** Takes back the newest migration, one that didn't apply, so the schema's change is pending again. */
|
|
46
47
|
export declare function unwriteMigration(root: string, tag: string): void;
|
|
47
|
-
export {};
|
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
-
const snapshotSchema = z.looseObject({
|
|
15
|
+
export 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
|
-
const EMPTY_SNAPSHOT = {
|
|
105
|
+
export const EMPTY_SNAPSHOT = {
|
|
106
106
|
version: "7",
|
|
107
107
|
dialect: "postgresql",
|
|
108
108
|
id: "00000000-0000-0000-0000-000000000000",
|
|
@@ -135,10 +135,10 @@ function latestSnapshot(root) {
|
|
|
135
135
|
* Writes the plan as the next migration, in drizzle-kit's own layout (the
|
|
136
136
|
* SQL, its snapshot, the journal entry), so drizzle's migrator applies it.
|
|
137
137
|
*/
|
|
138
|
-
export function writeMigration(root, planned) {
|
|
138
|
+
export function writeMigration(root, planned, name = "schema") {
|
|
139
139
|
const journal = readJournal(root);
|
|
140
140
|
const idx = journal.entries.length;
|
|
141
|
-
const tag = `${String(idx).padStart(4, "0")}
|
|
141
|
+
const tag = `${String(idx).padStart(4, "0")}_${name}`;
|
|
142
142
|
mkdirSync(join(root, MIGRATIONS_DIR, "meta"), { recursive: true });
|
|
143
143
|
const statements = [...archiveStatements(planned.archive, tag), ...planned.statements];
|
|
144
144
|
writeFileSync(join(root, MIGRATIONS_DIR, `${tag}.sql`), statements.join("\n--> statement-breakpoint\n") + "\n");
|
package/dist/platform.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The platform contract a page and the global middleware need: framing by
|
|
2
|
-
//
|
|
2
|
+
// the platform's App pane, the server-function door, analytics and error policies,
|
|
3
3
|
// and the pane bridge. Behaviour lives in @shubh90/app-runtime.
|
|
4
4
|
export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS } from "@shubh90/app-runtime/platform";
|
|
5
5
|
export { MiiPaneBridge } from "@shubh90/app-runtime/react";
|
package/dist/schema.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ declare function standardColumns(): {
|
|
|
36
36
|
}>>>>>;
|
|
37
37
|
};
|
|
38
38
|
type StandardColumns = ReturnType<typeof standardColumns>;
|
|
39
|
+
/** The columns `entity()` adds to every table, by name. */
|
|
40
|
+
export declare const STANDARD_COLUMNS: readonly string[];
|
|
39
41
|
/** The columns the platform sets, as a zod omit mask: never in a create or update. */
|
|
40
42
|
export declare const PLATFORM_COLUMNS: {
|
|
41
43
|
id: true;
|
package/dist/schema.js
CHANGED
|
@@ -32,6 +32,8 @@ function standardColumns() {
|
|
|
32
32
|
field_sources: jsonb().$type().notNull().default({})
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/** The columns `entity()` adds to every table, by name. */
|
|
36
|
+
export const STANDARD_COLUMNS = Object.keys(standardColumns());
|
|
35
37
|
/** The columns the platform sets, as a zod omit mask: never in a create or update. */
|
|
36
38
|
export const PLATFORM_COLUMNS = {
|
|
37
39
|
id: true,
|
package/dist/server/entities.js
CHANGED
|
@@ -37,11 +37,11 @@ export function createEntitiesHandler(options) {
|
|
|
37
37
|
.map((entity) => [getTableName(entity), tableRules(entity)]));
|
|
38
38
|
return async (request) => {
|
|
39
39
|
const url = new URL(request.url);
|
|
40
|
-
// Every org app shares one site (
|
|
40
|
+
// Every org app shares one site (*.<domain>): only this app's pages may use it.
|
|
41
41
|
if (!isSameOrigin(request, url)) {
|
|
42
42
|
return refuse(403, "cross_site", "Only this app's own pages can use its data.");
|
|
43
43
|
}
|
|
44
|
-
// No
|
|
44
|
+
// No platform to sign in with: only a bare local dev server serves (as the
|
|
45
45
|
// local developer). A deployment or a sandbox missing its sign-in config serves nothing.
|
|
46
46
|
const { configured, local } = env();
|
|
47
47
|
if (!configured && !local) {
|
package/dist/server/env.d.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
export declare function env(): {
|
|
3
3
|
databaseUrl: string | undefined;
|
|
4
4
|
unpooledDatabaseUrl: string | undefined;
|
|
5
|
-
/** Sign-in needs
|
|
5
|
+
/** Sign-in needs the platform, this app's id in it, and a database for sessions. */
|
|
6
6
|
configured: boolean;
|
|
7
7
|
/**
|
|
8
|
-
* A bare local dev server: no
|
|
8
|
+
* A bare local dev server: no platform and no app id at all (the platform
|
|
9
9
|
* gives every sandbox and deployment its MII_APP_ID), and not production.
|
|
10
10
|
*/
|
|
11
11
|
local: boolean;
|
package/dist/server/env.js
CHANGED
|
@@ -13,10 +13,10 @@ const serverEnv = z
|
|
|
13
13
|
.transform((env) => ({
|
|
14
14
|
databaseUrl: env.DATABASE_URL,
|
|
15
15
|
unpooledDatabaseUrl: env.DATABASE_URL_UNPOOLED,
|
|
16
|
-
/** Sign-in needs
|
|
16
|
+
/** Sign-in needs the platform, this app's id in it, and a database for sessions. */
|
|
17
17
|
configured: Boolean(env.MII_PLAZA_URL && env.MII_APP_ID && env.DATABASE_URL),
|
|
18
18
|
/**
|
|
19
|
-
* A bare local dev server: no
|
|
19
|
+
* A bare local dev server: no platform and no app id at all (the platform
|
|
20
20
|
* gives every sandbox and deployment its MII_APP_ID), and not production.
|
|
21
21
|
*/
|
|
22
22
|
local: !env.MII_PLAZA_URL && !env.MII_APP_ID && env.NODE_ENV !== "production"
|
package/dist/server/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
|
|
|
12
12
|
session: (request: Request) => Promise<import("./session.js").SessionState>;
|
|
13
13
|
/** Whether sign-in is configured (a bare local dev server has none). */
|
|
14
14
|
configured: () => boolean;
|
|
15
|
-
/** Email and password, checked by
|
|
15
|
+
/** Email and password, checked by the platform; the session to set as a cookie, or why not. */
|
|
16
16
|
signIn: (email: string, password: string) => Promise<import("./session.js").SignIn>;
|
|
17
17
|
/** The app's database, typed by its schema: `light.db.select().from(companies)`. */
|
|
18
18
|
readonly db: PostgresJsDatabase<S>;
|
|
@@ -22,7 +22,7 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
|
|
|
22
22
|
image: (request: Request) => Promise<Response>;
|
|
23
23
|
/** Server-side starts and reads of the app's contracts. */
|
|
24
24
|
invoke: import("@shubh90/app-runtime/invoke/server").MiiInvoke<C>;
|
|
25
|
-
/** `/api/mii/invoke/*`: the contract buttons, forwarded to
|
|
25
|
+
/** `/api/mii/invoke/*`: the contract buttons, forwarded to the platform as the person. */
|
|
26
26
|
invokeRoute: (request: Request) => Promise<Response>;
|
|
27
27
|
/** `/api/mii/chat/*`: the chat bubble's calls to the org's agents. */
|
|
28
28
|
chatRoute: (request: Request) => Promise<Response>;
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The app's server half, bound once in `src/server/light.ts`: its database,
|
|
2
|
-
// its door (sign-in through
|
|
2
|
+
// its door (sign-in through the platform), and the routes the SDK serves (entities,
|
|
3
3
|
// images, contracts, chat). Server-only.
|
|
4
4
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
5
5
|
import postgres from "postgres";
|
|
@@ -35,7 +35,7 @@ export function createLightServer(options) {
|
|
|
35
35
|
session,
|
|
36
36
|
/** Whether sign-in is configured (a bare local dev server has none). */
|
|
37
37
|
configured: () => env().configured,
|
|
38
|
-
/** Email and password, checked by
|
|
38
|
+
/** Email and password, checked by the platform; the session to set as a cookie, or why not. */
|
|
39
39
|
signIn: (email, password) => signIn(auth, email, password),
|
|
40
40
|
/** The app's database, typed by its schema: `light.db.select().from(companies)`. */
|
|
41
41
|
get db() {
|
|
@@ -47,13 +47,13 @@ export function createLightServer(options) {
|
|
|
47
47
|
image: createImageHandler({ db, session }),
|
|
48
48
|
/** Server-side starts and reads of the app's contracts. */
|
|
49
49
|
invoke,
|
|
50
|
-
/** `/api/mii/invoke/*`: the contract buttons, forwarded to
|
|
50
|
+
/** `/api/mii/invoke/*`: the contract buttons, forwarded to the platform as the person. */
|
|
51
51
|
invokeRoute: needsSignIn(invoke.handler),
|
|
52
52
|
/** `/api/mii/chat/*`: the chat bubble's calls to the org's agents. */
|
|
53
53
|
chatRoute: needsSignIn(chat)
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
-
/** A route that forwards to
|
|
56
|
+
/** A route that forwards to the platform as the person: nothing to forward without sign-in. */
|
|
57
57
|
function needsSignIn(handler) {
|
|
58
58
|
return (request) => env().configured
|
|
59
59
|
? handler(request)
|
package/dist/server/session.d.ts
CHANGED
|
@@ -14,5 +14,5 @@ export type SignIn = {
|
|
|
14
14
|
ok: false;
|
|
15
15
|
message: string;
|
|
16
16
|
};
|
|
17
|
-
/** The login form's whole job:
|
|
17
|
+
/** The login form's whole job: the platform checks the password, the app starts a session. */
|
|
18
18
|
export declare function signIn(auth: MiiAuthCore, email: string, password: string): Promise<SignIn>;
|
package/dist/server/session.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Who is asking, as the app's door sees it, and signing in through
|
|
1
|
+
// Who is asking, as the app's door sees it, and signing in through the platform.
|
|
2
2
|
import { signInWithPlaza } from "@shubh90/app-runtime/auth/core";
|
|
3
3
|
import { readCookie } from "@shubh90/app-runtime/chat/server";
|
|
4
4
|
import { env } from "./env.js";
|
|
5
5
|
/** The session a request's cookie names, and whether the app is members-only. */
|
|
6
6
|
export async function readSession(auth, request) {
|
|
7
|
-
// No
|
|
7
|
+
// No platform to sign in with: a bare local dev server has no door; any other
|
|
8
8
|
// server without its sign-in config keeps the door shut.
|
|
9
9
|
const { configured, local } = env();
|
|
10
10
|
if (!configured)
|
|
@@ -13,14 +13,14 @@ export async function readSession(auth, request) {
|
|
|
13
13
|
let found = null;
|
|
14
14
|
if (token !== null)
|
|
15
15
|
found = await auth.resolveSession(token);
|
|
16
|
-
//
|
|
16
|
+
// The platform unreachable on a cold start throws: an error page, not a door that guesses.
|
|
17
17
|
const mode = await auth.getAuthMode();
|
|
18
18
|
if (found === null)
|
|
19
19
|
return { user: null, mode };
|
|
20
20
|
const { id, name, email, avatarUrl, role } = found;
|
|
21
21
|
return { user: { id, name, email, avatarUrl, role }, mode };
|
|
22
22
|
}
|
|
23
|
-
/** The login form's whole job:
|
|
23
|
+
/** The login form's whole job: the platform checks the password, the app starts a session. */
|
|
24
24
|
export async function signIn(auth, email, password) {
|
|
25
25
|
if (!env().configured)
|
|
26
26
|
return { ok: false, message: "Sign-in is not configured." };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylight/app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"url": "https://github.com/shubh9/mii.git",
|
|
13
13
|
"directory": "packages/light-app"
|
|
14
14
|
},
|
|
15
|
+
"packageManager": "pnpm@11.0.9",
|
|
15
16
|
"files": [
|
|
16
17
|
"dist",
|
|
17
18
|
"bin"
|
|
@@ -41,6 +42,14 @@
|
|
|
41
42
|
"default": "./dist/vite.js"
|
|
42
43
|
}
|
|
43
44
|
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"lint": "eslint src test --max-warnings 0",
|
|
49
|
+
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
50
|
+
"check": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
51
|
+
"prepack": "pnpm run build"
|
|
52
|
+
},
|
|
44
53
|
"peerDependencies": {
|
|
45
54
|
"@tanstack/react-query": ">=5.69",
|
|
46
55
|
"drizzle-orm": ">=0.45",
|
|
@@ -63,6 +72,7 @@
|
|
|
63
72
|
"@tanstack/react-query": "^5.104.0",
|
|
64
73
|
"@types/node": "^22.20.4",
|
|
65
74
|
"@types/react": "^19.3.0",
|
|
75
|
+
"@types/react-dom": "^19.3.0",
|
|
66
76
|
"drizzle-orm": "^0.45.3",
|
|
67
77
|
"eslint": "^10.11.0",
|
|
68
78
|
"postgres": "^3.4.9",
|
|
@@ -72,12 +82,5 @@
|
|
|
72
82
|
"typescript-eslint": "^8.70.1",
|
|
73
83
|
"vite": "^8.2.0",
|
|
74
84
|
"zod": "^4.6.5"
|
|
75
|
-
},
|
|
76
|
-
"scripts": {
|
|
77
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
78
|
-
"typecheck": "tsc --noEmit",
|
|
79
|
-
"lint": "eslint src test --max-warnings 0",
|
|
80
|
-
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
81
|
-
"check": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build"
|
|
82
85
|
}
|
|
83
|
-
}
|
|
86
|
+
}
|