@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.
@@ -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 = (row: Record<string, unknown>): Record<string, unknown> => {
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 filters = limit ? { ...parsed.query.filters, [limit.field]: limit.value } : parsed.query.filters;
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
- const first = page_[0];
314
- const last = page_[page_.length - 1];
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
- * 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.
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 found = await rows.titles(unique, resource.titleField);
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 record = await rows.insert({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now });
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
- const current = limit || hooks.beforeUpdate || hooks.afterUpdate ? await rows.byId(id) : null;
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 record = await rows.update(id, { ...input, updatedAt: new Date() });
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
  },