@flaredev/cli 0.5.1 → 0.6.1
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 +59 -47
- package/dist/index.js +368 -146
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +1 -1
- package/templates/app/app/dashboard/error.tsx +37 -0
- package/templates/app/app/dashboard/import-actions.ts +1 -1
- package/templates/app/app/dashboard/loading.tsx +18 -0
- package/templates/app/app/dashboard/not-found.tsx +30 -0
- package/templates/app/app/error.tsx +55 -0
- package/templates/app/app/loading.tsx +22 -0
- package/templates/app/app/not-found.tsx +48 -0
- package/templates/app/components/dashboard/fields/file-cell.tsx +68 -0
- package/templates/app/components/dashboard/record-detail.tsx +4 -0
- package/templates/app/components/dashboard/resource-table.tsx +6 -1
- package/templates/app/components/dashboard/skeletons.tsx +146 -0
- package/templates/app/lib/api.ts +1 -1
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/dashboard.ts +2 -2
- package/templates/app/lib/resource/drizzle-rows.ts +134 -0
- package/templates/app/lib/resource/handlers.ts +157 -0
- package/templates/app/lib/resource/index.ts +14 -0
- package/templates/app/lib/resource/query.ts +149 -0
- package/templates/app/lib/resource/rows.ts +66 -0
- package/templates/app/lib/resource/store.ts +342 -0
- package/templates/next/app/dashboard/actions.ts +1 -1
- package/templates/next/lib/dashboard.ts +1 -1
- package/templates/next/lib/resource/index.ts +14 -0
- package/templates/next/lib/resource/prisma-rows.ts +169 -0
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
import { revalidateTag, unstable_cache } from "next/cache";
|
|
2
|
-
import type { ChangeEvent } from "
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Caching for data you read far more often than you write.
|
|
6
|
-
*
|
|
7
|
-
* Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
|
|
8
|
-
* what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
|
|
9
|
-
* colo and every isolate, so a value computed once is reused until it expires or
|
|
10
|
-
* its tag is revalidated.
|
|
11
|
-
*
|
|
12
|
-
* vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
|
|
13
|
-
* vinext fix lands: in the current beta, pages that redirect break behind it.
|
|
14
|
-
*
|
|
15
|
-
* Nothing here caches per-user data: everything keyed by session belongs
|
|
16
|
-
* outside the cache, since these entries are shared between visitors.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
|
|
20
|
-
export const TTL = {
|
|
21
|
-
/** Changes often, cheap to recompute: counts, dashboards. */
|
|
22
|
-
short: 60,
|
|
23
|
-
/** The usual case: lists and detail views behind a resource tag. */
|
|
24
|
-
medium: 5 * 60,
|
|
25
|
-
/** Rarely changes: settings, published content, reference tables. */
|
|
26
|
-
long: 60 * 60,
|
|
27
|
-
} as const;
|
|
28
|
-
|
|
29
|
-
/** Tag for everything derived from a resource, e.g. `resource/Deal`. */
|
|
30
|
-
export const resourceTag = (resource: string) => `resource/${resource}`;
|
|
31
|
-
|
|
32
|
-
/** Tag for one record, e.g. `resource/Deal/0f9…`. */
|
|
33
|
-
export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The KV data cache silently ignores tags containing `:`, `\` or control
|
|
37
|
-
* characters — the entry is stored untagged, so no revalidation ever reaches
|
|
38
|
-
* it. Refuse them here instead, where the mistake is still visible.
|
|
39
|
-
*/
|
|
40
|
-
function checkTag(tag: string): string {
|
|
41
|
-
if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
|
|
42
|
-
throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
|
|
43
|
-
}
|
|
44
|
-
return tag;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Cache the result of an async function in the data cache.
|
|
49
|
-
*
|
|
50
|
-
* `keyParts` identify the value (include every argument the result depends on);
|
|
51
|
-
* `tags` say what invalidates it. Cached functions must not read cookies,
|
|
52
|
-
* headers, or anything else request-specific — the value is shared.
|
|
53
|
-
*
|
|
54
|
-
* ```ts
|
|
55
|
-
* const openDeals = cached(
|
|
56
|
-
* () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
|
|
57
|
-
* ["deals", "open-count"],
|
|
58
|
-
* { tags: [resourceTag("Deal")] },
|
|
59
|
-
* );
|
|
60
|
-
* ```
|
|
61
|
-
*/
|
|
62
|
-
export function cached<Args extends unknown[], Value>(
|
|
63
|
-
fn: (...args: Args) => Promise<Value>,
|
|
64
|
-
keyParts: string[],
|
|
65
|
-
options: { tags?: string[]; revalidate?: number } = {},
|
|
66
|
-
) {
|
|
67
|
-
return unstable_cache(fn, keyParts, {
|
|
68
|
-
revalidate: options.revalidate ?? TTL.medium,
|
|
69
|
-
tags: (options.tags ?? []).map(checkTag),
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Drop everything cached for a resource (and for the record that changed).
|
|
75
|
-
* `lib/dashboard.ts` and `lib/api.ts` pass this to the store, so writes through the
|
|
76
|
-
* admin or the REST API invalidate without anyone remembering to call it.
|
|
77
|
-
*/
|
|
78
|
-
export function revalidateResource({ resource, action, id }: ChangeEvent) {
|
|
79
|
-
// { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
|
|
80
|
-
// A record someone just edited should never come back with its old values.
|
|
81
|
-
revalidateTag(resourceTag(resource), { expire: 0 });
|
|
82
|
-
if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
|
|
83
|
-
}
|
|
1
|
+
import { revalidateTag, unstable_cache } from "next/cache";
|
|
2
|
+
import type { ChangeEvent } from "@/lib/resource/store";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Caching for data you read far more often than you write.
|
|
6
|
+
*
|
|
7
|
+
* Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
|
|
8
|
+
* what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
|
|
9
|
+
* colo and every isolate, so a value computed once is reused until it expires or
|
|
10
|
+
* its tag is revalidated.
|
|
11
|
+
*
|
|
12
|
+
* vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
|
|
13
|
+
* vinext fix lands: in the current beta, pages that redirect break behind it.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here caches per-user data: everything keyed by session belongs
|
|
16
|
+
* outside the cache, since these entries are shared between visitors.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
|
|
20
|
+
export const TTL = {
|
|
21
|
+
/** Changes often, cheap to recompute: counts, dashboards. */
|
|
22
|
+
short: 60,
|
|
23
|
+
/** The usual case: lists and detail views behind a resource tag. */
|
|
24
|
+
medium: 5 * 60,
|
|
25
|
+
/** Rarely changes: settings, published content, reference tables. */
|
|
26
|
+
long: 60 * 60,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Tag for everything derived from a resource, e.g. `resource/Deal`. */
|
|
30
|
+
export const resourceTag = (resource: string) => `resource/${resource}`;
|
|
31
|
+
|
|
32
|
+
/** Tag for one record, e.g. `resource/Deal/0f9…`. */
|
|
33
|
+
export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The KV data cache silently ignores tags containing `:`, `\` or control
|
|
37
|
+
* characters — the entry is stored untagged, so no revalidation ever reaches
|
|
38
|
+
* it. Refuse them here instead, where the mistake is still visible.
|
|
39
|
+
*/
|
|
40
|
+
function checkTag(tag: string): string {
|
|
41
|
+
if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
|
|
42
|
+
throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
|
|
43
|
+
}
|
|
44
|
+
return tag;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cache the result of an async function in the data cache.
|
|
49
|
+
*
|
|
50
|
+
* `keyParts` identify the value (include every argument the result depends on);
|
|
51
|
+
* `tags` say what invalidates it. Cached functions must not read cookies,
|
|
52
|
+
* headers, or anything else request-specific — the value is shared.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const openDeals = cached(
|
|
56
|
+
* () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
|
|
57
|
+
* ["deals", "open-count"],
|
|
58
|
+
* { tags: [resourceTag("Deal")] },
|
|
59
|
+
* );
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function cached<Args extends unknown[], Value>(
|
|
63
|
+
fn: (...args: Args) => Promise<Value>,
|
|
64
|
+
keyParts: string[],
|
|
65
|
+
options: { tags?: string[]; revalidate?: number } = {},
|
|
66
|
+
) {
|
|
67
|
+
return unstable_cache(fn, keyParts, {
|
|
68
|
+
revalidate: options.revalidate ?? TTL.medium,
|
|
69
|
+
tags: (options.tags ?? []).map(checkTag),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drop everything cached for a resource (and for the record that changed).
|
|
75
|
+
* `lib/dashboard.ts` and `lib/api.ts` pass this to the store, so writes through the
|
|
76
|
+
* admin or the REST API invalidate without anyone remembering to call it.
|
|
77
|
+
*/
|
|
78
|
+
export function revalidateResource({ resource, action, id }: ChangeEvent) {
|
|
79
|
+
// { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
|
|
80
|
+
// A record someone just edited should never come back with its old values.
|
|
81
|
+
revalidateTag(resourceTag(resource), { expire: 0 });
|
|
82
|
+
if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
|
|
83
|
+
}
|
|
@@ -3,7 +3,7 @@ import { notFound, redirect } from "next/navigation";
|
|
|
3
3
|
import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
4
|
import { count, sql } from "drizzle-orm";
|
|
5
5
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
6
|
-
import { createResourceStore, type ResourceStore } from "
|
|
6
|
+
import { createResourceStore, drizzleRows, type ResourceStore } from "@/lib/resource";
|
|
7
7
|
import { getDb } from "@/db";
|
|
8
8
|
import { policies } from "@/policies";
|
|
9
9
|
import { resourceTables } from "@/resources/server";
|
|
@@ -66,7 +66,7 @@ export function dashboardStore(name: string): ResourceStore {
|
|
|
66
66
|
if (!entry) notFound();
|
|
67
67
|
let store = stores.get(name);
|
|
68
68
|
if (!store) {
|
|
69
|
-
store = createResourceStore({ resource: entry.resource,
|
|
69
|
+
store = createResourceStore({ resource: entry.resource, rows: drizzleRows(entry.table, getDb), onChange: revalidateResource, currentUser });
|
|
70
70
|
stores.set(name, store);
|
|
71
71
|
}
|
|
72
72
|
return store;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// This file is yours.
|
|
2
|
+
//
|
|
3
|
+
// Flare copies the code that turns a resource descriptor into a working endpoint into
|
|
4
|
+
// your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
|
|
5
|
+
// a component: you cannot fix, read or reason about what you cannot see. Nothing here
|
|
6
|
+
// is imported from the framework at runtime — change it, delete it, replace it.
|
|
7
|
+
//
|
|
8
|
+
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
|
+
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* {@link ResourceRows} over Drizzle and SQLite — the Cloudflare stack's half.
|
|
13
|
+
*
|
|
14
|
+
* This is the query building that used to sit inside the store, moved out so the store
|
|
15
|
+
* itself has no dialect in it. Behaviour is unchanged on purpose: the same LIKE search,
|
|
16
|
+
* the same `(sort, id)` cursor comparison, the same capped count.
|
|
17
|
+
*/
|
|
18
|
+
import { and, asc, count, desc, eq, getTableColumns, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
19
|
+
import type { BaseSQLiteDatabase, SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
20
|
+
import type { ConstraintHit, ResourceRows, Row, RowsQuery } from "./rows";
|
|
21
|
+
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
export type AnyDatabase = BaseSQLiteDatabase<"sync" | "async", any, any>;
|
|
24
|
+
|
|
25
|
+
/** Walks an error and its causes (drizzle and D1 both wrap SQLite errors). */
|
|
26
|
+
function errorText(error: unknown): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
for (let current = error, depth = 0; current && depth < 5; depth++) {
|
|
29
|
+
if (current instanceof Error) {
|
|
30
|
+
parts.push(current.message);
|
|
31
|
+
current = current.cause;
|
|
32
|
+
} else {
|
|
33
|
+
parts.push(String(current));
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts.join(" | ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): ResourceRows & { columns: Record<string, SQLiteColumn> } {
|
|
41
|
+
const columns = getTableColumns(table) as Record<string, SQLiteColumn>;
|
|
42
|
+
const idColumn = columns.id!;
|
|
43
|
+
const byId = (id: string) => eq(idColumn, id);
|
|
44
|
+
|
|
45
|
+
/** The search and filter half of a where clause — everything but the cursor. */
|
|
46
|
+
function matching(query: Pick<RowsQuery, "search" | "filters">): SQL[] {
|
|
47
|
+
const conditions: SQL[] = [];
|
|
48
|
+
if (query.search && query.search.fields.length > 0) {
|
|
49
|
+
const pattern = `%${query.search.term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
|
50
|
+
const searchable = query.search.fields.map((key) => columns[key]!);
|
|
51
|
+
conditions.push(or(...searchable.map((column) => sql`${column} LIKE ${pattern} ESCAPE '\\'`))!);
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of Object.entries(query.filters)) {
|
|
54
|
+
conditions.push(value === null ? isNull(columns[key]!) : eq(columns[key]!, value));
|
|
55
|
+
}
|
|
56
|
+
return conditions;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
columns,
|
|
61
|
+
get db() {
|
|
62
|
+
return getDb();
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async find(query) {
|
|
66
|
+
const conditions = matching(query);
|
|
67
|
+
const sortColumn = columns[query.sort.field]!;
|
|
68
|
+
const descending = query.sort.direction === "desc";
|
|
69
|
+
const order = descending ? [desc(sortColumn), desc(idColumn)] : [asc(sortColumn), asc(idColumn)];
|
|
70
|
+
|
|
71
|
+
if (query.cursor) {
|
|
72
|
+
// Compared as the column stores it: a date column's driver mapper expects a Date,
|
|
73
|
+
// and a cursor carries the milliseconds it was read as, so this stays raw SQL.
|
|
74
|
+
const { value, id, greaterThan } = query.cursor;
|
|
75
|
+
conditions.push(
|
|
76
|
+
greaterThan
|
|
77
|
+
? or(sql`${sortColumn} > ${value}`, and(sql`${sortColumn} = ${value}`, sql`${idColumn} > ${id}`))!
|
|
78
|
+
: or(sql`${sortColumn} < ${value}`, and(sql`${sortColumn} = ${value}`, sql`${idColumn} < ${id}`))!,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
83
|
+
const statement = getDb().select().from(table).where(where).orderBy(...order).limit(query.limit);
|
|
84
|
+
const rows = query.offset ? await statement.offset(query.offset) : await statement;
|
|
85
|
+
return rows as Row[];
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async countUpTo(query, limit) {
|
|
89
|
+
const conditions = matching(query);
|
|
90
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
91
|
+
const db = getDb();
|
|
92
|
+
// Counting a subquery that stops at the cap, rather than the whole table.
|
|
93
|
+
const [counted] = await db
|
|
94
|
+
.select({ total: count() })
|
|
95
|
+
.from(db.select({ id: idColumn }).from(table).where(where).limit(limit).as("capped"));
|
|
96
|
+
return counted?.total ?? 0;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async byId(id) {
|
|
100
|
+
const [record] = await getDb().select().from(table).where(byId(id)).limit(1);
|
|
101
|
+
return (record as Row | undefined) ?? null;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async titles(ids, titleField) {
|
|
105
|
+
const titleColumn = columns[titleField] ?? idColumn;
|
|
106
|
+
const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(inArray(idColumn, ids));
|
|
107
|
+
return rows as { id: string; title: unknown }[];
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async insert(values) {
|
|
111
|
+
const [record] = await getDb().insert(table).values(values).returning();
|
|
112
|
+
return record as Row;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async update(id, values) {
|
|
116
|
+
const [record] = await getDb().update(table).set(values).where(byId(id)).returning();
|
|
117
|
+
return (record as Row | undefined) ?? null;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async remove(id) {
|
|
121
|
+
const deleted = await getDb().delete(table).where(byId(id)).returning({ id: idColumn });
|
|
122
|
+
return deleted.length > 0;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
constraint(error): ConstraintHit | undefined {
|
|
126
|
+
const text = errorText(error);
|
|
127
|
+
const unique = /UNIQUE constraint failed: ([\w.]+(?:, [\w.]+)*)/.exec(text);
|
|
128
|
+
if (unique) return { kind: "unique", column: unique[1]!.split(", ")[0]!.split(".").pop() };
|
|
129
|
+
if (/FOREIGN KEY constraint failed/.test(text)) return { kind: "foreignKey" };
|
|
130
|
+
if (/CHECK constraint failed/.test(text)) return { kind: "check" };
|
|
131
|
+
return undefined;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// This file is yours.
|
|
2
|
+
//
|
|
3
|
+
// Flare copies the code that turns a resource descriptor into a working endpoint into
|
|
4
|
+
// your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
|
|
5
|
+
// a component: you cannot fix, read or reason about what you cannot see. Nothing here
|
|
6
|
+
// is imported from the framework at runtime — change it, delete it, replace it.
|
|
7
|
+
//
|
|
8
|
+
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
|
+
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
|
+
|
|
11
|
+
import type { Resource } from "@flaredev/core";
|
|
12
|
+
import type { ResourceRows } from "./rows";
|
|
13
|
+
import {
|
|
14
|
+
createResourceStore,
|
|
15
|
+
type ChangeEvent,
|
|
16
|
+
type Failure,
|
|
17
|
+
type ResourceAction,
|
|
18
|
+
type ResourceStoreOptions,
|
|
19
|
+
type Result,
|
|
20
|
+
} from "./store";
|
|
21
|
+
|
|
22
|
+
export type { ResourceAction } from "./store";
|
|
23
|
+
|
|
24
|
+
export interface AuthorizeContext {
|
|
25
|
+
request: Request;
|
|
26
|
+
resource: Resource;
|
|
27
|
+
action: ResourceAction;
|
|
28
|
+
id?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Return a Response (e.g. 401/403) to deny the request; return nothing to allow it. */
|
|
32
|
+
export type Authorize = (context: AuthorizeContext) => Response | void | Promise<Response | void>;
|
|
33
|
+
|
|
34
|
+
export interface ResourceHandlerOptions {
|
|
35
|
+
resource: Resource;
|
|
36
|
+
/** Where rows come from. See {@link ResourceStoreOptions.rows}. */
|
|
37
|
+
rows: ResourceRows;
|
|
38
|
+
authorize: Authorize;
|
|
39
|
+
/** Who is making the request, for the descriptor's hooks. */
|
|
40
|
+
currentUser?: ResourceStoreOptions["currentUser"];
|
|
41
|
+
/** Called after a write succeeds, for cache invalidation. */
|
|
42
|
+
onChange?: (event: ChangeEvent) => void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type RouteContext = { params: Promise<{ id: string }> };
|
|
46
|
+
|
|
47
|
+
const problem = (status: number, error: string, extra: object = {}) => Response.json({ error, ...extra }, { status });
|
|
48
|
+
|
|
49
|
+
function failureResponse(failure: Failure): Response {
|
|
50
|
+
if (failure.queryIssues) return problem(failure.status, failure.error, { issues: failure.queryIssues });
|
|
51
|
+
const extra: Record<string, unknown> = {};
|
|
52
|
+
if (failure.issues) extra.issues = failure.issues;
|
|
53
|
+
if (failure.field) extra.field = failure.field;
|
|
54
|
+
return problem(failure.status, failure.error, extra);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const respond = <T>(result: Result<T>, init?: ResponseInit) =>
|
|
58
|
+
result.ok ? Response.json(result.data, init) : failureResponse(result);
|
|
59
|
+
|
|
60
|
+
/** REST route handlers for a resource: thin HTTP wrappers over `createResourceStore`. */
|
|
61
|
+
export function createResourceHandlers(options: ResourceHandlerOptions) {
|
|
62
|
+
const { resource, authorize } = options;
|
|
63
|
+
const store = createResourceStore(options);
|
|
64
|
+
|
|
65
|
+
/** Deny cross-site writes: browsers send Origin on POST/PATCH/PUT/DELETE. */
|
|
66
|
+
const crossOrigin = (request: Request) => {
|
|
67
|
+
const origin = request.headers.get("origin");
|
|
68
|
+
return origin !== null && origin !== new URL(request.url).origin;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
async function readBody(request: Request): Promise<{ body: unknown } | { response: Response }> {
|
|
72
|
+
if (crossOrigin(request)) return { response: problem(403, "Cross-origin request blocked.") };
|
|
73
|
+
const type = request.headers.get("content-type") ?? "";
|
|
74
|
+
if (!/^application\/json\b/i.test(type)) return { response: problem(415, "Send JSON with Content-Type: application/json.") };
|
|
75
|
+
try {
|
|
76
|
+
return { body: await request.json() };
|
|
77
|
+
} catch {
|
|
78
|
+
return { response: problem(400, "Request body isn't valid JSON.") };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const guard = async (context: AuthorizeContext) => (await authorize(context)) ?? undefined;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read and drop a body the handler didn't consume (a 401/403/415 answered early).
|
|
86
|
+
* Answering with it unread breaks the next request through wrangler's local dev
|
|
87
|
+
* proxy ("Network connection lost"). Streams, so memory stays flat.
|
|
88
|
+
*/
|
|
89
|
+
async function drain(request: Request) {
|
|
90
|
+
if (!request.body || request.bodyUsed) return;
|
|
91
|
+
try {
|
|
92
|
+
const reader = request.body.getReader();
|
|
93
|
+
while (!(await reader.read()).done);
|
|
94
|
+
} catch {
|
|
95
|
+
// The client went away.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const draining =
|
|
99
|
+
<Rest extends unknown[]>(handler: (request: Request, ...rest: Rest) => Promise<Response>) =>
|
|
100
|
+
async (request: Request, ...rest: Rest): Promise<Response> => {
|
|
101
|
+
const response = await handler(request, ...rest);
|
|
102
|
+
await drain(request);
|
|
103
|
+
return response;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
async function GET(request: Request): Promise<Response> {
|
|
107
|
+
const denied = await guard({ request, resource, action: "list" });
|
|
108
|
+
if (denied) return denied;
|
|
109
|
+
return respond(await store.list(new URL(request.url).searchParams));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function POST(request: Request): Promise<Response> {
|
|
113
|
+
const denied = await guard({ request, resource, action: "create" });
|
|
114
|
+
if (denied) return denied;
|
|
115
|
+
const read = await readBody(request);
|
|
116
|
+
if ("response" in read) return read.response;
|
|
117
|
+
const result = await store.create(read.body);
|
|
118
|
+
if (!result.ok) return failureResponse(result);
|
|
119
|
+
const location = `${new URL(request.url).pathname.replace(/\/$/, "")}/${result.data.id as string}`;
|
|
120
|
+
return Response.json(result.data, { status: 201, headers: { location } });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function item(request: Request, context: RouteContext, action: ResourceAction, work: (id: string) => Promise<Response>) {
|
|
124
|
+
const { id } = await context.params;
|
|
125
|
+
const denied = await guard({ request, resource, action, id });
|
|
126
|
+
if (denied) return denied;
|
|
127
|
+
return work(id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const withBody = (request: Request, run: (body: unknown) => Promise<Response>) =>
|
|
131
|
+
readBody(request).then((read) => ("response" in read ? read.response : run(read.body)));
|
|
132
|
+
|
|
133
|
+
const itemHandlers = {
|
|
134
|
+
GET: (request: Request, context: RouteContext) => item(request, context, "read", async (id) => respond(await store.get(id))),
|
|
135
|
+
PATCH: (request: Request, context: RouteContext) =>
|
|
136
|
+
item(request, context, "update", (id) => withBody(request, async (body) => respond(await store.update(id, body)))),
|
|
137
|
+
PUT: (request: Request, context: RouteContext) =>
|
|
138
|
+
item(request, context, "update", (id) => withBody(request, async (body) => respond(await store.replace(id, body)))),
|
|
139
|
+
DELETE: (request: Request, context: RouteContext) =>
|
|
140
|
+
item(request, context, "delete", async (id) => {
|
|
141
|
+
if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.");
|
|
142
|
+
const result = await store.delete(id);
|
|
143
|
+
return result.ok ? new Response(null, { status: 204 }) : failureResponse(result);
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
collection: { GET, POST: draining(POST) },
|
|
149
|
+
item: {
|
|
150
|
+
GET: itemHandlers.GET,
|
|
151
|
+
PATCH: draining(itemHandlers.PATCH),
|
|
152
|
+
PUT: draining(itemHandlers.PUT),
|
|
153
|
+
DELETE: draining(itemHandlers.DELETE),
|
|
154
|
+
},
|
|
155
|
+
store,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The resource engine, in your app.
|
|
2
|
+
//
|
|
3
|
+
// Re-exported from one place so a route reads `from "@/lib/resource"`. Every file
|
|
4
|
+
// behind it is in this folder and is yours to change.
|
|
5
|
+
//
|
|
6
|
+
// On the Cloudflare stack the row adapter is Drizzle over D1; a Next.js app has
|
|
7
|
+
// prisma-rows.ts here instead. Both implement ResourceRows, which is the whole
|
|
8
|
+
// contract — about fifty lines in rows.ts, and the reason a descriptor doesn't care
|
|
9
|
+
// which database it is on.
|
|
10
|
+
export * from "./rows";
|
|
11
|
+
export * from "./query";
|
|
12
|
+
export * from "./store";
|
|
13
|
+
export * from "./handlers";
|
|
14
|
+
export * from "./drizzle-rows";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// This file is yours.
|
|
2
|
+
//
|
|
3
|
+
// Flare copies the code that turns a resource descriptor into a working endpoint into
|
|
4
|
+
// your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
|
|
5
|
+
// a component: you cannot fix, read or reason about what you cannot see. Nothing here
|
|
6
|
+
// is imported from the framework at runtime — change it, delete it, replace it.
|
|
7
|
+
//
|
|
8
|
+
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
|
+
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
|
+
|
|
11
|
+
import { storedFields, type Resource } from "@flaredev/core";
|
|
12
|
+
import type { StoredField } from "@flaredev/core";
|
|
13
|
+
|
|
14
|
+
export interface ListQuery {
|
|
15
|
+
page: number;
|
|
16
|
+
perPage: number;
|
|
17
|
+
sort: { field: string; direction: "asc" | "desc" };
|
|
18
|
+
q?: string;
|
|
19
|
+
/** Field key → coerced value; `null` means IS NULL. */
|
|
20
|
+
filters: Record<string, string | number | boolean | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
|
|
23
|
+
* SQLite walk half a million index entries before it reads anything (8s at a million
|
|
24
|
+
* rows); a cursor turns that into a range scan (~100ms).
|
|
25
|
+
*/
|
|
26
|
+
cursor?: Cursor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The last row of the page you came from, and which way you're going. */
|
|
30
|
+
export interface Cursor {
|
|
31
|
+
/** The sort field's value on that row. */
|
|
32
|
+
value: string | number | boolean | null;
|
|
33
|
+
id: string;
|
|
34
|
+
direction: "after" | "before";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cursors travel in the URL, so they're base64url of the smallest JSON that works. */
|
|
38
|
+
export function encodeCursor(cursor: Cursor): string {
|
|
39
|
+
const json = JSON.stringify([cursor.value, cursor.id, cursor.direction === "before" ? 0 : 1]);
|
|
40
|
+
return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decodeCursor(raw: string): Cursor | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const json = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
|
|
46
|
+
const parsed = JSON.parse(json) as [string | number | boolean | null, string, number];
|
|
47
|
+
if (!Array.isArray(parsed) || typeof parsed[1] !== "string") return undefined;
|
|
48
|
+
return { value: parsed[0], id: parsed[1], direction: parsed[2] === 0 ? "before" : "after" };
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QueryIssue {
|
|
55
|
+
param: string;
|
|
56
|
+
message: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const TIMESTAMP_FIELDS = ["createdAt", "updatedAt"];
|
|
60
|
+
const MAX_PER_PAGE = 100;
|
|
61
|
+
const MAX_SEARCH_LENGTH = 200;
|
|
62
|
+
|
|
63
|
+
export function isSortable(def: StoredField): boolean {
|
|
64
|
+
return def.sortable ?? !["text", "file"].includes(def.kind);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isFilterable(def: StoredField): boolean {
|
|
68
|
+
return def.filterable ?? ["enum", "boolean", "belongsTo"].includes(def.kind);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isSearchable(def: StoredField): boolean {
|
|
72
|
+
return def.searchable ?? def.kind === "string";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function coerce(def: StoredField, raw: string): string | number | boolean | null | undefined {
|
|
76
|
+
if (raw === "null") return def.required ? undefined : null;
|
|
77
|
+
switch (def.kind) {
|
|
78
|
+
case "boolean":
|
|
79
|
+
return raw === "true" || raw === "1" ? true : raw === "false" || raw === "0" ? false : undefined;
|
|
80
|
+
case "int":
|
|
81
|
+
return /^-?\d+$/.test(raw) ? Number(raw) : undefined;
|
|
82
|
+
case "float":
|
|
83
|
+
return raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : undefined;
|
|
84
|
+
case "enum":
|
|
85
|
+
return def.options.includes(raw) ? raw : undefined;
|
|
86
|
+
default:
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse `?page=2&perPage=50&sort=-createdAt&q=ada&filter[status]=lead` against a
|
|
93
|
+
* resource. Only fields the descriptor marks sortable/filterable/searchable are
|
|
94
|
+
* accepted, so clients can't probe arbitrary columns.
|
|
95
|
+
*/
|
|
96
|
+
export function parseListQuery(resource: Resource, params: URLSearchParams): { query: ListQuery } | { issues: QueryIssue[] } {
|
|
97
|
+
const issues: QueryIssue[] = [];
|
|
98
|
+
const fields = new Map(storedFields(resource));
|
|
99
|
+
|
|
100
|
+
const positiveInt = (name: string, fallback: number, max = Number.MAX_SAFE_INTEGER) => {
|
|
101
|
+
const raw = params.get(name);
|
|
102
|
+
if (raw === null) return fallback;
|
|
103
|
+
const value = Number(raw);
|
|
104
|
+
if (!/^\d+$/.test(raw) || value < 1 || value > max) {
|
|
105
|
+
issues.push({ param: name, message: `must be an integer from 1 to ${max === Number.MAX_SAFE_INTEGER ? "∞" : max}` });
|
|
106
|
+
return fallback;
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const page = positiveInt("page", 1);
|
|
112
|
+
const perPage = positiveInt("perPage", resource.perPage, MAX_PER_PAGE);
|
|
113
|
+
|
|
114
|
+
let sort = resource.defaultSort;
|
|
115
|
+
const rawSort = params.get("sort");
|
|
116
|
+
if (rawSort) {
|
|
117
|
+
const direction = rawSort.startsWith("-") ? "desc" : "asc";
|
|
118
|
+
const field = rawSort.replace(/^-/, "");
|
|
119
|
+
const def = fields.get(field);
|
|
120
|
+
if (TIMESTAMP_FIELDS.includes(field) || (def && isSortable(def))) sort = { field, direction };
|
|
121
|
+
else issues.push({ param: "sort", message: `can't sort by "${field}"` });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const q = params.get("q")?.trim().slice(0, MAX_SEARCH_LENGTH) || undefined;
|
|
125
|
+
|
|
126
|
+
const rawCursor = params.get("cursor");
|
|
127
|
+
let cursor: Cursor | undefined;
|
|
128
|
+
if (rawCursor) {
|
|
129
|
+
cursor = decodeCursor(rawCursor);
|
|
130
|
+
if (!cursor) issues.push({ param: "cursor", message: "isn't a cursor this list handed out" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const filters: ListQuery["filters"] = {};
|
|
134
|
+
for (const [name, raw] of params) {
|
|
135
|
+
const match = /^filter\[(.+)\]$/.exec(name);
|
|
136
|
+
if (!match) continue;
|
|
137
|
+
const key = match[1]!;
|
|
138
|
+
const def = fields.get(key);
|
|
139
|
+
if (!def || !isFilterable(def)) {
|
|
140
|
+
issues.push({ param: name, message: `can't filter by "${key}"` });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const value = coerce(def, raw);
|
|
144
|
+
if (value === undefined) issues.push({ param: name, message: `invalid value "${raw}"` });
|
|
145
|
+
else filters[key] = value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return issues.length ? { issues } : { query: { page, perPage, sort, q, filters, cursor } };
|
|
149
|
+
}
|