@flaredev/cli 0.9.1 → 0.11.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 +360 -59
- package/package.json +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +8 -8
- package/templates/app/components/dashboard/resource-form.tsx +14 -3
- package/templates/app/lib/auth.ts +222 -154
- package/templates/app/lib/resource/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +106 -15
- package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1211 -1414
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1088 -1311
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1099 -1229
- package/templates/locks/next/package-lock.json +13673 -0
- package/templates/locks/next/pnpm-lock.yaml +8232 -0
- package/templates/locks/next/yarn.lock +5747 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +68 -2
- package/templates/next/lib/db.ts +5 -1
- package/templates/next/lib/resource/prisma-rows.ts +3 -2
|
@@ -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, ownershipFilter, storedFields, type Policy, type Resource } from "@flaredev/core";
|
|
11
|
+
import { columnName, conditionHolds, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, 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";
|
|
@@ -46,6 +46,11 @@ export const COUNT_LIMIT = 10_000;
|
|
|
46
46
|
function toCursorValue(value: unknown): string | number | boolean | null {
|
|
47
47
|
if (value instanceof Date) return value.getTime();
|
|
48
48
|
if (value === null || value === undefined) return null;
|
|
49
|
+
// A money column is BigInt on Postgres, and Prisma hands those back as a bigint.
|
|
50
|
+
// JSON.stringify throws on one — "Do not know how to serialize a BigInt" — so sorting by
|
|
51
|
+
// a money field broke the cursor on that stack and nowhere else. Minor units fit a double
|
|
52
|
+
// to about ninety trillion, which is past any amount a column like this holds.
|
|
53
|
+
if (typeof value === "bigint") return Number(value);
|
|
49
54
|
if (typeof value === "object") return String(value);
|
|
50
55
|
return value as string | number | boolean;
|
|
51
56
|
}
|
|
@@ -195,9 +200,76 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
195
200
|
|
|
196
201
|
const notFound = () => fail(404, `${resource.label} not found.`);
|
|
197
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Money fields, and the currency each one counts in.
|
|
205
|
+
*
|
|
206
|
+
* A money column holds whole minor units, because a column of doubles drifts — sum a few
|
|
207
|
+
* thousand prices and the total is a penny out. Everything above this file works in the
|
|
208
|
+
* units people write, so the conversion happens here and nowhere else: the API sends
|
|
209
|
+
* 19.99, the form shows 19.99, and only the database sees 1999.
|
|
210
|
+
*/
|
|
211
|
+
const moneyFields = fields.flatMap(([key, def]) =>
|
|
212
|
+
(def.kind === "int" || def.kind === "float") && def.format === "money" ? [{ key, currency: def.currency }] : [],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Fields that only apply when another field says so, e.g. a billing cycle that exists
|
|
217
|
+
* on a recurring project and not on a one-off one.
|
|
218
|
+
*
|
|
219
|
+
* The validator decides whether one is *required*; this decides whether it is *kept*.
|
|
220
|
+
* Without it, changing a project from recurring to one-off leaves last month's billing
|
|
221
|
+
* cycle sitting in the row — the form stopped showing the input, so nothing ever sent a
|
|
222
|
+
* value to overwrite it, and the record quietly disagrees with itself.
|
|
223
|
+
*/
|
|
224
|
+
const conditionalFields = fields.flatMap(([key, def]) => (def.showWhen ? [{ key, condition: def.showWhen }] : []));
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Null the fields that no longer apply.
|
|
228
|
+
*
|
|
229
|
+
* `record` is what the condition is judged against: the whole record as it will be once
|
|
230
|
+
* this write lands, which on a PATCH means the stored row with the patch laid over it.
|
|
231
|
+
* Judging the patch alone would read a field it never mentioned as absent and clear a
|
|
232
|
+
* value that was meant to stay.
|
|
233
|
+
*/
|
|
234
|
+
function clearInapplicable(values: Row, record: Row): Row {
|
|
235
|
+
if (conditionalFields.length === 0) return values;
|
|
236
|
+
const out = { ...values };
|
|
237
|
+
for (const { key, condition } of conditionalFields) {
|
|
238
|
+
if (!conditionHolds(condition, record)) out[key] = null;
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Amounts as the database stores them: 19.99 -> 1999. */
|
|
244
|
+
function toStored(values: Row): Row {
|
|
245
|
+
if (moneyFields.length === 0) return values;
|
|
246
|
+
const out = { ...values };
|
|
247
|
+
for (const { key, currency } of moneyFields) {
|
|
248
|
+
const value = out[key];
|
|
249
|
+
if (typeof value === "number") out[key] = toMinorUnits(value, currency);
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Amounts as callers read them: 1999 -> 19.99. */
|
|
255
|
+
function toAmounts(row: Row): Row {
|
|
256
|
+
if (moneyFields.length === 0) return row;
|
|
257
|
+
const out = { ...row };
|
|
258
|
+
for (const { key, currency } of moneyFields) {
|
|
259
|
+
const value = out[key];
|
|
260
|
+
// Prisma hands a BigInt column back as a bigint; D1 gives a number.
|
|
261
|
+
if (typeof value === "number") out[key] = fromMinorUnits(value, currency);
|
|
262
|
+
else if (typeof value === "bigint") out[key] = fromMinorUnits(Number(value), currency);
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
198
267
|
const computed = Object.entries(resource.computed ?? {});
|
|
199
268
|
/** Add the descriptor's computed values to a row on its way out. */
|
|
200
|
-
const withComputed = (
|
|
269
|
+
const withComputed = (stored: Record<string, unknown>): Record<string, unknown> => {
|
|
270
|
+
// Amounts first: a computed value like `total` is written against the units the
|
|
271
|
+
// descriptor talks about, so it has to see 19.99 rather than 1999.
|
|
272
|
+
const row = toAmounts(stored);
|
|
201
273
|
if (computed.length === 0) return row;
|
|
202
274
|
const result = { ...row };
|
|
203
275
|
for (const [key, compute] of computed) {
|
|
@@ -265,7 +337,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
265
337
|
const limit = await confine();
|
|
266
338
|
if (isFailure(limit)) return limit;
|
|
267
339
|
// Last, so `?filter[userId]=someone-else` cannot widen the query past the owner.
|
|
268
|
-
const
|
|
340
|
+
const parsedFilters = limit ? { ...parsed.query.filters, [limit.field]: limit.value } : parsed.query.filters;
|
|
341
|
+
// `?filter[price]=19.99` is written in the units the caller knows; the column counts
|
|
342
|
+
// cents, so it is converted here like any other amount crossing this boundary.
|
|
343
|
+
const filters = toStored(parsedFilters) as typeof parsedFilters;
|
|
269
344
|
|
|
270
345
|
/**
|
|
271
346
|
* Whether a sort column holds a date, so the adapter can shape the cursor for it.
|
|
@@ -310,8 +385,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
310
385
|
const exactTotal = counting <= COUNT_LIMIT;
|
|
311
386
|
const total = exactTotal ? counting : COUNT_LIMIT;
|
|
312
387
|
|
|
313
|
-
|
|
314
|
-
|
|
388
|
+
// Cursors are built from the rows as the database gave them, not from `page_`.
|
|
389
|
+
// A cursor is compared against the column on the next request, so sorting by a money
|
|
390
|
+
// field has to carry 1999 — the stored value — and not the 19.99 callers see. Taking
|
|
391
|
+
// it from the converted rows paged by a number the column does not contain, which
|
|
392
|
+
// silently returned the same page forever.
|
|
393
|
+
const stored = hasMore ? found.slice(0, perPage) : found;
|
|
394
|
+
if (backwards) stored.reverse();
|
|
395
|
+
|
|
396
|
+
const first = stored[0];
|
|
397
|
+
const last = stored[stored.length - 1];
|
|
315
398
|
const moreAfter = backwards ? true : hasMore;
|
|
316
399
|
const moreBefore = backwards ? hasMore : Boolean(cursor) || page > 1;
|
|
317
400
|
const cursorFor = (row: Record<string, unknown> | undefined, direction: "after" | "before") =>
|
|
@@ -350,16 +433,19 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
350
433
|
/**
|
|
351
434
|
* Title-field values for a set of ids (for showing relations). Missing ids are omitted.
|
|
352
435
|
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
436
|
+
* Confined by ownership, like every other read: a relation picker shown to a user who
|
|
437
|
+
* only sees their own rows must not name anybody else's. An id they may not see is
|
|
438
|
+
* simply absent from the result, which the caller already handles — missing ids are
|
|
439
|
+
* omitted either way, so a title they are not entitled to looks the same as one that
|
|
440
|
+
* was deleted.
|
|
358
441
|
*/
|
|
359
442
|
async titles(ids: string[]): Promise<Record<string, string>> {
|
|
360
443
|
const unique = [...new Set(ids.filter(Boolean))];
|
|
361
444
|
if (unique.length === 0) return {};
|
|
362
|
-
const
|
|
445
|
+
const limit = await confine();
|
|
446
|
+
// Not signed in where sign-in is required: no titles rather than everyone's.
|
|
447
|
+
if (isFailure(limit)) return {};
|
|
448
|
+
const found = await rows.titles(unique, resource.titleField, limit ?? undefined);
|
|
363
449
|
return Object.fromEntries(found.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
|
|
364
450
|
},
|
|
365
451
|
|
|
@@ -381,7 +467,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
381
467
|
if (!result.success) return invalid(result.error.issues);
|
|
382
468
|
const input = result.data as Record<string, unknown>;
|
|
383
469
|
const now = new Date();
|
|
384
|
-
const
|
|
470
|
+
const fresh = { ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now };
|
|
471
|
+
const record = await rows.insert(toStored(clearInapplicable(fresh, fresh)));
|
|
385
472
|
const saved = withComputed(record);
|
|
386
473
|
await hooks.afterCreate?.(saved, context);
|
|
387
474
|
return { ok: true, data: saved };
|
|
@@ -397,7 +484,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
397
484
|
|
|
398
485
|
// A confined update has to read the row first: `rows.update(id, …)` takes an id
|
|
399
486
|
// and nothing else, so this is where "is it yours" is answered.
|
|
400
|
-
|
|
487
|
+
// Conditional fields join this list: deciding whether one still applies needs the
|
|
488
|
+
// whole record, and a PATCH on its own may not mention the field it depends on.
|
|
489
|
+
const needsCurrent = limit || hooks.beforeUpdate || hooks.afterUpdate || conditionalFields.length > 0;
|
|
490
|
+
const current = needsCurrent ? await rows.byId(id) : null;
|
|
401
491
|
if (limit) {
|
|
402
492
|
if (!current) return notFound();
|
|
403
493
|
if (!owns(current, limit)) return notFound();
|
|
@@ -421,7 +511,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
421
511
|
const result = validators.update.safeParse(supplied);
|
|
422
512
|
if (!result.success) return invalid(result.error.issues);
|
|
423
513
|
const input = result.data as Record<string, unknown>;
|
|
424
|
-
const
|
|
514
|
+
const merged = { ...((current ?? {}) as Row), ...input };
|
|
515
|
+
const record = await rows.update(id, toStored(clearInapplicable({ ...input, updatedAt: new Date() }, merged)));
|
|
425
516
|
if (!record) return notFound();
|
|
426
517
|
const saved = withComputed(record);
|
|
427
518
|
await hooks.afterUpdate?.(saved, { ...context, previous: current });
|
|
@@ -450,7 +541,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
450
541
|
// one of them, and a replace would quietly orphan the row it just saved.
|
|
451
542
|
if (limit) values[limit.field] = limit.value;
|
|
452
543
|
|
|
453
|
-
const record = await rows.update(id, values);
|
|
544
|
+
const record = await rows.update(id, toStored(clearInapplicable(values, values)));
|
|
454
545
|
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
455
546
|
});
|
|
456
547
|
},
|