@flaredev/cli 0.8.1 → 0.9.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/dist/index.js +2488 -1796
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +42 -4
- package/templates/app/components/dashboard/fields/field-widget.tsx +34 -2
- package/templates/app/components/dashboard/fields/json-field.tsx +90 -0
- package/templates/app/components/dashboard/fields/markdown-field.tsx +82 -0
- package/templates/app/components/dashboard/fields/markdown.tsx +159 -0
- package/templates/app/components/dashboard/fields/tags-field.tsx +100 -0
- package/templates/app/components/dashboard/import-dialog.tsx +19 -0
- package/templates/app/components/dashboard/record-actions.tsx +4 -1
- package/templates/app/components/dashboard/record-detail.tsx +18 -0
- package/templates/app/components/dashboard/resource-form-page.tsx +5 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +16 -2
- package/templates/app/components/dashboard/resource-form.tsx +11 -2
- package/templates/app/components/dashboard/resource-table.tsx +21 -2
- package/templates/app/components/dashboard/row-actions.tsx +42 -8
- package/templates/app/components/dashboard/trash-toggle.tsx +30 -0
- package/templates/app/lib/api-keys.ts +61 -0
- package/templates/app/lib/api.ts +35 -9
- package/templates/app/lib/dashboard.ts +36 -7
- package/templates/app/lib/realtime.ts +5 -0
- package/templates/app/lib/resource/drizzle-rows.ts +9 -2
- package/templates/app/lib/resource/http.ts +19 -3
- package/templates/app/lib/resource/query.ts +15 -1
- package/templates/app/lib/resource/rows.ts +11 -1
- package/templates/app/lib/resource/store.ts +176 -13
- package/templates/locks/package-lock.json +269 -263
- package/templates/locks/pnpm-lock.yaml +328 -322
- package/templates/locks/yarn.lock +288 -288
- package/templates/next/app/dashboard/actions.ts +42 -4
- package/templates/next/lib/dashboard.ts +38 -7
- package/templates/next/lib/realtime.ts +28 -5
- package/templates/next/lib/resource/prisma-rows.ts +5 -1
|
@@ -30,6 +30,16 @@ export interface RowsQuery {
|
|
|
30
30
|
search?: { term: string; fields: string[] };
|
|
31
31
|
/** field → value, or null for "is null". */
|
|
32
32
|
filters: Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Which rows a soft-deleting resource should return.
|
|
35
|
+
*
|
|
36
|
+
* `"exclude"` — the default everywhere except the Trash view — means `deleted_at is
|
|
37
|
+
* null`. `filters` cannot express the other two: it says "equals this value, or is
|
|
38
|
+
* null", and `"only"` needs `is not null`.
|
|
39
|
+
*
|
|
40
|
+
* Absent on a resource without `softDelete`, where there is no column to compare.
|
|
41
|
+
*/
|
|
42
|
+
deleted?: "exclude" | "only" | "all";
|
|
33
43
|
sort: { field: string; direction: "asc" | "desc" };
|
|
34
44
|
/**
|
|
35
45
|
* Keyset pagination: everything after (or before) this row, compared on
|
|
@@ -66,7 +76,7 @@ export interface ResourceRows {
|
|
|
66
76
|
readonly db: unknown;
|
|
67
77
|
find(query: RowsQuery): Promise<Row[]>;
|
|
68
78
|
/** How many rows match, giving up at `limit` so a huge table is never fully counted. */
|
|
69
|
-
countUpTo(query: Pick<RowsQuery, "search" | "filters">, limit: number): Promise<number>;
|
|
79
|
+
countUpTo(query: Pick<RowsQuery, "search" | "filters" | "deleted">, limit: number): Promise<number>;
|
|
70
80
|
byId(id: string): Promise<Row | null>;
|
|
71
81
|
titles(ids: string[], titleField: string): Promise<{ id: string; title: unknown }[]>;
|
|
72
82
|
insert(values: Row): Promise<Row>;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
9
|
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
10
|
|
|
11
|
-
import { columnName, storedFields, type Resource } from "@flaredev/core";
|
|
11
|
+
import { columnName, ownershipFilter, storedFields, type Policy, type Resource } from "@flaredev/core";
|
|
12
12
|
import { createValidators } from "@flaredev/core";
|
|
13
13
|
import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
|
|
14
14
|
import type { ResourceRows, Row } from "./rows";
|
|
@@ -24,7 +24,7 @@ export interface FieldIssue {
|
|
|
24
24
|
|
|
25
25
|
export type Failure = {
|
|
26
26
|
ok: false;
|
|
27
|
-
status: 400 | 404 | 409 | 422;
|
|
27
|
+
status: 400 | 403 | 404 | 409 | 422;
|
|
28
28
|
error: string;
|
|
29
29
|
/** Validation issues per field. */
|
|
30
30
|
issues?: FieldIssue[];
|
|
@@ -93,6 +93,17 @@ export interface ResourceStoreOptions {
|
|
|
93
93
|
* site, and this file never has to know which stack it is on.
|
|
94
94
|
*/
|
|
95
95
|
rows: ResourceRows;
|
|
96
|
+
/**
|
|
97
|
+
* The resource's policy, when it has one.
|
|
98
|
+
*
|
|
99
|
+
* Only `own` is read here — which rows this user may touch. Whether their role may
|
|
100
|
+
* perform the action at all is decided before the store is reached, by `authorize` on
|
|
101
|
+
* the API and by the dashboard's own check.
|
|
102
|
+
*
|
|
103
|
+
* Ownership applies only when `currentUser` is also given. A store built without one
|
|
104
|
+
* has no session to scope to, which is how a seed or a script writes rows for anybody.
|
|
105
|
+
*/
|
|
106
|
+
policy?: Policy | null;
|
|
96
107
|
/**
|
|
97
108
|
* Called after a write succeeds, for cache invalidation. Runs before the
|
|
98
109
|
* operation returns, so a caller that revalidates tags can't hand back a
|
|
@@ -117,7 +128,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
117
128
|
// can't be asked the same question without a round trip, so that check stays here.
|
|
118
129
|
const columns = "columns" in rows ? (rows.columns as Record<string, { name: string }>) : undefined;
|
|
119
130
|
if (columns) {
|
|
120
|
-
|
|
131
|
+
const expected = ["id", "createdAt", "updatedAt", ...(resource.softDelete ? ["deletedAt"] : []), ...fields.map(([key]) => key)];
|
|
132
|
+
for (const key of expected) {
|
|
121
133
|
if (!columns[key]) {
|
|
122
134
|
throw new Error(`Resource "${resource.name}": table has no column for "${key}". Run \`flare sync-types\` and create a migration.`);
|
|
123
135
|
}
|
|
@@ -202,6 +214,45 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
202
214
|
const hooks = resource.hooks ?? {};
|
|
203
215
|
const hookContext = async () => ({ db: rows.db, user: (await options.currentUser?.()) ?? null });
|
|
204
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Per-record ownership, from the policy.
|
|
219
|
+
*
|
|
220
|
+
* Caught here rather than at the first request: a policy naming a field the resource
|
|
221
|
+
* doesn't have would otherwise scope every query to a column that doesn't exist, which
|
|
222
|
+
* either errors deep in the adapter or — worse on a flexible store — matches nothing
|
|
223
|
+
* and looks like an empty table.
|
|
224
|
+
*/
|
|
225
|
+
const ownership = options.policy?.own;
|
|
226
|
+
if (ownership && !fields.some(([key]) => key === ownership.field)) {
|
|
227
|
+
const known = fields.map(([key]) => key).join(", ");
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Policy ${resource.name}: own.field is "${ownership.field}", which is not a field on ${resource.name}. Fields: ${known}.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The owner this call is confined to: `null` to touch every row, or a Failure.
|
|
235
|
+
*
|
|
236
|
+
* Only when a `currentUser` was given — see the option. A policy that restricts rows
|
|
237
|
+
* and a request with no session is a 403, not an unrestricted query: the API refuses
|
|
238
|
+
* that earlier, but the dashboard and server actions come through here too, and a
|
|
239
|
+
* missing session must never widen what a query returns.
|
|
240
|
+
*/
|
|
241
|
+
async function confine(): Promise<{ field: string; value: string } | Failure | null> {
|
|
242
|
+
if (!ownership || !options.currentUser) return null;
|
|
243
|
+
const user = await options.currentUser();
|
|
244
|
+
if (!user) return fail(403, "Sign in required.");
|
|
245
|
+
return ownershipFilter(options.policy, user);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Whether `row` belongs to the confined owner. */
|
|
249
|
+
const owns = (row: Row, limit: { field: string; value: string }) => row[limit.field] === limit.value;
|
|
250
|
+
|
|
251
|
+
const isFailure = (value: unknown): value is Failure => value !== null && typeof value === "object" && "ok" in value;
|
|
252
|
+
|
|
253
|
+
/** Whether a row has been soft-deleted. Always false on a resource that removes rows. */
|
|
254
|
+
const isDeleted = (row: Row) => resource.softDelete && row.deletedAt != null;
|
|
255
|
+
|
|
205
256
|
return {
|
|
206
257
|
resource,
|
|
207
258
|
|
|
@@ -209,7 +260,12 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
209
260
|
async list(params: URLSearchParams, options: { maxPerPage?: number } = {}): Promise<Result<ListResult>> {
|
|
210
261
|
const parsed = parseListQuery(resource, params, options);
|
|
211
262
|
if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
|
|
212
|
-
const { page, perPage, sort, q,
|
|
263
|
+
const { page, perPage, sort, q, cursor } = parsed.query;
|
|
264
|
+
|
|
265
|
+
const limit = await confine();
|
|
266
|
+
if (isFailure(limit)) return limit;
|
|
267
|
+
// Last, so `?filter[userId]=someone-else` cannot widen the query past the owner.
|
|
268
|
+
const filters = limit ? { ...parsed.query.filters, [limit.field]: limit.value } : parsed.query.filters;
|
|
213
269
|
|
|
214
270
|
/**
|
|
215
271
|
* Whether a sort column holds a date, so the adapter can shape the cursor for it.
|
|
@@ -222,7 +278,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
222
278
|
};
|
|
223
279
|
|
|
224
280
|
const searchable = fields.filter(([, def]) => isSearchable(def)).map(([key]) => key);
|
|
225
|
-
|
|
281
|
+
// A resource that keeps deleted rows hides them unless this list asked for them.
|
|
282
|
+
// Absent otherwise, so an adapter has nothing to compare on a table with no column.
|
|
283
|
+
const deleted = resource.softDelete ? (parsed.query.deleted ?? "exclude") : undefined;
|
|
284
|
+
const matching = { search: q ? { term: q, fields: searchable } : undefined, filters, deleted };
|
|
226
285
|
|
|
227
286
|
// Reading backwards from a "previous page" cursor means flipping the order and
|
|
228
287
|
// flipping the rows back afterwards.
|
|
@@ -275,12 +334,28 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
275
334
|
};
|
|
276
335
|
},
|
|
277
336
|
|
|
278
|
-
async get(id: string): Promise<Result<Record<string, unknown>>> {
|
|
337
|
+
async get(id: string, options: { deleted?: "exclude" | "only" | "all" } = {}): Promise<Result<Record<string, unknown>>> {
|
|
338
|
+
const limit = await confine();
|
|
339
|
+
if (isFailure(limit)) return limit;
|
|
279
340
|
const record = await rows.byId(id);
|
|
280
|
-
|
|
341
|
+
if (!record) return notFound();
|
|
342
|
+
// Not 403: a user who may not see this record should not learn that it exists.
|
|
343
|
+
if (limit && !owns(record, limit)) return notFound();
|
|
344
|
+
// `rows.byId` takes an id and nothing else, so hiding a deleted row happens here.
|
|
345
|
+
// `options.deleted` is how the Trash view and `restore` reach one.
|
|
346
|
+
if (isDeleted(record) && options.deleted !== "only" && options.deleted !== "all") return notFound();
|
|
347
|
+
return { ok: true, data: withComputed(record) };
|
|
281
348
|
},
|
|
282
349
|
|
|
283
|
-
/**
|
|
350
|
+
/**
|
|
351
|
+
* Title-field values for a set of ids (for showing relations). Missing ids are omitted.
|
|
352
|
+
*
|
|
353
|
+
* Not confined by ownership, and `rows.titles` has no way to be: it returns an id and
|
|
354
|
+
* a title, not the owner. It is called with ids this resource's rows already point at,
|
|
355
|
+
* so in practice it shows the names of records you can already see — but if you hand a
|
|
356
|
+
* confined user a relation picker over a confined resource, the titles are not the
|
|
357
|
+
* place that restriction is enforced. Filter the ids before calling it.
|
|
358
|
+
*/
|
|
284
359
|
async titles(ids: string[]): Promise<Record<string, string>> {
|
|
285
360
|
const unique = [...new Set(ids.filter(Boolean))];
|
|
286
361
|
if (unique.length === 0) return {};
|
|
@@ -294,7 +369,15 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
294
369
|
// The hook runs first so it can fill in what the caller couldn't know — an order
|
|
295
370
|
// number, a slug, a tenant — and whatever it returns is validated like any input.
|
|
296
371
|
const supplied = hooks.beforeCreate ? await hooks.beforeCreate({ ...((raw ?? {}) as object) }, context) : raw;
|
|
297
|
-
|
|
372
|
+
|
|
373
|
+
const limit = await confine();
|
|
374
|
+
if (isFailure(limit)) return limit;
|
|
375
|
+
// Before validation, not after: the owner field is usually required, and the
|
|
376
|
+
// client has no business sending it. Set here, a caller cannot create a row in
|
|
377
|
+
// someone else's name, and cannot fail validation for omitting what it can't know.
|
|
378
|
+
const body = limit ? { ...((supplied ?? {}) as object), [limit.field]: limit.value } : supplied;
|
|
379
|
+
|
|
380
|
+
const result = validators.create.safeParse(body);
|
|
298
381
|
if (!result.success) return invalid(result.error.issues);
|
|
299
382
|
const input = result.data as Record<string, unknown>;
|
|
300
383
|
const now = new Date();
|
|
@@ -309,8 +392,32 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
309
392
|
update(id: string, raw: unknown): Promise<Result<Record<string, unknown>>> {
|
|
310
393
|
return writing("update", async () => {
|
|
311
394
|
const context = await hookContext();
|
|
312
|
-
const
|
|
395
|
+
const limit = await confine();
|
|
396
|
+
if (isFailure(limit)) return limit;
|
|
397
|
+
|
|
398
|
+
// A confined update has to read the row first: `rows.update(id, …)` takes an id
|
|
399
|
+
// and nothing else, so this is where "is it yours" is answered.
|
|
400
|
+
const current = limit || hooks.beforeUpdate || hooks.afterUpdate ? await rows.byId(id) : null;
|
|
401
|
+
if (limit) {
|
|
402
|
+
if (!current) return notFound();
|
|
403
|
+
if (!owns(current, limit)) return notFound();
|
|
404
|
+
}
|
|
405
|
+
|
|
313
406
|
const supplied = hooks.beforeUpdate ? await hooks.beforeUpdate({ ...((raw ?? {}) as object) }, { ...context, id, current }) : raw;
|
|
407
|
+
|
|
408
|
+
// Reassigning the owner is refused rather than ignored. Giving a record away is
|
|
409
|
+
// a reasonable thing to want; doing it by accident, because the field was quietly
|
|
410
|
+
// dropped, is not.
|
|
411
|
+
if (limit && supplied && typeof supplied === "object" && limit.field in supplied) {
|
|
412
|
+
const sent = (supplied as Record<string, unknown>)[limit.field];
|
|
413
|
+
if (sent !== limit.value) {
|
|
414
|
+
const label = resource.fields[limit.field]?.label ?? limit.field;
|
|
415
|
+
return fail(422, `You can't change which user this ${resource.label.toLowerCase()} belongs to.`, {
|
|
416
|
+
issues: [{ path: limit.field, message: `${label} can't be changed.` }],
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
314
421
|
const result = validators.update.safeParse(supplied);
|
|
315
422
|
if (!result.success) return invalid(result.error.issues);
|
|
316
423
|
const input = result.data as Record<string, unknown>;
|
|
@@ -327,27 +434,83 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
327
434
|
const result = validators.create.safeParse(input);
|
|
328
435
|
if (!result.success) return Promise.resolve(invalid(result.error.issues));
|
|
329
436
|
return writing("update", async () => {
|
|
437
|
+
const limit = await confine();
|
|
438
|
+
if (isFailure(limit)) return limit;
|
|
439
|
+
if (limit) {
|
|
440
|
+
const current = await rows.byId(id);
|
|
441
|
+
if (!current || !owns(current, limit)) return notFound();
|
|
442
|
+
}
|
|
443
|
+
|
|
330
444
|
const values: Row = {};
|
|
331
445
|
for (const [key, def] of fields) {
|
|
332
446
|
values[key] = "default" in def && def.default !== undefined ? def.default : def.required ? undefined : null;
|
|
333
447
|
}
|
|
334
448
|
Object.assign(values, result.data, { updatedAt: new Date() });
|
|
449
|
+
// PUT resets every field the body left out. Without this the owner field would be
|
|
450
|
+
// one of them, and a replace would quietly orphan the row it just saved.
|
|
451
|
+
if (limit) values[limit.field] = limit.value;
|
|
452
|
+
|
|
335
453
|
const record = await rows.update(id, values);
|
|
336
454
|
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
337
455
|
});
|
|
338
456
|
},
|
|
339
457
|
|
|
340
|
-
|
|
458
|
+
/**
|
|
459
|
+
* Delete a record. On a `softDelete` resource this stamps `deletedAt` instead of
|
|
460
|
+
* removing the row, and `force` removes it for good.
|
|
461
|
+
*
|
|
462
|
+
* The hooks run either way and in the same order. `beforeDelete` is where a deletion
|
|
463
|
+
* is refused or cleaned up after, and that it happens to be reversible this time does
|
|
464
|
+
* not make it a different event.
|
|
465
|
+
*/
|
|
466
|
+
delete(id: string, options: { force?: boolean } = {}): Promise<Result<{ id: string }>> {
|
|
341
467
|
return writing("delete", async () => {
|
|
342
468
|
const context = await hookContext();
|
|
469
|
+
const limit = await confine();
|
|
470
|
+
if (isFailure(limit)) return limit;
|
|
471
|
+
|
|
472
|
+
const soft = resource.softDelete && options.force !== true;
|
|
473
|
+
const current = limit || soft || hooks.beforeDelete ? await rows.byId(id) : null;
|
|
474
|
+
if (limit && (!current || !owns(current, limit))) return notFound();
|
|
475
|
+
|
|
343
476
|
if (hooks.beforeDelete) {
|
|
344
|
-
await hooks.beforeDelete({ ...context, id, current
|
|
477
|
+
await hooks.beforeDelete({ ...context, id, current });
|
|
345
478
|
}
|
|
346
|
-
|
|
479
|
+
|
|
480
|
+
if (soft) {
|
|
481
|
+
if (!current) return notFound();
|
|
482
|
+
// Already in the trash: say not found rather than moving the date, so the
|
|
483
|
+
// original deletion time survives being deleted twice.
|
|
484
|
+
if (isDeleted(current)) return notFound();
|
|
485
|
+
if (!(await rows.update(id, { deletedAt: new Date(), updatedAt: new Date() }))) return notFound();
|
|
486
|
+
} else if (!(await rows.remove(id))) {
|
|
487
|
+
return notFound();
|
|
488
|
+
}
|
|
489
|
+
|
|
347
490
|
await hooks.afterDelete?.({ ...context, id });
|
|
348
491
|
return { ok: true, data: { id } };
|
|
349
492
|
});
|
|
350
493
|
},
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Put a soft-deleted record back. `404` for one that is not in the trash, and for a
|
|
497
|
+
* resource that does not keep deleted rows at all — there is nothing to restore.
|
|
498
|
+
*/
|
|
499
|
+
restore(id: string): Promise<Result<Record<string, unknown>>> {
|
|
500
|
+
return writing("update", async () => {
|
|
501
|
+
if (!resource.softDelete) return notFound();
|
|
502
|
+
const limit = await confine();
|
|
503
|
+
if (isFailure(limit)) return limit;
|
|
504
|
+
|
|
505
|
+
const current = await rows.byId(id);
|
|
506
|
+
if (!current) return notFound();
|
|
507
|
+
if (limit && !owns(current, limit)) return notFound();
|
|
508
|
+
if (!isDeleted(current)) return notFound();
|
|
509
|
+
|
|
510
|
+
const record = await rows.update(id, { deletedAt: null, updatedAt: new Date() });
|
|
511
|
+
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
512
|
+
});
|
|
513
|
+
},
|
|
351
514
|
};
|
|
352
515
|
}
|
|
353
516
|
|