@trylight/app 0.1.0 → 0.1.2
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 +36 -7
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/db/audit.d.ts +36 -0
- package/dist/db/audit.js +193 -0
- package/dist/db/cli.js +21 -5
- package/dist/db/migrations.d.ts +4 -1
- package/dist/db/migrations.js +6 -4
- package/dist/images.d.ts +7 -0
- package/dist/images.js +12 -0
- package/dist/schema.d.ts +91 -5
- package/dist/schema.js +22 -6
- package/dist/server/entities.js +13 -32
- package/dist/server/images.d.ts +40 -0
- package/dist/server/images.js +246 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +4 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ unchanged; entities and the schema sync are new here.
|
|
|
7
7
|
|
|
8
8
|
| Import | What |
|
|
9
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)
|
|
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` |
|
|
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) |
|
|
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
15
|
| `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
|
|
@@ -21,8 +21,8 @@ through `entity()`. Each request is parsed with the table's own Zod schema
|
|
|
21
21
|
(drizzle-zod; unknown or platform columns are refused, `field_sources` must be
|
|
22
22
|
`{url, evidence}` per real column), goes through the app's door (members-only:
|
|
23
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
|
|
25
|
-
|
|
24
|
+
tables refuse the app), and must come from the app's own pages; each write
|
|
25
|
+
names its person for the table's change trigger (below). An
|
|
26
26
|
edited value loses its source; sources sent are merged by column. Lists sort
|
|
27
27
|
by the asked column, then id. With no sign-in configured only a non-production
|
|
28
28
|
server asked on loopback answers (as "Local developer"); anything else gets 503.
|
|
@@ -33,6 +33,17 @@ cached list and row at once and stays pending until the table's refetch lands
|
|
|
33
33
|
|
|
34
34
|
Joins and aggregates stay typed server functions over `light.db`.
|
|
35
35
|
|
|
36
|
+
## Images
|
|
37
|
+
|
|
38
|
+
`/api/light/image?src=…` serves a logo or photo a row points at from the
|
|
39
|
+
app's own copy in `light_images`: the first view by a member (or any view
|
|
40
|
+
with no sign-in configured) fetches and keeps it, and every later one, and
|
|
41
|
+
the CDN for a year, is the copy. Only public addresses are connected to (checked on the resolved address, each
|
|
42
|
+
redirect hop included); images only (SVG served sandboxed, `nosniff`), size-
|
|
43
|
+
and time-capped. A source that gives no image is a `no-store` 404, so the
|
|
44
|
+
mark shows its letters and the next view asks again. Members-only apps need
|
|
45
|
+
sign-in; a stranger to a public app only reads what members kept.
|
|
46
|
+
|
|
36
47
|
## Schema sync
|
|
37
48
|
|
|
38
49
|
`plan()` diffs `db/schema` against the newest migration snapshot with
|
|
@@ -40,7 +51,7 @@ drizzle-kit's API, removals first and additions second, so drizzle-kit never
|
|
|
40
51
|
has to ask whether a column was renamed (it can't without a terminal).
|
|
41
52
|
The change is written as a drizzle migration (`db/migrations`,
|
|
42
53
|
drizzle-kit's own layout) and applied, whatever it does to the rows there (a
|
|
43
|
-
removed column's values
|
|
54
|
+
removed column's values are archived first, below); one the rows refuse is taken back out,
|
|
44
55
|
so the change stays pending rather than blocking every later migrate. Changed
|
|
45
56
|
indexes and constraints are dropped before the columns they cover and enums
|
|
46
57
|
only after, so the two halves apply in order. Syncs and migrates each hold a Postgres advisory lock (with a
|
|
@@ -49,6 +60,22 @@ overlap, and a process that dies releases it. `migrate` applies the journal; in
|
|
|
49
60
|
asks the platform's migrate socket to run it. `check` (in `pnpm build`)
|
|
50
61
|
fails while `db/schema` has changes no migration holds.
|
|
51
62
|
|
|
63
|
+
## Recoverable deletes
|
|
64
|
+
|
|
65
|
+
`src/db/audit.ts`, in Postgres itself. Every entity table has a row trigger
|
|
66
|
+
that records each insert, update, delete and truncate in `light_changes` (the
|
|
67
|
+
new row, `{ column: { from, to } }`, or the old row), whoever wrote it: the
|
|
68
|
+
actor is `light.actor_kind/_id/_name`, which the entities route and Plaza's
|
|
69
|
+
`mii db write` set per transaction, or else the database role. A migration
|
|
70
|
+
that drops a table or column or changes a column's type first copies what it
|
|
71
|
+
destroys into `light_archive` (`light_archive_values`, called at the top of
|
|
72
|
+
the migration, so in its transaction) and the migrate prints "Archived N
|
|
73
|
+
values from table.column". Both tables refuse UPDATE, DELETE and TRUNCATE (a guard against mistakes:
|
|
74
|
+
the owner role can still drop the trigger). DDL an agent runs itself (`drop
|
|
75
|
+
table` through `mii db write`) is not archived.
|
|
76
|
+
`migrate` installs the functions before applying migrations and the triggers
|
|
77
|
+
after; the template's AGENTS.md has the restore SQL.
|
|
78
|
+
|
|
52
79
|
## Develop
|
|
53
80
|
|
|
54
81
|
```bash
|
|
@@ -58,4 +85,6 @@ TEST_DATABASE_URL=postgres://you@127.0.0.1:5432/postgres pnpm test # with the
|
|
|
58
85
|
pnpm run build # dist/, what the template installs
|
|
59
86
|
```
|
|
60
87
|
|
|
61
|
-
|
|
88
|
+
Published to npm; the template installs it from there, so a change here
|
|
89
|
+
reaches new apps once it is published and the template's lockfile takes it.
|
|
90
|
+
To publish: bump `version`, then `pnpm publish` (`prepack` builds `dist`).
|
package/dist/client.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Entity } from "./schema.js";
|
|
|
4
4
|
export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
|
|
5
5
|
export type { FieldSource, FieldSources } from "./schema.js";
|
|
6
6
|
export { siteOf } from "./schema.js";
|
|
7
|
+
export { imageSrc } from "./images.js";
|
|
7
8
|
export { InvocationError, type InvocationStatus } from "@shubh90/app-runtime/invoke";
|
|
8
9
|
export declare function createApp<C extends Contracts = Record<never, never>>(): {
|
|
9
10
|
table: typeof tableClient;
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,7 @@ import { z } from "zod/v4";
|
|
|
12
12
|
import { ENTITIES_PATH, EntityError } from "./entities.js";
|
|
13
13
|
export { EntityError, LIST_LIMIT } from "./entities.js";
|
|
14
14
|
export { siteOf } from "./schema.js";
|
|
15
|
+
export { imageSrc } from "./images.js";
|
|
15
16
|
export { InvocationError } from "@shubh90/app-runtime/invoke";
|
|
16
17
|
export function createApp() {
|
|
17
18
|
return { table: tableClient, contracts: createInvoker() };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type postgres from "postgres";
|
|
2
|
+
/** Makes light_archive and the functions migrations and triggers call. Runs before migrating. */
|
|
3
|
+
export declare function prepareAudit(sql: postgres.Sql): Promise<void>;
|
|
4
|
+
/**
|
|
5
|
+
* The entity tables `schema` declares. Read before migrating: the triggers
|
|
6
|
+
* write into light_changes, so a schema that doesn't export it is refused
|
|
7
|
+
* before any migration applies.
|
|
8
|
+
*/
|
|
9
|
+
export declare function entityTables(schema: Record<string, unknown>): string[];
|
|
10
|
+
/**
|
|
11
|
+
* Puts the change trigger on each of `tables` the database has (one a
|
|
12
|
+
* migration hasn't made yet is tracked on the migrate that makes it), and
|
|
13
|
+
* makes light_changes append-only. Runs after migrating.
|
|
14
|
+
*/
|
|
15
|
+
export declare function trackTables(sql: postgres.Sql, tables: string[]): Promise<void>;
|
|
16
|
+
/** A table, or a column of one, that a migration destroys: what it archives first. */
|
|
17
|
+
export type Destroyed = {
|
|
18
|
+
table: string;
|
|
19
|
+
column: string | null;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* What going from `previous` to `current` (drizzle-kit snapshots' `tables`)
|
|
23
|
+
* destroys: dropped tables, dropped columns, and columns whose type changes
|
|
24
|
+
* (Postgres converts the values, which may not survive the trip back).
|
|
25
|
+
*/
|
|
26
|
+
export declare function destroyed(previous: Record<string, unknown>, current: Record<string, unknown>): Destroyed[];
|
|
27
|
+
/** The statements that archive what migration `tag` destroys, to run first in it. */
|
|
28
|
+
export declare function archiveStatements(archive: Destroyed[], tag: string): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Collects what a migrate archived, to print once its transaction commits
|
|
31
|
+
* (a migration that fails archives nothing). Other notices are dropped.
|
|
32
|
+
*/
|
|
33
|
+
export declare function archiveNotices(): {
|
|
34
|
+
onnotice(notice: postgres.Notice): void;
|
|
35
|
+
print(): void;
|
|
36
|
+
};
|
package/dist/db/audit.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Recoverable deletes: change triggers into light_changes, an archive step at
|
|
2
|
+
// the top of destructive migrations, both tables append-only (README,
|
|
3
|
+
// "Recoverable deletes"; the restore is the template AGENTS.md's).
|
|
4
|
+
import { getTableName, is } from "drizzle-orm";
|
|
5
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
6
|
+
import { z } from "zod/v4";
|
|
7
|
+
import { isEntity, lightChanges } from "../schema.js";
|
|
8
|
+
/** The SQLSTATE of the notice an archive raises, so the CLI prints just those. */
|
|
9
|
+
const ARCHIVED_NOTICE = "LA001";
|
|
10
|
+
/**
|
|
11
|
+
* Installed before migrations run (a migration's archive step calls these),
|
|
12
|
+
* all idempotent. No input reaches this text; the functions quote the
|
|
13
|
+
* identifiers they are given with format('%I').
|
|
14
|
+
*/
|
|
15
|
+
const PREPARE = `
|
|
16
|
+
create table if not exists light_archive (
|
|
17
|
+
id uuid primary key default gen_random_uuid(),
|
|
18
|
+
table_name text not null,
|
|
19
|
+
-- null when the whole row was archived (its table was dropped)
|
|
20
|
+
column_name text,
|
|
21
|
+
-- the row's id; null for a table without a uuid id
|
|
22
|
+
row_id uuid,
|
|
23
|
+
value jsonb not null,
|
|
24
|
+
migration text not null,
|
|
25
|
+
archived_at timestamptz not null default now()
|
|
26
|
+
);
|
|
27
|
+
create index if not exists light_archive_table_idx on light_archive (table_name, column_name);
|
|
28
|
+
|
|
29
|
+
create or replace function light_append_only() returns trigger language plpgsql as $$
|
|
30
|
+
begin
|
|
31
|
+
raise exception '% is append-only: it is the record of what changed and what was removed, so its rows are never changed or deleted.', tg_table_name
|
|
32
|
+
using errcode = 'insufficient_privilege';
|
|
33
|
+
end $$;
|
|
34
|
+
create or replace trigger light_append_only before update or delete or truncate on light_archive
|
|
35
|
+
for each statement execute function light_append_only();
|
|
36
|
+
|
|
37
|
+
create or replace function light_record_change() returns trigger language plpgsql as $$
|
|
38
|
+
declare
|
|
39
|
+
-- A setting set local reads '' (not null) once its transaction has ended.
|
|
40
|
+
actor_kind text := coalesce(nullif(current_setting('light.actor_kind', true), ''), 'database');
|
|
41
|
+
actor_id text := nullif(current_setting('light.actor_id', true), '');
|
|
42
|
+
actor_name text := coalesce(nullif(current_setting('light.actor_name', true), ''), current_user);
|
|
43
|
+
row_id uuid;
|
|
44
|
+
action text;
|
|
45
|
+
changes jsonb;
|
|
46
|
+
begin
|
|
47
|
+
if tg_op = 'TRUNCATE' then
|
|
48
|
+
execute format(
|
|
49
|
+
'insert into light_changes (table_name, row_id, action, changes, actor_kind, actor_id, actor_name)
|
|
50
|
+
select $1, t.id, ''delete'', to_jsonb(t), $2, $3, $4 from %I.%I t', tg_table_schema, tg_table_name)
|
|
51
|
+
using tg_table_name, actor_kind, actor_id, actor_name;
|
|
52
|
+
return null;
|
|
53
|
+
elsif tg_op = 'INSERT' then
|
|
54
|
+
row_id := new.id;
|
|
55
|
+
action := 'create';
|
|
56
|
+
changes := to_jsonb(new);
|
|
57
|
+
elsif tg_op = 'DELETE' then
|
|
58
|
+
row_id := old.id;
|
|
59
|
+
action := 'delete';
|
|
60
|
+
changes := to_jsonb(old);
|
|
61
|
+
else
|
|
62
|
+
row_id := new.id;
|
|
63
|
+
action := 'update';
|
|
64
|
+
select jsonb_object_agg(o.key, jsonb_build_object('from', o.value, 'to', n.value)) into changes
|
|
65
|
+
from jsonb_each(to_jsonb(old)) o join jsonb_each(to_jsonb(new)) n on n.key = o.key
|
|
66
|
+
where o.value is distinct from n.value and o.key <> 'updated_at';
|
|
67
|
+
-- An update that set every value to what it was changed nothing.
|
|
68
|
+
if changes is null then return null; end if;
|
|
69
|
+
end if;
|
|
70
|
+
insert into light_changes (table_name, row_id, action, changes, actor_kind, actor_id, actor_name)
|
|
71
|
+
values (tg_table_name, row_id, action, changes, actor_kind, actor_id, actor_name);
|
|
72
|
+
return null;
|
|
73
|
+
end $$;
|
|
74
|
+
|
|
75
|
+
create or replace function light_track(tbl text) returns void language plpgsql as $$
|
|
76
|
+
begin
|
|
77
|
+
execute format('create or replace trigger light_record_change after insert or update or delete on %I
|
|
78
|
+
for each row execute function light_record_change()', tbl);
|
|
79
|
+
execute format('create or replace trigger light_record_truncate before truncate on %I
|
|
80
|
+
for each statement execute function light_record_change()', tbl);
|
|
81
|
+
end $$;
|
|
82
|
+
|
|
83
|
+
-- col null: every row of tbl, whole (the table is being dropped).
|
|
84
|
+
create or replace function light_archive_values(tbl text, col text, tag text) returns void language plpgsql as $$
|
|
85
|
+
declare
|
|
86
|
+
id_of_row text := case when exists (
|
|
87
|
+
select 1 from pg_attribute
|
|
88
|
+
where attrelid = format('%I', tbl)::regclass and attname = 'id' and atttypid = 'uuid'::regtype and not attisdropped
|
|
89
|
+
) then 't.id' else 'null::uuid' end;
|
|
90
|
+
archived bigint;
|
|
91
|
+
begin
|
|
92
|
+
if col is null then
|
|
93
|
+
execute format(
|
|
94
|
+
'insert into light_archive (table_name, column_name, row_id, value, migration)
|
|
95
|
+
select $1, null, %s, to_jsonb(t), $2 from %I t', id_of_row, tbl)
|
|
96
|
+
using tbl, tag;
|
|
97
|
+
else
|
|
98
|
+
execute format(
|
|
99
|
+
'insert into light_archive (table_name, column_name, row_id, value, migration)
|
|
100
|
+
select $1, $2, %s, to_jsonb(t)->$2, $3 from %I t where %I is not null', id_of_row, tbl, col)
|
|
101
|
+
using tbl, col, tag;
|
|
102
|
+
end if;
|
|
103
|
+
get diagnostics archived = row_count;
|
|
104
|
+
if archived = 0 then return; end if;
|
|
105
|
+
raise notice using errcode = '${ARCHIVED_NOTICE}', message = format('Archived %s %s from %s (migration %s).',
|
|
106
|
+
archived, case when col is null then 'rows' else 'values' end, concat_ws('.', tbl, col), tag);
|
|
107
|
+
end $$;
|
|
108
|
+
`;
|
|
109
|
+
/** light_changes is a drizzle table (db/schema exports it), so it exists only once migrated. */
|
|
110
|
+
const CHANGES_APPEND_ONLY = `
|
|
111
|
+
create or replace trigger light_append_only before update or delete or truncate on light_changes
|
|
112
|
+
for each statement execute function light_append_only();
|
|
113
|
+
`;
|
|
114
|
+
/** Makes light_archive and the functions migrations and triggers call. Runs before migrating. */
|
|
115
|
+
export async function prepareAudit(sql) {
|
|
116
|
+
await sql.unsafe(PREPARE);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The entity tables `schema` declares. Read before migrating: the triggers
|
|
120
|
+
* write into light_changes, so a schema that doesn't export it is refused
|
|
121
|
+
* before any migration applies.
|
|
122
|
+
*/
|
|
123
|
+
export function entityTables(schema) {
|
|
124
|
+
const values = Object.values(schema);
|
|
125
|
+
if (!values.some((value) => is(value, PgTable) && getTableName(value) === getTableName(lightChanges))) {
|
|
126
|
+
throw new Error("db/schema/index.ts must export lightChanges (`export { lightChanges } from \"@trylight/app/schema\";`): it records every change to the app's tables.");
|
|
127
|
+
}
|
|
128
|
+
return values.filter(isEntity).map((entity) => getTableName(entity));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Puts the change trigger on each of `tables` the database has (one a
|
|
132
|
+
* migration hasn't made yet is tracked on the migrate that makes it), and
|
|
133
|
+
* makes light_changes append-only. Runs after migrating.
|
|
134
|
+
*/
|
|
135
|
+
export async function trackTables(sql, tables) {
|
|
136
|
+
await sql `select light_track(name) from unnest(${sql.array(tables)}::text[]) as name where to_regclass(quote_ident(name)) is not null`;
|
|
137
|
+
await sql.unsafe(CHANGES_APPEND_ONLY);
|
|
138
|
+
}
|
|
139
|
+
const snapshotTable = z.looseObject({
|
|
140
|
+
name: z.string(),
|
|
141
|
+
columns: z.record(z.string(), z.looseObject({ type: z.string() }))
|
|
142
|
+
});
|
|
143
|
+
/**
|
|
144
|
+
* What going from `previous` to `current` (drizzle-kit snapshots' `tables`)
|
|
145
|
+
* destroys: dropped tables, dropped columns, and columns whose type changes
|
|
146
|
+
* (Postgres converts the values, which may not survive the trip back).
|
|
147
|
+
*/
|
|
148
|
+
export function destroyed(previous, current) {
|
|
149
|
+
const found = [];
|
|
150
|
+
for (const [key, entry] of Object.entries(previous)) {
|
|
151
|
+
const before = snapshotTable.parse(entry);
|
|
152
|
+
if (!(key in current)) {
|
|
153
|
+
found.push({ table: before.name, column: null });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const now = snapshotTable.parse(current[key]);
|
|
157
|
+
for (const [name, column] of Object.entries(before.columns)) {
|
|
158
|
+
const after = now.columns[name];
|
|
159
|
+
if (after === undefined || after.type !== column.type)
|
|
160
|
+
found.push({ table: before.name, column: name });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
165
|
+
/** A SQL string literal, for the migration file drizzle's migrator runs. */
|
|
166
|
+
function literal(text) {
|
|
167
|
+
return `'${text.replaceAll("'", "''")}'`;
|
|
168
|
+
}
|
|
169
|
+
/** The statements that archive what migration `tag` destroys, to run first in it. */
|
|
170
|
+
export function archiveStatements(archive, tag) {
|
|
171
|
+
return archive.map(({ table, column }) => `SELECT light_archive_values(${literal(table)}, ${column === null ? "NULL" : literal(column)}, ${literal(tag)});`);
|
|
172
|
+
}
|
|
173
|
+
const archivedNotice = z.object({ code: z.literal(ARCHIVED_NOTICE), message: z.string() });
|
|
174
|
+
/**
|
|
175
|
+
* Collects what a migrate archived, to print once its transaction commits
|
|
176
|
+
* (a migration that fails archives nothing). Other notices are dropped.
|
|
177
|
+
*/
|
|
178
|
+
export function archiveNotices() {
|
|
179
|
+
const messages = [];
|
|
180
|
+
return {
|
|
181
|
+
onnotice(notice) {
|
|
182
|
+
const archived = archivedNotice.safeParse(notice);
|
|
183
|
+
if (archived.success)
|
|
184
|
+
messages.push(archived.data.message);
|
|
185
|
+
},
|
|
186
|
+
print() {
|
|
187
|
+
if (messages.length === 0)
|
|
188
|
+
return;
|
|
189
|
+
console.log(messages.join("\n"));
|
|
190
|
+
console.log("Archived values stay in light_archive; AGENTS.md, \"Getting deleted data back\", says how to restore them.");
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
package/dist/db/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|
|
9
9
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
10
10
|
import postgres from "postgres";
|
|
11
11
|
import { z } from "zod/v4";
|
|
12
|
+
import { archiveNotices, entityTables, prepareAudit, trackTables } from "./audit.js";
|
|
12
13
|
import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
|
|
13
14
|
import { env } from "../server/env.js";
|
|
14
15
|
/**
|
|
@@ -20,8 +21,10 @@ const PLATFORM_MIGRATE = "/run/mii/migrate.sock";
|
|
|
20
21
|
/** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
|
|
21
22
|
const MIGRATE_LOCK_KEY = 4_815_162_342;
|
|
22
23
|
const SYNC_LOCK_KEY = 4_815_162_343;
|
|
24
|
+
/** Exit code of a migrate whose migrations committed but whose change triggers didn't go on. */
|
|
25
|
+
const APPLIED_UNTRACKED = 2;
|
|
23
26
|
// pg_advisory_lock waits for the one before it; a process that dies releases it.
|
|
24
|
-
const connect = () => postgres(databaseUrl(), { max: 1, onnotice
|
|
27
|
+
const connect = (onnotice = () => undefined) => postgres(databaseUrl(), { max: 1, onnotice });
|
|
25
28
|
const root = process.cwd();
|
|
26
29
|
const envFile = join(root, ".env.local");
|
|
27
30
|
if (existsSync(envFile))
|
|
@@ -56,7 +59,7 @@ async function sync() {
|
|
|
56
59
|
console.error(error instanceof Error ? error.message : String(error));
|
|
57
60
|
return 1;
|
|
58
61
|
});
|
|
59
|
-
if (applied !== 0) {
|
|
62
|
+
if (applied !== 0 && applied !== APPLIED_UNTRACKED) {
|
|
60
63
|
unwriteMigration(root, tag);
|
|
61
64
|
console.error(`${MIGRATIONS_DIR}/${tag}.sql didn't apply, so it was taken back; change db/schema and save again.`);
|
|
62
65
|
}
|
|
@@ -74,17 +77,30 @@ async function check() {
|
|
|
74
77
|
console.error(`db/schema has changes with no migration yet; run \`pnpm db:sync\`:\n${planned.statements.join("\n")}`);
|
|
75
78
|
return 1;
|
|
76
79
|
}
|
|
77
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Applies every migration in db/migrations the database doesn't have, with
|
|
82
|
+
* the audit around them (./audit.ts): its functions first, since a
|
|
83
|
+
* migration's archive step calls them; its triggers after, on the tables
|
|
84
|
+
* migrations made.
|
|
85
|
+
*/
|
|
78
86
|
async function applyMigrations() {
|
|
79
87
|
// The agent (not root) in an app's sandbox: the platform migrates for it.
|
|
80
88
|
if (userInfo().uid !== 0 && existsSync(PLATFORM_MIGRATE))
|
|
81
89
|
return migrateThroughPlatform();
|
|
82
|
-
const
|
|
90
|
+
const archived = archiveNotices();
|
|
91
|
+
const client = connect(archived.onnotice);
|
|
83
92
|
try {
|
|
84
93
|
await client `select pg_advisory_lock(${MIGRATE_LOCK_KEY})`;
|
|
94
|
+
const tables = entityTables(await loadSchema());
|
|
95
|
+
await prepareAudit(client);
|
|
85
96
|
await migrate(drizzle(client), { migrationsFolder: join(root, MIGRATIONS_DIR) });
|
|
97
|
+
archived.print();
|
|
86
98
|
console.log("Database is up to date with db/migrations.");
|
|
87
|
-
|
|
99
|
+
// The migrations are committed: a failure here must not take their files back.
|
|
100
|
+
return await trackTables(client, tables).then(() => 0, (error) => {
|
|
101
|
+
console.error(`The change triggers didn't go on (${error instanceof Error ? error.message : String(error)}); the next migrate tries again.`);
|
|
102
|
+
return APPLIED_UNTRACKED;
|
|
103
|
+
});
|
|
88
104
|
}
|
|
89
105
|
finally {
|
|
90
106
|
await client.end();
|
package/dist/db/migrations.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
|
+
import { type Destroyed } from "./audit.js";
|
|
2
3
|
export declare const SCHEMA_ENTRY = "db/schema/index.ts";
|
|
3
4
|
export declare const MIGRATIONS_DIR = "db/migrations";
|
|
4
5
|
export type Plan = {
|
|
5
6
|
/** The new migration's statements, in order; empty when nothing changed. */
|
|
6
7
|
statements: string[];
|
|
8
|
+
/** What the statements destroy, archived first when the migration runs. */
|
|
9
|
+
archive: Destroyed[];
|
|
7
10
|
snapshot: Snapshot;
|
|
8
11
|
};
|
|
9
12
|
/** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
|
|
@@ -30,7 +33,7 @@ type Snapshot = z.infer<typeof snapshotSchema>;
|
|
|
30
33
|
* What `db/schema` needs beyond the last migration, in three diffs that each
|
|
31
34
|
* only remove or only add, so drizzle-kit never has to ask whether something
|
|
32
35
|
* 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
|
|
36
|
+
* therefore a drop and an add: the old column's values are archived. Removed enums go last,
|
|
34
37
|
* after the columns that used them have changed.
|
|
35
38
|
*/
|
|
36
39
|
export declare function plan(root: string, schema: Record<string, unknown>): Promise<Plan>;
|
package/dist/db/migrations.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Writing a schema file is the migration: the change from the last
|
|
2
2
|
// migration's snapshot to `db/schema` becomes a new drizzle migration,
|
|
3
3
|
// written and applied at once, whatever it does to the rows there (a removed
|
|
4
|
-
// column's values
|
|
4
|
+
// column's values are archived first, in light_archive).
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { isDeepStrictEqual } from "node:util";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
|
|
9
9
|
import { z } from "zod/v4";
|
|
10
|
+
import { archiveStatements, destroyed } from "./audit.js";
|
|
10
11
|
export const SCHEMA_ENTRY = "db/schema/index.ts";
|
|
11
12
|
export const MIGRATIONS_DIR = "db/migrations";
|
|
12
13
|
const entries = z.record(z.string(), z.unknown());
|
|
@@ -33,7 +34,7 @@ const snapshotSchema = z.looseObject({
|
|
|
33
34
|
* What `db/schema` needs beyond the last migration, in three diffs that each
|
|
34
35
|
* only remove or only add, so drizzle-kit never has to ask whether something
|
|
35
36
|
* 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
|
|
37
|
+
* therefore a drop and an add: the old column's values are archived. Removed enums go last,
|
|
37
38
|
* after the columns that used them have changed.
|
|
38
39
|
*/
|
|
39
40
|
export async function plan(root, schema) {
|
|
@@ -47,7 +48,7 @@ export async function plan(root, schema) {
|
|
|
47
48
|
...(await generateMigration(kept, withOldEnums)),
|
|
48
49
|
...(await generateMigration(withOldEnums, snapshot))
|
|
49
50
|
];
|
|
50
|
-
return { statements, snapshot };
|
|
51
|
+
return { statements, archive: destroyed(previous.tables, snapshot.tables), snapshot };
|
|
51
52
|
}
|
|
52
53
|
function only(from, keep) {
|
|
53
54
|
return Object.fromEntries(Object.entries(from).filter(([key]) => key in keep));
|
|
@@ -139,7 +140,8 @@ export function writeMigration(root, planned) {
|
|
|
139
140
|
const idx = journal.entries.length;
|
|
140
141
|
const tag = `${String(idx).padStart(4, "0")}_schema`;
|
|
141
142
|
mkdirSync(join(root, MIGRATIONS_DIR, "meta"), { recursive: true });
|
|
142
|
-
|
|
143
|
+
const statements = [...archiveStatements(planned.archive, tag), ...planned.statements];
|
|
144
|
+
writeFileSync(join(root, MIGRATIONS_DIR, `${tag}.sql`), statements.join("\n--> statement-breakpoint\n") + "\n");
|
|
143
145
|
writeFileSync(snapshotFile(root, idx), JSON.stringify(planned.snapshot, null, 2) + "\n");
|
|
144
146
|
journal.entries.push({ idx, version: "7", when: Date.now(), tag, breakpoints: true });
|
|
145
147
|
writeFileSync(join(root, MIGRATIONS_DIR, "meta", "_journal.json"), JSON.stringify(journal, null, 2) + "\n");
|
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const IMAGE_PATH = "/api/light/image";
|
|
2
|
+
/**
|
|
3
|
+
* Where a row's photo or logo loads from: the app's own copy (kept from the
|
|
4
|
+
* first view on) for an address on the web, the value itself for one of the
|
|
5
|
+
* app's own files.
|
|
6
|
+
*/
|
|
7
|
+
export declare function imageSrc(src: string): string;
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What the browser and the image route agree on: where a row's photo or logo
|
|
2
|
+
// loads from. Nothing here may touch a database or the DOM.
|
|
3
|
+
import { webUrl } from "./schema.js";
|
|
4
|
+
export const IMAGE_PATH = "/api/light/image";
|
|
5
|
+
/**
|
|
6
|
+
* Where a row's photo or logo loads from: the app's own copy (kept from the
|
|
7
|
+
* first view on) for an address on the web, the value itself for one of the
|
|
8
|
+
* app's own files.
|
|
9
|
+
*/
|
|
10
|
+
export function imageSrc(src) {
|
|
11
|
+
return webUrl.safeParse(src).success ? `${IMAGE_PATH}?src=${encodeURIComponent(src)}` : src;
|
|
12
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { PgTable, type PgColumnBuilderBase, type PgTableWithColumns, type PgTableExtraConfigValue } from "drizzle-orm/pg-core";
|
|
2
2
|
import type { BuildColumns, BuildExtraConfigColumns } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
|
+
/** An address on the web: http or https. */
|
|
5
|
+
export declare const webUrl: z.ZodURL;
|
|
4
6
|
/** Where one value was read: the page, and the words on it that say so. */
|
|
5
7
|
export declare const fieldSource: z.ZodObject<{
|
|
6
8
|
url: z.ZodURL;
|
|
@@ -73,9 +75,11 @@ export type Entity = PgTable & {
|
|
|
73
75
|
};
|
|
74
76
|
export declare function isEntity(value: unknown): value is Entity;
|
|
75
77
|
/**
|
|
76
|
-
* Every change
|
|
77
|
-
*
|
|
78
|
-
* the old row for a delete, and `{ column: { from, to } }`
|
|
78
|
+
* Every change to an entity table, whoever made it, written by the table's
|
|
79
|
+
* trigger (src/db/audit.ts) in the change's transaction. `changes` is the new
|
|
80
|
+
* row for a create, the old row for a delete, and `{ column: { from, to } }`
|
|
81
|
+
* for an update. A `database` actor is a writer that didn't name itself: its
|
|
82
|
+
* name is the database role.
|
|
79
83
|
*/
|
|
80
84
|
export declare const lightChanges: PgTableWithColumns<{
|
|
81
85
|
name: "light_changes";
|
|
@@ -173,14 +177,14 @@ export declare const lightChanges: PgTableWithColumns<{
|
|
|
173
177
|
tableName: "light_changes";
|
|
174
178
|
dataType: "string";
|
|
175
179
|
columnType: "PgText";
|
|
176
|
-
data: "person" | "agent";
|
|
180
|
+
data: "person" | "agent" | "database";
|
|
177
181
|
driverParam: string;
|
|
178
182
|
notNull: true;
|
|
179
183
|
hasDefault: false;
|
|
180
184
|
isPrimaryKey: false;
|
|
181
185
|
isAutoincrement: false;
|
|
182
186
|
hasRuntimeDefault: false;
|
|
183
|
-
enumValues: ["person", "agent"];
|
|
187
|
+
enumValues: ["person", "agent", "database"];
|
|
184
188
|
baseColumn: never;
|
|
185
189
|
identity: undefined;
|
|
186
190
|
generated: undefined;
|
|
@@ -239,4 +243,86 @@ export declare const lightChanges: PgTableWithColumns<{
|
|
|
239
243
|
};
|
|
240
244
|
dialect: "pg";
|
|
241
245
|
}>;
|
|
246
|
+
/**
|
|
247
|
+
* The app's own copy of each photo or logo its rows point at, kept on the
|
|
248
|
+
* first view (the image route, `@trylight/app/server`), so a source that
|
|
249
|
+
* moves or shuts down later does not take the picture with it.
|
|
250
|
+
*/
|
|
251
|
+
export declare const lightImages: PgTableWithColumns<{
|
|
252
|
+
name: "light_images";
|
|
253
|
+
schema: undefined;
|
|
254
|
+
columns: {
|
|
255
|
+
source_url: import("drizzle-orm/pg-core").PgColumn<{
|
|
256
|
+
name: "source_url";
|
|
257
|
+
tableName: "light_images";
|
|
258
|
+
dataType: "string";
|
|
259
|
+
columnType: "PgText";
|
|
260
|
+
data: string;
|
|
261
|
+
driverParam: string;
|
|
262
|
+
notNull: true;
|
|
263
|
+
hasDefault: false;
|
|
264
|
+
isPrimaryKey: true;
|
|
265
|
+
isAutoincrement: false;
|
|
266
|
+
hasRuntimeDefault: false;
|
|
267
|
+
enumValues: [string, ...string[]];
|
|
268
|
+
baseColumn: never;
|
|
269
|
+
identity: undefined;
|
|
270
|
+
generated: undefined;
|
|
271
|
+
}, {}, {}>;
|
|
272
|
+
content_type: import("drizzle-orm/pg-core").PgColumn<{
|
|
273
|
+
name: "content_type";
|
|
274
|
+
tableName: "light_images";
|
|
275
|
+
dataType: "string";
|
|
276
|
+
columnType: "PgText";
|
|
277
|
+
data: string;
|
|
278
|
+
driverParam: string;
|
|
279
|
+
notNull: true;
|
|
280
|
+
hasDefault: false;
|
|
281
|
+
isPrimaryKey: false;
|
|
282
|
+
isAutoincrement: false;
|
|
283
|
+
hasRuntimeDefault: false;
|
|
284
|
+
enumValues: [string, ...string[]];
|
|
285
|
+
baseColumn: never;
|
|
286
|
+
identity: undefined;
|
|
287
|
+
generated: undefined;
|
|
288
|
+
}, {}, {}>;
|
|
289
|
+
bytes: import("drizzle-orm/pg-core").PgColumn<{
|
|
290
|
+
name: "bytes";
|
|
291
|
+
tableName: "light_images";
|
|
292
|
+
dataType: "custom";
|
|
293
|
+
columnType: "PgCustomColumn";
|
|
294
|
+
data: Uint8Array<ArrayBufferLike>;
|
|
295
|
+
driverParam: unknown;
|
|
296
|
+
notNull: true;
|
|
297
|
+
hasDefault: false;
|
|
298
|
+
isPrimaryKey: false;
|
|
299
|
+
isAutoincrement: false;
|
|
300
|
+
hasRuntimeDefault: false;
|
|
301
|
+
enumValues: undefined;
|
|
302
|
+
baseColumn: never;
|
|
303
|
+
identity: undefined;
|
|
304
|
+
generated: undefined;
|
|
305
|
+
}, {}, {
|
|
306
|
+
pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
|
|
307
|
+
}>;
|
|
308
|
+
fetched_at: import("drizzle-orm/pg-core").PgColumn<{
|
|
309
|
+
name: "fetched_at";
|
|
310
|
+
tableName: "light_images";
|
|
311
|
+
dataType: "string";
|
|
312
|
+
columnType: "PgTimestampString";
|
|
313
|
+
data: string;
|
|
314
|
+
driverParam: string;
|
|
315
|
+
notNull: true;
|
|
316
|
+
hasDefault: true;
|
|
317
|
+
isPrimaryKey: false;
|
|
318
|
+
isAutoincrement: false;
|
|
319
|
+
hasRuntimeDefault: false;
|
|
320
|
+
enumValues: undefined;
|
|
321
|
+
baseColumn: never;
|
|
322
|
+
identity: undefined;
|
|
323
|
+
generated: undefined;
|
|
324
|
+
}, {}, {}>;
|
|
325
|
+
};
|
|
326
|
+
dialect: "pg";
|
|
327
|
+
}>;
|
|
242
328
|
export {};
|
package/dist/schema.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// the app. Imported by drizzle-kit and the browser's types, so nothing here
|
|
4
4
|
// may touch a database or the DOM.
|
|
5
5
|
import { is } from "drizzle-orm";
|
|
6
|
-
import { PgTable, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
6
|
+
import { PgTable, customType, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
7
7
|
import { z } from "zod/v4";
|
|
8
|
+
/** An address on the web: http or https. */
|
|
9
|
+
export const webUrl = z.url({ protocol: /^https?$/ });
|
|
8
10
|
/** Where one value was read: the page, and the words on it that say so. */
|
|
9
11
|
export const fieldSource = z.object({
|
|
10
|
-
url:
|
|
12
|
+
url: webUrl,
|
|
11
13
|
evidence: z.string().min(1)
|
|
12
14
|
});
|
|
13
15
|
/** The site a source's url names, as people read it: "https://www.acme.com/about" is "acme.com". */
|
|
@@ -62,9 +64,11 @@ export function isEntity(value) {
|
|
|
62
64
|
return is(value, PgTable) && "$access" in value;
|
|
63
65
|
}
|
|
64
66
|
/**
|
|
65
|
-
* Every change
|
|
66
|
-
*
|
|
67
|
-
* the old row for a delete, and `{ column: { from, to } }`
|
|
67
|
+
* Every change to an entity table, whoever made it, written by the table's
|
|
68
|
+
* trigger (src/db/audit.ts) in the change's transaction. `changes` is the new
|
|
69
|
+
* row for a create, the old row for a delete, and `{ column: { from, to } }`
|
|
70
|
+
* for an update. A `database` actor is a writer that didn't name itself: its
|
|
71
|
+
* name is the database role.
|
|
68
72
|
*/
|
|
69
73
|
export const lightChanges = pgTable("light_changes", {
|
|
70
74
|
id: uuid().primaryKey().defaultRandom(),
|
|
@@ -72,10 +76,22 @@ export const lightChanges = pgTable("light_changes", {
|
|
|
72
76
|
row_id: uuid().notNull(),
|
|
73
77
|
action: text({ enum: ["create", "update", "delete"] }).notNull(),
|
|
74
78
|
changes: jsonb().$type().notNull(),
|
|
75
|
-
actor_kind: text({ enum: ["person", "agent"] }).notNull(),
|
|
79
|
+
actor_kind: text({ enum: ["person", "agent", "database"] }).notNull(),
|
|
76
80
|
actor_id: text(),
|
|
77
81
|
actor_name: text().notNull(),
|
|
78
82
|
created_at: timestamp({ withTimezone: true, mode: "string" })
|
|
79
83
|
.notNull()
|
|
80
84
|
.defaultNow()
|
|
81
85
|
}, (t) => [index("light_changes_row_idx").on(t.table_name, t.row_id)]);
|
|
86
|
+
const bytea = customType({ dataType: () => "bytea" });
|
|
87
|
+
/**
|
|
88
|
+
* The app's own copy of each photo or logo its rows point at, kept on the
|
|
89
|
+
* first view (the image route, `@trylight/app/server`), so a source that
|
|
90
|
+
* moves or shuts down later does not take the picture with it.
|
|
91
|
+
*/
|
|
92
|
+
export const lightImages = pgTable("light_images", {
|
|
93
|
+
source_url: text().primaryKey(),
|
|
94
|
+
content_type: text().notNull(),
|
|
95
|
+
bytes: bytea().notNull(),
|
|
96
|
+
fetched_at: timestamp({ withTimezone: true, mode: "string" }).notNull().defaultNow()
|
|
97
|
+
});
|
package/dist/server/entities.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// The one route behind `app.table(...)`: list, get, create, update and delete
|
|
2
2
|
// on any entity table, each request checked against the table's own Zod
|
|
3
|
-
// schema and the app's door
|
|
4
|
-
//
|
|
5
|
-
import { and, asc, desc, eq, getTableColumns, getTableName, inArray, isNull } from "drizzle-orm";
|
|
3
|
+
// schema and the app's door. Each write names its person for the table's
|
|
4
|
+
// trigger, which records the change in light_changes (src/db/audit.ts).
|
|
5
|
+
import { and, asc, desc, eq, getTableColumns, getTableName, inArray, isNull, sql } from "drizzle-orm";
|
|
6
6
|
import { isDeepStrictEqual } from "node:util";
|
|
7
7
|
import { createSchemaFactory } from "drizzle-zod";
|
|
8
8
|
import { isSameOrigin } from "@shubh90/app-runtime/chat/server";
|
|
9
9
|
import { z } from "zod/v4";
|
|
10
10
|
import { ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
|
|
11
|
-
import { PLATFORM_COLUMNS, fieldSource, isEntity
|
|
11
|
+
import { PLATFORM_COLUMNS, fieldSource, isEntity } from "../schema.js";
|
|
12
12
|
import { env } from "./env.js";
|
|
13
13
|
/** Who writes when the app runs with no sign-in at all: a bare local dev server. */
|
|
14
14
|
const LOCAL_DEVELOPER = { id: null, name: "Local developer" };
|
|
@@ -153,54 +153,39 @@ async function get(db, table, id) {
|
|
|
153
153
|
}
|
|
154
154
|
async function create(db, table, actor, data) {
|
|
155
155
|
return db.transaction(async (tx) => {
|
|
156
|
-
|
|
156
|
+
await nameActor(tx, actor);
|
|
157
|
+
return exactlyOne(await tx
|
|
157
158
|
.insert(table.entity)
|
|
158
159
|
.values({ ...data, created_by: actor.id })
|
|
159
160
|
.returning());
|
|
160
|
-
await audit(tx, table, rowId(row), "create", row, actor);
|
|
161
|
-
return row;
|
|
162
161
|
});
|
|
163
162
|
}
|
|
164
163
|
async function update(db, table, actor, id, patch) {
|
|
165
164
|
return db.transaction(async (tx) => {
|
|
165
|
+
await nameActor(tx, actor);
|
|
166
166
|
const before = atMostOne(await tx.select().from(table.entity).where(eq(table.id, id)).for("update"));
|
|
167
167
|
if (before === null)
|
|
168
168
|
return null;
|
|
169
169
|
const changed = Object.keys(patch).filter((key) => !isDeepStrictEqual(patch[key], before[key]));
|
|
170
170
|
// A value a person sets is no longer what its source said; sources sent are merged in.
|
|
171
171
|
const field_sources = { ...withoutSources(before.field_sources, changed), ...patch.field_sources };
|
|
172
|
-
|
|
172
|
+
return exactlyOne(await tx
|
|
173
173
|
.update(table.entity)
|
|
174
174
|
.set({ ...patch, field_sources })
|
|
175
175
|
.where(eq(table.id, id))
|
|
176
176
|
.returning());
|
|
177
|
-
const changes = Object.fromEntries([...changed, "field_sources"]
|
|
178
|
-
.filter((key) => !isDeepStrictEqual(before[key], row[key]))
|
|
179
|
-
.map((key) => [key, { from: before[key], to: row[key] }]));
|
|
180
|
-
if (Object.keys(changes).length > 0)
|
|
181
|
-
await audit(tx, table, id, "update", changes, actor);
|
|
182
|
-
return row;
|
|
183
177
|
});
|
|
184
178
|
}
|
|
185
179
|
async function remove(db, table, actor, id) {
|
|
186
180
|
return db.transaction(async (tx) => {
|
|
181
|
+
await nameActor(tx, actor);
|
|
187
182
|
const row = atMostOne(await tx.delete(table.entity).where(eq(table.id, id)).returning());
|
|
188
|
-
|
|
189
|
-
return null;
|
|
190
|
-
await audit(tx, table, id, "delete", row, actor);
|
|
191
|
-
return { id };
|
|
183
|
+
return row === null ? null : { id };
|
|
192
184
|
});
|
|
193
185
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
row_id: rowId,
|
|
198
|
-
action,
|
|
199
|
-
changes,
|
|
200
|
-
actor_kind: "person",
|
|
201
|
-
actor_id: actor.id,
|
|
202
|
-
actor_name: actor.name
|
|
203
|
-
});
|
|
186
|
+
/** Who the table's trigger records in light_changes for the rest of this transaction. */
|
|
187
|
+
function nameActor(tx, actor) {
|
|
188
|
+
return tx.execute(sql `select set_config('light.actor_kind', 'person', true), set_config('light.actor_id', coalesce(${actor.id}, ''), true), set_config('light.actor_name', ${actor.name}, true)`);
|
|
204
189
|
}
|
|
205
190
|
/** The row a statement by primary key touched, or null when there is none. */
|
|
206
191
|
function atMostOne(rows) {
|
|
@@ -218,10 +203,6 @@ function exactlyOne(rows) {
|
|
|
218
203
|
}
|
|
219
204
|
// What's stored may come from an agent's own write: read as it is, never refused here.
|
|
220
205
|
const storedSources = z.record(z.string(), z.unknown());
|
|
221
|
-
const storedId = z.object({ id: z.uuid() });
|
|
222
|
-
function rowId(row) {
|
|
223
|
-
return storedId.parse(row).id;
|
|
224
|
-
}
|
|
225
206
|
function withoutSources(stored, columns) {
|
|
226
207
|
const sources = storedSources.parse(stored);
|
|
227
208
|
return Object.fromEntries(Object.entries(sources).filter(([column]) => !columns.includes(column)));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type LookupFunction } from "node:net";
|
|
2
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import type { SessionState } from "./session.js";
|
|
4
|
+
type Db = PostgresJsDatabase<Record<string, unknown>>;
|
|
5
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
6
|
+
export type StoredImage = {
|
|
7
|
+
readonly contentType: string;
|
|
8
|
+
readonly bytes: Uint8Array;
|
|
9
|
+
};
|
|
10
|
+
export type ImageLookup = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly image: StoredImage;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
};
|
|
17
|
+
export type ImageOptions = {
|
|
18
|
+
db: () => Db;
|
|
19
|
+
session: (request: Request) => Promise<SessionState>;
|
|
20
|
+
};
|
|
21
|
+
/** `/api/light/image?src=…`: a photo or logo a row points at, served from the app's own copy. */
|
|
22
|
+
export declare function createImageHandler(options: ImageOptions): (request: Request) => Promise<Response>;
|
|
23
|
+
/**
|
|
24
|
+
* An address on the public internet: not this machine, a private or carrier
|
|
25
|
+
* network, link-local (the cloud metadata address), multicast or reserved.
|
|
26
|
+
* An IPv4 address written as IPv6 is judged as itself.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The name lookup of every connection the image fetch opens, redirects
|
|
31
|
+
* included: the address checked is the address connected to, so a public
|
|
32
|
+
* name that resolves (or is re-pointed between two asks) to this machine or
|
|
33
|
+
* a private network is never reached.
|
|
34
|
+
*/
|
|
35
|
+
export declare const publicOnlyLookup: LookupFunction;
|
|
36
|
+
/** The app's copy of the image at `source`, if a member's view has kept one. */
|
|
37
|
+
export declare function keptImage(db: Db, source: URL): Promise<ImageLookup>;
|
|
38
|
+
/** The app's copy of the image at `source`, fetched and kept on the first ask. */
|
|
39
|
+
export declare function storedImage(db: Db, source: URL): Promise<ImageLookup>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The app's copy of the photos and logos its rows point at. The first time a
|
|
2
|
+
// member's browser asks the image route for one it is fetched and kept in
|
|
3
|
+
// light_images; every later ask is answered from there, so a source that
|
|
4
|
+
// rate-limits, moves or shuts down after that does not take the picture with
|
|
5
|
+
// it. Ported from the v1 template (#1063).
|
|
6
|
+
import dns from "node:dns";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import ipaddr from "ipaddr.js";
|
|
10
|
+
import { Agent } from "undici";
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
import { lightImages, webUrl } from "../schema.js";
|
|
13
|
+
import { env } from "./env.js";
|
|
14
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
15
|
+
const MAX_REDIRECTS = 5;
|
|
16
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
17
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
const YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
19
|
+
// An SVG is a document if someone navigates to it; the route's sandbox
|
|
20
|
+
// header keeps it inert there, and in an <img> it never runs script.
|
|
21
|
+
const IMAGE_TYPES = new Set([
|
|
22
|
+
"image/png",
|
|
23
|
+
"image/jpeg",
|
|
24
|
+
"image/gif",
|
|
25
|
+
"image/webp",
|
|
26
|
+
"image/avif",
|
|
27
|
+
"image/svg+xml",
|
|
28
|
+
"image/x-icon",
|
|
29
|
+
"image/vnd.microsoft.icon"
|
|
30
|
+
]);
|
|
31
|
+
// The bytes are an image or they are nothing, and an SVG opened on its own stays inert.
|
|
32
|
+
const INERT = {
|
|
33
|
+
"Content-Security-Policy": "sandbox",
|
|
34
|
+
"X-Content-Type-Options": "nosniff"
|
|
35
|
+
};
|
|
36
|
+
// Node's fetch reports a DNS or connection failure as "fetch failed", with the
|
|
37
|
+
// reason (ENOTFOUND, ECONNREFUSED…) only on its cause.
|
|
38
|
+
const networkFailure = z.object({ cause: z.object({ code: z.string() }) });
|
|
39
|
+
/** `/api/light/image?src=…`: a photo or logo a row points at, served from the app's own copy. */
|
|
40
|
+
export function createImageHandler(options) {
|
|
41
|
+
return async (request) => {
|
|
42
|
+
const { user, mode } = await options.session(request);
|
|
43
|
+
if (user === null && mode === "members") {
|
|
44
|
+
return new Response("Sign in to use this app.", { status: 401 });
|
|
45
|
+
}
|
|
46
|
+
const src = webUrl.safeParse(new URL(request.url).searchParams.get("src"));
|
|
47
|
+
if (!src.success) {
|
|
48
|
+
return new Response("src must be an http(s) address.", { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
const db = options.db();
|
|
51
|
+
const source = new URL(src.data);
|
|
52
|
+
// A stranger to a public app only reads the copy, so nobody signed out can
|
|
53
|
+
// make the app fetch or keep anything; a member's view fills it, as does
|
|
54
|
+
// every view where there is no sign-in to be a stranger to.
|
|
55
|
+
const stranger = user === null && env().configured;
|
|
56
|
+
const lookup = stranger ? keptImage : storedImage;
|
|
57
|
+
const found = await lookup(db, source);
|
|
58
|
+
if (!found.ok) {
|
|
59
|
+
// Nothing is kept for a source that did not answer with an image, so the
|
|
60
|
+
// next view asks it again; until then the row shows its letters.
|
|
61
|
+
return new Response(found.reason, {
|
|
62
|
+
status: 404,
|
|
63
|
+
headers: { ...INERT, "Cache-Control": "no-store" }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Cached by the CDN under the source address, which only someone who
|
|
67
|
+
// already knows it can ask for; the pictures are the public web's.
|
|
68
|
+
// A copy: the driver's buffer is typed over any ArrayBufferLike, a body needs one over ArrayBuffer.
|
|
69
|
+
return new Response(new Uint8Array(found.image.bytes), {
|
|
70
|
+
headers: {
|
|
71
|
+
...INERT,
|
|
72
|
+
"Content-Type": found.image.contentType,
|
|
73
|
+
"Cache-Control": `public, max-age=${YEAR_SECONDS}, immutable`
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* An address on the public internet: not this machine, a private or carrier
|
|
80
|
+
* network, link-local (the cloud metadata address), multicast or reserved.
|
|
81
|
+
* An IPv4 address written as IPv6 is judged as itself.
|
|
82
|
+
*/
|
|
83
|
+
export function isPublicAddress(address) {
|
|
84
|
+
return ipaddr.isValid(address) && ipaddr.process(address).range() === "unicast";
|
|
85
|
+
}
|
|
86
|
+
class NotPublicAddress extends Error {
|
|
87
|
+
address;
|
|
88
|
+
code = "ENOTPUBLIC";
|
|
89
|
+
constructor(address) {
|
|
90
|
+
super(`${address} is not a public address.`);
|
|
91
|
+
this.address = address;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The name lookup of every connection the image fetch opens, redirects
|
|
96
|
+
* included: the address checked is the address connected to, so a public
|
|
97
|
+
* name that resolves (or is re-pointed between two asks) to this machine or
|
|
98
|
+
* a private network is never reached.
|
|
99
|
+
*/
|
|
100
|
+
export const publicOnlyLookup = (hostname, options, callback) => {
|
|
101
|
+
dns.lookup(hostname, options, (error, address, family) => {
|
|
102
|
+
if (error !== null)
|
|
103
|
+
return callback(error, address, family);
|
|
104
|
+
const addresses = Array.isArray(address) ? address.map((found) => found.address) : [address];
|
|
105
|
+
const [refused = null] = addresses.filter((found) => !isPublicAddress(found));
|
|
106
|
+
if (refused !== null)
|
|
107
|
+
return callback(new NotPublicAddress(refused), address, family);
|
|
108
|
+
callback(null, address, family);
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
// Connects only to public addresses. A bare address in a URL skips the name
|
|
112
|
+
// lookup, so fetchImage checks that one itself.
|
|
113
|
+
const publicWeb = new Agent({ connect: { lookup: publicOnlyLookup } });
|
|
114
|
+
const refusedAddress = z.object({ cause: z.instanceof(NotPublicAddress) });
|
|
115
|
+
// A fragment never reaches the server, so two addresses that differ only
|
|
116
|
+
// there are one image.
|
|
117
|
+
function keyOf(source) {
|
|
118
|
+
const key = new URL(source);
|
|
119
|
+
key.hash = "";
|
|
120
|
+
return key;
|
|
121
|
+
}
|
|
122
|
+
/** The app's copy of the image at `source`, if a member's view has kept one. */
|
|
123
|
+
export async function keptImage(db, source) {
|
|
124
|
+
const [kept = null] = await db
|
|
125
|
+
.select({ contentType: lightImages.content_type, bytes: lightImages.bytes })
|
|
126
|
+
.from(lightImages)
|
|
127
|
+
.where(eq(lightImages.source_url, keyOf(source).href));
|
|
128
|
+
if (kept === null)
|
|
129
|
+
return { ok: false, reason: "No copy of this image is kept yet." };
|
|
130
|
+
return { ok: true, image: kept };
|
|
131
|
+
}
|
|
132
|
+
/** The app's copy of the image at `source`, fetched and kept on the first ask. */
|
|
133
|
+
export async function storedImage(db, source) {
|
|
134
|
+
const kept = await keptImage(db, source);
|
|
135
|
+
if (kept.ok)
|
|
136
|
+
return kept;
|
|
137
|
+
const key = keyOf(source);
|
|
138
|
+
const fetched = await fetchImage(key);
|
|
139
|
+
if (!fetched.ok)
|
|
140
|
+
return fetched;
|
|
141
|
+
await db
|
|
142
|
+
.insert(lightImages)
|
|
143
|
+
.values({ source_url: key.href, content_type: fetched.image.contentType, bytes: fetched.image.bytes })
|
|
144
|
+
.onConflictDoNothing();
|
|
145
|
+
return fetched;
|
|
146
|
+
}
|
|
147
|
+
function failureReason(error) {
|
|
148
|
+
const failure = networkFailure.safeParse(error);
|
|
149
|
+
return failure.success ? failure.data.cause.code : String(error);
|
|
150
|
+
}
|
|
151
|
+
async function fetchImage(source) {
|
|
152
|
+
const host = source.hostname.toLowerCase();
|
|
153
|
+
// Each hop is checked: a redirect must never be the way to make this app
|
|
154
|
+
// fetch from somewhere it would not fetch itself.
|
|
155
|
+
let url = source;
|
|
156
|
+
let response;
|
|
157
|
+
for (let hop = 0;; hop += 1) {
|
|
158
|
+
const at = url.hostname.toLowerCase();
|
|
159
|
+
const notPublic = (detail) => ({
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: hop === 0
|
|
162
|
+
? `${at} is not a public web address${detail}.`
|
|
163
|
+
: `${host} redirected to ${at}, which is not a public web address${detail}.`
|
|
164
|
+
});
|
|
165
|
+
// An IPv6 host is bracketed in a URL.
|
|
166
|
+
const bare = at.replace(/^\[(.*)\]$/, "$1");
|
|
167
|
+
if (isIP(bare) !== 0 && !isPublicAddress(bare))
|
|
168
|
+
return notPublic("");
|
|
169
|
+
const init = {
|
|
170
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
171
|
+
redirect: "manual",
|
|
172
|
+
dispatcher: publicWeb
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
response = await fetch(url, init);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const refused = refusedAddress.safeParse(error);
|
|
179
|
+
if (refused.success)
|
|
180
|
+
return notPublic(` (it resolves to ${refused.data.cause.address})`);
|
|
181
|
+
return { ok: false, reason: `${at} could not be reached (${failureReason(error)}).` };
|
|
182
|
+
}
|
|
183
|
+
const location = response.headers.get("location");
|
|
184
|
+
if (location === null || !REDIRECT_STATUSES.has(response.status))
|
|
185
|
+
break;
|
|
186
|
+
if (response.body !== null)
|
|
187
|
+
await response.body.cancel();
|
|
188
|
+
if (hop === MAX_REDIRECTS)
|
|
189
|
+
return { ok: false, reason: `${host} redirected too many times.` };
|
|
190
|
+
url = new URL(location, url);
|
|
191
|
+
}
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
return { ok: false, reason: `${host} answered HTTP ${response.status}.` };
|
|
194
|
+
const contentType = mediaType(response.headers.get("content-type"));
|
|
195
|
+
if (!IMAGE_TYPES.has(contentType)) {
|
|
196
|
+
return { ok: false, reason: `${host} answered with ${contentType || "no content type"}, not an image.` };
|
|
197
|
+
}
|
|
198
|
+
let bytes;
|
|
199
|
+
try {
|
|
200
|
+
bytes = await readCapped(response, MAX_IMAGE_BYTES);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return { ok: false, reason: `${host} stopped answering before the image was read (${failureReason(error)}).` };
|
|
204
|
+
}
|
|
205
|
+
if (bytes === null)
|
|
206
|
+
return { ok: false, reason: `${host}'s image is over ${MAX_IMAGE_BYTES} bytes.` };
|
|
207
|
+
// An empty 200 is what a rate-limiter or a CDN edge answers with; kept, it
|
|
208
|
+
// would be served as the picture for a year.
|
|
209
|
+
if (bytes.byteLength === 0)
|
|
210
|
+
return { ok: false, reason: `${host} answered with an empty body.` };
|
|
211
|
+
return { ok: true, image: { contentType, bytes } };
|
|
212
|
+
}
|
|
213
|
+
/** "image/png; charset=binary" is "image/png"; a response with none has "". */
|
|
214
|
+
function mediaType(header) {
|
|
215
|
+
if (header === null)
|
|
216
|
+
return "";
|
|
217
|
+
return header.replace(/;.*$/, "").trim().toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
// Read at most `limit` bytes, abandoning the response the moment it exceeds
|
|
220
|
+
// them: content-length is advisory, so the cap is applied while reading.
|
|
221
|
+
async function readCapped(response, limit) {
|
|
222
|
+
const stream = response.body;
|
|
223
|
+
if (stream === null)
|
|
224
|
+
return new Uint8Array(0);
|
|
225
|
+
const reader = stream.getReader();
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let total = 0;
|
|
228
|
+
for (;;) {
|
|
229
|
+
const { done, value } = await reader.read();
|
|
230
|
+
if (done)
|
|
231
|
+
break;
|
|
232
|
+
total += value.byteLength;
|
|
233
|
+
if (total > limit) {
|
|
234
|
+
await reader.cancel();
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
chunks.push(value);
|
|
238
|
+
}
|
|
239
|
+
const out = new Uint8Array(total);
|
|
240
|
+
let at = 0;
|
|
241
|
+
for (const chunk of chunks) {
|
|
242
|
+
out.set(chunk, at);
|
|
243
|
+
at += chunk.byteLength;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
|
|
|
18
18
|
readonly db: PostgresJsDatabase<S>;
|
|
19
19
|
/** `/api/light/entities/*`: what `app.table(...)` calls. */
|
|
20
20
|
entities: (request: Request) => Promise<Response>;
|
|
21
|
+
/** `/api/light/image`: a row's photo or logo, from the app's own copy (kept on the first view). */
|
|
22
|
+
image: (request: Request) => Promise<Response>;
|
|
21
23
|
/** Server-side starts and reads of the app's contracts. */
|
|
22
24
|
invoke: import("@shubh90/app-runtime/invoke/server").MiiInvoke<C>;
|
|
23
25
|
/** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The app's server half, bound once in `src/server/light.ts`: its database,
|
|
2
2
|
// its door (sign-in through Plaza), and the routes the SDK serves (entities,
|
|
3
|
-
// contracts, chat). Server-only.
|
|
3
|
+
// images, contracts, chat). Server-only.
|
|
4
4
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
5
5
|
import postgres from "postgres";
|
|
6
6
|
import { createMiiAuthCore } from "@shubh90/app-runtime/auth/core";
|
|
@@ -8,6 +8,7 @@ import { createMiiChatHandler } from "@shubh90/app-runtime/chat/server";
|
|
|
8
8
|
import { createMiiInvoke } from "@shubh90/app-runtime/invoke/server";
|
|
9
9
|
import { createEntitiesHandler } from "./entities.js";
|
|
10
10
|
import { env } from "./env.js";
|
|
11
|
+
import { createImageHandler } from "./images.js";
|
|
11
12
|
import { readSession, signIn } from "./session.js";
|
|
12
13
|
export { cookieAttributes, safeNext, SESSION_COOKIE } from "@shubh90/app-runtime/auth/core";
|
|
13
14
|
export { defineContracts, InvocationError } from "@shubh90/app-runtime/invoke/server";
|
|
@@ -42,6 +43,8 @@ export function createLightServer(options) {
|
|
|
42
43
|
},
|
|
43
44
|
/** `/api/light/entities/*`: what `app.table(...)` calls. */
|
|
44
45
|
entities: createEntitiesHandler({ schema: options.schema, db, session }),
|
|
46
|
+
/** `/api/light/image`: a row's photo or logo, from the app's own copy (kept on the first view). */
|
|
47
|
+
image: createImageHandler({ db, session }),
|
|
45
48
|
/** Server-side starts and reads of the app's contracts. */
|
|
46
49
|
invoke,
|
|
47
50
|
/** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylight/app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
"@shubh90/app-runtime": "^0.10.3",
|
|
55
55
|
"drizzle-kit": "0.31.11",
|
|
56
56
|
"drizzle-zod": "^0.8.3",
|
|
57
|
-
"
|
|
57
|
+
"ipaddr.js": "^2.5.0",
|
|
58
|
+
"tsx": "^4.23.15",
|
|
59
|
+
"undici": "^7.30.0"
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|
|
60
62
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",
|