@flaredev/cli 0.8.1 → 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.
- 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 +266 -260
- package/templates/locks/pnpm-lock.yaml +328 -322
- package/templates/locks/yarn.lock +285 -285
- 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
|
@@ -41,20 +41,58 @@ async function removeUploads(resource: Resource, record: Record<string, unknown>
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
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
|
|
50
|
-
const
|
|
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
|
}
|
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
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,
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
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.
|