@flaredev/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -47
- package/dist/index.js +379 -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
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
* The handful of things a resource store needs a database to do.
|
|
13
|
+
*
|
|
14
|
+
* Everything that makes a Flare resource behave the way it does — validation, hooks,
|
|
15
|
+
* computed values, cursors, capped counts, the wording of a constraint error — lives in
|
|
16
|
+
* the store and is the same on every stack. What differs is how rows are actually read
|
|
17
|
+
* and written, and that is this interface: nine operations, described in the
|
|
18
|
+
* descriptor's terms rather than in SQL.
|
|
19
|
+
*
|
|
20
|
+
* Drizzle over SQLite is one implementation; Prisma over Postgres is another. A store
|
|
21
|
+
* built on either behaves identically, which is the whole point — the same descriptor
|
|
22
|
+
* has to mean the same thing on Cloudflare and on Vercel.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type Row = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
/** What a list needs, before it becomes SQL. */
|
|
28
|
+
export interface RowsQuery {
|
|
29
|
+
/** Free-text search across the fields the descriptor marks searchable. */
|
|
30
|
+
search?: { term: string; fields: string[] };
|
|
31
|
+
/** field → value, or null for "is null". */
|
|
32
|
+
filters: Record<string, unknown>;
|
|
33
|
+
sort: { field: string; direction: "asc" | "desc" };
|
|
34
|
+
/**
|
|
35
|
+
* Keyset pagination: everything after (or before) this row, compared on
|
|
36
|
+
* `(sort field, id)` together so rows sharing a sort value are neither skipped nor
|
|
37
|
+
* repeated.
|
|
38
|
+
*/
|
|
39
|
+
cursor?: { field: string; value: unknown; id: string; greaterThan: boolean };
|
|
40
|
+
limit: number;
|
|
41
|
+
/** Offset paging, for the page-number case. Never combined with a cursor. */
|
|
42
|
+
offset?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A database refusing a write, in terms the store can turn into a message. */
|
|
46
|
+
export type ConstraintHit =
|
|
47
|
+
| { kind: "unique"; column?: string }
|
|
48
|
+
| { kind: "foreignKey" }
|
|
49
|
+
| { kind: "check" };
|
|
50
|
+
|
|
51
|
+
export interface ResourceRows {
|
|
52
|
+
/** The database handle a descriptor's hooks are given. Drizzle, or a Prisma client. */
|
|
53
|
+
readonly db: unknown;
|
|
54
|
+
find(query: RowsQuery): Promise<Row[]>;
|
|
55
|
+
/** How many rows match, giving up at `limit` so a huge table is never fully counted. */
|
|
56
|
+
countUpTo(query: Pick<RowsQuery, "search" | "filters">, limit: number): Promise<number>;
|
|
57
|
+
byId(id: string): Promise<Row | null>;
|
|
58
|
+
titles(ids: string[], titleField: string): Promise<{ id: string; title: unknown }[]>;
|
|
59
|
+
insert(values: Row): Promise<Row>;
|
|
60
|
+
/** The saved row, or null when there was nothing with that id. */
|
|
61
|
+
update(id: string, values: Row): Promise<Row | null>;
|
|
62
|
+
/** Whether a row was actually removed. */
|
|
63
|
+
remove(id: string): Promise<boolean>;
|
|
64
|
+
/** Recognise a constraint violation, or hand the error back by returning undefined. */
|
|
65
|
+
constraint(error: unknown): ConstraintHit | undefined;
|
|
66
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
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 { columnName, storedFields, type Resource } from "@flaredev/core";
|
|
12
|
+
import { createValidators } from "@flaredev/core";
|
|
13
|
+
import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
|
|
14
|
+
import type { ResourceRows, Row } from "./rows";
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export type ResourceAction = "list" | "read" | "create" | "update" | "delete";
|
|
18
|
+
|
|
19
|
+
export interface FieldIssue {
|
|
20
|
+
/** Dotted field path; "" for the whole body (e.g. unknown keys). */
|
|
21
|
+
path: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Failure = {
|
|
26
|
+
ok: false;
|
|
27
|
+
status: 400 | 404 | 409 | 422;
|
|
28
|
+
error: string;
|
|
29
|
+
/** Validation issues per field. */
|
|
30
|
+
issues?: FieldIssue[];
|
|
31
|
+
/** Invalid query parameters. */
|
|
32
|
+
queryIssues?: QueryIssue[];
|
|
33
|
+
/** Field that caused a conflict (e.g. a unique violation). */
|
|
34
|
+
field?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type Result<T> = { ok: true; data: T } | Failure;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How far a list will count before it answers "at least this many". Counting a million
|
|
41
|
+
* rows took 900ms even with an index; counting the first 10,000 takes a few.
|
|
42
|
+
*/
|
|
43
|
+
export const COUNT_LIMIT = 10_000;
|
|
44
|
+
|
|
45
|
+
/** Dates are stored as milliseconds, and a cursor has to compare the way the column does. */
|
|
46
|
+
function toCursorValue(value: unknown): string | number | boolean | null {
|
|
47
|
+
if (value instanceof Date) return value.getTime();
|
|
48
|
+
if (value === null || value === undefined) return null;
|
|
49
|
+
if (typeof value === "object") return String(value);
|
|
50
|
+
return value as string | number | boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ListResult<T = Record<string, unknown>> {
|
|
54
|
+
data: T[];
|
|
55
|
+
meta: {
|
|
56
|
+
page: number;
|
|
57
|
+
perPage: number;
|
|
58
|
+
/** Rows matching the query, counted up to `COUNT_LIMIT`. */
|
|
59
|
+
total: number;
|
|
60
|
+
totalPages: number;
|
|
61
|
+
/**
|
|
62
|
+
* Whether `total` is the real number. Counting every row of a large table on every
|
|
63
|
+
* page load is the single most expensive thing a list does, so the count stops at
|
|
64
|
+
* `COUNT_LIMIT` and says so; past that, the list pages by cursor.
|
|
65
|
+
*/
|
|
66
|
+
exactTotal: boolean;
|
|
67
|
+
/** Carry on from the end of this page, and from its start, without an offset. */
|
|
68
|
+
nextCursor?: string;
|
|
69
|
+
prevCursor?: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ChangeEvent {
|
|
74
|
+
/** Resource name, e.g. "Deal". */
|
|
75
|
+
resource: string;
|
|
76
|
+
action: "create" | "update" | "delete";
|
|
77
|
+
id: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ResourceStoreOptions {
|
|
81
|
+
resource: Resource;
|
|
82
|
+
/**
|
|
83
|
+
* Who is making the write, for the descriptor's hooks. The dashboard and the API both
|
|
84
|
+
* know; a seed doesn't, and passes nothing.
|
|
85
|
+
*/
|
|
86
|
+
currentUser?: () => Promise<{ id: string; email: string; role?: string | null } | null> | { id: string; email: string; role?: string | null } | null;
|
|
87
|
+
/**
|
|
88
|
+
* Where rows come from: `drizzleRows(table, getDb)` on Cloudflare,
|
|
89
|
+
* `prismaRows(prisma.model, prisma)` on Next.js, or anything else implementing
|
|
90
|
+
* {@link ResourceRows} — an in-memory fake in a test, an HTTP API, another database.
|
|
91
|
+
*
|
|
92
|
+
* Passed in rather than chosen here on purpose: the adapter is visible at every call
|
|
93
|
+
* site, and this file never has to know which stack it is on.
|
|
94
|
+
*/
|
|
95
|
+
rows: ResourceRows;
|
|
96
|
+
/**
|
|
97
|
+
* Called after a write succeeds, for cache invalidation. Runs before the
|
|
98
|
+
* operation returns, so a caller that revalidates tags can't hand back a
|
|
99
|
+
* response the cache would then contradict. Failures are logged, not thrown:
|
|
100
|
+
* the write already happened.
|
|
101
|
+
*/
|
|
102
|
+
onChange?: (event: ChangeEvent) => void | Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* CRUD operations for one resource, driven by its descriptor. Shared by the REST
|
|
107
|
+
* handlers and the admin's server actions, so validation, constraint handling, and
|
|
108
|
+
* search behave identically everywhere. Operations never throw for expected failures;
|
|
109
|
+
* they return `{ ok: false, status, error }`.
|
|
110
|
+
*/
|
|
111
|
+
export function createResourceStore(options: ResourceStoreOptions) {
|
|
112
|
+
const { resource, rows } = options;
|
|
113
|
+
const validators = createValidators(resource);
|
|
114
|
+
const fields = storedFields(resource);
|
|
115
|
+
|
|
116
|
+
// Drizzle can say up front whether the table matches the descriptor; a Prisma client
|
|
117
|
+
// can't be asked the same question without a round trip, so that check stays here.
|
|
118
|
+
const columns = "columns" in rows ? (rows.columns as Record<string, { name: string }>) : undefined;
|
|
119
|
+
if (columns) {
|
|
120
|
+
for (const key of ["id", "createdAt", "updatedAt", ...fields.map(([key]) => key)]) {
|
|
121
|
+
if (!columns[key]) {
|
|
122
|
+
throw new Error(`Resource "${resource.name}": table has no column for "${key}". Run \`flare sync-types\` and create a migration.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** A column name from a constraint error back to the field that owns it. */
|
|
127
|
+
const keyForColumn = (name: string): string | undefined =>
|
|
128
|
+
columns
|
|
129
|
+
? Object.entries(columns).find(([, column]) => column.name === name)?.[0]
|
|
130
|
+
: ["id", "createdAt", "updatedAt", ...fields.map(([key]) => key)].find((key) => columnName(key) === name || key === name);
|
|
131
|
+
|
|
132
|
+
const fail = (status: Failure["status"], error: string, extra: Partial<Failure> = {}): Failure => ({ ok: false, status, error, ...extra });
|
|
133
|
+
|
|
134
|
+
/** The same wording whichever database refused the write. */
|
|
135
|
+
function constraintFailure(error: unknown, action: ResourceAction): Failure | undefined {
|
|
136
|
+
const hit = rows.constraint(error);
|
|
137
|
+
if (!hit) return undefined;
|
|
138
|
+
if (hit.kind === "unique") {
|
|
139
|
+
const key = hit.column ? keyForColumn(hit.column) : undefined;
|
|
140
|
+
const label = key ? (resource.fields[key]?.label ?? key) : "Value";
|
|
141
|
+
return fail(409, `${label} is already taken.`, key ? { field: key } : {});
|
|
142
|
+
}
|
|
143
|
+
if (hit.kind === "foreignKey") {
|
|
144
|
+
return action === "delete"
|
|
145
|
+
? fail(409, `This ${resource.label.toLowerCase()} is still referenced by other records.`)
|
|
146
|
+
: fail(422, "A related record doesn't exist.");
|
|
147
|
+
}
|
|
148
|
+
return fail(422, "A value is outside its allowed options.");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function guarded<T>(action: ResourceAction, work: () => Promise<Result<T>>): Promise<Result<T>> {
|
|
152
|
+
try {
|
|
153
|
+
return await work();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const failure = constraintFailure(error, action);
|
|
156
|
+
if (failure) return failure;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Announce a successful write. A broken listener must not turn a completed write into an error. */
|
|
162
|
+
async function announce(action: ChangeEvent["action"], result: Result<{ id?: unknown }>): Promise<void> {
|
|
163
|
+
if (!options.onChange || !result.ok) return;
|
|
164
|
+
try {
|
|
165
|
+
await options.onChange({ resource: resource.name, action, id: String(result.data.id) });
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error(`[flare] ${resource.name} ${action} cache invalidation failed:`, error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Run a write, then announce it before handing the result back. */
|
|
172
|
+
async function writing<T extends { id?: unknown }>(
|
|
173
|
+
action: ChangeEvent["action"],
|
|
174
|
+
work: () => Promise<Result<T>>,
|
|
175
|
+
): Promise<Result<T>> {
|
|
176
|
+
const result = await guarded(action, work);
|
|
177
|
+
await announce(action, result);
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const invalid = (issues: { path: PropertyKey[]; message: string }[]) =>
|
|
182
|
+
fail(422, "Validation failed.", { issues: issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message })) });
|
|
183
|
+
|
|
184
|
+
const notFound = () => fail(404, `${resource.label} not found.`);
|
|
185
|
+
|
|
186
|
+
const computed = Object.entries(resource.computed ?? {});
|
|
187
|
+
/** Add the descriptor's computed values to a row on its way out. */
|
|
188
|
+
const withComputed = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
189
|
+
if (computed.length === 0) return row;
|
|
190
|
+
const result = { ...row };
|
|
191
|
+
for (const [key, compute] of computed) {
|
|
192
|
+
try {
|
|
193
|
+
result[key] = compute(row);
|
|
194
|
+
} catch {
|
|
195
|
+
// A computed value that throws shouldn't take the record with it.
|
|
196
|
+
result[key] = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const hooks = resource.hooks ?? {};
|
|
203
|
+
const hookContext = async () => ({ db: rows.db, user: (await options.currentUser?.()) ?? null });
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
resource,
|
|
207
|
+
|
|
208
|
+
/** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
|
|
209
|
+
async list(params: URLSearchParams): Promise<Result<ListResult>> {
|
|
210
|
+
const parsed = parseListQuery(resource, params);
|
|
211
|
+
if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
|
|
212
|
+
const { page, perPage, sort, q, filters, cursor } = parsed.query;
|
|
213
|
+
|
|
214
|
+
const searchable = fields.filter(([, def]) => isSearchable(def)).map(([key]) => key);
|
|
215
|
+
const matching = { search: q ? { term: q, fields: searchable } : undefined, filters };
|
|
216
|
+
|
|
217
|
+
// Reading backwards from a "previous page" cursor means flipping the order and
|
|
218
|
+
// flipping the rows back afterwards.
|
|
219
|
+
const backwards = cursor?.direction === "before";
|
|
220
|
+
const descending = backwards ? sort.direction === "asc" : sort.direction === "desc";
|
|
221
|
+
|
|
222
|
+
const [found, counting] = await Promise.all([
|
|
223
|
+
// One row past the limit is enough to know there's more without counting it all.
|
|
224
|
+
rows.find({
|
|
225
|
+
...matching,
|
|
226
|
+
sort: { field: sort.field, direction: descending ? "desc" : "asc" },
|
|
227
|
+
cursor: cursor ? { field: sort.field, value: cursor.value, id: cursor.id, greaterThan: !descending } : undefined,
|
|
228
|
+
limit: perPage + 1,
|
|
229
|
+
offset: cursor ? undefined : (page - 1) * perPage,
|
|
230
|
+
}),
|
|
231
|
+
rows.countUpTo(matching, COUNT_LIMIT + 1),
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
// The extra row only tells us another page exists; it isn't part of this one.
|
|
235
|
+
const hasMore = found.length > perPage;
|
|
236
|
+
const page_ = (hasMore ? found.slice(0, perPage) : found).map(withComputed);
|
|
237
|
+
if (backwards) page_.reverse();
|
|
238
|
+
|
|
239
|
+
const exactTotal = counting <= COUNT_LIMIT;
|
|
240
|
+
const total = exactTotal ? counting : COUNT_LIMIT;
|
|
241
|
+
|
|
242
|
+
const first = page_[0];
|
|
243
|
+
const last = page_[page_.length - 1];
|
|
244
|
+
const moreAfter = backwards ? true : hasMore;
|
|
245
|
+
const moreBefore = backwards ? hasMore : Boolean(cursor) || page > 1;
|
|
246
|
+
const cursorFor = (row: Record<string, unknown> | undefined, direction: "after" | "before") =>
|
|
247
|
+
row ? encodeCursor({ value: toCursorValue(row[sort.field]), id: String(row.id), direction }) : undefined;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
data: {
|
|
252
|
+
data: page_,
|
|
253
|
+
meta: {
|
|
254
|
+
page,
|
|
255
|
+
perPage,
|
|
256
|
+
total,
|
|
257
|
+
totalPages: Math.max(1, Math.ceil(total / perPage)),
|
|
258
|
+
exactTotal,
|
|
259
|
+
nextCursor: moreAfter ? cursorFor(last, "after") : undefined,
|
|
260
|
+
prevCursor: moreBefore ? cursorFor(first, "before") : undefined,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async get(id: string): Promise<Result<Record<string, unknown>>> {
|
|
267
|
+
const record = await rows.byId(id);
|
|
268
|
+
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
|
|
272
|
+
async titles(ids: string[]): Promise<Record<string, string>> {
|
|
273
|
+
const unique = [...new Set(ids.filter(Boolean))];
|
|
274
|
+
if (unique.length === 0) return {};
|
|
275
|
+
const found = await rows.titles(unique, resource.titleField);
|
|
276
|
+
return Object.fromEntries(found.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
create(raw: unknown): Promise<Result<Record<string, unknown>>> {
|
|
280
|
+
return writing("create", async () => {
|
|
281
|
+
const context = await hookContext();
|
|
282
|
+
// The hook runs first so it can fill in what the caller couldn't know — an order
|
|
283
|
+
// number, a slug, a tenant — and whatever it returns is validated like any input.
|
|
284
|
+
const supplied = hooks.beforeCreate ? await hooks.beforeCreate({ ...((raw ?? {}) as object) }, context) : raw;
|
|
285
|
+
const result = validators.create.safeParse(supplied);
|
|
286
|
+
if (!result.success) return invalid(result.error.issues);
|
|
287
|
+
const input = result.data as Record<string, unknown>;
|
|
288
|
+
const now = new Date();
|
|
289
|
+
const record = await rows.insert({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now });
|
|
290
|
+
const saved = withComputed(record);
|
|
291
|
+
await hooks.afterCreate?.(saved, context);
|
|
292
|
+
return { ok: true, data: saved };
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/** Partial update (PATCH semantics). */
|
|
297
|
+
update(id: string, raw: unknown): Promise<Result<Record<string, unknown>>> {
|
|
298
|
+
return writing("update", async () => {
|
|
299
|
+
const context = await hookContext();
|
|
300
|
+
const current = hooks.beforeUpdate || hooks.afterUpdate ? await rows.byId(id) : null;
|
|
301
|
+
const supplied = hooks.beforeUpdate ? await hooks.beforeUpdate({ ...((raw ?? {}) as object) }, { ...context, id, current }) : raw;
|
|
302
|
+
const result = validators.update.safeParse(supplied);
|
|
303
|
+
if (!result.success) return invalid(result.error.issues);
|
|
304
|
+
const input = result.data as Record<string, unknown>;
|
|
305
|
+
const record = await rows.update(id, { ...input, updatedAt: new Date() });
|
|
306
|
+
if (!record) return notFound();
|
|
307
|
+
const saved = withComputed(record);
|
|
308
|
+
await hooks.afterUpdate?.(saved, { ...context, previous: current });
|
|
309
|
+
return { ok: true, data: saved };
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
/** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
|
|
314
|
+
replace(id: string, input: unknown): Promise<Result<Record<string, unknown>>> {
|
|
315
|
+
const result = validators.create.safeParse(input);
|
|
316
|
+
if (!result.success) return Promise.resolve(invalid(result.error.issues));
|
|
317
|
+
return writing("update", async () => {
|
|
318
|
+
const values: Row = {};
|
|
319
|
+
for (const [key, def] of fields) {
|
|
320
|
+
values[key] = "default" in def && def.default !== undefined ? def.default : def.required ? undefined : null;
|
|
321
|
+
}
|
|
322
|
+
Object.assign(values, result.data, { updatedAt: new Date() });
|
|
323
|
+
const record = await rows.update(id, values);
|
|
324
|
+
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
delete(id: string): Promise<Result<{ id: string }>> {
|
|
329
|
+
return writing("delete", async () => {
|
|
330
|
+
const context = await hookContext();
|
|
331
|
+
if (hooks.beforeDelete) {
|
|
332
|
+
await hooks.beforeDelete({ ...context, id, current: await rows.byId(id) });
|
|
333
|
+
}
|
|
334
|
+
if (!(await rows.remove(id))) return notFound();
|
|
335
|
+
await hooks.afterDelete?.({ ...context, id });
|
|
336
|
+
return { ok: true, data: { id } };
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export type ResourceStore = ReturnType<typeof createResourceStore>;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { revalidatePath } from "next/cache";
|
|
4
4
|
import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
|
|
5
|
-
import type { FieldIssue } from "
|
|
5
|
+
import type { FieldIssue } from "@/lib/resource/store";
|
|
6
6
|
import { diffFields, recordAudit } from "@/lib/audit";
|
|
7
7
|
import { listViews, MAX_VIEWS } from "@/lib/views";
|
|
8
8
|
import { prisma } from "@/lib/db";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { headers } from "next/headers";
|
|
2
2
|
import { notFound, redirect } from "next/navigation";
|
|
3
3
|
import { allowedActions, can, columnName, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
-
import { createResourceStore, prismaRows, type PrismaDelegate, type ResourceStore } from "
|
|
4
|
+
import { createResourceStore, prismaRows, type PrismaDelegate, type ResourceStore } from "@/lib/resource";
|
|
5
5
|
import { prisma } from "@/lib/db";
|
|
6
6
|
import { policies } from "@/policies";
|
|
7
7
|
import { resourceTables } from "@/resources/server";
|
|
@@ -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
|
+
// This is the Next.js stack, so the row adapter is Prisma over Postgres. The
|
|
7
|
+
// Cloudflare stack has drizzle-rows.ts here instead. Both implement ResourceRows,
|
|
8
|
+
// which is the whole contract — about fifty lines in rows.ts, and the reason a
|
|
9
|
+
// descriptor doesn't care which database it is on.
|
|
10
|
+
export * from "./rows";
|
|
11
|
+
export * from "./query";
|
|
12
|
+
export * from "./store";
|
|
13
|
+
export * from "./handlers";
|
|
14
|
+
export * from "./prisma-rows";
|
|
@@ -0,0 +1,169 @@
|
|
|
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 Prisma and Postgres — the Next.js stack's half.
|
|
13
|
+
*
|
|
14
|
+
* The same nine operations the Drizzle adapter implements, expressed as Prisma queries.
|
|
15
|
+
* Everything a resource *means* — validation, hooks, computed values, the capped count,
|
|
16
|
+
* the wording of a unique-constraint error — stays in the store and is shared, so a
|
|
17
|
+
* descriptor behaves the same on either stack.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here imports `@prisma/client`: a generated client is app-specific and this
|
|
20
|
+
* package can't depend on one. The delegate is described structurally instead, which is
|
|
21
|
+
* also what makes it easy to test without a database.
|
|
22
|
+
*/
|
|
23
|
+
import type { ConstraintHit, ResourceRows, Row, RowsQuery } from "./rows";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The part of a Prisma model delegate a resource store uses.
|
|
27
|
+
*
|
|
28
|
+
* The arguments are loose on purpose. Prisma generates a precise argument type per
|
|
29
|
+
* model — `Prisma.ProductFindManyArgs` and so on — and describing them here would mean
|
|
30
|
+
* reproducing Prisma's generics for a client this package can't import. The queries are
|
|
31
|
+
* all built in this file, so there is exactly one place to get them right.
|
|
32
|
+
*/
|
|
33
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
34
|
+
export interface PrismaDelegate {
|
|
35
|
+
findMany(args: any): Promise<any[]>;
|
|
36
|
+
findUnique(args: any): Promise<any>;
|
|
37
|
+
count(args: any): Promise<number>;
|
|
38
|
+
create(args: any): Promise<any>;
|
|
39
|
+
update(args: any): Promise<any>;
|
|
40
|
+
delete(args: any): Promise<any>;
|
|
41
|
+
}
|
|
42
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
43
|
+
|
|
44
|
+
/** Prisma's "no record matched" — an expected outcome here, not an error. */
|
|
45
|
+
const NOT_FOUND = "P2025";
|
|
46
|
+
|
|
47
|
+
const codeOf = (error: unknown): string | undefined =>
|
|
48
|
+
typeof error === "object" && error !== null && "code" in error ? String((error as { code: unknown }).code) : undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which column a unique violation was about.
|
|
52
|
+
*
|
|
53
|
+
* Prisma reports this two different ways. Without a driver adapter it is `meta.target`,
|
|
54
|
+
* a list of field names. With one — which Prisma 7 requires for SQL databases — the
|
|
55
|
+
* driver's own error is passed through instead, and the only clue is the name of the
|
|
56
|
+
* index Postgres rejected: `products_sku_key`, which is `<table>_<column>_key`.
|
|
57
|
+
*
|
|
58
|
+
* Getting this right is what puts "Sku is already taken" under the SKU field instead of
|
|
59
|
+
* a bare "Value is already taken" at the top of the form.
|
|
60
|
+
*/
|
|
61
|
+
function uniqueColumn(error: unknown): string | undefined {
|
|
62
|
+
const meta = (error as { meta?: Record<string, unknown> }).meta;
|
|
63
|
+
if (!meta) return undefined;
|
|
64
|
+
|
|
65
|
+
const target = meta.target;
|
|
66
|
+
if (Array.isArray(target) && target.length > 0) return String(target[0]);
|
|
67
|
+
if (typeof target === "string") return fromIndexName(target);
|
|
68
|
+
|
|
69
|
+
const cause = (meta.driverAdapterError as { cause?: { constraint?: { index?: string; fields?: string[] } } } | undefined)?.cause;
|
|
70
|
+
const constraint = cause?.constraint;
|
|
71
|
+
if (constraint?.fields?.length) return constraint.fields[0];
|
|
72
|
+
if (constraint?.index) return fromIndexName(constraint.index);
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `products_sku_key` → `sku`; anything else is handed back unchanged. */
|
|
77
|
+
function fromIndexName(name: string): string {
|
|
78
|
+
const match = /^.*?_(.+)_key$/.exec(name);
|
|
79
|
+
return match ? match[1]! : name;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceRows {
|
|
83
|
+
/** The search and filter half of a where clause — everything but the cursor. */
|
|
84
|
+
function matching(query: Pick<RowsQuery, "search" | "filters">): Record<string, unknown> {
|
|
85
|
+
const where: Record<string, unknown> = { ...query.filters };
|
|
86
|
+
if (query.search && query.search.fields.length > 0) {
|
|
87
|
+
// Insensitive on purpose: SQLite's LIKE is case-insensitive for ASCII, so this is
|
|
88
|
+
// what keeps search behaving the same on both stacks.
|
|
89
|
+
where.OR = query.search.fields.map((field) => ({ [field]: { contains: query.search!.term, mode: "insensitive" } }));
|
|
90
|
+
}
|
|
91
|
+
return where;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
db: client,
|
|
96
|
+
|
|
97
|
+
async find(query) {
|
|
98
|
+
const where = matching(query);
|
|
99
|
+
if (query.cursor) {
|
|
100
|
+
const { field, value, id, greaterThan } = query.cursor;
|
|
101
|
+
const op = greaterThan ? "gt" : "lt";
|
|
102
|
+
// (sort, id) as one comparison, so rows sharing a sort value are neither skipped
|
|
103
|
+
// nor repeated. AND-ed with the filters rather than replacing them.
|
|
104
|
+
const keyset = [{ [field]: { [op]: value } }, { AND: [{ [field]: value }, { id: { [op]: id } }] }];
|
|
105
|
+
where.AND = [...((where.AND as unknown[]) ?? []), { OR: keyset }];
|
|
106
|
+
}
|
|
107
|
+
return delegate.findMany({
|
|
108
|
+
where,
|
|
109
|
+
orderBy: [{ [query.sort.field]: query.sort.direction }, { id: query.sort.direction }],
|
|
110
|
+
take: query.limit,
|
|
111
|
+
...(query.offset ? { skip: query.offset } : {}),
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
countUpTo(query, limit) {
|
|
116
|
+
// Prisma's count takes `take`, so a huge table is never counted in full.
|
|
117
|
+
return delegate.count({ where: matching(query), take: limit });
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
byId(id) {
|
|
121
|
+
return delegate.findUnique({ where: { id } });
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async titles(ids, titleField) {
|
|
125
|
+
const rows = await delegate.findMany({ where: { id: { in: ids } }, select: { id: true, [titleField]: true } });
|
|
126
|
+
return rows.map((row) => ({ id: String(row.id), title: row[titleField] }));
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
insert(values) {
|
|
130
|
+
return delegate.create({ data: values });
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async update(id, values) {
|
|
134
|
+
try {
|
|
135
|
+
return await delegate.update({ where: { id }, data: values });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (codeOf(error) === NOT_FOUND) return null;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async remove(id) {
|
|
143
|
+
try {
|
|
144
|
+
await delegate.delete({ where: { id } });
|
|
145
|
+
return true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (codeOf(error) === NOT_FOUND) return false;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
constraint(error): ConstraintHit | undefined {
|
|
153
|
+
switch (codeOf(error)) {
|
|
154
|
+
case "P2002":
|
|
155
|
+
return { kind: "unique", column: uniqueColumn(error) };
|
|
156
|
+
case "P2003":
|
|
157
|
+
case "P2014":
|
|
158
|
+
return { kind: "foreignKey" };
|
|
159
|
+
// A value outside its type, too long for its column, or null where it can't be.
|
|
160
|
+
case "P2000":
|
|
161
|
+
case "P2011":
|
|
162
|
+
case "P2020":
|
|
163
|
+
return { kind: "check" };
|
|
164
|
+
default:
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|