@flaredev/cli 0.8.0 → 0.9.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.
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 +45 -5
  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 +29 -3
  25. package/templates/app/lib/resource/rows.ts +11 -1
  26. package/templates/app/lib/resource/store.ts +178 -15
  27. package/templates/locks/package-lock.json +266 -260
  28. package/templates/locks/pnpm-lock.yaml +328 -322
  29. package/templates/locks/yarn.lock +285 -285
  30. package/templates/next/app/dashboard/actions.ts +45 -5
  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
@@ -41,20 +41,58 @@ async function removeUploads(resource: Resource, record: Record<string, unknown>
41
41
  }
42
42
  }
43
43
 
44
- export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
44
+ /**
45
+ * Delete a record, or empty it from the trash with `force`.
46
+ *
47
+ * Uploads are only cleaned up when the row really goes. A soft delete is meant to be
48
+ * reversible, and a restore that gave back a record with its files missing would be a
49
+ * worse outcome than keeping the bytes a little longer.
50
+ */
51
+ export async function deleteRecordAction(
52
+ resourceName: string,
53
+ id: string,
54
+ options: { force?: boolean } = {},
55
+ ): Promise<ActionResult<{ id: string }>> {
45
56
  const denied = await allowed(resourceName, "delete");
46
57
  if (denied) return denied;
47
58
 
48
59
  const store = dashboardStore(resourceName);
49
- const existing = await store.get(id);
50
- const result = await store.delete(id);
60
+ const force = options.force === true;
61
+ const soft = store.resource.softDelete && !force;
62
+ // `deleted: "all"` so emptying the trash can read the row it is about to remove.
63
+ const existing = await store.get(id, { deleted: "all" });
64
+ const result = await store.delete(id, { force });
51
65
  if (result.ok) {
52
- await removeUploads(store.resource, existing.ok ? existing.data : null);
66
+ if (!soft) await removeUploads(store.resource, existing.ok ? existing.data : null);
53
67
  await recordAudit({
54
68
  action: "delete",
55
69
  resource: resourceName,
56
70
  recordId: id,
57
71
  recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
72
+ // Which kind of delete this was, since on a softDelete resource they are different
73
+ // events with the same name.
74
+ changes: store.resource.softDelete ? { trash: soft ? "moved to" : "emptied from" } : null,
75
+ });
76
+ revalidatePath(resourcePath(store.resource));
77
+ }
78
+ return result;
79
+ }
80
+
81
+ /** Put a record back from the trash. Only a `softDelete` resource has anything to restore. */
82
+ export async function restoreRecordAction(resourceName: string, id: string): Promise<ActionResult<Record<string, unknown>>> {
83
+ // Restoring is a change to the record, so it needs the permission that changes records.
84
+ const denied = await allowed(resourceName, "update");
85
+ if (denied) return denied;
86
+
87
+ const store = dashboardStore(resourceName);
88
+ const result = await store.restore(id);
89
+ if (result.ok) {
90
+ await recordAudit({
91
+ action: "update",
92
+ resource: resourceName,
93
+ recordId: id,
94
+ recordLabel: recordTitle(store.resource, result.data),
95
+ changes: { trash: "restored from" },
58
96
  });
59
97
  revalidatePath(resourcePath(store.resource));
60
98
  }
@@ -120,7 +158,9 @@ export async function exportRecordsAction(resourceName: string, query: string):
120
158
  const params = new URLSearchParams(query);
121
159
  params.set("page", String(page));
122
160
  params.set("perPage", String(EXPORT_PAGE));
123
- const result = await store.list(params);
161
+ // A bigger page than a request from outside may ask for: this is the server
162
+ // paging through everything on purpose, and 500 round trips would be absurd.
163
+ const result = await store.list(params, { maxPerPage: EXPORT_PAGE });
124
164
  if (!result.ok) return result;
125
165
  rows.push(...result.data.data);
126
166
  if (page >= result.data.meta.totalPages) break;
@@ -1,6 +1,6 @@
1
1
  import { headers } from "next/headers";
2
2
  import { notFound, redirect } from "next/navigation";
3
- import { allowedActions, can, columnName, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
3
+ import { allowedActions, can, columnName, ownershipFilter, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
4
  import { createResourceStore, prismaRows, type PrismaDelegate, type ResourceStore } from "@/lib/resource";
5
5
  import { prisma } from "@/lib/db";
6
6
  import { policies } from "@/policies";
@@ -59,6 +59,17 @@ export async function adminPermissions(resourceName: string) {
59
59
  return allowedActions(policyFor(resourceName), role);
60
60
  }
61
61
 
62
+ /**
63
+ * The field the current user is confined by on this resource, or undefined.
64
+ *
65
+ * Forms leave it out: the store sets it from the session, so an input for it would throw
66
+ * away whatever was typed into it. Everyone exempt from `own` sees and edits it normally,
67
+ * because for them it really is just a field.
68
+ */
69
+ export async function confinedField(resourceName: string): Promise<string | undefined> {
70
+ return ownershipFilter(policyFor(resourceName), await currentUser())?.field;
71
+ }
72
+
62
73
  const stores = new Map<string, ResourceStore>();
63
74
 
64
75
  /** CRUD store for a resource by name (404 for unknown names). */
@@ -70,6 +81,10 @@ export function dashboardStore(name: string): ResourceStore {
70
81
  store = createResourceStore({
71
82
  resource: entry.resource,
72
83
  rows: prismaRows(entry.delegate, prisma),
84
+ // The admin is not exempt from ownership. A policy that confines staff to their own
85
+ // rows has to mean the same thing here as it does over the API, or the dashboard is
86
+ // a way around it.
87
+ policy: policyFor(name),
73
88
  onChange: revalidateResource,
74
89
  currentUser,
75
90
  });
@@ -98,9 +113,16 @@ export interface ResourceStats {
98
113
  values: Record<string, number>;
99
114
  }
100
115
 
101
- export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
116
+ export async function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
117
+ // Counted over the rows this user may see, not the table. A policy with `own` that the
118
+ // table below obeys and the cards above it don't would report 142 invoices to someone
119
+ // who owns three — wrong, and a leak besides. The owner is an argument rather than a
120
+ // closed-over value because unstable_cache keys on the arguments: without it one user's
121
+ // numbers would be served to the next.
122
+ const limit = ownershipFilter(policyFor(name), await currentUser());
123
+
102
124
  const read = cached(
103
- async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
125
+ async (resourceName: string, key: string | undefined, windowDays: number, owner?: { field: string; value: string }): Promise<ResourceStats> => {
104
126
  const entry = (resourceTables as unknown as Record<string, { resource: Resource } | undefined>)[resourceName];
105
127
  if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
106
128
 
@@ -116,9 +138,18 @@ export function resourceStats(name: string, field?: string, days = 7): Promise<R
116
138
  `count(*) filter (where "created_at" >= '${before}' and "created_at" < '${since}')::int as previous`,
117
139
  ...(grouped ? [`${grouped} as value`] : []),
118
140
  ].join(", ");
119
- const query = `select ${select} from ${table}${grouped ? ` group by ${grouped}` : ""}`;
120
-
121
- const rows = (await prisma.$queryRawUnsafe(query)) as { total: number; current: number; previous: number; value?: unknown }[];
141
+ // Parameterised, not interpolated. The id comes from a session rather than a URL,
142
+ // but raw SQL is the one place that distinction should never be relied on.
143
+ const ownerColumn = owner && entry.resource.fields[owner.field] ? `"${columnName(owner.field)}"` : undefined;
144
+ const where = ownerColumn ? ` where ${ownerColumn} = $1` : "";
145
+ const query = `select ${select} from ${table}${where}${grouped ? ` group by ${grouped}` : ""}`;
146
+
147
+ const rows = (await (ownerColumn ? prisma.$queryRawUnsafe(query, owner!.value) : prisma.$queryRawUnsafe(query))) as {
148
+ total: number;
149
+ current: number;
150
+ previous: number;
151
+ value?: unknown;
152
+ }[];
122
153
  const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
123
154
  for (const row of rows) {
124
155
  stats.total += Number(row.total ?? 0);
@@ -131,7 +162,7 @@ export function resourceStats(name: string, field?: string, days = 7): Promise<R
131
162
  ["flare", "resource-stats"],
132
163
  { tags: [resourceTag(name)], revalidate: TTL.medium },
133
164
  );
134
- return read(name, field, days);
165
+ return read(name, field, days, limit ?? undefined);
135
166
  }
136
167
 
137
168
  /** How many records a resource has. One field of {@link resourceStats}. */
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * The Cloudflare stack gives every channel a Durable Object: one address, one thread,
5
5
  * its own storage, and websockets that stay open. Vercel has no equivalent — functions
6
- * don't hold connections — so rather than pretend, this says so, and publishing is a
7
- * no-op that never breaks a write.
6
+ * don't hold connections — so rather than pretend, publishing warns once per channel
7
+ * and drops the event, which never breaks the write that triggered it.
8
8
  *
9
9
  * If you need it here, the shape to reach for is a hosted pub/sub (Ably, Pusher,
10
10
  * Upstash) behind these same two functions.
@@ -17,7 +17,30 @@ export interface RealtimeChannel {
17
17
  publish(event: string, data: unknown): Promise<void>;
18
18
  }
19
19
 
20
- /** A channel that accepts publishes and drops them, so callers need no special case. */
21
- export function realtimeChannel(_name: string): RealtimeChannel {
22
- return { publish: async () => undefined };
20
+ /**
21
+ * Channels this process has already warned about, so a publish in a hot path logs once
22
+ * rather than on every write.
23
+ */
24
+ const warned = new Set<string>();
25
+
26
+ /**
27
+ * A channel that accepts publishes and drops them, so callers need no special case.
28
+ *
29
+ * It says so the first time, once per channel. A silent no-op is the worst version of
30
+ * this: the code reads as though it works, nothing arrives, and there is nothing to
31
+ * search for.
32
+ */
33
+ export function realtimeChannel(name: string): RealtimeChannel {
34
+ return {
35
+ publish: async (event) => {
36
+ if (warned.has(name)) return;
37
+ warned.add(name);
38
+ console.warn(
39
+ `[flare] realtimeChannel("${name}").publish("${event}") did nothing: this app is on the Next.js stack, ` +
40
+ "where there is nowhere to hold a websocket open. The write itself succeeded. " +
41
+ "To deliver events, put a hosted pub/sub (Ably, Pusher, Upstash) behind lib/realtime.ts — " +
42
+ "both functions in that file are yours to change.",
43
+ );
44
+ },
45
+ };
23
46
  }
@@ -81,8 +81,12 @@ function fromIndexName(name: string): string {
81
81
 
82
82
  export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceRows {
83
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> {
84
+ function matching(query: Pick<RowsQuery, "search" | "filters" | "deleted">): Record<string, unknown> {
85
85
  const where: Record<string, unknown> = { ...query.filters };
86
+ // A soft-deleting resource hides its deleted rows. Set on the query rather than in
87
+ // `filters` because "only" is `not: null`, which the filter shape cannot say.
88
+ if (query.deleted === "exclude") where.deletedAt = null;
89
+ else if (query.deleted === "only") where.deletedAt = { not: null };
86
90
  if (query.search && query.search.fields.length > 0) {
87
91
  // Insensitive on purpose: SQLite's LIKE is case-insensitive for ASCII, so this is
88
92
  // what keeps search behaving the same on both stacks.