@usequark/quark-create-app 1.25.0 → 1.25.2
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/package.json +1 -1
- package/src/index.js +4 -0
- package/templates/base-project/MAIN.md +2 -3
- package/templates/base-project/README.md +1 -1
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +75 -9
- package/templates/base-project/apps/web/src/app/api/health/route.js +19 -211
- package/templates/base-project/apps/web/src/app/layout.js +6 -1
- package/templates/base-project/packages/db/src/queries.js +17 -0
- package/templates/base-project/scripts/run-tests.mjs +42 -2
- package/templates/ui/src/form-field.integration-test.js +127 -9
- package/templates/ui/src/form-field.js +54 -14
- package/templates/ui/src/lightbox.integration-test.js +10 -5
- package/templates/ui/src/theme.js +19 -4
- package/templates/worker/src/handlers/files.js +9 -1
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1530,6 +1530,10 @@ STORAGE_PROVIDER=local
|
|
|
1530
1530
|
__QUARK_SCOPE__: scope,
|
|
1531
1531
|
__QUARK_PROJECT_NAME__: projectName,
|
|
1532
1532
|
__QUARK_SCAFFOLD_DATE__: scaffoldDate,
|
|
1533
|
+
// The web port is resolved in Step 9 (findAvailablePort), so the
|
|
1534
|
+
// docs can point at the port the app will actually bind rather
|
|
1535
|
+
// than always claiming 3000.
|
|
1536
|
+
__QUARK_WEB_PORT__: String(webPort),
|
|
1533
1537
|
__QUARK_OPTIONAL_APPS__: optionalAppLines,
|
|
1534
1538
|
__QUARK_OPTIONAL_PACKAGES__: optionalBlock,
|
|
1535
1539
|
__QUARK_FEATURE_ROWS__: featureRows,
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
1. `CLAUDE.md` - project rules and conventions (keep this updated)
|
|
8
8
|
2. `docs/` - guides and walkthroughs
|
|
9
|
-
3.
|
|
10
|
-
4. `.opencode/skills/` - embedded skills (how to build bookings, CRM, CMS, AI, and more on this project)
|
|
9
|
+
3. `.opencode/skills/` - embedded skills (how to build bookings, CRM, CMS, AI, and more on this project)
|
|
11
10
|
|
|
12
11
|
## Quick start
|
|
13
12
|
|
|
@@ -18,4 +17,4 @@ pnpm db:migrate # Apply migrations
|
|
|
18
17
|
pnpm dev # Start the app (and worker, if included)
|
|
19
18
|
```
|
|
20
19
|
|
|
21
|
-
Open [http://localhost:
|
|
20
|
+
Open [http://localhost:__QUARK_WEB_PORT__](http://localhost:__QUARK_WEB_PORT__).
|
|
@@ -19,7 +19,7 @@ pnpm db:seed # Seed the database (admin user + sample data)
|
|
|
19
19
|
pnpm dev # Start the app (and worker, if included)
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Open [http://localhost:
|
|
22
|
+
Open [http://localhost:__QUARK_WEB_PORT__](http://localhost:__QUARK_WEB_PORT__)
|
|
23
23
|
|
|
24
24
|
## UI References
|
|
25
25
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
ConflictError,
|
|
9
|
+
createLogger,
|
|
8
10
|
createStorage,
|
|
9
11
|
ForbiddenError,
|
|
10
12
|
NotFoundError,
|
|
@@ -15,16 +17,30 @@ import { NextResponse } from "next/server";
|
|
|
15
17
|
import { requireAuth } from "@/lib/auth-middleware";
|
|
16
18
|
import { handleError } from "../../error-handler";
|
|
17
19
|
|
|
20
|
+
const logger = createLogger("files");
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* GET /api/files/[id]
|
|
20
|
-
* Serve / download a file by its database ID.
|
|
21
|
-
* Public endpoint (no auth required) - access control is by knowledge of
|
|
24
|
+
* Serve / download a file by its database ID or its storage key.
|
|
25
|
+
* Public endpoint (no auth required) - access control is by knowledge of the
|
|
26
|
+
* identifier.
|
|
27
|
+
*
|
|
28
|
+
* Both identifier forms are accepted because `getAssetUrl()` in
|
|
29
|
+
* `@usequark/quark-core` builds `/api/files/<key>` from a storage key, and
|
|
30
|
+
* nothing in this app ever called it with anything else. Serving only by id
|
|
31
|
+
* made every URL that function produces a 404.
|
|
22
32
|
*/
|
|
23
33
|
export async function GET(_request, { params }) {
|
|
24
34
|
try {
|
|
25
35
|
const { id } = await params;
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
// The two forms are disjoint: a cuid never contains "/", and a storage key
|
|
38
|
+
// always does (`<prefix>/<year>/<month>/<id>-<name>`). An id lookup that
|
|
39
|
+
// misses is followed by a key lookup, so the id path costs no extra query.
|
|
40
|
+
let record = await file.findById(id);
|
|
41
|
+
if (!record && id.includes("/")) {
|
|
42
|
+
record = await file.findByStorageKey(id);
|
|
43
|
+
}
|
|
28
44
|
if (!record) {
|
|
29
45
|
throw new NotFoundError("File not found");
|
|
30
46
|
}
|
|
@@ -35,7 +51,13 @@ export async function GET(_request, { params }) {
|
|
|
35
51
|
const headers = new Headers();
|
|
36
52
|
headers.set("Content-Type", contentType || record.mimeType);
|
|
37
53
|
headers.set("Content-Length", String(body.length));
|
|
38
|
-
|
|
54
|
+
// Bounded, not `immutable`. The URL carries no version segment and nothing
|
|
55
|
+
// in the path is content-addressed, so `immutable` (never revalidate for a
|
|
56
|
+
// year) was a promise this route cannot keep: any code path that reuses an
|
|
57
|
+
// identifier — admin tooling, a seed, a restore from backup — would leave
|
|
58
|
+
// every browser and CDN holding the old bytes indefinitely. A bounded
|
|
59
|
+
// max-age bounds that window instead.
|
|
60
|
+
headers.set("Cache-Control", "public, max-age=3600");
|
|
39
61
|
|
|
40
62
|
// Inline display for images; attachment download for everything else
|
|
41
63
|
const isImage = record.mimeType.startsWith("image/");
|
|
@@ -57,6 +79,20 @@ export async function GET(_request, { params }) {
|
|
|
57
79
|
/**
|
|
58
80
|
* DELETE /api/files/[id]
|
|
59
81
|
* Delete a file. Only the uploader or an admin can delete.
|
|
82
|
+
*
|
|
83
|
+
* Order matters: the database row goes first, the storage object second.
|
|
84
|
+
*
|
|
85
|
+
* `File` has no incoming relations today, so nothing can make the row delete
|
|
86
|
+
* fail on a foreign key — but a future incoming relation with a restrictive
|
|
87
|
+
* `onDelete` will, and at that point this ordering is what keeps the storage
|
|
88
|
+
* object intact. The old order (storage, then row) destroyed the bytes first,
|
|
89
|
+
* so a refused row delete left a row pointing at a blob that no longer existed:
|
|
90
|
+
* unrecoverable, because the bytes are the only copy and they are already gone.
|
|
91
|
+
*
|
|
92
|
+
* `deleteIfPresent` also closes the concurrent-delete race. Two overlapping
|
|
93
|
+
* requests both pass the ownership check above; the row delete reports how many
|
|
94
|
+
* rows it removed, so only the request that actually removed the row goes on to
|
|
95
|
+
* delete the storage object. The loser returns 404 without touching storage.
|
|
60
96
|
*/
|
|
61
97
|
export const DELETE = withCsrfProtection(async (_request, { params }) => {
|
|
62
98
|
try {
|
|
@@ -75,12 +111,42 @@ export const DELETE = withCsrfProtection(async (_request, { params }) => {
|
|
|
75
111
|
throw new ForbiddenError("You can only delete your own files");
|
|
76
112
|
}
|
|
77
113
|
|
|
78
|
-
// Remove
|
|
79
|
-
|
|
80
|
-
|
|
114
|
+
// Remove the database record first. This is the reversible step: if the
|
|
115
|
+
// storage delete below fails, we have an orphaned blob, which is junk but
|
|
116
|
+
// harmless. The reverse order risks a row whose bytes are already gone.
|
|
117
|
+
let deleted;
|
|
118
|
+
try {
|
|
119
|
+
({ count: deleted } = await file.deleteIfPresent(record.id));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// P2003: a foreign key still references this row. Reachable once File
|
|
122
|
+
// gains incoming relations; the storage object is untouched at this point.
|
|
123
|
+
if (error?.code === "P2003") {
|
|
124
|
+
throw new ConflictError("File is referenced and cannot be deleted");
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
81
128
|
|
|
82
|
-
//
|
|
83
|
-
|
|
129
|
+
// count === 0 means a concurrent request already removed the row. It owns
|
|
130
|
+
// the storage cleanup; touching it here would race that request's delete.
|
|
131
|
+
if (deleted === 0) {
|
|
132
|
+
throw new NotFoundError("File not found");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Row is gone, so the blob is now unreferenced. This is the irreversible
|
|
136
|
+
// step and deliberately last.
|
|
137
|
+
const storage = createStorage();
|
|
138
|
+
try {
|
|
139
|
+
await storage.delete(record.storageKey);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// The row is already deleted, so the delete as a whole succeeded from the
|
|
142
|
+
// caller's point of view. Reporting an error would be a lie the client
|
|
143
|
+
// cannot act on, and the leftover blob is recoverable via the existing
|
|
144
|
+
// orphaned-file cleanup job. Log it rather than swallow it.
|
|
145
|
+
logger.error("Failed to remove file from storage", {
|
|
146
|
+
fileId: record.id,
|
|
147
|
+
error: error?.message ?? String(error),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
84
150
|
|
|
85
151
|
return NextResponse.json({ message: "File deleted" });
|
|
86
152
|
} catch (error) {
|
|
@@ -10,37 +10,39 @@
|
|
|
10
10
|
* connection and produces a fresh connection storm on the dependency that was
|
|
11
11
|
* already struggling. That turns a transient blip into a restart loop.
|
|
12
12
|
* Callers that need a hard verdict should read `status`, not the status code.
|
|
13
|
+
*
|
|
14
|
+
* The probing itself lives in `@usequark/quark-core/health`. This route is the
|
|
15
|
+
* thin HTTP shell around it: it wires the app's probes in and renders the
|
|
16
|
+
* report. Projects hand-rolled their own copy of this orchestration, which is
|
|
17
|
+
* what the core module replaces.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import {
|
|
21
|
+
checkQueues,
|
|
22
|
+
checkStorage,
|
|
16
23
|
createLogger,
|
|
17
|
-
createStorage,
|
|
18
24
|
getRegisteredQueues,
|
|
19
25
|
pingRedis,
|
|
26
|
+
runHealthChecks,
|
|
20
27
|
} from "@usequark/quark-core";
|
|
21
28
|
import { pingDatabase } from "@usequark/quark-db";
|
|
22
29
|
import { NextResponse } from "next/server";
|
|
23
30
|
|
|
24
31
|
const logger = createLogger("health");
|
|
25
32
|
|
|
26
|
-
/** Ceiling for the whole check (ms). */
|
|
27
|
-
const HEALTH_CHECK_TIMEOUT = 5000;
|
|
28
|
-
|
|
29
|
-
/** Ceiling for one dependency probe (ms). Clamped to the overall budget, so a
|
|
30
|
-
* single probe can never outlive the request. */
|
|
31
|
-
const PROBE_TIMEOUT = 3000;
|
|
32
|
-
|
|
33
|
-
/** Message returned in place of a real error when NODE_ENV is production. */
|
|
34
|
-
const GENERIC_MESSAGES = {
|
|
35
|
-
database: "Database unavailable",
|
|
36
|
-
redis: "Redis unavailable",
|
|
37
|
-
storage: "Storage unavailable",
|
|
38
|
-
queue: "Queue unavailable",
|
|
39
|
-
};
|
|
40
|
-
|
|
41
33
|
export async function GET() {
|
|
42
34
|
try {
|
|
43
|
-
|
|
35
|
+
// `pingDatabase` is passed in rather than imported by the core module:
|
|
36
|
+
// `@usequark/quark-db` depends on `@usequark/quark-core`, so core cannot
|
|
37
|
+
// import it back without a cycle.
|
|
38
|
+
const result = await runHealthChecks({
|
|
39
|
+
probes: {
|
|
40
|
+
database: pingDatabase,
|
|
41
|
+
redis: pingRedis,
|
|
42
|
+
storage: checkStorage,
|
|
43
|
+
queues: () => checkQueues(getRegisteredQueues),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
44
46
|
|
|
45
47
|
// Always 200 — see the module comment. An orchestrator restarting this
|
|
46
48
|
// service is what turns a dependency blip into an outage.
|
|
@@ -67,197 +69,3 @@ export async function GET() {
|
|
|
67
69
|
);
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Runs every dependency probe concurrently and aggregates the results.
|
|
73
|
-
*
|
|
74
|
-
* Concurrency matters as much as the deadlines: run sequentially, a 3s database
|
|
75
|
-
* timeout plus a 3s Redis timeout exhausts the 5s budget before storage is even
|
|
76
|
-
* attempted, and the whole request falls through to the give-up path.
|
|
77
|
-
*
|
|
78
|
-
* @returns {Promise<{ status: string, timestamp: string, durationMs: number, checks: Record<string, object> }>}
|
|
79
|
-
*/
|
|
80
|
-
async function runHealthChecks() {
|
|
81
|
-
const startedAt = performance.now();
|
|
82
|
-
|
|
83
|
-
// Every probe is wrapped in settleProbe(), so this Promise.all cannot reject
|
|
84
|
-
// and cannot outlast the deadline.
|
|
85
|
-
const [database, redis, storage, queues] = await Promise.all([
|
|
86
|
-
settleProbe("database", () => pingDatabase()),
|
|
87
|
-
settleProbe("redis", () => pingRedis()),
|
|
88
|
-
// `provider` rides along on the error result so a failure still names the
|
|
89
|
-
// provider that failed — settleProbe owns the message redaction.
|
|
90
|
-
settleProbe("storage", checkStorage, {
|
|
91
|
-
provider: process.env.STORAGE_PROVIDER || "local",
|
|
92
|
-
}),
|
|
93
|
-
settleProbe("queues", checkQueues),
|
|
94
|
-
]);
|
|
95
|
-
|
|
96
|
-
const checks = { database, redis, storage };
|
|
97
|
-
if (queues) checks.queues = queues;
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
// "degraded" means at least one dependency failed. Both this and "ok"
|
|
101
|
-
// answer 200 — see the module comment.
|
|
102
|
-
status: Object.values(checks).some(isFailing) ? "degraded" : "ok",
|
|
103
|
-
timestamp: new Date().toISOString(),
|
|
104
|
-
durationMs: Math.round(performance.now() - startedAt),
|
|
105
|
-
checks,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Reports whether a check entry counts as a failure. Handles both a flat probe
|
|
111
|
-
* result (`{ status: "error" }`) and the nested queue map, where the verdict
|
|
112
|
-
* lives on each queue rather than on the map itself.
|
|
113
|
-
*
|
|
114
|
-
* @param {unknown} check
|
|
115
|
-
* @returns {boolean}
|
|
116
|
-
*/
|
|
117
|
-
function isFailing(check) {
|
|
118
|
-
if (!check || typeof check !== "object") return false;
|
|
119
|
-
|
|
120
|
-
// A flat probe result carries its own `status`. The nested queue map does
|
|
121
|
-
// not, so fall through to its entries.
|
|
122
|
-
if (typeof (/** @type {any} */ (check).status) === "string") {
|
|
123
|
-
return /** @type {any} */ (check).status === "error";
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return Object.values(/** @type {Record<string, any>} */ (check)).some(
|
|
127
|
-
(entry) => entry?.status === "error",
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Runs one dependency probe with its own deadline and normalises every failure
|
|
133
|
-
* mode — rejection, timeout, or a probe that never settles — into a result
|
|
134
|
-
* object. Never throws, so callers can rely on `Promise.all` resolving.
|
|
135
|
-
*
|
|
136
|
-
* @param {string} name - Probe name, also the key under `checks`.
|
|
137
|
-
* @param {() => Promise<object>} probe
|
|
138
|
-
* @param {object} [context] - Fields to merge into the error result.
|
|
139
|
-
* @returns {Promise<{ status: string } & Record<string, unknown>>}
|
|
140
|
-
*/
|
|
141
|
-
async function settleProbe(name, probe, context = {}) {
|
|
142
|
-
// A probe that hangs must not hold up the aggregate, and must never outlive
|
|
143
|
-
// the overall budget. The timer is always cleared, so it never leaks.
|
|
144
|
-
let timer;
|
|
145
|
-
const deadline = new Promise((resolve) => {
|
|
146
|
-
timer = setTimeout(
|
|
147
|
-
() => resolve({ status: "error", message: `${name} check timed out` }),
|
|
148
|
-
Math.min(PROBE_TIMEOUT, HEALTH_CHECK_TIMEOUT),
|
|
149
|
-
);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
return withSafeMessage(name, await Promise.race([probe(), deadline]));
|
|
154
|
-
} catch (/** @type {any} */ error) {
|
|
155
|
-
return withSafeMessage(name, {
|
|
156
|
-
...context,
|
|
157
|
-
status: "error",
|
|
158
|
-
message: error?.message,
|
|
159
|
-
});
|
|
160
|
-
} finally {
|
|
161
|
-
clearTimeout(timer);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Returns a check result whose message is safe to serve publicly.
|
|
167
|
-
*
|
|
168
|
-
* A probe can signal failure two ways — by rejecting, or by *resolving* with
|
|
169
|
-
* `{ status: "error", message }`. Both are routed through here so neither can
|
|
170
|
-
* bypass the redaction: `pingRedis` returned `{ status: "error" }` carrying the
|
|
171
|
-
* raw `REDIS_URL`, so a normaliser that only handled rejections would have let
|
|
172
|
-
* the password straight through.
|
|
173
|
-
*
|
|
174
|
-
* @param {string} name - Probe name.
|
|
175
|
-
* @param {object} [result]
|
|
176
|
-
* @returns {object}
|
|
177
|
-
*/
|
|
178
|
-
function withSafeMessage(name, result) {
|
|
179
|
-
if (result?.status !== "error") return result;
|
|
180
|
-
|
|
181
|
-
const { message, ...rest } = result;
|
|
182
|
-
return { ...rest, status: "error", message: safeMessage(name, message) };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Reduces a failure to a message safe to return from an unauthenticated route.
|
|
187
|
-
*
|
|
188
|
-
* Driver errors carry the connection string they failed on, so the raw text is
|
|
189
|
-
* only echoed outside production. `pingRedis`/`pingDatabase` redact at the
|
|
190
|
-
* source regardless; this is the second layer.
|
|
191
|
-
*
|
|
192
|
-
* @param {string} name - Probe name.
|
|
193
|
-
* @param {string|Error} [source] - The failure message or the error itself.
|
|
194
|
-
* @returns {string}
|
|
195
|
-
*/
|
|
196
|
-
function safeMessage(name, source) {
|
|
197
|
-
if (process.env.NODE_ENV === "production") {
|
|
198
|
-
return GENERIC_MESSAGES[name] ?? "Check unavailable";
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return source?.message ?? source ?? "Unknown error";
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Verifies storage is reachable and writable by writing then deleting a
|
|
206
|
-
* small sentinel object. Uses whichever provider is configured via
|
|
207
|
-
* STORAGE_PROVIDER (defaults to "local" when unset).
|
|
208
|
-
* @returns {Promise<{ status: string, provider: string }>}
|
|
209
|
-
*/
|
|
210
|
-
async function checkStorage() {
|
|
211
|
-
const provider = process.env.STORAGE_PROVIDER || "local";
|
|
212
|
-
const storage = createStorage();
|
|
213
|
-
const sentinelKey = ".health-check-sentinel";
|
|
214
|
-
|
|
215
|
-
await storage.put(sentinelKey, Buffer.from("ok"), {
|
|
216
|
-
contentType: "text/plain",
|
|
217
|
-
});
|
|
218
|
-
await storage.delete(sentinelKey);
|
|
219
|
-
|
|
220
|
-
return { status: "ok", provider };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Reports queue depths. Only meaningful in a process that registered the
|
|
225
|
-
* queues — the web app registers none, so this resolves to null and the key is
|
|
226
|
-
* omitted from the response rather than reported as a false pass.
|
|
227
|
-
*
|
|
228
|
-
* @returns {Promise<Record<string, { status: string, waiting?: number, active?: number, failed?: number, message?: string }> | null>}
|
|
229
|
-
*/
|
|
230
|
-
async function checkQueues() {
|
|
231
|
-
const registeredQueues = getRegisteredQueues();
|
|
232
|
-
if (registeredQueues.size === 0) return null;
|
|
233
|
-
|
|
234
|
-
const entries = [...registeredQueues.entries()];
|
|
235
|
-
const results = await Promise.allSettled(
|
|
236
|
-
entries.map(([, queue]) =>
|
|
237
|
-
Promise.all([
|
|
238
|
-
queue.getWaitingCount(),
|
|
239
|
-
queue.getActiveCount(),
|
|
240
|
-
queue.getFailedCount(),
|
|
241
|
-
]),
|
|
242
|
-
),
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
const queueChecks = {};
|
|
246
|
-
|
|
247
|
-
for (let i = 0; i < entries.length; i++) {
|
|
248
|
-
const [name] = entries[i];
|
|
249
|
-
const result = results[i];
|
|
250
|
-
|
|
251
|
-
if (result.status === "fulfilled") {
|
|
252
|
-
const [waiting, active, failed] = result.value;
|
|
253
|
-
queueChecks[name] = { status: "ok", waiting, active, failed };
|
|
254
|
-
} else {
|
|
255
|
-
queueChecks[name] = withSafeMessage("queue", {
|
|
256
|
-
status: "error",
|
|
257
|
-
message: result.reason?.message,
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return queueChecks;
|
|
263
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "./globals.css";
|
|
2
|
+
import { ThemeProvider } from "@usequark/quark-ui";
|
|
2
3
|
import {
|
|
3
4
|
THEME_ATTR,
|
|
4
5
|
THEME_STORAGE_KEY,
|
|
@@ -48,7 +49,11 @@ export default function RootLayout({ children }) {
|
|
|
48
49
|
) : null}
|
|
49
50
|
</head>
|
|
50
51
|
<body>
|
|
51
|
-
{
|
|
52
|
+
{/* ThemeProvider renders no markup. It publishes theme state to
|
|
53
|
+
React consumers (useTheme/ThemeToggle) and syncs data-theme
|
|
54
|
+
after hydration via useLayoutEffect, which is why it must
|
|
55
|
+
coexist with the blocking script above rather than replace it. */}
|
|
56
|
+
<ThemeProvider>{children}</ThemeProvider>
|
|
52
57
|
{umamiConfig.enabled ? <UmamiWebVitals /> : null}
|
|
53
58
|
{umamiConfig.replayEnabled ? <UmamiReplayRecorder /> : null}
|
|
54
59
|
</body>
|
|
@@ -283,6 +283,23 @@ export const file = {
|
|
|
283
283
|
delete: (id) => {
|
|
284
284
|
return prisma.file.delete({ where: { id } });
|
|
285
285
|
},
|
|
286
|
+
/**
|
|
287
|
+
* Deletes a File row and reports whether this call was the one that removed
|
|
288
|
+
* it.
|
|
289
|
+
*
|
|
290
|
+
* `delete()` throws P2025 when the row is already gone, which is the normal
|
|
291
|
+
* outcome of two concurrent DELETEs of the same file and is not an error
|
|
292
|
+
* worth surfacing. This returns `{ count }` instead so the caller can tell
|
|
293
|
+
* "I deleted it" from "someone beat me to it" and skip the destructive
|
|
294
|
+
* follow-up (removing the storage object) in the second case.
|
|
295
|
+
*
|
|
296
|
+
* A count of 1 means the caller owns the cleanup. Any incoming relation
|
|
297
|
+
* added later still surfaces here as a P2003 foreign-key error, before the
|
|
298
|
+
* storage object has been touched.
|
|
299
|
+
*/
|
|
300
|
+
deleteIfPresent: (id) => {
|
|
301
|
+
return prisma.file.deleteMany({ where: { id } });
|
|
302
|
+
},
|
|
286
303
|
deleteMany: (ids) => {
|
|
287
304
|
return prisma.file.deleteMany({ where: { id: { in: ids } } });
|
|
288
305
|
},
|
|
@@ -31,7 +31,18 @@ function collectTests(targetPath) {
|
|
|
31
31
|
continue;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// `*.integration-test.js` renders components through jsdom + react-dom,
|
|
35
|
+
// so it must be collected too. It was previously skipped, which meant
|
|
36
|
+
// those suites never ran in CI - a real gap, not a naming preference:
|
|
37
|
+
// every one of them passed locally but was invisible to the pipeline.
|
|
38
|
+
// Note the file suffix is `-integration-test.js`, so
|
|
39
|
+
// `*.integration.test.js` (a different, separately-excluded name) is
|
|
40
|
+
// still only picked up by the plain `.test.js` rule below.
|
|
41
|
+
if (
|
|
42
|
+
!entry.isFile() ||
|
|
43
|
+
(!entry.name.endsWith(".test.js") &&
|
|
44
|
+
!entry.name.endsWith(".integration-test.js"))
|
|
45
|
+
) {
|
|
35
46
|
continue;
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -49,6 +60,35 @@ function collectTests(targetPath) {
|
|
|
49
60
|
|
|
50
61
|
const files = roots.flatMap((rootPath) => collectTests(path.resolve(rootPath)));
|
|
51
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Escapes glob metacharacters so `node --test` treats the path as a literal
|
|
65
|
+
* path rather than a pattern.
|
|
66
|
+
*
|
|
67
|
+
* Next.js dynamic route segments are directories of the form `[id]` and
|
|
68
|
+
* `[...slug]`, and `node --test` matches its file arguments as globs. An
|
|
69
|
+
* unescaped `[id]` is read as a character class matching a single `i` or `d`,
|
|
70
|
+
* so the path matches nothing. The failure is silent — the runner reports
|
|
71
|
+
* zero tests and exits 0 — which meant every test co-located in a dynamic
|
|
72
|
+
* segment (`api/files/[id]/route.test.js`, and `users/[id]` and
|
|
73
|
+
* `[...nextauth]` which have none) was collected here and then silently
|
|
74
|
+
* dropped at the `node --test` boundary.
|
|
75
|
+
*
|
|
76
|
+
* Only `[` and `]` need escaping: a literal `*`, `?`, `{`, `}`, `+`, `@` or
|
|
77
|
+
* `!` in a directory name is resolved as itself once no glob metacharacter
|
|
78
|
+
* remains to give it meaning. `[[]` and `[]]` are the character-class escapes
|
|
79
|
+
* for the literal characters; a backslash is *not* honoured here, so it
|
|
80
|
+
* silently fails to escape anything.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} filePath
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function escapeGlobChars(filePath) {
|
|
86
|
+
// Single pass. Two `replaceAll` calls in sequence do not work: the `[` of
|
|
87
|
+
// `[[]` introduces a `]` that the second call then escapes again, yielding
|
|
88
|
+
// `[[[]]` for one literal `[`.
|
|
89
|
+
return filePath.replace(/[[\]]/g, (char) => (char === "[" ? "[[]" : "[]]"));
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
if (files.length === 0) {
|
|
53
93
|
// An empty suite is not a failure. `node --test` exits 0 when it matches no
|
|
54
94
|
// files, so exiting 1 here made a freshly scaffolded project fail its own
|
|
@@ -64,7 +104,7 @@ const result = spawnSync(
|
|
|
64
104
|
"tsx/esm",
|
|
65
105
|
"--experimental-test-module-mocks",
|
|
66
106
|
"--test",
|
|
67
|
-
...files,
|
|
107
|
+
...files.map(escapeGlobChars),
|
|
68
108
|
],
|
|
69
109
|
{
|
|
70
110
|
stdio: "inherit",
|
|
@@ -57,7 +57,8 @@ test("FormField - renders label and input", async () => {
|
|
|
57
57
|
assert.ok(input, "should render an input");
|
|
58
58
|
assert.strictEqual(label.textContent, "Name");
|
|
59
59
|
assert.strictEqual(input.getAttribute("name"), "name");
|
|
60
|
-
root.unmount();
|
|
60
|
+
await act(() => root.unmount());
|
|
61
|
+
container.remove();
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
test("FormField - connects label to input via htmlFor/id", async () => {
|
|
@@ -76,7 +77,8 @@ test("FormField - connects label to input via htmlFor/id", async () => {
|
|
|
76
77
|
const input = container.querySelector("input");
|
|
77
78
|
assert.strictEqual(label.getAttribute("for"), "custom-id");
|
|
78
79
|
assert.strictEqual(input.getAttribute("id"), "custom-id");
|
|
79
|
-
root.unmount();
|
|
80
|
+
await act(() => root.unmount());
|
|
81
|
+
container.remove();
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
test("FormField - renders error message with role alert", async () => {
|
|
@@ -94,7 +96,8 @@ test("FormField - renders error message with role alert", async () => {
|
|
|
94
96
|
const errorEl = container.querySelector("[role='alert']");
|
|
95
97
|
assert.ok(errorEl, "should render an error element with role alert");
|
|
96
98
|
assert.strictEqual(errorEl.textContent, "Required");
|
|
97
|
-
root.unmount();
|
|
99
|
+
await act(() => root.unmount());
|
|
100
|
+
container.remove();
|
|
98
101
|
});
|
|
99
102
|
|
|
100
103
|
test("FormField - sets aria-invalid and aria-describedby when error present", async () => {
|
|
@@ -110,13 +113,127 @@ test("FormField - sets aria-invalid and aria-describedby when error present", as
|
|
|
110
113
|
}),
|
|
111
114
|
),
|
|
112
115
|
);
|
|
113
|
-
const
|
|
114
|
-
assert.ok(
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
const input = container.querySelector("input");
|
|
117
|
+
assert.ok(input, "should render an input");
|
|
118
|
+
assert.strictEqual(
|
|
119
|
+
input.getAttribute("aria-invalid"),
|
|
120
|
+
"true",
|
|
121
|
+
"aria-invalid must be on the focusable control",
|
|
122
|
+
);
|
|
123
|
+
assert.ok(
|
|
124
|
+
!container.querySelector("div[aria-invalid]"),
|
|
125
|
+
"aria-invalid on a non-interactive wrapper has no effect",
|
|
126
|
+
);
|
|
127
|
+
const errorId = input.getAttribute("aria-describedby");
|
|
128
|
+
assert.ok(errorId, "should set aria-describedby on the input");
|
|
129
|
+
assert.strictEqual(errorId, "email-field-error");
|
|
117
130
|
const errorEl = container.querySelector(`#${errorId}`);
|
|
118
131
|
assert.ok(errorEl, "aria-describedby should point to existing error element");
|
|
119
|
-
root.unmount();
|
|
132
|
+
await act(() => root.unmount());
|
|
133
|
+
container.remove();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("FormField - applies className to the control, not the wrapper", async () => {
|
|
137
|
+
const container = setupContainer();
|
|
138
|
+
const root = createRoot(container);
|
|
139
|
+
await act(() =>
|
|
140
|
+
root.render(
|
|
141
|
+
createElement(FormField, {
|
|
142
|
+
label: "Name",
|
|
143
|
+
name: "name",
|
|
144
|
+
className: "w-72",
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
const input = container.querySelector("input");
|
|
149
|
+
assert.match(input.getAttribute("class"), /\bw-72\b/);
|
|
150
|
+
assert.doesNotMatch(
|
|
151
|
+
container.firstElementChild.getAttribute("class"),
|
|
152
|
+
/\bw-72\b/,
|
|
153
|
+
"className targets the control; the wrapper is layout-only",
|
|
154
|
+
);
|
|
155
|
+
await act(() => root.unmount());
|
|
156
|
+
container.remove();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("FormField - wrapperClassName styles the layout container", async () => {
|
|
160
|
+
const container = setupContainer();
|
|
161
|
+
const root = createRoot(container);
|
|
162
|
+
await act(() =>
|
|
163
|
+
root.render(
|
|
164
|
+
createElement(FormField, {
|
|
165
|
+
label: "Name",
|
|
166
|
+
name: "name",
|
|
167
|
+
wrapperClassName: "sm:col-span-2",
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
assert.match(
|
|
172
|
+
container.firstElementChild.getAttribute("class"),
|
|
173
|
+
/\bsm:col-span-2\b/,
|
|
174
|
+
);
|
|
175
|
+
await act(() => root.unmount());
|
|
176
|
+
container.remove();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("FormField - forwards error ARIA to a custom child control", async () => {
|
|
180
|
+
const container = setupContainer();
|
|
181
|
+
const root = createRoot(container);
|
|
182
|
+
await act(() =>
|
|
183
|
+
root.render(
|
|
184
|
+
createElement(
|
|
185
|
+
FormField,
|
|
186
|
+
{ label: "Bio", name: "bio", error: "Too short", id: "bio" },
|
|
187
|
+
createElement("textarea", { id: "bio", "data-testid": "custom" }),
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
const textarea = container.querySelector("textarea");
|
|
192
|
+
assert.strictEqual(textarea.getAttribute("aria-invalid"), "true");
|
|
193
|
+
assert.strictEqual(textarea.getAttribute("aria-describedby"), "bio-error");
|
|
194
|
+
assert.ok(
|
|
195
|
+
container.querySelector("#bio-error"),
|
|
196
|
+
"describedby target must exist",
|
|
197
|
+
);
|
|
198
|
+
assert.strictEqual(
|
|
199
|
+
textarea.getAttribute("name"),
|
|
200
|
+
"bio",
|
|
201
|
+
"name should be forwarded to the custom control",
|
|
202
|
+
);
|
|
203
|
+
await act(() => root.unmount());
|
|
204
|
+
container.remove();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("FormField - preserves a custom child's own className", async () => {
|
|
208
|
+
const container = setupContainer();
|
|
209
|
+
const root = createRoot(container);
|
|
210
|
+
await act(() =>
|
|
211
|
+
root.render(
|
|
212
|
+
createElement(
|
|
213
|
+
FormField,
|
|
214
|
+
{ label: "Bio", name: "bio", className: "h-40" },
|
|
215
|
+
createElement("textarea", { className: "resize-y" }),
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
const cls = container.querySelector("textarea").getAttribute("class");
|
|
220
|
+
assert.match(cls, /\bresize-y\b/);
|
|
221
|
+
assert.match(cls, /\bh-40\b/);
|
|
222
|
+
await act(() => root.unmount());
|
|
223
|
+
container.remove();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("FormField - leaves aria-invalid off when there is no error", async () => {
|
|
227
|
+
const container = setupContainer();
|
|
228
|
+
const root = createRoot(container);
|
|
229
|
+
await act(() =>
|
|
230
|
+
root.render(createElement(FormField, { label: "Name", name: "name" })),
|
|
231
|
+
);
|
|
232
|
+
const input = container.querySelector("input");
|
|
233
|
+
assert.strictEqual(input.getAttribute("aria-invalid"), null);
|
|
234
|
+
assert.strictEqual(input.getAttribute("aria-describedby"), null);
|
|
235
|
+
await act(() => root.unmount());
|
|
236
|
+
container.remove();
|
|
120
237
|
});
|
|
121
238
|
|
|
122
239
|
test("FormField - renders children instead of default input", async () => {
|
|
@@ -138,5 +255,6 @@ test("FormField - renders children instead of default input", async () => {
|
|
|
138
255
|
null,
|
|
139
256
|
"should not render default input",
|
|
140
257
|
);
|
|
141
|
-
root.unmount();
|
|
258
|
+
await act(() => root.unmount());
|
|
259
|
+
container.remove();
|
|
142
260
|
});
|
|
@@ -3,12 +3,33 @@ import React, { useId } from "react";
|
|
|
3
3
|
import { Input } from "./input.js";
|
|
4
4
|
import { Label } from "./label.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Label + control + error message, wired together for accessibility.
|
|
8
|
+
*
|
|
9
|
+
* `className` is applied to the control, matching every other component in this
|
|
10
|
+
* package (`Input`, `Textarea`, `Select`, `Card`, …), where it extends the
|
|
11
|
+
* component's own root element. Use `wrapperClassName` to style the layout
|
|
12
|
+
* container instead.
|
|
13
|
+
*
|
|
14
|
+
* The error association (`aria-invalid` / `aria-describedby`) always lands on
|
|
15
|
+
* the focusable control, never on a wrapper element, so a screen reader that is
|
|
16
|
+
* sitting on the control actually announces it.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} props
|
|
19
|
+
* @param {React.ReactNode} [props.label]
|
|
20
|
+
* @param {string} [props.name]
|
|
21
|
+
* @param {string} [props.error]
|
|
22
|
+
* @param {React.ReactNode} [props.children] custom control, e.g. a <Textarea>
|
|
23
|
+
* @param {string} [props.className] classes for the control
|
|
24
|
+
* @param {string} [props.wrapperClassName] classes for the layout container
|
|
25
|
+
*/
|
|
6
26
|
export function FormField({
|
|
7
27
|
label,
|
|
8
28
|
name,
|
|
9
29
|
error,
|
|
10
30
|
children,
|
|
11
31
|
className = "",
|
|
32
|
+
wrapperClassName = "",
|
|
12
33
|
...props
|
|
13
34
|
}) {
|
|
14
35
|
const generatedId = useId();
|
|
@@ -16,25 +37,44 @@ export function FormField({
|
|
|
16
37
|
|
|
17
38
|
const errorId = `${fieldId}-error`;
|
|
18
39
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
inputProps["aria-describedby"] = errorId;
|
|
23
|
-
}
|
|
40
|
+
const ariaProps = error
|
|
41
|
+
? { "aria-invalid": true, "aria-describedby": errorId }
|
|
42
|
+
: {};
|
|
24
43
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
const control = children
|
|
45
|
+
? // A caller-supplied control already owns its own markup; forward the
|
|
46
|
+
// accessibility wiring to it so the attributes land on the element
|
|
47
|
+
// that can receive focus. Explicit props on the child win, so a
|
|
48
|
+
// caller can still override them.
|
|
49
|
+
React.Children.map(children, (child) => {
|
|
50
|
+
if (!React.isValidElement(child)) return child;
|
|
51
|
+
const merged = [child.props.className, className]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join(" ");
|
|
54
|
+
return React.cloneElement(child, {
|
|
55
|
+
id: child.props.id ?? fieldId,
|
|
56
|
+
name: child.props.name ?? name,
|
|
57
|
+
// Omitted entirely when empty so a bare child is not given a
|
|
58
|
+
// meaningless class="" attribute.
|
|
59
|
+
...(merged ? { className: merged } : {}),
|
|
60
|
+
...ariaProps,
|
|
61
|
+
});
|
|
62
|
+
})
|
|
63
|
+
: React.createElement(Input, {
|
|
64
|
+
id: fieldId,
|
|
65
|
+
name,
|
|
66
|
+
className,
|
|
67
|
+
...ariaProps,
|
|
68
|
+
...props,
|
|
69
|
+
});
|
|
30
70
|
|
|
31
71
|
return React.createElement(
|
|
32
72
|
"div",
|
|
33
|
-
{
|
|
73
|
+
{
|
|
74
|
+
className: `min-w-0 space-y-1.5 ${wrapperClassName}`.trim(),
|
|
75
|
+
},
|
|
34
76
|
label ? React.createElement(Label, { htmlFor: fieldId }, label) : null,
|
|
35
|
-
|
|
36
|
-
? React.createElement("div", childrenWrapperProps, children)
|
|
37
|
-
: React.createElement(Input, inputProps),
|
|
77
|
+
control,
|
|
38
78
|
error
|
|
39
79
|
? React.createElement(
|
|
40
80
|
"p",
|
|
@@ -56,7 +56,8 @@ test("Lightbox - renders nothing when closed", async () => {
|
|
|
56
56
|
const root = createRoot(container);
|
|
57
57
|
await act(() => root.render(createElement(Lightbox, { open: false })));
|
|
58
58
|
assert.strictEqual(container.innerHTML, "");
|
|
59
|
-
root.unmount();
|
|
59
|
+
await act(() => root.unmount());
|
|
60
|
+
container.remove();
|
|
60
61
|
});
|
|
61
62
|
|
|
62
63
|
test("Lightbox - renders overlay when open", async () => {
|
|
@@ -76,7 +77,8 @@ test("Lightbox - renders overlay when open", async () => {
|
|
|
76
77
|
assert.ok(img, "should render an img element");
|
|
77
78
|
assert.strictEqual(img.getAttribute("src"), "/test.jpg");
|
|
78
79
|
assert.strictEqual(img.getAttribute("alt"), "Test image");
|
|
79
|
-
root.unmount();
|
|
80
|
+
await act(() => root.unmount());
|
|
81
|
+
container.remove();
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
test("Lightbox - calls onClose when close button clicked", async () => {
|
|
@@ -100,7 +102,8 @@ test("Lightbox - calls onClose when close button clicked", async () => {
|
|
|
100
102
|
assert.ok(closeBtn, "should have a close button");
|
|
101
103
|
await act(() => closeBtn.click());
|
|
102
104
|
assert.ok(closed, "onClose should be called");
|
|
103
|
-
root.unmount();
|
|
105
|
+
await act(() => root.unmount());
|
|
106
|
+
container.remove();
|
|
104
107
|
});
|
|
105
108
|
|
|
106
109
|
test("Lightbox - shows prev/next buttons and counter", async () => {
|
|
@@ -127,7 +130,8 @@ test("Lightbox - shows prev/next buttons and counter", async () => {
|
|
|
127
130
|
);
|
|
128
131
|
assert.ok(container.textContent.includes("Next"), "should show Next button");
|
|
129
132
|
assert.ok(container.textContent.includes("2 / 5"), "should show counter");
|
|
130
|
-
root.unmount();
|
|
133
|
+
await act(() => root.unmount());
|
|
134
|
+
container.remove();
|
|
131
135
|
});
|
|
132
136
|
|
|
133
137
|
test("Lightbox - calls onPrevious and onNext on button clicks", async () => {
|
|
@@ -163,5 +167,6 @@ test("Lightbox - calls onPrevious and onNext on button clicks", async () => {
|
|
|
163
167
|
assert.ok(prev, "onPrevious should be called");
|
|
164
168
|
await act(() => nextBtn.click());
|
|
165
169
|
assert.ok(next, "onNext should be called");
|
|
166
|
-
root.unmount();
|
|
170
|
+
await act(() => root.unmount());
|
|
171
|
+
container.remove();
|
|
167
172
|
});
|
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
THEME_STORAGE_KEY,
|
|
14
14
|
} from "./theme-constants.js";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// `undefined` means "no ThemeProvider above this consumer". It must not be a
|
|
17
|
+
// usable default: a plausible-looking default ({ theme, setTheme: noop }) turns a
|
|
18
|
+
// missing provider into a silently broken toggle that still renders and still
|
|
19
|
+
// reports a theme. See useTheme().
|
|
20
|
+
const ThemeCtx = createContext(undefined);
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* Wraps a subtree with a shared theme value.
|
|
@@ -90,18 +94,29 @@ export function ThemeProvider({ defaultTheme, children }) {
|
|
|
90
94
|
|
|
91
95
|
/**
|
|
92
96
|
* Returns the current theme and a setter from the nearest ThemeProvider.
|
|
93
|
-
*
|
|
97
|
+
*
|
|
98
|
+
* Throws when there is no ThemeProvider above the caller. A missing provider is
|
|
99
|
+
* a wiring bug, not a state to default: `ThemeToggle` renders a labelled,
|
|
100
|
+
* focusable button either way, so a silent fallback yields a control that looks
|
|
101
|
+
* real, reports the wrong theme, and does nothing when clicked.
|
|
94
102
|
*
|
|
95
103
|
* @returns {{ theme: 'light' | 'dark', setTheme: (t: string) => void }}
|
|
104
|
+
* @throws {Error} if called outside a ThemeProvider
|
|
96
105
|
*/
|
|
97
106
|
export function useTheme() {
|
|
98
|
-
|
|
107
|
+
const ctx = useContext(ThemeCtx);
|
|
108
|
+
if (ctx === undefined) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"useTheme() must be used within a ThemeProvider. Wrap your app in <ThemeProvider> (import it from @scope/ui) around the tree containing this component.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return ctx;
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
/**
|
|
102
117
|
* Labeled pill button that shows the current theme and switches on click.
|
|
103
118
|
* Displays "🌙 Dark Mode" when dark, "☀ Light Mode" when light.
|
|
104
|
-
* Must be rendered inside a ThemeProvider.
|
|
119
|
+
* Must be rendered inside a ThemeProvider — outside one, `useTheme()` throws.
|
|
105
120
|
*/
|
|
106
121
|
export function ThemeToggle({ className = "" }) {
|
|
107
122
|
const { theme, setTheme } = useTheme();
|
|
@@ -37,8 +37,16 @@ export async function handleCleanupOrphanedFiles(bullJob, logger) {
|
|
|
37
37
|
|
|
38
38
|
for (const record of orphaned) {
|
|
39
39
|
try {
|
|
40
|
+
// Row first, storage second — same ordering as DELETE /api/files/[id].
|
|
41
|
+
// Storage-first means a failed row delete leaves a row pointing at bytes
|
|
42
|
+
// that are already gone, which cannot be recovered: the blob was the only
|
|
43
|
+
// copy. A failed storage delete here leaves an orphaned blob instead, and
|
|
44
|
+
// this very job is what sweeps those up on a later pass.
|
|
45
|
+
const { count } = await file.deleteIfPresent(record.id);
|
|
46
|
+
// A concurrent request (or an earlier pass of this job) already took it.
|
|
47
|
+
if (count === 0) continue;
|
|
48
|
+
|
|
40
49
|
await storage.delete(record.storageKey);
|
|
41
|
-
await file.delete(record.id);
|
|
42
50
|
deleted++;
|
|
43
51
|
} catch (err) {
|
|
44
52
|
errors.push({ id: record.id, error: err.message });
|