@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.
Files changed (33) hide show
  1. package/dist/index.js +2488 -1796
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +42 -4
  4. package/templates/app/components/dashboard/fields/field-widget.tsx +34 -2
  5. package/templates/app/components/dashboard/fields/json-field.tsx +90 -0
  6. package/templates/app/components/dashboard/fields/markdown-field.tsx +82 -0
  7. package/templates/app/components/dashboard/fields/markdown.tsx +159 -0
  8. package/templates/app/components/dashboard/fields/tags-field.tsx +100 -0
  9. package/templates/app/components/dashboard/import-dialog.tsx +19 -0
  10. package/templates/app/components/dashboard/record-actions.tsx +4 -1
  11. package/templates/app/components/dashboard/record-detail.tsx +18 -0
  12. package/templates/app/components/dashboard/resource-form-page.tsx +5 -1
  13. package/templates/app/components/dashboard/resource-form-sheet.tsx +16 -2
  14. package/templates/app/components/dashboard/resource-form.tsx +11 -2
  15. package/templates/app/components/dashboard/resource-table.tsx +21 -2
  16. package/templates/app/components/dashboard/row-actions.tsx +42 -8
  17. package/templates/app/components/dashboard/trash-toggle.tsx +30 -0
  18. package/templates/app/lib/api-keys.ts +61 -0
  19. package/templates/app/lib/api.ts +35 -9
  20. package/templates/app/lib/dashboard.ts +36 -7
  21. package/templates/app/lib/realtime.ts +5 -0
  22. package/templates/app/lib/resource/drizzle-rows.ts +9 -2
  23. package/templates/app/lib/resource/http.ts +19 -3
  24. package/templates/app/lib/resource/query.ts +15 -1
  25. package/templates/app/lib/resource/rows.ts +11 -1
  26. package/templates/app/lib/resource/store.ts +176 -13
  27. package/templates/locks/package-lock.json +269 -263
  28. package/templates/locks/pnpm-lock.yaml +328 -322
  29. package/templates/locks/yarn.lock +288 -288
  30. package/templates/next/app/dashboard/actions.ts +42 -4
  31. package/templates/next/lib/dashboard.ts +38 -7
  32. package/templates/next/lib/realtime.ts +28 -5
  33. 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
- for (const key of ["id", "createdAt", "updatedAt", ...fields.map(([key]) => key)]) {
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, filters, cursor } = parsed.query;
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
- const matching = { search: q ? { term: q, fields: searchable } : undefined, filters };
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
- return record ? { ok: true, data: withComputed(record) } : notFound();
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
- /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
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
- const result = validators.create.safeParse(supplied);
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 current = hooks.beforeUpdate || hooks.afterUpdate ? await rows.byId(id) : null;
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
- delete(id: string): Promise<Result<{ id: string }>> {
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: await rows.byId(id) });
477
+ await hooks.beforeDelete({ ...context, id, current });
345
478
  }
346
- if (!(await rows.remove(id))) return notFound();
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